From 208b62f55fb5cd723fccc715f834d951ab01047f Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 7 Sep 2026 15:14:04 -0300 Subject: [PATCH 01/11] docs(repo): define managed profile membership contract --- docs/README.md | 39 ++--- docs/adr-0003-managed-headless-profiles.md | 15 +- docs/adr-0005-managed-profile-memberships.md | 75 +++++++++ docs/architecture-email-notifications.md | 155 ++++++++++-------- docs/architecture-identity-model.md | 50 +++++- docs/operations-testing.md | 68 ++++---- docs/product-dashboard.md | 144 ++++++++-------- .../01-auth/admin-impersonation.md | 83 +++++----- docs/security-spec/01-auth/api-keys.md | 11 +- .../01-auth/managed-profile-memberships.md | 147 +++++++++++++++++ .../03-ramp-engine/profile-partner-pricing.md | 32 ++-- .../03-ramp-engine/quote-lifecycle.md | 43 ++--- .../03-ramp-engine/recipient-transfers.md | 22 +-- .../05-integrations/alfredpay.md | 57 ++++--- docs/security-spec/05-integrations/brla.md | 67 ++++---- docs/security-spec/05-integrations/resend.md | 51 +++--- .../07-operations/api-surface.md | 8 +- .../07-operations/client-observability.md | 32 ++-- .../07-operations/notifications.md | 18 +- docs/security-spec/README.md | 129 ++++++++------- docs/security-spec/RISK-REGISTER.md | 46 +++--- 21 files changed, 811 insertions(+), 481 deletions(-) create mode 100644 docs/adr-0005-managed-profile-memberships.md create mode 100644 docs/security-spec/01-auth/managed-profile-memberships.md diff --git a/docs/README.md b/docs/README.md index f40111798..3d9eca5c4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,28 +7,29 @@ home for each kind of information, not a record of every implementation session. Only two topics are large enough to earn their own directories: -| Location | Purpose | Authority | -|---|---|---| -| [`security-spec/`](security-spec/README.md) | Security invariants, trust boundaries, current risks, and audit evidence | Normative for security-sensitive behavior | -| [`api/`](api/README.md) | Partner-facing OpenAPI, generated types, publication scripts, and integration guides | Public API contract and publication source | +| Location | Purpose | Authority | +| ------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------ | +| [`security-spec/`](security-spec/README.md) | Security invariants, trust boundaries, current risks, and audit evidence | Normative for security-sensitive behavior | +| [`api/`](api/README.md) | Partner-facing OpenAPI, generated types, publication scripts, and integration guides | Public API contract and publication source | The smaller set of general project documents stays directly in `docs/`: -| Document | Purpose | -|---|---| -| [`adr-0001-user-gated-ramp-registration.md`](adr-0001-user-gated-ramp-registration.md) | Accepted architectural decision and rationale | -| [`adr-0002-alfredpay-fee-collection.md`](adr-0002-alfredpay-fee-collection.md) | Accepted decision on Alfredpay fee collection and sequential EVM fee distribution | -| [`adr-0003-managed-headless-profiles.md`](adr-0003-managed-headless-profiles.md) | Accepted identity, ownership, authorization, and lifecycle decisions for managed headless profiles | -| [`adr-0004-sandbox-demo-environment.md`](adr-0004-sandbox-demo-environment.md) | Accepted decision on the seeded sales-demo account in the sandbox environment | -| [`architecture-email-notifications.md`](architecture-email-notifications.md) | Current transactional/auth email architecture: queue, dispatch, producers | -| [`architecture-identity-model.md`](architecture-identity-model.md) | Current cross-module identity and ownership architecture | -| [`operations-demo-environment.md`](operations-demo-environment.md) | Setup and runbook for the sandbox sales-demo account | -| [`operations-legacy-schema-cleanup.md`](operations-legacy-schema-cleanup.md) | Deployment gates and recovery runbook for irreversible migrations 060-061 | -| [`operations-testing.md`](operations-testing.md) | Maintained test strategy and suite boundaries | -| [`product-dashboard.md`](product-dashboard.md) | Current dashboard product scope and acknowledged gaps | -| [`proposal-mcp-server.md`](proposal-mcp-server.md) | Active, non-authoritative discussion draft | -| [`proposal-api-driven-kyc-kyb.md`](proposal-api-driven-kyc-kyb.md) | Proposal for API-driven verification using preserved provider-specific workflows | -| [`proposal-sumsub-kyc-token-sharing.md`](proposal-sumsub-kyc-token-sharing.md) | Implemented and enabled in code on the branch; production readiness still awaits provider, legal, and sandbox confirmation | +| Document | Purpose | +| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| [`adr-0001-user-gated-ramp-registration.md`](adr-0001-user-gated-ramp-registration.md) | Accepted architectural decision and rationale | +| [`adr-0002-alfredpay-fee-collection.md`](adr-0002-alfredpay-fee-collection.md) | Accepted decision on Alfredpay fee collection and sequential EVM fee distribution | +| [`adr-0003-managed-headless-profiles.md`](adr-0003-managed-headless-profiles.md) | Accepted identity, ownership, authorization, and lifecycle decisions for managed headless profiles | +| [`adr-0004-sandbox-demo-environment.md`](adr-0004-sandbox-demo-environment.md) | Accepted decision on the seeded sales-demo account in the sandbox environment | +| [`adr-0005-managed-profile-memberships.md`](adr-0005-managed-profile-memberships.md) | Accepted child-scoped membership, invitation, capability, and immutable-owner decisions | +| [`architecture-email-notifications.md`](architecture-email-notifications.md) | Current transactional/auth email architecture: queue, dispatch, producers | +| [`architecture-identity-model.md`](architecture-identity-model.md) | Current cross-module identity and ownership architecture | +| [`operations-demo-environment.md`](operations-demo-environment.md) | Setup and runbook for the sandbox sales-demo account | +| [`operations-legacy-schema-cleanup.md`](operations-legacy-schema-cleanup.md) | Deployment gates and recovery runbook for irreversible migrations 060-061 | +| [`operations-testing.md`](operations-testing.md) | Maintained test strategy and suite boundaries | +| [`product-dashboard.md`](product-dashboard.md) | Current dashboard product scope and acknowledged gaps | +| [`proposal-mcp-server.md`](proposal-mcp-server.md) | Active, non-authoritative discussion draft | +| [`proposal-api-driven-kyc-kyb.md`](proposal-api-driven-kyc-kyb.md) | Proposal for API-driven verification using preserved provider-specific workflows | +| [`proposal-sumsub-kyc-token-sharing.md`](proposal-sumsub-kyc-token-sharing.md) | Implemented and enabled in code on the branch; production readiness still awaits provider, legal, and sandbox confirmation | The root [`README.md`](../README.md) is human onboarding, [`MAP.md`](../MAP.md) is repository wayfinding, and `CLAUDE.md` files contain instructions for coding agents. diff --git a/docs/adr-0003-managed-headless-profiles.md b/docs/adr-0003-managed-headless-profiles.md index a5603432a..8e6ad7259 100644 --- a/docs/adr-0003-managed-headless-profiles.md +++ b/docs/adr-0003-managed-headless-profiles.md @@ -1,7 +1,9 @@ # ADR 0003: Managed Headless Profiles -Status: accepted. Implemented by migration 063 and the managed-profile API and -authorization services. +Status: partially superseded by +[`ADR 0005`](adr-0005-managed-profile-memberships.md). Migration 063's headless child, +immutable owner, policy, pricing, namespace, and lifecycle decisions remain accepted; +ADR 0005 replaces the exactly-one-manager authorization decision. ## Context @@ -14,7 +16,7 @@ than introduce a parallel tenant or impersonation model. - A headless customer is a normal `profiles` row with immutable `kind = managed`, a null login email, no Supabase identity, exactly one active customer entity, and exactly one - retained manager relationship. + retained owner relationship. - The child owns its customer entity, provider records, credentials, quotes, and ramps. The manager is the authenticated actor for delegated requests and never becomes the resource owner. @@ -33,9 +35,10 @@ than introduce a parallel tenant or impersonation model. - Deletion is logical and idempotent. It revokes child credentials and blocks new child activity while retaining provider, compliance, quote, ramp, callback, and attribution records needed for in-flight processing and reconciliation. -- Nested management, manager transfer, generic impersonation, operation-specific - permission matrices, and durable differentiation between delegated-manager and direct - child-credential requests are outside the accepted design. +- Nested management, owner transfer, generic impersonation, and durable differentiation + between delegated-member and direct child-credential requests remain outside the + accepted design. ADR 0005 introduces the child-scoped permission matrix that this ADR + originally excluded. ## Consequences diff --git a/docs/adr-0005-managed-profile-memberships.md b/docs/adr-0005-managed-profile-memberships.md new file mode 100644 index 000000000..66a2fb955 --- /dev/null +++ b/docs/adr-0005-managed-profile-memberships.md @@ -0,0 +1,75 @@ +# ADR 0005: Managed-Profile Memberships + +Status: accepted. Partially supersedes ADR 0003's exactly-one-manager authorization +model and its exclusion of operation-specific permissions. It preserves ADR 0003's +headless child, immutable owner, policy, pricing, namespace, and lifecycle decisions. + +## Context + +A managed profile represents one company or customer identity that may need access from +more than one authenticated person. The original model stores exactly one +`managed_profiles.manager_profile_id`; treating that field as a mutable team member or +copying a child for every operator would break pricing provenance, provider identity, +credential ownership, and retained financial history. + +Global `profile_roles` are also the wrong scope. `vortex_admin` and +`discount_manager` describe platform-wide capabilities, while access to a managed child +must be granted independently for each child. + +## Decision + +- Keep `managed_profiles.manager_profile_id` immutable and reinterpret it as the child's + owner and policy principal. The owner continues to supply corridor/customer-type + policy, pricing fallback, provider-contact namespace, provisioning authority, and + logical deletion authority. +- Add active, revocable memberships from authenticated profiles to one managed child. + Membership roles are exactly `manager` and `read_only`; they are not stored in + `profile_roles`. +- Backfill every retained managed-profile relationship with an active owner `manager` + membership. New provisioning creates that membership and an append-only + `member_added` event in the provisioning transaction. +- Protect the owner membership from downgrade or removal. Any active manager may invite, + change, or remove a non-owner member, but only the owner may create sibling children or + delete the child. +- Authorize delegated routes by a server-owned capability classification rather than HTTP + method: `read`, `manage`, `credential_manage`, or `ramp`. Unknown roles and unclassified + routes fail closed. +- Allow both roles to read the documented child surfaces. Allow `manager` to mutate child + resources; deny every mutation to `read_only`, including when it presents its own secret + credential. +- Require a secret API credential for selected-child provider/KYC/KYB mutations and ramp + register/update/start. Supabase dashboard bearer sessions are denied immediately, with + no grandfathered ramp exception. Child-owned credentials remain independent shared + company principals with their existing supported capabilities. +- Use email invitations with a seven-day lifetime and explicit acceptance. The invitation + UUID is a locator, not authentication. Preview and acceptance require the exact current, + verified, normalized Supabase email; OTP verification alone does not accept an invite. +- Record invitation and membership state changes in an append-only event table in the same + transaction. Serialize lifecycle mutations with the common owner-then-child lock order. +- Permit direct-recipient email only for the managed-profile invitation notification type. + All existing notification types remain profile-addressed and preference-gated. + +## Consequences + +The child stays the owner of provider records, credentials, quotes, ramps, recipients, and +financial history. Membership changes alter future authorization decisions but do not +rewrite child ownership or revoke child-owned credentials. Managers must explicitly revoke +shared child credentials when their distribution is no longer trusted. + +The dashboard gains child-scoped Team and API-key surfaces. It removes all selected-child +transfer initiation because browser sessions cannot satisfy the credential requirement. +KYC/KYB remains read-only in child dashboard mode. + +Membership and invitation storage is hidden from PostgREST through RLS and privilege +revocation. Application APIs expose only scoped projections and stable error codes. + +No generic organization/workspace abstraction, nested child management, owner transfer, +custom roles, per-member corridor grants, or automatic OTP-time acceptance is introduced. + +## Specifications + +- [Identity architecture](architecture-identity-model.md) +- [Dashboard product behavior](product-dashboard.md) +- [Managed-profile membership security](security-spec/01-auth/managed-profile-memberships.md) +- [API credential security](security-spec/01-auth/api-keys.md) +- [Public managed-profile API](api/pages/14-managed-profiles.md) diff --git a/docs/architecture-email-notifications.md b/docs/architecture-email-notifications.md index 33c01cacb..cedc6eaf1 100644 --- a/docs/architecture-email-notifications.md +++ b/docs/architecture-email-notifications.md @@ -30,7 +30,7 @@ flowchart LR consumer, no polling, no notification — `TaxId.kycAttempt` was declared on the model but never written, and nothing in the codebase listened to Avenia at all. - Main's in-app notification centre (migration 043, `notifications` table) existed with a - comment marking where email dispatch *would* hook in. Nothing was wired. + comment marking where email dispatch _would_ hook in. Nothing was wired. **After** — two independent classes of mail, both through Resend, sharing only the sending domain. @@ -53,16 +53,16 @@ flowchart LR end ``` -| | Before | After | -|---|---|---| -| Auth mail transport | Supabase default / Inbucket | Resend SMTP relay, `vortexfinance.co` | -| Transactional mail | none | Resend HTTPS API from `apps/api` | -| Durability | n/a | every email is a DB row before any send | -| Retries | n/a | 6 attempts, backoff 1/5/15/60/180 min | -| Dedupe | n/a | unique `(provider, type, resource_id)` | -| KYC/KYB outcome visibility | user re-checks manually | Avenia webhook, emailed on settle; hourly poll as fallback | -| Inbound Avenia events | not consumed | RSA-PSS verified receiver, raw-body mounted | -| Non-prod safety | n/a | recipient allowlist gate | +| | Before | After | +| -------------------------- | --------------------------- | ---------------------------------------------------------- | +| Auth mail transport | Supabase default / Inbucket | Resend SMTP relay, `vortexfinance.co` | +| Transactional mail | none | Resend HTTPS API from `apps/api` | +| Durability | n/a | every email is a DB row before any send | +| Retries | n/a | 6 attempts, backoff 1/5/15/60/180 min | +| Dedupe | n/a | unique `(provider, type, resource_id)` | +| KYC/KYB outcome visibility | user re-checks manually | Avenia webhook, emailed on settle; hourly poll as fallback | +| Inbound Avenia events | not consumed | RSA-PSS verified receiver, raw-body mounted | +| Non-prod safety | n/a | recipient allowlist gate | --- @@ -80,7 +80,7 @@ flowchart TB end ``` -- **Auth mail** is configured *outside the repo*. `supabase/config.toml` only governs the +- **Auth mail** is configured _outside the repo_. `supabase/config.toml` only governs the local stack; staging and production must be set in the Supabase Dashboard (Project Settings → Authentication → SMTP). Nothing in CI pushes that file. - **Transactional mail** is the rest of this document. @@ -92,7 +92,7 @@ deliberately, in exchange for a recognisable sender. ## 3. Producers — what enqueues, and when -Four producers, all fire-and-forget into the same table. None of them ever sends. +Five producers enqueue into the same durable table. None of them ever sends directly. ```mermaid flowchart LR @@ -101,22 +101,31 @@ flowchart LR P2["POST /v1/webhooks/avenia
KYC + KYB events"] P3["KybStatusWorker.poll()
hourly reconciliation"] P4["refreshAlfredpayCustomerStatus()
dashboard refresh + hourly sweep"] + P5["ManagedProfileMembershipService
invitation transaction"] end P1 -->|"provider: vortex
type: ramp_completed
resourceId: rampState.id"| Q[(email_notifications)] P2 -->|"provider: avenia
type: verification_*
resourceId: attempt.id"| Q P3 -.->|"same key — deduped"| Q P4 -->|"provider: alfredpay
type: verification_*
resourceId: submissionId"| Q + P5 -->|"provider: vortex
type: managed_profile_membership_invitation
resourceId: invitation id"| Q ``` P2 and P3 deliberately overlap. They enqueue through the same `enqueueVerificationNotification()` on the same `(provider, type, attempt.id)` key, so the poll racing or repeating a webhook is a no-op rather than a second email. +**Managed-profile membership invitation** — invitation creation inserts the invitation, +its `invited` event, and one `managed_profile_membership_invitation` outbox row in the same +transaction. This is the only producer allowed to address a normalized email directly +before that address has a Vortex profile. `DASHBOARD_PUBLIC_URL` supplies the trusted link +origin; request headers and body fields never choose it. Replaying the same pending invite +uses the invitation ID dedupe key and does not send twice. + **Ramp completion** — `enqueueRampCompletedEmail()` in `apps/api/src/api/services/email/ramp-completion.ts`, called from the terminal `complete` branch of `apps/api/src/api/services/phases/phase-processor.ts`. -The hook belongs on the phase processor because that is the *only* place a ramp actually +The hook belongs on the phase processor because that is the _only_ place a ramp actually reaches `complete` — it is the "single source of authority for phase transitions" and writes `currentPhase` straight onto the model. `RampService.logPhaseTransition()` (and the `notifyStatusChangeIfNeeded()` it wraps) has no call sites, so anything hung off it never @@ -129,17 +138,17 @@ profile (`req.userId ?? req.credential.profileId`), so relying on the null check would email the partner once per end-customer ramp. When the ramp's quote carries an `apiCredentialId`, the producer records a `skipped` tombstone row instead — no mail, and the reconcile sweep stops re-surfacing the ramp. A partner-driven ramp has no Vortex-side -recipient: the address on `additionalData.email` belongs to the *partner's* customer, not +recipient: the address on `additionalData.email` belongs to the _partner's_ customer, not to us. The payload carries both legs of the trade, already resolved to the user's perspective. On a buy the user pays fiat and receives the token; on a sell it is reversed, so which side of the quote each leg reads from swaps with `rampState.type`: -| Payload field | `BUY` (onramp) | `SELL` (offramp) | -| --- | --- | --- | -| `fiatAmount` / `fiatCurrency` | `quote.inputAmount` / `inputCurrency` | `quote.outputAmount` / `outputCurrency` | -| `tokenAmount` / `tokenSymbol` | `quote.outputAmount` / `outputCurrency` | `quote.inputAmount` / `inputCurrency` | +| Payload field | `BUY` (onramp) | `SELL` (offramp) | +| ----------------------------- | --------------------------------------- | --------------------------------------- | +| `fiatAmount` / `fiatCurrency` | `quote.inputAmount` / `inputCurrency` | `quote.outputAmount` / `outputCurrency` | +| `tokenAmount` / `tokenSymbol` | `quote.outputAmount` / `outputCurrency` | `quote.inputAmount` / `inputCurrency` | Plus `network`, `rampId`, `rampType` and `completedAt`. The timestamp comes from the recorded `complete` entry in `phaseHistory`, so delayed reconciliation does not claim the ramp completed @@ -186,7 +195,7 @@ Avenia sent the bytes; it says nothing about their shape, and `JSON.parse` will return `null`, an array, or an attempt with no `status`. Since the payload is persisted and later rendered into someone's inbox, the envelope and the attempt are both checked before the first property read, and anything that fails gets a deterministic `400`. An unrecognised -*value* — a status Avenia adds later — is not malformed: it is acknowledged `200` and maps +_value_ — a status Avenia adds later — is not malformed: it is acknowledged `200` and maps to no email, because a `400` would make Avenia retry it forever. Avenia's guides document two envelope shapes. The receiver accepts both the management @@ -196,7 +205,7 @@ shape (`{ subAccountId, subscription, data }`) and the event-specific shape **Verification, reconciliation path** — `apps/api/src/api/workers/kyb-status.worker.ts` Runs hourly. It exists because **Avenia documents no KYB subscription**: their subscription -list is `TICKET`, `KYC`, `LIMIT-UPDATE`, `*`. Company attempts are *expected* to arrive +list is `TICKET`, `KYC`, `LIMIT-UPDATE`, `*`. Company attempts are _expected_ to arrive under the wildcard because Avenia fetches both kinds from the same `/v2/kyc/attempts` resource — but that is an inference, not a documented guarantee, and if it is wrong the failure is silent (no KYB emails, no error). The poll is what makes being wrong survivable. @@ -205,7 +214,7 @@ It selects `kyc_cases` rows that are `provider = 'avenia'` + `type = 'kyb'` + un have a `providerCaseId` + belong to an entity with a `profileId` + were last written within 60 days, then calls `getKybAttemptStatus(providerCaseId)` for each one. It polls **one known attempt id**, not a list: `GET /v2/kyc/attempts` has no documented ordering, so picking from -it would guess at which attempt a notification describes — and that attempt id *is* the +it would guess at which attempt a notification describes — and that attempt id _is_ the dedupe key. The window is on `updatedAt`, not `createdAt`: the case row is rebound to a fresh attempt on re-initiation, so its creation date says nothing about the attempt in flight. @@ -250,13 +259,13 @@ Its background/onboarding callers share `refreshAlfredpayCustomerStatus()`. The Alfredpay status endpoints perform the same terminal enqueue through `enqueueAlfredpayVerificationNotification()` before they write their legacy-shaped view: -| Caller | When | Covers | -| --- | --- | --- | -| `onboarding.controller.ts` | dashboard status aggregation, TTL-throttled per account | the user who comes back to look | -| `alfredpay.controller.ts` | `/alfredpayStatus` and `/getKycStatus` | legacy clients that poll either status endpoint | -| `AlfredpayStatusWorker` | hourly, `15 * * * *` | the user who never returns | +| Caller | When | Covers | +| -------------------------- | ------------------------------------------------------- | ----------------------------------------------- | +| `onboarding.controller.ts` | dashboard status aggregation, TTL-throttled per account | the user who comes back to look | +| `alfredpay.controller.ts` | `/alfredpayStatus` and `/getKycStatus` | legacy clients that poll either status endpoint | +| `AlfredpayStatusWorker` | hourly, `15 * * * *` | the user who never returns | -These paths select on or eventually exclude a *terminal stored status*, so an account drops out of every +These paths select on or eventually exclude a _terminal stored status_, so an account drops out of every future poll the moment its outcome is written. Whichever caller observes the transition is therefore the only one that may see it. Every observer consequently uses the same idempotent enqueue helper before persisting the terminal outcome. @@ -297,28 +306,28 @@ skipped in the loop, so they never spend provider calls. ## 4. The queue — `email_notifications` -The table *is* the design. It is simultaneously the queue, the retry ledger, the audit +The table _is_ the design. It is simultaneously the queue, the retry ledger, the audit trail, and the idempotency key. -Migration `062-create-email-notifications-table.ts`, model -`apps/api/src/models/emailNotification.model.ts`. - -| Column | Purpose | -|---|---| -| `provider` / `type` / `resource_id` | unique together — the idempotency key. All three `NOT NULL` because Postgres treats NULLs as distinct and would let duplicates through | -| `user_id` | FK → `profiles`, CASCADE. The *only* source of a recipient | -| `locale` | resolved at enqueue from Supabase `user_metadata.locale` | -| `payload` | JSONB snapshot of the facts at enqueue time | -| `status` | see the lifecycle below | -| `attempts` | incremented **at claim time**, not after success | -| `next_attempt_at` | backoff schedule; also the dispatch ordering key | -| `sent_at`, `provider_message_id` | proof of delivery | -| `last_error` | truncated to 2000 chars, never contains the API key | +Migration `062-create-email-notifications-table.ts`, extended by migration 069 for +membership invitations; model `apps/api/src/models/emailNotification.model.ts`. + +| Column | Purpose | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `provider` / `type` / `resource_id` | unique together — the idempotency key. All three `NOT NULL` because Postgres treats NULLs as distinct and would let duplicates through | +| `user_id` / `recipient_email` | Exactly one is set. Existing types resolve a profile recipient; only managed-profile invitations may store a normalized direct recipient | +| `locale` | resolved at enqueue from Supabase `user_metadata.locale` | +| `payload` | JSONB snapshot of the facts at enqueue time | +| `status` | see the lifecycle below | +| `attempts` | incremented **at claim time**, not after success | +| `next_attempt_at` | backoff schedule; also the dispatch ordering key | +| `sent_at`, `provider_message_id` | proof of delivery | +| `last_error` | truncated to 2000 chars, never contains the API key | Indexes: unique `uniq_email_notifications_provider_type_resource`, plus `idx_email_notifications_dispatch` and `idx_email_notifications_user_id`. -> **Name collision:** this is `email_notifications`, *not* `notifications`. Migration 043 on +> **Name collision:** this is `email_notifications`, _not_ `notifications`. Migration 043 on > `main` already owns `notifications` for the in-app notification centre. See §7. ### Status lifecycle @@ -361,11 +370,13 @@ sequenceDiagram W->>DB: UPDATE → sending, attempts = attempts + 1 W->>DB: COMMIT loop each claimed row - W->>DB: notification_preferences WHERE profile_id = user_id - alt opted out + alt profile-addressed notification + W->>DB: notification_preferences WHERE profile_id = user_id + end + alt profile recipient opted out W->>DB: status = skipped (no request made) else - W->>P: profiles.email WHERE id = user_id + W->>P: profiles.email WHERE id = user_id, or use the allowlisted invitation row's recipient_email alt no email W->>DB: status = skipped else non-prod and not allowlisted @@ -389,14 +400,16 @@ Four properties worth naming, because each one is load-bearing: 1. **`FOR UPDATE SKIP LOCKED`.** Both flow-variant backends run against one database. Without the claim, both dispatch the same row and the user gets the email twice. This is the single most important line in the feature. -2. **Recipient resolved at send time**, never snapshotted and never caller-supplied. It comes - from `profiles.email` via `user_id`. No request payload can influence where mail goes. +2. **Recipient source is constrained by type.** Existing notifications resolve + `profiles.email` at send time through `user_id`. Only + `managed_profile_membership_invitation` may use the normalized `recipient_email` + snapshotted by its dedicated producer; generic enqueue callers cannot supply it. 3. **`attempts` increments at claim, not after success.** A process that dies mid-send still burns an attempt. That alone does not stop the loop, though: a crashed send records no failure, so the cap in `handleDeliveryFailure` never runs for it. The stale-claim sweep therefore abandons rows at the cap rather than releasing them, and the claim query skips them — those two are what actually terminate a crash loop. -4. **The row id is Resend's `Idempotency-Key`.** The unique index stops two *rows* for one +4. **The row id is Resend's `Idempotency-Key`.** The unique index stops two _rows_ for one event; it says nothing about the window between Resend accepting a send and `sent` being persisted. A crash in there returns the row to the queue with the mail already gone, and the key is what makes the retry a replay rather than a second email. @@ -410,8 +423,10 @@ flowchart LR N["row.type + row.locale + row.payload"] --> RN["renderNotification()"] RN -->|"ramp_completed"| T1["ramp-completed.ts"] RN -->|"verification_approved
verification_rejected
verification_expired"| T2["verification-status.ts"] + RN -->|"managed_profile_membership_invitation"| T3["managed-profile-membership-invitation.ts"] T1 --> L["layout.ts"] T2 --> L + T3 --> L L --> O["{ subject, html, text }"] ``` @@ -430,15 +445,15 @@ flowchart LR `main` has a separate, older feature also called notifications: -| | In-app notifications (`main`) | Email notifications (this branch) | -|---|---|---| -| Table | `notifications` (migration 043) | `email_notifications` (migration 062) | -| Model | `models/notification.model.ts` | `models/emailNotification.model.ts` | -| Service | `api/services/notifications/` | `api/services/email/` | -| Preferences | `notification_preferences.email_enabled` | same row, read at delivery | -| Surface | API routes, read by the client | no route; write-only, worker-read | +| | In-app notifications (`main`) | Email notifications (this branch) | +| ----------- | ---------------------------------------- | ------------------------------------- | +| Table | `notifications` (migration 043) | `email_notifications` (migration 062) | +| Model | `models/notification.model.ts` | `models/emailNotification.model.ts` | +| Service | `api/services/notifications/` | `api/services/email/` | +| Preferences | `notification_preferences.email_enabled` | same row, read at delivery | +| Surface | API routes, read by the client | no route; write-only, worker-read | -The two tables stay separate, but they share one opt-out. `notification_preferences` is +The two tables stay separate, but profile-addressed mail shares one opt-out. `notification_preferences` is already the user-facing switch (`GET`/`PUT /v1/notifications/preferences`), so the dispatcher reads it rather than introducing a second one: @@ -447,31 +462,35 @@ reads it rather than introducing a second one: `ramp_completed`, `verification_approved`, `verification_rejected`, `verification_expired`. Any other value, including an absent key, means enabled. -Both fields can only ever *suppress* mail, which is what makes the missing-row case safe: a +Both fields can only ever _suppress_ mail, which is what makes the missing-row case safe: a profile that has never touched its preferences has no row, and is treated exactly as the default row `getOrCreateNotificationPreferences` would write. The dispatcher reads rather than creates, so a send never writes a preferences row as a side effect. The check runs at delivery, not at enqueue — an opt-out registered while a row is still in the queue is honoured, and an opted-out row is recorded `skipped` with no request to Resend. +Managed-profile invitations are security/account-access mail addressed before a profile may +exist, so they bypass profile preferences. Their dedicated type and producer are the only +exception. --- ## 8. Configuration -| Variable | Effect | -|---|---| -| `RESEND_API_KEY` | Missing → the worker warns and leaves rows `pending`. Never marks them sent/failed/abandoned, so the backlog flushes when the key arrives | -| `EMAIL_FROM_ADDRESS` | Defaults to `Vortex Finance ` | -| `EMAIL_REPLY_TO_ADDRESS` | Optional | -| `EMAIL_RECIPIENT_ALLOWLIST` | Comma-separated. Enforced whenever `DEPLOYMENT_ENV !== "production"`. **Empty = nothing is ever sent outside production** | -| `AVENIA_WEBHOOK_URL` | Public https URL of this backend's `/v1/webhooks/avenia`. Read only by `bun register:avenia-webhook`; the receiver itself needs no config | +| Variable | Effect | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `RESEND_API_KEY` | Missing → the worker warns and leaves rows `pending`. Never marks them sent/failed/abandoned, so the backlog flushes when the key arrives | +| `EMAIL_FROM_ADDRESS` | Defaults to `Vortex Finance ` | +| `EMAIL_REPLY_TO_ADDRESS` | Optional | +| `EMAIL_RECIPIENT_ALLOWLIST` | Comma-separated. Enforced whenever `DEPLOYMENT_ENV !== "production"`. **Empty = nothing is ever sent outside production** | +| `DASHBOARD_PUBLIC_URL` | Trusted absolute dashboard origin used to build membership invitation links | +| `AVENIA_WEBHOOK_URL` | Public https URL of this backend's `/v1/webhooks/avenia`. Read only by `bun register:avenia-webhook`; the receiver itself needs no config | Domain requirements on `vortexfinance.co`: Resend DKIM CNAMEs, exactly **one** SPF record (Resend merged into any existing sender — two records fail SPF outright), and a published DMARC policy. -Auth-mail SMTP is *not* configured by this repo outside local dev. Set it in the Supabase +Auth-mail SMTP is _not_ configured by this repo outside local dev. Set it in the Supabase Dashboard per hosted project. --- @@ -493,12 +512,14 @@ apps/api/src/ │ │ ├── notification.service.ts enqueue, claim, deliver, retry, stale-release │ │ ├── dispatch.test.ts preference gate, idempotency key, crash-loop cap │ │ ├── ramp-completion.ts ramp-completion producer (payload from quote) +│ │ ├── managed-profile-membership-invitation.ts constrained direct-recipient producer │ │ ├── resend.transport.ts the only HTTP call to Resend │ │ ├── types.ts locales + payload shapes │ │ └── templates/ │ │ ├── index.ts type → template dispatch │ │ ├── layout.ts shared HTML shell │ │ ├── ramp-completed.ts +│ │ ├── managed-profile-membership-invitation.ts │ │ └── verification-status.ts approved / rejected / expired │ ├── workers/ │ │ ├── notification-dispatch.worker.ts cron 1m — the only sender diff --git a/docs/architecture-identity-model.md b/docs/architecture-identity-model.md index bb6a918c6..c357f0a74 100644 --- a/docs/architecture-identity-model.md +++ b/docs/architecture-identity-model.md @@ -1,7 +1,7 @@ # Identity, Customer, and Partner Model -Status: current architecture. Last reconciled with migrations 038-063 and the API models -on 2026-08-10. +Status: current architecture. Last reconciled with migrations 038-069 and the API models +on 2026-09-07. This document explains the implemented identity model across authentication, compliance customers, provider accounts, partner pricing, and recipients. Security invariants remain @@ -32,6 +32,8 @@ erDiagram profiles ||--o| managed_profile_managers : enables managed_profile_managers ||--o{ managed_profiles : controls profiles ||--o| managed_profiles : identifies + managed_profiles ||--o{ managed_profile_memberships : authorizes + profiles ||--o{ managed_profile_memberships : participates customer_entities ||--o{ recipient_invitations : sends customer_entities ||--o{ sender_recipients : participates sender_recipients ||--o{ recipient_payout_references : uses @@ -114,15 +116,38 @@ external-subject and contact-email pairs, and revokes all child credentials. Del is active on quote, ramp, limits, ramp-info, onboarding-status, Avenia, and Alfredpay routes, plus sender-side recipient list, invitation creation/archive, relationship mutation, and eligibility. Invite preview and acceptance remain bearer-invitee operations and reject a -managed-child selector. The managed-profile list response includes the active manager's profile ID -and current corridor/customer-type policy even when no children match the list query. +managed-child selector. + +Migration 069 adds active, revocable `manager` and `read_only` memberships between +authenticated profiles and managed children, email-bound invitations, and append-only +membership events. Every retained child is backfilled with an active owner-manager +membership; new provisioning creates the owner membership and its `member_added` event in +the same transaction as the profile, entity, and relationship. Database constraints keep +ownership immutable, protect the active owner's manager membership, require authenticated +member profiles, and permit only one active grant for each child/member pair. + +The immutable controlling manager remains the child owner and supplies corridor/customer-type +policy, pricing fallback, external identity namespace, and lifecycle authority. Memberships grant +other authenticated actors access without transferring ownership. Lifecycle reads return the actor +profile ID plus each child's actor-specific role, owner flag, and controlling-owner policy. An +active configured owner can still receive an empty list before provisioning a child. Manager +members may perform supported delegated mutations and manage child credentials; read-only members +may use supported reads only. Browser bearer sessions cannot perform child provider/KYC/KYB or ramp +mutations; these require an eligible secret API credential. Child-owned credentials remain shared +company principals independent of the human who created or possesses them. + +Membership invitations are scoped to one child and expire after seven days. The exact current +verified Supabase email must explicitly accept; OTP authentication alone never grants membership. +Invitation, membership, and event rows are not directly available through PostgREST. The child Team +surface exposes only server-authorized roster, pending invitation, and access-history projections. Migration 063 rollback locks both managed tables and refuses to proceed while either a child relationship or manager configuration exists, so manager policy cannot be silently discarded by a down/up cycle. The durable rationale and intentionally excluded capabilities are recorded in -[`ADR 0003`](adr-0003-managed-headless-profiles.md). +[`ADR 0003`](adr-0003-managed-headless-profiles.md) and its partial supersession, +[`ADR 0005`](adr-0005-managed-profile-memberships.md). ### Recipients @@ -150,9 +175,13 @@ Current product behavior and acknowledged gaps are in identity is preserved separately on `req.impersonation` for audit; it does not participate in ownership resolution. 2. On delegated routes, `X-Managed-Profile-Id` selects a child profile. The authorization - middleware verifies the active manager, direct active relationship, managed child, - active child customer entity, configured corridor, optional customer-type narrowing, - and canonical corridor/type capability for mutations. + middleware verifies the actor's active membership, the active immutable owner policy, + direct relationship, managed child, active child customer entity, configured corridor, + optional customer-type narrowing, and canonical corridor/type capability for mutations. + `read_only` can use only read-classified routes; `manager` can use management routes. + Delegated provider/KYC/KYB actions use `credential_manage`, and ramp register/update/start + use `ramp`; both additionally require the member's secret credential. Selected-child + bearer ramp requests are rejected before buffering their body. 3. `getEffectiveUserId()` uses the verified child subject when delegation is present; otherwise it uses the bearer principal or validated secret-key profile. For an impersonation token, `req.userId` already reflects step 1's target substitution. @@ -168,7 +197,8 @@ step 2 onward bypasses route authorization. Its session lifecycle, controls, and this document only reflects where the seam sits in principal resolution. The derived request context retains `actorProfileId`, `subjectProfileId`, -`controllingManagerProfileId`, `customerEntityId`, and the manager-child relationship ID. +`controllingManagerProfileId`, `customerEntityId`, the manager-child relationship ID, +and, for delegated members, the exact membership ID and role. It never overwrites `req.userId`, and a public API key cannot authenticate a manager. Alfredpay customer creation uses the child's immutable provider contact email, never the manager's login email. Email-bound Mykobo and Monerium routes remain unsupported. These legacy @@ -200,6 +230,8 @@ quote cannot be claimed by another user. - Provider ownership resolution: `apps/api/src/api/services/avenia-account.ts` and provider controllers/services - Schema history: `apps/api/src/database/migrations/038-*` onward - Managed-profile schema: `apps/api/src/database/migrations/063-create-managed-profiles.ts` +- Managed-profile membership schema: `apps/api/src/database/migrations/069-create-managed-profile-memberships.ts` +- Managed membership lifecycle: `apps/api/src/api/services/managed-profile-membership.service.ts` - Migrations 060-061 production gates: [`operations-legacy-schema-cleanup.md`](operations-legacy-schema-cleanup.md) - Security details: `docs/security-spec/01-auth/`, `03-ramp-engine/recipient-transfers.md`, and the provider specs under `05-integrations/` diff --git a/docs/operations-testing.md b/docs/operations-testing.md index 4ea5fd321..02da691e4 100644 --- a/docs/operations-testing.md +++ b/docs/operations-testing.md @@ -15,15 +15,15 @@ together with the shared test harness (`apps/api/src/test-utils`) — see "How t ## Test layers -| Layer | What | Where | Runner | -|---|---|---|---| -| 1. Unit | Pure logic: helpers, token configs, SDK handlers | each package, next to source | `bun test` (Vitest for frontend) | -| 2. API integration | Real Express + real Postgres + fake external world, driven over HTTP; incl. the quote pricing goldens (`quote-pricing.golden.test.ts`) and the HTTP surface tests (auth OTP flow, webhooks, ramp history, public routes; `http-surface.invariants.test.ts`) | `apps/api/src/tests/` | `bun test` | -| 3. Corridor scenarios | Phase processor end-to-end per corridor against the fake world: BRL onramp (pix→BRLA-on-Base), BRL offramp (USDC-on-Base→pix incl. real Nabla swap + both EVM subsidy phases), CROSS-CHAIN BRL offramp (USDC-on-Polygon→squid→Base→pix incl. user-reported squid-hash verification), MXN on/offramp (spei↔USDT-on-Polygon), CROSS-CHAIN MXN onramp (spei→Polygon mint→squid→USDT-on-Arbitrum incl. real squidRouterSwap/Pay + Arbitrum settlement subsidy), CROSS-CHAIN BRL onramp (pix→Base mint+Nabla swap→squid→USDC-on-Arbitrum), a USD/COP/ARS matrix over the same Alfredpay rails (happy paths + per-currency limit breaches + per-currency transient AND unrecoverable failures + per-currency cross-chain BUY and no-permit cross-chain SELL, incl. MXN SELL cross-chain), and EUR (Mykobo) on/offramp scenarios (SEPA↔EURC/USDC-on-Base incl. real Nabla swap; registration stays kill-switched — see the coverage matrix) | `apps/api/src/tests/corridors/` | `bun test` | -| 4. SDK contract | Real SDK against the real API in-process: BRL onramp lifecycle (`sdk-contract.test.ts`), the SELL/user-transaction surface — offramp lifecycle via submitUserTransactions, updateRamp, getQuote, listAlfredpayFiatAccounts (`sdk-contract.offramp.test.ts`) — and full per-currency lifecycles for all four Alfredpay currencies in both directions: SELL offramp lifecycles for USD/ach, MXN/spei, COP/ach and ARS/cbu (`sdk-contract.alfredpay-offramp.test.ts`) and BUY onramp lifecycles for MXN/spei, USD/ach, COP/ach and ARS/cbu (`sdk-contract.alfredpay-onramp.test.ts`) | `apps/api/src/tests/sdk-contract*.test.ts` | `bun test` | -| 5. Frontend | XState machine tests, actor tests (register/sign/start/KYC-routing against MSW with mocked wallet seams), component tests (RTL + MSW + mock wagmi) | `apps/frontend/src` | Vitest | -| 6. E2E | Critical Playwright journeys with a mock wallet: BRL on/offramp plus parameterized Alfredpay journeys for all four currencies in both directions. The dashboard runs its own Playwright config covering auth, account selection, onboarding/KYC/KYB, recipient invitations, the MXN offramp journey, and BRL/MXN/USD/COP/ARS onramps. The nightly job also smoke-tests deployed staging and production BUY/SELL quotes through a cross-chain Squid corridor. | `apps/frontend/e2e/`, `apps/dashboard/e2e/`, `apps/api/src/tests/deployed-quotes.e2e.test.ts` | Playwright + Bun (non-blocking) | -| 7. External API contracts | Consumed-contract zod schemas (`packages/shared/src/services/*/schemas.ts`, plus `apps/api/.../priceFeed.schemas.ts`) validated against the fakes (PR-blocking) and against the real partner APIs (live, nightly, non-blocking); SquidRouter, Alfredpay, Avenia/BRLA, CoinGecko | `apps/api/src/tests/contracts/` | `bun test` / nightly `contracts.yml` | +| Layer | What | Where | Runner | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------ | +| 1. Unit | Pure logic: helpers, token configs, SDK handlers | each package, next to source | `bun test` (Vitest for frontend) | +| 2. API integration | Real Express + real Postgres + fake external world, driven over HTTP; incl. the quote pricing goldens (`quote-pricing.golden.test.ts`) and the HTTP surface tests (auth OTP flow, webhooks, ramp history, public routes; `http-surface.invariants.test.ts`) | `apps/api/src/tests/` | `bun test` | +| 3. Corridor scenarios | Phase processor end-to-end per corridor against the fake world: BRL onramp (pix→BRLA-on-Base), BRL offramp (USDC-on-Base→pix incl. real Nabla swap + both EVM subsidy phases), CROSS-CHAIN BRL offramp (USDC-on-Polygon→squid→Base→pix incl. user-reported squid-hash verification), MXN on/offramp (spei↔USDT-on-Polygon), CROSS-CHAIN MXN onramp (spei→Polygon mint→squid→USDT-on-Arbitrum incl. real squidRouterSwap/Pay + Arbitrum settlement subsidy), CROSS-CHAIN BRL onramp (pix→Base mint+Nabla swap→squid→USDC-on-Arbitrum), a USD/COP/ARS matrix over the same Alfredpay rails (happy paths + per-currency limit breaches + per-currency transient AND unrecoverable failures + per-currency cross-chain BUY and no-permit cross-chain SELL, incl. MXN SELL cross-chain), and EUR (Mykobo) on/offramp scenarios (SEPA↔EURC/USDC-on-Base incl. real Nabla swap; registration stays kill-switched — see the coverage matrix) | `apps/api/src/tests/corridors/` | `bun test` | +| 4. SDK contract | Real SDK against the real API in-process: BRL onramp lifecycle (`sdk-contract.test.ts`), the SELL/user-transaction surface — offramp lifecycle via submitUserTransactions, updateRamp, getQuote, listAlfredpayFiatAccounts (`sdk-contract.offramp.test.ts`) — and full per-currency lifecycles for all four Alfredpay currencies in both directions: SELL offramp lifecycles for USD/ach, MXN/spei, COP/ach and ARS/cbu (`sdk-contract.alfredpay-offramp.test.ts`) and BUY onramp lifecycles for MXN/spei, USD/ach, COP/ach and ARS/cbu (`sdk-contract.alfredpay-onramp.test.ts`) | `apps/api/src/tests/sdk-contract*.test.ts` | `bun test` | +| 5. Frontend | XState machine tests, actor tests (register/sign/start/KYC-routing against MSW with mocked wallet seams), component tests (RTL + MSW + mock wagmi) | `apps/frontend/src` | Vitest | +| 6. E2E | Critical Playwright journeys with a mock wallet: BRL on/offramp plus parameterized Alfredpay journeys for all four currencies in both directions. The dashboard runs its own Playwright config covering auth, account selection, onboarding/KYC/KYB, recipient invitations, the MXN offramp journey, and BRL/MXN/USD/COP/ARS onramps. The nightly job also smoke-tests deployed staging and production BUY/SELL quotes through a cross-chain Squid corridor. | `apps/frontend/e2e/`, `apps/dashboard/e2e/`, `apps/api/src/tests/deployed-quotes.e2e.test.ts` | Playwright + Bun (non-blocking) | +| 7. External API contracts | Consumed-contract zod schemas (`packages/shared/src/services/*/schemas.ts`, plus `apps/api/.../priceFeed.schemas.ts`) validated against the fakes (PR-blocking) and against the real partner APIs (live, nightly, non-blocking); SquidRouter, Alfredpay, Avenia/BRLA, CoinGecko | `apps/api/src/tests/contracts/` | `bun test` / nightly `contracts.yml` | ### The invariants the suite protects @@ -65,21 +65,21 @@ dashboard Playwright section below rather than reading it out of this table. Legend: ✅ directly tested · ◐ covered only via shared code/another corridor (see footnote) · ❌ missing · — not applicable · 🚫 kill-switched. -| Corridor (rail) | Dir | Happy path | Transient | Unrecoverable | Security / caps | Cross-chain leg | SDK | E2E journey | -|---|---|---|---|---|---|---|---|---| -| BRL (Avenia / Pix) | BUY | ✅ | ✅ | ✅ | ✅ recipient | ✅² | ✅ | ✅ | -| BRL (Avenia / Pix) | SELL | ✅ | ✅ | ✅ | ✅ pre/post-swap caps, recipient | ✅ F-021 | ✅ | ✅ | -| MXN (Alfredpay / SPEI) | BUY | ✅ | ✅ | ✅ | ✅ recipient | ✅ settlement subsidy | ✅ | ✅ | -| MXN (Alfredpay / SPEI) | SELL | ✅ | ✅ | ✅ | ✅ calldata, F-001 cap | ✅¹ | ✅ | ✅ | -| USD (Alfredpay / ACH) | BUY | ✅ | ✅ | ✅ | ✅ + limit breach | ✅ | ✅ | ✅ | -| USD (Alfredpay / ACH) | SELL | ✅ | ✅ | ✅ | ✅ + limit breach | ✅¹ | ✅ | ✅ | -| COP (Alfredpay / ACH) | BUY | ✅ | ✅ | ✅ | ✅ + limit breach | ✅ | ✅ | ✅ | -| COP (Alfredpay / ACH) | SELL | ✅ | ✅ | ✅ | ✅ + limit breach | ✅¹ | ✅ | ✅ | -| ARS (Alfredpay / CBU) | BUY | ✅ | ✅ | ✅ | ✅ + limit breach | ✅ | ✅ | ✅ | -| ARS (Alfredpay / CBU) | SELL | ✅ | ✅ | ✅ | ✅ + limit breach | ✅¹ | ✅ | ✅ | -| EUR (Mykobo / SEPA) | BUY | ✅³ | ✅³ | ✅³ | ✅ recipient, KYC gate | ◐⁴ | 🚫 | 🚫 | -| EUR (Mykobo / SEPA) | SELL | ✅³ | ✅³ | ✅³ | ✅ payout-vs-intent match, KYC gate | ◐⁴ | 🚫 | 🚫 | -| AssetHub (BRL BUY → USDC; USDC SELL → Pix) | both | ❌ deferred | ❌ | ❌ | ❌ | — | ❌ | ❌ | +| Corridor (rail) | Dir | Happy path | Transient | Unrecoverable | Security / caps | Cross-chain leg | SDK | E2E journey | +| ------------------------------------------ | ---- | ----------- | --------- | ------------- | ----------------------------------- | --------------------- | --- | ----------- | +| BRL (Avenia / Pix) | BUY | ✅ | ✅ | ✅ | ✅ recipient | ✅² | ✅ | ✅ | +| BRL (Avenia / Pix) | SELL | ✅ | ✅ | ✅ | ✅ pre/post-swap caps, recipient | ✅ F-021 | ✅ | ✅ | +| MXN (Alfredpay / SPEI) | BUY | ✅ | ✅ | ✅ | ✅ recipient | ✅ settlement subsidy | ✅ | ✅ | +| MXN (Alfredpay / SPEI) | SELL | ✅ | ✅ | ✅ | ✅ calldata, F-001 cap | ✅¹ | ✅ | ✅ | +| USD (Alfredpay / ACH) | BUY | ✅ | ✅ | ✅ | ✅ + limit breach | ✅ | ✅ | ✅ | +| USD (Alfredpay / ACH) | SELL | ✅ | ✅ | ✅ | ✅ + limit breach | ✅¹ | ✅ | ✅ | +| COP (Alfredpay / ACH) | BUY | ✅ | ✅ | ✅ | ✅ + limit breach | ✅ | ✅ | ✅ | +| COP (Alfredpay / ACH) | SELL | ✅ | ✅ | ✅ | ✅ + limit breach | ✅¹ | ✅ | ✅ | +| ARS (Alfredpay / CBU) | BUY | ✅ | ✅ | ✅ | ✅ + limit breach | ✅ | ✅ | ✅ | +| ARS (Alfredpay / CBU) | SELL | ✅ | ✅ | ✅ | ✅ + limit breach | ✅¹ | ✅ | ✅ | +| EUR (Mykobo / SEPA) | BUY | ✅³ | ✅³ | ✅³ | ✅ recipient, KYC gate | ◐⁴ | 🚫 | 🚫 | +| EUR (Mykobo / SEPA) | SELL | ✅³ | ✅³ | ✅³ | ✅ payout-vs-intent match, KYC gate | ◐⁴ | 🚫 | 🚫 | +| AssetHub (BRL BUY → USDC; USDC SELL → Pix) | both | ❌ deferred | ❌ | ❌ | ❌ | — | ❌ | ❌ | ¹ Alfredpay SELL cross-chain is covered on the no-permit fallback path (user-broadcast squid approve+swap verified against the blueprints by hash); the permit/TokenRelayer variant is @@ -195,7 +195,7 @@ different set of endpoints than the widget. Covered so far: `squidRouterNoPermitTransfer` with its hash reported in a second update → `/ramp/start` → status polling to a terminal phase while the form navigates to `/transactions`. A second test pins payout-account selection: the mock serves two saved fiat accounts, and choosing the non-default - one must register against *that* `fiatAccountId` — a broken selector would pay the wrong account. + one must register against _that_ `fiatAccountId` — a broken selector would pay the wrong account. - **Onramps and transfer modes** (`onramp-journeys.spec.ts`): route-backed Offramp/Onramp/ Cross-border selection, the complete Cross-border coming-soon state, and parameterized BUY journeys for BRL PIX plus MXN/USD/COP/ARS bank instructions. Each journey runs without AppKit, @@ -206,14 +206,16 @@ different set of endpoints than the widget. Covered so far: approved AlfredPay corridor creates a self payout account and updates the card/recipient state; disconnected wallet actions open AppKit's `Connect` view, while the connected address opens its `Account` view. The connected-wallet-only funding gate remains pinned. -- **Managed profiles** (`managed-profiles.spec.ts`): ordinary-user route denial, manager child - selection, persisted acting mode, route-scoped managed-profile headers, hidden manager-only - navigation, stopping child mode, and long-identifier mobile layout. +- **Managed profiles** (`managed-profiles.spec.ts`): ordinary-user route denial, role/owner badges, + persisted acting mode, route-scoped managed-profile headers, child credential lifecycle, + read-only recipient and payout-account gates, live-downgrade dialog closure, immediate blocking + of every transfer entry point, stopping child mode, and long-identifier mobile layout. -Managed-child selection has unit coverage for persisted manager-bound selection, cross-tab changes, -route-scoped header attachment and authorization failure handling, transfer identity guards, and -owner-keyed payment recovery. API integration coverage exercises delegated recipient operations and -policy revalidation. +Managed-child selection has unit coverage for persisted actor-bound role metadata, same-child +downgrades while identity switching is blocked, cross-tab changes, route-scoped header attachment, +bootstrap-only authorization reconciliation, transfer identity guards, and owner-keyed payment +recovery. API integration coverage exercises membership capabilities, delegated recipient +operations, per-child owner policy, and policy revalidation. Notes: @@ -269,7 +271,7 @@ zod schema in `packages/shared/src/services//schemas.ts` models the raw the PR-blocking api suite) and against the real partner API (`RUN_LIVE_TESTS=1`, nightly `contracts.yml`, non-blocking). -Sandbox shakiness is priced in: an error from the live call itself is *inconclusive* +Sandbox shakiness is priced in: an error from the live call itself is _inconclusive_ (warn + skip), except that a `ZodError` from parsing a successful response is rethrown and fails the test. The nightly sets `CONTRACT_EXPECT_LIVE=1`, which fails a run where zero live calls completed, so credential rot or a dead endpoint alerts within a day instead of rotting as green. Covered: SquidRouter (`/v2/route` diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index f74c7b733..a97eaada3 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -8,7 +8,7 @@ Absorb everything the widget (`apps/frontend`) does today — on/offramp quoting signing, KYC/KYB, ramp tracking — behind an email-authenticated account, and add two capabilities the widget cannot express: -1. **Cross-border payments** — a sender pays **fiat in** and a *third-party* recipient +1. **Cross-border payments** — a sender pays **fiat in** and a _third-party_ recipient receives **fiat out**, in another country. Onramp chained to offramp; the stablecoin leg is an implementation detail the sender never sees, and the sender needs no wallet at all. This is the dashboard's core feature. `#review` @@ -22,19 +22,19 @@ persistent identity, saved recipients, history, notifications — and money that two people. **Current scope.** The dashboard ships the unified schema (customer entities, provider customers, - KYC cases, recipients, notifications), sender/recipient KYC/KYB onboarding, wallet-funded - self-offramps, and fiat-funded self-onramps for BRL, MXN, COP, USD, and ARS. Cross-border - fiat-to-fiat transfers, recipient payability, and invited-recipient payout-instrument registration - remain target-state rather than current behavior. EUR onramps remain unavailable while dashboard - onboarding uses Monerium but active EUR ramps resolve Mykobo. The API and dashboard implement - managed headless profiles and route-scoped manager delegation: active managers can select a child, - act through supported dashboard surfaces, and return to their own account without changing the - authenticated manager identity. - +KYC cases, recipients, notifications), sender/recipient KYC/KYB onboarding, wallet-funded +self-offramps, and fiat-funded self-onramps for BRL, MXN, COP, USD, and ARS. Cross-border +fiat-to-fiat transfers, recipient payability, and invited-recipient payout-instrument registration +remain target-state rather than current behavior. EUR onramps remain unavailable while dashboard +onboarding uses Monerium but active EUR ramps resolve Mykobo. The API and dashboard implement +managed headless profiles and route-scoped manager delegation: active managers can select a child, +act through supported dashboard surfaces, and return to their own account without changing the +authenticated manager identity. ## User stories ### Account & auth + - As a user, I sign in with my email via a 6-digit OTP; no wallet is needed to reach my account. - As a user, my account has a type (individual or company) and an identifier (CPF/CNPJ), and I see it on Settings. @@ -42,6 +42,7 @@ two people. never asks for one. ### Onboarding (KYC/KYB) + - As a sender, I pick the corridors I care about (BR, EU, MX, CO, US, AR) and track only those. - As a sender, I complete KYC (individual) or KYB (company) per corridor from the dashboard. Monerium uses its hosted OAuth portal; after the callback exchange, the dashboard reopens the EU onboarding modal. @@ -66,7 +67,7 @@ two people. Monerium** affordance is built on it, so confirming (or refuting) it with Monerium changes shipped behavior, not just documentation. - As a sender, I see each corridor's real status — `not_started · started · pending · in_review · - approved/rejected` — read from the provider, surviving reload. `pending` is only used for +approved/rejected` — read from the provider, surviving reload. `pending` is only used for missing or stale provider data when applicable. - As a Brazilian individual, my flow includes a liveness selfie; EU individuals and companies use Monerium's hosted OAuth KYC/KYB. @@ -82,6 +83,7 @@ two people. third-party payments. Raw bank details are sent directly to AlfredPay and are not stored locally. ### Recipients & invitations `#review` + - As a sender, once **any** corridor of mine is approved I invite a recipient for **any live corridor** by generating a shareable link; I choose their country, rail, and payout currency, and type an **alias** — a sender-local label that identifies the link (and later the recipient) @@ -116,12 +118,13 @@ two people. provider-neutral (**"Pending review"**, no provider names in the recipients list). - As a sender, a recipient becomes payable only when: invite accepted, relationship active, their onboarding approved for that corridor, and a payout reference is verified. Otherwise I see - *why* it is blocked. + _why_ it is blocked. - As a recipient, I can be linked to many senders; I onboard once. ### Transfers **Paying a third party.** The core feature. `#review` + - As a sender, I select an approved recipient, enter the payout amount in their currency, and see the rate and fees before committing. - As a sender, I choose how to fund it: **crypto** from my connected wallet, or **fiat** from my @@ -130,17 +133,20 @@ two people. - As a recipient, the money arrives in my bank account on my corridor's rail. **Paying myself.** Widget parity. + - As a user, I send crypto from my wallet and receive fiat in my own bank account (implemented). - As a user, I pay BRL, MXN, COP, USD, or ARS from my bank account and receive a selected token at an editable EVM destination address. A connected AppKit wallet prefills that address but is not required and never signs a BUY transaction (implemented). ### Transactions + - As a sender, I see my started onramp and offramp history — destination, corridor, amounts in and out, status (`processing · completed · failed · cancelled`), and the reason a payout failed. Ramps that remain in the `initial` phase are omitted from history. ### Notifications & settings + - As a user, I get in-app and email alerts when a corridor's KYC/KYB resolves and when a ramp settles. - As a user, I toggle each of those two email notification categories on Settings. (A third @@ -150,19 +156,21 @@ two people. ### Managed profiles (implemented) This is managed-child delegation, not another login or admin impersonation mechanism. A managed -child is headless and has no Supabase identity. The manager remains the authenticated actor, and +child is headless and has no Supabase identity. An authenticated member remains the actor, and supported API requests carry the selected child's profile ID in `X-Managed-Profile-Id`. The API -must continue to verify the active manager, direct active relationship, child entity, corridor, -and customer-type policy on every delegated authorization decision. - -- As an active managed-profile manager, I see **Managed profiles** in the sidebar. Ordinary users - do not see the item. Manager detection uses the authenticated manager lifecycle API rather than - a client-side role claim. -- As a manager, I can keep using the dashboard as my own account when no child is selected. -- As a manager, I open **Managed profiles** and see my active children. Each row identifies the - child by contact email and external subject ID, shows its immutable customer type, and shows the - corridors authorized for the manager. Corridors are manager policy, not per-child grants. -- As a manager, I use a row's three-dot menu to open a confirmation dialog and choose **Act for +must continue to verify the active membership and role, controlling owner, direct active +relationship, child entity, corridor, and customer-type policy on every delegated authorization +decision. + +- As an active managed-profile member, I see **Managed profiles** in the sidebar. Ordinary users do + not see the item. Access detection uses the authenticated lifecycle API rather than a client-side + role claim. +- As a member, I can keep using the dashboard as my own account when no child is selected. +- As a member, I open **Managed profiles** and see only children assigned to me. Each row identifies + the child, shows its immutable customer type, my `Manager` or `Read only` role and `Owner` badge + where applicable, and the controlling owner's authorized corridors. Corridors remain owner + policy, not per-membership grants. +- As a member, I use a row's three-dot menu to open a confirmation dialog and choose **Act for this profile**. The product must not call this action “Log in as” or “Impersonate”. - Confirming stores the selection, clears account-scoped query and notification state, disconnects the displayed wallet session, and redirects to `/overview`. It MUST NOT clear ramp ephemerals, @@ -171,32 +179,25 @@ and customer-type policy on every delegated authorization decision. explicitly stopped, and is bound to the authenticated manager profile so it cannot survive a change of login identity. - While acting for a child, a persistent yellow banner above the topbar names the child and offers - **Stop acting**. Stopping clears the selection and returns to `/managed-profiles` under the - manager's own account. -- Entering child mode, switching children, or stopping child mode is blocked while the transfer - machine is in its client-owned preparation and signing sequence. This sequence starts when a - submitted transfer enters final quote/balance validation and includes ramp registration, - ephemeral signing, user-wallet signing or broadcast, and submission of the signed ramp update. - The selector and banner explain that the current signing step must finish or fail before the - identity can change; they never reset the machine to force the switch through. -- Once the ramp and all currently required signatures are durably submitted to the backend, an - identity change is allowed. A BUY awaiting payment keeps its payment instructions and ramp ID - under the originating manager/child identity. A started ramp continues on the backend and - remains discoverable in that identity's transaction history even if local polling stops. - Returning to the originating identity restores any resumable payment state. -- Transfer resume state is keyed by the effective owner identity (manager profile when acting as - self, otherwise managed child profile), not one global dashboard key. It must never be displayed, - resumed, or submitted under another selected identity. -- If the selected relationship is deleted, the manager is disabled, or authorization otherwise - becomes invalid, the dashboard clears child mode and returns to the manager's selection page - rather than silently retrying against the manager's own resources. - -**Child-mode navigation.** Onboarding status, Recipients, Get a quote, New transfer, Transactions, -and Limits remain available where their API routes support managed-child authorization. KYC/KYB -actions are read-only: a manager cannot start, continue, retry, or re-authenticate verification for -the child from the dashboard. Generic API keys, Settings and notification preferences, the admin -console, webhook management, and email-bound Monerium/Mykobo operations remain manager-scoped or -unavailable and must not be shown as child operations. The dashboard API client adds + **Stop acting**, and repeats the current role and owner badges. Stopping clears the selection and + returns to `/managed-profiles` under the member's own account. +- Selected-child mode never mounts a transfer form or resumes a saved payment. Existing transfer + state remains keyed by its effective subject and is neither displayed nor submitted while a + child is selected. The backend independently rejects selected-child bearer register, update, and + start requests, so hidden navigation is not the authorization boundary. +- The dashboard bootstraps a persisted selection from a dedicated child lifecycle read and + revalidates it whenever the window regains focus. It refreshes role and owner metadata in place. + If the membership is revoked, the relationship is deleted, the controlling owner is disabled, or + the response identity does not match the selection, it clears child mode and returns to the + member's selection page rather than silently retrying against the member's own resources. + +**Child-mode navigation.** Onboarding status, Recipients, Get a quote, Transactions, child API keys, +Team, and Limits remain available where their API routes support managed-child authorization. New +transfer, resume-payment, and recipient/quote transfer entry points are removed immediately from +every selected-child session because browser bearer sessions cannot satisfy the secret-credential +requirement for managed ramp operations. KYC/KYB actions are read-only. Settings and notification +preferences, the admin console, webhook management, and email-bound Monerium/Mykobo operations +remain member-scoped or unavailable and must not be shown as child operations. The dashboard API client adds `X-Managed-Profile-Id` only when a service explicitly opts into a supported delegated route; it must never attach the header indiscriminately, because an endpoint that ignores it would otherwise operate on the manager while the UI claims to show the child. @@ -205,9 +206,25 @@ The legacy Monerium and Mykobo routes are the known instance of that ignored-hea always use the authenticated manager identity. Dashboard services do not opt them into managed selection, and child-mode onboarding actions remain disabled. -**Recipients in child mode.** The selected child is the sender and owns its invitations and -sender-recipient relationships. The manager may list recipients, create invitations, archive -invitations, update or archive relationships, and check eligibility on the child's behalf. +**Role gates in child mode.** The selected child owns its API credentials, recipient records, and +Alfredpay payout accounts. Both roles may list them. A `manager` member may create/revoke child API +credentials, create/archive recipients, and add/delete payout accounts; a `read_only` member cannot. +If a live bootstrap downgrades the role, any open recipient action, credential revocation, or payout +account form/dialog closes before another submission can be made. + +**Team access.** Both roles can inspect the child roster, pending invitations, and recent access +events. A non-impersonated `manager` can invite an email as `manager` or `read_only`, cancel a +pending invitation, change a non-owner role, or remove a non-owner member. Owner rows are visibly +immutable. No mutation is displayed optimistically before server confirmation. + +An invitation link opens `/member-invitations/:invitationId`. Before authentication the page shows +no child, inviter, role, or status detail. After OTP authentication, the exact current verified +Supabase email may preview and explicitly accept the invitation. Wrong-account, expired, cancelled, +already-accepted, retry, and success states remain distinct. Acceptance refreshes managed-profile +queries and offers to open the child; OTP verification by itself does not grant access. + +**Recipients in child mode.** A manager member may create invitations, archive invitations, update +or archive relationships, and check eligibility on the child's behalf. Invitation creation remains subject to the manager's corridor policy. Privileged discount attachment checks the authenticated manager actor's `discount_manager` role rather than granting that role to the child. Invite preview and acceptance are not delegated: those actions belong to @@ -281,13 +298,9 @@ provider-shaped rather than UI-shaped. transactions page omits the matching initial BUY ramp from history and offers **Resume payment** in a prominent standalone card. Resume affordances are scoped to the account that created the ramp; switching accounts does not expose its payment details. - Managed-child selection extends this rule by keying resumable snapshots to the effective owner - profile rather than using one global snapshot. Selection changes are forbidden while the machine - is in `CheckingQuote`, `CheckingBalance`, `Registering`, or `SigningUserTxs`. Once registration - and signing updates are durably accepted, its owner-scoped `AwaitingPayment` snapshot or backend - transaction record survives selection changes and is available again when that owner is selected. - Ramp ephemeral storage is independent recovery custody and is never pruned or cleared by - manager/child selection. + Managed-child selection never exposes or resumes these snapshots because selected-child bearer + ramp execution is prohibited. Ramp ephemeral storage remains independent recovery custody and is + never pruned or cleared by manager/child selection. The customer can return to the same instructions while the payment window remains open. Once the instructions expire, **Get a new quote** clears only the local transfer state. Starting an expired ramp remains rejected by the API. @@ -301,7 +314,7 @@ provider-shaped rather than UI-shaped. - **Cross-border needs a second principal in ramp registration.** Registration today is structurally a self-ramp — payout destinations are sender-bound. A sender→recipient transfer must carry the relationship id, have the server verify ownership and eligibility, and resolve - the payout side from the *recipient's* provider identity. BRL is the cheapest first corridor + the payout side from the _recipient's_ provider identity. BRL is the cheapest first corridor (it already accepts a third-party PIX destination). `#review` - **Invitations are link-based.** The invite link carries a bearer token — 24 random bytes. The @@ -309,7 +322,7 @@ provider-shaped rather than UI-shaped. **while it is pending** (a deliberate product decision, so the sender can re-copy the link from the list) and is cleared on first acceptance. It is exposed only to the sender who owns the invitation, it is not the invitation id, and it does not authenticate: - `POST /v1/recipients/invite/:token/accept` requires *both* a session and the token. + `POST /v1/recipients/invite/:token/accept` requires _both_ a session and the token. - **Redemption and recipient KYC happen in the widget. `#decided`** The invite link opens the widget carrying the token (`?invite=`) plus `?kybLocked=`, which pre-pins the corridor when the @@ -319,6 +332,7 @@ provider-shaped rather than UI-shaped. has OTP login and the shipped KYC/KYB flows; it gains one new step — after login, redeem the token against the accept endpoint, then proceed into the existing KYB flow. The dashboard needs no `/invite` route. + - **Accepted cost:** widget and dashboard sessions are namespaced apart on purpose (`vortex_access_token` vs `vortex_dashboard_access_token`), so a recipient who later uses the dashboard signs in a second time. Fine for this iteration. @@ -353,8 +367,8 @@ provider-shaped rather than UI-shaped. - Self-onramps and self-offramps are functional. Third-party recipient payments and fiat-funded fiat-to-fiat payments remain future work; the Cross-border mode renders a complete coming-soon state. -- **No recipient can currently become payable.** The payable gate requires a *verified payout - reference*, and nothing in the API creates `RecipientPayoutReference` rows — payout-instrument +- **No recipient can currently become payable.** The payable gate requires a _verified payout + reference_, and nothing in the API creates `RecipientPayoutReference` rows — payout-instrument registration is not implemented. Invitations and recipient KYC work end-to-end, but capability #2 stops at "onboarded", not "payable". The product and provider contract must define how payout instruments are created for both senders creating links and recipients redeeming them, @@ -387,7 +401,7 @@ provider-shaped rather than UI-shaped. `profiles.active_customer_entity_id` once (`ACTIVE_ENTITY_IMMUTABLE` on change attempts), a unique `(profile_id, type)` index precludes duplicate entities, and profiles without a selection fall back deterministically to their oldest entity in - `getOrCreateCustomerEntityForProfile`. Whether users will ever be able to *switch* the active + `getOrCreateCustomerEntityForProfile`. Whether users will ever be able to _switch_ the active entity (individual ↔ company) remains open. ## Admin console (operator surface) diff --git a/docs/security-spec/01-auth/admin-impersonation.md b/docs/security-spec/01-auth/admin-impersonation.md index 523393ac7..ae8f5d984 100644 --- a/docs/security-spec/01-auth/admin-impersonation.md +++ b/docs/security-spec/01-auth/admin-impersonation.md @@ -6,13 +6,15 @@ surface — the per-operator, Supabase-identity-bearing counterpart to the shared-secret `/v1/admin/*` surface documented in [`admin-auth.md`](admin-auth.md). Authenticated profiles are direct session targets. Managed headless profiles are reached by impersonating their -authenticated manager and composing that session with the existing managed-profile selector. +authenticated owner or member and composing that session with an active managed-profile +membership and selector. Impersonation is not read-only. The operator may create quotes, inspect ramp and KYC/KYB status, history, and errors, and perform customer-account mutations outside the protected boundaries. Ramp registration/update/start and KYC/KYB initiation, submission, upload, retry, and OAuth actions reject the request. Durable credential minting and revocation are denied, as are managed-child -creation and deletion. Alfredpay fiat-account creation and deletion remain deliberately available: +creation/deletion and membership/invitation mutations. Alfredpay fiat-account creation and +deletion remain deliberately available: these provider-side payout-account mutations outlive the session and are part of the accepted operator capability (see the risk register, RISK-018). @@ -20,13 +22,13 @@ operator capability (see the risk register, RISK-018). All routes live under `/v1/admin-console/*` (`accounts.route.ts`, `impersonation.route.ts`). -| Route | Guard | Success | Notable errors | -|---|---|---|---| -| `GET /accounts?search=&cursor=&limit=` | `requireVortexAdmin` | `200` — paginated account list; search matches login email, managed contact/external ID, or controlling-manager email; managed rows include child contact identity and controlling-manager identity | — | -| `GET /accounts/:profileId` | `requireVortexAdmin` | `200` — profile kind, managed relationship when present, entities, provider customers, KYC cases, and recent direct impersonation sessions targeting this profile | `400 INVALID_PROFILE_ID`; `404 USER_NOT_FOUND` | -| `POST /impersonation` `{ targetProfileId }` | `requireVortexAdmin` | `201 { token, sessionId, expiresAt, target: { id, email } }` | `400 INVALID_IMPERSONATION_INPUT` (malformed `targetProfileId`); `400 IMPERSONATION_TARGET_INVALID` (self-target, unknown target — from `ImpersonationTargetError`); `403 VORTEX_ADMIN_REQUIRED` if the role is removed during creation; `503 IMPERSONATION_DISABLED` (kill switch off — the caller is authorized, the capability is off, so this is a capability error, not an auth error) | -| `GET /impersonation?limit=` | `requireVortexAdmin` | `200 { sessions: [...] }` — active-first audit view; a non-positive or malformed limit falls back to the default | — | -| `DELETE /impersonation/:sessionId` | see Invariant 12 | `204` | `400 INVALID_IMPERSONATION_SESSION_ID`; `403 IMPERSONATION_NOT_ALLOWED`; `403 VORTEX_ADMIN_REQUIRED`; `404 IMPERSONATION_SESSION_NOT_FOUND` | +| Route | Guard | Success | Notable errors | +| ------------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /accounts?search=&cursor=&limit=` | `requireVortexAdmin` | `200` — paginated account list; search matches login email, managed contact/external ID, or controlling-manager email; managed rows include child contact identity and controlling-manager identity | — | +| `GET /accounts/:profileId` | `requireVortexAdmin` | `200` — profile kind, managed relationship when present, entities, provider customers, KYC cases, and recent direct impersonation sessions targeting this profile | `400 INVALID_PROFILE_ID`; `404 USER_NOT_FOUND` | +| `POST /impersonation` `{ targetProfileId }` | `requireVortexAdmin` | `201 { token, sessionId, expiresAt, target: { id, email } }` | `400 INVALID_IMPERSONATION_INPUT` (malformed `targetProfileId`); `400 IMPERSONATION_TARGET_INVALID` (self-target, unknown target — from `ImpersonationTargetError`); `403 VORTEX_ADMIN_REQUIRED` if the role is removed during creation; `503 IMPERSONATION_DISABLED` (kill switch off — the caller is authorized, the capability is off, so this is a capability error, not an auth error) | +| `GET /impersonation?limit=` | `requireVortexAdmin` | `200 { sessions: [...] }` — active-first audit view; a non-positive or malformed limit falls back to the default | — | +| `DELETE /impersonation/:sessionId` | see Invariant 12 | `204` | `400 INVALID_IMPERSONATION_SESSION_ID`; `403 IMPERSONATION_NOT_ALLOWED`; `403 VORTEX_ADMIN_REQUIRED`; `404 IMPERSONATION_SESSION_NOT_FOUND` | `requireVortexAdmin` (`vortexAdminAuth.ts`) is the chain `requireAuth → rejectImpersonation → checkVortexAdminRole`: Supabase auth, then no impersonation chaining, then the `vortex_admin` @@ -42,7 +44,7 @@ Invariant 12 for the exact self-revoke mechanism this enables. 1. `POST /v1/admin-console/impersonation` with `{ targetProfileId }` mints a session (`impersonation.service.ts::createSession`) and returns `{ token, sessionId, expiresAt, - target }`. The token is `vtx_imp_` followed by 32 random bytes (256 bits), base64url-encoded. +target }`. The token is `vtx_imp_` followed by 32 random bytes (256 bits), base64url-encoded. Only its SHA-256 hash is persisted to `admin_impersonation_sessions`; the raw value is returned exactly once and never stored server-side. 2. The operator presents the token as an ordinary `Authorization: Bearer` header on subsequent @@ -64,11 +66,12 @@ Invariant 12 for the exact self-revoke mechanism this enables. 5. `GET /v1/admin-console/impersonation` lists sessions for audit (active first, then recent); `DELETE /v1/admin-console/impersonation/:sessionId` revokes one immediately. -For a managed child, the dashboard starts the session against the authenticated manager returned -by the account lookup, then stores the child profile ID as the managed-profile selection. The -impersonation audit target remains the manager. Delegated requests carry `X-Managed-Profile-Id` -and continue through the normal active-manager, direct-relationship, entity, customer-type, and -corridor authorization checks. No impersonation token directly targets a headless profile. +For a managed child, the dashboard starts the session against an authenticated profile with an +active membership, then stores the child profile ID as the managed-profile selection. The +impersonation audit target remains that authenticated member. Delegated requests carry +`X-Managed-Profile-Id` and continue through normal live membership, immutable-owner policy, +relationship, entity, customer-type, and corridor checks. No impersonation token directly targets +a headless profile, grants a missing membership, or upgrades a role. Both `requireAuth`/`optionalAuth` (`supabaseAuth.ts`) and the dual-auth handlers (`dualAuth.ts`) call `resolveBearerPrincipal()`, so an impersonation token is honored on any @@ -123,7 +126,7 @@ and requires deployment/database access rather than an HTTP credential — see stop resolving immediately, with no per-row revocation pass required. 7. **Starting a new session for the same (actor, target) MUST supersede the prior one** — `createSession()` revokes any existing non-revoked session for that exact `(actorProfileId, - targetProfileId)` pair with `revokedReason: "superseded"` before minting the new token. This +targetProfileId)` pair with `revokedReason: "superseded"` before minting the new token. This is serialized by a row lock on the actor profile and backed by the partial unique index `uq_admin_impersonation_sessions_active`, so concurrent starts cannot leave two non-revoked sessions for the same pair. @@ -142,7 +145,7 @@ and requires deployment/database access rather than an HTTP credential — see directly. 11. **An impersonated request MUST NOT be able to mint durable credentials** — `rejectImpersonation` is applied ahead of both `POST /v1/api-credentials` and `POST - /v1/managed-profiles/:profileId/api-credentials`: a credential minted while acting as someone +/v1/managed-profiles/:profileId/api-credentials`: a credential minted while acting as someone else would outlive the 30-minute session and become a standing backdoor into the target or a managed child. 12. **An impersonated request MUST NOT be able to reach the admin console, except to end its own @@ -156,12 +159,12 @@ and requires deployment/database access rather than an HTTP credential — see straight to revocation. Any other impersonated request to that same route — a different `sessionId`, including a different session belonging to the same operator — is rejected with `403 IMPERSONATION_NOT_ALLOWED` before any role check runs. Every other route (`GET - /accounts`, `GET /accounts/:profileId`, `POST /impersonation`, `GET /impersonation`) sits +/accounts`, `GET /accounts/:profileId`, `POST /impersonation`, `GET /impersonation`) sits behind `requireVortexAdmin` = [`requireAuth`, `rejectImpersonation`, role check], so an impersonated caller is refused at the `rejectImpersonation` step, before role or business logic runs at all. **Verified**: `admin-console.route.test.ts` — an impersonated caller can end its own session (`204`), is refused ending a different session (`403 - IMPERSONATION_NOT_ALLOWED`), and is refused `GET /accounts` and `POST /impersonation` +IMPERSONATION_NOT_ALLOWED`), and is refused `GET /accounts` and `POST /impersonation` (`403`). 13. **Every `api_client_events` row raised during an impersonated request MUST carry both identities** — `buildApiClientRequestMetadata()` stamps `metadata.impersonationSessionId` and @@ -202,24 +205,28 @@ and requires deployment/database access rather than an HTTP credential — see disabling integrations through credential revocation, or creating/deleting retained child identities. Alfredpay fiat-account creation and deletion are intentionally outside this denial: their durable provider-side mutation is explicitly accepted by RISK-018. +19. **An impersonated request MUST NOT mutate managed membership or invitations** — member role + change/removal, invitation creation/cancellation, and invitee acceptance reject impersonation + before service mutation. An impersonation token can use only the target profile's already-live + membership and cannot manufacture or upgrade child access. ## Threat Vectors & Mitigations -| Threat | Attack Scenario | Mitigation | -|---|---|---| -| Database dump exposes usable tokens | Attacker reads `admin_impersonation_sessions` from a backup or replica | Only a SHA-256 hash is stored; the raw token is never persisted (Invariant 2) | -| Stolen or leaked impersonation token replayed after the operator's intent has ended | Token captured via logs, browser history, or a compromised operator device | 30-minute non-renewable TTL (Invariant 4); instant hash-based revocation via `DELETE /impersonation/:sessionId` (Invariant 8); re-checked liveness on every use (Invariant 5) | -| Impersonation used to mint a permanent backdoor | Operator (or an attacker who obtained an operator's token) mints an API secret key for the target or a managed child while impersonating, which outlives the session | `rejectImpersonation` on both credential-creation routes (Invariant 11) | -| Privilege re-escalation / impersonation chaining | An impersonated request is used to start a second impersonation session, list sessions, or browse accounts | `requireVortexAdmin`'s `rejectImpersonation` step refuses `GET /accounts`, `GET /accounts/:profileId`, `POST /impersonation`, and `GET /impersonation` outright (Invariant 12) | -| Impersonated caller abuses the self-revoke carve-out to end someone else's session | Operator impersonating profile A presents that token against profile B's `sessionId` | Rejected with `403 IMPERSONATION_NOT_ALLOWED`: the carve-out only matches when the path `:sessionId` equals the caller's own `req.impersonation.sessionId` (Invariant 12) | -| Impersonation initiates or advances money movement | Operator calls ramp register, update, or start while acting as a customer | All three mutating ramp routes apply `rejectImpersonation` after principal resolution and before controller execution (Invariant 16); quote creation and ramp inspection remain available | -| Self-impersonation used to launder attribution | Operator targets their own profile to blur operator/target identity | Rejected at both the application layer and a database `CHECK` constraint (Invariant 3) | -| Stale sessions surviving an incident response kill switch | Operator response to a suspected compromise is "disable impersonation", but existing tokens keep working | `IMPERSONATION_ENABLED=false` invalidates all live sessions on next resolution, not just new mints (Invariant 6) | -| Removed operator role leaves previously minted tokens usable | An operator is deprovisioned while one or more impersonation sessions remain live | Role removal atomically revokes all non-revoked sessions, and token resolution independently re-checks `vortex_admin` on every use (Invariants 5 and 8) | -| Token brute force / guessing | Attacker attempts to guess a valid `vtx_imp_*` value | 256 bits of randomness in the token; lookup requires an exact SHA-256 hash match | -| Shared-secret surface used to self-grant impersonation rights | An operator (or anyone) with `ADMIN_SECRET` calls `POST /v1/admin/profile-roles` to grant themselves `vortex_admin`, turning a shared secret into broad customer-account access | `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` (Invariant 14); the only grant path is `scripts/grant-vortex-admin.ts`, which requires deployment/database access, not an HTTP credential | -| Concurrent session creation races the supersession check | Two near-simultaneous `POST /impersonation` calls for the same (actor, target) both attempt to supersede and mint | Actor-row transaction locking serializes creation; the partial unique index rejects any second non-revoked row if locking regresses (Invariant 7) | -| Profile deletion erases the impersonation audit trail | Deleting a target or operator cascades into session history | Both foreign keys use `ON DELETE RESTRICT`, preserving the audit record until retention is handled explicitly (Invariant 15) | +| Threat | Attack Scenario | Mitigation | +| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Database dump exposes usable tokens | Attacker reads `admin_impersonation_sessions` from a backup or replica | Only a SHA-256 hash is stored; the raw token is never persisted (Invariant 2) | +| Stolen or leaked impersonation token replayed after the operator's intent has ended | Token captured via logs, browser history, or a compromised operator device | 30-minute non-renewable TTL (Invariant 4); instant hash-based revocation via `DELETE /impersonation/:sessionId` (Invariant 8); re-checked liveness on every use (Invariant 5) | +| Impersonation used to mint a permanent backdoor | Operator (or an attacker who obtained an operator's token) mints an API secret key for the target or a managed child while impersonating, which outlives the session | `rejectImpersonation` on both credential-creation routes (Invariant 11) | +| Privilege re-escalation / impersonation chaining | An impersonated request is used to start a second impersonation session, list sessions, or browse accounts | `requireVortexAdmin`'s `rejectImpersonation` step refuses `GET /accounts`, `GET /accounts/:profileId`, `POST /impersonation`, and `GET /impersonation` outright (Invariant 12) | +| Impersonated caller abuses the self-revoke carve-out to end someone else's session | Operator impersonating profile A presents that token against profile B's `sessionId` | Rejected with `403 IMPERSONATION_NOT_ALLOWED`: the carve-out only matches when the path `:sessionId` equals the caller's own `req.impersonation.sessionId` (Invariant 12) | +| Impersonation initiates or advances money movement | Operator calls ramp register, update, or start while acting as a customer | All three mutating ramp routes apply `rejectImpersonation` after principal resolution and before controller execution (Invariant 16); quote creation and ramp inspection remain available | +| Self-impersonation used to launder attribution | Operator targets their own profile to blur operator/target identity | Rejected at both the application layer and a database `CHECK` constraint (Invariant 3) | +| Stale sessions surviving an incident response kill switch | Operator response to a suspected compromise is "disable impersonation", but existing tokens keep working | `IMPERSONATION_ENABLED=false` invalidates all live sessions on next resolution, not just new mints (Invariant 6) | +| Removed operator role leaves previously minted tokens usable | An operator is deprovisioned while one or more impersonation sessions remain live | Role removal atomically revokes all non-revoked sessions, and token resolution independently re-checks `vortex_admin` on every use (Invariants 5 and 8) | +| Token brute force / guessing | Attacker attempts to guess a valid `vtx_imp_*` value | 256 bits of randomness in the token; lookup requires an exact SHA-256 hash match | +| Shared-secret surface used to self-grant impersonation rights | An operator (or anyone) with `ADMIN_SECRET` calls `POST /v1/admin/profile-roles` to grant themselves `vortex_admin`, turning a shared secret into broad customer-account access | `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` (Invariant 14); the only grant path is `scripts/grant-vortex-admin.ts`, which requires deployment/database access, not an HTTP credential | +| Concurrent session creation races the supersession check | Two near-simultaneous `POST /impersonation` calls for the same (actor, target) both attempt to supersede and mint | Actor-row transaction locking serializes creation; the partial unique index rejects any second non-revoked row if locking regresses (Invariant 7) | +| Profile deletion erases the impersonation audit trail | Deleting a target or operator cascades into session history | Both foreign keys use `ON DELETE RESTRICT`, preserving the audit record until retention is handled explicitly (Invariant 15) | ## Gaps Identified During This Review @@ -271,18 +278,18 @@ and requires deployment/database access rather than an HTTP credential — see - [x] `rejectImpersonation` blocks credential minting through `/v1/api-credentials` and the managed-profile credential-creation route — **PASS** (`api-credentials.route.test.ts`). - [x] `rejectImpersonation` blocks `POST /v1/ramp/register`, `POST /v1/ramp/update`, and `POST - /v1/ramp/start`, while quote creation reaches normal validation and ramp history remains + /v1/ramp/start`, while quote creation reaches normal validation and ramp history remains readable — **PASS** (`ramp.route.test.ts`). - [x] `rejectImpersonation` blocks Alfredpay, Avenia, Monerium, and Mykobo KYC/KYB action routes during admin impersonation while aggregate status stays readable — **PASS** (`provider-verification.route.test.ts`). - [x] `requireVortexAdmin` (`requireAuth → rejectImpersonation → role check`) gates `GET - /accounts`, `GET /accounts/:profileId`, `POST /impersonation`, and `GET /impersonation`; an + /accounts`, `GET /accounts/:profileId`, `POST /impersonation`, and `GET /impersonation`; an impersonated caller is refused all four — **PASS** (`admin-console.route.test.ts`, "refuses an impersonated caller from reaching GET /accounts or POST /impersonation"). - [x] `DELETE /impersonation/:sessionId` allows an impersonated caller to end only its own session (`req.impersonation.sessionId === :sessionId`) and rejects any other target with `403 - IMPERSONATION_NOT_ALLOWED`, while a non-impersonated caller still needs `vortex_admin` to + IMPERSONATION_NOT_ALLOWED`, while a non-impersonated caller still needs `vortex_admin` to revoke any session — **PASS** (`admin-console.route.test.ts`, all four cases under "DELETE /impersonation/:sessionId while impersonating"). - [x] Every `api_client_events` row raised while `req.impersonation` is set carries @@ -300,7 +307,7 @@ and requires deployment/database access rather than an HTTP credential — see **PASS** (`impersonation.service.test.ts`). - [x] An out-of-band, idempotent operator process for granting `vortex_admin` exists and is documented — **PASS** (`scripts/grant-vortex-admin.ts`, `bun run grant:vortex-admin - `). + `). - [x] Managed-child creation/deletion and manager/child credential creation/revocation reject impersonation, while list/read operations remain available — **PASS** (`api-credentials.route.test.ts`). diff --git a/docs/security-spec/01-auth/api-keys.md b/docs/security-spec/01-auth/api-keys.md index 13f8ccfa5..81f51ba1b 100644 --- a/docs/security-spec/01-auth/api-keys.md +++ b/docs/security-spec/01-auth/api-keys.md @@ -32,7 +32,8 @@ Every credential has a non-null `profile_id`. A null `partner_id` is profile-man | Register, update, start, or read a ramp | No | Yes | Yes | | Read ramp history or diagnostic error logs | No | Yes | Yes | | Manage fiat/provider accounts | No | Yes | Yes | -| Act for an authorized managed child | No | Yes | Yes | +| Read or manage an authorized managed child, subject to membership role | No | Yes | Yes | +| Register, update, or start a selected child's ramp | No | Yes | No | | Use a child-owned credential as the managed child | Public capabilities only | Yes | N/A | | Manage a directly owned child's credentials | No | Yes | Yes | | Manage webhooks | No | Yes | No | @@ -96,9 +97,11 @@ Its response is an allowlisted per-corridor projection: 18. **There MUST be no legacy request-path fallback**: runtime validation reads only `api_credentials`; it does not read `api_keys`, bcrypt hashes, old prefixes, unpaired halves, or name-based relationships. 19. **Startup MUST fail closed**: after migrations and before listening, the API verifies required `api_credentials` columns, nullability, indexes, constraints, and that the legacy `api_keys` table is absent. Any failure prevents serving traffic. 20. **`ramp-info` MUST be subject-derived and sanitized**: it accepts no user selector and returns only the documented KYC state and buy/sell booleans. -21. **Managed-profile selection MUST be authorization-derived**: `X-Managed-Profile-Id` is accepted only on delegated routes after a Supabase session or secret credential establishes the manager actor. Secret-key middleware explicitly records the authenticated credential profile; delegated authorization MUST NOT infer authentication by inspecting `CredentialContext.strength`. Authorization requires an active manager, a direct active relationship, a managed child with exactly one customer entity matching its active entity, and, for policy-bound operations, every required corridor, canonical corridor/type support, and inclusion under any non-null manager customer-type narrowing. Null customer types add no restriction beyond the canonical matrix. The verified child becomes the effective operation subject without replacing the authenticated actor. A direct child credential cannot present the selector to act for another child. -22. **Managed-profile lifecycle MUST remain manager-scoped and logically deleted**: `POST/GET/DELETE /v1/managed-profiles` accepts only a Supabase session or secret credential whose subject is an active configured manager. During admin impersonation, list/read operations remain available but creation and deletion return `403 IMPERSONATION_NOT_ALLOWED`. Creation derives the manager from authentication, requires immutable `externalSubjectId`, `contactEmail`, and customer type values, rejects a customer type outside the manager's non-null `allowedCustomerTypes` narrowing rather than creating a child the manager could never operate, accepts no corridor grant, is idempotent by `(manager_profile_id, external_subject_id)`, and rejects reuse of a normalized `(manager_profile_id, contact_email)` by another child. Listing defaults to active children and returns the active manager projection `{ profileId, allowedCorridors, allowedCustomerTypes }` even when the child list is empty; direct reads may return retained deleted children. Foreign children return `404`. Deletion locks the child profile and relationship, atomically marks the relationship deleted and revokes all active child credentials, preserves customer/provider/KYC/ramp records, and returns `204` on repeated requests. Deleted external subject IDs and contact emails remain permanently reserved within that manager. Database triggers enforce the immutability of both `external_subject_id` and `contact_email`, so the identity a manager's records are keyed by cannot be reassigned after creation. -23. **Child credentials MUST remain relationship-controlled**: `POST/GET/DELETE /v1/managed-profiles/:profileId/api-credentials` requires the active controlling manager, scopes every operation by both manager and child, and is the only credential-issuance path that accepts a managed subject. During admin impersonation, credential listing remains available but creation and revocation return `403 IMPERSONATION_NOT_ALLOWED`. It issues only `partner_id = NULL` credentials under the child's shared five-active-credential cap. Credential creation locks the child profile and relationship in the same order as logical deletion. Public and secret validation of a managed child's credential dynamically requires the unique relationship and manager to remain active. Corridor-bound routes apply the manager's current corridor and optional customer-type narrowing plus the canonical corridor capability matrix, and deletion revokes both halves. Direct child credentials cannot manage webhooks or manager lifecycle resources. Manager deactivation, relationship deletion, or policy changes block authorization decisions that begin after the change commits; they do not cancel requests already authorized and in flight. The retained relationship provides manager-level attribution; durable distinction between delegated-manager and direct-child-credential requests is not required unless credential-level attribution becomes a product requirement. +21. **Managed-profile selection MUST be authorization-derived**: `X-Managed-Profile-Id` is accepted only on delegated routes after a Supabase session or secret credential establishes an authenticated member actor. Secret-key middleware explicitly records the authenticated credential profile; delegated authorization MUST NOT infer authentication by inspecting `CredentialContext.strength`. Authorization requires the actor's active `managed_profile_memberships` row, an active immutable controlling manager, a direct active relationship, a managed child with exactly one customer entity matching its active entity, and, for policy-bound operations, every required corridor, canonical corridor/type support, and inclusion under any non-null owner customer-type narrowing. `read_only` membership permits only explicitly read-classified routes; `manager` membership permits read and management. Provider/KYC/KYB `credential_manage` and ramp capabilities additionally require the manager member's secret credential. Null customer types add no restriction beyond the canonical matrix. The verified child becomes the effective operation subject without replacing the authenticated actor. A direct child credential does not require a member row and cannot present the selector to act for another child. +22. **Managed-profile lifecycle mutations MUST remain owner-scoped and logically deleted**: `POST` and `DELETE /v1/managed-profiles` derive the owner from authentication and do not delegate through membership. During admin impersonation, list/read operations remain available but creation and deletion return `403 IMPERSONATION_NOT_ALLOWED`. Creation requires immutable `externalSubjectId`, `contactEmail`, and customer type values, rejects a customer type outside the owner's non-null `allowedCustomerTypes` narrowing, accepts no corridor grant, is idempotent by `(manager_profile_id, external_subject_id)`, and rejects reuse of a normalized `(manager_profile_id, contact_email)` by another child. List/read operations return only active memberships of the authenticated actor, include each membership role and immutable owner policy, and may return retained deleted children on direct read; foreign children return `404`. Deletion locks the child profile and relationship, atomically marks the relationship deleted and revokes all active child credentials, preserves customer/provider/KYC/ramp records, and returns `204` on repeated requests. Deleted external subject IDs and contact emails remain permanently reserved within that owner. Database triggers enforce immutable ownership, external subject, and contact email. +23. **Child credentials MUST remain membership- and relationship-controlled**: `GET /v1/managed-profiles/:profileId/api-credentials` requires an active `manager` or `read_only` membership; `POST` and `DELETE` require `manager`. Every operation is scoped to the path child, and this is the only credential-issuance path that accepts a managed subject. A mismatched `X-Managed-Profile-Id` and path child is denied. During admin impersonation, credential listing remains available but creation and revocation return `403 IMPERSONATION_NOT_ALLOWED`. It issues only `partner_id = NULL` credentials under the child's shared five-active-credential cap. Credential creation locks the child profile and relationship in the same order as logical deletion. Public and secret validation of a managed child's credential dynamically requires the unique relationship and owner manager to remain active. Corridor-bound routes apply the owner's current corridor and optional customer-type narrowing plus the canonical corridor capability matrix, and deletion revokes both halves. Direct child credentials cannot manage webhooks or manager lifecycle resources. +24. **Selected-child ramp execution MUST require a member-owned secret credential before body buffering**: Supabase bearer sessions may perform non-ramp operations allowed by their membership role, but `POST /v1/ramp/register`, `/update`, and `/start` with `X-Managed-Profile-Id` return `403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL`. The guard authenticates the bearer, rejects impersonation first, verifies membership/role, and responds before the global JSON parser or body-derived corridor resolver. Manager-member secret credentials continue through full corridor and ownership authorization. A direct child secret credential remains supported without a selector. +25. **Membership persistence MUST preserve ownership and audit history**: every retained relationship has an active owner `manager` membership; new provisioning atomically creates the profile, customer entity, relationship, owner membership, and append-only `member_added` event. The database permits only authenticated members, one active membership per child/member, and valid `manager|read_only` roles; it prevents changing the immutable owner or downgrading/removing the active owner's membership. Membership and invitation mutations write append-only events atomically. Human membership administration is bearer-only and cannot be performed by either a member-owned or child-owned API credential. ## Threat Vectors & Mitigations diff --git a/docs/security-spec/01-auth/managed-profile-memberships.md b/docs/security-spec/01-auth/managed-profile-memberships.md new file mode 100644 index 000000000..5ddf86229 --- /dev/null +++ b/docs/security-spec/01-auth/managed-profile-memberships.md @@ -0,0 +1,147 @@ +# Managed-Profile Memberships + +## What This Does + +Managed-profile memberships grant an authenticated profile access to one headless managed +child without changing that child's owner or resource ownership. The immutable owner in +`managed_profiles.manager_profile_id` remains the policy, pricing, namespace, provisioning, +and deletion principal. Membership is a separate child-scoped authorization edge. + +There are exactly two roles: + +| Role | Child access | +| ----------- | ------------------------------------------------------------------------------------------- | +| `manager` | Reads and supported child mutations; may administer non-owner members and child credentials | +| `read_only` | Reads only; no child, credential, provider, ramp, or membership mutation | + +The server classifies every delegated route as `read`, `manage`, `credential_manage`, or +`ramp`. Membership APIs use a Supabase bearer principal and reject API credentials and +impersonation. Selected-child provider/KYC/KYB and ramp mutations require an eligible +member-owned secret API credential. A direct child secret authenticates as the shared child +principal and does not depend on a human membership. + +## Routes + +Manager/member routes are nested under `/v1/managed-profiles/:profileId`: + +| Route | Minimum role | Purpose | +| ------------------------------------------ | ------------ | ------------------------------------------------------- | +| `GET /members` | `read_only` | List active members, including immutable owner metadata | +| `PATCH /members/:memberProfileId` | `manager` | Change a non-owner member role | +| `DELETE /members/:memberProfileId` | `manager` | Revoke a non-owner member | +| `GET /member-invitations` | `read_only` | List pending and terminal invitations | +| `POST /member-invitations` | `manager` | Invite one normalized email with one role | +| `DELETE /member-invitations/:invitationId` | `manager` | Cancel a pending invitation | +| `GET /member-events` | `read_only` | Read cursor-paginated access history | + +Invitee routes are bearer-authenticated and do not accept managed selection: + +| Route | Purpose | +| ------------------------------------------------------------------ | ------------------------------------------------------------- | +| `GET /v1/managed-profile-member-invitations/:invitationId` | Preview status only after exact verified-email authentication | +| `POST /v1/managed-profile-member-invitations/:invitationId/accept` | Explicitly accept and create/reactivate membership | + +## Security Invariants + +1. **Authorization MUST be live and child-scoped** — every delegated request resolves the + selected managed child, its active immutable owner configuration, and the actor's active + membership. A selector, cached dashboard role, invitation UUID, or request email is not + authority. +2. **Member and subject kinds MUST be enforced** — members are `authenticated` profiles; + subjects are `managed` profiles with their required managed relationship and active + customer entity. +3. **Ownership MUST remain immutable** — `managed_profiles.manager_profile_id` cannot + change. The active owner membership must exist with role `manager` and cannot be revoked + or downgraded while the child is active. +4. **Roles MUST be an allowlist** — only `manager` and `read_only` are valid. Capability + mappings are server-owned; unknown roles or unclassified routes fail closed. +5. **Read-only MUST remain read-only across authentication methods** — `read_only` cannot + use `manage`, `credential_manage`, or `ramp`, including through its own profile secret + credential. +6. **Lifecycle MUST remain owner-only** — membership does not authorize child provisioning, + sibling creation, child deletion, owner-policy changes, pricing administration, or global + profile-role changes. +7. **Owner policy MUST govern every member** — corridor and customer-type authorization, + pricing fallback, provider-contact namespace, and direct-child credential policy resolve + from the immutable owner, never the acting member's personal manager configuration. +8. **Selected-child provider and ramp mutations MUST require a secret** — a Supabase bearer + may use allowed read/manage operations but cannot use `credential_manage` or register, + update, or start a child ramp. Ramp denial occurs before the global body parser and uses + `MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL`. There is no drain exception. +9. **Child credentials MUST remain independent company principals** — possession grants the + credential's currently supported direct-child provider, fiat-account, quote, and ramp + capabilities without a human membership. Member removal does not revoke these credentials; + child deletion does. +10. **Membership administration MUST reject non-human principals** — membership and + invitation mutations require a current Supabase bearer session and reject API credentials, + direct child credentials, and admin impersonation. +11. **Invitation creation MUST be non-enumerating and idempotent** — responses do not reveal + whether the normalized email already has a profile. At most one pending invitation exists + per `(managed_profile_id, email)` regardless of role; changing a pending role requires + cancellation and a new invitation. Repeated identical creation does not send twice. +12. **Invitation acceptance MUST bind the current verified email** — preview and acceptance + compare the normalized invitation email with the current Supabase principal's verified + email, not request input or stale `profiles.email`. Mismatched callers receive no child, + inviter, role, or status details. +13. **Acceptance MUST be explicit and transactional** — OTP verification does not grant + access. Acceptance locks and rechecks the invitation, expiry, owner, child, and member; + creates one active membership; marks acceptance; and writes `invitation_accepted` plus + `member_added` events atomically. Replay creates no duplicate membership or event. +14. **Expiry and cancellation MUST be terminal** — invitations expire after seven days. + Creation, preview, listing, and acceptance persist observed expiry exactly once. Accepted, + cancelled, and expired invitations remain auditable and cannot be accepted. +15. **Mutations MUST use one lock order** — owner configuration/profile first, child + profile/aggregate second, then invitation or membership rows. Role change, removal, + cancellation, expiry, and acceptance recheck active authority while holding those locks. +16. **Events MUST be append-only and atomic** — every post-migration invitation or membership + state change writes its event in the same transaction. Event rows cannot be updated or + deleted. Migration backfill is the only eventless membership creation. +17. **Direct database access MUST be denied to clients** — membership, invitation, event, + and related sequence objects have RLS enabled with no client policy and explicit + `anon`/`authenticated` privilege revocation. +18. **Errors MUST not expand discovery** — generic probing receives + `MANAGED_PROFILE_ACCESS_DENIED`. `MANAGED_PROFILE_MEMBERSHIP_INVALID` is reserved for an + authenticated actor's previously selected child bootstrap and is the only error that may + clear dashboard selection. Role and policy denial leave valid selection intact. +19. **Invitations MUST use the durable email queue** — creation writes one direct-recipient + outbox row in the invitation transaction. Only the managed-profile invitation type may + set `recipient_email`; existing retry, idempotency, non-production allowlist, escaping, + and abandoned-send controls remain active. +20. **Secrets and invitation identity MUST not enter telemetry** — logs, client events, and + event payloads omit API-key secrets, invitation URLs, bearer tokens, and unnecessary + membership/email details. + +## Threat Vectors & Mitigations + +| Threat | Attack scenario | Mitigation | +| -------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Cross-child access | A member changes `X-Managed-Profile-Id` to a sibling or foreign child | Active membership is resolved for the exact selected child on every request | +| Owner lockout | A manager removes or downgrades the immutable owner | Database invariant plus transactional owner check rejects the mutation | +| Read-only escalation | A read-only member presents a personal secret key | Role is checked independently from credential strength | +| Invitation interception | A UUID leaks through browser history | UUID is only a locator; exact current verified-email authentication and explicit acceptance are required | +| Email enumeration | Inviter probes whether an address already uses Vortex | Creation response and delivery behavior expose no profile-existence distinction | +| Acceptance race | Two sessions accept the same invite concurrently | Invitation and child locks plus uniqueness and terminal-state checks create one membership/events set | +| Stale dashboard authority | Cached manager role remains after downgrade | Server checks live membership; bootstrap refresh removes controls without treating cache as authority | +| Shared credential survives offboarding | Removed member retained a child API secret | Child credentials are explicit shared principals; UI warns managers and revocation is the response | +| Impersonation creates standing access | Admin session invites a new member or mints a child key | Membership/invitation and credential mutations reject impersonation | + +## Audit Checklist + +- [ ] Migration backfills exactly one owner-manager membership for every retained child. +- [ ] Database constraints enforce profile kinds, active uniqueness, immutable ownership, + owner protection, valid roles, invitation terminal states, and append-only events. +- [ ] RLS flags and deployed grants deny `anon` and `authenticated` direct access. +- [ ] Every delegated route declares exactly one capability and unknown roles fail closed. +- [ ] Supabase selected-child ramp register/update/start fails before body buffering. +- [ ] Manager and read-only personal secrets are distinguished by live membership role. +- [ ] Direct child secrets cannot select another child or administer membership. +- [ ] All membership mutations reject impersonation and API credentials. +- [ ] Invitation create, preview, and accept normalize email identically. +- [ ] Preview and accept require `email_confirmed_at` and the exact current Supabase email. +- [ ] Acceptance, replay, cancellation, expiry, role changes, removals, and owner races have + transactional tests. +- [ ] Every state change writes one immutable event with the correct actor and subject. +- [ ] Direct-recipient email is accepted only for the membership-invitation type. +- [ ] Dashboard clears selection only after bootstrap returns membership-invalid. +- [ ] Desktop and mobile tests cover role badges, read-only controls, Team, API keys, and + blocked transfer entry points. diff --git a/docs/security-spec/03-ramp-engine/profile-partner-pricing.md b/docs/security-spec/03-ramp-engine/profile-partner-pricing.md index 8c00827b8..e990a9c0a 100644 --- a/docs/security-spec/03-ramp-engine/profile-partner-pricing.md +++ b/docs/security-spec/03-ramp-engine/profile-partner-pricing.md @@ -2,7 +2,7 @@ ## What This Does -Profile partner pricing lets an authenticated profile receive the custom quote behavior of a partner without exposing partner API credentials. An administrator assigns a profile to a partner name, and the backend resolves that (unique) name once into a stable `partner_id`. When that profile creates a quote through a supported authentication path, the backend reads the applicable active assignment's `partner_id` and loads the partner's pricing config for the requested ramp type from `partner_pricing_configs` (`UNIQUE(partner_id, ramp_type, COALESCE(fiat_currency, '*'))` — a config scoped to the quote's corridor fiat currency wins over the partner's wildcard `fiat_currency IS NULL` config). A managed child uses its own active assignment when present and otherwise inherits the controlling manager profile's active assignment. +Profile partner pricing lets an authenticated profile receive the custom quote behavior of a partner without exposing partner API credentials. An administrator assigns a profile to a partner name, and the backend resolves that (unique) name once into a stable `partner_id`. When that profile creates a quote through a supported authentication path, the backend reads the applicable active assignment's `partner_id` and loads the partner's pricing config for the requested ramp type from `partner_pricing_configs` (`UNIQUE(partner_id, ramp_type, COALESCE(fiat_currency, '*'))` — a config scoped to the quote's corridor fiat currency wins over the partner's wildcard `fiat_currency IS NULL` config). A managed child uses its own active assignment when present and otherwise inherits the immutable owner profile's active assignment; an invited member's assignment never participates. This feature is intentionally different from partner API-key authentication: @@ -21,7 +21,7 @@ For profile-assigned frontend quotes, `quote_tickets.user_id` is set to the auth ## Security Invariants -1. **Profile assignments MUST be server-side only** - The client MUST NOT be able to choose its assigned partner by passing a request body field, URL parameter, or local storage value. The backend resolves assignments only from the authenticated effective profile and, for a verified managed child, its controlling manager profile. +1. **Profile assignments MUST be server-side only** - The client MUST NOT be able to choose its assigned partner by passing a request body field, URL parameter, or local storage value. The backend resolves assignments only from the authenticated effective profile and, for a verified managed child, its immutable owner profile. 2. **Profile assignments MUST NOT authenticate partner ownership** - A Supabase profile assigned to a partner MUST NOT populate `req.authenticatedPartner`, MUST NOT satisfy `enforcePartnerAuth()`, and MUST NOT access partner-owned quotes or ramps. 3. **Explicit partner API-key integrations MUST keep their existing behavior** - Requests that include `partnerId` still require a matching partner secret key. Existing SDK/API clients using partner keys must continue to create partner-owned quotes. 4. **Partner pricing source precedence MUST be deterministic** - Explicit `partnerId` has highest precedence, then validated public API key partner name, then the effective profile assignment. For a verified managed child with no active, unexpired assignment, the controlling manager profile assignment is next, followed by default `"vortex"` pricing. A managed child's assignment supersedes the manager's for both delegated-manager and direct-child-credential requests. @@ -39,20 +39,20 @@ For profile-assigned frontend quotes, `quote_tickets.user_id` is set to the auth ## Threat Vectors & Mitigations -| Threat | Attack Scenario | Mitigation | -|---|---|---| -| **User spoofs partner discount** | A frontend user passes another partner's `partnerId` in the quote request body to claim better rates. | Existing `enforcePartnerAuth()` rejects `partnerId` unless a matching `sk_*` is present. Profile assignment resolution ignores client-supplied partner fields. | -| **Assigned user becomes partner principal** | A profile assigned to a partner tries to read or mutate partner-owned quotes, ramps, webhooks, or history. | Assignment affects quote pricing only. It does not set `req.authenticatedPartner`; ownership guards still separate user-owned and partner-owned resources. | -| **Broken API-client compatibility** | Splitting pricing and owner fields accidentally makes SDK quotes user-owned or anonymous. | Existing partner-key and public-key request paths continue to populate `partner_id` as before. The new user-owned behavior is only used for server-resolved profile assignments. | -| **Dropped partner markup payout** | A profile-assigned quote computes a partner markup but downstream fee distribution looks only at `quote.partnerId`, sees `NULL`, and skips partner payout. | Fee distribution resolves payout from `pricing_partner_id ?? partner_id`. | -| **Dynamic state drift for the wrong principal** | A profile-assigned quote is consumed but dynamic discount state is decremented for no partner or the wrong partner. | Ramp registration resolves the partner from `pricing_partner_id ?? partner_id` before calling `handleQuoteConsumptionForDiscountState`. | -| **Stale assignment remains usable** | A profile's temporary partner entitlement expires but quote creation still applies custom rates. | Resolver filters out assignments with `expires_at <= now()`. | -| **Managed child receives unintended pricing** | A manager credential's partner attribution overrides a child's assignment, or direct-child and delegated requests resolve different defaults. | Both authentication paths use verified managed context and the same server-side precedence: active child assignment, active controlling-manager assignment, then default pricing. Credential partner attribution is suppressed for managed requests. | -| **Assignment changes after partner rename** | A partner row is renamed after assignment creation, and future quotes unexpectedly lose or change pricing. | Assignments persist `partner_id`; `partner_name` is display/audit only. | -| **Assignment to missing ramp-type config** | A profile is assigned to partner `Acme`, but `partner_pricing_configs` has only an active BUY config and the user requests SELL — or only configs scoped to other fiat corridors (e.g. MXN-only) and the user quotes BRL. | The `(partner_id, SELL)` pricing-config lookup (corridor-scoped first, wildcard fallback) returns nothing; resolver falls back to default pricing for that quote. | -| **Expired assignment shown as active** | Admin tooling lists an expired row as active, leading support to assume custom rates still apply. | Default list filtering uses the same active + unexpired predicate as quote resolution; `includeInactive=true` is the historical view. | -| **Unauthorized assignment management** | A partner or normal frontend user assigns themselves or another profile to a discounted partner. | Assignment management routes live under `/v1/admin/profile-partner-assignments` and require `adminAuth`. | -| **Partial assignment replacement** | Admin assignment creation deactivates the current row and then fails before inserting the replacement, leaving the profile with no active pricing assignment. Concurrent creates can also race against the active-user partial unique index. | Replacement runs in one transaction after locking the profile row. Rollback preserves the prior active assignment, and residual unique-index conflicts return `409 ASSIGNMENT_CONFLICT` so the admin can retry. | +| Threat | Attack Scenario | Mitigation | +| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **User spoofs partner discount** | A frontend user passes another partner's `partnerId` in the quote request body to claim better rates. | Existing `enforcePartnerAuth()` rejects `partnerId` unless a matching `sk_*` is present. Profile assignment resolution ignores client-supplied partner fields. | +| **Assigned user becomes partner principal** | A profile assigned to a partner tries to read or mutate partner-owned quotes, ramps, webhooks, or history. | Assignment affects quote pricing only. It does not set `req.authenticatedPartner`; ownership guards still separate user-owned and partner-owned resources. | +| **Broken API-client compatibility** | Splitting pricing and owner fields accidentally makes SDK quotes user-owned or anonymous. | Existing partner-key and public-key request paths continue to populate `partner_id` as before. The new user-owned behavior is only used for server-resolved profile assignments. | +| **Dropped partner markup payout** | A profile-assigned quote computes a partner markup but downstream fee distribution looks only at `quote.partnerId`, sees `NULL`, and skips partner payout. | Fee distribution resolves payout from `pricing_partner_id ?? partner_id`. | +| **Dynamic state drift for the wrong principal** | A profile-assigned quote is consumed but dynamic discount state is decremented for no partner or the wrong partner. | Ramp registration resolves the partner from `pricing_partner_id ?? partner_id` before calling `handleQuoteConsumptionForDiscountState`. | +| **Stale assignment remains usable** | A profile's temporary partner entitlement expires but quote creation still applies custom rates. | Resolver filters out assignments with `expires_at <= now()`. | +| **Managed child receives unintended pricing** | A manager credential's partner attribution overrides a child's assignment, or direct-child and delegated requests resolve different defaults. | Both authentication paths use verified managed context and the same server-side precedence: active child assignment, active controlling-manager assignment, then default pricing. Credential partner attribution is suppressed for managed requests. | +| **Assignment changes after partner rename** | A partner row is renamed after assignment creation, and future quotes unexpectedly lose or change pricing. | Assignments persist `partner_id`; `partner_name` is display/audit only. | +| **Assignment to missing ramp-type config** | A profile is assigned to partner `Acme`, but `partner_pricing_configs` has only an active BUY config and the user requests SELL — or only configs scoped to other fiat corridors (e.g. MXN-only) and the user quotes BRL. | The `(partner_id, SELL)` pricing-config lookup (corridor-scoped first, wildcard fallback) returns nothing; resolver falls back to default pricing for that quote. | +| **Expired assignment shown as active** | Admin tooling lists an expired row as active, leading support to assume custom rates still apply. | Default list filtering uses the same active + unexpired predicate as quote resolution; `includeInactive=true` is the historical view. | +| **Unauthorized assignment management** | A partner or normal frontend user assigns themselves or another profile to a discounted partner. | Assignment management routes live under `/v1/admin/profile-partner-assignments` and require `adminAuth`. | +| **Partial assignment replacement** | Admin assignment creation deactivates the current row and then fails before inserting the replacement, leaving the profile with no active pricing assignment. Concurrent creates can also race against the active-user partial unique index. | Replacement runs in one transaction after locking the profile row. Rollback preserves the prior active assignment, and residual unique-index conflicts return `409 ASSIGNMENT_CONFLICT` so the admin can retry. | ## Audit Checklist diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index c2b57241e..ef47a9a76 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -15,6 +15,7 @@ Quotes are the entry point for every ramp. A quote calculates the expected outpu The platform uses a per-partner dynamic pricing mechanism to adjust the offered rate based on partner quoting behavior. The system is designed to reward partners who quote-but-don't-convert (improving their rate) and slightly worsen the rate for partners who consistently convert (since the platform bears subsidization risk). **Key variables:** + - `deltaDBasisPoints` (config, default `0.3`) — The step size for each rate adjustment, in basis points. Converted to a decimal: `0.3 / 10000 = 0.00003`. - `discountStateTimeoutMinutes` (config, default `10`) — The inactivity window. If a partner's last quote is **older** than this timeout, the system considers it "inactive" and adjusts the rate on the next quote. - `targetDiscount` (per pricing config, DB) — The base discount rate from the active `partner_pricing_configs` row selected for the ramp direction and fiat corridor. @@ -26,11 +27,13 @@ The platform uses a per-partner dynamic pricing mechanism to adjust the offered The system maintains an **in-memory** `Map` called `partnerDiscountState`. The `stateKey` scopes each position by partner ID, ramp direction, and fiat corridor; wildcard pricing uses `*` as the corridor component. 1. **On each quote request** (`getAdjustedDifference`): + - If no state exists for the pricing scope → initialize with `difference = clamp(0, minDynamicDifference, maxDynamicDifference)` and return it. - If the last quote was **within** the timeout window → re-clamp the current `difference` to both current bounds and return it. - If the last quote was **outside** the timeout window (partner was quoting but not converting) → **increase** `difference` by `deltaD` (= `deltaDBasisPoints / 10000`), then clamp it to both current bounds. This **improves** the rate for the partner. 2. **On quote consumption** (ramp registration, `handleQuoteConsumptionForDiscountState`): + - If the last quote was **within** the timeout window → **decrease** `difference` by `deltaD`, clamped to both current bounds. This **worsens** the rate slightly. The `lastQuoteTimestamp` is set to `null`. - If the last quote was **outside** the timeout window → no change (the state already timed out). @@ -62,9 +65,9 @@ The refresh policy is intentionally strict. Onramps require byte-identical `toAm 2. **Each quote MUST be consumable exactly once** — After a quote is bound to a ramp, it MUST NOT be reusable for another ramp. This prevents a single favorable quote from being exploited multiple times. 3. **Quote amounts MUST be immutable after creation** — Once a quote is stored, its `inputAmount`, `outputAmount`, fee breakdown, and exchange rate MUST NOT be modifiable. The ramp uses these exact values. 4. **The quoted output amount MUST be the guaranteed minimum the user receives** — The platform is obligated to deliver the quoted output after fees. Automated subsidy caps are internal loss-containment and escalation thresholds, not exceptions to that obligation. If an automated cap is exceeded, the ramp MUST pause without underpaying or declaring successful completion and be reconciled through an authorized internal fulfillment path. An automatic deadline and refund of funds in transit are planned but not part of the current flow version; until then, the absence of a bounded automated terminal path is accepted as RISK-005, not a weakening of the amount guarantee. -5. **Fee calculations MUST be deterministic for the same inputs** — Given the same input amount, currencies, ramp direction, and fee configuration, the quote MUST produce the same fee breakdown. Non-deterministic fees create audit and reconciliation gaps. Note: the dynamic pricing adjustment (`difference`) adds intentional variability to the *rate*, not the *fees*. +5. **Fee calculations MUST be deterministic for the same inputs** — Given the same input amount, currencies, ramp direction, and fee configuration, the quote MUST produce the same fee breakdown. Non-deterministic fees create audit and reconciliation gaps. Note: the dynamic pricing adjustment (`difference`) adds intentional variability to the _rate_, not the _fees_. 6. **Quote validation MUST occur at ramp registration time** — When binding a quote to a ramp, the API MUST verify: quote exists, quote is not expired, quote is not already consumed, and the requesting user/partner is authorized to use it. -7. **Dynamic pricing `difference` MUST be clamped to partner bounds** — The `difference` value must never exceed `maxDynamicDifference` or fall below `minDynamicDifference`. Both functions (`getAdjustedDifference` and `handleQuoteConsumptionForDiscountState`) clamp to both bounds against the partner's *current* config on every call, so an admin range change takes effect on the next quote (fresh state also starts clamped, i.e. at `minDynamicDifference` when that is positive). If a misconfigured row has `min > max`, the max cap wins. +7. **Dynamic pricing `difference` MUST be clamped to partner bounds** — The `difference` value must never exceed `maxDynamicDifference` or fall below `minDynamicDifference`. Both functions (`getAdjustedDifference` and `handleQuoteConsumptionForDiscountState`) clamp to both bounds against the partner's _current_ config on every call, so an admin range change takes effect on the next quote (fresh state also starts clamped, i.e. at `minDynamicDifference` when that is positive). If a misconfigured row has `min > max`, the max cap wins. 8. **Dynamic pricing state MUST NOT be externally modifiable** — The `partnerDiscountState` Map is in-memory and module-private. No API endpoint should expose or allow modification of discount state. 9. **Exchange rates MUST be sourced from authoritative sources** — Swap rates must come from the actual DEX (Nabla) or routing protocol (Squid). Fiat forex rates are sourced from fastforex.io and, when CoinGecko is available, sanity-checked against CoinGecko's `usd-coin` fiat price. If fastforex is missing, unavailable, invalid, or outside the configured per-currency sanity band, CoinGecko is used as fallback. If fastforex returns a valid rate but CoinGecko is unavailable or invalid, the API logs the missing sanity check and accepts fastforex rather than making CoinGecko a hard dependency. Cached forex rates must stay within the configured short TTL. If no valid fiat rate provider remains, quote/conversion paths must fail closed rather than reusing the input amount or proceeding with an unverified rate. Operators must treat the CoinGecko fallback/reference as a USDC-as-USD proxy, not as pure fiat FX, during USDC depeg conditions. 10. **Subsidy MUST only be applied when `targetDiscount ≠ 0`** — If a partner has no target discount configured (`targetDiscount = 0`), the subsidy amount is always `0`, regardless of the shortfall. A negative base target is valid; the dynamic `difference` may still lift its adjusted target to or above the reference rate when the configured range allows. For AlfredPay SELL, a non-zero target is best effort only when the effective partner/runtime allowance is positive: a zero allowance skips the exact-output target probe and returns the fee-net baseline quote, while a positive binding cap lowers the quote itself and emits a warning. The final returned `quote.outputAmount` remains immutable and guaranteed by invariant 4. @@ -72,10 +75,10 @@ The refresh policy is intentionally strict. Onramps require byte-identical `toAm 12. **Quote creation MUST honor active maintenance windows server-side** — `POST /v1/quotes` and `POST /v1/quotes/best` must reject during active maintenance before quote calculation/persistence, including enough downtime metadata for direct API clients to retry after the window. 13. **Quote ownership MUST stay separate from pricing attribution** — Profile-assigned quotes MUST remain user-owned (`user_id = req.userId`, `partner_id = NULL`) while storing the applied partner pricing row in `pricing_partner_id`. 14. **Displayed discount MUST be a snapshot, not a live recomputation** — Public `QuoteResponse` discount fields MUST come from quote metadata captured at creation time. `GET /v1/quotes/:id` and ramp status responses MUST NOT recompute discount display amounts from live FX rates because quote economics are immutable after creation. -15. **Quote creation is anonymous-eligible for every corridor; provider *orders* MUST NOT carry placeholder identity** — Alfredpay quote requests carry the customer id only in the tracking-only `metadata` object; `resolveAlfredpayQuoteCustomerId` fills in the caller's real customer id when a canonically `approved` customer resolves, and the `"anonymous"` sentinel otherwise. Alfredpay *order* creation (at register/start) always goes through the strict, KYC-gated `resolveAlfredpayCustomerId` and never uses a placeholder. BRL/Avenia quote creation remains anonymous-eligible because Avenia quotes do not require a user-bound provider identity. **Register/start remains blocked for all corridors** without an effective user via route-level auth and the universal `RampService.registerRamp` check. +15. **Quote creation is anonymous-eligible for every corridor; provider _orders_ MUST NOT carry placeholder identity** — Alfredpay quote requests carry the customer id only in the tracking-only `metadata` object; `resolveAlfredpayQuoteCustomerId` fills in the caller's real customer id when a canonically `approved` customer resolves, and the `"anonymous"` sentinel otherwise. Alfredpay _order_ creation (at register/start) always goes through the strict, KYC-gated `resolveAlfredpayCustomerId` and never uses a placeholder. BRL/Avenia quote creation remains anonymous-eligible because Avenia quotes do not require a user-bound provider identity. **Register/start remains blocked for all corridors** without an effective user via route-level auth and the universal `RampService.registerRamp` check. 16. **Provider-backed ramp registration MUST derive the sender's provider identity from the effective user, not from request body** — BRL/Avenia tax ID, Alfredpay `alfredPayId`, and the Mykobo (EUR) `email` are resolved server-side from the credential context's `profileId` (or the Supabase session): `profile_id -> customer_entities -> provider_customers (avenia)`, `profile_id -> customer_entities -> provider_customers (alfredpay)`, and `profile_id -> profiles.email` respectively. The corresponding client-supplied field (`additionalData.taxId` / `additionalData.email`) is accepted only for backward compatibility and MUST match the derived sender value or the request is rejected with `400`. Every resolver requires canonical `provider_customers.status = approved`; provider-native state remains separately available in `status_external`. Null-email managed profiles are currently incompatible only with the disabled Mykobo rail and are rejected before provider calls; that accepted gap must be resolved before Mykobo is re-enabled for managed profiles. The `receiverTaxId` (where it differs from the sender — e.g. third-party PIX recipient) is supplied by the client and is allowed to differ from the derived sender tax ID; the Avenia payout registration hook passes it to block-owned `validateAveniaOfframpRecipient`, which compares it with the provider's masked PIX-owner tax ID and derives the payout wallet from the trusted subaccount response. The `RampService.registerRamp` quote/user consistency check ensures the caller cannot register a provider-backed quote using a different user context. 17. **User-owned quotes MUST only be registered by their owner; anonymous quotes MAY be claimed** — `RampService.registerRamp` rejects with `403` when `quote.userId` is set and differs from the authenticated caller. A quote created with an API credential also stores `api_credential_id`; secret-key registration MUST resolve that same credential ID, so another credential for the same profile or partner cannot consume it. A Supabase session for the owning profile remains valid. An anonymous quote (`quote.userId = null`) carries no owner and MAY be claimed by any authenticated caller — this is the normal web-app funnel (quote before login, register after). Claiming grants no access to anyone else's resources because provider identity is always derived from the claimer's own KYC records (inv. 16), never from the quote or the request body. -18. **Managed ramp operations MUST re-authorize the child, type, and corridor** — A manager-selected or directly credential-authenticated child's quote or ramp is authorized only when its `user_id` matches the verified managed child. Register, update, and start derive the corridor from persisted quote/ramp state and require that corridor in the active controlling manager configuration, require the child's immutable entity type under any current `allowedCustomerTypes` narrowing, and require the canonical corridor capability matrix to support that type. Quote creation and historical reads do not treat later policy narrowing as a denial. +18. **Managed ramp operations MUST re-authorize membership, child, type, corridor, and credential** — A member-selected or directly credential-authenticated child's quote or ramp is authorized only when its `user_id` matches the verified managed child. Selected-child Supabase bearer sessions cannot register, update, or start a ramp. A member-owned secret additionally requires a live `manager` membership; a credential-bound quote must be registered by that exact credential. Register, update, and start derive the corridor from persisted quote/ramp state and require that corridor in the immutable owner's active configuration, require the child's entity type under any current `allowedCustomerTypes` narrowing, and require the canonical corridor capability matrix to support that type. Quote creation and historical reads remain read-classified and do not treat later policy narrowing as a denial. 19. **Quote and ramp preparation MUST resolve the same persisted flow** — Registration resolves the catalog flow from `quote.metadata.globals.request`, calls that flow's `register` and `prepareTxs`, and transactionally persists metadata refreshed by registration hooks. No route resolver or corridor transaction assembler remains; registration MUST NOT select a different corridor from mutable input. Phase registration facts and response artifacts are projected into the compatibility `StateMetadata` / API response shape only for active ramps; provider operations remain owned by the resolved flow. 20. **Presigned Squid input MUST equal the quoted block input** — Cross-chain AlfredPay source and destination fallback transaction construction MUST use `metadata.blocks.squidRouterSwap.inputAmountRaw`. It MUST NOT substitute the gross AlfredPay mint amount, because fees and subsidy can make those values differ. 21. **Dashboard BUY quote direction MUST match the selected fiat rail and EVM destination** — Dashboard onramp requests set `from`/`paymentMethod` from the approved fiat corridor, `to` and `network` to the selected ramp-enabled EVM network, `inputCurrency`/`inputAmount` to the fiat payment, `outputCurrency` to the selected dynamic-catalog token key, and `rampType = BUY`. The displayed receive amount and registration quote ID must come from that server response, not client-side rate math. @@ -86,22 +89,22 @@ The refresh policy is intentionally strict. Onramps require byte-identical `toAm ## Threat Vectors & Mitigations -| Threat | Attack Scenario | Mitigation | -|---|---|---| -| **Stale quote exploitation** | Attacker creates a quote when rates are favorable, waits for rates to move against the platform, then registers a ramp at the old rate | Quote expiry (10-minute default, shortened to the earliest provider expiry); quote-time discount subsidy is bounded by partner `maxSubsidy`, and EVM post-swap runtime subsidy components are bounded by their own env-configured caps before funds move. | -| **Quote replay** | Attacker uses the same favorable quote ID for multiple ramps | One-time consumption: quote status is set to `"consumed"` on ramp registration; second attempt is rejected (`quote.status !== "pending"`) | -| **Quote manipulation** | Attacker modifies quote amounts in transit or in database | Quotes stored server-side; amounts calculated server-side from authoritative sources; client cannot override amounts | -| **Price oracle manipulation** | Attacker manipulates the DEX price before requesting a quote to get an artificially favorable rate | Use TWAP or multi-source pricing; bound acceptable deviation from reference rates; monitor for unusual quote patterns | -| **Dynamic pricing farming** | Attacker rapidly requests quotes without consuming them to push `difference` toward `maxDynamicDifference`, then consumes at the best possible rate | Each quote request within the timeout window does NOT change the difference — only quotes **after** the timeout increase it. So the attacker would need to wait `discountStateTimeoutMinutes` between each step increase. With default `deltaD = 0.00003` and a 10-minute timeout, farming is slow. However, the `maxDynamicDifference` cap is the hard limit. | -| **⚠️ In-memory state loss** | Server restart clears all partner discount states. Partners lose their accumulated rate adjustments. | **NO MITIGATION.** State is in-memory only. After restart, each pricing scope starts fresh at `clamp(0, current min, current max)` on its next quote. This could cause abrupt rate changes if a partner had a significant accumulated difference. | -| **Subsidization abuse** | Attacker creates quotes during high volatility, forcing the platform to cover large subsidization amounts | Quote-time discount subsidy is capped by `maxSubsidy` per partner; EVM runtime top-ups are separately bounded by the pre/post-swap cap fractions; dynamic pricing adjusts rates over time; `maxDynamicDifference` bounds the maximum rate improvement | -| **Unauthorized quote consumption** | Attacker binds someone else's quote to their own ramp | Quotes carrying an owner (`partner_id` or `user_id`) are bound to that owner; credential-originated quotes additionally bind `api_credential_id`, and secret registration must match it. Ownership is verified at ramp registration via `assertQuoteOwnership` and the `registerRamp` cross-user check (inv. 17). `pricing_partner_id` is not an ownership credential. Anonymous quotes carry no owner and are claimable, but claiming one only consumes a rate estimate — provider identity and funds routing derive from the claimer's own KYC records, so nothing belonging to another user can be reached. | -| **Pricing partner treated as owner** | A profile-assigned user receives partner pricing, then tries to access partner-owned quotes or ramps. | Profile assignments populate `pricing_partner_id` only; `partner_id` stays `NULL`, so ownership guards continue to authorize through the Supabase `user_id` path. | -| **Negative `minDynamicDifference`** | If `minDynamicDifference` is set to a large negative value in the partner DB record, consuming quotes could push the rate below the base `targetDiscount`, potentially making the effective discount negative (user receives less than the oracle rate) | DB constraint: `minDynamicDifference` defaults to `0`. However, there is no DB-level CHECK constraint preventing negative values. If set manually, the clamping logic would allow `difference` to go negative. | -| **Concurrent quote and consumption** | Two simultaneous requests — one quoting, one consuming — for the same partner could read stale `difference` values from the in-memory Map | JavaScript's single-threaded event loop prevents true concurrency for synchronous Map operations. However, the `async` functions in `compute()` could interleave if there are `await` points between reading and writing the Map. In practice, the read and write of `partnerDiscountState` in `getAdjustedDifference` are synchronous, so this is safe within a single process. | -| **Quote-output precision loss** | A quote targets an 18-decimal destination token but stores only 6 decimal places. The user-visible amount looks close, but final raw transfer construction under-delivers by the truncated dust amount. | Finalize EVM onramp quotes with destination token decimals when the final amount comes from Squid; tests should cover 6-decimal source → 18-decimal destination routes. | -| **Direct API quote creation during planned downtime** | A partner bypasses the UI maintenance banner and requests quotes directly while operators expect Vortex services to be unavailable. | Quote creation routes run the backend maintenance guard and return `503` with `Retry-After`, `maintenance_start`, and `maintenance_end` before any quote is persisted. | -| **USDC-as-USD fallback divergence** | CoinGecko's `usd-coin` fiat price diverges from true USD fiat FX during a USDC depeg. Healthy FastForex quotes may fail the sanity band when CoinGecko is available, or CoinGecko fallback may misprice quote/fee/subsidy math when FastForex fails. | FastForex remains primary when valid; CoinGecko sanity-check outages do not block FastForex. Operators should monitor spread warnings, missing-sanity-check warnings, and quote availability during stablecoin stress. See `05-integrations/fastforex.md`. | +| Threat | Attack Scenario | Mitigation | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Stale quote exploitation** | Attacker creates a quote when rates are favorable, waits for rates to move against the platform, then registers a ramp at the old rate | Quote expiry (10-minute default, shortened to the earliest provider expiry); quote-time discount subsidy is bounded by partner `maxSubsidy`, and EVM post-swap runtime subsidy components are bounded by their own env-configured caps before funds move. | +| **Quote replay** | Attacker uses the same favorable quote ID for multiple ramps | One-time consumption: quote status is set to `"consumed"` on ramp registration; second attempt is rejected (`quote.status !== "pending"`) | +| **Quote manipulation** | Attacker modifies quote amounts in transit or in database | Quotes stored server-side; amounts calculated server-side from authoritative sources; client cannot override amounts | +| **Price oracle manipulation** | Attacker manipulates the DEX price before requesting a quote to get an artificially favorable rate | Use TWAP or multi-source pricing; bound acceptable deviation from reference rates; monitor for unusual quote patterns | +| **Dynamic pricing farming** | Attacker rapidly requests quotes without consuming them to push `difference` toward `maxDynamicDifference`, then consumes at the best possible rate | Each quote request within the timeout window does NOT change the difference — only quotes **after** the timeout increase it. So the attacker would need to wait `discountStateTimeoutMinutes` between each step increase. With default `deltaD = 0.00003` and a 10-minute timeout, farming is slow. However, the `maxDynamicDifference` cap is the hard limit. | +| **⚠️ In-memory state loss** | Server restart clears all partner discount states. Partners lose their accumulated rate adjustments. | **NO MITIGATION.** State is in-memory only. After restart, each pricing scope starts fresh at `clamp(0, current min, current max)` on its next quote. This could cause abrupt rate changes if a partner had a significant accumulated difference. | +| **Subsidization abuse** | Attacker creates quotes during high volatility, forcing the platform to cover large subsidization amounts | Quote-time discount subsidy is capped by `maxSubsidy` per partner; EVM runtime top-ups are separately bounded by the pre/post-swap cap fractions; dynamic pricing adjusts rates over time; `maxDynamicDifference` bounds the maximum rate improvement | +| **Unauthorized quote consumption** | Attacker binds someone else's quote to their own ramp | Quotes carrying an owner (`partner_id` or `user_id`) are bound to that owner; credential-originated quotes additionally bind `api_credential_id`, and secret registration must match it. Ownership is verified at ramp registration via `assertQuoteOwnership` and the `registerRamp` cross-user check (inv. 17). `pricing_partner_id` is not an ownership credential. Anonymous quotes carry no owner and are claimable, but claiming one only consumes a rate estimate — provider identity and funds routing derive from the claimer's own KYC records, so nothing belonging to another user can be reached. | +| **Pricing partner treated as owner** | A profile-assigned user receives partner pricing, then tries to access partner-owned quotes or ramps. | Profile assignments populate `pricing_partner_id` only; `partner_id` stays `NULL`, so ownership guards continue to authorize through the Supabase `user_id` path. | +| **Negative `minDynamicDifference`** | If `minDynamicDifference` is set to a large negative value in the partner DB record, consuming quotes could push the rate below the base `targetDiscount`, potentially making the effective discount negative (user receives less than the oracle rate) | DB constraint: `minDynamicDifference` defaults to `0`. However, there is no DB-level CHECK constraint preventing negative values. If set manually, the clamping logic would allow `difference` to go negative. | +| **Concurrent quote and consumption** | Two simultaneous requests — one quoting, one consuming — for the same partner could read stale `difference` values from the in-memory Map | JavaScript's single-threaded event loop prevents true concurrency for synchronous Map operations. However, the `async` functions in `compute()` could interleave if there are `await` points between reading and writing the Map. In practice, the read and write of `partnerDiscountState` in `getAdjustedDifference` are synchronous, so this is safe within a single process. | +| **Quote-output precision loss** | A quote targets an 18-decimal destination token but stores only 6 decimal places. The user-visible amount looks close, but final raw transfer construction under-delivers by the truncated dust amount. | Finalize EVM onramp quotes with destination token decimals when the final amount comes from Squid; tests should cover 6-decimal source → 18-decimal destination routes. | +| **Direct API quote creation during planned downtime** | A partner bypasses the UI maintenance banner and requests quotes directly while operators expect Vortex services to be unavailable. | Quote creation routes run the backend maintenance guard and return `503` with `Retry-After`, `maintenance_start`, and `maintenance_end` before any quote is persisted. | +| **USDC-as-USD fallback divergence** | CoinGecko's `usd-coin` fiat price diverges from true USD fiat FX during a USDC depeg. Healthy FastForex quotes may fail the sanity band when CoinGecko is available, or CoinGecko fallback may misprice quote/fee/subsidy math when FastForex fails. | FastForex remains primary when valid; CoinGecko sanity-check outages do not block FastForex. Operators should monitor spread warnings, missing-sanity-check warnings, and quote availability during stablecoin stress. See `05-integrations/fastforex.md`. | ## Audit Checklist diff --git a/docs/security-spec/03-ramp-engine/recipient-transfers.md b/docs/security-spec/03-ramp-engine/recipient-transfers.md index ca5a82310..3248d4963 100644 --- a/docs/security-spec/03-ramp-engine/recipient-transfers.md +++ b/docs/security-spec/03-ramp-engine/recipient-transfers.md @@ -8,8 +8,9 @@ transfers (offramps) that pay out to that recipient. Backed by the migration-`04 (`recipient_invitations`, `sender_recipients`, `recipient_payout_references`), all anchored to `customer_entities`; migration-`050` added the sender-local `alias`, the retained raw `token`, and `archived_at` to `recipient_invitations` (dropping the unused `amount`). Routes live under -`/v1/recipients` (`recipients.controller.ts`) behind Supabase bearer authentication. Sender-side -routes additionally accept an authorized managed-child selector; preview and acceptance do not: +`/v1/recipients` (`recipients.controller.ts`). Sender-side routes accept Supabase bearer +authentication or a profile secret credential and may additionally use an authorized +managed-child selector; preview and acceptance remain bearer-only and do not: | Endpoint | Purpose | | :----------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------ | @@ -83,7 +84,7 @@ out against another tenant's relationship. 6. **All sender-side routes are entity-scoped.** Create/list/PATCH (relationship and invitation archive)/eligibility resolve the sender profile through `getEffectiveUserId` and filter on `sender_customer_entity_id`; for managed delegation this is the authorization-verified child, - while the authenticated manager remains the actor. Foreign ids + while the authenticated member remains the actor. Foreign ids return a uniform `404`. Entity resolution is deterministic: a partial unique index on `customer_entities (profile_id, type)` (migration 049) makes the acceptance-path `findOrCreate` race-safe, and `getOrCreateCustomerEntityForProfile` resolves type-less @@ -107,8 +108,8 @@ out against another tenant's relationship. AR company KYB), and senders with no approved onboarding anywhere (`403 NO_APPROVED_CORRIDOR`; approvals are read from `provider_customers.status`, which every provider persists). For delegated managed senders, malformed input retains the same `400` - errors before authorization, then the requested country must be in the manager's current - `allowedCorridors` and valid for the child's immutable type. The dashboard's corridor filter is + errors before authorization, then the actor must hold `manager`, the requested country must be + in the immutable owner's current `allowedCorridors`, and the child type must be permitted. The dashboard's corridor filter is a UX mirror of these rules, not the enforcement point. 10. **Sender self accounts are not recipient payout references.** For Alfredpay self offramps, the dashboard lists and creates provider-side fiat accounts owned by the authenticated sender and @@ -165,11 +166,12 @@ out against another tenant's relationship. instead of silently ignoring them. No eligibility response authorizes money movement. Enabling recipient payout requires a separately reviewed registration schema, ownership and eligibility enforcement, and provider-side payout-instrument resolution. -13. **Managed delegation is sender-only.** `POST /invite`, sender list, invitation archive, - relationship update/archive, and eligibility accept an authorized `X-Managed-Profile-Id` and - operate on the child's sender entity. Every delegated decision revalidates the active direct - relationship and the manager's current customer-type narrowing. Creation authorizes the - requested corridor; listing omits records outside the manager's current corridor policy; +13. **Managed delegation is sender-only and membership-scoped.** `POST /invite`, sender list, + invitation archive, relationship update/archive, and eligibility accept an authorized + `X-Managed-Profile-Id` and operate on the child's sender entity. Every delegated decision + revalidates the active membership and owner relationship. `read_only` may list and check + eligibility but cannot create or mutate recipient state. `manager` creation authorizes the + requested corridor; listing omits records outside the immutable owner's current policy; invitation archive, relationship update/archive, and eligibility first resolve an owner-scoped target and authorize its stored invitation country (or the relationship rail for retained legacy rows) before acting. A foreign target remains `404`, including when its corridor is diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 27a08402e..456a8f0a7 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -11,7 +11,7 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations **Verification outcome delivery:** Alfredpay exposes no verification webhook — `AlfredpayApiService` carries only request/response methods — so an outcome is only ever learned by polling `getKycStatus`/`getKybStatus`. `refreshAlfredpayCustomerStatus` owns that poll, persists the result, and queues the user's `verification_approved`/`verification_rejected` email; it runs both from the dashboard's status aggregation (TTL-throttled) and from `AlfredpayStatusWorker` (hourly) for users who never return. Alfredpay has no expiry status, so `verification_expired` is never produced for this provider. -**Verification collection:** MX and CO individual KYC and company KYB are submitted through the authenticated API flow. Company KYB requires tax ID, incorporation, and address documents plus the authorized representative's ID front and back. Company documents are keyed by `submissionId`; the representative's documents are keyed by an Alfredpay-generated `idRelatedPerson`, which only exists once the company record is created — the client therefore fetches it back via `GET /findKybCustomerAndBusiness` (`getKybBusinessDetails`) between the two upload steps. That endpoint returns *every* business the customer has, so the response carries each business's `submissionId` and the client selects the related persons of the submission it is filing. US individual and company verification use Alfredpay's hosted redirect flow. AR supports individual KYC only; the shared client state machine rejects `country = AR` with `business = true` before making any provider request and does not allow an AR individual flow to toggle to business. +**Verification collection:** MX and CO individual KYC and company KYB are submitted through the authenticated API flow. Company KYB requires tax ID, incorporation, and address documents plus the authorized representative's ID front and back. Company documents are keyed by `submissionId`; the representative's documents are keyed by an Alfredpay-generated `idRelatedPerson`, which only exists once the company record is created — the client therefore fetches it back via `GET /findKybCustomerAndBusiness` (`getKybBusinessDetails`) between the two upload steps. That endpoint returns _every_ business the customer has, so the response carries each business's `submissionId` and the client selects the related persons of the submission it is filing. US individual and company verification use Alfredpay's hosted redirect flow. AR supports individual KYC only; the shared client state machine rejects `country = AR` with `business = true` before making any provider request and does not allow an AR individual flow to toggle to business. **KYB requirement set (provider-defined, per country):** Alfredpay self-describes what a KYB submission must carry at `GET …/penny/kybRequirements?country=` (`MEX` and `MX` both resolve; every corridor answers). It is the source of truth: `sendKybSubmission` rejects a submission missing any required field with `110002 "Invalid field(s)"` naming them. Beyond the company/representative identity fields, it requires a compliance questionnaire (`walletAddresses`, `sourceOfFunds`, `transmitsCustomerFunds`, `operatesInSanctionedCountries`, `isRegulatedBusiness`, `businessActivities`, `accountPurpose`, `expectedMonthlyVolumeUsd`, `expectedMonthlyTransactions`) sent flat alongside the company fields and stored by Alfredpay nested under `questionnaire`, plus a fourth company document, `shareholderRegistry`. Two branches are conditional: `transmitsCustomerFunds = true` additionally requires `conductsComplianceScreening` (and `complianceScreeningDescription` when that is true), and `isRegulatedBusiness = true` additionally requires the `businessLicense` and `uploadAmlPolicy` documents. `pep` on the representative is required for CO/US/AR but not MX — the only field that differs between corridors, so the form always asks it. @@ -20,12 +20,14 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations **Stuck-submission recovery (PENDING):** Alfredpay reports a created-but-never-finalized (or invalid-data) submission as `PENDING`. It is not a rejection: status sync maps it to the canonical `pending` state (resumable in the dashboard), and `submitKybInformation` probes the last submission first — when it is `PENDING`/`CREATED`, the controller updates it in place via `PUT …/customers/kyb` (`updateKybInformation`) and returns the existing `submissionId` instead of POSTing a new submission, which Alfredpay refuses while one is pending (error `111405 "Customer KYB already exists"`; the controller also recovers from that POST error, but rechecks that Alfredpay still reports `PENDING`/`CREATED` before updating). Submission-id resolution (`resolveAlfredpayKybSubmissionId`) reconciles the persisted `kyc_cases.providerCaseId` with IDs from the last-submission endpoint and KYB details: a persisted ID is preferred only when Alfredpay still returns it, otherwise the first provider ID wins; the persisted ID is used alone whenever discovery yields no IDs (calls failing or answering empty — an empty last-submission response is not authoritative in sandbox). The latest observed Alfredpay `submissionId` is persisted by the submit, status, redirect-link, and retry paths so recovery survives the wizard closing. **Block phases:** + - `phases/blocks/phases/alfredpay-mint/` — On-ramp simulation, registration, start lifecycle, transaction preparation, and execution. The executor waits for Alfredpay payment settlement in the ephemeral's Polygon balance. - `phases/blocks/phases/alfredpay-offramp/` — Off-ramp simulation, registration, transaction preparation, user-hash validation, provider transfer, and expired-provider-quote recovery. - `phases/blocks/phases/subsidize-pre/` — Tops up the ephemeral's Alfredpay on-chain token balance on Polygon to the phase-owned subsidy target before routing. - `phases/blocks/phases/squid-router-swap/` — Cross-chain, same-chain swap, and explicit same-token passthrough blocks selected by the Alfredpay flow definitions. **On-ramp flow:** + 1. Quote stage emits `ctx.alfredpayOnramp` with provider `quoteId` (30s upstream TTL) and `ctx.subsidy` with the discount-engine target. 2. API-key-authenticated integration initiates on-ramp for a user with completed Alfredpay KYC → receives Alfredpay payment instructions. 3. User makes fiat payment. @@ -37,6 +39,7 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations For routed Alfredpay onramps (any non-passthrough output), the final quote output is the Squid destination-token amount. `quote.outputAmount` MUST be stored with the destination token's decimals, and `evmToEvm.outputAmountRaw` MUST preserve Squid's destination-token raw output. The Polygon-minted Alfredpay token remains the Squid source amount; the spec must not treat Polygon source-token decimals as final settlement precision. **Off-ramp flow:** + 1. The catalog `AlfredpayOfframp` block stores provider quote facts under `metadata.blocks.alfredpayOfframp` and returns the selected provider expiration as the Vortex quote TTL. Quote selection and registration require more than ten seconds of remaining provider-quote lifetime; created and replacement payout orders require at least two minutes before funds may move. Its `pricing` metadata records three separate observations: the source-labelled Vortex USD/fiat reference, Alfredpay's gross rate and fee-adjusted net rate, and the final customer all-in rate after Vortex pricing. The Vortex snapshot remains the reference source. For a non-zero target with a positive effective subsidy allowance, the block asks Alfredpay for the exact fiat `toAmount`, uses the returned USDT `fromAmount` to incorporate provider spread/fees, and caps the actual raw settlement top-up by partner `maxSubsidy` and the $10 runtime limit. With zero effective allowance it keeps the valid fee-net fixed-input quote and skips the fallible target probe. A positive binding cap returns a lower executable quote and logs `ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED`; naturally better provider pricing is returned with zero subsidy. Registration validates `fiatAccountId` and wallet address, resolves the authenticated KYC-approved customer, refreshes by the persisted provider input, compares `fromAmount`, `toAmount`, and fee exactly, updates only that block's `quoteId`/expiration, and creates the order transactionally. Drift hard-fails registration. 2. `squidRouterPermitExecute` or `squidRouterNoPermitTransfer/Approve/Swap` phase: executes the user-signed permit (or the no-permit equivalent) and lands the Alfredpay on-chain token on Polygon. 3. `finalSettlementSubsidy` phase: always runs for Alfredpay offramps because `AlfredpayOfframp` declares it between funding and provider transfer for every source variant. Delivery evidence waits for the persisted quoted bridge output; the phase then tops up to Alfredpay deposit PLUS the charged vortex/partner/network fee reserve so the later fee transfers stay funded, without exceeding the subsidy authorized by the quote. @@ -45,7 +48,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu **Request validation:** Alfredpay middleware (`alfredpay.middleware.ts`) validates the `country` parameter against the `AlfredPayCountry` enum for all Alfredpay-related requests. The country-prefixed aliases `/v1/mx/*`, `/v1/co/*`, and `/v1/ar/*` mount the same authenticated router as `/v1/alfredpay/*`; on those aliases, the path country is canonical and replaces any query or body country before validation and corridor authorization. The legacy `/v1/alfredpay/*` prefix remains available and continues to require the country in the request. -**Customer, KYC/KYB, and fiat-account routes:** These routes accept either a Supabase Bearer token or a user-scoped secret API key via `requirePartnerOrUserAuth()`. The controller resolves the effective profile so a manager-selected child or direct child credential uses the child's provider records. `GET /alfredpayStatus` accepts an optional `type` selector so headless business flows resolve the business customer explicitly; omitting it retains the active-entity lookup used by existing UI consumers. Managed-child mutations require the controlling manager's current country corridor, any non-null customer-type narrowing, and canonical corridor/type support. KYC/KYB action routes reject admin impersonation while status and business-detail reads remain available. Fiat-account creation and deletion deliberately remain available during admin impersonation as an accepted durable operator capability under RISK-018. Customer creation uses the child's immutable managed-profile contact email and provisioned entity type; it never inherits the manager's login email. Fiat-account PII is passed directly to Alfredpay and is not persisted in Vortex's database, browser storage, analytics, or notifications. Provider 4xx rejections are sanitized before reaching callers; provider 5xx and transport failures remain opaque. +**Customer, KYC/KYB, and fiat-account routes:** These routes accept either a Supabase Bearer token or a user-scoped secret API key via `requirePartnerOrUserAuth()`. The controller resolves the effective profile so a member-selected child or direct child credential uses the child's provider records. `read_only` memberships can use only status/business-detail/fiat-account reads; mutations require `manager`. `GET /alfredpayStatus` accepts an optional `type` selector so headless business flows resolve the business customer explicitly; omitting it retains the active-entity lookup used by existing UI consumers. Managed-child mutations require the immutable owner's current country corridor, any non-null customer-type narrowing, and canonical corridor/type support. KYC/KYB action routes reject admin impersonation while status and business-detail reads remain available. Fiat-account creation and deletion deliberately remain available during admin impersonation as an accepted durable operator capability under RISK-018. Customer creation uses the child's immutable managed-profile contact email and provisioned entity type; it never inherits the actor's login email. Fiat-account PII is passed directly to Alfredpay and is not persisted in Vortex's database, browser storage, analytics, or notifications. Provider 4xx rejections are sanitized before reaching callers; provider 5xx and transport failures remain opaque. ## Security Invariants @@ -65,7 +68,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 14. **`finalSettlementSubsidy` MUST NOT be skipped or used as its own delivery threshold for Alfredpay offramps** — `phases/blocks/phases/alfredpay-offramp/index.ts` declares `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` for every source variant. The arrival threshold is the persisted quoted bridge output; the settlement target is provider input plus canonical fee reserve. This ensures the Polygon ephemeral is topped up before provider settlement without waiting for the top-up itself, and observed bridge under-delivery cannot increase treasury funding above the quote's persisted subsidy plus the shortfall that same arrival threshold already accepts, nor above `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` in absolute terms. 15. **Routed Alfredpay onramp quote output precision MUST match the destination token** — For Alfredpay USD/MXN/COP/ARS onramps that route through Squid, `quote.outputAmount` MUST preserve the final destination token's decimal precision, and `evmToEvm.outputAmountRaw` MUST represent the destination token's raw units. The Polygon-minted Alfredpay token is only the Squid source-side input. Direct Polygon same-token passthrough remains at the minted token's 6-decimal precision. 16. **Alfredpay ramp registration MUST bind to a completed KYC/KYB customer** — `AlfredpayMint.register` and `AlfredpayOfframp.register` MUST reject customer records whose Alfredpay status is not `Success`. On-ramp registration stores only the verified customer ID as phase-owned facts; quote refresh, order creation, and payment instructions remain at start time. SDK/server integrations authenticate with partner API keys (`pk_*`/`sk_*`); Supabase Bearer tokens are frontend/user-session auth. -17. **Alfredpay ramp registration MUST derive the customer id from the effective user; quotes carry only tracking metadata** — The on-ramp and off-ramp flow registration hooks, on-ramp start-time quote refresh, and off-ramp transfer recovery path all resolve `alfredPayId` via the strict, KYC-gated `resolveAlfredpayCustomerId(fiatCurrency, effectiveUserId)`. Quote creation is anonymous-eligible: the quote blocks use `resolveAlfredpayQuoteCustomerId`, which fills the *tracking-only* quote `metadata.customerId` with the caller's real customer id when a KYC-completed customer resolves, and the `"anonymous"` sentinel otherwise. Alfredpay validates the top-level `customerId` only on order creation, so no provider *order* ever carries a placeholder identity. Public keys and unlinked secret keys can quote but cannot register Alfredpay ramps. +17. **Alfredpay ramp registration MUST derive the customer id from the effective user; quotes carry only tracking metadata** — The on-ramp and off-ramp flow registration hooks, on-ramp start-time quote refresh, and off-ramp transfer recovery path all resolve `alfredPayId` via the strict, KYC-gated `resolveAlfredpayCustomerId(fiatCurrency, effectiveUserId)`. Quote creation is anonymous-eligible: the quote blocks use `resolveAlfredpayQuoteCustomerId`, which fills the _tracking-only_ quote `metadata.customerId` with the caller's real customer id when a KYC-completed customer resolves, and the `"anonymous"` sentinel otherwise. Alfredpay validates the top-level `customerId` only on order creation, so no provider _order_ ever carries a placeholder identity. Public keys and unlinked secret keys can quote but cannot register Alfredpay ramps. 18. **`alfredpayOfframpTransfer` MUST verify the ephemeral's token balance before the first broadcast of the presigned transfer** — The presigned final transfer is single-use (its nonce is consumed even on revert), so the handler calls `ensurePresignedTransferFunded` before `sendRawTransaction`: sender/token/amount are decoded from the signed raw tx and the Polygon ephemeral balance is polled (3-minute timeout); a shortfall raises a recoverable error instead of burning the nonce. This complements invariant 14 (`finalSettlementSubsidy` ordering) by also catching capped/failed subsidies. See `03-ramp-engine/ramp-phase-flows.md` invariant 12. 19. **Argentina business onboarding MUST fail before provider access** — Alfredpay does not support AR company KYB. Client hosts using the shared Alfredpay machine MUST pass the account type in machine input; `AR + business` transitions directly to local failure without status, customer creation, or redirect requests. Account selectors MUST not offer the AR business combination. 20. **A provider `PENDING` submission MUST map to canonical `pending`, never to a decided state** — `PENDING` means the submission was never finalized or its data was invalid; treating it as `in_review`/`approved` would let un-reviewed due diligence advance, and treating it as `rejected` would dead-end a recoverable flow. Re-submission against a `PENDING`/`CREATED` submission MUST update it in place (`updateKybInformation`) rather than create a new one. @@ -79,32 +82,32 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 27. **The background verification sweep MUST be bounded, fair, and MUST NOT poll accounts it cannot notify** — `AlfredpayStatusWorker` costs two to three Alfredpay calls per account (submission-id resolution, then status). It MUST bound the sweep by account age (60 days on `provider_customers.updatedAt`, since an account abandoned mid-wizard never reaches a terminal status) and by batch size. A stable keyset cursor advances after every full page and wraps at the end; repeatedly selecting only the newest page would starve older eligible accounts. Entities with a null `profile_id` are partner-owned and have no profile to email; they MUST be excluded in the query so they never consume provider requests. Only the `mykobo` flow-variant backend owns the provider status workers, and each cron uses `waitForCompletion`, preventing duplicate cross-backend polls and overlapping same-process cycles. 28. **Alfredpay offramp pricing observations MUST remain source-labelled, while executable provider terms may reconcile the local SELL deposit** — The persisted block metadata records the exact Vortex reference-feed source and observation time, Alfredpay's returned gross rate and fee breakdown, the provider net rate derived from `toAmount ÷ fromAmount`, and the customer all-in rate derived from final fiat output divided by the USD-valued quote input. Alfredpay's rate MUST NOT become a general Vortex reference source. Its executable `fromAmount`/`toAmount` may be used only inside `AlfredpayOfframp` to solve or cap the provider deposit needed for the Vortex-derived customer target. 29. **Cross-manager email identity adoption is an accepted risk** — Contact-email uniqueness is scoped to one manager, but Alfredpay identifies customers by email. Two managers may therefore submit the same normalized email, and conflict recovery adopts Alfredpay's existing customer when country and type match without independently proving that the second manager controls that provider identity. This is explicitly accepted as [RISK-019](../RISK-REGISTER.md). Manager isolation, immutable manager-scoped email uniqueness, country/type matching, and local provider-ID uniqueness limit accidental attachment; global/provider-scoped ownership or provider claim proof is required before overlapping manager email namespaces are supported. -30. **The demo Alfredpay stand-in MUST be unreachable outside an opted-in sandbox** — `installDemoProviders` (`api/services/demo/demo-alfredpay.provider.ts`, called once at startup) replaces `AlfredpayApiService.getInstance` with canned in-process KYB responses that always approve. It returns without doing anything unless `DEMO_PROVIDER_ENABLED=true`, and throws when that flag is set with any `DEPLOYMENT_ENV` other than `sandbox`; `config/vars.ts` repeats the check at load time so the process refuses to start rather than serving a mixed configuration. The flag is off by default precisely because a sandbox also serves partner integration testing, which must exercise the real provider. Only the business-KYB surface is faked — individual (KYC) customer creation and every other Alfredpay method fall through to the real client, so an unimplemented path fails visibly instead of returning invented data. The stand-in fabricates provider *status* only; it never writes `provider_customers`, and the demo restore that consumes it is itself sandbox-guarded. See `docs/adr-0004-sandbox-demo-environment.md`. +30. **The demo Alfredpay stand-in MUST be unreachable outside an opted-in sandbox** — `installDemoProviders` (`api/services/demo/demo-alfredpay.provider.ts`, called once at startup) replaces `AlfredpayApiService.getInstance` with canned in-process KYB responses that always approve. It returns without doing anything unless `DEMO_PROVIDER_ENABLED=true`, and throws when that flag is set with any `DEPLOYMENT_ENV` other than `sandbox`; `config/vars.ts` repeats the check at load time so the process refuses to start rather than serving a mixed configuration. The flag is off by default precisely because a sandbox also serves partner integration testing, which must exercise the real provider. Only the business-KYB surface is faked — individual (KYC) customer creation and every other Alfredpay method fall through to the real client, so an unimplemented path fails visibly instead of returning invented data. The stand-in fabricates provider _status_ only; it never writes `provider_customers`, and the demo restore that consumes it is itself sandbox-guarded. See `docs/adr-0004-sandbox-demo-environment.md`. 31. **An Alfredpay SELL order MUST be `CREATED` before Vortex's first provider-bound transfer** — a pre-transfer `FAILED` response terminates the ramp without moving the user's USDT; `ON_CHAIN_DEPOSIT_RECEIVED`, `TRADE_COMPLETED`, or either fiat-transfer state without a confirmed/replayed local transfer indicates an unexplained external side effect and requires reconciliation. A confirmed local transfer journal is replayed before this mutable status check. ## Threat Vectors & Mitigations -| Threat | Attack Scenario | Mitigation | -|---|---|---| -| **Demo KYB stand-in reaching a real deployment** | An operator copies the sandbox env to staging or production, carrying `DEMO_PROVIDER_ENABLED=true`, and every Alfredpay KYB submission auto-approves without due diligence | The flag is off by default; `config/vars.ts` throws at load and `installDemoProviders` throws at startup when it is set outside `DEPLOYMENT_ENV=sandbox`, so the process fails to boot rather than approving anything. Covered by `demo-alfredpay.provider.test.ts`. | -| **Invalid country injection** | Attacker sends unsupported country code to bypass validation | `validateResultCountry` middleware checks against `AlfredPayCountry` enum; rejects invalid values with 400 | -| **Fiat payment spoofing (on-ramp)** | User claims payment without paying | Wait for Alfredpay payment confirmation; no token crediting without confirmation | -| **Permit replay (off-ramp)** | Attacker replays a previously-used SquidRouter permit | SquidRouter permits include nonces; the permit contract rejects replayed nonces | -| **Amount manipulation between subsidy and transfer** | Race condition modifies the balance between subsidy top-up and Alfredpay transfer | Both steps happen sequentially in the phase processor under a single ramp lock | -| **Alfredpay API compromise** | Attacker manipulates Alfredpay API responses | Validate response amounts against quote; HTTPS enforcement; monitor for discrepancies | -| **Multi-country regulatory complexity** | Different countries have different KYC/AML requirements | Country-specific validation at Alfredpay level; KYB vs KYC mapping branched by `AlfredpayCustomerType` | -| **Provider quote-quote-fall fallback abuse** | Attacker times provider quote drift between Vortex quote and ramp start to maximise the discount-engine fallback subsidy | Provider quote TTL is ~30s; `refreshAlfredpayOnrampQuoteIfMatching` only re-binds on byte-identical `toAmount`/`fee`; otherwise the fallback path is bounded by `maxSubsidy × expectedOutput` and only fires when `targetDiscount ≠ 0` | -| **Expired provider quote on offramp transfer** | A replacement quote degrades payout, changes the declared funding origin, or an ambiguous create retry duplicates provider orders | Re-quote the fixed provider input; require matching currencies/input and `fresh toAmount >= original toAmount`; keep `originAddress` bound to the EVM ephemeral; journal replacement creation and pause unknown outcomes. The Vortex `QuoteTicket` remains untouched. | -| **Offramp quote drift at prep time** | Market moves between quote creation and ramp registration; the refreshed Alfredpay offramp quote has different executable terms | Registration compares pair, chain when returned, `fromAmount`, `toAmount`, fee, and safe lifetime exactly; proven pre-order drift returns 422 `UNPROCESSABLE_ENTITY`, leaves the durable operation retryable, and requires a fresh provider quote. | -| **Offramp pricing source confusion** | Diagnostics present Alfredpay's executable rate as the general market reference, obscuring whether a difference comes from the reference feed, provider fees, or Vortex pricing | Persist separate source-labelled reference, provider gross/net, and customer all-in observations. The Vortex snapshot defines the target; Alfredpay terms only solve the local executable deposit. | -| **Unfundable AlfredPay SELL target** | Provider spread/fees make the configured target require more than partner/runtime subsidy limits or the provider's trade maximum | Cap the provider input to the lowest allowance that still covers the fee-net baseline, return the resulting executable payout, and emit `ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED` with target, delivered output, required/applied subsidy, and binding cap. A provider maximum below baseline rejects because no full-value fixed-input quote exists. | -| **Alfredpay offramp skipping subsidy** | An Alfredpay offramp reaches provider transfer without `finalSettlementSubsidy`, under-funding the settlement | The `AlfredpayOfframp` block declares subsidy before transfer for every source variant; flow tests pin the sequence | -| **Polygon passthrough rounding** | Same-chain same-token shortcut rounds the bridge output incorrectly, leaking dust or under-funding the destination | `toFixed(0, 0)` round-down in the squid-router finalize; downstream subsidy ensures the destination receives the quoted amount | -| **Polygon wrong-token delivery** | A user on-ramps via Alfredpay and requests a non-USDT Polygon output (e.g. USDC); the flow skips the swap on destination-network alone and transfers the minted USDT | `AlfredpayOnrampDirect` selects passthrough only for `ALFREDPAY_EVM_TOKEN`; non-USDT Polygon outputs compose `SameChainSquidRouterSwap` | -| **Routed destination precision loss** | A USD/MXN/COP/ARS Alfredpay onramp mints a 6-decimal Polygon source token, routes to an 18-decimal destination token, and stores the final quote output with source precision. The final amount is truncated before destination-transfer expectations are calculated. | Finalize routed Alfredpay EVM quotes with the destination token's decimals when `evmToEvm` metadata exists; keep direct Polygon same-token passthrough at minted-token precision. | -| **Anonymous Alfredpay quote/resource creation** | An SDK caller requests an Alfredpay quote without a linked user, hoping to create provider-side resources with placeholder customer identity | Alfredpay quotes are rate estimates: the customer id appears only in tracking-only quote `metadata` (`"anonymous"` sentinel for non-KYC'd callers). Provider *orders* — the only calls that create customer-bound resources — are created at registration, which requires an effective user with a `Success` Alfredpay customer via `resolveAlfredpayCustomerId`. | -| **Claiming an Alfredpay quote with a different user** | Attacker creates a quote under one linked user, then presents another Supabase token or linked secret key at register time | `RampService.registerRamp` rejects cross-user quote registration with `403`; Alfredpay customer lookup is performed from the effective user at quote and register time. | -| **Cross-manager email collision** | Two managers provision children with the same email and the later customer-creation request adopts an existing same-country/type Alfredpay identity | Manager-scoped immutability, country/type checks, and globally unique local provider IDs reduce accidental attachment, but do not prove provider ownership; accepted RISK-019 requires operationally disjoint email namespaces until an ownership control replaces adoption. | +| Threat | Attack Scenario | Mitigation | +| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Demo KYB stand-in reaching a real deployment** | An operator copies the sandbox env to staging or production, carrying `DEMO_PROVIDER_ENABLED=true`, and every Alfredpay KYB submission auto-approves without due diligence | The flag is off by default; `config/vars.ts` throws at load and `installDemoProviders` throws at startup when it is set outside `DEPLOYMENT_ENV=sandbox`, so the process fails to boot rather than approving anything. Covered by `demo-alfredpay.provider.test.ts`. | +| **Invalid country injection** | Attacker sends unsupported country code to bypass validation | `validateResultCountry` middleware checks against `AlfredPayCountry` enum; rejects invalid values with 400 | +| **Fiat payment spoofing (on-ramp)** | User claims payment without paying | Wait for Alfredpay payment confirmation; no token crediting without confirmation | +| **Permit replay (off-ramp)** | Attacker replays a previously-used SquidRouter permit | SquidRouter permits include nonces; the permit contract rejects replayed nonces | +| **Amount manipulation between subsidy and transfer** | Race condition modifies the balance between subsidy top-up and Alfredpay transfer | Both steps happen sequentially in the phase processor under a single ramp lock | +| **Alfredpay API compromise** | Attacker manipulates Alfredpay API responses | Validate response amounts against quote; HTTPS enforcement; monitor for discrepancies | +| **Multi-country regulatory complexity** | Different countries have different KYC/AML requirements | Country-specific validation at Alfredpay level; KYB vs KYC mapping branched by `AlfredpayCustomerType` | +| **Provider quote-quote-fall fallback abuse** | Attacker times provider quote drift between Vortex quote and ramp start to maximise the discount-engine fallback subsidy | Provider quote TTL is ~30s; `refreshAlfredpayOnrampQuoteIfMatching` only re-binds on byte-identical `toAmount`/`fee`; otherwise the fallback path is bounded by `maxSubsidy × expectedOutput` and only fires when `targetDiscount ≠ 0` | +| **Expired provider quote on offramp transfer** | A replacement quote degrades payout, changes the declared funding origin, or an ambiguous create retry duplicates provider orders | Re-quote the fixed provider input; require matching currencies/input and `fresh toAmount >= original toAmount`; keep `originAddress` bound to the EVM ephemeral; journal replacement creation and pause unknown outcomes. The Vortex `QuoteTicket` remains untouched. | +| **Offramp quote drift at prep time** | Market moves between quote creation and ramp registration; the refreshed Alfredpay offramp quote has different executable terms | Registration compares pair, chain when returned, `fromAmount`, `toAmount`, fee, and safe lifetime exactly; proven pre-order drift returns 422 `UNPROCESSABLE_ENTITY`, leaves the durable operation retryable, and requires a fresh provider quote. | +| **Offramp pricing source confusion** | Diagnostics present Alfredpay's executable rate as the general market reference, obscuring whether a difference comes from the reference feed, provider fees, or Vortex pricing | Persist separate source-labelled reference, provider gross/net, and customer all-in observations. The Vortex snapshot defines the target; Alfredpay terms only solve the local executable deposit. | +| **Unfundable AlfredPay SELL target** | Provider spread/fees make the configured target require more than partner/runtime subsidy limits or the provider's trade maximum | Cap the provider input to the lowest allowance that still covers the fee-net baseline, return the resulting executable payout, and emit `ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED` with target, delivered output, required/applied subsidy, and binding cap. A provider maximum below baseline rejects because no full-value fixed-input quote exists. | +| **Alfredpay offramp skipping subsidy** | An Alfredpay offramp reaches provider transfer without `finalSettlementSubsidy`, under-funding the settlement | The `AlfredpayOfframp` block declares subsidy before transfer for every source variant; flow tests pin the sequence | +| **Polygon passthrough rounding** | Same-chain same-token shortcut rounds the bridge output incorrectly, leaking dust or under-funding the destination | `toFixed(0, 0)` round-down in the squid-router finalize; downstream subsidy ensures the destination receives the quoted amount | +| **Polygon wrong-token delivery** | A user on-ramps via Alfredpay and requests a non-USDT Polygon output (e.g. USDC); the flow skips the swap on destination-network alone and transfers the minted USDT | `AlfredpayOnrampDirect` selects passthrough only for `ALFREDPAY_EVM_TOKEN`; non-USDT Polygon outputs compose `SameChainSquidRouterSwap` | +| **Routed destination precision loss** | A USD/MXN/COP/ARS Alfredpay onramp mints a 6-decimal Polygon source token, routes to an 18-decimal destination token, and stores the final quote output with source precision. The final amount is truncated before destination-transfer expectations are calculated. | Finalize routed Alfredpay EVM quotes with the destination token's decimals when `evmToEvm` metadata exists; keep direct Polygon same-token passthrough at minted-token precision. | +| **Anonymous Alfredpay quote/resource creation** | An SDK caller requests an Alfredpay quote without a linked user, hoping to create provider-side resources with placeholder customer identity | Alfredpay quotes are rate estimates: the customer id appears only in tracking-only quote `metadata` (`"anonymous"` sentinel for non-KYC'd callers). Provider _orders_ — the only calls that create customer-bound resources — are created at registration, which requires an effective user with a `Success` Alfredpay customer via `resolveAlfredpayCustomerId`. | +| **Claiming an Alfredpay quote with a different user** | Attacker creates a quote under one linked user, then presents another Supabase token or linked secret key at register time | `RampService.registerRamp` rejects cross-user quote registration with `403`; Alfredpay customer lookup is performed from the effective user at quote and register time. | +| **Cross-manager email collision** | Two managers provision children with the same email and the later customer-creation request adopts an existing same-country/type Alfredpay identity | Manager-scoped immutability, country/type checks, and globally unique local provider IDs reduce accidental attachment, but do not prove provider ownership; accepted RISK-019 requires operationally disjoint email namespaces until an ownership control replaces adoption. | ## Audit Checklist @@ -132,7 +135,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu - [x] AlfredPay offramp order is created by the block phase registration hook; `AlfredpayOfframp.start` retains the defensive validation-only no-op and is idempotent after registration. **PASS** — block lifecycle tests. - [x] Routed Alfredpay onramp quote output precision follows destination token decimals; direct Polygon same-token passthrough remains at minted-token precision. **PASS** — Alfredpay flow and transaction tests. - [x] Alfredpay onramp registration rejects missing customer context before customer lookup and requires a `Success` Alfredpay customer status. **PASS** — `phases/blocks/phases/alfredpay-mint/registration.ts`. -- [x] Alfredpay quote simulation resolves tracking-only `metadata.customerId` via `resolveAlfredpayQuoteCustomerId` (real id for KYC-completed users, `"anonymous"` sentinel otherwise); provider *orders* always resolve via strict `resolveAlfredpayCustomerId`. **PASS**. +- [x] Alfredpay quote simulation resolves tracking-only `metadata.customerId` via `resolveAlfredpayQuoteCustomerId` (real id for KYC-completed users, `"anonymous"` sentinel otherwise); provider _orders_ always resolve via strict `resolveAlfredpayCustomerId`. **PASS**. - [x] Fiat-account routes resolve the authenticated effective user's Alfredpay customer; the dashboard lists/adds sender self accounts without persisting raw bank-account fields locally, and registration carries only the selected provider `fiatAccountId`. **PASS**. - [x] Managed Alfredpay customer, KYC/KYB, and fiat-account routes resolve the verified child profile and enforce mutation corridor, customer-type narrowing, and canonical capability. Customer creation uses the child's immutable contact email, validates entity type before provider access, rejects conflicting provider country/type data, refuses to adopt a provider customer claimed by another profile, and refuses a country that differs between query string and body. **PASS**. - [ ] Replace email-based Alfredpay conflict adoption with cross-manager ownership proof before allowing overlapping manager email namespaces. **ACCEPTED RISK-019**. @@ -162,7 +165,7 @@ Alfredpay identity moved from `alfredpay_customers` (keyed by `user_id`) to migrated business customers invisible to every KYB endpoint and findOrCreate'd stray empty business entities as a side effect of reads. `createAlfredpayCustomer` homes a new row on the entity already carrying the profile's alfredpay rows of that `customer_type`, - preferring the *active* entity when such rows are split across entities (a pre-fix + preferring the _active_ entity when such rows are split across entities (a pre-fix duplicate can sit on a stray business entity) and falling back to the typed entity, so ramp registration — which resolves the active entity — keeps seeing every corridor of a migrated profile. `lookupAlfredpayCustomerType` keeps the type-ASC precedence diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index 17eaeb65b..1e7c9530b 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -11,6 +11,7 @@ BRLA is the Brazilian Real stablecoin used for BRL on/off-ramp operations, acces **Chain involved:** Base (BRLA is an ERC-20 on Base) **API route:** `/v1/brl/*`; the legacy `/v1/brla/*` prefix remains an equivalent alias during migration. Both prefixes use the same authentication, authorization, validation, and controller chain, including the auth-first `/kyc/import-token` mount. **Block phases:** + - `phases/blocks/phases/avenia-mint/` and `avenia-direct-mint/` — BRL quote simulation, registration, transaction preparation, and Base mint settlement. - `phases/blocks/phases/avenia-offramp-payout/` — Presigned BRLA transfer to the Avenia-controlled address and PIX payout execution. @@ -137,11 +138,11 @@ inbox, so a missing `status` or `updatedAt` is rejected `400` rather than queued Three distinct BRL amounts are involved in `brlaPayoutOnBase`. They are **intentionally different**: -| Amount | Source | Purpose | -|---|---|---| -| `brlaTransferAmountRaw` | `quote.metadata.nablaSwapEvm.outputAmountRaw` | On-chain ERC-20 transfer to Avenia's deposit address. Sends the **full Nabla swap output**. | -| `amountForPayout` (balance check) | `quote.metadata.nablaSwapEvm.outputAmountDecimal` | Sanity check that Avenia received the full deposit before initiating PIX. | -| `amountForQuote` (Avenia PIX payout) | `quote.outputAmount.round(2,0)` | The **net BRL the user receives via PIX**. Equals deposit minus Avenia anchor fee. | +| Amount | Source | Purpose | +| ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `brlaTransferAmountRaw` | `quote.metadata.nablaSwapEvm.outputAmountRaw` | On-chain ERC-20 transfer to Avenia's deposit address. Sends the **full Nabla swap output**. | +| `amountForPayout` (balance check) | `quote.metadata.nablaSwapEvm.outputAmountDecimal` | Sanity check that Avenia received the full deposit before initiating PIX. | +| `amountForQuote` (Avenia PIX payout) | `quote.outputAmount.round(2,0)` | The **net BRL the user receives via PIX**. Equals deposit minus Avenia anchor fee. | The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payout + anchor fee). If Nabla underdelivers, the balance-poll timeout fails the phase before any PIX is attempted. @@ -172,14 +173,14 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 23. **BRL Base destination variants MUST use token-specific static topology** — Base USDC MUST omit Squid entirely. Other configured non-BRLA Base outputs MUST execute exactly one same-chain `squidRouterSwap` phase before `destinationTransfer`; transaction preparation MUST use the Base builder, omit `squidRouterPay` and backup transactions, and allocate `destinationTransfer` at the nonce immediately after the Squid swap. BRLA remains the direct bypass in invariant 14. 24. **Dashboard BRL BUY confirmation MUST not bypass PIX verification** — The dashboard displays the server-generated `depositQrCode`, keeps the ramp unstarted, and calls `/ramp/start` only after the user confirms submitting PIX. That click is not proof of settlement; `brlaOnrampMint` must still verify the Avenia/Base balance before advancing. 25. **Unified BRL limit reads MUST use the authenticated user's provider account** — `POST /v1/limits` MUST derive the Avenia subaccount through `resolveAveniaAccountForUser`; it MUST NOT accept a caller-supplied tax ID or subaccount. BRL `max`, `used`, year, and month are mapped directly from Avenia's BRL fiat-in/fiat-out limit row. Tax IDs and provider subaccount IDs are never returned. -26. **Managed BRLA operations MUST remain child-, type-, and corridor-scoped** — Supported customer, KYC/KYB, and onboarding-status routes may derive the effective user from a verified manager selector or direct child credential. Mutating provider/KYC operations require the controlling manager's current `BR` corridor, any current manager customer-type narrowing, and canonical `BR` support for the child's immutable entity type; status and account reads preserve access after policy removal. Tax IDs and subaccount IDs still require ownership through the child's customer entities. Subaccount creation MUST reject a requested account type that differs from the child's provisioned customer-entity type before calling Avenia, preventing a managed child from acquiring a second entity. Individual document-upload and KYC-submission routes MUST require an individual managed profile, and their controllers MUST independently reject a non-individual owned Avenia row before any provider call. Admin impersonation is rejected before route-level validation, controller execution, and provider access; token import additionally rejects before its route-local JSON parser. `GET /v1/brla/validatePixKey` remains an anonymous preflight utility rather than a managed-child operation: presenting a credential grants no capability unavailable to an anonymous caller, so managed corridor policy does not apply to it. +26. **Managed BRLA operations MUST remain child-, membership-, type-, and corridor-scoped** — Supported customer, KYC/KYB, and onboarding-status routes may derive the effective user from a verified member selector or direct child credential. `read_only` membership permits only read-classified status/account routes; provider and KYC mutations require `manager`. Mutations enforce the immutable owner's current `BR` corridor, current customer-type narrowing, and canonical `BR` support for the child's immutable entity type; status and account reads preserve access after policy removal. Tax IDs and subaccount IDs still require ownership through the child's customer entities. Subaccount creation MUST reject a requested account type that differs from the child's provisioned customer-entity type before calling Avenia, preventing a managed child from acquiring a second entity. Individual document-upload and KYC-submission routes MUST require an individual managed profile, and their controllers MUST independently reject a non-individual owned Avenia row before any provider call. Admin impersonation is rejected before route-level validation, controller execution, and provider access; token import additionally rejects before its route-local JSON parser and rechecks the exact manager membership at its provider boundary. `GET /v1/brla/validatePixKey` remains an anonymous preflight utility rather than a managed-child operation: presenting a credential grants no capability unavailable to an anonymous caller, so managed corridor policy does not apply to it. 27. **Avenia API KYB mutations MUST be ownership-bound and document-gated** — `/v1/brla/kyb/documents`, `/v1/brla/kyb/ubos`, and `/v1/brla/kyb/new-level-1/api` accept Supabase sessions or profile-bound secret API credentials. Every operation resolves the supplied subaccount to an Avenia business `provider_customers` row owned by one of the effective profile's customer entities before calling Avenia. Document-target creation validates Avenia's response against the requested kind: ordinary documents require a pre-signed `uploadURLFront`, while `SELFIE-FROM-LIVENESS` requires `livenessUrl` and `validateLivenessToken` and has no binary upload target. UBO identification/selfie documents and final-submission corporate documents are fetched from that same subaccount and must be provider-ready with the expected document type. Binary document bytes are uploaded directly to Avenia's short-lived pre-signed URL; Vortex does not proxy or persist them. 28. **Avenia API KYB retries MUST reconcile an active provider attempt before creating another** — A successful API submission binds the returned attempt ID to the existing KYB case, sets both canonical rows to `pending`, records external `PENDING`, and clears prior rejection fields. After the POST, the binding transaction locks and rereads the provider customer before the exact case. If concurrent reconciliation already bound the returned attempt, its newer pending, processing, or terminal state remains unchanged; a different concurrent attempt binding fails closed. Before the POST, Vortex lists attempts through the already ownership-verified business account's `provider_subaccount_id`. Exactly one company-level attempt in `PENDING` or `PROCESSING` is transactionally bound to the case and mirrored to both canonical rows, and the endpoint returns that attempt ID without another POST. Company-level names are matched as a family — legacy `level-1` plus every `kyb-level-1` generation (currently `kyb-level-1-v2`) — because Avenia renames the level across generations and an exact-name filter silently stops matching; other level names are excluded, and the nightly Avenia contract test pins the observed family. The same reconciliation runs after a definitive provider `409`. Zero active attempts after a conflict, multiple active attempts, malformed responses, and terminal attempts fail closed; unrelated provider and transport errors are propagated. When no active attempt exists during preflight, terminal attempt retry eligibility remains Avenia's decision on the single subsequent POST. 29. **The Avenia webhook MUST reject any body whose RSA-PSS signature does not verify** — Verification runs against the raw request bytes before the payload is parsed or any lookup happens. An absent `Signature` header, a non-buffer body, or a failed verify MUST return 401 and MUST NOT enqueue anything. 30. **The Avenia webhook MUST NOT mutate ramp, quote, or verification state** — Its only effect is an `email_notifications` row. A forged or replayed event therefore cannot advance a ramp, approve a user, or move funds; the worst case is a duplicate-suppressed email. 31. **Webhook-triggered emails MUST remain idempotent under replay** — Avenia's signature carries no timestamp or nonce, so replay is not prevented at the transport level. It is neutralised by the `(provider, type, resource_id)` unique index keyed on the Avenia attempt id: a replayed event, or a poll racing a webhook, cannot produce a second email. 32. **Public-key refetches on a signature miss MUST be bounded** — The route is unauthenticated, so any caller can force a miss. Refetches are coalesced into one in-flight request, rate-limited to one per 30-second cooldown, and aborted after 10 seconds; a miss inside the cooldown is rejected without an outbound call. Key rotation is still picked up (within the cooldown), but forged bodies cannot be amplified into load on Avenia or leave a verifier waiting indefinitely. -33. **The webhook body MUST be runtime-validated before any property is read** — A valid signature proves only that Avenia sent the bytes. `JSON.parse` alone admits `null`, arrays, scalars, and attempts missing the fields an email is rendered from, so the receiver accepts Avenia's two documented envelopes (top-level `subAccountId` or nested `event.accountId`), normalizes them, and validates the account id plus `subscription` and, when one is present, the attempt (`id`, `status`, `updatedAt` as non-empty strings; `result` and `resultMessage` as strings when present) before the first property access or database lookup. Anything failing that returns a deterministic `400` and enqueues nothing. An unrecognised *value* of `status` or `result` is not a validation failure: it is a well-formed event with no email mapped to it, and is acknowledged `200` so Avenia does not retry it indefinitely. +33. **The webhook body MUST be runtime-validated before any property is read** — A valid signature proves only that Avenia sent the bytes. `JSON.parse` alone admits `null`, arrays, scalars, and attempts missing the fields an email is rendered from, so the receiver accepts Avenia's two documented envelopes (top-level `subAccountId` or nested `event.accountId`), normalizes them, and validates the account id plus `subscription` and, when one is present, the attempt (`id`, `status`, `updatedAt` as non-empty strings; `result` and `resultMessage` as strings when present) before the first property access or database lookup. Anything failing that returns a deterministic `400` and enqueues nothing. An unrecognised _value_ of `status` or `result` is not a validation failure: it is a well-formed event with no email mapped to it, and is acknowledged `200` so Avenia does not retry it indefinitely. 34. **A provider-confirmed paid initial PIX ramp on a runtime-enabled flow MUST be recoverable without current managed-profile authorization** — The client start deadline and current managed corridor/type policy continue to govern public update/start calls. The unhandled-payment worker separately compares the ramp's exact persisted Avenia ticket with the provider's `PAID` tickets. Runtime-enabled initial ramps remain pollable through the worker's three-day age window when the ticket is absent, has an unknown/non-paid status, or carries a historical unhandled-payment alert flag. When a signed, still-`initial` ramp is paid, the worker starts the persisted flow under a row lock without applying the expired client deadline or re-authorizing the now-committed manager policy. Only successful recovery suppresses later worker cycles; a failed automatic attempt remains eligible and alerts operations. Moonbeam-dependent AssetHub flows are excluded under RISK-020: the worker does not poll, recover, or alert on them, and operations must reconcile them manually. Startup compatibility checks retain the ramp's persisted flow version while such a payable ticket exists. 35. **Ramp updates MUST NOT modify persisted Avenia recovery identity** — `POST /v1/ramp/update` accepts only the documented client-reported transaction-hash fields in `additionalData`. It rejects every other key with `400`, including the registration-owned `taxId`, `subAccountId`, `aveniaTicketId`, and nested `blockState`. The unhandled-payment worker therefore compares paid provider tickets against the immutable identity snapshotted by Avenia registration. 36. **KYC preflight MUST NOT reserve a client-asserted tax identity** — `POST /v1/brla/kyc/record-attempt` may validate authentication, managed BR authorization, quote ownership, and the BRL corridor for compatibility, but MUST NOT create a `provider_customers` or `kyc_cases` row from its client-supplied CPF/CNPJ. Quote ownership proves only quote ownership. The globally unique Avenia tax hash is persisted only by the authenticated subaccount creation flow that establishes the canonical provider account. @@ -197,32 +198,32 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou ## Threat Vectors & Mitigations -| Threat | Attack Scenario | Mitigation | -|---|---|---| -| **PIX payment spoofing (on-ramp)** | Attacker claims PIX payment was made without actually paying | System polls Base RPC for actual BRLA arrival; never trusts user claim. | -| **Tax ID fraud** | Attacker uses someone else's CPF to receive off-ramp payouts | Tax ID validation is Avenia's responsibility at KYC level; Vortex passes through validated data only. | -| **Double payout (off-ramp)** | Bug causes `createPixOutputTicket` to be called twice for the same ramp | (a) Phase processor's per-ramp lock prevents concurrent execution; (b) `payOutTicketId` recovery branch skips re-issue; (c) `brlaPayoutTxHash` recovery branch skips re-broadcast. | -| **Double on-chain transfer** | Crash between sending the BRLA transfer and storing the hash | Handler stores `brlaPayoutTxHash` only after the receipt. On retry, if no hash is stored, the same presigned tx is re-broadcast — EVM nonce uniqueness prevents double-spend. | -| **Avenia API compromise** | Attacker intercepts or manipulates Avenia API calls | HTTPS enforced; balance verified on-chain against deposit; PIX amount derived from immutable quote. | -| **Amount manipulation between quote and payout** | Attacker modifies the payout amount between quote and execution | `quote.outputAmount` read from DB at execution time; quote is immutable post-creation. | -| **Avenia service outage or partial ticket failure** | Avenia API is unreachable mid-ramp, or a ticket reaches `PARTIAL-FAILED` after one leg completed and a later leg failed | `RecoverablePhaseError` → phase processor retries transient outages. `PARTIAL-FAILED` must be treated as ticket-specific failure with prior completed legs preserved; callers may retry only after reconciling source/destination balances. | -| **Subaccount data leak** | Avenia subaccount details exposed via API | Canonical `provider_customers` stores the provider subaccount ID and normalized tax reference; account reads are scoped through the owning customer entity. | -| **Underdelivery from Nabla** | Nabla swap returns less BRLA than quoted, balance poll times out, ramp stuck | Balance-poll timeout (5min) fails the phase as recoverable; `subsidizePostSwap` (EVM branch) tops up eligible shortfalls subject to the env-configured split quote-relative EVM subsidy caps documented in `fund-routing.md`. The actual-vs-quoted swap discrepancy is capped at the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` × quote output; the discount component uses `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` (no floor). Both fractions default to `0.05`. | -| **Disabled AssetHub corridor accidentally re-enabled** | A developer mistakes either cataloged flow for an eligible production corridor | Quote eligibility rejects both directions, registration/start return unavailable, and phase processing holds persisted Moonbeam-dependent state before executor access. Flow and transaction tests may resolve definitions directly, but any runtime execution is a regression until the RISK-020 exit criteria are met. | -| **BRL→BRLA-Base self-swap drain** | The generic pipeline swaps the user's already-minted BRLA to USDC and back, charging two swaps of slippage/fees and triggering `finalSettlementSubsidy` against bridge-less dust (over-subsidy + strand) | `isBrlToBrlaBaseDirect` collapses the corridor to a single `destinationTransfer` with `isDirectTransfer = true`; Nabla/distributeFees/Squid/finalSettlementSubsidy/cleanup are skipped at both route-build and handler level. | -| **Anonymous BRL register on someone else's subaccount** | An anonymous SDK caller (no Supabase session, no linked secret key) uses an anonymous BRL quote to register a ramp on top of another user's Avenia subaccount via the quoteId guess | `RampService.registerRamp` rejects provider-backed ramps without an effective user with `400 Invalid quote`; an attacker cannot bind a BRL ramp to a subaccount they do not own. | -| **Claiming an anonymous BRL estimate at register time** | Attacker mints an anonymous BRL quote, then presents a Supabase token (or a different user's linked secret API key) at register time to bind the resulting ramp to a different user's Avenia provider customer | An authenticated caller may claim an ownerless quote; `RampService.registerRamp` rejects only when both `quote.userId` and `request.userId` are non-null and differ. Provider identity is derived from the authenticated caller's canonical Avenia account, so the quote cannot select another user's provider customer. | -| **Forged verification webhook** | Attacker posts a fabricated `verification_approved` event for another user's subaccount | Body must carry a valid RSA-PSS signature from Avenia's published key; unsigned or mis-signed bodies are rejected 401 before any DB lookup. Even a valid event only enqueues an email — it grants no entitlement. | -| **Webhook replay** | Attacker re-sends a captured, correctly-signed event repeatedly | Avenia provides no timestamp or nonce to check. Enqueue is idempotent on the attempt id, so replays collapse to a no-op; no email amplification is possible. | -| **Key-rotation denial of service** | Avenia rotates the signing key; genuine events start failing verification | Key is fetched, never pinned; a failed verify against the cached key triggers exactly one refetch before rejection, so rotation self-heals within one request. | -| **Unknown-subaccount probing** | Attacker uses signed events to enumerate which subaccounts Vortex knows | Requires a valid Avenia signature, so it is not reachable by an external attacker; responses are an identical `200 {received:true}` for known, unknown, and partner-owned subaccounts. | -| **Destination-token decimal under-delivery** | A BRL on-ramp targets an 18-decimal token such as BSC USDT, but the quote output is truncated to 6 decimals before `destinationTransfer` raw amount construction. | On-ramp finalization uses destination-token decimals for BRL EVM outputs; Squid metadata preserves destination raw output from `route.estimate.toAmount`. | -| **Company KYB status bypass or cross-user attempt lookup** | A browser asserts that hosted verification finished, or probes another user's Avenia attempt ID and receives provider submission metadata. | Initiation binds the attempt to the authenticated user's KYB case; every exact lookup checks that binding and scopes the provider request with the owning subaccount before the call, rejects a mismatched response ID, minimizes its response, and accepts only provider-confirmed `COMPLETED` + `APPROVED`. | -| **Duplicate KYB attempt while provider processing is active** | A caller starts another API or hosted KYB attempt while Avenia is already processing one for the company. | Both creation paths list attempts for the ownership-verified subaccount. Hosted creation rejects an active attempt. API creation transactionally binds exactly one active attempt and returns it without POSTing; ambiguous multiple-active results fail closed. A provider `409` triggers the same scoped re-query and exact-one reconciliation. Terminal attempts are left to Avenia's retry rules. | -| **Tax-ID reservation through KYC preflight** | An authenticated attacker owns a BRL quote but submits a victim's valid CPF/CNPJ to the initial-attempt endpoint, attempting to occupy the globally unique Avenia tax hash. | The endpoint retains its empty compatibility response and quote checks but performs no identity persistence. Only canonical subaccount creation may create the globally reserving provider-customer row. | -| **Share-token replay or ambiguous duplicate import** | A timeout or malformed provider response causes the caller to resend a bearer-like identity-transfer token, potentially creating multiple attempts or transferring data twice. | A durable pre-send claim and token digest serialize submission. Same-key/same-token retries may reconcile through provider reads without another POST or token send. Deterministic provider rejections created no attempt, so they are failed/retriable with a new key; other unresolved outcomes remain quarantined and are never automatically replayed. | -| **Share-token disclosure** | Request/error logging, telemetry, provider errors, or support tooling captures the token and enables unauthorized identity-data transfer. | Sensitive-body provider mode, flat sanitized errors, strict observability exclusion, request-memory-only handling, and digest-only persistence prevent raw-token persistence or emission. | -| **KYC completion spoofing** | A caller treats token possession, import acceptance, a Sumsub result, or a webhook as proof of approval. | The canonical case binds one exact Avenia attempt; only exact polling of Avenia `COMPLETED + APPROVED` can approve. `EXPIRED` remains non-approved and pending reconciliation, and the webhook is notification-only. | +| Threat | Attack Scenario | Mitigation | +| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **PIX payment spoofing (on-ramp)** | Attacker claims PIX payment was made without actually paying | System polls Base RPC for actual BRLA arrival; never trusts user claim. | +| **Tax ID fraud** | Attacker uses someone else's CPF to receive off-ramp payouts | Tax ID validation is Avenia's responsibility at KYC level; Vortex passes through validated data only. | +| **Double payout (off-ramp)** | Bug causes `createPixOutputTicket` to be called twice for the same ramp | (a) Phase processor's per-ramp lock prevents concurrent execution; (b) `payOutTicketId` recovery branch skips re-issue; (c) `brlaPayoutTxHash` recovery branch skips re-broadcast. | +| **Double on-chain transfer** | Crash between sending the BRLA transfer and storing the hash | Handler stores `brlaPayoutTxHash` only after the receipt. On retry, if no hash is stored, the same presigned tx is re-broadcast — EVM nonce uniqueness prevents double-spend. | +| **Avenia API compromise** | Attacker intercepts or manipulates Avenia API calls | HTTPS enforced; balance verified on-chain against deposit; PIX amount derived from immutable quote. | +| **Amount manipulation between quote and payout** | Attacker modifies the payout amount between quote and execution | `quote.outputAmount` read from DB at execution time; quote is immutable post-creation. | +| **Avenia service outage or partial ticket failure** | Avenia API is unreachable mid-ramp, or a ticket reaches `PARTIAL-FAILED` after one leg completed and a later leg failed | `RecoverablePhaseError` → phase processor retries transient outages. `PARTIAL-FAILED` must be treated as ticket-specific failure with prior completed legs preserved; callers may retry only after reconciling source/destination balances. | +| **Subaccount data leak** | Avenia subaccount details exposed via API | Canonical `provider_customers` stores the provider subaccount ID and normalized tax reference; account reads are scoped through the owning customer entity. | +| **Underdelivery from Nabla** | Nabla swap returns less BRLA than quoted, balance poll times out, ramp stuck | Balance-poll timeout (5min) fails the phase as recoverable; `subsidizePostSwap` (EVM branch) tops up eligible shortfalls subject to the env-configured split quote-relative EVM subsidy caps documented in `fund-routing.md`. The actual-vs-quoted swap discrepancy is capped at the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` × quote output; the discount component uses `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` (no floor). Both fractions default to `0.05`. | +| **Disabled AssetHub corridor accidentally re-enabled** | A developer mistakes either cataloged flow for an eligible production corridor | Quote eligibility rejects both directions, registration/start return unavailable, and phase processing holds persisted Moonbeam-dependent state before executor access. Flow and transaction tests may resolve definitions directly, but any runtime execution is a regression until the RISK-020 exit criteria are met. | +| **BRL→BRLA-Base self-swap drain** | The generic pipeline swaps the user's already-minted BRLA to USDC and back, charging two swaps of slippage/fees and triggering `finalSettlementSubsidy` against bridge-less dust (over-subsidy + strand) | `isBrlToBrlaBaseDirect` collapses the corridor to a single `destinationTransfer` with `isDirectTransfer = true`; Nabla/distributeFees/Squid/finalSettlementSubsidy/cleanup are skipped at both route-build and handler level. | +| **Anonymous BRL register on someone else's subaccount** | An anonymous SDK caller (no Supabase session, no linked secret key) uses an anonymous BRL quote to register a ramp on top of another user's Avenia subaccount via the quoteId guess | `RampService.registerRamp` rejects provider-backed ramps without an effective user with `400 Invalid quote`; an attacker cannot bind a BRL ramp to a subaccount they do not own. | +| **Claiming an anonymous BRL estimate at register time** | Attacker mints an anonymous BRL quote, then presents a Supabase token (or a different user's linked secret API key) at register time to bind the resulting ramp to a different user's Avenia provider customer | An authenticated caller may claim an ownerless quote; `RampService.registerRamp` rejects only when both `quote.userId` and `request.userId` are non-null and differ. Provider identity is derived from the authenticated caller's canonical Avenia account, so the quote cannot select another user's provider customer. | +| **Forged verification webhook** | Attacker posts a fabricated `verification_approved` event for another user's subaccount | Body must carry a valid RSA-PSS signature from Avenia's published key; unsigned or mis-signed bodies are rejected 401 before any DB lookup. Even a valid event only enqueues an email — it grants no entitlement. | +| **Webhook replay** | Attacker re-sends a captured, correctly-signed event repeatedly | Avenia provides no timestamp or nonce to check. Enqueue is idempotent on the attempt id, so replays collapse to a no-op; no email amplification is possible. | +| **Key-rotation denial of service** | Avenia rotates the signing key; genuine events start failing verification | Key is fetched, never pinned; a failed verify against the cached key triggers exactly one refetch before rejection, so rotation self-heals within one request. | +| **Unknown-subaccount probing** | Attacker uses signed events to enumerate which subaccounts Vortex knows | Requires a valid Avenia signature, so it is not reachable by an external attacker; responses are an identical `200 {received:true}` for known, unknown, and partner-owned subaccounts. | +| **Destination-token decimal under-delivery** | A BRL on-ramp targets an 18-decimal token such as BSC USDT, but the quote output is truncated to 6 decimals before `destinationTransfer` raw amount construction. | On-ramp finalization uses destination-token decimals for BRL EVM outputs; Squid metadata preserves destination raw output from `route.estimate.toAmount`. | +| **Company KYB status bypass or cross-user attempt lookup** | A browser asserts that hosted verification finished, or probes another user's Avenia attempt ID and receives provider submission metadata. | Initiation binds the attempt to the authenticated user's KYB case; every exact lookup checks that binding and scopes the provider request with the owning subaccount before the call, rejects a mismatched response ID, minimizes its response, and accepts only provider-confirmed `COMPLETED` + `APPROVED`. | +| **Duplicate KYB attempt while provider processing is active** | A caller starts another API or hosted KYB attempt while Avenia is already processing one for the company. | Both creation paths list attempts for the ownership-verified subaccount. Hosted creation rejects an active attempt. API creation transactionally binds exactly one active attempt and returns it without POSTing; ambiguous multiple-active results fail closed. A provider `409` triggers the same scoped re-query and exact-one reconciliation. Terminal attempts are left to Avenia's retry rules. | +| **Tax-ID reservation through KYC preflight** | An authenticated attacker owns a BRL quote but submits a victim's valid CPF/CNPJ to the initial-attempt endpoint, attempting to occupy the globally unique Avenia tax hash. | The endpoint retains its empty compatibility response and quote checks but performs no identity persistence. Only canonical subaccount creation may create the globally reserving provider-customer row. | +| **Share-token replay or ambiguous duplicate import** | A timeout or malformed provider response causes the caller to resend a bearer-like identity-transfer token, potentially creating multiple attempts or transferring data twice. | A durable pre-send claim and token digest serialize submission. Same-key/same-token retries may reconcile through provider reads without another POST or token send. Deterministic provider rejections created no attempt, so they are failed/retriable with a new key; other unresolved outcomes remain quarantined and are never automatically replayed. | +| **Share-token disclosure** | Request/error logging, telemetry, provider errors, or support tooling captures the token and enables unauthorized identity-data transfer. | Sensitive-body provider mode, flat sanitized errors, strict observability exclusion, request-memory-only handling, and digest-only persistence prevent raw-token persistence or emission. | +| **KYC completion spoofing** | A caller treats token possession, import acceptance, a Sumsub result, or a webhook as proof of approval. | The canonical case binds one exact Avenia attempt; only exact polling of Avenia `COMPLETED + APPROVED` can approve. `EXPIRED` remains non-approved and pending reconciliation, and the webhook is notification-only. | ## Audit Checklist diff --git a/docs/security-spec/05-integrations/resend.md b/docs/security-spec/05-integrations/resend.md index 26f5710f5..10e04179c 100644 --- a/docs/security-spec/05-integrations/resend.md +++ b/docs/security-spec/05-integrations/resend.md @@ -5,7 +5,7 @@ Resend is the outbound email provider for Vortex. It carries two independent classes of mail: 1. **Authentication mail** — OTP / magic-link messages generated by Supabase Auth (GoTrue). Supabase renders and sends these itself; Resend is configured as its SMTP relay. No Vortex application code is involved. -2. **Transactional notifications** — Emails the API sends about things that happened to a user's account: a completed ramp (`ramp_completed`) and a settled verification (`verification_approved` / `verification_rejected` / `verification_expired`) from either Avenia (KYC + KYB) or Alfredpay (KYC + KYB). These go through the Resend HTTPS API from `apps/api`. +2. **Transactional notifications** — Emails the API sends about account events: completed ramps, settled verification, and managed-profile membership invitations. These go through the Resend HTTPS API from `apps/api`. Only the second class is application code and the subject of this spec. @@ -18,6 +18,7 @@ Every notification is persisted to the `email_notifications` table before any se **Sending domain:** `vortexfinance.co`, envelope sender `support@vortexfinance.co` **API auth method:** bearer API key (`RESEND_API_KEY`) over HTTPS **Code:** + - `apps/api/src/api/services/email/` — transport, templates, queue service - `apps/api/src/api/workers/notification-dispatch.worker.ts` — the only sender - `apps/api/src/api/workers/kyb-status.worker.ts` — enqueues Avenia KYB outcomes @@ -41,7 +42,7 @@ keyset (id-ordered cursor, like the Alfredpay sweep) so a backlog larger than on drains instead of re-selecting the same prefix, and a returned attempt whose id does not match the case's `provider_case_id` is discarded — the same mismatch guard the authenticated route applies. The authenticated `GET /v1/brla/kyb/attempt-status` route -also enqueues the outcome *before* persisting a terminal status: once a case is +also enqueues the outcome _before_ persisting a terminal status: once a case is Approved/Rejected both that route's short-circuit and this worker stop observing the attempt, so a client polling ahead of a lost webhook would otherwise lose the email forever. It does not list a subaccount's attempts @@ -62,8 +63,8 @@ second rejection of that same submission — Alfredpay exposes no per-outcome id ## Security Invariants -1. **A notification MUST only ever be addressed to a Vortex-authenticated user's own verified address.** The recipient is resolved at send time as `profiles.email` for `email_notifications.user_id`. No caller supplies a recipient address. -2. **Partner-supplied and ramp-supplied addresses MUST NOT be used as recipients, and partner-API ramps MUST NOT produce mail.** `RampState.state.additionalData.email` belongs to a *partner's* customer on API-driven ramps. A ramp only produces a notification when `RampState.userId` is non-null **and** its quote carries no `api_credential_id`: credential-authenticated requests fill `userId` with the credential's linked profile (`getEffectiveUserId`), so without the quote check every end-customer ramp would mail the partner. Excluded ramps are recorded as `skipped` tombstone rows so the reconcile sweep does not re-surface them. +1. **Recipient source MUST be constrained by notification type.** Existing notification types resolve the recipient at send time as `profiles.email` for `email_notifications.user_id`. Only `managed_profile_membership_invitation` may instead use a normalized `recipient_email`, and only its dedicated server-side producer can create such a row. Exactly one recipient source is present. +2. **Partner-supplied and ramp-supplied addresses MUST NOT be used as recipients, and partner-API ramps MUST NOT produce mail.** `RampState.state.additionalData.email` belongs to a _partner's_ customer on API-driven ramps. A ramp only produces a notification when `RampState.userId` is non-null **and** its quote carries no `api_credential_id`: credential-authenticated requests fill `userId` with the credential's linked profile (`getEffectiveUserId`), so without the quote check every end-customer ramp would mail the partner. Excluded ramps are recorded as `skipped` tombstone rows so the reconcile sweep does not re-surface them. 3. **A given upstream event MUST produce at most one email.** The unique index `uniq_email_notifications_provider_type_resource` on `(provider, type, resource_id)` is the idempotency key; enqueuing uses `findOrCreate` against it. Re-polling a settled KYB attempt or replaying a phase transition cannot re-notify. The unique index alone does not close the window between Resend accepting a send and `sent` being persisted — a crash in between returns the row to the queue with the mail already away — so each send additionally carries the row id as Resend's `Idempotency-Key`, and the provider replays the original response instead of sending again. 4. **A due notification MUST be claimed before it is sent.** Both flow-variant backends share one database. Dispatch claims rows inside a transaction using `FOR UPDATE SKIP LOCKED` and flips them to `sending` with `attempts` incremented, so two backends cannot send the same row. 5. **A notification MUST NOT be lost when a send fails.** Enqueue only writes a row; the cron worker is the only sender. Failures are recorded with a backoff schedule (1/5/15/60/180 minutes) and retried up to 6 attempts — one initial send plus one retry per backoff step — after which the row is `abandoned` and a Slack alert fires. @@ -76,33 +77,36 @@ second rejection of that same submission — Alfredpay exposes no per-outcome id 12. **A completed ramp MUST NOT lose its notification to a crash between the phase write and the enqueue.** The enqueue at ramp completion runs after the terminal phase is persisted and must not fail the ramp, so it is not atomic with it. `NotificationDispatchWorker` reconciles hourly with an indexed anti-join: every ramp that reached `complete` with a non-null `userId` and no `(vortex, ramp_completed, )` row is eligible, with no age cutoff that could turn an outage into permanent loss. The reconcile is idempotent against invariant 3, so a row the inline path did write is untouched. `completedAt` is recovered from the ramp's `complete` phase-history entry, not reconciliation time. 13. **A settled Alfredpay verification MUST NOT lose its notification to the status write that ends its polling.** Alfredpay has no webhook, and the onboarding refresh, background worker, and legacy status endpoints eventually exclude a terminal stored status — so an account written terminal while its enqueue failed could be excluded from every subsequent poll and never notified. Every observer queues through the same idempotent helper before the status write: a failure leaves the account non-terminal and a later poll retries the outcome and the email together. Invariant 3 makes the retry idempotent. 14. **The sending domain MUST be SPF/DKIM/DMARC aligned.** `vortexfinance.co` carries one SPF record (Resend merged into any existing sender), Resend's DKIM CNAMEs, and a DMARC policy. Because auth mail and transactional mail share the root domain, a reputation incident in either affects both — this was accepted deliberately in exchange for sender recognisability. -15. **A recipient who has opted out MUST NOT be emailed.** Dispatch resolves `notification_preferences` for `email_notifications.user_id` before every send and records the row as `skipped` — with no request to Resend — when the recipient has opted out. `email_enabled = false` silences everything; `prefs[] = false`, keyed by the stored `type` value (`ramp_completed`, `verification_approved`, …), silences one type. Opting out is the only meaning either field carries: a profile with no preferences row is treated exactly as the default row `getOrCreateNotificationPreferences` writes, so a missing row can never suppress mail. The check is at delivery, not enqueue, so an opt-out registered while a row waits in the queue is honoured. +15. **A profile recipient who has opted out MUST NOT be emailed.** Dispatch resolves `notification_preferences` for profile-addressed rows before every send and records the row as `skipped` when opted out. `email_enabled = false` silences profile notifications; `prefs[] = false` silences one type. Managed-profile invitations are the only exception because the direct recipient may not yet have a profile and the message communicates a pending access grant. They remain protected by exact verified-email acceptance and do not themselves grant access. +16. **Membership invitation links MUST use a trusted origin and minimal payload.** The API builds links from `DASHBOARD_PUBLIC_URL` plus the invitation UUID. Request headers and request body cannot select the origin. The template may identify the invited role and child display label but must not contain API secrets, bearer tokens, provider identity, financial data, or internal membership IDs other than the invitation locator. ## Threat Vectors & Mitigations -| Threat | Attack Scenario | Mitigation | -|---|---|---| -| **Mail sent to an attacker's address** | Attacker drives a ramp with `additionalData.email` set to their own address and receives the victim's transaction details | Recipient is never taken from ramp or request data; it is looked up from `profiles.email` by `user_id` at send time (inv. 1, 2) | -| **Partner impersonating a user** | Partner uses an `sk_` key to create a ramp and expects the notification to be addressed under their control | The recipient can only ever be `profiles.email` of the ramp's `userId`, and a ramp whose quote carries an `api_credential_id` enqueues a `skipped` tombstone instead of mail (inv. 2) | -| **Duplicate email flood** | Recovery worker or a second backend re-processes the same ramp/attempt | Unique dedupe index plus transactional row claim (inv. 3, 4) | -| **Silent mail loss** | The in-process enqueue call is fire-and-forget; an exception would previously vanish | Enqueue writes a durable row before any send; the worker retries independently of the request that queued it (inv. 5) | -| **Queue stall** | Process is killed between claim and send, leaving rows `sending` forever | Stale-claim release after 15 minutes (inv. 6) | -| **Crash-loop mail flood** | A backend dies mid-send on every cycle, so no failure is ever recorded and the row is requeued indefinitely | Stale claims at the attempt cap are abandoned rather than released, and the claim query refuses rows at the cap (inv. 6) | -| **Double send across a crash window** | The process dies after Resend accepts but before `sent` is persisted; the recovered row is sent again | The row id travels as Resend's `Idempotency-Key`, so the retry replays the original send (inv. 3) | -| **Mailing a user who opted out** | A user disables email via `/v1/notifications/preferences` and still receives ramp and verification mail | Preferences are resolved at delivery time and an opted-out recipient is recorded `skipped` with no outbound request (inv. 15) | -| **Leaking production mail from staging** | A staging deploy pointed at production-like data emails real users | Allowlist gate outside `DEPLOYMENT_ENV=production` (inv. 7) | -| **API key compromise** | Attacker obtains `RESEND_API_KEY` and sends mail as `support@vortexfinance.co` | Env-only storage, no logging of the key, rotation via the Resend dashboard; DMARC limits third-party spoofing of the domain itself (inv. 8, 14) | -| **HTML injection via provider text** | Avenia returns a `resultMessage`, or Alfredpay a `metadata.failureReason`, containing markup or a link | All interpolated values are HTML-escaped and the reason is length-capped (inv. 9, 10) | -| **Verification outcome silently unnotified** | A poll writes the terminal status but its enqueue fails, and the account is then filtered out of every future poll | The enqueue is ordered before the status write, so the account stays pollable until both succeed (inv. 13) | -| **PII over-disclosure** | Email body reveals more than the recipient should receive by mail | Bodies carry only amount, currency, network, ramp id, and timestamp — no tax id, no address, no counterparty | -| **Enumeration via notification rows** | Attacker infers user activity from the table | `email_notifications` is not exposed through any API route; there is no read endpoint | +| Threat | Attack Scenario | Mitigation | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Mail sent to an attacker's address** | Attacker drives a ramp with `additionalData.email` set to their own address and receives the victim's transaction details | Recipient is never taken from ramp or request data; it is looked up from `profiles.email` by `user_id` at send time (inv. 1, 2) | +| **Generic direct-email abuse** | A new producer tries to use the queue as an arbitrary mail relay | Database recipient-source constraint plus a dedicated producer and dispatch type allowlist reject direct email for every other type (inv. 1) | +| **Invitation phishing redirect** | Host or origin input turns a real invitation email into an attacker-controlled link | Link origin comes only from validated `DASHBOARD_PUBLIC_URL` (inv. 16) | +| **Partner impersonating a user** | Partner uses an `sk_` key to create a ramp and expects the notification to be addressed under their control | The recipient can only ever be `profiles.email` of the ramp's `userId`, and a ramp whose quote carries an `api_credential_id` enqueues a `skipped` tombstone instead of mail (inv. 2) | +| **Duplicate email flood** | Recovery worker or a second backend re-processes the same ramp/attempt | Unique dedupe index plus transactional row claim (inv. 3, 4) | +| **Silent mail loss** | The in-process enqueue call is fire-and-forget; an exception would previously vanish | Enqueue writes a durable row before any send; the worker retries independently of the request that queued it (inv. 5) | +| **Queue stall** | Process is killed between claim and send, leaving rows `sending` forever | Stale-claim release after 15 minutes (inv. 6) | +| **Crash-loop mail flood** | A backend dies mid-send on every cycle, so no failure is ever recorded and the row is requeued indefinitely | Stale claims at the attempt cap are abandoned rather than released, and the claim query refuses rows at the cap (inv. 6) | +| **Double send across a crash window** | The process dies after Resend accepts but before `sent` is persisted; the recovered row is sent again | The row id travels as Resend's `Idempotency-Key`, so the retry replays the original send (inv. 3) | +| **Mailing a user who opted out** | A user disables email via `/v1/notifications/preferences` and still receives ramp and verification mail | Preferences are resolved at delivery time and an opted-out recipient is recorded `skipped` with no outbound request (inv. 15) | +| **Leaking production mail from staging** | A staging deploy pointed at production-like data emails real users | Allowlist gate outside `DEPLOYMENT_ENV=production` (inv. 7) | +| **API key compromise** | Attacker obtains `RESEND_API_KEY` and sends mail as `support@vortexfinance.co` | Env-only storage, no logging of the key, rotation via the Resend dashboard; DMARC limits third-party spoofing of the domain itself (inv. 8, 14) | +| **HTML injection via provider text** | Avenia returns a `resultMessage`, or Alfredpay a `metadata.failureReason`, containing markup or a link | All interpolated values are HTML-escaped and the reason is length-capped (inv. 9, 10) | +| **Verification outcome silently unnotified** | A poll writes the terminal status but its enqueue fails, and the account is then filtered out of every future poll | The enqueue is ordered before the status write, so the account stays pollable until both succeed (inv. 13) | +| **PII over-disclosure** | Email body reveals more than the recipient should receive by mail | Bodies carry only amount, currency, network, ramp id, and timestamp — no tax id, no address, no counterparty | +| **Enumeration via notification rows** | Attacker infers user activity from the table | `email_notifications` is not exposed through any API route; there is no read endpoint | ## Audit Checklist - [ ] `RESEND_API_KEY` is read only from `config.integrations.resend.apiKey` and never logged -- [ ] Recipient is resolved from `profiles.email` via `email_notifications.user_id` — grep for any code path that passes a request-supplied address to `sendEmail` +- [ ] Profile recipients resolve through `user_id`; only managed-profile invitations use normalized `recipient_email` through their dedicated producer - [ ] `enqueueRampCompletedEmail` returns early when `RampState.userId` is null, and tombstones API-credential ramps as `skipped` -- [ ] Migration `062` creates `uniq_email_notifications_provider_type_resource` and all three member columns are `NOT NULL` +- [ ] Migration `062` creates the base queue and migration 069 enforces exactly one recipient source plus the direct-email type allowlist - [ ] `enqueueNotification` uses `findOrCreate` keyed on `(provider, type, resourceId)` - [ ] Dispatch claims rows with `lock: transaction.LOCK.UPDATE` and `skipLocked: true` before sending - [ ] `attempts` is incremented at claim time, not after a successful send @@ -120,3 +124,4 @@ second rejection of that same submission — Alfredpay exposes no per-outcome id - [ ] Missing API key leaves rows `pending`, not `failed` or `skipped` - [ ] `vortexfinance.co` has exactly one SPF record, Resend DKIM CNAMEs resolve, and DMARC is published - [ ] No route exposes the `email_notifications` table +- [ ] Membership invitation links use validated `DASHBOARD_PUBLIC_URL` and templates contain no secret or financial data diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index e7f7cd62b..588895c32 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -5,6 +5,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api/`): how requests enter the system, what validation is applied, how errors are returned, and what network-level protections exist. **Express configuration** (`config/express.ts`): + - CORS: Explicit origin whitelist — `app.vortexfinance.co`, `dashboard.vortexfinance.co`, `metrics.vortexfinance.co`, staging Netlify and localhost (non-production only, gated on `DEPLOYMENT_ENV`), plus optional comma-separated fixed origins from `DASHBOARD_ORIGINS` and `BROWSER_SDK_ORIGINS` (resolved once at boot, wildcard entries dropped) and the optional `DASHBOARD_PREVIEW_SITE` env var (a single Netlify site slug; enables the fixed-shape pattern `https://deploy-preview---.netlify.app` for dashboard deploy previews, non-production only; helpers in `config/corsOrigins.ts`) - Rate limiting: 100 requests per minute per IP (global, all endpoints) - Helmet: Standard HTTP security headers @@ -50,7 +51,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api ## Security Invariants -1. **CORS MUST only allow explicit origins** — The whitelist is defined in `corsConfig.ts` (helpers in `corsOrigins.ts`). No wildcard (`*`) origins. No dynamic origin reflection. `DASHBOARD_ORIGINS` and `BROWSER_SDK_ORIGINS` extend the whitelist with additional *fixed* origins only: each is parsed once at boot and entries containing `*` are silently discarded. Browser SDK operators MUST add each production integrator origin explicitly. The only permitted pattern-based entry is the dashboard deploy-preview regex: `DASHBOARD_PREVIEW_SITE` supplies a validated Netlify site slug and is disabled when `DEPLOYMENT_ENV` is `production`. +1. **CORS MUST only allow explicit origins** — The whitelist is defined in `corsConfig.ts` (helpers in `corsOrigins.ts`). No wildcard (`*`) origins. No dynamic origin reflection. `DASHBOARD_ORIGINS` and `BROWSER_SDK_ORIGINS` extend the whitelist with additional _fixed_ origins only: each is parsed once at boot and entries containing `*` are silently discarded. Browser SDK operators MUST add each production integrator origin explicitly. The only permitted pattern-based entry is the dashboard deploy-preview regex: `DASHBOARD_PREVIEW_SITE` supplies a validated Netlify site slug and is disabled when `DEPLOYMENT_ENV` is `production`. 2. **Rate limiting MUST be enforced on all endpoints** — 100 req/min per IP applies globally via `express-rate-limit`. No endpoint should bypass this. 3. **Body size MUST be bounded** — The JSON body parser has a limit. **⚠️ FINDING: The limit is 20MB (`"20mb"`), which is still large for a JSON API.** A typical API allows 1-10MB. 20MB still enables avoidable memory pressure. 4. **All user input MUST be validated before reaching controllers** — Validators run as middleware before the controller function. Missing validation on an endpoint means raw user input reaches business logic. @@ -73,10 +74,11 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api 21. **Credential startup MUST fail closed** — the process must not listen unless the complete `api_credentials` schema, nullability, indexes, and constraints exist and the legacy `api_keys` table is absent. Runtime auth must not fall back to legacy rows, hashes, prefixes, or pairing heuristics. 22. **`ramp-info` MUST expose only a sanitized subject-derived projection** — `GET /v1/ramp-info` may accept public or secret credential capability, but not a Supabase session. It derives the profile from `CredentialContext`; a manager secret may additionally use the authorization-derived `X-Managed-Profile-Id` selector, while a public key may not. It accepts no body/query profile, user, or PII identifier and returns only per-corridor `kycStatus`, `canBuy`, and `canSell`. 23. **Managed-profile provisioning MUST use immutable associations** — `POST /v1/admin/managed-profiles` requires admin auth, normalizes email, and binds a genuine Supabase/profile identity to unique `(partner_id, external_user_id)` and unique `profile_id` records. Existing Auth identities may be reconciled only when their immutable metadata matches. Technical subjects must not receive customer entities or register ramps. -24. **Managed-profile context MUST be route-authorized** — On supported child-oriented routes, `X-Managed-Profile-Id` is a selector only. A Supabase session or secret API credential establishes the manager actor; middleware verifies active manager configuration, a direct active relationship, and the managed child with its single active customer entity before attaching an immutable actor/subject context. Corridor- or customer-type-scoped operations additionally enforce the manager's current customer-type narrowing, every required corridor, and canonical corridor capability; policy-free status and historical reads remain available. Public API keys cannot establish the manager actor, raw headers never alter `req.userId`, and ownership checks use the verified child subject. Target-specific authorization must resolve the target under that verified child before evaluating its stored corridor, preserving the route's missing-resource response for foreign targets and never falling back to the manager's resource. A child-owned credential instead authenticates directly as its child and dynamically derives the same controlling relationship and current policy; it cannot select another child. Admin impersonation may compose with managed selection when the impersonated profile is the active controlling manager; the admin remains attributable through the impersonation context. Manager or relationship deactivation, and policy narrowing on policy-scoped operations, block authorization decisions begun after the committed change but do not cancel already-authorized requests in flight. The header is explicitly CORS-allowlisted for browser-based manager sessions, and relationship plus route-specific entity-type authorization precedes multipart buffering; multipart country authorization follows body parsing. -25. **Headless profile lifecycle MUST fail closed** — Manager lifecycle routes derive the manager from a Supabase session or profile-bound secret credential and require its current manager configuration to be active. Creation requires an immutable provider contact email separate from the child's null login email; normalized contact emails are unique and permanently reserved within each manager. Child reads, credential management, and deletion are scoped by both manager and child profile IDs so foreign relationships are indistinguishable from missing rows. Only the manager-scoped child-credential route may issue credentials for a managed subject; generic profile-managed and admin partner-managed creation reject them. Credential creation and logical deletion lock the child profile and relationship in a common order; deletion is idempotent, revokes child credentials in the same transaction, and leaves retained provider, KYC, quote, ramp, and callback state intact. Managed profiles cannot create a second customer-entity type after provisioning. +24. **Managed-profile context MUST be route-authorized** — On supported child-oriented routes, `X-Managed-Profile-Id` is a selector only. A Supabase session or secret API credential establishes an authenticated member actor; middleware verifies that actor's active `manager` or `read_only` membership, the immutable owner's active manager configuration, a direct active relationship, and the managed child with its single active customer entity before attaching an immutable actor/subject context. Every call site declares a read, management, credential-management, or ramp capability. Read-only members are rejected from every non-read capability even when presenting their own secret credential. Corridor- or customer-type-scoped operations additionally enforce the owner's current customer-type narrowing, every required corridor, and canonical corridor capability; policy-free status and historical reads remain available. Public API keys cannot establish the member actor, raw headers never alter `req.userId`, and ownership checks use the verified child subject. Target-specific authorization must resolve the target under that verified child before evaluating its stored corridor, preserving the route's missing-resource response for foreign targets and never falling back to the actor's resource. A child-owned credential instead authenticates directly as its child and dynamically derives the same controlling relationship and current policy; it cannot select another child and does not require a membership row. Admin impersonation may compose with managed selection only on permitted read/management operations; the admin remains attributable through the impersonation context. Owner-manager or relationship deactivation, membership revocation, and policy narrowing block authorization decisions begun after the committed change but do not cancel already-authorized requests in flight. The header is explicitly CORS-allowlisted, and relationship plus route-specific entity-type authorization precedes multipart buffering; multipart country authorization follows body parsing. +25. **Headless profile lifecycle MUST fail closed** — Create and delete derive the immutable owner from a Supabase session or profile-bound secret credential and cannot be delegated to another `manager` member. List/read return only the authenticated actor's active memberships with role and immutable owner policy; direct reads may inspect retained deleted children. Creation requires an immutable provider contact email separate from the child's null login email; normalized contact emails are unique and permanently reserved within each owner. Child credential listing permits either active role, while create/revoke requires `manager`; every operation is path-child scoped, and only this route may issue credentials for a managed subject. Generic profile-managed and admin partner-managed creation reject managed subjects. Credential creation and logical deletion lock the child profile and relationship in a common order; deletion is idempotent, revokes child credentials in the same transaction, and leaves retained provider, KYC, quote, ramp, and callback state intact. Provisioning atomically creates the owner membership and append-only event. Managed profiles cannot create a second customer-entity type after provisioning. 26. **Managed selector handling MUST be explicit per route** — Recipient invite preview and acceptance reject `X-Managed-Profile-Id` rather than redeeming as a selected child; sender-side recipient routes are delegated only after managed-profile authorization. Direct child credentials are rejected from webhook and manager lifecycle routes. Managed children have one immutable active customer entity from provisioning, so `PUT /v1/onboarding/active-entity` is not a delegated child operation. The legacy Monerium and Mykobo routes are the accepted exception: they ignore the selector and remain scoped to the Supabase-authenticated manager. Managed clients must not send the header to those routes, and dashboard child mode disables those actions. 27. **Public onboarding discovery MUST keep OpenAPI authoritative for request schemas** — `GET /v1/onboarding/requirements` is unauthenticated and returns only the reviewed static Avenia/Alfredpay flow identity, document requirements, ordered non-GET API/hosted/upload actions, workflow value bindings, and documentation/OpenAPI links. Initial reads, readiness getters, redirect getters, and status polling MUST NOT be advertised; integration documentation and OpenAPI own those completion details. No top-level field catalog or independent request schema is returned. `fixedBody`, `fixedQuery`, and `derivedValues` may bind provider discriminators or prior step outputs only to body/query fields accepted by the referenced OpenAPI operation. The endpoint MUST NOT inspect profile state, return customer or provider identifiers, accept an owner selector, or advertise unsupported combinations such as AR business or Monerium flows. Every advertised API step, request-schema fragment, and workflow-binding target is checked against the reviewed OpenAPI document so stale mappings fail the documentation gate. +28. **Selected-child bearer ramps MUST fail before body buffering** — The three mutable ramp entrypoints authenticate selected-child Supabase bearer requests ahead of the global JSON parser, reject impersonation, verify membership role, and return `403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL` for a valid manager member without reading the body. Member-owned secret credentials and direct child credentials continue through the normal parsed route and full body-derived corridor/ownership checks. ## Threat Vectors & Mitigations diff --git a/docs/security-spec/07-operations/client-observability.md b/docs/security-spec/07-operations/client-observability.md index 44d05f8e9..d103f1492 100644 --- a/docs/security-spec/07-operations/client-observability.md +++ b/docs/security-spec/07-operations/client-observability.md @@ -33,25 +33,27 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev 12. **Public `ramp-info` telemetry MUST remain sanitized** — events may record operation, outcome, credential ID/strength, safe prefix, duration, and HTTP status. They must not include the response projection, KYC details, profile selectors, provider identifiers, or exact limits. 13. **KYC token-import telemetry MUST exclude token material** — Token-import and standard-KYC controller/provider operations do not currently emit `api_client_events`; sanitized `auth_dual` failures may be emitted before KYC body parsing. `importToken`, the request body, token fingerprints, consent payloads, identity payloads, provider request/response details, and free-form provider errors MUST NOT enter `api_client_events`, structured logs, traces, Sentry, metrics, or support exports. Any future instrumentation may record only bounded operation/outcome data, HTTP status, duration, request ID, safe authenticated credential attribution, and stable public error classifications. 14. **Standard KYC provider logging MUST omit identity payloads** — Level 1 names, birth dates, tax IDs, emails, addresses, document IDs, selfie IDs, request bodies, and provider errors that may echo those values MUST NOT enter logs, traces, Sentry, metrics, or support exports. The Avenia client may log only endpoint/method context and a fixed sensitive-payload omission marker, and thrown provider errors must contain a fixed sanitized response body. +15. **Managed membership telemetry MUST omit invitation authority and unnecessary identity** — API logs and `api_client_events` must not contain invitation URLs, invitation UUIDs in concrete paths, normalized invitation emails, bearer tokens, API-key secrets, or raw membership records. Stable operation/error classifications and request IDs are sufficient for support. ## Threat Vectors & Mitigations -| Threat | Mitigation | -|---|---| -| **Observability database leak** — An attacker gains read access to `api_client_events` | Store only minimal sanitized event fields and allowlisted request summaries. Do not persist secrets, raw request bodies, tax IDs, PIX data, KYC data, or private key material. Treat the table as operationally sensitive even after redaction. | -| **API key/header capture** — Instrumentation accidentally records `X-API-Key`, `X-Public-Key`, bearer tokens, or raw headers | Use an allowlist-shaped event schema and denylist sensitive metadata keys before persistence. Store only immutable credential IDs and short 16-character prefixes when explicitly safe. | -| **PII leakage through metadata** — Client-provided `additionalData` or error messages include tax IDs, PIX keys, or bank details | Do not persist nested metadata objects. Keep metadata scalar-only and sanitized. Pass only allowlisted request-derived fields to observability helpers; use counts or presence flags for arrays/objects such as presigned transactions, signing accounts, and `additionalData`. Truncate error messages and prefer stable `errorType` categories. | -| **Business flow disruption** — Database/logging outage causes quote/ramp requests to fail | Observability writes are fire-and-forget/best-effort and catch their own errors. The request path must proceed exactly as it would without observability. | -| **Missing correlation during incidents** — Operators cannot connect a partner report to backend logs | Generate or propagate `requestId` for all requests and return it via `X-Request-ID`. Persist request IDs alongside quote/ramp IDs when available. | -| **Misread partner attribution** — Operators interpret a display `partnerName` as proof of partner ownership or pricing authority. | Observability labels are non-authoritative. Authorization comes from `partner_id`/`user_id` ownership checks, and pricing attribution comes from quote-time `pricing_partner_id` when present. | -| **High-cardinality metric explosion** — Future observability metrics use ramp IDs or user IDs as labels | Keep high-cardinality identifiers in logs/event rows only. Export aggregate metrics using bounded labels. | -| **Unbounded telemetry retention** — Raw event rows grow indefinitely | Use the backend retention worker to delete `api_client_events` older than the 7-day UTC calendar retention window. The cleanup runs on startup and daily, uses advisory locking, and deletes in bounded batches. | -| **Internal metrics client exposure** — An internal metrics consumer is reachable by outsiders | Require the dedicated backend metrics dashboard bearer token for all event data. Do not rely on obscurity of client URLs. | -| **BI embed secret leak** — A future Metabase embed is generated in client-side code | Generate signed embed URLs only from the backend. Do not place Metabase signing secrets in publicly exposed environment variables. | -| **Mismatch logs leak two credentials** — Error instrumentation records both full presented halves | Emit `CREDENTIAL_MISMATCH` with safe IDs/prefixes only and never attach raw headers or request bodies. | -| **Public eligibility telemetry becomes a shadow profile store** — `ramp-info` events persist KYC state or provider details | Record only request outcome metadata; keep the response and all identity/provider details out of events. | +| Threat | Mitigation | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Observability database leak** — An attacker gains read access to `api_client_events` | Store only minimal sanitized event fields and allowlisted request summaries. Do not persist secrets, raw request bodies, tax IDs, PIX data, KYC data, or private key material. Treat the table as operationally sensitive even after redaction. | +| **API key/header capture** — Instrumentation accidentally records `X-API-Key`, `X-Public-Key`, bearer tokens, or raw headers | Use an allowlist-shaped event schema and denylist sensitive metadata keys before persistence. Store only immutable credential IDs and short 16-character prefixes when explicitly safe. | +| **PII leakage through metadata** — Client-provided `additionalData` or error messages include tax IDs, PIX keys, or bank details | Do not persist nested metadata objects. Keep metadata scalar-only and sanitized. Pass only allowlisted request-derived fields to observability helpers; use counts or presence flags for arrays/objects such as presigned transactions, signing accounts, and `additionalData`. Truncate error messages and prefer stable `errorType` categories. | +| **Business flow disruption** — Database/logging outage causes quote/ramp requests to fail | Observability writes are fire-and-forget/best-effort and catch their own errors. The request path must proceed exactly as it would without observability. | +| **Missing correlation during incidents** — Operators cannot connect a partner report to backend logs | Generate or propagate `requestId` for all requests and return it via `X-Request-ID`. Persist request IDs alongside quote/ramp IDs when available. | +| **Misread partner attribution** — Operators interpret a display `partnerName` as proof of partner ownership or pricing authority. | Observability labels are non-authoritative. Authorization comes from `partner_id`/`user_id` ownership checks, and pricing attribution comes from quote-time `pricing_partner_id` when present. | +| **High-cardinality metric explosion** — Future observability metrics use ramp IDs or user IDs as labels | Keep high-cardinality identifiers in logs/event rows only. Export aggregate metrics using bounded labels. | +| **Unbounded telemetry retention** — Raw event rows grow indefinitely | Use the backend retention worker to delete `api_client_events` older than the 7-day UTC calendar retention window. The cleanup runs on startup and daily, uses advisory locking, and deletes in bounded batches. | +| **Internal metrics client exposure** — An internal metrics consumer is reachable by outsiders | Require the dedicated backend metrics dashboard bearer token for all event data. Do not rely on obscurity of client URLs. | +| **BI embed secret leak** — A future Metabase embed is generated in client-side code | Generate signed embed URLs only from the backend. Do not place Metabase signing secrets in publicly exposed environment variables. | +| **Mismatch logs leak two credentials** — Error instrumentation records both full presented halves | Emit `CREDENTIAL_MISMATCH` with safe IDs/prefixes only and never attach raw headers or request bodies. | +| **Public eligibility telemetry becomes a shadow profile store** — `ramp-info` events persist KYC state or provider details | Record only request outcome metadata; keep the response and all identity/provider details out of events. | | **KYC share token captured by telemetry** — Generic request summarization stores `importToken`, consent data, a token fingerprint, or a provider error containing request configuration | Token-import controller/provider operations do not currently emit API client events. Authentication may emit a sanitized `auth_dual` failure before body parsing. The shared sanitizer still excludes sensitive fields, raw bodies and nested metadata are forbidden, provider errors are sanitized, and any future instrumentation may observe only stable bounded outcome classifications. | -| **Standard KYC identity echoed into logs** — Request debugging or an Avenia validation error captures names, tax IDs, addresses, document identifiers, or other submitted identity data | Standard Level 1 submission uses sensitive-body mode, which omits the request payload and replaces provider response/error details with fixed text before the error reaches callers or logging. | +| **Standard KYC identity echoed into logs** — Request debugging or an Avenia validation error captures names, tax IDs, addresses, document identifiers, or other submitted identity data | Standard Level 1 submission uses sensitive-body mode, which omits the request payload and replaces provider response/error details with fixed text before the error reaches callers or logging. | +| **Invitation link captured by telemetry** — Concrete routes, request bodies, or queue payloads expose a usable invitation locator or target email | Route templates and stable error types are allowlisted; concrete IDs, request bodies, target email, and links are excluded from client events and logs. | ## Audit Checklist diff --git a/docs/security-spec/07-operations/notifications.md b/docs/security-spec/07-operations/notifications.md index 4e14a7f4f..9d7994734 100644 --- a/docs/security-spec/07-operations/notifications.md +++ b/docs/security-spec/07-operations/notifications.md @@ -6,13 +6,13 @@ In-app notification feed + per-profile preferences for the dashboard (plan §8). `notifications` and `notification_preferences` (migration `043`), routes under `/v1/notifications` (`notifications.controller.ts`), all behind `requireAuth`: -| Endpoint | Purpose | -| :-- | :-- | +| Endpoint | Purpose | +| :------------------------------------- | :--------------------------------------------------------- | | `GET /v1/notifications?limit=&before=` | Newest-first feed + `unreadCount` (limit clamped to 1–100) | -| `POST /v1/notifications/:id/read` | Mark one read (owner-scoped) | -| `POST /v1/notifications/read-all` | Mark all unread read | -| `GET /v1/notifications/preferences` | Read prefs (row created with defaults on first read) | -| `PUT /v1/notifications/preferences` | Update `emailEnabled` / `prefs` (typed validation) | +| `POST /v1/notifications/:id/read` | Mark one read (owner-scoped) | +| `POST /v1/notifications/read-all` | Mark all unread read | +| `GET /v1/notifications/preferences` | Read prefs (row created with defaults on first read) | +| `PUT /v1/notifications/preferences` | Update `emailEnabled` / `prefs` (typed validation) | Writes go through `emitNotification(profileId, event)` (`notification.service.ts`). Notification content is rendered verbatim to users and may later be emailed, so it is a PII-leak surface. @@ -53,9 +53,11 @@ content is rendered verbatim to users and may later be emailed, so it is a PII-l - [ ] **Email dispatch is implemented and gates on these preferences at delivery time** (see [`05-integrations/resend.md`](../05-integrations/resend.md) for the transport, queue, and its own invariants). Before every send the dispatch worker re-reads - `notification_preferences`: `email_enabled` is the master switch, and + `notification_preferences` for profile-addressed mail: `email_enabled` is the master switch, and `prefs[] === false` mutes one type — the stored type strings are the shared `EmailNotificationType` enum consumed by both the worker and the dashboard's Settings toggles. A muted row is recorded `skipped`, never sent. Sending remains - server-side triggered only; the `email_notifications` queue is unrelated to the + server-side triggered only. Managed-profile membership invitations are the narrow exception: + they may address a normalized email before a profile exists and bypass profile preferences, + but exact verified-email acceptance is still required. The `email_notifications` queue is unrelated to the in-app `notifications` table this spec covers, and no client can write either. diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index 5dc966a9a..45be1b938 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -36,48 +36,49 @@ documents win. ## Module Index -| Module | Path | Scope | -|---|---|---| -| Current Risk Register | `RISK-REGISTER.md` | Authoritative accepted, deferred, and rollout-dependent exceptions | -| System Overview | `00-system-overview/architecture.md` | Trust boundaries, component map, data flows | -| Supabase OTP Auth | `01-auth/supabase-otp.md` | Email OTP, session lifecycle, token handling | -| API Credential Auth | `01-auth/api-keys.md` | Unified pk\_/sk\_ credential record, capability matrix, validation, lifecycle | -| Admin Auth | `01-auth/admin-auth.md` | Admin bearer token, endpoint protection | -| Admin Impersonation | `01-auth/admin-impersonation.md` | `vortex_admin` acting as a customer profile via `/v1/admin-console/*`: session lifecycle, principal substitution, revocation, audit trail | -| Ephemeral Accounts | `02-signing-keys/ephemeral-accounts.md` | Client-side key generation, multi-chain, storage | -| Server-Side Signing | `02-signing-keys/server-side-signing.md` | Funding keys, executor keys, webhook signing | -| State Machine | `03-ramp-engine/state-machine.md` | Phase transitions, locking, idempotency, recovery | -| Quote Lifecycle | `03-ramp-engine/quote-lifecycle.md` | Creation, expiry, binding to ramp | -| Fee Integrity | `03-ramp-engine/fee-integrity.md` | Fee pipeline: quote-time snapshot, deduction, distribution, rounding | -| Discount Mechanism | `03-ramp-engine/discount-mechanism.md` | Partner discounts, subsidies, dynamic adjustment | -| Profile Partner Pricing | `03-ramp-engine/profile-partner-pricing.md` | Supabase profile assignments to ramp-specific partner pricing IDs | -| Recipient Transfers | `03-ramp-engine/recipient-transfers.md` | Invite token hashing/retention/expiry, token-bound redemption, invitation/relationship archiving, sender↔recipient authorization, transfer eligibility gate | -| Transaction Validation | `03-ramp-engine/transaction-validation.md` | Presigned tx verification, content validation, signing model | -| Ephemeral Account Lifecycle | `03-ramp-engine/ephemeral-accounts.md` | Funding, cleanup, stuck fund prevention | -| Ramp Phase Flows | `03-ramp-engine/ramp-phase-flows.md` | Per-corridor token flow, phase handler map, subsidy bounds | -| Block-Flow Architecture | `03-ramp-engine/block-flow-architecture.md` | Persisted flow identity, version dispatch, topology, schemas, and executor wiring | -| Token Relayer | `04-smart-contracts/token-relayer.md` | EIP-712, permit, known findings | -| Integration Template | `05-integrations/_template.md` | Template for new provider specs | -| BRLA | `05-integrations/brla.md` | BRLA anchor for BRL on/off-ramp | -| Mykobo | `05-integrations/mykobo.md` | Mykobo EUR on/off-ramp on Base (currently registration-gated) | -| Monerium | `05-integrations/monerium.md` | Server-side OAuth KYC/KYB and verification status mirroring | -| Alfredpay | `05-integrations/alfredpay.md` | Alfredpay on/off-ramp | -| Binance | `05-integrations/binance.md` | Binance USDT spot price used as the primary USD<>BRL rate source | -| FastForex | `05-integrations/fastforex.md` | Fiat forex price provider used by quote/conversion math | -| Resend | `05-integrations/resend.md` | Outbound email — auth mail relay and transactional notifications | -| Squid Router | `05-integrations/squid-router.md` | Cross-chain EVM routing | -| XCM Transfers | `06-cross-chain/xcm-transfers.md` | Dormant Pendulum↔Moonbeam↔AssetHub compatibility and re-enable constraints | -| Fund Routing | `06-cross-chain/fund-routing.md` | Subsidization, fee distribution, amount integrity | -| Rebalancer | `07-operations/rebalancer.md` | Automated liquidity management — BRLA↔axlUSDC (legacy, Pendulum), cost/profit/opportunistic USDC→BRLA→USDC (Base), and cost/profit-aware BRLA→USDC correction (Base low-coverage) | -| Secret Management | `07-operations/secret-management.md` | Env vars, rotation, blast radius | -| API Surface | `07-operations/api-surface.md` | Rate limiting, CORS, input validation, error handling | -| Client Observability | `07-operations/client-observability.md` | Request IDs, sanitized API client events, operational monitoring | -| Notifications | `07-operations/notifications.md` | In-app feed authorization, PII redaction rules, email dispatch status | +| Module | Path | Scope | +| --------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Current Risk Register | `RISK-REGISTER.md` | Authoritative accepted, deferred, and rollout-dependent exceptions | +| System Overview | `00-system-overview/architecture.md` | Trust boundaries, component map, data flows | +| Supabase OTP Auth | `01-auth/supabase-otp.md` | Email OTP, session lifecycle, token handling | +| API Credential Auth | `01-auth/api-keys.md` | Unified pk\_/sk\_ credential record, capability matrix, validation, lifecycle | +| Managed-Profile Memberships | `01-auth/managed-profile-memberships.md` | Child-scoped roles, invitations, immutable ownership, and delegated capabilities | +| Admin Auth | `01-auth/admin-auth.md` | Admin bearer token, endpoint protection | +| Admin Impersonation | `01-auth/admin-impersonation.md` | `vortex_admin` acting as a customer profile via `/v1/admin-console/*`: session lifecycle, principal substitution, revocation, audit trail | +| Ephemeral Accounts | `02-signing-keys/ephemeral-accounts.md` | Client-side key generation, multi-chain, storage | +| Server-Side Signing | `02-signing-keys/server-side-signing.md` | Funding keys, executor keys, webhook signing | +| State Machine | `03-ramp-engine/state-machine.md` | Phase transitions, locking, idempotency, recovery | +| Quote Lifecycle | `03-ramp-engine/quote-lifecycle.md` | Creation, expiry, binding to ramp | +| Fee Integrity | `03-ramp-engine/fee-integrity.md` | Fee pipeline: quote-time snapshot, deduction, distribution, rounding | +| Discount Mechanism | `03-ramp-engine/discount-mechanism.md` | Partner discounts, subsidies, dynamic adjustment | +| Profile Partner Pricing | `03-ramp-engine/profile-partner-pricing.md` | Supabase profile assignments to ramp-specific partner pricing IDs | +| Recipient Transfers | `03-ramp-engine/recipient-transfers.md` | Invite token hashing/retention/expiry, token-bound redemption, invitation/relationship archiving, sender↔recipient authorization, transfer eligibility gate | +| Transaction Validation | `03-ramp-engine/transaction-validation.md` | Presigned tx verification, content validation, signing model | +| Ephemeral Account Lifecycle | `03-ramp-engine/ephemeral-accounts.md` | Funding, cleanup, stuck fund prevention | +| Ramp Phase Flows | `03-ramp-engine/ramp-phase-flows.md` | Per-corridor token flow, phase handler map, subsidy bounds | +| Block-Flow Architecture | `03-ramp-engine/block-flow-architecture.md` | Persisted flow identity, version dispatch, topology, schemas, and executor wiring | +| Token Relayer | `04-smart-contracts/token-relayer.md` | EIP-712, permit, known findings | +| Integration Template | `05-integrations/_template.md` | Template for new provider specs | +| BRLA | `05-integrations/brla.md` | BRLA anchor for BRL on/off-ramp | +| Mykobo | `05-integrations/mykobo.md` | Mykobo EUR on/off-ramp on Base (currently registration-gated) | +| Monerium | `05-integrations/monerium.md` | Server-side OAuth KYC/KYB and verification status mirroring | +| Alfredpay | `05-integrations/alfredpay.md` | Alfredpay on/off-ramp | +| Binance | `05-integrations/binance.md` | Binance USDT spot price used as the primary USD<>BRL rate source | +| FastForex | `05-integrations/fastforex.md` | Fiat forex price provider used by quote/conversion math | +| Resend | `05-integrations/resend.md` | Outbound email — auth mail relay and transactional notifications | +| Squid Router | `05-integrations/squid-router.md` | Cross-chain EVM routing | +| XCM Transfers | `06-cross-chain/xcm-transfers.md` | Dormant Pendulum↔Moonbeam↔AssetHub compatibility and re-enable constraints | +| Fund Routing | `06-cross-chain/fund-routing.md` | Subsidization, fee distribution, amount integrity | +| Rebalancer | `07-operations/rebalancer.md` | Automated liquidity management — BRLA↔axlUSDC (legacy, Pendulum), cost/profit/opportunistic USDC→BRLA→USDC (Base), and cost/profit-aware BRLA→USDC correction (Base low-coverage) | +| Secret Management | `07-operations/secret-management.md` | Env vars, rotation, blast radius | +| API Surface | `07-operations/api-surface.md` | Rate limiting, CORS, input validation, error handling | +| Client Observability | `07-operations/client-observability.md` | Request IDs, sanitized API client events, operational monitoring | +| Notifications | `07-operations/notifications.md` | In-app feed authorization, PII redaction rules, email dispatch status | ## Retained Evidence -| Document | Why it remains | -|---|---| +| Document | Why it remains | +| -------------------------------- | -------------------------------------------------------------------------------------------------------- | | `REVIEW-POST-1232-2026-07-30.md` | Latest full spec-first review of the block-flow architecture and the evidence that drove its remediation | ## Checklist Semantics @@ -99,26 +100,30 @@ Most module specifications use these sections: ## Glossary -| Term | Definition | -|---|---| -| **Ramp** | A conversion between fiat and crypto (on-ramp = fiat→crypto, off-ramp = crypto→fiat) | -| **Ephemeral account** | A temporary blockchain account created per ramp, used for signing transactions, then discarded | -| **Phase** | A discrete step in the ramp state machine (e.g., `nablaSwap`, `distributeFees`) | -| **Nabla** | DEX on Pendulum used for token swaps | -| **XCM** | Cross-Consensus Messaging — the cross-chain transfer protocol between Polkadot parachains | -| **BRLA** | Brazilian Real stablecoin anchor (BRL on/off-ramp) | -| **Mykobo** | EUR fiat anchor for SEPA on/off-ramp on Base (settles EURC on Base; currently registration-gated) | -| **Monerium** | European e-money provider used for OAuth-based KYC/KYB verification and EUR profile status. | -| **Alfredpay** | Fiat payment provider supporting multiple currencies | -| **Binance** | Crypto exchange whose USDT/fiat spot ticker is the primary USD-to-fiat rate source for currencies with a liquid market (currently BRL via `USDTBRL`) | -| **FastForex** | Fiat exchange-rate provider used as the USD-to-fiat rate source for currencies without a Binance market, and the fallback after Binance for those that have one | -| **Squid Router** | Cross-chain swap/routing protocol for EVM chains | -| **Axelar** | Cross-chain messaging protocol used by SquidRouter for EVM-to-EVM bridging | -| **Avenia** | BRLA's internal settlement platform; handles BRLA transfers, swaps, and PIX payouts | -| **Subsidization** | When the platform tops up an ephemeral account to ensure the user receives the quoted amount | -| **pk\_/sk\_** | Public key / Secret key prefixes for the dual API key system | -| **PIX** | Brazilian instant payment system | -| **SEPA** | Single Euro Payments Area — European bank transfer system | -| **Coverage ratio** | Reserve ÷ liabilities for a Nabla swap pool; ratio > 1 means the pool is over-collateralized and triggers rebalancing | -| **Request ID** | Non-secret correlation identifier generated or propagated by the API for log/event debugging | -| **Client event** | Sanitized operational record of a partner-facing API request outcome | +| Term | Definition | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Ramp** | A conversion between fiat and crypto (on-ramp = fiat→crypto, off-ramp = crypto→fiat) | +| **Ephemeral account** | A temporary blockchain account created per ramp, used for signing transactions, then discarded | +| **Phase** | A discrete step in the ramp state machine (e.g., `nablaSwap`, `distributeFees`) | +| **Nabla** | DEX on Pendulum used for token swaps | +| **XCM** | Cross-Consensus Messaging — the cross-chain transfer protocol between Polkadot parachains | +| **BRLA** | Brazilian Real stablecoin anchor (BRL on/off-ramp) | +| **Mykobo** | EUR fiat anchor for SEPA on/off-ramp on Base (settles EURC on Base; currently registration-gated) | +| **Monerium** | European e-money provider used for OAuth-based KYC/KYB verification and EUR profile status. | +| **Alfredpay** | Fiat payment provider supporting multiple currencies | +| **Binance** | Crypto exchange whose USDT/fiat spot ticker is the primary USD-to-fiat rate source for currencies with a liquid market (currently BRL via `USDTBRL`) | +| **FastForex** | Fiat exchange-rate provider used as the USD-to-fiat rate source for currencies without a Binance market, and the fallback after Binance for those that have one | +| **Squid Router** | Cross-chain swap/routing protocol for EVM chains | +| **Axelar** | Cross-chain messaging protocol used by SquidRouter for EVM-to-EVM bridging | +| **Avenia** | BRLA's internal settlement platform; handles BRLA transfers, swaps, and PIX payouts | +| **Subsidization** | When the platform tops up an ephemeral account to ensure the user receives the quoted amount | +| **pk\_/sk\_** | Public key / Secret key prefixes for the dual API key system | +| **PIX** | Brazilian instant payment system | +| **SEPA** | Single Euro Payments Area — European bank transfer system | +| **Coverage ratio** | Reserve ÷ liabilities for a Nabla swap pool; ratio > 1 means the pool is over-collateralized and triggers rebalancing | +| **Request ID** | Non-secret correlation identifier generated or propagated by the API for log/event debugging | +| **Client event** | Sanitized operational record of a partner-facing API request outcome | +| **Managed-profile owner** | Immutable authenticated profile in `managed_profiles.manager_profile_id`; supplies child policy, pricing fallback, namespace, and lifecycle authority | +| **Managed-profile membership** | Revocable authorization edge from one authenticated member profile to one managed child | +| **Membership role** | Child-scoped `manager` or `read_only` permission; separate from global `profile_roles` | +| **Membership invitation** | Seven-day email-bound offer that grants no access until explicitly accepted by the exact verified Supabase email | diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index 928aa2e53..a94f163bf 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -20,29 +20,29 @@ register and the owning module specification. ## Current risks -| ID | Status | Severity | Owner role | Scope and decision | Existing controls | Revisit / exit criteria | -|---|---|---:|---|---|---|---| -| RISK-001 | Accepted | High | Platform + Finance | Subsidy limits are per component/ramp; there is no atomically reserved principal, partner, corridor, funding-wallet, or rolling-window budget. Current aggregate behavior is preserved. | Quote-bound amounts, per-component caps, fail-closed USD valuation, durable operation claims, funding-wallet balance. | Before materially increasing volume, adding concurrent workers, or widening subsidy-eligible corridors. | -| RISK-002 | Accepted | Medium | Operations | Administrative writes on the shared-secret `/v1/admin/*` surface use one `ADMIN_SECRET`; there is no individual principal, MFA, role separation, selective revocation, or per-operator attribution on that surface. | Independent high-entropy secret, constant-time equal-length comparison, route middleware, rate limiting, operational rotation. `HTTP_GRANTABLE_PROFILE_ROLES` additionally prevents this shared secret from granting `vortex_admin` (`admin-auth.md` Invariant 8), so it cannot bootstrap its way onto the identity-bearing `/v1/admin-console/*` surface. | Introduce an identity provider before broadening the `/v1/admin/*` surface or team access. The Supabase-authenticated, role-gated `/v1/admin-console/*` surface (RISK-018) satisfies this warning for its own bounded scope by using per-operator identity instead of a shared secret; `/v1/admin/*` itself is unchanged and this entry still applies to it. | -| RISK-003 | Accepted | Medium | Product + Security | Pending recipient invitations retain the raw bearer token so the sender can re-copy the link. | 192-bit random token, 14-day TTL, hash-only redemption lookup, sender-scoped listing, optional email binding, first-redeemer binding, raw token cleared on acceptance/observed expiry. | Revisit if invitations gain money-movement authority or threat exposure changes. | -| RISK-004 | Deferred | High | Product + Payments Architecture | Recipient eligibility is advisory; recipient-directed payout is unsupported. Ramp registration is a sender self-offramp and rejects common recipient-context fields. | Authenticated/entity-scoped recipient APIs; explicit registration rejection prevents accidental reliance on ignored fields. | A separate PR must define the second principal, relationship ownership, hard eligibility gate, and provider-side payout reference resolution before enabling recipient payout. | -| RISK-005 | Accepted | Medium | Product + Operations | The product promises the exact quoted amount. A ramp does not downgrade that promise or report a lesser amount as successful when automated delivery cannot complete. | Exact quote-bound targets, balance checks, capped subsidy paths, recoverable/terminal phase states, reconciliation data. | Add a formal deadline and automatic return of in-transit funds without weakening the exact-amount promise. | -| RISK-006 | Accepted | Medium | Client Platform | Widget/dashboard recovery keys are retained until a terminal ramp state is observed, then for 90 days; unresolved ramps are retained indefinitely. The prototype browser SDK backup has no automatic terminal pruning and remains in plaintext same-origin localStorage until the integrator removes it. | Route-scoped freshness; widget/dashboard pruning on storage access; explicit browser-SDK documentation and origin allowlisting. | Add a browser storage adapter and terminal-aware pruning before presenting browser SDK custody as a hardened production default, or sooner if storage pressure or client compromise data warrants it. | -| RISK-007 | Deployment pending | High | Smart Contracts + Operations | TokenRelayer source rejects fee-on-transfer shortfalls, partial consumption, codeless destinations, and cross-execution balance subsidy. Existing deployed addresses do not inherit the fix. Automatic discrepancy subsidy is intentionally absent because no immutable cap/funding policy has been approved. | Execution-local token/native balance accounting, exact transient allowance, refund attribution, events, contract tests. | Redeploy and verify bytecode on every supported chain, update the address registry, retire old deployments, and record rollout evidence. | -| RISK-008 | Accepted | High | Payments Platform | Squid/Axelar terminal status is preferred, but an EVM destination-balance fallback remains necessary because provider indexing can miss real arrivals. The fallback waits for baseline plus 90% of the exact route output; a remainder racing the final pre-claim balance read and broadcast can still overfund the ephemeral. | Route/source/token/amount/baseline-bound persisted evidence, explicit fallback kind and ratio, structured logging, per-ramp settlement cap, and two live shortfall reads inside the funding FIFO before the durable claim. This prevents queue delay from making the subsidy stale. | Add provider receipt proof or late-arrival reconciliation/recovery before raising caps or expanding exposure. | -| RISK-009 | Deferred | High | Cross-chain Platform | Dormant BRL↔AssetHub executors retain XCM evidence exceptions: Pendulum→AssetHub re-entry trusts an internally persisted finalized source block without proving AssetHub arrival, and Pendulum→Moonbeam recovery may use source depletion. | Quote creation, registration, presign updates, start, phase execution, and automatic recovery reject, hold, or skip Moonbeam-dependent ramps; the catalog remains only for persisted schema/history compatibility. | Destination receipt/balance-delta proof and durable ambiguous-broadcast recovery are release blockers before re-enabling execution or automatic recovery. | -| RISK-010 | Accepted | Medium | Payments Architecture | Fee distribution is final even if a later phase fails; there is no fee-refund path. | Per-flow fee ordering, later-phase recovery/retry, exact phase metadata and durable external-operation claims. | Revisit when implementing automatic failure deadlines/refunds. | -| RISK-011 | Accepted | High | Infrastructure + Security | Application secrets are environment variables; there is no integrated secrets manager, access audit, dual-secret rollout, or automated rotation. | Deployment access controls, independent credentials, startup/runtime presence checks on security-critical paths, operational rotation. | Adopt managed secret storage and dual-key rotation before materially expanding privileged operators or deployments. | -| RISK-012 | Accepted | High | Rebalancer Operations | Rebalancer state has no distributed lock and several externally visible steps can be ambiguous across a crash; single-run scheduling is an operational assumption. | One-shot process, saved state, chain nonces/balance checks on some steps, daily bridge limit and route-cost policy. | Add a lease and durable operation claims before allowing overlapping schedules or multiple replicas. | -| RISK-014 | Accepted | Medium | Pricing + Treasury | CoinGecko’s `usd-coin` price is used as a USD/fiat fallback or sanity reference, so a USDC depeg can distort the reference. | FastForex/Binance primary routes, sanity bands, short cache TTL, fail-closed when no valid provider remains, operational depeg monitoring. | Replace with an independent fiat FX reference before raising depeg-sensitive exposure. | -| RISK-015 | Accepted | Low | EVM Operations | Base cleanup sweeps supported ERC-20 residuals after completion but does not sweep small native gas dust. | Just-in-time gas funding and token sweeps limit residual value. | Revisit if observed native residuals become material. | -| RISK-016 | Deferred | High | Payments Platform | Failed/timed-out Base ramps are not swept by the Base post-process handler, and AssetHub cleanup is a no-op. | Cleanup worker selects terminal ramps; completed Base token cleanup works; AssetHub corridors remain quote-disabled. | Widen safe Base cleanup to failed/timed-out states and implement/remove AssetHub cleanup before enabling AssetHub or relying on automatic failure refunds. | -| RISK-017 | Deferred | High | Operations + Data | Migrations 060-061 permanently delete legacy provider/KYC/credential records and schema objects. Approved legacy-only data has no archive or database down migration; unknown consumers, old processes, incomplete canonical mappings, or an unusable backup could turn deployment into unrecoverable loss or outage. | Fail-closed parity script, maintenance-window hard cutover and process drain, PostgreSQL catalog/external-consumer audit, five-second lock timeout, default `RESTRICT`, and rehearsed pre-migration restore. | Complete and retain every gate in [`operations-legacy-schema-cleanup.md`](../operations-legacy-schema-cleanup.md), verify production cleanup, and confirm the post-deploy observation window is clean. | -| RISK-018 | Accepted | High | Operations + Security | `/v1/admin-console/*` lets a `vortex_admin` operator impersonate a customer with broad read and mutation rights. Ramp registration, update, and start; provider onboarding and KYC/KYB mutations; managed-child creation/deletion; and manager/child credential creation/revocation are denied. Quote generation, recipient, active-entity, notification, and other customer-account operations remain available. Alfredpay fiat-account creation and deletion are explicitly accepted even though these provider-side payout-account mutations outlive the session. | Per-operator Supabase identity plus per-request `vortex_admin` re-check; role removal atomically revokes live sessions; 30-minute non-renewable TTL; transaction-serialized and database-unique active session per (actor, target); hash-only token storage so a leaked row cannot be replayed and revocation is instant; `IMPERSONATION_ENABLED` kill switch that invalidates in-flight sessions, not just new mints; `rejectImpersonation` blocks ramp money movement, KYC/KYB mutations, managed-child lifecycle, manager/child credential lifecycle, and re-entry into the admin console during an impersonated request, with a narrow self-revoke carve-out on `DELETE /impersonation/:sessionId`; `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` so `ADMIN_SECRET` cannot grant it; `impersonationSessionId`/`impersonatorProfileId` stamped on every `api_client_events` row raised during the request; actor/target foreign keys restrict deletion so audit history is retained. | Revisit before changing the allowed mutation scope; managed sub-account composition has been reviewed with lifecycle and credential mutations denied. See `01-auth/admin-impersonation.md`. | -| RISK-019 | Accepted | High | Product + Compliance | Managed-profile contact email uniqueness is manager-scoped, while Alfredpay uses email as provider identity. Different managers can submit the same normalized email; on an Alfredpay `409`, Vortex may adopt the provider customer returned for that email when country and customer type match, without independent proof that the second manager controls that provider identity. | Manager/child authorization remains isolated; contact email is immutable and unique within one manager; conflict recovery rejects country/type mismatch; the provider customer ID remains globally unique locally. Partners must supply an email identity they are authorized to use, and operations must investigate cross-manager collision errors rather than bypass uniqueness. | Before onboarding managers whose customer-email namespaces may overlap, enforce global or provider-scoped ownership of contact email, or replace email-based adoption with a provider ownership/claim proof and migrate existing relationships. | -| RISK-020 | Deferred | High | Cross-chain + Operations | Moonbeam is unavailable. Historical ramps, residual ephemeral funds, and legacy rebalancer state may remain stranded. A successful `moonbeamCleanup` now records retirement acknowledgement rather than an on-chain sweep. | Moonbeam-dependent registration/update/start, phase execution, automatic recovery, status polling, and legacy rebalancing are disabled without deleting persisted flow identities or recovery data. | Reconcile every affected ramp/account and complete a reviewed manual rescue before restoring any Moonbeam runtime path or automatic recovery. | -| RISK-021 | Accepted | High | Product + Compliance + Operations | Individual Avenia token-import code is enabled despite unresolved provider, legal, consent, and live-sandbox production-readiness confirmations. The caller is responsible for placing the canonical CPF in the source Sumsub applicant's TIN field; when Avenia omits `accountInfo.taxId`, Vortex accepts provider approval without independently comparing that CPF. This accepts enabled source behavior, not production readiness or evidence that any confirmation exists. | Auth-first profile binding, immutable KYC method selection, durable no-replay claims, exact-attempt polling, rejection of non-empty CPF mismatches, provisional consent evidence, and sensitive-data redaction. | Production rollout requires every [blocking confirmation and sandbox contract flow](../proposal-sumsub-kyc-token-sharing.md#blocking-confirmations) in the proposal. Add mandatory provider-returned CPF validation if caller responsibility proves insufficient. | -| RISK-022 | Accepted | Medium | Product + Compliance + Operations | Avenia API and hosted KYB submission do not persist a durable pre-send claim. An ambiguous provider success or concurrent retry can therefore leave an unbound or superseded provider attempt. The low current KYB volume does not justify the additional submission-state machinery. | Provider active-attempt preflight, API conflict reconciliation, exact bound-attempt polling, and fail-closed handling of multiple active attempts. | Add a durable submission claim before increasing KYB volume, relying on unattended recovery, or observing duplicate or orphaned attempts operationally. | +| ID | Status | Severity | Owner role | Scope and decision | Existing controls | Revisit / exit criteria | +| -------- | ------------------ | -------: | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| RISK-001 | Accepted | High | Platform + Finance | Subsidy limits are per component/ramp; there is no atomically reserved principal, partner, corridor, funding-wallet, or rolling-window budget. Current aggregate behavior is preserved. | Quote-bound amounts, per-component caps, fail-closed USD valuation, durable operation claims, funding-wallet balance. | Before materially increasing volume, adding concurrent workers, or widening subsidy-eligible corridors. | +| RISK-002 | Accepted | Medium | Operations | Administrative writes on the shared-secret `/v1/admin/*` surface use one `ADMIN_SECRET`; there is no individual principal, MFA, role separation, selective revocation, or per-operator attribution on that surface. | Independent high-entropy secret, constant-time equal-length comparison, route middleware, rate limiting, operational rotation. `HTTP_GRANTABLE_PROFILE_ROLES` additionally prevents this shared secret from granting `vortex_admin` (`admin-auth.md` Invariant 8), so it cannot bootstrap its way onto the identity-bearing `/v1/admin-console/*` surface. | Introduce an identity provider before broadening the `/v1/admin/*` surface or team access. The Supabase-authenticated, role-gated `/v1/admin-console/*` surface (RISK-018) satisfies this warning for its own bounded scope by using per-operator identity instead of a shared secret; `/v1/admin/*` itself is unchanged and this entry still applies to it. | +| RISK-003 | Accepted | Medium | Product + Security | Pending recipient invitations retain the raw bearer token so the sender can re-copy the link. | 192-bit random token, 14-day TTL, hash-only redemption lookup, sender-scoped listing, optional email binding, first-redeemer binding, raw token cleared on acceptance/observed expiry. | Revisit if invitations gain money-movement authority or threat exposure changes. | +| RISK-004 | Deferred | High | Product + Payments Architecture | Recipient eligibility is advisory; recipient-directed payout is unsupported. Ramp registration is a sender self-offramp and rejects common recipient-context fields. | Authenticated/entity-scoped recipient APIs; explicit registration rejection prevents accidental reliance on ignored fields. | A separate PR must define the second principal, relationship ownership, hard eligibility gate, and provider-side payout reference resolution before enabling recipient payout. | +| RISK-005 | Accepted | Medium | Product + Operations | The product promises the exact quoted amount. A ramp does not downgrade that promise or report a lesser amount as successful when automated delivery cannot complete. | Exact quote-bound targets, balance checks, capped subsidy paths, recoverable/terminal phase states, reconciliation data. | Add a formal deadline and automatic return of in-transit funds without weakening the exact-amount promise. | +| RISK-006 | Accepted | Medium | Client Platform | Widget/dashboard recovery keys are retained until a terminal ramp state is observed, then for 90 days; unresolved ramps are retained indefinitely. The prototype browser SDK backup has no automatic terminal pruning and remains in plaintext same-origin localStorage until the integrator removes it. | Route-scoped freshness; widget/dashboard pruning on storage access; explicit browser-SDK documentation and origin allowlisting. | Add a browser storage adapter and terminal-aware pruning before presenting browser SDK custody as a hardened production default, or sooner if storage pressure or client compromise data warrants it. | +| RISK-007 | Deployment pending | High | Smart Contracts + Operations | TokenRelayer source rejects fee-on-transfer shortfalls, partial consumption, codeless destinations, and cross-execution balance subsidy. Existing deployed addresses do not inherit the fix. Automatic discrepancy subsidy is intentionally absent because no immutable cap/funding policy has been approved. | Execution-local token/native balance accounting, exact transient allowance, refund attribution, events, contract tests. | Redeploy and verify bytecode on every supported chain, update the address registry, retire old deployments, and record rollout evidence. | +| RISK-008 | Accepted | High | Payments Platform | Squid/Axelar terminal status is preferred, but an EVM destination-balance fallback remains necessary because provider indexing can miss real arrivals. The fallback waits for baseline plus 90% of the exact route output; a remainder racing the final pre-claim balance read and broadcast can still overfund the ephemeral. | Route/source/token/amount/baseline-bound persisted evidence, explicit fallback kind and ratio, structured logging, per-ramp settlement cap, and two live shortfall reads inside the funding FIFO before the durable claim. This prevents queue delay from making the subsidy stale. | Add provider receipt proof or late-arrival reconciliation/recovery before raising caps or expanding exposure. | +| RISK-009 | Deferred | High | Cross-chain Platform | Dormant BRL↔AssetHub executors retain XCM evidence exceptions: Pendulum→AssetHub re-entry trusts an internally persisted finalized source block without proving AssetHub arrival, and Pendulum→Moonbeam recovery may use source depletion. | Quote creation, registration, presign updates, start, phase execution, and automatic recovery reject, hold, or skip Moonbeam-dependent ramps; the catalog remains only for persisted schema/history compatibility. | Destination receipt/balance-delta proof and durable ambiguous-broadcast recovery are release blockers before re-enabling execution or automatic recovery. | +| RISK-010 | Accepted | Medium | Payments Architecture | Fee distribution is final even if a later phase fails; there is no fee-refund path. | Per-flow fee ordering, later-phase recovery/retry, exact phase metadata and durable external-operation claims. | Revisit when implementing automatic failure deadlines/refunds. | +| RISK-011 | Accepted | High | Infrastructure + Security | Application secrets are environment variables; there is no integrated secrets manager, access audit, dual-secret rollout, or automated rotation. | Deployment access controls, independent credentials, startup/runtime presence checks on security-critical paths, operational rotation. | Adopt managed secret storage and dual-key rotation before materially expanding privileged operators or deployments. | +| RISK-012 | Accepted | High | Rebalancer Operations | Rebalancer state has no distributed lock and several externally visible steps can be ambiguous across a crash; single-run scheduling is an operational assumption. | One-shot process, saved state, chain nonces/balance checks on some steps, daily bridge limit and route-cost policy. | Add a lease and durable operation claims before allowing overlapping schedules or multiple replicas. | +| RISK-014 | Accepted | Medium | Pricing + Treasury | CoinGecko’s `usd-coin` price is used as a USD/fiat fallback or sanity reference, so a USDC depeg can distort the reference. | FastForex/Binance primary routes, sanity bands, short cache TTL, fail-closed when no valid provider remains, operational depeg monitoring. | Replace with an independent fiat FX reference before raising depeg-sensitive exposure. | +| RISK-015 | Accepted | Low | EVM Operations | Base cleanup sweeps supported ERC-20 residuals after completion but does not sweep small native gas dust. | Just-in-time gas funding and token sweeps limit residual value. | Revisit if observed native residuals become material. | +| RISK-016 | Deferred | High | Payments Platform | Failed/timed-out Base ramps are not swept by the Base post-process handler, and AssetHub cleanup is a no-op. | Cleanup worker selects terminal ramps; completed Base token cleanup works; AssetHub corridors remain quote-disabled. | Widen safe Base cleanup to failed/timed-out states and implement/remove AssetHub cleanup before enabling AssetHub or relying on automatic failure refunds. | +| RISK-017 | Deferred | High | Operations + Data | Migrations 060-061 permanently delete legacy provider/KYC/credential records and schema objects. Approved legacy-only data has no archive or database down migration; unknown consumers, old processes, incomplete canonical mappings, or an unusable backup could turn deployment into unrecoverable loss or outage. | Fail-closed parity script, maintenance-window hard cutover and process drain, PostgreSQL catalog/external-consumer audit, five-second lock timeout, default `RESTRICT`, and rehearsed pre-migration restore. | Complete and retain every gate in [`operations-legacy-schema-cleanup.md`](../operations-legacy-schema-cleanup.md), verify production cleanup, and confirm the post-deploy observation window is clean. | +| RISK-018 | Accepted | High | Operations + Security | `/v1/admin-console/*` lets a `vortex_admin` operator impersonate a customer with broad read and mutation rights. Ramp registration, update, and start; provider onboarding and KYC/KYB mutations; managed-child creation/deletion; membership/invitation mutations; and manager/child credential creation/revocation are denied. Quote generation, recipient, active-entity, notification, and other customer-account operations remain available. Alfredpay fiat-account creation and deletion are explicitly accepted even though these provider-side payout-account mutations outlive the session. | Per-operator Supabase identity plus per-request `vortex_admin` re-check; role removal atomically revokes live sessions; 30-minute non-renewable TTL; transaction-serialized and database-unique active session per (actor, target); hash-only token storage so a leaked row cannot be replayed and revocation is instant; `IMPERSONATION_ENABLED` kill switch that invalidates in-flight sessions, not just new mints; `rejectImpersonation` blocks ramp money movement, KYC/KYB mutations, managed-child lifecycle, membership/invitation mutation, manager/child credential lifecycle, and re-entry into the admin console during an impersonated request, with a narrow self-revoke carve-out on `DELETE /impersonation/:sessionId`; `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` so `ADMIN_SECRET` cannot grant it; `impersonationSessionId`/`impersonatorProfileId` stamped on every `api_client_events` row raised during the request; actor/target foreign keys restrict deletion so audit history is retained. | Revisit before changing the allowed mutation scope; managed membership composition has been reviewed with access, lifecycle, and credential mutations denied. See `01-auth/admin-impersonation.md`. | +| RISK-019 | Accepted | High | Product + Compliance | Managed-profile contact email uniqueness is manager-scoped, while Alfredpay uses email as provider identity. Different managers can submit the same normalized email; on an Alfredpay `409`, Vortex may adopt the provider customer returned for that email when country and customer type match, without independent proof that the second manager controls that provider identity. | Manager/child authorization remains isolated; contact email is immutable and unique within one manager; conflict recovery rejects country/type mismatch; the provider customer ID remains globally unique locally. Partners must supply an email identity they are authorized to use, and operations must investigate cross-manager collision errors rather than bypass uniqueness. | Before onboarding managers whose customer-email namespaces may overlap, enforce global or provider-scoped ownership of contact email, or replace email-based adoption with a provider ownership/claim proof and migrate existing relationships. | +| RISK-020 | Deferred | High | Cross-chain + Operations | Moonbeam is unavailable. Historical ramps, residual ephemeral funds, and legacy rebalancer state may remain stranded. A successful `moonbeamCleanup` now records retirement acknowledgement rather than an on-chain sweep. | Moonbeam-dependent registration/update/start, phase execution, automatic recovery, status polling, and legacy rebalancing are disabled without deleting persisted flow identities or recovery data. | Reconcile every affected ramp/account and complete a reviewed manual rescue before restoring any Moonbeam runtime path or automatic recovery. | +| RISK-021 | Accepted | High | Product + Compliance + Operations | Individual Avenia token-import code is enabled despite unresolved provider, legal, consent, and live-sandbox production-readiness confirmations. The caller is responsible for placing the canonical CPF in the source Sumsub applicant's TIN field; when Avenia omits `accountInfo.taxId`, Vortex accepts provider approval without independently comparing that CPF. This accepts enabled source behavior, not production readiness or evidence that any confirmation exists. | Auth-first profile binding, immutable KYC method selection, durable no-replay claims, exact-attempt polling, rejection of non-empty CPF mismatches, provisional consent evidence, and sensitive-data redaction. | Production rollout requires every [blocking confirmation and sandbox contract flow](../proposal-sumsub-kyc-token-sharing.md#blocking-confirmations) in the proposal. Add mandatory provider-returned CPF validation if caller responsibility proves insufficient. | +| RISK-022 | Accepted | Medium | Product + Compliance + Operations | Avenia API and hosted KYB submission do not persist a durable pre-send claim. An ambiguous provider success or concurrent retry can therefore leave an unbound or superseded provider attempt. The low current KYB volume does not justify the additional submission-state machinery. | Provider active-attempt preflight, API conflict reconciliation, exact bound-attempt polling, and fail-closed handling of multiple active attempts. | Add a durable submission claim before increasing KYB volume, relying on unattended recovery, or observing duplicate or orphaned attempts operationally. | ## Review cadence From a4742e2ce514656436cda850547432946f35d879 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 7 Sep 2026 15:17:03 -0300 Subject: [PATCH 02/11] feat(api): add managed profile membership storage --- ...naged-profile-provisioning.service.test.ts | 18 +- .../managed-profile-provisioning.service.ts | 21 + ...naged-profile-memberships-rollback.test.ts | 37 ++ ...managed-profile-memberships-schema.test.ts | 342 +++++++++++++ .../database/managed-profiles-schema.test.ts | 5 + .../069-create-managed-profile-memberships.ts | 479 ++++++++++++++++++ apps/api/src/models/index.ts | 69 +++ .../models/managedProfileMembership.model.ts | 95 ++++ .../managedProfileMembershipEvent.model.ts | 104 ++++ ...anagedProfileMembershipInvitation.model.ts | 109 ++++ 10 files changed, 1278 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/database/069-create-managed-profile-memberships-rollback.test.ts create mode 100644 apps/api/src/database/managed-profile-memberships-schema.test.ts create mode 100644 apps/api/src/database/migrations/069-create-managed-profile-memberships.ts create mode 100644 apps/api/src/models/managedProfileMembership.model.ts create mode 100644 apps/api/src/models/managedProfileMembershipEvent.model.ts create mode 100644 apps/api/src/models/managedProfileMembershipInvitation.model.ts diff --git a/apps/api/src/api/services/managed-profile-provisioning.service.test.ts b/apps/api/src/api/services/managed-profile-provisioning.service.test.ts index 9f14a6e93..45aaccaa4 100644 --- a/apps/api/src/api/services/managed-profile-provisioning.service.test.ts +++ b/apps/api/src/api/services/managed-profile-provisioning.service.test.ts @@ -2,6 +2,8 @@ import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; import CustomerEntity from "../../models/customerEntity.model"; import ManagedProfile from "../../models/managedProfile.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; +import ManagedProfileMembership from "../../models/managedProfileMembership.model"; +import ManagedProfileMembershipEvent from "../../models/managedProfileMembershipEvent.model"; import User from "../../models/user.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; import { createTestUser } from "../../test-utils/factories"; @@ -31,7 +33,7 @@ describe("managed profile provisioning", () => { beforeAll(setupTestDatabase); beforeEach(resetTestDatabase); - it("atomically creates the headless profile, customer entity, and relationship", async () => { + it("atomically creates the headless profile, customer entity, relationship, and owner membership", async () => { const manager = await createManager(); const result = await provisionManagedProfile({ @@ -63,6 +65,18 @@ describe("managed profile provisioning", () => { profileId: result.profileId, status: "active" }); + expect(await ManagedProfileMembership.findOne({ where: { managedProfileId: result.profileId } })).toMatchObject({ + createdByProfileId: manager.id, + memberProfileId: manager.id, + revokedAt: null, + role: "manager" + }); + expect(await ManagedProfileMembershipEvent.findOne({ where: { managedProfileId: result.profileId } })).toMatchObject({ + action: "member_added", + actorProfileId: manager.id, + memberProfileId: manager.id, + role: "manager" + }); }); it("returns the existing profile for an idempotent retry", async () => { @@ -82,6 +96,8 @@ describe("managed profile provisioning", () => { expect(await User.count({ where: { kind: "managed" } })).toBe(1); expect(await CustomerEntity.count({ where: { profileId: created.profileId } })).toBe(1); expect(await ManagedProfile.count()).toBe(1); + expect(await ManagedProfileMembership.count()).toBe(1); + expect(await ManagedProfileMembershipEvent.count()).toBe(1); }); it("serializes concurrent retries for the same external subject", async () => { diff --git a/apps/api/src/api/services/managed-profile-provisioning.service.ts b/apps/api/src/api/services/managed-profile-provisioning.service.ts index c919506fe..1b6b13309 100644 --- a/apps/api/src/api/services/managed-profile-provisioning.service.ts +++ b/apps/api/src/api/services/managed-profile-provisioning.service.ts @@ -5,6 +5,8 @@ import type { CustomerEntityType } from "../../models/customerEntity.model"; import CustomerEntity from "../../models/customerEntity.model"; import ManagedProfile, { type ManagedProfileCreationSource } from "../../models/managedProfile.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; +import ManagedProfileMembership from "../../models/managedProfileMembership.model"; +import ManagedProfileMembershipEvent from "../../models/managedProfileMembershipEvent.model"; import User from "../../models/user.model"; export class ManagedProfileProvisioningError extends Error { @@ -143,6 +145,25 @@ export async function provisionManagedProfile(input: ProvisionManagedProfileInpu }, { transaction } ); + await ManagedProfileMembership.create( + { + createdByProfileId: input.managerProfileId, + managedProfileId: profile.id, + memberProfileId: input.managerProfileId, + role: "manager" + }, + { transaction } + ); + await ManagedProfileMembershipEvent.create( + { + action: "member_added", + actorProfileId: input.managerProfileId, + managedProfileId: profile.id, + memberProfileId: input.managerProfileId, + role: "manager" + }, + { transaction } + ); return { contactEmail, diff --git a/apps/api/src/database/069-create-managed-profile-memberships-rollback.test.ts b/apps/api/src/database/069-create-managed-profile-memberships-rollback.test.ts new file mode 100644 index 000000000..b37f942af --- /dev/null +++ b/apps/api/src/database/069-create-managed-profile-memberships-rollback.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, mock } from "bun:test"; +import type { QueryInterface } from "sequelize"; +import { down } from "./migrations/069-create-managed-profile-memberships"; + +describe("069 managed-profile memberships rollback", () => { + it("refuses to discard membership activity", async () => { + const dropTable = mock(async () => undefined); + const query = mock(async (sql: string) => { + if (sql.includes('AS "hasEvents"')) { + return [[{ hasEvents: true }], {}]; + } + return [[], {}]; + }); + const transaction = {}; + const queryInterface = { + dropTable, + sequelize: { + query, + transaction: async (callback: (value: object) => Promise) => callback(transaction) + } + } as unknown as QueryInterface; + + await expect(down(queryInterface)).rejects.toThrow( + "Cannot revert managed-profile memberships after membership activity has been recorded" + ); + expect(query).toHaveBeenCalledWith( + `LOCK TABLE + managed_profile_membership_events, + managed_profile_membership_invitations, + managed_profile_memberships, + managed_profiles + IN ACCESS EXCLUSIVE MODE;`, + { transaction } + ); + expect(dropTable).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/database/managed-profile-memberships-schema.test.ts b/apps/api/src/database/managed-profile-memberships-schema.test.ts new file mode 100644 index 000000000..075bb875f --- /dev/null +++ b/apps/api/src/database/managed-profile-memberships-schema.test.ts @@ -0,0 +1,342 @@ +import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { QueryTypes } from "sequelize"; +import sequelize from "../config/database"; +import ManagedProfile from "../models/managedProfile.model"; +import ManagedProfileManager from "../models/managedProfileManager.model"; +import ManagedProfileMembership from "../models/managedProfileMembership.model"; +import ManagedProfileMembershipEvent from "../models/managedProfileMembershipEvent.model"; +import ManagedProfileMembershipInvitation from "../models/managedProfileMembershipInvitation.model"; +import User from "../models/user.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestUser } from "../test-utils/factories"; +import { down, up } from "./migrations/069-create-managed-profile-memberships"; + +async function createManager(): Promise { + const profile = await createTestUser(); + await ManagedProfileManager.create({ allowedCorridors: ["BR"], profileId: profile.id }); + return profile; +} + +async function createManagedProfile( + managerProfileId: string, + externalSubjectId: string, + status: "active" | "deleted" = "active" +): Promise { + return sequelize.transaction(async transaction => { + const profile = await User.create({ email: null, id: crypto.randomUUID(), kind: "managed" }, { transaction }); + await ManagedProfile.create( + { + creationSource: "manager", + deletedAt: status === "deleted" ? new Date() : null, + externalSubjectId, + managerProfileId, + profileId: profile.id, + status + }, + { transaction } + ); + await ManagedProfileMembership.create( + { managedProfileId: profile.id, memberProfileId: managerProfileId, role: "manager" }, + { transaction } + ); + return profile; + }); +} + +describe("managed profile membership schema", () => { + beforeAll(setupTestDatabase); + beforeEach(resetTestDatabase); + + it("backfills an owner manager membership for every retained relationship", async () => { + const queryInterface = sequelize.getQueryInterface(); + await down(queryInterface); + let defaultPrivilegesGranted = false; + + try { + await sequelize.query(`DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN CREATE ROLE anon; END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN CREATE ROLE authenticated; END IF; + END; + $$;`); + await sequelize.query("ALTER DEFAULT PRIVILEGES GRANT ALL PRIVILEGES ON TABLES TO anon, authenticated;"); + defaultPrivilegesGranted = true; + const manager = await createManager(); + const profileIds = await sequelize.transaction(async transaction => { + const active = await User.create( + { email: null, id: crypto.randomUUID(), kind: "managed" }, + { transaction } + ); + const deleted = await User.create( + { email: null, id: crypto.randomUUID(), kind: "managed" }, + { transaction } + ); + await ManagedProfile.bulkCreate( + [ + { + creationSource: "manager", + externalSubjectId: "pre-membership-active", + managerProfileId: manager.id, + profileId: active.id + }, + { + creationSource: "manager", + deletedAt: new Date(), + externalSubjectId: "pre-membership-deleted", + managerProfileId: manager.id, + profileId: deleted.id, + status: "deleted" + } + ], + { transaction } + ); + return [active.id, deleted.id]; + }); + + await up(queryInterface); + + const memberships = await ManagedProfileMembership.findAll({ order: [["managedProfileId", "ASC"]] }); + expect(memberships).toHaveLength(2); + expect(memberships.map(membership => membership.managedProfileId).sort()).toEqual(profileIds.sort()); + expect(memberships.every(membership => membership.memberProfileId === manager.id)).toBe(true); + expect(memberships.every(membership => membership.role === "manager" && membership.revokedAt === null)).toBe(true); + expect(memberships.every(membership => membership.createdByProfileId === null)).toBe(true); + expect(await ManagedProfileMembershipEvent.count()).toBe(0); + + await down(queryInterface); + const [droppedTable] = await sequelize.query<{ name: string | null }>( + "SELECT to_regclass('public.managed_profile_memberships')::text AS name", + { type: QueryTypes.SELECT } + ); + expect(droppedTable?.name).toBeNull(); + await up(queryInterface); + } finally { + if (defaultPrivilegesGranted) { + await sequelize.query("ALTER DEFAULT PRIVILEGES REVOKE ALL PRIVILEGES ON TABLES FROM anon, authenticated;"); + } + const [table] = await sequelize.query<{ present: boolean }>( + "SELECT to_regclass('public.managed_profile_memberships') IS NOT NULL AS present", + { type: QueryTypes.SELECT } + ); + if (!table?.present) await up(queryInterface); + } + }); + + it("requires active children to have an active owner manager membership", async () => { + const manager = await createManager(); + + await expect( + sequelize.transaction(async transaction => { + const profile = await User.create( + { email: null, id: crypto.randomUUID(), kind: "managed" }, + { transaction } + ); + await ManagedProfile.create( + { + creationSource: "manager", + externalSubjectId: "missing-owner-membership", + managerProfileId: manager.id, + profileId: profile.id + }, + { transaction } + ); + }) + ).rejects.toThrow("Active managed profiles require an active owner manager membership"); + }); + + it("requires authenticated members and a retained managed-profile target", async () => { + const manager = await createManager(); + const child = await createManagedProfile(manager.id, "membership-kind-child"); + const otherChild = await createManagedProfile(manager.id, "membership-kind-member"); + const authenticatedProfile = await createTestUser(); + + await expect( + ManagedProfileMembership.create({ + managedProfileId: child.id, + memberProfileId: otherChild.id, + role: "read_only" + }) + ).rejects.toThrow("Managed profile members must be authenticated profiles"); + await expect( + ManagedProfileMembership.create({ + managedProfileId: authenticatedProfile.id, + memberProfileId: authenticatedProfile.id, + role: "read_only" + }) + ).rejects.toThrow(); + }); + + it("allows only one active membership and permits a distinct grant after revocation", async () => { + const owner = await createManager(); + const member = await createTestUser(); + const invalidRoleMember = await createTestUser(); + const child = await createManagedProfile(owner.id, "membership-history"); + const first = await ManagedProfileMembership.create({ + createdByProfileId: owner.id, + managedProfileId: child.id, + memberProfileId: member.id, + role: "read_only" + }); + + await expect( + ManagedProfileMembership.create({ + createdByProfileId: owner.id, + managedProfileId: child.id, + memberProfileId: member.id, + role: "manager" + }) + ).rejects.toThrow(); + await expect( + ManagedProfileMembership.create({ + managedProfileId: child.id, + memberProfileId: invalidRoleMember.id, + role: "operator" as "manager" + }) + ).rejects.toThrow(); + + await first.update({ revokedAt: new Date(), revokedByProfileId: owner.id }); + const second = await ManagedProfileMembership.create({ + createdByProfileId: owner.id, + managedProfileId: child.id, + memberProfileId: member.id, + role: "manager" + }); + + expect(second.id).not.toBe(first.id); + expect(await ManagedProfileMembership.count({ where: { managedProfileId: child.id, memberProfileId: member.id } })).toBe(2); + }); + + it("protects active owner membership and immutable ownership at database level", async () => { + const owner = await createManager(); + const otherManager = await createManager(); + const child = await createManagedProfile(owner.id, "protected-owner"); + const relationship = await ManagedProfile.findOne({ where: { profileId: child.id } }); + const membership = await ManagedProfileMembership.findOne({ + where: { managedProfileId: child.id, memberProfileId: owner.id } + }); + + await expect(membership?.update({ role: "read_only" })).rejects.toThrow( + "Active managed profile owner membership cannot be downgraded or removed" + ); + await expect(membership?.update({ revokedAt: new Date(), revokedByProfileId: owner.id })).rejects.toThrow( + "Active managed profile owner membership cannot be downgraded or removed" + ); + await expect(membership?.destroy()).rejects.toThrow( + "Active managed profile owner membership cannot be downgraded or removed" + ); + await expect(relationship?.update({ managerProfileId: otherManager.id })).rejects.toThrow( + "Managed profile owner cannot be changed after creation" + ); + }); + + it("constrains invitation roles, normalized email, terminal state, and pending uniqueness", async () => { + const owner = await createManager(); + const acceptedBy = await createTestUser(); + const child = await createManagedProfile(owner.id, "invitation-constraints"); + const expiresAt = new Date(Date.now() + 60_000); + const invitation = await ManagedProfileMembershipInvitation.create({ + email: "member@example.com", + expiresAt, + invitedByProfileId: owner.id, + managedProfileId: child.id, + role: "manager" + }); + + await expect( + ManagedProfileMembershipInvitation.create({ + email: "member@example.com", + expiresAt, + invitedByProfileId: owner.id, + managedProfileId: child.id, + role: "read_only" + }) + ).rejects.toThrow(); + await expect( + ManagedProfileMembershipInvitation.create({ + email: "other@example.com", + expiresAt, + invitedByProfileId: owner.id, + managedProfileId: child.id, + role: "operator" as "manager" + }) + ).rejects.toThrow(); + await expect( + ManagedProfileMembershipInvitation.create({ + email: " Member@Example.com ", + expiresAt, + invitedByProfileId: owner.id, + managedProfileId: child.id, + role: "manager" + }) + ).rejects.toThrow(); + await expect(invitation.update({ acceptedAt: new Date() })).rejects.toThrow(); + + await invitation.update({ acceptedAt: new Date(), acceptedByProfileId: acceptedBy.id }); + await expect( + ManagedProfileMembershipInvitation.create({ + email: "member@example.com", + expiresAt, + invitedByProfileId: owner.id, + managedProfileId: child.id, + role: "read_only" + }) + ).resolves.toBeInstanceOf(ManagedProfileMembershipInvitation); + }); + + it("makes membership events append-only", async () => { + const owner = await createManager(); + const child = await createManagedProfile(owner.id, "append-only-events"); + const event = await ManagedProfileMembershipEvent.create({ + action: "member_added", + actorProfileId: owner.id, + managedProfileId: child.id, + memberProfileId: owner.id, + role: "manager" + }); + + await expect(event.update({ role: "read_only" })).rejects.toThrow("Managed profile membership events are append-only"); + await expect(event.destroy()).rejects.toThrow("Managed profile membership events are append-only"); + await expect( + ManagedProfileMembershipEvent.create({ + action: "membership_exported" as "member_added", + managedProfileId: child.id + }) + ).rejects.toThrow(); + }); + + it("enables RLS and grants no direct client-role privileges or owned sequences", async () => { + const tables = [ + "managed_profile_memberships", + "managed_profile_membership_invitations", + "managed_profile_membership_events" + ]; + const rows = await sequelize.query<{ clientHasPrivilege: boolean; name: string; rlsEnabled: boolean }>( + `SELECT + class.relname AS name, + class.relrowsecurity AS "rlsEnabled", + EXISTS ( + SELECT 1 + FROM aclexplode(COALESCE(class.relacl, acldefault('r', class.relowner))) privilege + JOIN pg_roles role ON role.oid = privilege.grantee + WHERE role.rolname IN ('anon', 'authenticated') + ) AS "clientHasPrivilege" + FROM pg_class class + WHERE class.relname IN (:tables) + ORDER BY class.relname`, + { replacements: { tables }, type: QueryTypes.SELECT } + ); + + expect(rows).toHaveLength(3); + expect(rows.every(row => row.rlsEnabled && !row.clientHasPrivilege)).toBe(true); + + const [sequenceState] = await sequelize.query<{ count: number }>( + `SELECT count(*)::int AS count + FROM pg_class sequence + JOIN pg_depend dependency ON dependency.objid = sequence.oid AND dependency.deptype = 'a' + JOIN pg_class owner_table ON owner_table.oid = dependency.refobjid + WHERE sequence.relkind = 'S' AND owner_table.relname IN (:tables)`, + { replacements: { tables }, type: QueryTypes.SELECT } + ); + expect(sequenceState?.count).toBe(0); + }); +}); diff --git a/apps/api/src/database/managed-profiles-schema.test.ts b/apps/api/src/database/managed-profiles-schema.test.ts index 0a385900a..08b4cc175 100644 --- a/apps/api/src/database/managed-profiles-schema.test.ts +++ b/apps/api/src/database/managed-profiles-schema.test.ts @@ -3,6 +3,7 @@ import { QueryTypes } from "sequelize"; import sequelize from "../config/database"; import ManagedProfile from "../models/managedProfile.model"; import ManagedProfileManager from "../models/managedProfileManager.model"; +import ManagedProfileMembership from "../models/managedProfileMembership.model"; import User from "../models/user.model"; import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; import { createTestUser } from "../test-utils/factories"; @@ -33,6 +34,10 @@ async function createManagedProfile( }, { transaction } ); + await ManagedProfileMembership.create( + { managedProfileId: profile.id, memberProfileId: managerProfileId, role: "manager" }, + { transaction } + ); return profile; }); } diff --git a/apps/api/src/database/migrations/069-create-managed-profile-memberships.ts b/apps/api/src/database/migrations/069-create-managed-profile-memberships.ts new file mode 100644 index 000000000..bf778eacd --- /dev/null +++ b/apps/api/src/database/migrations/069-create-managed-profile-memberships.ts @@ -0,0 +1,479 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +const TABLES = [ + "managed_profile_memberships", + "managed_profile_membership_invitations", + "managed_profile_membership_events" +] as const; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.transaction(async transaction => { + await queryInterface.createTable( + "managed_profile_memberships", + { + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + created_by_profile_id: { + allowNull: true, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + managed_profile_id: { + allowNull: false, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "profile_id", model: "managed_profiles" }, + type: DataTypes.UUID + }, + member_profile_id: { + allowNull: false, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + revoked_at: { allowNull: true, type: DataTypes.DATE }, + revoked_by_profile_id: { + allowNull: true, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + role: { allowNull: false, type: DataTypes.STRING(16) }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE } + }, + { transaction } + ); + await queryInterface.sequelize.query( + `ALTER TABLE managed_profile_memberships + ADD CONSTRAINT chk_managed_profile_memberships_role + CHECK (role IN ('manager', 'read_only')), + ADD CONSTRAINT chk_managed_profile_memberships_revocation + CHECK ((revoked_at IS NULL) = (revoked_by_profile_id IS NULL)); + + CREATE UNIQUE INDEX uq_managed_profile_memberships_active + ON managed_profile_memberships (managed_profile_id, member_profile_id) + WHERE revoked_at IS NULL; + + CREATE INDEX idx_managed_profile_memberships_member + ON managed_profile_memberships (member_profile_id, created_at);`, + { transaction } + ); + + await queryInterface.createTable( + "managed_profile_membership_invitations", + { + accepted_at: { allowNull: true, type: DataTypes.DATE }, + accepted_by_profile_id: { + allowNull: true, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + cancelled_at: { allowNull: true, type: DataTypes.DATE }, + cancelled_by_profile_id: { + allowNull: true, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + email: { allowNull: false, type: DataTypes.STRING(255) }, + expired_at: { allowNull: true, type: DataTypes.DATE }, + expires_at: { allowNull: false, type: DataTypes.DATE }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + invited_by_profile_id: { + allowNull: false, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + managed_profile_id: { + allowNull: false, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "profile_id", model: "managed_profiles" }, + type: DataTypes.UUID + }, + role: { allowNull: false, type: DataTypes.STRING(16) }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE } + }, + { transaction } + ); + await queryInterface.sequelize.query( + `ALTER TABLE managed_profile_membership_invitations + ADD CONSTRAINT chk_managed_profile_membership_invitations_role + CHECK (role IN ('manager', 'read_only')), + ADD CONSTRAINT chk_managed_profile_membership_invitations_email + CHECK (email <> '' AND email = lower(btrim(email))), + ADD CONSTRAINT chk_managed_profile_membership_invitations_terminal_state CHECK ( + num_nonnulls(accepted_at, cancelled_at, expired_at) <= 1 + AND (accepted_at IS NULL) = (accepted_by_profile_id IS NULL) + AND (cancelled_at IS NULL) = (cancelled_by_profile_id IS NULL) + AND (expired_at IS NULL OR expired_at = expires_at) + ); + + CREATE UNIQUE INDEX uq_managed_profile_membership_invitations_pending + ON managed_profile_membership_invitations (managed_profile_id, email) + WHERE accepted_at IS NULL AND cancelled_at IS NULL AND expired_at IS NULL; + + CREATE INDEX idx_managed_profile_membership_invitations_child_created + ON managed_profile_membership_invitations (managed_profile_id, created_at);`, + { transaction } + ); + + await queryInterface.createTable( + "managed_profile_membership_events", + { + action: { allowNull: false, type: DataTypes.STRING(32) }, + actor_profile_id: { + allowNull: true, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + invitation_id: { + allowNull: true, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "managed_profile_membership_invitations" }, + type: DataTypes.UUID + }, + managed_profile_id: { + allowNull: false, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "profile_id", model: "managed_profiles" }, + type: DataTypes.UUID + }, + member_profile_id: { + allowNull: true, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + previous_role: { allowNull: true, type: DataTypes.STRING(16) }, + role: { allowNull: true, type: DataTypes.STRING(16) }, + subject_email: { allowNull: true, type: DataTypes.STRING(255) } + }, + { transaction } + ); + await queryInterface.sequelize.query( + `ALTER TABLE managed_profile_membership_events + ADD CONSTRAINT chk_managed_profile_membership_events_action CHECK ( + action IN ( + 'member_added', + 'invited', + 'invitation_cancelled', + 'invitation_expired', + 'invitation_accepted', + 'role_changed', + 'member_removed' + ) + ), + ADD CONSTRAINT chk_managed_profile_membership_events_previous_role + CHECK (previous_role IS NULL OR previous_role IN ('manager', 'read_only')), + ADD CONSTRAINT chk_managed_profile_membership_events_role + CHECK (role IS NULL OR role IN ('manager', 'read_only')), + ADD CONSTRAINT chk_managed_profile_membership_events_subject_email + CHECK (subject_email IS NULL OR (subject_email <> '' AND subject_email = lower(btrim(subject_email)))); + + CREATE INDEX idx_managed_profile_membership_events_child_created + ON managed_profile_membership_events (managed_profile_id, created_at DESC, id DESC);`, + { transaction } + ); + + // Existing relationships predate event attribution, so they intentionally receive no event. + await queryInterface.sequelize.query( + `INSERT INTO managed_profile_memberships ( + id, + managed_profile_id, + member_profile_id, + role, + created_by_profile_id, + created_at, + updated_at + ) + SELECT + uuid_generate_v4(), + mp.profile_id, + mp.manager_profile_id, + 'manager', + NULL, + mp.created_at, + mp.created_at + FROM managed_profiles mp;`, + { transaction } + ); + + await queryInterface.sequelize.query( + `CREATE FUNCTION enforce_managed_profile_manager_immutable() RETURNS trigger AS $$ + BEGIN + IF OLD.manager_profile_id IS DISTINCT FROM NEW.manager_profile_id THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + CONSTRAINT = 'chk_managed_profiles_manager_immutable', + MESSAGE = 'Managed profile owner cannot be changed after creation'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER trg_managed_profiles_manager_immutable + BEFORE UPDATE OF manager_profile_id ON managed_profiles + FOR EACH ROW EXECUTE FUNCTION enforce_managed_profile_manager_immutable(); + + CREATE FUNCTION enforce_managed_profile_owner_membership() RETURNS trigger AS $$ + DECLARE + relationship managed_profiles%ROWTYPE; + BEGIN + SELECT * INTO relationship + FROM managed_profiles + WHERE profile_id = OLD.managed_profile_id; + + IF relationship.status = 'active' + AND relationship.manager_profile_id = OLD.member_profile_id + AND ( + TG_OP = 'DELETE' + OR NEW.managed_profile_id IS DISTINCT FROM OLD.managed_profile_id + OR NEW.member_profile_id IS DISTINCT FROM OLD.member_profile_id + OR NEW.role <> 'manager' + OR NEW.revoked_at IS NOT NULL + ) + THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + CONSTRAINT = 'chk_managed_profiles_owner_membership', + MESSAGE = 'Active managed profile owner membership cannot be downgraded or removed'; + END IF; + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER trg_managed_profile_memberships_owner_protection + BEFORE UPDATE OR DELETE ON managed_profile_memberships + FOR EACH ROW EXECUTE FUNCTION enforce_managed_profile_owner_membership();`, + { transaction } + ); + + await queryInterface.sequelize.query( + `CREATE FUNCTION enforce_managed_profile_membership_invariants() RETURNS trigger AS $$ + DECLARE + affected_managed_profile_ids uuid[] := ARRAY[]::uuid[]; + affected_member_profile_ids uuid[] := ARRAY[]::uuid[]; + BEGIN + IF TG_TABLE_NAME = 'managed_profile_memberships' THEN + IF TG_OP = 'INSERT' THEN + affected_managed_profile_ids := ARRAY[NEW.managed_profile_id]; + affected_member_profile_ids := ARRAY[NEW.member_profile_id]; + ELSIF TG_OP = 'DELETE' THEN + affected_managed_profile_ids := ARRAY[OLD.managed_profile_id]; + affected_member_profile_ids := ARRAY[OLD.member_profile_id]; + ELSE + affected_managed_profile_ids := ARRAY[OLD.managed_profile_id, NEW.managed_profile_id]; + affected_member_profile_ids := ARRAY[OLD.member_profile_id, NEW.member_profile_id]; + END IF; + ELSIF TG_TABLE_NAME = 'managed_profiles' THEN + IF TG_OP = 'INSERT' THEN + affected_managed_profile_ids := ARRAY[NEW.profile_id]; + ELSIF TG_OP = 'DELETE' THEN + affected_managed_profile_ids := ARRAY[OLD.profile_id]; + ELSE + affected_managed_profile_ids := ARRAY[OLD.profile_id, NEW.profile_id]; + END IF; + ELSE + IF TG_OP = 'INSERT' THEN + affected_member_profile_ids := ARRAY[NEW.id]; + ELSIF TG_OP = 'DELETE' THEN + affected_member_profile_ids := ARRAY[OLD.id]; + ELSE + affected_member_profile_ids := ARRAY[OLD.id, NEW.id]; + END IF; + END IF; + + IF EXISTS ( + SELECT 1 + FROM managed_profile_memberships membership + JOIN profiles member ON member.id = membership.member_profile_id + WHERE membership.member_profile_id = ANY(affected_member_profile_ids) + AND member.kind <> 'authenticated' + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + CONSTRAINT = 'chk_managed_profile_memberships_member_kind', + MESSAGE = 'Managed profile members must be authenticated profiles'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM managed_profiles relationship + WHERE relationship.profile_id = ANY(affected_managed_profile_ids) + AND relationship.status = 'active' + AND NOT EXISTS ( + SELECT 1 + FROM managed_profile_memberships membership + WHERE membership.managed_profile_id = relationship.profile_id + AND membership.member_profile_id = relationship.manager_profile_id + AND membership.role = 'manager' + AND membership.revoked_at IS NULL + ) + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + CONSTRAINT = 'chk_managed_profiles_owner_membership', + MESSAGE = 'Active managed profiles require an active owner manager membership'; + END IF; + + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + + CREATE CONSTRAINT TRIGGER trg_managed_profile_memberships_invariants + AFTER INSERT OR UPDATE OR DELETE ON managed_profile_memberships + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION enforce_managed_profile_membership_invariants(); + + CREATE CONSTRAINT TRIGGER trg_managed_profiles_membership_invariants + AFTER INSERT OR UPDATE OR DELETE ON managed_profiles + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION enforce_managed_profile_membership_invariants(); + + CREATE CONSTRAINT TRIGGER trg_profiles_membership_invariants + AFTER INSERT OR UPDATE OR DELETE ON profiles + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION enforce_managed_profile_membership_invariants();`, + { transaction } + ); + + await queryInterface.sequelize.query( + `CREATE FUNCTION reject_managed_profile_membership_event_mutation() RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION USING + ERRCODE = '23514', + CONSTRAINT = 'chk_managed_profile_membership_events_append_only', + MESSAGE = 'Managed profile membership events are append-only'; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER trg_managed_profile_membership_events_append_only + BEFORE UPDATE OR DELETE ON managed_profile_membership_events + FOR EACH ROW EXECUTE FUNCTION reject_managed_profile_membership_event_mutation();`, + { transaction } + ); + + for (const table of TABLES) { + await queryInterface.sequelize.query(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`, { transaction }); + } + await queryInterface.sequelize.query( + `DO $$ + DECLARE + role_name text; + table_name text; + BEGIN + FOREACH role_name IN ARRAY ARRAY['anon', 'authenticated'] LOOP + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = role_name) THEN + FOREACH table_name IN ARRAY ARRAY[ + 'managed_profile_memberships', + 'managed_profile_membership_invitations', + 'managed_profile_membership_events' + ] LOOP + EXECUTE format('REVOKE ALL PRIVILEGES ON TABLE %I FROM %I', table_name, role_name); + END LOOP; + END IF; + END LOOP; + END; + $$;`, + { transaction } + ); + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.transaction(async transaction => { + await queryInterface.sequelize.query( + `LOCK TABLE + managed_profile_membership_events, + managed_profile_membership_invitations, + managed_profile_memberships, + managed_profiles + IN ACCESS EXCLUSIVE MODE;`, + { transaction } + ); + const [rows] = await queryInterface.sequelize.query( + `SELECT + EXISTS (SELECT 1 FROM managed_profile_membership_events) AS "hasEvents", + EXISTS (SELECT 1 FROM managed_profile_membership_invitations) AS "hasInvitations", + EXISTS ( + SELECT 1 + FROM managed_profile_memberships membership + LEFT JOIN managed_profiles relationship + ON relationship.profile_id = membership.managed_profile_id + WHERE relationship.profile_id IS NULL + OR membership.member_profile_id <> relationship.manager_profile_id + OR membership.role <> 'manager' + OR membership.created_by_profile_id IS NOT NULL + OR membership.revoked_at IS NOT NULL + OR membership.revoked_by_profile_id IS NOT NULL + ) AS "hasNonBackfillMemberships", + EXISTS ( + SELECT 1 + FROM managed_profiles relationship + WHERE NOT EXISTS ( + SELECT 1 + FROM managed_profile_memberships membership + WHERE membership.managed_profile_id = relationship.profile_id + AND membership.member_profile_id = relationship.manager_profile_id + AND membership.role = 'manager' + AND membership.created_by_profile_id IS NULL + AND membership.revoked_at IS NULL + AND membership.revoked_by_profile_id IS NULL + ) + ) AS "hasMissingBackfillMemberships";`, + { transaction } + ); + const state = rows[0] as + | { + hasEvents?: boolean; + hasInvitations?: boolean; + hasMissingBackfillMemberships?: boolean; + hasNonBackfillMemberships?: boolean; + } + | undefined; + if (state?.hasEvents || state?.hasInvitations || state?.hasMissingBackfillMemberships || state?.hasNonBackfillMemberships) { + throw new Error("Cannot revert managed-profile memberships after membership activity has been recorded"); + } + + await queryInterface.sequelize.query("DROP TRIGGER trg_managed_profiles_membership_invariants ON managed_profiles;", { + transaction + }); + await queryInterface.sequelize.query("DROP TRIGGER trg_profiles_membership_invariants ON profiles;", { transaction }); + await queryInterface.sequelize.query("DROP TRIGGER trg_managed_profiles_manager_immutable ON managed_profiles;", { + transaction + }); + await queryInterface.dropTable("managed_profile_membership_events", { transaction }); + await queryInterface.dropTable("managed_profile_membership_invitations", { transaction }); + await queryInterface.dropTable("managed_profile_memberships", { transaction }); + await queryInterface.sequelize.query("DROP FUNCTION reject_managed_profile_membership_event_mutation();", { + transaction + }); + await queryInterface.sequelize.query("DROP FUNCTION enforce_managed_profile_membership_invariants();", { + transaction + }); + await queryInterface.sequelize.query("DROP FUNCTION enforce_managed_profile_owner_membership();", { transaction }); + await queryInterface.sequelize.query("DROP FUNCTION enforce_managed_profile_manager_immutable();", { transaction }); + }); +} diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 6c97bf074..0b950b822 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -10,6 +10,9 @@ import KycCase from "./kycCase.model"; import MaintenanceSchedule from "./maintenanceSchedule.model"; import ManagedProfile from "./managedProfile.model"; import ManagedProfileManager from "./managedProfileManager.model"; +import ManagedProfileMembership from "./managedProfileMembership.model"; +import ManagedProfileMembershipEvent from "./managedProfileMembershipEvent.model"; +import ManagedProfileMembershipInvitation from "./managedProfileMembershipInvitation.model"; import Notification from "./notification.model"; import NotificationPreference from "./notificationPreference.model"; import Partner from "./partner.model"; @@ -74,6 +77,69 @@ User.hasOne(ManagedProfile, { as: "managedProfileRelationship", foreignKey: "pro ManagedProfile.belongsTo(User, { as: "profile", foreignKey: "profileId" }); ManagedProfileManager.hasMany(ManagedProfile, { as: "managedProfiles", foreignKey: "managerProfileId" }); ManagedProfile.belongsTo(ManagedProfileManager, { as: "manager", foreignKey: "managerProfileId" }); +ManagedProfile.hasMany(ManagedProfileMembership, { + as: "memberships", + foreignKey: "managedProfileId", + sourceKey: "profileId" +}); +ManagedProfileMembership.belongsTo(ManagedProfile, { + as: "managedProfile", + foreignKey: "managedProfileId", + targetKey: "profileId" +}); +ManagedProfile.hasMany(ManagedProfileMembershipInvitation, { + as: "membershipInvitations", + foreignKey: "managedProfileId", + sourceKey: "profileId" +}); +ManagedProfileMembershipInvitation.belongsTo(ManagedProfile, { + as: "managedProfile", + foreignKey: "managedProfileId", + targetKey: "profileId" +}); +ManagedProfile.hasMany(ManagedProfileMembershipEvent, { + as: "membershipEvents", + foreignKey: "managedProfileId", + sourceKey: "profileId" +}); +ManagedProfileMembershipEvent.belongsTo(ManagedProfile, { + as: "managedProfile", + foreignKey: "managedProfileId", + targetKey: "profileId" +}); +User.hasMany(ManagedProfileMembership, { as: "managedProfileMemberships", foreignKey: "memberProfileId" }); +ManagedProfileMembership.belongsTo(User, { as: "memberProfile", foreignKey: "memberProfileId" }); +User.hasMany(ManagedProfileMembership, { as: "createdManagedProfileMemberships", foreignKey: "createdByProfileId" }); +ManagedProfileMembership.belongsTo(User, { as: "createdByProfile", foreignKey: "createdByProfileId" }); +User.hasMany(ManagedProfileMembership, { as: "revokedManagedProfileMemberships", foreignKey: "revokedByProfileId" }); +ManagedProfileMembership.belongsTo(User, { as: "revokedByProfile", foreignKey: "revokedByProfileId" }); +User.hasMany(ManagedProfileMembershipInvitation, { + as: "managedProfileMembershipInvitationsSent", + foreignKey: "invitedByProfileId" +}); +ManagedProfileMembershipInvitation.belongsTo(User, { as: "invitedByProfile", foreignKey: "invitedByProfileId" }); +User.hasMany(ManagedProfileMembershipInvitation, { + as: "managedProfileMembershipInvitationsAccepted", + foreignKey: "acceptedByProfileId" +}); +ManagedProfileMembershipInvitation.belongsTo(User, { as: "acceptedByProfile", foreignKey: "acceptedByProfileId" }); +User.hasMany(ManagedProfileMembershipInvitation, { + as: "managedProfileMembershipInvitationsCancelled", + foreignKey: "cancelledByProfileId" +}); +ManagedProfileMembershipInvitation.belongsTo(User, { as: "cancelledByProfile", foreignKey: "cancelledByProfileId" }); +ManagedProfileMembershipInvitation.hasMany(ManagedProfileMembershipEvent, { + as: "events", + foreignKey: "invitationId" +}); +ManagedProfileMembershipEvent.belongsTo(ManagedProfileMembershipInvitation, { + as: "invitation", + foreignKey: "invitationId" +}); +User.hasMany(ManagedProfileMembershipEvent, { as: "managedProfileMembershipEventsActed", foreignKey: "actorProfileId" }); +ManagedProfileMembershipEvent.belongsTo(User, { as: "actorProfile", foreignKey: "actorProfileId" }); +User.hasMany(ManagedProfileMembershipEvent, { as: "managedProfileMembershipEventsReceived", foreignKey: "memberProfileId" }); +ManagedProfileMembershipEvent.belongsTo(User, { as: "memberProfile", foreignKey: "memberProfileId" }); // Partner pricing split Partner.hasMany(PartnerPricingConfig, { as: "pricingConfigs", foreignKey: "partnerId" }); @@ -124,6 +190,9 @@ const models = { MaintenanceSchedule, ManagedProfile, ManagedProfileManager, + ManagedProfileMembership, + ManagedProfileMembershipEvent, + ManagedProfileMembershipInvitation, Notification, NotificationPreference, Partner, diff --git a/apps/api/src/models/managedProfileMembership.model.ts b/apps/api/src/models/managedProfileMembership.model.ts new file mode 100644 index 000000000..06456c5b6 --- /dev/null +++ b/apps/api/src/models/managedProfileMembership.model.ts @@ -0,0 +1,95 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export type ManagedProfileMembershipRole = "manager" | "read_only"; + +export interface ManagedProfileMembershipAttributes { + id: string; + managedProfileId: string; + memberProfileId: string; + role: ManagedProfileMembershipRole; + createdByProfileId: string | null; + revokedAt: Date | null; + revokedByProfileId: string | null; + createdAt: Date; + updatedAt: Date; +} + +type ManagedProfileMembershipCreationAttributes = Optional< + ManagedProfileMembershipAttributes, + "id" | "createdByProfileId" | "revokedAt" | "revokedByProfileId" | "createdAt" | "updatedAt" +>; + +class ManagedProfileMembership + extends Model + implements ManagedProfileMembershipAttributes +{ + declare id: string; + declare managedProfileId: string; + declare memberProfileId: string; + declare role: ManagedProfileMembershipRole; + declare createdByProfileId: string | null; + declare revokedAt: Date | null; + declare revokedByProfileId: string | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +ManagedProfileMembership.init( + { + createdAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "created_at", type: DataTypes.DATE }, + createdByProfileId: { + allowNull: true, + field: "created_by_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + managedProfileId: { + allowNull: false, + field: "managed_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "profile_id", model: "managed_profiles" }, + type: DataTypes.UUID + }, + memberProfileId: { + allowNull: false, + field: "member_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + revokedAt: { allowNull: true, field: "revoked_at", type: DataTypes.DATE }, + revokedByProfileId: { + allowNull: true, + field: "revoked_by_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + role: { allowNull: false, type: DataTypes.STRING(16) }, + updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE } + }, + { + indexes: [ + { + fields: ["managed_profile_id", "member_profile_id"], + name: "uq_managed_profile_memberships_active", + unique: true, + where: { revoked_at: null } + }, + { fields: ["member_profile_id", "created_at"], name: "idx_managed_profile_memberships_member" } + ], + modelName: "ManagedProfileMembership", + sequelize, + tableName: "managed_profile_memberships", + timestamps: true + } +); + +export default ManagedProfileMembership; diff --git a/apps/api/src/models/managedProfileMembershipEvent.model.ts b/apps/api/src/models/managedProfileMembershipEvent.model.ts new file mode 100644 index 000000000..6c1cb24a2 --- /dev/null +++ b/apps/api/src/models/managedProfileMembershipEvent.model.ts @@ -0,0 +1,104 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; +import type { ManagedProfileMembershipRole } from "./managedProfileMembership.model"; + +export type ManagedProfileMembershipAction = + | "member_added" + | "invited" + | "invitation_cancelled" + | "invitation_expired" + | "invitation_accepted" + | "role_changed" + | "member_removed"; + +export interface ManagedProfileMembershipEventAttributes { + id: string; + managedProfileId: string; + action: ManagedProfileMembershipAction; + actorProfileId: string | null; + memberProfileId: string | null; + invitationId: string | null; + subjectEmail: string | null; + previousRole: ManagedProfileMembershipRole | null; + role: ManagedProfileMembershipRole | null; + createdAt: Date; +} + +type ManagedProfileMembershipEventCreationAttributes = Optional< + ManagedProfileMembershipEventAttributes, + "id" | "actorProfileId" | "memberProfileId" | "invitationId" | "subjectEmail" | "previousRole" | "role" | "createdAt" +>; + +class ManagedProfileMembershipEvent + extends Model + implements ManagedProfileMembershipEventAttributes +{ + declare id: string; + declare managedProfileId: string; + declare action: ManagedProfileMembershipAction; + declare actorProfileId: string | null; + declare memberProfileId: string | null; + declare invitationId: string | null; + declare subjectEmail: string | null; + declare previousRole: ManagedProfileMembershipRole | null; + declare role: ManagedProfileMembershipRole | null; + declare createdAt: Date; +} + +ManagedProfileMembershipEvent.init( + { + action: { allowNull: false, type: DataTypes.STRING(32) }, + actorProfileId: { + allowNull: true, + field: "actor_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + createdAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "created_at", type: DataTypes.DATE }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + invitationId: { + allowNull: true, + field: "invitation_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "managed_profile_membership_invitations" }, + type: DataTypes.UUID + }, + managedProfileId: { + allowNull: false, + field: "managed_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "profile_id", model: "managed_profiles" }, + type: DataTypes.UUID + }, + memberProfileId: { + allowNull: true, + field: "member_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + previousRole: { allowNull: true, field: "previous_role", type: DataTypes.STRING(16) }, + role: { allowNull: true, type: DataTypes.STRING(16) }, + subjectEmail: { allowNull: true, field: "subject_email", type: DataTypes.STRING(255) } + }, + { + indexes: [ + { + fields: ["managed_profile_id", { name: "created_at", order: "DESC" }, { name: "id", order: "DESC" }], + name: "idx_managed_profile_membership_events_child_created" + } + ], + modelName: "ManagedProfileMembershipEvent", + sequelize, + tableName: "managed_profile_membership_events", + timestamps: true, + updatedAt: false + } +); + +export default ManagedProfileMembershipEvent; diff --git a/apps/api/src/models/managedProfileMembershipInvitation.model.ts b/apps/api/src/models/managedProfileMembershipInvitation.model.ts new file mode 100644 index 000000000..ec7dd715b --- /dev/null +++ b/apps/api/src/models/managedProfileMembershipInvitation.model.ts @@ -0,0 +1,109 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; +import type { ManagedProfileMembershipRole } from "./managedProfileMembership.model"; + +export interface ManagedProfileMembershipInvitationAttributes { + id: string; + managedProfileId: string; + email: string; + role: ManagedProfileMembershipRole; + invitedByProfileId: string; + expiresAt: Date; + expiredAt: Date | null; + acceptedAt: Date | null; + acceptedByProfileId: string | null; + cancelledAt: Date | null; + cancelledByProfileId: string | null; + createdAt: Date; + updatedAt: Date; +} + +type ManagedProfileMembershipInvitationCreationAttributes = Optional< + ManagedProfileMembershipInvitationAttributes, + "id" | "expiredAt" | "acceptedAt" | "acceptedByProfileId" | "cancelledAt" | "cancelledByProfileId" | "createdAt" | "updatedAt" +>; + +class ManagedProfileMembershipInvitation + extends Model + implements ManagedProfileMembershipInvitationAttributes +{ + declare id: string; + declare managedProfileId: string; + declare email: string; + declare role: ManagedProfileMembershipRole; + declare invitedByProfileId: string; + declare expiresAt: Date; + declare expiredAt: Date | null; + declare acceptedAt: Date | null; + declare acceptedByProfileId: string | null; + declare cancelledAt: Date | null; + declare cancelledByProfileId: string | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +ManagedProfileMembershipInvitation.init( + { + acceptedAt: { allowNull: true, field: "accepted_at", type: DataTypes.DATE }, + acceptedByProfileId: { + allowNull: true, + field: "accepted_by_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + cancelledAt: { allowNull: true, field: "cancelled_at", type: DataTypes.DATE }, + cancelledByProfileId: { + allowNull: true, + field: "cancelled_by_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + createdAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "created_at", type: DataTypes.DATE }, + email: { allowNull: false, type: DataTypes.STRING(255) }, + expiredAt: { allowNull: true, field: "expired_at", type: DataTypes.DATE }, + expiresAt: { allowNull: false, field: "expires_at", type: DataTypes.DATE }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + invitedByProfileId: { + allowNull: false, + field: "invited_by_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + managedProfileId: { + allowNull: false, + field: "managed_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "profile_id", model: "managed_profiles" }, + type: DataTypes.UUID + }, + role: { allowNull: false, type: DataTypes.STRING(16) }, + updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE } + }, + { + indexes: [ + { + fields: ["managed_profile_id", "email"], + name: "uq_managed_profile_membership_invitations_pending", + unique: true, + where: { accepted_at: null, cancelled_at: null, expired_at: null } + }, + { + fields: ["managed_profile_id", "created_at"], + name: "idx_managed_profile_membership_invitations_child_created" + } + ], + modelName: "ManagedProfileMembershipInvitation", + sequelize, + tableName: "managed_profile_membership_invitations", + timestamps: true + } +); + +export default ManagedProfileMembershipInvitation; From 1bc5da7a372fc68edeeb568f4c8207ee9eb3f563 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 7 Sep 2026 17:10:23 -0300 Subject: [PATCH 03/11] feat(api): enforce managed profile membership capabilities --- .../api/controllers/brla.controller.test.ts | 13 + .../src/api/controllers/brla.controller.ts | 3 + .../managedProfiles.controller.test.ts | 350 +++++++++++++++++- .../controllers/managedProfiles.controller.ts | 26 +- .../middlewares/managedProfileAuth.test.ts | 284 ++++++++++++-- .../src/api/middlewares/managedProfileAuth.ts | 166 ++++++++- .../src/api/middlewares/ownershipAuth.test.ts | 5 + apps/api/src/api/routes/v1/alfredpay.route.ts | 104 ++++-- .../api/routes/v1/brla-kyc-import.route.ts | 12 +- apps/api/src/api/routes/v1/brla.route.ts | 72 ++-- apps/api/src/api/routes/v1/limits.route.ts | 4 +- .../managed-profile-ramp-bearer.route.test.ts | 91 +++++ .../v1/managed-profile-reads.route.test.ts | 199 +++++++++- .../api/routes/v1/managed-profiles.route.ts | 47 ++- .../api/src/api/routes/v1/onboarding.route.ts | 8 +- apps/api/src/api/routes/v1/quote.route.ts | 6 +- apps/api/src/api/routes/v1/ramp-info.route.ts | 12 +- apps/api/src/api/routes/v1/ramp.route.test.ts | 94 ++++- apps/api/src/api/routes/v1/ramp.route.ts | 39 +- .../api/src/api/routes/v1/recipients.route.ts | 44 ++- .../services/apiCredential.service.test.ts | 82 ++++ .../src/api/services/apiCredential.service.ts | 88 +++-- .../avenia/avenia-kyc-import.service.test.ts | 117 +++++- .../avenia/avenia-kyc-import.service.ts | 45 ++- .../avenia-standard-kyc.service.test.ts | 117 +++++- .../avenia/avenia-standard-kyc.service.ts | 58 ++- .../managed-profile-lifecycle.service.test.ts | 98 ++++- .../managed-profile-lifecycle.service.ts | 200 ++++++++-- apps/api/src/config/express.ts | 5 + apps/api/src/test-utils/test-app.ts | 6 + ...edpay-managed-customer.integration.test.ts | 22 +- ...e-quote-ramp-lifecycle.integration.test.ts | 61 ++- docs/architecture-identity-model.md | 34 +- docs/security-spec/01-auth/api-keys.md | 21 +- .../01-auth/managed-profile-memberships.md | 55 ++- 35 files changed, 2277 insertions(+), 311 deletions(-) create mode 100644 apps/api/src/api/routes/v1/managed-profile-ramp-bearer.route.test.ts diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index 2f013b63d..a686ca864 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -9,6 +9,7 @@ import FinancialOperation from "../../models/financialOperation.model"; import KycCase from "../../models/kycCase.model"; import ManagedProfile from "../../models/managedProfile.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; +import ManagedProfileMembership from "../../models/managedProfileMembership.model"; import PartnerManagedProfile from "../../models/partnerManagedProfile.model"; import ProviderCustomer, {VerificationStatus} from "../../models/providerCustomer.model"; import QuoteTicket from "../../models/quoteTicket.model"; @@ -66,6 +67,7 @@ function mockEntityPerProfile() { const originalUserFindByPk = User.findByPk; const originalManagedProfileFindOne = PartnerManagedProfile.findOne; +const originalManagedProfileMembershipFindByPk = ManagedProfileMembership.findByPk; const originalEntityFindAll = CustomerEntity.findAll; beforeEach(() => { @@ -75,6 +77,7 @@ beforeEach(() => { afterEach(() => { PartnerManagedProfile.findOne = originalManagedProfileFindOne; + ManagedProfileMembership.findByPk = originalManagedProfileMembershipFindByPk; User.findByPk = originalUserFindByPk; CustomerEntity.findAll = originalEntityFindAll; }); @@ -422,6 +425,13 @@ describe("importKycToken", () => { profileId: "child-1", status: "active" })) as unknown as typeof ManagedProfile.findByPk; + ManagedProfileMembership.findByPk = mock(async () => ({ + id: "membership-1", + managedProfileId: "child-1", + memberProfileId: "manager-1", + revokedAt: null, + role: "manager" + })) as unknown as typeof ManagedProfileMembership.findByPk; const customer = { country: "BR", customerEntityId: entityId, @@ -491,8 +501,11 @@ describe("importKycToken", () => { get: () => "request-1", managedProfileContext: { actorProfileId: "manager-1", + controllingManagerProfileId: "manager-1", customerEntityId: "entity-child-1", managedProfileId: "relationship-1", + membershipId: "membership-1", + membershipRole: "manager", subjectProfileId: "child-1" } } as any, diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 7fa04159d..2c249cd39 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -260,10 +260,12 @@ export const importKycToken = async ( const result = await importBrKycToken({ actorProfileId, + controllingManagerProfileId: req.managedProfileContext?.controllingManagerProfileId, expectedCustomerEntityId: req.managedProfileContext?.customerEntityId, idempotencyKey, importToken: req.body.importToken, managedProfileId: req.managedProfileContext?.managedProfileId, + membershipId: req.managedProfileContext?.membershipId, subjectProfileId }); res.status(httpStatus.ACCEPTED).json(result); @@ -912,6 +914,7 @@ export const newKyc = async ( controllingManagerProfileId: req.managedProfileContext?.controllingManagerProfileId, expectedCustomerEntityId: req.managedProfileContext?.customerEntityId, managedProfileId: req.managedProfileContext?.managedProfileId, + membershipId: req.managedProfileContext?.membershipId, payload: req.body, providerCustomer: record, subjectProfileId diff --git a/apps/api/src/api/controllers/managedProfiles.controller.test.ts b/apps/api/src/api/controllers/managedProfiles.controller.test.ts index f084c44d0..c7c74b331 100644 --- a/apps/api/src/api/controllers/managedProfiles.controller.test.ts +++ b/apps/api/src/api/controllers/managedProfiles.controller.test.ts @@ -1,14 +1,27 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; import express from "express"; +import { config } from "../../config/vars"; import ApiCredential from "../../models/apiCredential.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; +import ManagedProfileMembership from "../../models/managedProfileMembership.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; import { createTestApiKey, createTestUser } from "../../test-utils/factories"; import managedProfilesRoutes from "../routes/v1/managed-profiles.route"; +import { SupabaseAuthService } from "../services/auth"; +import * as bearerPrincipal from "../middlewares/bearerPrincipal"; +import * as credentialService from "../services/apiCredential.service"; +import { getManagedProfile, listManagedProfiles } from "../services/managed-profile-lifecycle.service"; +import { + changeManagedProfileMember, + createManagedProfileInvitation, + readOrAcceptManagedProfileInvitation, + removeManagedProfileMember +} from "../services/managed-profile-membership.service"; const BASE_PATH = "/v1/managed-profiles"; describe("managed profile lifecycle routes", () => { + const originalDashboardPublicUrl = config.dashboardPublicUrl; let server: ReturnType; let baseUrl: string; @@ -24,6 +37,10 @@ describe("managed profile lifecycle routes", () => { }); afterAll(() => server?.close()); + afterEach(() => { + mock.restore(); + config.dashboardPublicUrl = originalDashboardPublicUrl; + }); beforeEach(resetTestDatabase); async function createManager(isActive = true) { @@ -33,14 +50,231 @@ describe("managed profile lifecycle routes", () => { return { headers: { "Content-Type": "application/json", "X-API-Key": credential.plaintextKey }, manager }; } - it("requires manager authentication and active enablement", async () => { + async function invitedManager() { + config.dashboardPublicUrl = "https://dashboard.example.com"; + const owner = await createManager(); + const childResponse = await fetch(baseUrl, { + body: JSON.stringify({ contactEmail: "credential-race@example.com", customerType: "individual", externalSubjectId: "credential-race" }), + headers: owner.headers, + method: "POST" + }); + expect(childResponse.status).toBe(201); + const profileId = ((await childResponse.json()) as { managedProfile: { profileId: string } }).managedProfile.profileId; + const member = await createTestUser(); + const { invitation } = await createManagedProfileInvitation(owner.manager.id, profileId, { email: member.email, role: "manager" }); + await readOrAcceptManagedProfileInvitation({ profileId: member.id, email: member.email, emailConfirmedAt: new Date().toISOString() }, invitation.id, true); + const secret = await createTestApiKey({ userId: member.id }); + spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ user_id: member.id, valid: true }); + return { member, owner, profileId, secret, url: `${baseUrl}/${profileId}/api-credentials` }; + } + + for (const auth of ["bearer", "secret"] as const) { + it(`returns 200 and false actor flags for a ${auth} actor with no memberships`, async () => { + const actor = await createTestUser(); + const credential = await createTestApiKey({ userId: actor.id }); + spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ user_id: actor.id, valid: true }); + const headers: Record = auth === "bearer" + ? { Authorization: "Bearer actor-token" } + : { "X-API-Key": credential.plaintextKey }; + const response = await fetch(`${baseUrl}?limit=1&offset=100`, { headers }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + actor: { canProvisionManagedProfiles: false, hasMemberships: false, profileId: actor.id }, + managedProfiles: [], pagination: { limit: 1, offset: 100, total: 0 } + }); + for (const status of ["all", "deleted"]) { + const denied = await fetch(`${baseUrl}?status=${status}`, { headers }); + expect(denied.status).toBe(403); + expect(await denied.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_OWNER_REQUIRED" } }); + } + }); + + for (const role of ["manager", "read_only"] as const) { + it(`restricts retained profiles and invalidates historical bootstrap for a ${role} ${auth} actor`, async () => { + const { member, owner, profileId, secret } = await invitedManager(); + if (role === "read_only") await changeManagedProfileMember(owner.manager.id, profileId, member.id, role); + spyOn(SupabaseAuthService, "verifyToken").mockImplementation(async token => ({ user_id: token, valid: true })); + const headers: Record = auth === "bearer" + ? { Authorization: `Bearer ${member.id}` } + : { "X-API-Key": secret.plaintextKey }; + const ownerHeaders: Record = auth === "bearer" + ? { Authorization: `Bearer ${owner.manager.id}` } + : owner.headers; + const childUrl = `${baseUrl}/${profileId}`; + const selected = { ...headers, "X-Managed-Profile-Id": profileId }; + const selectedOwner = { ...ownerHeaders, "X-Managed-Profile-Id": profileId }; + const page = await fetch(`${baseUrl}?limit=1&offset=100`, { headers }); + expect(await page.json()).toMatchObject({ + actor: { canProvisionManagedProfiles: false, hasMemberships: true, profileId: member.id }, + managedProfiles: [], pagination: { total: 1 } + }); + const detail = await fetch(childUrl, { headers: selected }); + expect(detail.status).toBe(200); + expect(await detail.json()).toMatchObject({ + actor: { canProvisionManagedProfiles: false, hasMemberships: true, profileId: member.id }, + managedProfile: { profileId, membership: { isOwner: false, role } } + }); + const deniedDeletion = await fetch(childUrl, { headers: selected, method: "DELETE" }); + expect(deniedDeletion.status).toBe(403); + expect(await deniedDeletion.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_OWNER_REQUIRED" } }); + + await ManagedProfileManager.update({ isActive: false }, { where: { profileId: owner.manager.id } }); + for (const bootstrapHeaders of [selected, selectedOwner]) { + const denied = await fetch(childUrl, { headers: bootstrapHeaders }); + expect(denied.status).toBe(403); + expect(await denied.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_MEMBERSHIP_INVALID" } }); + } + expect(await (await fetch(baseUrl, { headers })).json()).toMatchObject({ + actor: { canProvisionManagedProfiles: false, hasMemberships: false }, managedProfiles: [] + }); + await ManagedProfileManager.update({ isActive: true }, { where: { profileId: owner.manager.id } }); + expect((await fetch(childUrl, { headers: ownerHeaders, method: "DELETE" })).status).toBe(204); + + expect((await fetch(childUrl, { headers })).status).toBe(404); + await expect(getManagedProfile(member.id, profileId)).rejects.toMatchObject({ code: "MANAGED_PROFILE_NOT_FOUND" }); + for (const bootstrapHeaders of [selected, selectedOwner]) { + const denied = await fetch(childUrl, { headers: bootstrapHeaders }); + expect(denied.status).toBe(403); + expect(await denied.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_MEMBERSHIP_INVALID" } }); + } + const retained = await fetch(childUrl, { headers: ownerHeaders }); + expect(retained.status).toBe(200); + expect(await retained.json()).toMatchObject({ + actor: { canProvisionManagedProfiles: true, hasMemberships: false }, + managedProfile: { profileId, status: "deleted" } + }); + for (const status of ["all", "deleted"] as const) { + const denied = await fetch(`${baseUrl}?status=${status}`, { headers }); + expect(denied.status).toBe(403); + expect(await denied.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_OWNER_REQUIRED" } }); + await expect(listManagedProfiles(member.id, { status, offset: 0, limit: 50 })).rejects.toMatchObject({ + code: "MANAGED_PROFILE_OWNER_REQUIRED" + }); + const owned = await fetch(`${baseUrl}?status=${status}`, { headers: ownerHeaders }); + expect(owned.status).toBe(200); + expect(await owned.json()).toMatchObject({ + actor: { canProvisionManagedProfiles: true, hasMemberships: false }, + managedProfiles: [{ profileId, status: "deleted" }], pagination: { total: 1 } + }); + } + await ManagedProfileManager.update({ isActive: false }, { where: { profileId: owner.manager.id } }); + expect((await fetch(childUrl, { headers: ownerHeaders })).status).toBe(404); + for (const status of ["all", "deleted"]) { + const denied = await fetch(`${baseUrl}?status=${status}`, { headers: ownerHeaders }); + expect(denied.status).toBe(403); + expect(await denied.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_OWNER_REQUIRED" } }); + } + }); + } + + it(`uses membership history, not the ${auth} selector, to distinguish invalid bootstrap from unknown-child probing`, async () => { + const { member, owner, profileId, secret } = await invitedManager(); + await removeManagedProfileMember(owner.manager.id, profileId, member.id); + const stranger = await createTestUser(); + const strangerCredential = await createTestApiKey({ userId: stranger.id }); + spyOn(SupabaseAuthService, "verifyToken").mockImplementation(async token => ({ user_id: token, valid: true })); + const headers: Record = auth === "bearer" + ? { Authorization: `Bearer ${member.id}` } + : { "X-API-Key": secret.plaintextKey }; + const strangerHeaders: Record = auth === "bearer" + ? { Authorization: `Bearer ${stranger.id}` } + : { "X-API-Key": strangerCredential.plaintextKey }; + const historical = await fetch(`${baseUrl}/${profileId}`, { headers: { ...headers, "X-Managed-Profile-Id": profileId } }); + expect(historical.status).toBe(403); + expect(await historical.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_MEMBERSHIP_INVALID" } }); + expect((await fetch(`${baseUrl}/${profileId}`, { headers })).status).toBe(404); + for (const subject of [profileId, crypto.randomUUID()]) { + for (const selected of [false, true]) { + const probeHeaders = { ...strangerHeaders, ...(selected ? { "X-Managed-Profile-Id": subject } : {}) }; + for (const method of ["GET", "DELETE"]) { + const response = await fetch(`${baseUrl}/${subject}`, { headers: probeHeaders, method }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + error: { code: "MANAGED_PROFILE_NOT_FOUND", message: "Managed profile was not found", status: 404 } + }); + } + } + } + }); + } + + for (const method of ["POST", "DELETE"] as const) { + for (const change of ["remove", "downgrade"] as const) { + for (const auth of ["bearer", "secret"] as const) { + it(`denies credential ${method} when membership is ${change}d after ${auth} middleware authorization`, async () => { + const { member, owner, profileId, secret, url } = await invitedManager(); + const initial = await fetch(url, { headers: owner.headers, method: "POST", body: JSON.stringify({ name: "Existing company key" }) }); + expect(initial.status).toBe(201); + const existing = (await initial.json()) as { id: string }; + const beforeService = async () => { + if (change === "remove") await removeManagedProfileMember(owner.manager.id, profileId, member.id); + else await changeManagedProfileMember(owner.manager.id, profileId, member.id, "read_only"); + }; + // This boundary is reached only after the real route middleware has authorized the member. + const create = credentialService.createManagedProfileCredential; + const revoke = credentialService.revokeManagedProfileCredential; + const intercepted = method === "POST" + ? spyOn(credentialService, "createManagedProfileCredential").mockImplementationOnce(async input => { + await beforeService(); + return create(input); + }) + : spyOn(credentialService, "revokeManagedProfileCredential").mockImplementationOnce(async (...args) => { + await beforeService(); + return revoke(...args); + }); + const headers: Record = { + "Content-Type": "application/json", + "X-Managed-Profile-Id": profileId, + ...(auth === "bearer" ? { Authorization: "Bearer member-token" } : { "X-API-Key": secret.plaintextKey }) + }; + const response = await fetch(method === "POST" ? url : `${url}/${existing.id}`, { + headers, + method, + ...(method === "POST" ? { body: JSON.stringify({ name: "Denied key", actorProfileId: owner.manager.id, membershipRole: "manager" }) } : {}) + }); + expect(intercepted).toHaveBeenCalledTimes(1); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ error: { code: "CREDENTIAL_ACCESS_DENIED" } }); + expect(await ApiCredential.count({ where: { profileId } })).toBe(1); + expect((await ApiCredential.findByPk(existing.id))?.revokedAt).toBeNull(); + }); + } + } + } + + it("lets an invited manager mint/revoke company keys while human removal leaves remaining company keys valid", async () => { + const { member, owner, profileId, secret, url } = await invitedManager(); + expect(await ManagedProfileManager.findByPk(member.id)).toBeNull(); + const keys: Array<{ id: string; publicKey: string; secretKey: string }> = []; + for (const auth of [{ Authorization: "Bearer member-token" }, { "X-API-Key": secret.plaintextKey }] as Record[]) { + const headers = { ...auth, "Content-Type": "application/json", "X-Managed-Profile-Id": profileId }; + const response = await fetch(url, { headers, method: "POST", body: JSON.stringify({ name: "Company integration" }) }); + expect(response.status).toBe(201); + keys.push(await response.json() as { id: string; publicKey: string; secretKey: string }); + } + const revokeResponse = await fetch(`${url}/${keys[0].id}`, { headers: { Authorization: "Bearer member-token" }, method: "DELETE" }); + expect(revokeResponse.status).toBe(204); + await removeManagedProfileMember(owner.manager.id, profileId, member.id); + expect((await ApiCredential.findByPk(keys[1].id))?.revokedAt).toBeNull(); + expect(await credentialService.validatePublicKey(keys[1].publicKey)).toMatchObject({ profileId, strength: "public" }); + expect(await credentialService.validateSecretKey(keys[1].secretKey)).toMatchObject({ profileId, strength: "secret" }); + expect(await credentialService.validateSecretKey(keys[0].secretKey)).toBeNull(); + expect((await fetch(`${url}/${keys[1].id}`, { headers: { "X-API-Key": secret.plaintextKey }, method: "DELETE" })).status).toBe(404); + }); + + it("requires authentication but returns the empty actor projection without active enablement", async () => { expect((await fetch(baseUrl)).status).toBe(401); const inactive = await createManager(false); - expect((await fetch(baseUrl, { headers: inactive.headers })).status).toBe(403); + const response = await fetch(baseUrl, { headers: inactive.headers }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + actor: { canProvisionManagedProfiles: false, hasMemberships: false, profileId: inactive.manager.id }, + managedProfiles: [], pagination: { limit: 50, offset: 0, total: 0 } + }); }); it("creates idempotently, lists active children, reads, and logically deletes", async () => { - const { headers } = await createManager(); + const { headers, manager } = await createManager(); const body = JSON.stringify({ contactEmail: " Managed.Child@Example.COM ", customerType: "individual", @@ -62,8 +296,15 @@ describe("managed profile lifecycle routes", () => { expect((await fetch(baseUrl, { body, headers, method: "POST" })).status).toBe(200); const listed = await fetch(baseUrl, { headers }); expect(await listed.json()).toMatchObject({ - manager: { allowedCorridors: ["BR"], allowedCustomerTypes: null }, - managedProfiles: [{ profileId, status: "active" }], + actor: { canProvisionManagedProfiles: true, hasMemberships: true, profileId: manager.id }, + managedProfiles: [ + { + membership: { isOwner: true, role: "manager" }, + policy: { allowedCorridors: ["BR"], allowedCustomerTypes: null }, + profileId, + status: "active" + } + ], pagination: { limit: 50, offset: 0, total: 1 } }); expect((await fetch(`${baseUrl}/${profileId}`, { headers })).status).toBe(200); @@ -88,7 +329,7 @@ describe("managed profile lifecycle routes", () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ - manager: { allowedCorridors: ["BR"], allowedCustomerTypes: null, profileId: manager.id }, + actor: { canProvisionManagedProfiles: true, hasMemberships: false, profileId: manager.id }, managedProfiles: [], pagination: { limit: 50, offset: 0, total: 0 } }); @@ -151,6 +392,101 @@ describe("managed profile lifecycle routes", () => { expect((await fetch(revokeUrl, { headers: first.headers, method: "DELETE" })).status).toBe(204); }); + it("allows manager members to manage credentials, keeps read-only members read-only, and reserves deletion for the owner", async () => { + const owner = await createManager(); + const createdChild = await fetch(baseUrl, { + body: JSON.stringify({ + contactEmail: "member-capabilities@example.com", + customerType: "individual", + externalSubjectId: "member-capabilities" + }), + headers: owner.headers, + method: "POST" + }); + const profileId = ((await createdChild.json()) as { managedProfile: { profileId: string } }).managedProfile.profileId; + const member = await createTestUser(); + const memberCredential = await createTestApiKey({ userId: member.id }); + const managerMember = { + manager: member, + headers: { "Content-Type": "application/json", "X-API-Key": memberCredential.plaintextKey } + }; + const readOnlyMember = await createTestUser(); + const readOnlyCredential = await createTestApiKey({ userId: readOnlyMember.id }); + await ManagedProfileMembership.bulkCreate([ + { + createdByProfileId: owner.manager.id, + managedProfileId: profileId, + memberProfileId: managerMember.manager.id, + role: "manager" + }, + { + createdByProfileId: owner.manager.id, + managedProfileId: profileId, + memberProfileId: readOnlyMember.id, + role: "read_only" + } + ]); + const managerHeaders = { ...managerMember.headers, "X-Managed-Profile-Id": profileId }; + const readOnlyHeaders = { + "Content-Type": "application/json", + "X-API-Key": readOnlyCredential.plaintextKey, + "X-Managed-Profile-Id": profileId + }; + + expect( + ( + await fetch(`${baseUrl}/${profileId}/api-credentials`, { + body: JSON.stringify({ name: "Member-created child credential" }), + headers: managerHeaders, + method: "POST" + }) + ).status + ).toBe(201); + expect((await fetch(`${baseUrl}/${profileId}/api-credentials`, { headers: managerHeaders })).status).toBe(200); + expect((await fetch(`${baseUrl}/${profileId}`, { headers: readOnlyHeaders })).status).toBe(200); + expect((await fetch(`${baseUrl}/${profileId}/api-credentials`, { headers: readOnlyHeaders })).status).toBe(200); + const deniedCredentialCreation = await fetch(`${baseUrl}/${profileId}/api-credentials`, { + body: JSON.stringify({ name: "Denied" }), + headers: readOnlyHeaders, + method: "POST" + }); + expect(deniedCredentialCreation.status).toBe(403); + expect(await deniedCredentialCreation.json()).toMatchObject({ + error: { code: "MANAGED_PROFILE_MANAGER_REQUIRED" } + }); + expect((await fetch(`${baseUrl}/${profileId}`, { headers: managerHeaders, method: "DELETE" })).status).toBe(403); + + spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ user_id: member.id, valid: true }); + const bearerHeaders = { + Authorization: "Bearer member-token", "Content-Type": "application/json", "X-Managed-Profile-Id": profileId + }; + const bearerCreated = await fetch(`${baseUrl}/${profileId}/api-credentials`, { + headers: bearerHeaders, method: "POST", body: JSON.stringify({ name: "Bearer-created child credential" }) + }); + expect(bearerCreated.status).toBe(201); + const credentialId = ((await bearerCreated.json()) as { id: string }).id; + const revokeUrl = `${baseUrl}/${profileId}/api-credentials/${credentialId}`; + expect((await fetch(revokeUrl, { headers: readOnlyHeaders, method: "DELETE" })).status).toBe(403); + expect((await fetch(revokeUrl, { headers: bearerHeaders, method: "DELETE" })).status).toBe(204); + + const membership = await ManagedProfileMembership.findOne({ where: { managedProfileId: profileId, memberProfileId: member.id } }); + await membership!.update({ role: "read_only" }); + expect((await fetch(`${baseUrl}/${profileId}/api-credentials`, { headers: bearerHeaders })).status).toBe(200); + expect((await fetch(`${baseUrl}/${profileId}/api-credentials`, { + headers: bearerHeaders, method: "POST", body: JSON.stringify({ name: "Denied bearer" }) + })).status).toBe(403); + expect((await fetch(revokeUrl, { headers: bearerHeaders, method: "DELETE" })).status).toBe(403); + await membership!.update({ role: "manager" }); + spyOn(bearerPrincipal, "resolveBearerPrincipal").mockResolvedValue({ + userId: member.id, valid: true, impersonation: { targetProfileId: member.id } as never + }); + for (const [method, url] of [["POST", `${baseUrl}/${profileId}/api-credentials`], ["DELETE", revokeUrl]]) { + const denied = await fetch(url, { headers: bearerHeaders, method }); + expect(denied.status).toBe(403); + expect(await denied.json()).toMatchObject({ error: { code: "IMPERSONATION_NOT_ALLOWED" } }); + } + }); + it("denies child credential lifecycle for inactive managers and deleted children", async () => { const owner = await createManager(); const createdChild = await fetch(baseUrl, { diff --git a/apps/api/src/api/controllers/managedProfiles.controller.ts b/apps/api/src/api/controllers/managedProfiles.controller.ts index 9a2081fdc..756353f36 100644 --- a/apps/api/src/api/controllers/managedProfiles.controller.ts +++ b/apps/api/src/api/controllers/managedProfiles.controller.ts @@ -4,7 +4,6 @@ import logger from "../../config/logger"; import { config } from "../../config/vars"; import { CUSTOMER_ENTITY_TYPES } from "../../models/customerEntity.model"; import type { ManagedProfileStatus } from "../../models/managedProfile.model"; -import ManagedProfileManager from "../../models/managedProfileManager.model"; import { getAuthenticatedProfileId } from "../middlewares/effectiveUser"; import { ApiCredentialServiceError, @@ -15,7 +14,7 @@ import { import { createManagedProfile, deleteManagedProfile, - getManagedProfile, + getManagedProfileActor, listManagedProfiles, ManagedProfileLifecycleError } from "../services/managed-profile-lifecycle.service"; @@ -28,6 +27,10 @@ function managerProfileId(req: Request): string { return profileId; } +function controllingManagerProfileId(req: Request): string { + return req.managedProfileContext?.controllingManagerProfileId ?? managerProfileId(req); +} + function sendError(res: Response, error: unknown): void { if (error instanceof ApiCredentialServiceError) { const status = @@ -122,17 +125,9 @@ export async function readManagedProfiles(req: Request, res: Response): Promise< offset, status: status as ManagedProfileStatus | "all" }); - const manager = await ManagedProfileManager.findByPk(managerProfileId(req)); - if (!manager?.isActive) { - throw new ManagedProfileLifecycleError("MANAGED_PROFILE_ACCESS_DENIED", "Managed profile access is denied"); - } res.status(httpStatus.OK).json({ + actor: result.actor, managedProfiles: result.managedProfiles, - manager: { - allowedCorridors: manager.allowedCorridors, - allowedCustomerTypes: manager.allowedCustomerTypes, - profileId: manager.profileId - }, pagination: { limit: result.limit, offset: result.offset, total: result.total } }); } catch (error) { @@ -143,8 +138,9 @@ export async function readManagedProfiles(req: Request, res: Response): Promise< export async function readManagedProfile(req: Request<{ profileId: string }>, res: Response): Promise { try { requireProfileId(req.params.profileId); - const managedProfile = await getManagedProfile(managerProfileId(req), req.params.profileId); - res.status(httpStatus.OK).json({ managedProfile }); + const actorProfileId = managerProfileId(req); + const actor = await getManagedProfileActor(actorProfileId); + res.status(httpStatus.OK).json({ actor, managedProfile: res.locals.managedProfile }); } catch (error) { sendError(res, error); } @@ -164,9 +160,9 @@ export async function postManagedProfileApiCredential(req: Request<{ profileId: try { requireProfileId(req.params.profileId); const credential = await createManagedProfileCredential({ + actorProfileId: managerProfileId(req), environment: config.sandboxEnabled ? "test" : "live", expiresAt: req.body?.expiresAt, - managerProfileId: managerProfileId(req), name: req.body?.name, profileId: req.params.profileId }); @@ -179,7 +175,7 @@ export async function postManagedProfileApiCredential(req: Request<{ profileId: export async function readManagedProfileApiCredentials(req: Request<{ profileId: string }>, res: Response): Promise { try { requireProfileId(req.params.profileId); - const credentials = await listManagedProfileCredentials(managerProfileId(req), req.params.profileId); + const credentials = await listManagedProfileCredentials(controllingManagerProfileId(req), req.params.profileId); res.status(httpStatus.OK).json({ credentials }); } catch (error) { sendError(res, error); diff --git a/apps/api/src/api/middlewares/managedProfileAuth.test.ts b/apps/api/src/api/middlewares/managedProfileAuth.test.ts index f6b5697a2..d4a043838 100644 --- a/apps/api/src/api/middlewares/managedProfileAuth.test.ts +++ b/apps/api/src/api/middlewares/managedProfileAuth.test.ts @@ -2,16 +2,25 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; import CustomerEntity from "../../models/customerEntity.model"; import ManagedProfile from "../../models/managedProfile.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; +import ManagedProfileMembership from "../../models/managedProfileMembership.model"; import User from "../../models/user.model"; import type { CredentialContext } from "../services/apiCredential.service"; -import { authorizeManagedProfile, rejectDirectManagedCredential, type ManagedProfileContext } from "./managedProfileAuth"; +import { + authorizeManagedProfile, + ManagedProfileCapability, + rejectDirectManagedCredential, + type ManagedProfileContext +} from "./managedProfileAuth"; const MANAGER_ID = "11111111-1111-4111-8111-111111111111"; const CHILD_ID = "22222222-2222-4222-8222-222222222222"; +const OWNER_ID = "33333333-3333-4333-8333-333333333333"; function response() { const res = { body: undefined as unknown, + headersSent: false, + locals: {} as Record, statusCode: 200, json: mock((body: unknown) => { res.body = body; @@ -35,12 +44,16 @@ function request(overrides: Record = {}) { describe("authorizeManagedProfile", () => { const originalManagerFindByPk = ManagedProfileManager.findByPk; + const originalMembershipFindOne = ManagedProfileMembership.findOne; + const originalMembershipCount = ManagedProfileMembership.count; const originalRelationshipFindOne = ManagedProfile.findOne; const originalUserFindByPk = User.findByPk; const originalEntityFindAll = CustomerEntity.findAll; afterEach(() => { ManagedProfileManager.findByPk = originalManagerFindByPk; + ManagedProfileMembership.findOne = originalMembershipFindOne; + ManagedProfileMembership.count = originalMembershipCount; ManagedProfile.findOne = originalRelationshipFindOne; User.findByPk = originalUserFindByPk; CustomerEntity.findAll = originalEntityFindAll; @@ -52,14 +65,19 @@ describe("authorizeManagedProfile", () => { allowedCustomerTypes: null, isActive: true })) as never; - ManagedProfile.findOne = mock(async () => ({ id: "relationship-1" })) as never; + ManagedProfileMembership.findOne = mock(async () => ({ id: "membership-1", role: "manager" })) as never; + ManagedProfile.findOne = mock(async () => ({ id: "relationship-1", managerProfileId: OWNER_ID })) as never; User.findByPk = mock(async () => ({ activeCustomerEntityId: "entity-1", kind: "managed" })) as never; CustomerEntity.findAll = mock(async () => [{ id: "entity-1", status: "active", type: "individual" }]) as never; } it("does nothing when the managed profile header is absent", async () => { const next = mock(() => {}); - await authorizeManagedProfile()(request({ get: () => undefined }) as never, response() as never, next); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read })( + request({ get: () => undefined }) as never, + response() as never, + next + ); expect(next).toHaveBeenCalledTimes(1); expect(ManagedProfile.findOne).toBe(originalRelationshipFindOne); }); @@ -67,7 +85,11 @@ describe("authorizeManagedProfile", () => { it("rejects an invalid managed profile id", async () => { const res = response(); const next = mock(() => {}); - await authorizeManagedProfile()(request({ get: () => "not-a-uuid" }) as never, res as never, next); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read })( + request({ get: () => "not-a-uuid" }) as never, + res as never, + next + ); expect(res.statusCode).toBe(400); expect(next).not.toHaveBeenCalled(); }); @@ -75,7 +97,11 @@ describe("authorizeManagedProfile", () => { it("requires an authenticated manager actor", async () => { const res = response(); const next = mock(() => {}); - await authorizeManagedProfile()(request({ userId: undefined }) as never, res as never, next); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read })( + request({ userId: undefined }) as never, + res as never, + next + ); expect(res.statusCode).toBe(401); expect(next).not.toHaveBeenCalled(); }); @@ -83,7 +109,7 @@ describe("authorizeManagedProfile", () => { it("does not treat a public API credential as manager authentication", async () => { const res = response(); const next = mock(() => {}); - await authorizeManagedProfile()( + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read })( request({ credential: { credentialId: "credential-1", @@ -103,9 +129,13 @@ describe("authorizeManagedProfile", () => { it("accepts a manager profile explicitly authenticated by secret-credential middleware", async () => { allowManagedProfile(); - const req = request({ authenticatedCredentialProfileId: MANAGER_ID, userId: undefined }); + const req = request({ + authenticatedCredentialProfileId: MANAGER_ID, + credential: { profileId: MANAGER_ID, strength: "secret" }, + userId: undefined + }); const next = mock(() => {}); - await authorizeManagedProfile()(req as never, response() as never, next); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read })(req as never, response() as never, next); expect(next).toHaveBeenCalledTimes(1); }); @@ -113,13 +143,20 @@ describe("authorizeManagedProfile", () => { allowManagedProfile(); const req = request() as ReturnType & { managedProfileContext?: ManagedProfileContext }; const next = mock(() => {}); - await authorizeManagedProfile({ corridor: "BR" })(req as never, response() as never, next); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read, corridor: "BR" })( + req as never, + response() as never, + next + ); expect(req).toMatchObject({ managedProfileContext: { actorProfileId: MANAGER_ID, - controllingManagerProfileId: MANAGER_ID, + capability: ManagedProfileCapability.Read, + controllingManagerProfileId: OWNER_ID, customerEntityId: "entity-1", managedProfileId: "relationship-1", + membershipId: "membership-1", + membershipRole: "manager", subjectProfileId: CHILD_ID }, userId: MANAGER_ID @@ -128,12 +165,159 @@ describe("authorizeManagedProfile", () => { expect(next).toHaveBeenCalledTimes(1); }); + it("allows read-only members to read but not manage the selected child", async () => { + allowManagedProfile(); + ManagedProfileMembership.findOne = mock(async () => ({ id: "membership-1", role: "read_only" })) as never; + const readNext = mock(() => {}); + + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read })( + request() as never, + response() as never, + readNext + ); + + expect(readNext).toHaveBeenCalledTimes(1); + const denied = response(); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Manage })( + request() as never, + denied as never, + mock(() => {}) + ); + expect(denied.statusCode).toBe(403); + expect(denied.body).toMatchObject({ error: { code: "MANAGED_PROFILE_MANAGER_REQUIRED" } }); + }); + + it("rejects provider mutations and ramp execution for a selected-child bearer session", async () => { + allowManagedProfile(); + + const credentialNext = mock(() => {}); + const credentialDenied = response(); + await authorizeManagedProfile({ capability: ManagedProfileCapability.CredentialManage })( + request() as never, + credentialDenied as never, + credentialNext + ); + expect(credentialNext).not.toHaveBeenCalled(); + expect(credentialDenied.body).toMatchObject({ error: { code: "MANAGED_PROFILE_REQUIRES_API_CREDENTIAL" } }); + + const rampDenied = response(); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Ramp })( + request() as never, + rampDenied as never, + mock(() => {}) + ); + expect(rampDenied.body).toMatchObject({ error: { code: "MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL" } }); + }); + + it("enforces read-only membership for a member-owned secret credential", async () => { + allowManagedProfile(); + ManagedProfileMembership.findOne = mock(async () => ({ id: "membership-1", role: "read_only" })) as never; + const secretRequest = request({ + authenticatedCredentialProfileId: MANAGER_ID, + credential: { profileId: MANAGER_ID, strength: "secret" }, + userId: undefined + }); + + for (const capability of [ + ManagedProfileCapability.Manage, + ManagedProfileCapability.CredentialManage, + ManagedProfileCapability.Ramp + ]) { + const denied = response(); + await authorizeManagedProfile({ capability })(secretRequest as never, denied as never, mock(() => {})); + expect(denied.body).toMatchObject({ error: { code: "MANAGED_PROFILE_MANAGER_REQUIRED" } }); + } + }); + + it("allows a manager member's authenticated secret credential to use credential-only capabilities", async () => { + allowManagedProfile(); + const secretRequest = request({ + authenticatedCredentialProfileId: MANAGER_ID, + credential: { profileId: MANAGER_ID, strength: "secret" }, + userId: undefined + }); + + for (const capability of [ManagedProfileCapability.CredentialManage, ManagedProfileCapability.Ramp]) { + const next = mock(() => {}); + await authorizeManagedProfile({ capability })(secretRequest as never, response() as never, next); + expect(next).toHaveBeenCalledTimes(1); + } + }); + + it("keeps generic selected-child probing separate from bearer membership bootstrap", async () => { + allowManagedProfile(); + ManagedProfileMembership.findOne = mock(async () => null) as never; + const res = response(); + + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read })( + request() as never, + res as never, + mock(() => {}) + ); + + expect(res.body).toMatchObject({ error: { code: "MANAGED_PROFILE_ACCESS_DENIED" } }); + const bootstrapResponse = response(); + ManagedProfileMembership.count = mock(async () => 0) as never; + await authorizeManagedProfile({ + allowDeleted: true, + capability: ManagedProfileCapability.Read, + membershipBootstrap: true, + subjectProfileId: () => CHILD_ID + })(request() as never, bootstrapResponse as never, mock(() => {})); + expect(bootstrapResponse.body).toMatchObject({ error: { code: "MANAGED_PROFILE_NOT_FOUND" } }); + ManagedProfileMembership.count = mock(async () => 1) as never; + const historicalResponse = response(); + await authorizeManagedProfile({ + allowDeleted: true, + capability: ManagedProfileCapability.Read, + membershipBootstrap: true, + subjectProfileId: () => CHILD_ID + })(request() as never, historicalResponse as never, mock(() => {})); + expect(historicalResponse.body).toMatchObject({ error: { code: "MANAGED_PROFILE_MEMBERSHIP_INVALID" } }); + expect(ManagedProfileMembership.count).toHaveBeenCalledWith({ + where: { managedProfileId: CHILD_ID, memberProfileId: MANAGER_ID } + }); + }); + + it("fails closed for unknown membership roles and unclassified delegated or direct-child routes", async () => { + allowManagedProfile(); + ManagedProfileMembership.findOne = mock(async () => ({ id: "membership-1", role: "admin" })) as never; + const denied = response(); + const next = mock(() => {}); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read })(request() as never, denied as never, next); + expect(denied.statusCode).toBe(403); + expect(next).not.toHaveBeenCalled(); + for (const req of [request(), request({ + get: () => undefined, userId: undefined, + credential: { profileId: CHILD_ID, strength: "secret", managedProfile: { controllingManagerProfileId: OWNER_ID } } + })]) { + const unclassified = response(); + await authorizeManagedProfile({ capability: undefined as never })(req as never, unclassified as never, next); + expect(unclassified.statusCode).toBe(403); + expect(next).not.toHaveBeenCalled(); + } + }); + + it("hides a missing path-subject membership as not found", async () => { + allowManagedProfile(); + ManagedProfileMembership.findOne = mock(async () => null) as never; + const res = response(); + + await authorizeManagedProfile({ + capability: ManagedProfileCapability.Read, + subjectProfileId: () => CHILD_ID + })(request({ get: () => undefined }) as never, res as never, mock(() => {})); + + expect(res.statusCode).toBe(404); + expect(res.body).toMatchObject({ error: { code: "MANAGED_PROFILE_NOT_FOUND" } }); + }); + it("rejects a child that is not directly managed by the authenticated actor", async () => { allowManagedProfile(); ManagedProfile.findOne = mock(async () => null) as never; const res = response(); const next = mock(() => {}); - await authorizeManagedProfile()(request() as never, res as never, next); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read })(request() as never, res as never, next); expect(res.statusCode).toBe(403); expect(next).not.toHaveBeenCalled(); }); @@ -158,10 +342,15 @@ describe("authorizeManagedProfile", () => { }) as ReturnType & { managedProfileContext?: ManagedProfileContext }; const next = mock(() => {}); - await authorizeManagedProfile({ corridor: "BR" })(req as never, response() as never, next); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Ramp, corridor: "BR" })( + req as never, + response() as never, + next + ); expect(req.managedProfileContext).toEqual({ actorProfileId: CHILD_ID, + capability: ManagedProfileCapability.Ramp, controllingManagerProfileId: MANAGER_ID, customerEntityId: "entity-1", managedProfileId: "relationship-1", @@ -170,13 +359,17 @@ describe("authorizeManagedProfile", () => { expect(next).toHaveBeenCalledTimes(1); const denied = response(); - await authorizeManagedProfile({ corridor: "MX" })(req as never, denied as never, mock(() => {})); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Ramp, corridor: "MX" })( + req as never, + denied as never, + mock(() => {}) + ); expect(denied.statusCode).toBe(403); }); it("does not let a direct child credential select another managed child", async () => { const res = response(); - await authorizeManagedProfile()( + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read })( request({ credential: { managedProfile: { @@ -194,18 +387,37 @@ describe("authorizeManagedProfile", () => { expect(res.statusCode).toBe(403); }); + it("rejects a selected child that differs from the route subject", async () => { + const res = response(); + await authorizeManagedProfile({ + capability: ManagedProfileCapability.Read, + subjectProfileId: () => OWNER_ID + })(request() as never, res as never, mock(() => {})); + + expect(res.body).toMatchObject({ error: { code: "MANAGED_PROFILE_ACCESS_DENIED" } }); + expect(ManagedProfileMembership.findOne).toBe(originalMembershipFindOne); + }); + it("rejects an inactive manager", async () => { allowManagedProfile(); ManagedProfileManager.findByPk = mock(async () => ({ allowedCorridors: ["BR"], isActive: false })) as never; const res = response(); - await authorizeManagedProfile()(request() as never, res as never, mock(() => {})); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read })( + request() as never, + res as never, + mock(() => {}) + ); expect(res.statusCode).toBe(403); }); it("rejects a corridor that is not enabled for the manager", async () => { allowManagedProfile(); const res = response(); - await authorizeManagedProfile({ corridor: "MX" })(request() as never, res as never, mock(() => {})); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read, corridor: "MX" })( + request() as never, + res as never, + mock(() => {}) + ); expect(res.statusCode).toBe(403); }); @@ -218,12 +430,20 @@ describe("authorizeManagedProfile", () => { })) as never; const narrowed = response(); - await authorizeManagedProfile({ corridor: "BR" })(request() as never, narrowed as never, mock(() => {})); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read, corridor: "BR" })( + request() as never, + narrowed as never, + mock(() => {}) + ); expect(narrowed.statusCode).toBe(403); CustomerEntity.findAll = mock(async () => [{ id: "entity-1", status: "active", type: "business" }]) as never; const unsupported = response(); - await authorizeManagedProfile({ corridor: "AR" })(request() as never, unsupported as never, mock(() => {})); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read, corridor: "AR" })( + request() as never, + unsupported as never, + mock(() => {}) + ); expect(unsupported.statusCode).toBe(403); }); @@ -236,7 +456,7 @@ describe("authorizeManagedProfile", () => { })) as never; const next = mock(() => {}); - await authorizeManagedProfile()(request() as never, response() as never, next); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read })(request() as never, response() as never, next); expect(next).toHaveBeenCalledTimes(1); }); @@ -250,7 +470,7 @@ describe("authorizeManagedProfile", () => { })) as never; const res = response(); - await authorizeManagedProfile({ enforceCustomerTypePolicy: true })( + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read, enforceCustomerTypePolicy: true })( request() as never, res as never, mock(() => {}) @@ -262,7 +482,11 @@ describe("authorizeManagedProfile", () => { it("requires the route customer type to match the immutable child entity type", async () => { allowManagedProfile(); const res = response(); - await authorizeManagedProfile({ corridor: "BR", customerType: "business" })( + await authorizeManagedProfile({ + capability: ManagedProfileCapability.Read, + corridor: "BR", + customerType: "business" + })( request() as never, res as never, mock(() => {}) @@ -274,12 +498,20 @@ describe("authorizeManagedProfile", () => { it("requires every resolved corridor to be enabled for the manager", async () => { allowManagedProfile(); const denied = response(); - await authorizeManagedProfile({ corridor: () => ["BR", "MX"] })(request() as never, denied as never, mock(() => {})); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read, corridor: () => ["BR", "MX"] })( + request() as never, + denied as never, + mock(() => {}) + ); expect(denied.statusCode).toBe(403); ManagedProfileManager.findByPk = mock(async () => ({ allowedCorridors: ["BR", "MX"], isActive: true })) as never; const next = mock(() => {}); - await authorizeManagedProfile({ corridor: () => ["BR", "MX"] })(request() as never, response() as never, next); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read, corridor: () => ["BR", "MX"] })( + request() as never, + response() as never, + next + ); expect(next).toHaveBeenCalledTimes(1); }); @@ -290,7 +522,11 @@ describe("authorizeManagedProfile", () => { { id: "entity-2", status: "archived", type: "individual" } ]) as never; const res = response(); - await authorizeManagedProfile()(request() as never, res as never, mock(() => {})); + await authorizeManagedProfile({ capability: ManagedProfileCapability.Read })( + request() as never, + res as never, + mock(() => {}) + ); expect(res.statusCode).toBe(403); }); }); diff --git a/apps/api/src/api/middlewares/managedProfileAuth.ts b/apps/api/src/api/middlewares/managedProfileAuth.ts index 4530c8d37..356d0a107 100644 --- a/apps/api/src/api/middlewares/managedProfileAuth.ts +++ b/apps/api/src/api/middlewares/managedProfileAuth.ts @@ -4,16 +4,30 @@ import httpStatus from "http-status"; import CustomerEntity, { type CustomerEntityType } from "../../models/customerEntity.model"; import ManagedProfile from "../../models/managedProfile.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; +import ManagedProfileMembership, { type ManagedProfileMembershipRole } from "../../models/managedProfileMembership.model"; import User from "../../models/user.model"; +import { getManagedProfile, ManagedProfileLifecycleError } from "../services/managed-profile-lifecycle.service"; import { getAuthenticatedProfileId } from "./effectiveUser"; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +export enum ManagedProfileCapability { + CredentialManage = "credential_manage", + Manage = "manage", + Ramp = "ramp", + Read = "read" +} + +export type { ManagedProfileMembershipRole } from "../../models/managedProfileMembership.model"; + export interface ManagedProfileContext { actorProfileId: string; + capability: ManagedProfileCapability; controllingManagerProfileId: string; customerEntityId: string; managedProfileId: string; + membershipId?: string; + membershipRole?: ManagedProfileMembershipRole; subjectProfileId: string; } @@ -38,17 +52,43 @@ type CustomerTypeResolver = | ((req: Request) => CustomerEntityType | undefined | Promise); interface ManagedProfileAuthOptions { + allowDeleted?: boolean; + capability: ManagedProfileCapability; corridor?: CorridorResolver; customerType?: CustomerTypeResolver; enforceCustomerTypePolicy?: boolean; + membershipBootstrap?: boolean; + subjectProfileId?: (req: Request) => string | undefined; } -export function authorizeManagedProfile(options: ManagedProfileAuthOptions = {}) { +const CAPABILITIES_BY_ROLE: Record = { + manager: [ + ManagedProfileCapability.Read, + ManagedProfileCapability.Manage, + ManagedProfileCapability.CredentialManage, + ManagedProfileCapability.Ramp + ], + read_only: [ManagedProfileCapability.Read] +}; + +export function authorizeManagedProfile(options: ManagedProfileAuthOptions) { return async (req: Request, res: Response, next: NextFunction): Promise => { - const subjectProfileId = req.get("X-Managed-Profile-Id"); + const selectedProfileId = req.get("X-Managed-Profile-Id"); + const subjectProfileId = options.subjectProfileId?.(req) ?? selectedProfileId; const directManagedCredential = req.credential?.managedProfile; const directCredentialProfileId = req.credential?.profileId; - if (directManagedCredential && subjectProfileId !== undefined) { + if ( + (subjectProfileId !== undefined || directManagedCredential) && + !Object.values(ManagedProfileCapability).includes(options.capability) + ) { + sendAccessDenied(res); + return; + } + if (options.subjectProfileId && selectedProfileId !== undefined && selectedProfileId !== subjectProfileId) { + sendAccessDenied(res); + return; + } + if (directManagedCredential && selectedProfileId !== undefined) { sendAccessDenied(res); return; } @@ -61,6 +101,7 @@ export function authorizeManagedProfile(options: ManagedProfileAuthOptions = {}) try { const customerType = await attachManagedProfileContext(req, res, { actorProfileId: directCredentialProfileId, + capability: options.capability, controllingManagerProfileId: directManagedCredential.controllingManagerProfileId, managedProfileId: directManagedCredential.relationshipId, subjectProfileId: directCredentialProfileId @@ -72,7 +113,7 @@ export function authorizeManagedProfile(options: ManagedProfileAuthOptions = {}) options.corridor !== undefined && (corridors.length === 0 || corridors.some(corridor => !directManagedCredential.allowedCorridors.includes(corridor))) ) { - sendAccessDenied(res); + sendPolicyDenied(res); return; } if ( @@ -122,23 +163,69 @@ export function authorizeManagedProfile(options: ManagedProfileAuthOptions = {}) } try { - const [manager, relationship, subject] = await Promise.all([ - ManagedProfileManager.findByPk(actorProfileId), + if (options.allowDeleted) { + if (options.capability !== ManagedProfileCapability.Read) { + sendAccessDenied(res); + return; + } + res.locals.managedProfile = await getManagedProfile(actorProfileId, subjectProfileId, { + bootstrap: options.membershipBootstrap === true && selectedProfileId !== undefined + }); + next(); + return; + } + const [membership, relationship, subject] = await Promise.all([ + ManagedProfileMembership.findOne({ + where: { managedProfileId: subjectProfileId, memberProfileId: actorProfileId, revokedAt: null } + }), ManagedProfile.findOne({ - where: { managerProfileId: actorProfileId, profileId: subjectProfileId, status: "active" } + where: { profileId: subjectProfileId, status: "active" } }), User.findByPk(subjectProfileId, { attributes: ["activeCustomerEntityId", "kind"] }) ]); - if (!manager?.isActive || !relationship || subject?.kind !== "managed" || !subject.activeCustomerEntityId) { + if (!relationship || subject?.kind !== "managed" || !subject.activeCustomerEntityId) { + if (options.subjectProfileId) sendManagedProfileNotFound(res); + else sendAccessDenied(res); + return; + } + if (!membership || !isMembershipRole(membership.role)) { + if (options.subjectProfileId) sendManagedProfileNotFound(res); + else sendAccessDenied(res); + return; + } + + const manager = await ManagedProfileManager.findByPk(relationship.managerProfileId); + if (!manager?.isActive) { sendAccessDenied(res); return; } + if (!CAPABILITIES_BY_ROLE[membership.role].includes(options.capability)) { + sendManagerRequired(res); + return; + } + if (options.capability === ManagedProfileCapability.Ramp && !isSecretCredentialActor(req, actorProfileId)) { + sendRampCredentialRequired(res); + return; + } + if (options.capability === ManagedProfileCapability.CredentialManage && !isSecretCredentialActor(req, actorProfileId)) { + res.status(httpStatus.FORBIDDEN).json({ + error: { + code: "MANAGED_PROFILE_REQUIRES_API_CREDENTIAL", + message: "Managed-profile provider mutations require a secret API credential", + status: httpStatus.FORBIDDEN + } + }); + return; + } const customerType = await attachManagedProfileContext(req, res, { actorProfileId, - controllingManagerProfileId: actorProfileId, + capability: options.capability, + controllingManagerProfileId: relationship.managerProfileId, managedProfileId: relationship.id, + membershipId: membership.id, + membershipRole: membership.role, subjectProfileId }); if (!customerType) return; @@ -148,18 +235,35 @@ export function authorizeManagedProfile(options: ManagedProfileAuthOptions = {}) options.corridor !== undefined && (corridors.length === 0 || corridors.some(corridor => !manager.allowedCorridors.includes(corridor))) ) { - sendAccessDenied(res); + sendPolicyDenied(res); return; } if (!(await authorizeCustomerType(req, res, options, corridors, customerType, manager.allowedCustomerTypes))) return; res.locals.managedProfilePolicy = { allowedCorridors: manager.allowedCorridors, customerType }; next(); } catch (error) { + if (error instanceof ManagedProfileLifecycleError) { + const status = error.code === "MANAGED_PROFILE_NOT_FOUND" ? httpStatus.NOT_FOUND : httpStatus.FORBIDDEN; + res.status(status).json({ error: { code: error.code, message: error.message, status } }); + return; + } next(error); } }; } +function isMembershipRole(role: string): role is ManagedProfileMembershipRole { + return role === "manager" || role === "read_only"; +} + +function isSecretCredentialActor(req: Request, actorProfileId: string): boolean { + return ( + req.authenticatedCredentialProfileId === actorProfileId && + req.credential?.profileId === actorProfileId && + req.credential.strength === "secret" + ); +} + async function resolveCorridors( req: Request, res: Response, @@ -220,7 +324,7 @@ async function authorizeCustomerType( !allowedCustomerTypes.includes(customerType)) || corridors.some(corridor => !isCorridorSupportedForCustomerType(corridor, customerType)) ) { - sendAccessDenied(res); + sendPolicyDenied(res); return false; } return true; @@ -259,3 +363,43 @@ function sendAccessDenied(res: Response): void { } }); } + +function sendManagedProfileNotFound(res: Response): void { + res.status(httpStatus.NOT_FOUND).json({ + error: { + code: "MANAGED_PROFILE_NOT_FOUND", + message: "Managed profile was not found", + status: httpStatus.NOT_FOUND + } + }); +} + +function sendManagerRequired(res: Response): void { + res.status(httpStatus.FORBIDDEN).json({ + error: { + code: "MANAGED_PROFILE_MANAGER_REQUIRED", + message: "An active manager membership is required for this operation", + status: httpStatus.FORBIDDEN + } + }); +} + +function sendRampCredentialRequired(res: Response): void { + res.status(httpStatus.FORBIDDEN).json({ + error: { + code: "MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL", + message: "Managed-profile ramps require a secret API credential", + status: httpStatus.FORBIDDEN + } + }); +} + +function sendPolicyDenied(res: Response): void { + res.status(httpStatus.FORBIDDEN).json({ + error: { + code: "MANAGED_PROFILE_POLICY_DENIED", + message: "The managed-profile owner policy does not allow this operation", + status: httpStatus.FORBIDDEN + } + }); +} diff --git a/apps/api/src/api/middlewares/ownershipAuth.test.ts b/apps/api/src/api/middlewares/ownershipAuth.test.ts index 8307face1..e23f3010c 100644 --- a/apps/api/src/api/middlewares/ownershipAuth.test.ts +++ b/apps/api/src/api/middlewares/ownershipAuth.test.ts @@ -1,6 +1,7 @@ import {afterEach, describe, expect, it, mock} from "bun:test"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; +import {ManagedProfileCapability} from "./managedProfileAuth"; import {assertQuoteOwnership, assertRampOwnership} from "./ownershipAuth"; describe("assertQuoteOwnership", () => { @@ -148,6 +149,7 @@ describe("assertQuoteOwnership", () => { }, managedProfileContext: { actorProfileId: "manager-user", + capability: ManagedProfileCapability.Ramp, controllingManagerProfileId: "manager-user", customerEntityId: "entity-1", managedProfileId: "relationship-1", @@ -170,6 +172,7 @@ describe("assertQuoteOwnership", () => { { managedProfileContext: { actorProfileId: "manager-user", + capability: ManagedProfileCapability.Ramp, controllingManagerProfileId: "manager-user", customerEntityId: "entity-1", managedProfileId: "relationship-1", @@ -323,6 +326,7 @@ describe("assertRampOwnership", () => { { managedProfileContext: { actorProfileId: "manager-user", + capability: ManagedProfileCapability.Ramp, controllingManagerProfileId: "manager-user", customerEntityId: "entity-1", managedProfileId: "relationship-1", @@ -346,6 +350,7 @@ describe("assertRampOwnership", () => { { managedProfileContext: { actorProfileId: "manager-user", + capability: ManagedProfileCapability.Ramp, controllingManagerProfileId: "manager-user", customerEntityId: "entity-1", managedProfileId: "relationship-1", diff --git a/apps/api/src/api/routes/v1/alfredpay.route.ts b/apps/api/src/api/routes/v1/alfredpay.route.ts index 9b127d92d..8234ddcd5 100644 --- a/apps/api/src/api/routes/v1/alfredpay.route.ts +++ b/apps/api/src/api/routes/v1/alfredpay.route.ts @@ -4,7 +4,7 @@ import { AlfredpayController } from "../../controllers/alfredpay.controller"; import { validateAlfredpayCustomerType, validateResultCountry } from "../../middlewares/alfredpay.middleware"; import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; -import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth"; +import { authorizeManagedProfile, ManagedProfileCapability } from "../../middlewares/managedProfileAuth"; import { getManagedProfileAlfredpayCustomerType, getManagedProfileCountryCorridor @@ -19,14 +19,18 @@ router.get( requirePartnerOrUserAuth(), validateResultCountry, validateAlfredpayCustomerType, - authorizeManagedProfile(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), AlfredpayController.alfredpayStatus ); router.post( "/createIndividualCustomer", requirePartnerOrUserAuth(), validateResultCountry, - authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "individual" }), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: getManagedProfileCountryCorridor, + customerType: "individual" + }), rejectImpersonation, AlfredpayController.createIndividualCustomer ); @@ -34,7 +38,11 @@ router.get( "/getKycRedirectLink", requirePartnerOrUserAuth(), validateResultCountry, - authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "individual" }), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: getManagedProfileCountryCorridor, + customerType: "individual" + }), rejectImpersonation, AlfredpayController.getKycRedirectLink ); @@ -42,7 +50,11 @@ router.post( "/kycRedirectOpened", requirePartnerOrUserAuth(), validateResultCountry, - authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: getManagedProfileAlfredpayCustomerType }), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: getManagedProfileCountryCorridor, + customerType: getManagedProfileAlfredpayCustomerType + }), rejectImpersonation, AlfredpayController.kycRedirectOpened ); @@ -50,7 +62,11 @@ router.post( "/kycRedirectFinished", requirePartnerOrUserAuth(), validateResultCountry, - authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: getManagedProfileAlfredpayCustomerType }), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: getManagedProfileCountryCorridor, + customerType: getManagedProfileAlfredpayCustomerType + }), rejectImpersonation, AlfredpayController.kycRedirectFinished ); @@ -58,14 +74,18 @@ router.get( "/getKycStatus", requirePartnerOrUserAuth(), validateResultCountry, - authorizeManagedProfile(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), AlfredpayController.getKycStatus ); router.post( "/retryKyc", requirePartnerOrUserAuth(), validateResultCountry, - authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: getManagedProfileAlfredpayCustomerType }), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: getManagedProfileCountryCorridor, + customerType: getManagedProfileAlfredpayCustomerType + }), rejectImpersonation, AlfredpayController.retryKyc ); @@ -73,7 +93,11 @@ router.post( "/createBusinessCustomer", requirePartnerOrUserAuth(), validateResultCountry, - authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "business" }), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: getManagedProfileCountryCorridor, + customerType: "business" + }), rejectImpersonation, AlfredpayController.createBusinessCustomer ); @@ -81,7 +105,11 @@ router.get( "/getKybRedirectLink", requirePartnerOrUserAuth(), validateResultCountry, - authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "business" }), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: getManagedProfileCountryCorridor, + customerType: "business" + }), rejectImpersonation, AlfredpayController.getKybRedirectLink ); @@ -91,7 +119,11 @@ router.post( "/submitKycInformation", requirePartnerOrUserAuth(), validateResultCountry, - authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "individual" }), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: getManagedProfileCountryCorridor, + customerType: "individual" + }), rejectImpersonation, validateKycSubmission, AlfredpayController.submitKycInformation @@ -101,18 +133,26 @@ router.post( requirePartnerOrUserAuth(), // Authenticate the relationship and immutable entity type before buffering. The country // corridor can only be authorized after multer exposes the multipart body. - authorizeManagedProfile({ customerType: "individual" }), + authorizeManagedProfile({ capability: ManagedProfileCapability.CredentialManage, customerType: "individual" }), rejectImpersonation, upload.single("file"), validateResultCountry, - authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "individual" }), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: getManagedProfileCountryCorridor, + customerType: "individual" + }), AlfredpayController.submitKycFile ); router.post( "/sendKycSubmission", requirePartnerOrUserAuth(), validateResultCountry, - authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "individual" }), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: getManagedProfileCountryCorridor, + customerType: "individual" + }), rejectImpersonation, AlfredpayController.sendKycSubmission ); @@ -122,7 +162,11 @@ router.post( "/submitKybInformation", requirePartnerOrUserAuth(), validateResultCountry, - authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "business" }), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: getManagedProfileCountryCorridor, + customerType: "business" + }), rejectImpersonation, validateKybSubmission, AlfredpayController.submitKybInformation @@ -131,36 +175,48 @@ router.post( "/submitKybFile", requirePartnerOrUserAuth(), // See submitKycFile: identity/type are pre-buffer checks; country policy is post-parse. - authorizeManagedProfile({ customerType: "business" }), + authorizeManagedProfile({ capability: ManagedProfileCapability.CredentialManage, customerType: "business" }), rejectImpersonation, upload.single("file"), validateResultCountry, - authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "business" }), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: getManagedProfileCountryCorridor, + customerType: "business" + }), AlfredpayController.submitKybFile ); router.get( "/findKybCustomerAndBusiness", requirePartnerOrUserAuth(), validateResultCountry, - authorizeManagedProfile(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), AlfredpayController.findKybCustomerAndBusiness ); router.post( "/submitKybRelatedPersonFile", requirePartnerOrUserAuth(), // See submitKycFile: identity/type are pre-buffer checks; country policy is post-parse. - authorizeManagedProfile({ customerType: "business" }), + authorizeManagedProfile({ capability: ManagedProfileCapability.CredentialManage, customerType: "business" }), rejectImpersonation, upload.single("file"), validateResultCountry, - authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "business" }), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: getManagedProfileCountryCorridor, + customerType: "business" + }), AlfredpayController.submitKybRelatedPersonFile ); router.post( "/sendKybSubmission", requirePartnerOrUserAuth(), validateResultCountry, - authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor, customerType: "business" }), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: getManagedProfileCountryCorridor, + customerType: "business" + }), rejectImpersonation, AlfredpayController.sendKybSubmission ); @@ -172,21 +228,21 @@ router.post( "/fiatAccounts", requirePartnerOrUserAuth(), validateResultCountry, - authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor }), + authorizeManagedProfile({ capability: ManagedProfileCapability.Manage, corridor: getManagedProfileCountryCorridor }), AlfredpayController.addFiatAccount ); router.get( "/fiatAccounts", requirePartnerOrUserAuth(), validateResultCountry, - authorizeManagedProfile(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), AlfredpayController.listFiatAccounts ); router.delete( "/fiatAccounts/:fiatAccountId", requirePartnerOrUserAuth(), validateResultCountry, - authorizeManagedProfile({ corridor: getManagedProfileCountryCorridor }), + authorizeManagedProfile({ capability: ManagedProfileCapability.Manage, corridor: getManagedProfileCountryCorridor }), AlfredpayController.deleteFiatAccount ); diff --git a/apps/api/src/api/routes/v1/brla-kyc-import.route.ts b/apps/api/src/api/routes/v1/brla-kyc-import.route.ts index f44ab6ac9..761eb9e09 100644 --- a/apps/api/src/api/routes/v1/brla-kyc-import.route.ts +++ b/apps/api/src/api/routes/v1/brla-kyc-import.route.ts @@ -3,7 +3,11 @@ import { RequestHandler, Router } from "express"; import * as brlaController from "../../controllers/brla.controller"; import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; import { requirePartnerOrUserAuth, requireProfileBoundPrincipal } from "../../middlewares/dualAuth"; -import { authorizeManagedProfile, rejectDirectManagedCredential } from "../../middlewares/managedProfileAuth"; +import { + authorizeManagedProfile, + ManagedProfileCapability, + rejectDirectManagedCredential +} from "../../middlewares/managedProfileAuth"; import { validateAveniaKycTokenImport } from "../../middlewares/validators"; const router: Router = Router({ mergeParams: true }); @@ -13,7 +17,11 @@ router.post( requirePartnerOrUserAuth(), requireProfileBoundPrincipal, rejectDirectManagedCredential, - authorizeManagedProfile({ corridor: "BR", customerType: "individual" }), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: "BR", + customerType: "individual" + }), rejectImpersonation, bodyParser.json({ limit: "16kb" }), validateAveniaKycTokenImport, diff --git a/apps/api/src/api/routes/v1/brla.route.ts b/apps/api/src/api/routes/v1/brla.route.ts index 8e05bd369..ccc7ad6bb 100644 --- a/apps/api/src/api/routes/v1/brla.route.ts +++ b/apps/api/src/api/routes/v1/brla.route.ts @@ -2,7 +2,7 @@ import { RequestHandler, Router } from "express"; import * as brlaController from "../../controllers/brla.controller"; import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; import { optionalPartnerOrUserAuth, requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; -import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth"; +import { authorizeManagedProfile, ManagedProfileCapability } from "../../middlewares/managedProfileAuth"; import { validateAveniaKybDocument, validateAveniaKybLevel1, @@ -23,28 +23,28 @@ const router: Router = Router({ mergeParams: true }); router.get( "/getUser", optionalPartnerOrUserAuth(), - authorizeManagedProfile(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), brlaController.getAveniaUser as unknown as RequestHandler ); router.get( "/getUserRemainingLimit", optionalPartnerOrUserAuth(), - authorizeManagedProfile(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), brlaController.getAveniaUserRemainingLimit as unknown as RequestHandler ); router.get( "/getKycStatus", requirePartnerOrUserAuth(), - authorizeManagedProfile(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), brlaController.fetchSubaccountKycStatus as unknown as RequestHandler ); router.get( "/getSelfieLivenessUrl", requirePartnerOrUserAuth(), - authorizeManagedProfile({ corridor: "BR" }), + authorizeManagedProfile({ capability: ManagedProfileCapability.CredentialManage, corridor: "BR" }), rejectImpersonation, brlaController.getSelfieLivenessUrl as unknown as RequestHandler ); @@ -55,36 +55,40 @@ router .route("/createSubaccount") .post( requirePartnerOrUserAuth(), - authorizeManagedProfile({ corridor: "BR" }), + authorizeManagedProfile({ capability: ManagedProfileCapability.CredentialManage, corridor: "BR" }), rejectImpersonation, validateSubaccountCreation, brlaController.createSubaccount as unknown as RequestHandler ); -router - .route("/getUploadUrls") - .post( - requirePartnerOrUserAuth(), - authorizeManagedProfile({ corridor: "BR", customerType: "individual" }), - rejectImpersonation, - validateStartKyc2, - brlaController.getUploadUrls - ); +router.route("/getUploadUrls").post( + requirePartnerOrUserAuth(), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: "BR", + customerType: "individual" + }), + rejectImpersonation, + validateStartKyc2, + brlaController.getUploadUrls +); -router - .route("/newKyc") - .post( - requirePartnerOrUserAuth(), - authorizeManagedProfile({ corridor: "BR", customerType: "individual" }), - rejectImpersonation, - brlaController.newKyc - ); +router.route("/newKyc").post( + requirePartnerOrUserAuth(), + authorizeManagedProfile({ + capability: ManagedProfileCapability.CredentialManage, + corridor: "BR", + customerType: "individual" + }), + rejectImpersonation, + brlaController.newKyc +); router .route("/kyb/new-level-1/web-sdk") .post( requirePartnerOrUserAuth(), - authorizeManagedProfile({ corridor: "BR" }), + authorizeManagedProfile({ capability: ManagedProfileCapability.CredentialManage, corridor: "BR" }), rejectImpersonation, brlaController.initiateKybLevel1 ); @@ -93,7 +97,7 @@ router .route("/kyb/documents") .post( requirePartnerOrUserAuth(), - authorizeManagedProfile({ corridor: "BR" }), + authorizeManagedProfile({ capability: ManagedProfileCapability.CredentialManage, corridor: "BR" }), rejectImpersonation, validateAveniaKybDocument, brlaController.createKybDocument as unknown as RequestHandler @@ -101,13 +105,17 @@ router router .route("/kyb/documents/:documentId") - .get(requirePartnerOrUserAuth(), authorizeManagedProfile(), brlaController.getKybDocument as unknown as RequestHandler); + .get( + requirePartnerOrUserAuth(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), + brlaController.getKybDocument as unknown as RequestHandler + ); router .route("/kyb/ubos") .post( requirePartnerOrUserAuth(), - authorizeManagedProfile({ corridor: "BR" }), + authorizeManagedProfile({ capability: ManagedProfileCapability.CredentialManage, corridor: "BR" }), rejectImpersonation, validateAveniaKybUbo, brlaController.createKybUbo as unknown as RequestHandler @@ -117,7 +125,7 @@ router .route("/kyb/new-level-1/api") .post( requirePartnerOrUserAuth(), - authorizeManagedProfile({ corridor: "BR" }), + authorizeManagedProfile({ capability: ManagedProfileCapability.CredentialManage, corridor: "BR" }), rejectImpersonation, validateAveniaKybLevel1, brlaController.submitKybLevel1Api as unknown as RequestHandler @@ -125,13 +133,17 @@ router router .route("/kyb/attempt-status") - .get(requirePartnerOrUserAuth(), authorizeManagedProfile(), brlaController.getKybAttemptStatus as unknown as RequestHandler); + .get( + requirePartnerOrUserAuth(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), + brlaController.getKybAttemptStatus as unknown as RequestHandler + ); router .route("/kyc/record-attempt") .post( requirePartnerOrUserAuth(), - authorizeManagedProfile({ corridor: "BR" }), + authorizeManagedProfile({ capability: ManagedProfileCapability.CredentialManage, corridor: "BR" }), rejectImpersonation, brlaController.recordInitialKycAttempt ); diff --git a/apps/api/src/api/routes/v1/limits.route.ts b/apps/api/src/api/routes/v1/limits.route.ts index e446fab3f..96e31a26f 100644 --- a/apps/api/src/api/routes/v1/limits.route.ts +++ b/apps/api/src/api/routes/v1/limits.route.ts @@ -1,7 +1,7 @@ import { RequestHandler, Router } from "express"; import { getLimits, validateLimitsRequest } from "../../controllers/limits.controller"; import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; -import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth"; +import { authorizeManagedProfile, ManagedProfileCapability } from "../../middlewares/managedProfileAuth"; import { getManagedProfileLimitsCorridors } from "../../middlewares/managedProfileCorridor"; const router: Router = Router({ mergeParams: true }); @@ -10,7 +10,7 @@ router.post( "/", requirePartnerOrUserAuth(), validateLimitsRequest, - authorizeManagedProfile({ corridor: getManagedProfileLimitsCorridors }), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read, corridor: getManagedProfileLimitsCorridors }), getLimits as unknown as RequestHandler ); diff --git a/apps/api/src/api/routes/v1/managed-profile-ramp-bearer.route.test.ts b/apps/api/src/api/routes/v1/managed-profile-ramp-bearer.route.test.ts new file mode 100644 index 000000000..e3c1ccb95 --- /dev/null +++ b/apps/api/src/api/routes/v1/managed-profile-ramp-bearer.route.test.ts @@ -0,0 +1,91 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, mock, spyOn } from "bun:test"; +import express from "express"; +import { connect } from "node:net"; +import CustomerEntity from "../../../models/customerEntity.model"; +import ManagedProfile from "../../../models/managedProfile.model"; +import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import ManagedProfileMembership from "../../../models/managedProfileMembership.model"; +import User from "../../../models/user.model"; +import { handler as errorHandler } from "../../middlewares/error"; +import { SupabaseAuthService } from "../../services/auth"; +import { managedProfileRampBearerRoutes } from "./ramp.route"; + +const MEMBER_ID = "11111111-1111-4111-8111-111111111111"; +const CHILD_ID = "22222222-2222-4222-8222-222222222222"; +const OWNER_ID = "33333333-3333-4333-8333-333333333333"; + +describe("selected-child ramp bearer pre-parser guard", () => { + let server: ReturnType; + let baseUrl: string; + + beforeAll(() => { + const app = express(); + app.use("/v1/ramp", managedProfileRampBearerRoutes); + app.use(express.json()); + app.use((_req, res) => res.status(418).json({ reachedParsedRoutes: true })); + app.use(errorHandler); + server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Could not bind test server"); + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterEach(() => mock.restore()); + afterAll(() => server.close()); + + it("denies register, update, and start without waiting for the request body", async () => { + spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ user_id: MEMBER_ID, valid: true }); + spyOn(ManagedProfileMembership, "findOne").mockResolvedValue({ id: "membership-1", role: "manager" } as never); + spyOn(ManagedProfile, "findOne").mockResolvedValue({ id: "relationship-1", managerProfileId: OWNER_ID } as never); + spyOn(ManagedProfileManager, "findByPk").mockResolvedValue({ isActive: true } as never); + spyOn(User, "findByPk").mockResolvedValue({ activeCustomerEntityId: "entity-1", kind: "managed" } as never); + spyOn(CustomerEntity, "findAll").mockResolvedValue([ + { id: "entity-1", status: "active", type: "individual" } + ] as never); + const headers = { + Authorization: "Bearer valid-token", + "Content-Length": "1048576", + "Content-Type": "application/json", + "X-Managed-Profile-Id": CHILD_ID + }; + + for (const path of ["register", "update", "start"]) { + const response = await postPartialBody(`${baseUrl}/v1/ramp/${path}`, headers); + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ error: { code: "MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL" } }); + } + }); +}); + +function postPartialBody(url: string, headers: Record): Promise<{ body: unknown; status: number }> { + return new Promise((resolve, reject) => { + const requestUrl = new URL(url); + let response = ""; + let settled = false; + const socket = connect(Number(requestUrl.port), requestUrl.hostname, () => { + const requestHeaders = { ...headers, Connection: "close", Host: requestUrl.host }; + socket.write( + `POST ${requestUrl.pathname} HTTP/1.1\r\n${Object.entries(requestHeaders) + .map(([name, value]) => `${name}: ${value}`) + .join("\r\n")}\r\n\r\n{` + ); + }); + socket.setEncoding("utf8"); + socket.setTimeout(4_000, () => { + socket.destroy(); + reject(new Error("Server waited for the incomplete request body")); + }); + socket.on("data", chunk => { + response += chunk; + }); + socket.on("end", () => { + settled = true; + const [head, body = ""] = response.split("\r\n\r\n", 2); + const status = Number(head?.split(" ")[1] ?? 0); + resolve({ body: JSON.parse(body), status }); + }); + socket.on("error", error => { + if (!settled) reject(error); + }); + }); +} diff --git a/apps/api/src/api/routes/v1/managed-profile-reads.route.test.ts b/apps/api/src/api/routes/v1/managed-profile-reads.route.test.ts index ad36eaa7a..f769d4b8f 100644 --- a/apps/api/src/api/routes/v1/managed-profile-reads.route.test.ts +++ b/apps/api/src/api/routes/v1/managed-profile-reads.route.test.ts @@ -1,15 +1,28 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, mock, spyOn } from "bun:test"; import express from "express"; +import { AlfredpayApiService } from "@vortexfi/shared"; import CustomerEntity from "../../../models/customerEntity.model"; import ManagedProfile from "../../../models/managedProfile.model"; import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import ManagedProfileMembership from "../../../models/managedProfileMembership.model"; +import ProfileRole from "../../../models/profileRole.model"; +import ProviderCustomer from "../../../models/providerCustomer.model"; +import RecipientInvitation from "../../../models/recipientInvitation.model"; +import SenderRecipient from "../../../models/senderRecipient.model"; import User from "../../../models/user.model"; import * as apiKeyAuthHelpers from "../../middlewares/apiKeyAuth.helpers"; import * as limitsService from "../../services/limits.service"; +import * as customerEntityService from "../../services/customer-entity.service"; +import * as eligibilityService from "../../services/recipients/transfer-eligibility.service"; +import * as alfredpayCustomerService from "../../services/alfredpay/alfredpay-customer.service"; import * as rampInfoService from "../../services/rampInfo.service"; import { SupabaseAuthService } from "../../services/auth"; import limitsRoutes from "./limits.route"; import rampInfoRoutes from "./ramp-info.route"; +import alfredpayRoutes from "./alfredpay.route"; +import brlaRoutes from "./brla.route"; +import brlaImportRoutes from "./brla-kyc-import.route"; +import recipientsRoutes from "./recipients.route"; const MANAGER_ID = "11111111-1111-4111-8111-111111111111"; const CHILD_ID = "22222222-2222-4222-8222-222222222222"; @@ -22,7 +35,11 @@ describe("managed profile read routes", () => { beforeAll(() => { const app = express(); + app.use("/v1/brla/kyc/import-token", brlaImportRoutes); app.use(express.json()); + app.use("/v1/alfredpay", alfredpayRoutes); + app.use("/v1/brla", brlaRoutes); + app.use("/v1/recipients", recipientsRoutes); app.use("/v1/limits", limitsRoutes); app.use("/v1/ramp-info", rampInfoRoutes); server = app.listen(0); @@ -34,13 +51,191 @@ describe("managed profile read routes", () => { afterEach(() => mock.restore()); afterAll(() => server.close()); - function allowManagedProfile(allowedCorridors = ["BR", "MX", "US"]): void { + function allowManagedProfile(allowedCorridors = ["BR", "MX", "US"], role = "manager"): void { spyOn(ManagedProfileManager, "findByPk").mockResolvedValue({ allowedCorridors, allowedCustomerTypes: null, isActive: true } as never); - spyOn(ManagedProfile, "findOne").mockResolvedValue({ id: "relationship-1" } as never); + spyOn(ManagedProfile, "findOne").mockResolvedValue({ id: "relationship-1", managerProfileId: "owner-1" } as never); + spyOn(ManagedProfileMembership, "findOne").mockResolvedValue({ id: "membership-1", role } as never); spyOn(User, "findByPk").mockResolvedValue({ activeCustomerEntityId: "entity-1", kind: "managed" } as never); spyOn(CustomerEntity, "findAll").mockResolvedValue([{ id: "entity-1", status: "active", type: "individual" }] as never); } + function authenticateMember(): void { + spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ user_id: MANAGER_ID, valid: true }); + spyOn(apiKeyAuthHelpers, "validateSecretApiKey").mockResolvedValue({ + apiKeyId: "credential-1", + credential: { + credentialId: "credential-1", + environment: "test", + partnerId: null, + profileId: MANAGER_ID, + strength: "secret" + }, + partner: null + }); + } + + it("keeps fiat-account mutations Manage and reads Read for both bearer and secret members", async () => { + authenticateMember(); + allowManagedProfile(); + const customerLookup = spyOn(alfredpayCustomerService, "findAlfredpayCustomer") + .mockResolvedValue({ alfredPayId: "child-provider-id" } as never); + const createFiatAccount = mock(async () => ({ id: "fiat-1" })); + const deleteFiatAccount = mock(async () => {}); + const listFiatAccounts = mock(async () => []); + spyOn(AlfredpayApiService, "getInstance").mockReturnValue({ + createFiatAccount, deleteFiatAccount, listFiatAccounts + } as never); + for (const role of ["manager", "read_only"]) { + spyOn(ManagedProfileMembership, "findOne").mockResolvedValue({ id: "membership-1", role } as never); + for (const auth of [{ Authorization: "Bearer member-token" }, { "X-API-Key": SECRET_KEY }] as Record[]) { + const headers = { ...auth, "Content-Type": "application/json", "X-Managed-Profile-Id": CHILD_ID }; + expect((await fetch(`${baseUrl}/v1/alfredpay/fiatAccounts?country=MX`, { headers })).status).toBe(200); + expect((await fetch(`${baseUrl}/v1/alfredpay/fiatAccounts`, { + headers, method: "POST", body: JSON.stringify({ country: "MX", type: "SPEI", accountNumber: "123" }) + })).status).toBe(role === "manager" ? 200 : 403); + expect((await fetch(`${baseUrl}/v1/alfredpay/fiatAccounts/fiat-1?country=MX`, { + headers, method: "DELETE" + })).status).toBe(role === "manager" ? 204 : 403); + } + } + expect(customerLookup).toHaveBeenCalledWith(CHILD_ID, "MX"); + expect(createFiatAccount).toHaveBeenCalledTimes(2); + expect(deleteFiatAccount).toHaveBeenCalledTimes(2); + expect(deleteFiatAccount).toHaveBeenCalledWith("child-provider-id", "fiat-1"); + expect(listFiatAccounts).toHaveBeenCalledTimes(4); + }); + + it("classifies every provider mutation as secret-only, including GET links and multipart uploads", async () => { + authenticateMember(); + allowManagedProfile(); + const mutations = [ + ["GET", "/brla/getSelfieLivenessUrl"], + ...["createSubaccount", "getUploadUrls", "newKyc", "kyb/new-level-1/web-sdk", "kyb/documents", "kyb/ubos", + "kyb/new-level-1/api", "kyc/record-attempt", "kyc/import-token"].map(path => ["POST", `/brla/${path}`]), + ...["getKycRedirectLink", "getKybRedirectLink"].map(path => ["GET", `/alfredpay/${path}?country=MX`]), + ...["createIndividualCustomer", "createBusinessCustomer", "kycRedirectOpened", "kycRedirectFinished", "retryKyc", + "submitKycInformation", "submitKycFile", "sendKycSubmission", "submitKybInformation", "submitKybFile", + "submitKybRelatedPersonFile", "sendKybSubmission"].map(path => ["POST", `/alfredpay/${path}`]) + ]; + for (const [method, path] of mutations) { + const response = await fetch(`${baseUrl}/v1${path}`, { + method, + ...(method === "POST" ? { body: JSON.stringify({ country: "MX" }) } : {}), + headers: { Authorization: "Bearer member-token", "Content-Type": "application/json", "X-Managed-Profile-Id": CHILD_ID } + }); + expect({ path, status: response.status, body: await response.json() }).toMatchObject({ + path, status: 403, body: { error: { code: "MANAGED_PROFILE_REQUIRES_API_CREDENTIAL" } } + }); + } + spyOn(ManagedProfileMembership, "findOne").mockResolvedValue({ id: "membership-1", role: "read_only" } as never); + for (const [method, path] of mutations) { + const response = await fetch(`${baseUrl}/v1${path}`, { + method, + ...(method === "POST" ? { body: JSON.stringify({ country: "MX" }) } : {}), + headers: { "X-API-Key": SECRET_KEY, "Content-Type": "application/json", "X-Managed-Profile-Id": CHILD_ID } + }); + expect({ path, status: response.status, body: await response.json() }).toMatchObject({ + path, status: 403, body: { error: { code: "MANAGED_PROFILE_MANAGER_REQUIRED" } } + }); + } + }); + + it("allows member-secret recipient operations on the exact child and checks the actor's discount role", async () => { + authenticateMember(); + allowManagedProfile(); + const resolveEntity = spyOn(customerEntityService, "getOrCreateCustomerEntityForProfile") + .mockResolvedValue({ id: "entity-1" } as never); + spyOn(SenderRecipient, "findAll").mockResolvedValue([]); + spyOn(RecipientInvitation, "findAll").mockResolvedValue([]); + spyOn(RecipientInvitation, "update").mockResolvedValue([0]); + spyOn(ProviderCustomer, "count").mockResolvedValue(1); + const role = spyOn(ProfileRole, "findOne").mockResolvedValue(null); + const create = spyOn(RecipientInvitation, "create").mockImplementation(async values => values as never); + const update = mock(async () => {}); + const recipientId = "33333333-3333-4333-8333-333333333333"; + const recipient = { id: recipientId, rail: "brl", get: () => ({ country: "BR" }), update }; + const findRecipient = spyOn(SenderRecipient, "findOne").mockResolvedValue(recipient as never); + const findInvitation = spyOn(RecipientInvitation, "findOne").mockResolvedValue({ id: recipientId, country: "BR", update } as never); + spyOn(eligibilityService, "getTransferEligibility").mockResolvedValue({ canCreateTransfer: true } as never); + const headers = { "X-API-Key": SECRET_KEY, "Content-Type": "application/json", "X-Managed-Profile-Id": CHILD_ID }; + const invite = { country: "BR", rail: "brl", payoutCurrency: "brl" }; + + expect((await fetch(`${baseUrl}/v1/recipients`, { headers })).status).toBe(200); + expect((await fetch(`${baseUrl}/v1/recipients/${recipientId}/eligibility`, { headers })).status).toBe(200); + expect((await fetch(`${baseUrl}/v1/recipients/${recipientId}`, { + headers, method: "PATCH", body: JSON.stringify({ nickname: "Child recipient" }) + })).status).toBe(200); + expect((await fetch(`${baseUrl}/v1/recipients/invitations/${recipientId}`, { + headers, method: "PATCH", body: JSON.stringify({ archived: true }) + })).status).toBe(200); + expect((await fetch(`${baseUrl}/v1/recipients/invite`, { + headers, method: "POST", body: JSON.stringify(invite) + })).status).toBe(201); + expect(resolveEntity).toHaveBeenCalledWith(CHILD_ID); + expect(findRecipient).toHaveBeenCalledWith(expect.objectContaining({ where: { id: recipientId, senderCustomerEntityId: "entity-1" } })); + expect(findInvitation).toHaveBeenCalledWith({ where: { id: recipientId, senderCustomerEntityId: "entity-1" } }); + const deniedDiscount = await fetch(`${baseUrl}/v1/recipients/invite`, { + headers, method: "POST", body: JSON.stringify({ ...invite, discounts: { buyBps: 1 } }) + }); + expect(await deniedDiscount.json()).toMatchObject({ error: { code: "DISCOUNT_ROLE_REQUIRED" } }); + expect(role).toHaveBeenCalledWith({ where: { role: "discount_manager", userId: MANAGER_ID } }); + expect(create).toHaveBeenCalledTimes(1); + role.mockResolvedValue({ role: "discount_manager" } as never); + expect((await fetch(`${baseUrl}/v1/recipients/invite`, { + headers, method: "POST", body: JSON.stringify({ ...invite, discounts: { buyBps: 1 } }) + })).status).toBe(201); + + spyOn(ManagedProfileMembership, "findOne").mockResolvedValue({ id: "membership-1", role: "read_only" } as never); + for (const authHeaders of [{ "X-API-Key": SECRET_KEY }, { Authorization: "Bearer member-token" }] as Record[]) { + const readOnlyHeaders = { "Content-Type": "application/json", "X-Managed-Profile-Id": CHILD_ID, ...authHeaders }; + expect((await fetch(`${baseUrl}/v1/recipients`, { headers: readOnlyHeaders })).status).toBe(200); + expect((await fetch(`${baseUrl}/v1/recipients/${recipientId}/eligibility`, { headers: readOnlyHeaders })).status).toBe(200); + for (const [method, path, body] of [ + ["POST", "invite", invite], ["PATCH", recipientId, { nickname: "denied" }], + ["PATCH", `invitations/${recipientId}`, { archived: true }] + ] as const) { + const denied = await fetch(`${baseUrl}/v1/recipients/${path}`, { + headers: readOnlyHeaders, method, body: JSON.stringify(body) + }); + expect(denied.status).toBe(403); + expect(await denied.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_MANAGER_REQUIRED" } }); + } + } + expect(update).toHaveBeenCalledTimes(2); + expect(create).toHaveBeenCalledTimes(2); + }); + + it("does not open direct-child or self recipient routes to secrets and keeps invitee routes bearer-only", async () => { + authenticateMember(); + allowManagedProfile(); + const invitationLookup = spyOn(RecipientInvitation, "findOne").mockResolvedValue(null); + for (const [method, path] of [["GET", ""], ["POST", "/invite"], ["PATCH", `/${CHILD_ID}`], + ["PATCH", `/invitations/${CHILD_ID}`], ["GET", `/${CHILD_ID}/eligibility`], + ["GET", "/invite/token"], ["POST", "/invite/token/accept"]]) { + expect((await fetch(`${baseUrl}/v1/recipients${path}`, { method, headers: { "X-API-Key": SECRET_KEY } })).status).toBe(401); + } + spyOn(apiKeyAuthHelpers, "validateSecretApiKey").mockResolvedValue({ + apiKeyId: "child-key", partner: null, + credential: { credentialId: "child-key", environment: "test", partnerId: null, profileId: CHILD_ID, strength: "secret", + managedProfile: { allowedCorridors: ["BR"], allowedCustomerTypes: null, controllingManagerProfileId: MANAGER_ID, relationshipId: "relationship-1" } } + }); + for (const [method, path] of [["GET", ""], ["POST", "/invite"], ["PATCH", `/${CHILD_ID}`], + ["PATCH", `/invitations/${CHILD_ID}`], ["GET", `/${CHILD_ID}/eligibility`]]) { + expect((await fetch(`${baseUrl}/v1/recipients${path}`, { + method, headers: { "X-API-Key": SECRET_KEY, "X-Managed-Profile-Id": CHILD_ID } + })).status).toBe(403); + } + for (const [method, path] of [["GET", "/invite/token"], ["POST", "/invite/token/accept"]]) { + expect((await fetch(`${baseUrl}/v1/recipients${path}`, { + method, headers: { Authorization: "Bearer member-token", "X-Managed-Profile-Id": CHILD_ID } + })).status).toBe(400); + expect((await fetch(`${baseUrl}/v1/recipients${path}`, { + method, headers: { Authorization: "Bearer member-token" } + })).status).toBe(404); + } + expect(invitationLookup).toHaveBeenCalledTimes(2); + }); + it("returns exact child limits through a manager Bearer session", async () => { spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ user_id: MANAGER_ID, valid: true }); allowManagedProfile(); diff --git a/apps/api/src/api/routes/v1/managed-profiles.route.ts b/apps/api/src/api/routes/v1/managed-profiles.route.ts index 322216581..69faf5d75 100644 --- a/apps/api/src/api/routes/v1/managed-profiles.route.ts +++ b/apps/api/src/api/routes/v1/managed-profiles.route.ts @@ -1,4 +1,4 @@ -import { Router } from "express"; +import { Request, Router } from "express"; import { postManagedProfile, postManagedProfileApiCredential, @@ -10,18 +10,53 @@ import { } from "../../controllers/managedProfiles.controller"; import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; -import { rejectDirectManagedCredential } from "../../middlewares/managedProfileAuth"; +import { + authorizeManagedProfile, + ManagedProfileCapability, + rejectDirectManagedCredential +} from "../../middlewares/managedProfileAuth"; const router = Router(); +const pathProfileId = (req: Request): string | undefined => + typeof req.params.profileId === "string" ? req.params.profileId : undefined; router.use(requirePartnerOrUserAuth()); router.use(rejectDirectManagedCredential); router.post("/", rejectImpersonation, postManagedProfile); router.get("/", readManagedProfiles); -router.post("/:profileId/api-credentials", rejectImpersonation, postManagedProfileApiCredential); -router.get("/:profileId/api-credentials", readManagedProfileApiCredentials); -router.delete("/:profileId/api-credentials/:credentialId", rejectImpersonation, removeManagedProfileApiCredential); -router.get("/:profileId", readManagedProfile); +router.post( + "/:profileId/api-credentials", + rejectImpersonation, + authorizeManagedProfile({ + capability: ManagedProfileCapability.Manage, + subjectProfileId: pathProfileId + }), + postManagedProfileApiCredential +); +router.get( + "/:profileId/api-credentials", + authorizeManagedProfile({ capability: ManagedProfileCapability.Read, subjectProfileId: pathProfileId }), + readManagedProfileApiCredentials +); +router.delete( + "/:profileId/api-credentials/:credentialId", + rejectImpersonation, + authorizeManagedProfile({ + capability: ManagedProfileCapability.Manage, + subjectProfileId: pathProfileId + }), + removeManagedProfileApiCredential +); +router.get( + "/:profileId", + authorizeManagedProfile({ + allowDeleted: true, + capability: ManagedProfileCapability.Read, + membershipBootstrap: true, + subjectProfileId: pathProfileId + }), + readManagedProfile +); router.delete("/:profileId", rejectImpersonation, removeManagedProfile); export default router; diff --git a/apps/api/src/api/routes/v1/onboarding.route.ts b/apps/api/src/api/routes/v1/onboarding.route.ts index 3f4d5b13a..299e203e8 100644 --- a/apps/api/src/api/routes/v1/onboarding.route.ts +++ b/apps/api/src/api/routes/v1/onboarding.route.ts @@ -1,7 +1,11 @@ import { Request, Response, Router } from "express"; import { getOnboardingRequirements, getOnboardingStatus, putActiveEntity } from "../../controllers/onboarding.controller"; import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; -import { authorizeManagedProfile, rejectManagedProfileSelection } from "../../middlewares/managedProfileAuth"; +import { + authorizeManagedProfile, + ManagedProfileCapability, + rejectManagedProfileSelection +} from "../../middlewares/managedProfileAuth"; import { requireAuth } from "../../middlewares/supabaseAuth"; const router: Router = Router({ mergeParams: true }); @@ -15,7 +19,7 @@ router.get("/requirements", getOnboardingRequirements); router.get( "/status", requirePartnerOrUserAuth(), - authorizeManagedProfile(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), getOnboardingStatus as unknown as (req: Request, res: Response) => void ); router.put( diff --git a/apps/api/src/api/routes/v1/quote.route.ts b/apps/api/src/api/routes/v1/quote.route.ts index 51bedc071..469916648 100644 --- a/apps/api/src/api/routes/v1/quote.route.ts +++ b/apps/api/src/api/routes/v1/quote.route.ts @@ -2,7 +2,7 @@ import { Router } from "express"; import { createBestQuote, createQuote, getQuote } from "../../controllers/quote.controller"; import { apiKeyAuth, enforcePartnerAuth } from "../../middlewares/apiKeyAuth"; import { rejectDuringActiveMaintenance } from "../../middlewares/maintenanceGuard"; -import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth"; +import { authorizeManagedProfile, ManagedProfileCapability } from "../../middlewares/managedProfileAuth"; import { validatePublicKey } from "../../middlewares/publicKeyAuth"; import { optionalAuth } from "../../middlewares/supabaseAuth"; import { validateCreateBestQuoteInput, validateCreateQuoteInput } from "../../middlewares/validators"; @@ -52,7 +52,7 @@ router apiKeyAuth({ required: false }), validateCreateQuoteInput, enforcePartnerAuth(), - authorizeManagedProfile(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), createQuote ); @@ -118,7 +118,7 @@ router apiKeyAuth({ required: false }), validateCreateBestQuoteInput, enforcePartnerAuth(), - authorizeManagedProfile(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), createBestQuote ); diff --git a/apps/api/src/api/routes/v1/ramp-info.route.ts b/apps/api/src/api/routes/v1/ramp-info.route.ts index f2d668459..2c373d21d 100644 --- a/apps/api/src/api/routes/v1/ramp-info.route.ts +++ b/apps/api/src/api/routes/v1/ramp-info.route.ts @@ -2,7 +2,7 @@ import { Router } from "express"; import rateLimit from "express-rate-limit"; import { getRampInfo } from "../../controllers/rampInfo.controller"; import { apiKeyAuth } from "../../middlewares/apiKeyAuth"; -import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth"; +import { authorizeManagedProfile, ManagedProfileCapability } from "../../middlewares/managedProfileAuth"; import { validatePublicKey } from "../../middlewares/publicKeyAuth"; const router = Router({ mergeParams: true }); @@ -17,6 +17,14 @@ const credentialLimiter = rateLimit({ windowMs }); -router.get("/", ipLimiter, validatePublicKey(), apiKeyAuth(), authorizeManagedProfile(), credentialLimiter, getRampInfo); +router.get( + "/", + ipLimiter, + validatePublicKey(), + apiKeyAuth(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), + credentialLimiter, + getRampInfo +); export default router; diff --git a/apps/api/src/api/routes/v1/ramp.route.test.ts b/apps/api/src/api/routes/v1/ramp.route.test.ts index 3cc41641a..a21642cb4 100644 --- a/apps/api/src/api/routes/v1/ramp.route.test.ts +++ b/apps/api/src/api/routes/v1/ramp.route.test.ts @@ -1,23 +1,30 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import express from "express"; +import { connect } from "node:net"; import { config } from "../../../config/vars"; +import ManagedProfileManager from "../../../models/managedProfileManager.model"; import ProfileRole from "../../../models/profileRole.model"; import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { installFakeSupabaseAuth, testUserToken } from "../../../test-utils/fake-world/fake-auth"; import { createTestUser } from "../../../test-utils/factories"; import { handler as errorHandler } from "../../middlewares/error"; import { createSession } from "../../services/impersonation.service"; +import { createManagedProfile } from "../../services/managed-profile-lifecycle.service"; import quoteRoutes from "./quote.route"; -import rampRoutes from "./ramp.route"; +import rampRoutes, { managedProfileRampBearerRoutes } from "./ramp.route"; describe("ramp routes under impersonation", () => { const originalImpersonationEnabled = config.impersonationEnabled; + let auth: { restore: () => void }; let server: ReturnType; let baseUrl: string; beforeAll(async () => { + auth = installFakeSupabaseAuth(); await setupTestDatabase(); const app = express(); + app.use("/v1/ramp", managedProfileRampBearerRoutes); app.use(express.json()); app.use("/v1/quotes", quoteRoutes); app.use("/v1/ramp", rampRoutes); @@ -32,6 +39,7 @@ describe("ramp routes under impersonation", () => { afterAll(() => { server?.close(); + auth?.restore(); config.impersonationEnabled = originalImpersonationEnabled; }); @@ -64,6 +72,57 @@ describe("ramp routes under impersonation", () => { } }); + it("rejects selected-child ramp registration, update, and start for a normal Supabase bearer", async () => { + const manager = await createTestUser(); + await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: manager.id }); + const { managedProfile } = await createManagedProfile({ + contactEmail: "bearer-ramp-child@example.com", + creationSource: "manager", + customerType: "individual", + externalSubjectId: "bearer-ramp-child", + managerProfileId: manager.id + }); + const headers = { + Authorization: `Bearer ${testUserToken(manager.id, manager.email)}`, + "Content-Type": "application/json", + "X-Managed-Profile-Id": managedProfile.profileId + }; + + for (const path of ["register", "update", "start"]) { + const response = await fetch(`${baseUrl}/ramp/${path}`, { body: JSON.stringify({}), headers, method: "POST" }); + expect(response.status).toBe(403); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL"); + } + }); + + it("rejects selected-child Supabase ramp requests without draining the body", async () => { + const manager = await createTestUser(); + await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: manager.id }); + const { managedProfile } = await createManagedProfile({ + contactEmail: "undrained-bearer-ramp-child@example.com", + creationSource: "manager", + customerType: "individual", + externalSubjectId: "undrained-bearer-ramp-child", + managerProfileId: manager.id + }); + const headers = { + Authorization: `Bearer ${testUserToken(manager.id, manager.email)}`, + "Content-Length": "1048576", + "Content-Type": "application/json", + "X-Managed-Profile-Id": managedProfile.profileId + }; + + for (const path of ["register", "update", "start"]) { + const response = await postPartialBody(`${baseUrl}/ramp/${path}`, headers); + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ error: { code: "MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL" } }); + const emptyKeyResponse = await postPartialBody(`${baseUrl}/ramp/${path}`, { ...headers, "X-API-Key": "" }); + expect(emptyKeyResponse.status).toBe(403); + expect(emptyKeyResponse.body).toMatchObject({ error: { code: "MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL" } }); + } + }); + it("still allows quote requests to reach normal validation while impersonating", async () => { const headers = await impersonationHeaders(); @@ -84,3 +143,36 @@ describe("ramp routes under impersonation", () => { expect(response.status).toBe(200); }); }); + +function postPartialBody(url: string, headers: Record): Promise<{ body: unknown; status: number }> { + return new Promise((resolve, reject) => { + const requestUrl = new URL(url); + let response = ""; + let settled = false; + const socket = connect(Number(requestUrl.port), requestUrl.hostname, () => { + const requestHeaders = { ...headers, Connection: "close", Host: requestUrl.host }; + socket.write( + `POST ${requestUrl.pathname} HTTP/1.1\r\n${Object.entries(requestHeaders) + .map(([name, value]) => `${name}: ${value}`) + .join("\r\n")}\r\n\r\n{` + ); + }); + socket.setEncoding("utf8"); + socket.setTimeout(4_000, () => { + socket.destroy(); + reject(new Error("Server waited for the incomplete request body")); + }); + socket.on("data", chunk => { + response += chunk; + }); + socket.on("end", () => { + settled = true; + const [head, body = ""] = response.split("\r\n\r\n", 2); + const status = Number(head?.split(" ")[1] ?? 0); + resolve({ body: JSON.parse(body), status }); + }); + socket.on("error", error => { + if (!settled) reject(error); + }); + }); +} diff --git a/apps/api/src/api/routes/v1/ramp.route.ts b/apps/api/src/api/routes/v1/ramp.route.ts index 107e8d691..787cf45e1 100644 --- a/apps/api/src/api/routes/v1/ramp.route.ts +++ b/apps/api/src/api/routes/v1/ramp.route.ts @@ -1,12 +1,33 @@ -import { RequestHandler, Router } from "express"; +import { NextFunction, Request, RequestHandler, Response, Router } from "express"; import * as rampController from "../../controllers/ramp.controller"; import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; import { optionalPartnerOrUserAuth, requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; import { rejectDuringActiveMaintenance } from "../../middlewares/maintenanceGuard"; -import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth"; +import { authorizeManagedProfile, ManagedProfileCapability } from "../../middlewares/managedProfileAuth"; import { getManagedProfileQuoteCorridor, getManagedProfileRampCorridor } from "../../middlewares/managedProfileCorridor"; const router = Router(); +export const managedProfileRampBearerRoutes = Router(); + +function selectedManagedProfileBearerOnly(req: Request, _res: Response, next: NextFunction): void { + if ( + req.get("X-Managed-Profile-Id") !== undefined && + req.get("Authorization")?.startsWith("Bearer ") && + !req.get("X-API-Key") + ) { + next(); + return; + } + next("route"); +} + +managedProfileRampBearerRoutes.post( + ["/register", "/update", "/start"], + selectedManagedProfileBearerOnly, + requirePartnerOrUserAuth(), + rejectImpersonation, + authorizeManagedProfile({ capability: ManagedProfileCapability.Ramp }) +); /** * @api {post} v1/ramp/register Register ramping process @@ -39,7 +60,7 @@ router.post( rejectDuringActiveMaintenance("ramp_register"), requirePartnerOrUserAuth(), rejectImpersonation, - authorizeManagedProfile({ corridor: getManagedProfileQuoteCorridor }), + authorizeManagedProfile({ capability: ManagedProfileCapability.Ramp, corridor: getManagedProfileQuoteCorridor }), rampController.registerRamp as unknown as RequestHandler ); @@ -73,7 +94,7 @@ router.post( rejectDuringActiveMaintenance("ramp_update"), optionalPartnerOrUserAuth(), rejectImpersonation, - authorizeManagedProfile({ corridor: getManagedProfileRampCorridor }), + authorizeManagedProfile({ capability: ManagedProfileCapability.Ramp, corridor: getManagedProfileRampCorridor }), rampController.updateRamp as unknown as RequestHandler ); @@ -106,7 +127,7 @@ router.post( rejectDuringActiveMaintenance("ramp_start"), optionalPartnerOrUserAuth(), rejectImpersonation, - authorizeManagedProfile({ corridor: getManagedProfileRampCorridor }), + authorizeManagedProfile({ capability: ManagedProfileCapability.Ramp, corridor: getManagedProfileRampCorridor }), rampController.startRamp as unknown as RequestHandler ); @@ -134,14 +155,14 @@ router.post( router.get( "/history", requirePartnerOrUserAuth(), - authorizeManagedProfile(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), rampController.getAuthenticatedUserRampHistory as unknown as RequestHandler ); router.get( "/:id", optionalPartnerOrUserAuth(), - authorizeManagedProfile(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), rampController.getRampStatus as unknown as RequestHandler ); @@ -162,7 +183,7 @@ router.get( router.get( "/:id/errors", optionalPartnerOrUserAuth(), - authorizeManagedProfile(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), rampController.getErrorLogs as unknown as RequestHandler ); @@ -183,7 +204,7 @@ router.get( router.get( "/history/:walletAddress", requirePartnerOrUserAuth(), - authorizeManagedProfile(), + authorizeManagedProfile({ capability: ManagedProfileCapability.Read }), rampController.getRampHistory as unknown as RequestHandler ); diff --git a/apps/api/src/api/routes/v1/recipients.route.ts b/apps/api/src/api/routes/v1/recipients.route.ts index e9fbf8d17..dfb26d12c 100644 --- a/apps/api/src/api/routes/v1/recipients.route.ts +++ b/apps/api/src/api/routes/v1/recipients.route.ts @@ -1,4 +1,4 @@ -import { Request, Response, Router } from "express"; +import { NextFunction, Request, Response, Router } from "express"; import { acceptInvite, archiveInvitation, @@ -11,20 +11,36 @@ import { updateRecipient, validateCreateInvite } from "../../controllers/recipients.controller"; -import { authorizeManagedProfile, rejectManagedProfileSelection } from "../../middlewares/managedProfileAuth"; +import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; +import { + authorizeManagedProfile, + ManagedProfileCapability, + rejectDirectManagedCredential, + rejectManagedProfileSelection +} from "../../middlewares/managedProfileAuth"; import { requireAuth } from "../../middlewares/supabaseAuth"; const router: Router = Router({ mergeParams: true }); +// Secret access is delegated only: do not expand the existing self/direct-child API surface. +function requireRecipientSenderAuth(req: Request, res: Response, next: NextFunction): void { + if (req.get("X-Managed-Profile-Id") === undefined) { + requireAuth(req, res, next); + return; + } + void requirePartnerOrUserAuth()(req, res, next); +} + /** * POST /v1/recipients/invite * Create a recipient invite for the authenticated sender; returns the raw link token once. */ router.post( "/invite", - requireAuth, + requireRecipientSenderAuth, + rejectDirectManagedCredential, validateCreateInvite, - authorizeManagedProfile({ corridor: req => req.body.country.toUpperCase() }), + authorizeManagedProfile({ capability: ManagedProfileCapability.Manage, corridor: req => req.body.country.toUpperCase() }), createInvite as unknown as (req: Request, res: Response) => void ); @@ -57,8 +73,9 @@ router.post( */ router.get( "/", - requireAuth, - authorizeManagedProfile({ enforceCustomerTypePolicy: true }), + requireRecipientSenderAuth, + rejectDirectManagedCredential, + authorizeManagedProfile({ capability: ManagedProfileCapability.Read, enforceCustomerTypePolicy: true }), listRecipients as unknown as (req: Request, res: Response) => void ); @@ -69,8 +86,9 @@ router.get( */ router.patch( "/invitations/:id", - requireAuth, - authorizeManagedProfile({ corridor: resolveInvitationAuthorizationTarget }), + requireRecipientSenderAuth, + rejectDirectManagedCredential, + authorizeManagedProfile({ capability: ManagedProfileCapability.Manage, corridor: resolveInvitationAuthorizationTarget }), archiveInvitation as unknown as (req: Request<{ id: string }>, res: Response) => void ); @@ -80,8 +98,9 @@ router.patch( */ router.patch( "/:id", - requireAuth, - authorizeManagedProfile({ corridor: resolveRecipientAuthorizationTarget }), + requireRecipientSenderAuth, + rejectDirectManagedCredential, + authorizeManagedProfile({ capability: ManagedProfileCapability.Manage, corridor: resolveRecipientAuthorizationTarget }), updateRecipient as unknown as (req: Request<{ id: string }>, res: Response) => void ); @@ -91,8 +110,9 @@ router.patch( */ router.get( "/:id/eligibility", - requireAuth, - authorizeManagedProfile({ corridor: resolveRecipientAuthorizationTarget }), + requireRecipientSenderAuth, + rejectDirectManagedCredential, + authorizeManagedProfile({ capability: ManagedProfileCapability.Read, corridor: resolveRecipientAuthorizationTarget }), getRecipientEligibility as unknown as (req: Request<{ id: string }>, res: Response) => void ); diff --git a/apps/api/src/api/services/apiCredential.service.test.ts b/apps/api/src/api/services/apiCredential.service.test.ts index 1283e4d3d..fb9f27311 100644 --- a/apps/api/src/api/services/apiCredential.service.test.ts +++ b/apps/api/src/api/services/apiCredential.service.test.ts @@ -2,20 +2,26 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; import { Op } from "sequelize"; import sequelize from "../../config/database"; import ApiCredential from "../../models/apiCredential.model"; +import CustomerEntity from "../../models/customerEntity.model"; import ManagedProfile from "../../models/managedProfile.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; +import ManagedProfileMembership from "../../models/managedProfileMembership.model"; import User from "../../models/user.model"; import { digestApiKey, generateApiKey, getSecretKeyLookupPrefix } from "../middlewares/apiKeyFormat"; import { createCredential, + createManagedProfileCredential, assertApiCredentialSchemaReady, MAX_ACTIVE_CREDENTIALS_PER_PROFILE, revokeCredential, + revokeManagedProfileCredential, validatePublicKey, validateSecretKey } from "./apiCredential.service"; const originals = { + entityFindAll: CustomerEntity.findAll, + membershipFindOne: ManagedProfileMembership.findOne, count: ApiCredential.count, managerFindByPk: ManagedProfileManager.findByPk, managedProfileFindOne: ManagedProfile.findOne, @@ -29,6 +35,8 @@ const originals = { }; afterEach(() => { + CustomerEntity.findAll = originals.entityFindAll; + ManagedProfileMembership.findOne = originals.membershipFindOne; ApiCredential.count = originals.count; ApiCredential.create = originals.create; ApiCredential.findAll = originals.findAll; @@ -42,6 +50,80 @@ afterEach(() => { }); describe("api credential service", () => { + function managedAuthority() { + const lockOrder: string[] = []; + const transaction = { LOCK: { UPDATE: "UPDATE" } }; + const membership = { role: "manager" }; + sequelize.transaction = mock(async callback => callback(transaction as never)) as never; + ManagedProfile.findOne = mock(async (options: { lock?: unknown }) => { + if (options.lock) lockOrder.push("relationship"); + return { managerProfileId: "owner", profileId: "child", status: "active" }; + }) as never; + ManagedProfileManager.findByPk = mock(async () => { + lockOrder.push("owner-config"); + return { isActive: true }; + }) as never; + User.findByPk = mock(async (id: string, options: { lock?: unknown }) => { + if (options.lock) lockOrder.push(`profile:${id}`); + return { activeCustomerEntityId: "entity", kind: id === "child" ? "managed" : "authenticated" }; + }) as never; + CustomerEntity.findAll = mock(async () => { + lockOrder.push("entity"); + return [{ id: "entity", status: "active" }]; + }) as never; + ManagedProfileMembership.findOne = mock(async () => { + lockOrder.push("membership"); + return membership; + }) as never; + ApiCredential.count = mock(async () => 0) as never; + ApiCredential.create = mock(async values => { + lockOrder.push("write"); + return { ...values, id: "credential" }; + }) as never; + ApiCredential.update = mock(async () => { + lockOrder.push("write"); + return [1]; + }) as never; + return { lockOrder, membership, transaction }; + } + + it.each(["create", "revoke"] as const)("holds common owner/child/membership locks through credential %s", async operation => { + const { lockOrder, transaction } = managedAuthority(); + if (operation === "create") { + await createManagedProfileCredential({ actorProfileId: "member", environment: "test", profileId: "child" }); + } else { + await revokeManagedProfileCredential("member", "child", "credential"); + } + expect(lockOrder).toEqual(["owner-config", "profile:owner", "profile:child", "relationship", "entity", "membership", "write"]); + const lockOptions = { lock: "UPDATE", transaction }; + expect(ManagedProfileManager.findByPk).toHaveBeenCalledWith("owner", lockOptions); + expect(User.findByPk).toHaveBeenCalledWith("owner", lockOptions); + expect(User.findByPk).toHaveBeenCalledWith("child", lockOptions); + expect(CustomerEntity.findAll).toHaveBeenCalledWith({ ...lockOptions, where: { profileId: "child" } }); + expect(ManagedProfileMembership.findOne).toHaveBeenCalledWith({ + ...lockOptions, where: { managedProfileId: "child", memberProfileId: "member", revokedAt: null } + }); + if (operation === "create") { + expect(ApiCredential.count).toHaveBeenCalledWith(expect.objectContaining({ transaction })); + expect(ApiCredential.create).toHaveBeenCalledWith(expect.objectContaining({ profileId: "child", partnerId: null }), { transaction }); + } else { + expect(ApiCredential.update).toHaveBeenCalledWith({ revokedAt: expect.any(Date) }, { + transaction, where: { id: "credential", profileId: "child", revokedAt: null } + }); + } + }); + + it.each(["read_only", "unknown"])("denies live %s membership for both credential mutations", async role => { + const { membership } = managedAuthority(); + membership.role = role; + await expect(createManagedProfileCredential({ actorProfileId: "member", environment: "test", profileId: "child" })) + .rejects.toMatchObject({ code: "CREDENTIAL_ACCESS_DENIED" }); + await expect(revokeManagedProfileCredential("member", "child", "credential")) + .rejects.toMatchObject({ code: "CREDENTIAL_ACCESS_DENIED" }); + expect(ApiCredential.create).not.toHaveBeenCalled(); + expect(ApiCredential.update).not.toHaveBeenCalled(); + }); + it("locks the profile and excludes expired credentials from the cap query", async () => { const transaction = { LOCK: { UPDATE: "UPDATE" } }; let countWhere: Record = {}; diff --git a/apps/api/src/api/services/apiCredential.service.ts b/apps/api/src/api/services/apiCredential.service.ts index 1fb87470b..dd5f54df6 100644 --- a/apps/api/src/api/services/apiCredential.service.ts +++ b/apps/api/src/api/services/apiCredential.service.ts @@ -4,8 +4,10 @@ import { Op, QueryTypes, Transaction } from "sequelize"; import sequelize from "../../config/database"; import logger from "../../config/logger"; import ApiCredential, { ApiCredentialEnvironment } from "../../models/apiCredential.model"; +import CustomerEntity from "../../models/customerEntity.model"; import ManagedProfile from "../../models/managedProfile.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; +import ManagedProfileMembership from "../../models/managedProfileMembership.model"; import User from "../../models/user.model"; import { digestApiKey, generateApiKey, getSecretKeyLookupPrefix } from "../middlewares/apiKeyFormat"; @@ -141,9 +143,9 @@ export async function createCredential(input: { } export async function createManagedProfileCredential(input: { + actorProfileId: string; environment: ApiCredentialEnvironment; expiresAt?: unknown; - managerProfileId: string; name?: unknown; profileId: string; }): Promise { @@ -151,31 +153,52 @@ export async function createManagedProfileCredential(input: { const publicKey = generateApiKey("public", input.environment); const secretKey = generateApiKey("secret", input.environment); const credential = await sequelize.transaction(async transaction => { - const profile = await User.findByPk(input.profileId, { - attributes: ["id", "kind"], - lock: transaction.LOCK.UPDATE, - transaction - }); - if (profile?.kind !== "managed") { - throw new ApiCredentialServiceError("CREDENTIAL_NOT_FOUND", "Managed profile was not found"); - } - const relationship = await ManagedProfile.findOne({ - lock: transaction.LOCK.UPDATE, - transaction, - where: { managerProfileId: input.managerProfileId, profileId: input.profileId, status: "active" } - }); - if (!relationship) { - throw new ApiCredentialServiceError("CREDENTIAL_NOT_FOUND", "Managed profile was not found"); - } - const manager = await ManagedProfileManager.findByPk(input.managerProfileId, { transaction }); - if (!manager?.isActive) { - throw new ApiCredentialServiceError("CREDENTIAL_ACCESS_DENIED", "Managed-profile manager is not active"); - } + await lockManagedCredentialAuthority(input.actorProfileId, input.profileId, transaction); return insertCredential({ ...input, partnerId: null }, validated, publicKey, secretKey, transaction); }); return { ...toDto(credential), secretKey }; } +async function lockManagedCredentialAuthority( + actorProfileId: string, + profileId: string, + transaction: Transaction +): Promise { + const locator = await ManagedProfile.findOne({ transaction, where: { profileId } }); + if (!locator) throw new ApiCredentialServiceError("CREDENTIAL_NOT_FOUND", "Managed profile was not found"); + + // Serialize with membership changes and deletion: owner first, child aggregate, then membership. + const options = { lock: transaction.LOCK.UPDATE, transaction }; + const manager = await ManagedProfileManager.findByPk(locator.managerProfileId, options); + const owner = await User.findByPk(locator.managerProfileId, options); + if (!manager?.isActive || owner?.kind !== "authenticated") { + throw new ApiCredentialServiceError("CREDENTIAL_ACCESS_DENIED", "Managed-profile manager is not active"); + } + const profile = await User.findByPk(profileId, options); + const relationship = await ManagedProfile.findOne({ + ...options, + where: { managerProfileId: locator.managerProfileId, profileId, status: "active" } + }); + if (profile?.kind !== "managed" || !relationship) { + throw new ApiCredentialServiceError("CREDENTIAL_NOT_FOUND", "Managed profile was not found"); + } + const entities = await CustomerEntity.findAll({ ...options, where: { profileId } }); + const actor = await User.findByPk(actorProfileId, { transaction }); + const membership = await ManagedProfileMembership.findOne({ + ...options, + where: { managedProfileId: profileId, memberProfileId: actorProfileId, revokedAt: null } + }); + if ( + entities.length !== 1 || + entities[0].id !== profile.activeCustomerEntityId || + entities[0].status !== "active" || + actor?.kind !== "authenticated" || + membership?.role !== "manager" + ) { + throw new ApiCredentialServiceError("CREDENTIAL_ACCESS_DENIED", "An active manager membership is required"); + } +} + async function insertCredential( input: { environment: ApiCredentialEnvironment; partnerId?: string | null; profileId: string }, validated: { expiresAt: Date; name: string }, @@ -228,20 +251,21 @@ export async function listManagedProfileCredentials(managerProfileId: string, pr } export async function revokeManagedProfileCredential( - managerProfileId: string, + actorProfileId: string, profileId: string, credentialId: string ): Promise { - await requireManagedCredentialSubject(managerProfileId, profileId); - // Revocation stays idempotent, but only the first one writes: a repeat must not rewrite when - // the credential actually stopped being valid. - const [updated] = await ApiCredential.update( - { revokedAt: new Date() }, - { where: { id: credentialId, profileId, revokedAt: null } } - ); - if (updated === 0 && (await ApiCredential.count({ where: { id: credentialId, profileId } })) === 0) { - throw new ApiCredentialServiceError("CREDENTIAL_NOT_FOUND", "API credential not found"); - } + await sequelize.transaction(async transaction => { + await lockManagedCredentialAuthority(actorProfileId, profileId, transaction); + // Replays preserve the original revocation time, but still require live authority. + const [updated] = await ApiCredential.update( + { revokedAt: new Date() }, + { transaction, where: { id: credentialId, profileId, revokedAt: null } } + ); + if (updated === 0 && (await ApiCredential.count({ transaction, where: { id: credentialId, profileId } })) === 0) { + throw new ApiCredentialServiceError("CREDENTIAL_NOT_FOUND", "API credential not found"); + } + }); } export async function listCredentials(filter: { partnerId?: string | null; profileId: string }): Promise { diff --git a/apps/api/src/api/services/avenia/avenia-kyc-import.service.test.ts b/apps/api/src/api/services/avenia/avenia-kyc-import.service.test.ts index 82ff26f92..a639457cc 100644 --- a/apps/api/src/api/services/avenia/avenia-kyc-import.service.test.ts +++ b/apps/api/src/api/services/avenia/avenia-kyc-import.service.test.ts @@ -6,6 +6,7 @@ import CustomerEntity from "../../../models/customerEntity.model"; import KycCase from "../../../models/kycCase.model"; import ManagedProfile from "../../../models/managedProfile.model"; import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import ManagedProfileMembership from "../../../models/managedProfileMembership.model"; import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; import User from "../../../models/user.model"; import { @@ -24,6 +25,7 @@ const originals = { entityFindOne: CustomerEntity.findOne, getInstance: BrlaApiService.getInstance, managerFindByPk: ManagedProfileManager.findByPk, + membershipFindByPk: ManagedProfileMembership.findByPk, relationshipFindByPk: ManagedProfile.findByPk, transaction: sequelize.transaction, userFindByPk: User.findByPk @@ -35,6 +37,9 @@ interface HarnessOptions { baseline?: Array<{ createdAt: string; id: string; levelName: string }>; boundAttemptIds?: string[]; importToken?: () => Promise<{ id: string; message: string }>; + managed?: boolean; + membershipRevoked?: boolean; + membershipRole?: "manager" | "read_only"; verificationMethod?: KycCase["verificationMethod"]; } @@ -79,33 +84,48 @@ function harness(options: HarnessOptions = {}) { BrlaApiService.getInstance = mock( () => ({ getKycAttempts, getUploadedDocuments, importKycToken: providerImport }) as unknown as BrlaApiService ); - User.findByPk = mock(async () => ({ activeCustomerEntityId: "entity-1", kind: "authenticated" })) as never; + User.findByPk = mock(async (_id: string, query?: { lock?: unknown }) => { + if (query?.lock) lockOrder.push("subject"); + return { activeCustomerEntityId: "entity-1", kind: options.managed ? "managed" : "authenticated" }; + }) as never; CustomerEntity.findOne = mock(async () => ({ id: "entity-1", profileId: "subject-1", status: "active", type: "individual" })) as never; - CustomerEntity.findByPk = mock(async () => ({ id: "entity-1", profileId: "subject-1", status: "active", type: "individual" })) as never; + CustomerEntity.findByPk = mock(async () => { + lockOrder.push("entity"); + return { id: "entity-1", profileId: "subject-1", status: "active", type: "individual" }; + }) as never; ProviderCustomer.findAll = mock(async () => [providerCustomer]) as never; ProviderCustomer.findByPk = mock(async (_id: string, query?: { lock?: unknown }) => { if (query?.lock) lockOrder.push("customer"); return providerCustomer; }) as never; - KycCase.findAll = mock(async (query?: { attributes?: string[] }) => - query?.attributes ? (options.boundAttemptIds ?? []).map(providerCaseId => ({ providerCaseId })) : [kycCase] - ) as never; + KycCase.findAll = mock(async (query?: { attributes?: string[]; lock?: unknown }) => { + if (query?.lock) lockOrder.push("case"); + return query?.attributes ? (options.boundAttemptIds ?? []).map(providerCaseId => ({ providerCaseId })) : [kycCase]; + }) as never; KycCase.findByPk = mock(async (_id: string, query?: { lock?: unknown }) => { if (query?.lock) lockOrder.push("case"); return kycCase; }) as never; - ManagedProfileManager.findByPk = mock(async () => ({ - allowedCorridors: ["BR"], - allowedCustomerTypes: null, - isActive: true - })) as never; - ManagedProfile.findByPk = mock(async () => ({ - managerProfileId: "manager-1", - profileId: "subject-1", - status: "active" - })) as never; + ManagedProfileManager.findByPk = mock(async () => { + lockOrder.push("owner"); + return { allowedCorridors: ["BR"], allowedCustomerTypes: null, isActive: true }; + }) as never; + ManagedProfile.findByPk = mock(async () => { + lockOrder.push("relationship"); + return { managerProfileId: "manager-1", profileId: "subject-1", status: "active" }; + }) as never; + const membership = { + managedProfileId: "subject-1", + memberProfileId: "member-1", + revokedAt: options.membershipRevoked ? new Date() : null, + role: options.membershipRole ?? "manager" + }; + ManagedProfileMembership.findByPk = mock(async () => { + lockOrder.push("membership"); + return membership; + }) as never; sequelize.transaction = mock(async callback => callback({ LOCK: { UPDATE: "UPDATE" } } as never)) as never; - return { getKycAttempts, getUploadedDocuments, kycCase, lockOrder, providerCustomer, providerImport }; + return { getKycAttempts, getUploadedDocuments, kycCase, lockOrder, membership, providerCustomer, providerImport }; } const request = { @@ -124,12 +144,75 @@ afterEach(() => { CustomerEntity.findOne = originals.entityFindOne; BrlaApiService.getInstance = originals.getInstance; ManagedProfileManager.findByPk = originals.managerFindByPk; + ManagedProfileMembership.findByPk = originals.membershipFindByPk; ManagedProfile.findByPk = originals.relationshipFindByPk; sequelize.transaction = originals.transaction; User.findByPk = originals.userFindByPk; }); describe("importBrKycToken", () => { + const managedRequest = { + ...request, + actorProfileId: "member-1", + controllingManagerProfileId: "manager-1", + expectedCustomerEntityId: "entity-1", + managedProfileId: "relationship-1", + membershipId: "membership-1" + }; + + it("allows an active manager membership under the immutable owner policy", async () => { + const state = harness({ managed: true }); + + await expect(importBrKycToken(managedRequest)).resolves.toEqual({ attemptId: "attempt-1", status: "pending" }); + expect(ManagedProfileManager.findByPk).toHaveBeenCalledWith("manager-1", expect.objectContaining({ lock: "UPDATE" })); + expect(ManagedProfileMembership.findByPk).toHaveBeenCalledWith( + "membership-1", + expect.objectContaining({ lock: "UPDATE" }) + ); + expect(state.providerImport).toHaveBeenCalledTimes(1); + expect(state.lockOrder).toEqual([ + "owner", "subject", "relationship", "entity", "membership", "case", + "owner", "subject", "relationship", "entity", "membership", "customer", "case", + "customer", "case" + ]); + }); + + it.each([ + { managedProfileId: "another-child" }, + { memberProfileId: "another-member" }, + { role: "read_only" }, + { revokedAt: new Date() } + ])("rechecks exact live membership after preparation: %j", async invalidMembership => { + const state = harness({ managed: true }); + state.getKycAttempts.mockImplementation(async () => { + Object.assign(state.membership, invalidMembership); + return { attempts: [] }; + }); + await expect(importBrKycToken(managedRequest)).rejects.toMatchObject({ status: 403 }); + expect(state.providerImport).not.toHaveBeenCalled(); + expect(state.kycCase.verificationSubmission).toMatchObject({ status: "failed", errorClassification: "authorization_revoked" }); + expect(state.lockOrder.slice(-7)).toEqual(["owner", "subject", "relationship", "entity", "membership", "customer", "case"]); + }); + + it("denies a revoked manager membership before importing with the provider", async () => { + const state = harness({ managed: true, membershipRevoked: true }); + + await expect(importBrKycToken(managedRequest)).rejects.toMatchObject({ status: 403 }); + expect(state.providerImport).not.toHaveBeenCalled(); + }); + + it.each(["submitted", "confirmed"] as const)("does not overwrite a concurrently %s claim after membership revocation", async status => { + const state = harness({ managed: true }); + state.getKycAttempts.mockImplementation(async () => { + state.membership.revokedAt = new Date(); + state.kycCase.verificationSubmission!.status = status; + return { attempts: [] }; + }); + await expect(importBrKycToken(managedRequest)).rejects.toMatchObject({ status: 403 }); + expect(state.providerImport).not.toHaveBeenCalled(); + expect(state.kycCase.verificationSubmission!.status).toBe(status); + }); + it("stores only fingerprints, binds the exact attempt, and replays only the same confirmed key", async () => { const state = harness(); expect(await importBrKycToken(request)).toEqual({ attemptId: "attempt-1", status: "pending" }); @@ -154,7 +237,7 @@ describe("importBrKycToken", () => { } }); expect(JSON.stringify(state.kycCase.verificationSubmission)).not.toContain(request.importToken); - expect(state.lockOrder.slice(-2)).toEqual(["customer", "case"]); + expect(state.lockOrder).toEqual(["case", "customer", "case", "customer", "case", "case"]); await expect(importBrKycToken({ ...request, idempotencyKey: "another-key" })).rejects.toMatchObject({ status: 409 }); await expect(importBrKycToken({ ...request, importToken: "changed-token" })).rejects.toMatchObject({ status: 409 }); }); diff --git a/apps/api/src/api/services/avenia/avenia-kyc-import.service.ts b/apps/api/src/api/services/avenia/avenia-kyc-import.service.ts index caa915922..26f04c17c 100644 --- a/apps/api/src/api/services/avenia/avenia-kyc-import.service.ts +++ b/apps/api/src/api/services/avenia/avenia-kyc-import.service.ts @@ -7,6 +7,7 @@ import CustomerEntity from "../../../models/customerEntity.model"; import KycCase, { type IndividualKycSubmission } from "../../../models/kycCase.model"; import ManagedProfile from "../../../models/managedProfile.model"; import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import ManagedProfileMembership from "../../../models/managedProfileMembership.model"; import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; import User from "../../../models/user.model"; import { APIError } from "../../errors/api-error"; @@ -23,11 +24,13 @@ export interface ResolvedAveniaIndividualKycCase { export interface ImportAveniaKycTokenArgs { actorProfileId: string; + controllingManagerProfileId?: string; subjectProfileId: string; expectedCustomerEntityId?: string; idempotencyKey: string; importToken: string; managedProfileId?: string; + membershipId?: string; } export interface ImportedAveniaKycToken { @@ -52,24 +55,38 @@ function managedAccessDenied(): APIError { } async function assertCurrentImportAuthorization(args: ImportAveniaKycTokenArgs, transaction: Transaction): Promise { - if (!args.managedProfileId && !args.expectedCustomerEntityId) { + if (!args.controllingManagerProfileId && !args.managedProfileId && !args.expectedCustomerEntityId && !args.membershipId) { if (args.actorProfileId !== args.subjectProfileId) throw managedAccessDenied(); return; } - if (!args.managedProfileId || !args.expectedCustomerEntityId) throw managedAccessDenied(); + if (!args.controllingManagerProfileId || !args.managedProfileId || !args.expectedCustomerEntityId || !args.membershipId) { + throw managedAccessDenied(); + } - const manager = await ManagedProfileManager.findByPk(args.actorProfileId, { lock: transaction.LOCK.UPDATE, transaction }); - const relationship = await ManagedProfile.findByPk(args.managedProfileId, { lock: transaction.LOCK.UPDATE, transaction }); + const manager = await ManagedProfileManager.findByPk(args.controllingManagerProfileId, { + lock: transaction.LOCK.UPDATE, + transaction + }); const subject = await User.findByPk(args.subjectProfileId, { lock: transaction.LOCK.UPDATE, transaction }); + const relationship = await ManagedProfile.findByPk(args.managedProfileId, { lock: transaction.LOCK.UPDATE, transaction }); const entity = await CustomerEntity.findByPk(args.expectedCustomerEntityId, { lock: transaction.LOCK.UPDATE, transaction }); + const membership = await ManagedProfileMembership.findByPk(args.membershipId, { + lock: transaction.LOCK.UPDATE, + transaction + }); if ( !manager?.isActive || !manager.allowedCorridors.includes("BR") || (manager.allowedCustomerTypes !== null && !manager.allowedCustomerTypes.includes("individual")) || !relationship || - relationship.managerProfileId !== args.actorProfileId || + relationship.managerProfileId !== args.controllingManagerProfileId || relationship.profileId !== args.subjectProfileId || relationship.status !== "active" || + !membership || + membership.managedProfileId !== args.subjectProfileId || + membership.memberProfileId !== args.actorProfileId || + membership.role !== "manager" || + membership.revokedAt !== null || subject?.kind !== "managed" || subject.activeCustomerEntityId !== args.expectedCustomerEntityId || !entity || @@ -215,13 +232,13 @@ async function prepareImportClaim( tokenFingerprint: string ): Promise { return sequelize.transaction(async transaction => { + await assertCurrentImportAuthorization(args, transaction); const { kycCase, providerCustomer } = await resolveEligibleCase( args.subjectProfileId, args.expectedCustomerEntityId, transaction, true ); - await assertCurrentImportAuthorization(args, transaction); const existing = kycCase.verificationSubmission; if (existing?.idempotencyKeyHash === idempotencyKeyHash) { if (existing.tokenFingerprint !== tokenFingerprint) throw conflict("The idempotency key was used with a different token"); @@ -286,17 +303,21 @@ async function submitPreparedClaim( attemptBaselineIds: string[] ): Promise { const result = await sequelize.transaction(async transaction => { + let authorizationError: APIError | undefined; + try { + await assertCurrentImportAuthorization(args, transaction); + } catch (error) { + if (!(error instanceof APIError) || error.status !== httpStatus.FORBIDDEN) throw error; + authorizationError = error; + } const providerCustomer = await ProviderCustomer.findByPk(claim.providerCustomer.id, { lock: transaction.LOCK.UPDATE, transaction }); const kycCase = await KycCase.findByPk(claim.kycCaseId, { lock: transaction.LOCK.UPDATE, transaction }); - try { - await assertCurrentImportAuthorization(args, transaction); - } catch (error) { - if (!(error instanceof APIError) || error.status !== httpStatus.FORBIDDEN) throw error; + if (authorizationError) { if ( - kycCase?.verificationSubmission && + kycCase?.verificationSubmission?.status === "prepared" && sameTokenClaim(kycCase.verificationSubmission, args, idempotencyKeyHash, tokenFingerprint) ) { await kycCase.update( @@ -310,7 +331,7 @@ async function submitPreparedClaim( { transaction } ); } - return error; + return authorizationError; } const submission = kycCase?.verificationSubmission; if ( diff --git a/apps/api/src/api/services/avenia/avenia-standard-kyc.service.test.ts b/apps/api/src/api/services/avenia/avenia-standard-kyc.service.test.ts index 99e3fee36..b68acb325 100644 --- a/apps/api/src/api/services/avenia/avenia-standard-kyc.service.test.ts +++ b/apps/api/src/api/services/avenia/avenia-standard-kyc.service.test.ts @@ -13,6 +13,7 @@ import CustomerEntity from "../../../models/customerEntity.model"; import KycCase, { type IndividualKycSubmission } from "../../../models/kycCase.model"; import ManagedProfile from "../../../models/managedProfile.model"; import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import ManagedProfileMembership from "../../../models/managedProfileMembership.model"; import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; import User from "../../../models/user.model"; import { submitStandardAveniaKyc } from "./avenia-standard-kyc.service"; @@ -24,6 +25,7 @@ const originals = { entityFindByPk: CustomerEntity.findByPk, getInstance: BrlaApiService.getInstance, managerFindByPk: ManagedProfileManager.findByPk, + membershipFindByPk: ManagedProfileMembership.findByPk, relationshipFindByPk: ManagedProfile.findByPk, transaction: sequelize.transaction, userFindByPk: User.findByPk @@ -72,6 +74,8 @@ interface HarnessOptions { attempts?: KycAttempt[]; boundAttemptIds?: string[]; managerActive?: boolean; + membershipRevoked?: boolean; + membershipRole?: "manager" | "read_only"; providerAttemptId?: string; submittedAt?: Date | null; submission?: IndividualKycSubmission; @@ -117,14 +121,32 @@ function harness(options: HarnessOptions = {}) { if (query?.lock) lockOrder.push("customer"); return providerCustomer; }) as never; - ManagedProfileManager.findByPk = mock(async () => ({ - allowedCorridors: ["BR"], - allowedCustomerTypes: null, - isActive: options.managerActive ?? true - })) as never; - ManagedProfile.findByPk = mock(async () => ({ managerProfileId: "manager-1", profileId: "subject-1", status: "active" })) as never; - User.findByPk = mock(async () => ({ activeCustomerEntityId: "entity-1", kind: "managed" })) as never; - CustomerEntity.findByPk = mock(async () => ({ profileId: "subject-1", status: "active", type: "individual" })) as never; + ManagedProfileManager.findByPk = mock(async () => { + lockOrder.push("owner"); + return { allowedCorridors: ["BR"], allowedCustomerTypes: null, isActive: options.managerActive ?? true }; + }) as never; + ManagedProfile.findByPk = mock(async () => { + lockOrder.push("relationship"); + return { managerProfileId: "manager-1", profileId: "subject-1", status: "active" }; + }) as never; + const membership = { + managedProfileId: "subject-1", + memberProfileId: "member-1", + revokedAt: options.membershipRevoked ? new Date() : null, + role: options.membershipRole ?? "manager" + }; + ManagedProfileMembership.findByPk = mock(async () => { + lockOrder.push("membership"); + return membership; + }) as never; + User.findByPk = mock(async () => { + lockOrder.push("subject"); + return { activeCustomerEntityId: "entity-1", kind: "managed" }; + }) as never; + CustomerEntity.findByPk = mock(async () => { + lockOrder.push("entity"); + return { profileId: "subject-1", status: "active", type: "individual" }; + }) as never; sequelize.transaction = mock(async callback => callback({ LOCK: { UPDATE: "UPDATE" } } as never)) as never; const submitKycLevel1 = mock(async () => { if (options.submitError) throw new Error("response lost"); @@ -162,7 +184,7 @@ function harness(options: HarnessOptions = {}) { BrlaApiService.getInstance = mock( () => ({ getKycAttempts, getUploadedDocuments, getVerificationAttemptStatus, submitKycLevel1 }) as unknown as BrlaApiService ); - return { getKycAttempts, getVerificationAttemptStatus, kycCase, lockOrder, providerCustomer, submitKycLevel1 }; + return { getKycAttempts, getVerificationAttemptStatus, kycCase, lockOrder, membership, providerCustomer, submitKycLevel1 }; } const request = { actorProfileId: "subject-1", payload, subjectProfileId: "subject-1" }; @@ -174,6 +196,7 @@ afterEach(() => { CustomerEntity.findByPk = originals.entityFindByPk; BrlaApiService.getInstance = originals.getInstance; ManagedProfileManager.findByPk = originals.managerFindByPk; + ManagedProfileMembership.findByPk = originals.membershipFindByPk; ManagedProfile.findByPk = originals.relationshipFindByPk; sequelize.transaction = originals.transaction; User.findByPk = originals.userFindByPk; @@ -206,23 +229,95 @@ describe("submitStandardAveniaKyc", () => { timeout.mockRestore(); }); + it("allows an active manager member while rechecking the immutable owner policy and exact membership", async () => { + const state = harness(); + const timeout = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void) => { + callback(); + return 0; + }) as typeof setTimeout); + + await expect( + submitStandardAveniaKyc({ + ...request, + actorProfileId: "member-1", + controllingManagerProfileId: "manager-1", + expectedCustomerEntityId: "entity-1", + managedProfileId: "relationship-1", + membershipId: "membership-1", + providerCustomer: state.providerCustomer + }) + ).resolves.toEqual({ id: "attempt-1" }); + expect(ManagedProfileManager.findByPk).toHaveBeenCalledWith("manager-1", expect.objectContaining({ lock: "UPDATE" })); + expect(ManagedProfileMembership.findByPk).toHaveBeenCalledWith( + "membership-1", + expect.objectContaining({ lock: "UPDATE" }) + ); + expect(state.lockOrder.slice(0, 21)).toEqual(Array(3).fill([ + "owner", "subject", "relationship", "entity", "membership", "customer", "case" + ]).flat()); + timeout.mockRestore(); + }); + + it.each([ + { managedProfileId: "another-child" }, + { memberProfileId: "another-member" }, + { role: "read_only" }, + { revokedAt: new Date() } + ])("rechecks exact live membership after preparation: %j", async invalidMembership => { + const state = harness(); + spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void) => { + callback(); + return 0; + }) as typeof setTimeout); + state.getKycAttempts.mockImplementation(async () => { + Object.assign(state.membership, invalidMembership); + return { attempts: [] }; + }); + await expect(submitStandardAveniaKyc({ + ...request, actorProfileId: "member-1", controllingManagerProfileId: "manager-1", + expectedCustomerEntityId: "entity-1", managedProfileId: "relationship-1", membershipId: "membership-1", + providerCustomer: state.providerCustomer + })).rejects.toMatchObject({ status: 403 }); + expect(state.submitKycLevel1).not.toHaveBeenCalled(); + expect(state.kycCase.verificationSubmission).toMatchObject({ status: "failed", errorClassification: "authorization_revoked" }); + expect(state.lockOrder.slice(-7)).toEqual(["owner", "subject", "relationship", "entity", "membership", "customer", "case"]); + }); + + it("denies a read-only member before the provider submission", async () => { + const state = harness({ membershipRole: "read_only" }); + + await expect( + submitStandardAveniaKyc({ + ...request, + actorProfileId: "member-1", + controllingManagerProfileId: "manager-1", + expectedCustomerEntityId: "entity-1", + managedProfileId: "relationship-1", + membershipId: "membership-1", + providerCustomer: state.providerCustomer + }) + ).rejects.toMatchObject({ status: 403 }); + expect(state.submitKycLevel1).not.toHaveBeenCalled(); + }); + it("leaves a nullable method unclaimed when managed authorization was revoked", async () => { const state = harness({ managerActive: false, verificationMethod: null }); await expect( submitStandardAveniaKyc({ ...request, - actorProfileId: "manager-1", + actorProfileId: "member-1", controllingManagerProfileId: "manager-1", expectedCustomerEntityId: "entity-1", managedProfileId: "relationship-1", + membershipId: "membership-1", providerCustomer: state.providerCustomer }) ).rejects.toMatchObject({ status: 403 }); expect(state.kycCase.verificationMethod).toBeNull(); expect(state.getKycAttempts).not.toHaveBeenCalled(); expect(state.submitKycLevel1).not.toHaveBeenCalled(); - expect(state.lockOrder.slice(0, 2)).toEqual(["customer", "case"]); + expect(state.lockOrder).toEqual(["owner", "subject", "relationship", "entity", "membership"]); }); it("commits baseline and send time before POST, then stores the exact attempt without payload data", async () => { diff --git a/apps/api/src/api/services/avenia/avenia-standard-kyc.service.ts b/apps/api/src/api/services/avenia/avenia-standard-kyc.service.ts index 504f61240..a8b39e6f9 100644 --- a/apps/api/src/api/services/avenia/avenia-standard-kyc.service.ts +++ b/apps/api/src/api/services/avenia/avenia-standard-kyc.service.ts @@ -14,6 +14,7 @@ import CustomerEntity from "../../../models/customerEntity.model"; import KycCase, { type IndividualKycSubmission } from "../../../models/kycCase.model"; import ManagedProfile from "../../../models/managedProfile.model"; import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import ManagedProfileMembership from "../../../models/managedProfileMembership.model"; import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; import User from "../../../models/user.model"; import { APIError } from "../../errors/api-error"; @@ -27,6 +28,7 @@ export interface SubmitStandardAveniaKycArgs { controllingManagerProfileId?: string; expectedCustomerEntityId?: string; managedProfileId?: string; + membershipId?: string; payload: KycLevel1Payload; providerCustomer: ProviderCustomer; subjectProfileId: string; @@ -45,32 +47,32 @@ function managedAccessDenied(): APIError { } function assertAuthorizationShape(args: SubmitStandardAveniaKycArgs): void { - if (!args.controllingManagerProfileId && !args.managedProfileId && !args.expectedCustomerEntityId) { + if (!args.controllingManagerProfileId && !args.managedProfileId && !args.expectedCustomerEntityId && !args.membershipId) { if (args.actorProfileId !== args.subjectProfileId) throw managedAccessDenied(); return; } if (!args.controllingManagerProfileId || !args.managedProfileId || !args.expectedCustomerEntityId) { throw managedAccessDenied(); } - if (args.actorProfileId !== args.controllingManagerProfileId && args.actorProfileId !== args.subjectProfileId) { + if (args.actorProfileId !== args.subjectProfileId && !args.membershipId) { throw managedAccessDenied(); } } -async function assertCurrentAuthorization( - args: SubmitStandardAveniaKycArgs, - transaction: Transaction, - providerCustomer?: ProviderCustomer | null -): Promise { +async function assertCurrentAuthorization(args: SubmitStandardAveniaKycArgs, transaction: Transaction): Promise { assertAuthorizationShape(args); if (!args.controllingManagerProfileId || !args.managedProfileId || !args.expectedCustomerEntityId) return; const manager = await ManagedProfileManager.findByPk(args.controllingManagerProfileId, { lock: transaction.LOCK.UPDATE, transaction }); - const relationship = await ManagedProfile.findByPk(args.managedProfileId, { lock: transaction.LOCK.UPDATE, transaction }); const subject = await User.findByPk(args.subjectProfileId, { lock: transaction.LOCK.UPDATE, transaction }); + const relationship = await ManagedProfile.findByPk(args.managedProfileId, { lock: transaction.LOCK.UPDATE, transaction }); const entity = await CustomerEntity.findByPk(args.expectedCustomerEntityId, { lock: transaction.LOCK.UPDATE, transaction }); + const membership = + args.actorProfileId === args.subjectProfileId + ? null + : await ManagedProfileMembership.findByPk(args.membershipId, { lock: transaction.LOCK.UPDATE, transaction }); if ( !manager?.isActive || !manager.allowedCorridors.includes("BR") || @@ -79,13 +81,19 @@ async function assertCurrentAuthorization( relationship.managerProfileId !== args.controllingManagerProfileId || relationship.profileId !== args.subjectProfileId || relationship.status !== "active" || + (args.actorProfileId !== args.subjectProfileId && + (!membership || + membership.managedProfileId !== args.subjectProfileId || + membership.memberProfileId !== args.actorProfileId || + membership.role !== "manager" || + membership.revokedAt !== null)) || subject?.kind !== "managed" || subject.activeCustomerEntityId !== args.expectedCustomerEntityId || !entity || entity.profileId !== args.subjectProfileId || entity.status !== "active" || entity.type !== "individual" || - (providerCustomer ?? args.providerCustomer).customerEntityId !== args.expectedCustomerEntityId + args.providerCustomer.customerEntityId !== args.expectedCustomerEntityId ) { throw managedAccessDenied(); } @@ -93,6 +101,7 @@ async function assertCurrentAuthorization( async function claimAuthorizedStandardMethod(args: SubmitStandardAveniaKycArgs): Promise { return sequelize.transaction(async transaction => { + await assertCurrentAuthorization(args, transaction); const providerCustomer = await ProviderCustomer.findByPk(args.providerCustomer.id, { lock: transaction.LOCK.UPDATE, transaction @@ -107,7 +116,9 @@ async function claimAuthorizedStandardMethod(args: SubmitStandardAveniaKycArgs): }); if (cases.length !== 1) throw conflict("Exactly one canonical KYC case is required"); const kycCase = cases[0]; - await assertCurrentAuthorization(args, transaction, providerCustomer); + if (args.expectedCustomerEntityId && providerCustomer.customerEntityId !== args.expectedCustomerEntityId) { + throw managedAccessDenied(); + } if (providerCustomer.status === VerificationStatus.Approved || kycCase.status === VerificationStatus.Approved) { throw conflict("The KYC case is already approved"); } @@ -179,6 +190,7 @@ async function prepareSubmission( payloadFingerprint: string ): Promise { return sequelize.transaction(async transaction => { + await assertCurrentAuthorization(args, transaction); const providerCustomer = await ProviderCustomer.findByPk(args.providerCustomer.id, { lock: transaction.LOCK.UPDATE, transaction @@ -187,7 +199,9 @@ async function prepareSubmission( if (!kycCase || !providerCustomer || kycCase.verificationMethod !== "standard") { throw new Error("Standard KYC submission state disappeared"); } - await assertCurrentAuthorization(args, transaction, providerCustomer); + if (args.expectedCustomerEntityId && providerCustomer.customerEntityId !== args.expectedCustomerEntityId) { + throw managedAccessDenied(); + } if (kycCase.status === VerificationStatus.Approved || providerCustomer.status === VerificationStatus.Approved) { throw conflict("This customer is already approved"); } @@ -219,6 +233,7 @@ async function prepareRetrySubmission( payloadFingerprint: string ): Promise { return sequelize.transaction(async transaction => { + await assertCurrentAuthorization(args, transaction); const providerCustomer = await ProviderCustomer.findByPk(args.providerCustomer.id, { lock: transaction.LOCK.UPDATE, transaction @@ -235,7 +250,9 @@ async function prepareRetrySubmission( ) { throw reconciliationError(); } - await assertCurrentAuthorization(args, transaction, providerCustomer); + if (args.expectedCustomerEntityId && providerCustomer.customerEntityId !== args.expectedCustomerEntityId) { + throw managedAccessDenied(); + } if (kycCase.status === VerificationStatus.Approved || providerCustomer.status === VerificationStatus.Approved) { throw conflict("This customer is already approved"); } @@ -263,15 +280,22 @@ async function claimPreparedSubmission( attemptBaselineIds: string[] ): Promise { return sequelize.transaction(async transaction => { + let authorizationError: APIError | undefined; + try { + await assertCurrentAuthorization(args, transaction); + } catch (error) { + if (!(error instanceof APIError) || error.status !== httpStatus.FORBIDDEN) throw error; + authorizationError = error; + } const providerCustomer = await ProviderCustomer.findByPk(args.providerCustomer.id, { lock: transaction.LOCK.UPDATE, transaction }); const kycCase = await KycCase.findByPk(kycCaseId, { lock: transaction.LOCK.UPDATE, transaction }); - try { - await assertCurrentAuthorization(args, transaction, providerCustomer); - } catch (error) { - if (!(error instanceof APIError) || error.status !== httpStatus.FORBIDDEN) throw error; + if (args.expectedCustomerEntityId && providerCustomer?.customerEntityId !== args.expectedCustomerEntityId) { + authorizationError ??= managedAccessDenied(); + } + if (authorizationError) { const submission = kycCase?.verificationSubmission; if (kycCase && submission?.status === "prepared") { assertSubmissionBinding(submission, args, payloadFingerprint); @@ -280,7 +304,7 @@ async function claimPreparedSubmission( { transaction } ); } - return error; + return authorizationError; } const submission = kycCase?.verificationSubmission; if (!providerCustomer || !kycCase || !submission || kycCase.verificationMethod !== "standard") { diff --git a/apps/api/src/api/services/managed-profile-lifecycle.service.test.ts b/apps/api/src/api/services/managed-profile-lifecycle.service.test.ts index 7a9cec707..d6af1daeb 100644 --- a/apps/api/src/api/services/managed-profile-lifecycle.service.test.ts +++ b/apps/api/src/api/services/managed-profile-lifecycle.service.test.ts @@ -3,6 +3,7 @@ import ApiCredential from "../../models/apiCredential.model"; import CustomerEntity from "../../models/customerEntity.model"; import ManagedProfile from "../../models/managedProfile.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; +import ManagedProfileMembership from "../../models/managedProfileMembership.model"; import User from "../../models/user.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; import { createTestApiKey, createTestUser } from "../../test-utils/factories"; @@ -11,8 +12,7 @@ import { createCredential, createManagedProfileCredential, revokeManagedProfileC import { deleteManagedProfile, getManagedProfile, - listManagedProfiles, - ManagedProfileLifecycleError + listManagedProfiles } from "./managed-profile-lifecycle.service"; import { provisionManagedProfile } from "./managed-profile-provisioning.service"; @@ -48,6 +48,8 @@ describe("managed profile lifecycle", () => { expect(activePage.total).toBe(1); expect(activePage.managedProfiles[0]).toMatchObject({ externalSubjectId: "active-child", + membership: { isOwner: true, role: "manager" }, + policy: { allowedCorridors: ["BR"], allowedCustomerTypes: null }, profileId: active.profileId, status: "active" }); @@ -62,6 +64,46 @@ describe("managed profile lifecycle", () => { }); }); + it("lists only the actor's memberships with per-child role and owner policy", async () => { + const owner = await createManager(); + const otherOwner = await createManager(); + const member = await createTestUser(); + const visible = await provisionManagedProfile({ + contactEmail: "visible@example.com", + creationSource: "manager", + customerType: "individual", + externalSubjectId: "visible-child", + managerProfileId: owner.id + }); + await provisionManagedProfile({ + contactEmail: "hidden@example.com", + creationSource: "manager", + customerType: "individual", + externalSubjectId: "hidden-child", + managerProfileId: otherOwner.id + }); + await ManagedProfileMembership.create({ + managedProfileId: visible.profileId, + memberProfileId: member.id, + role: "read_only" + }); + + const page = await listManagedProfiles(member.id, { limit: 50, offset: 0, status: "active" }); + + expect(page.total).toBe(1); + expect(page.managedProfiles).toEqual([ + expect.objectContaining({ + membership: { isOwner: false, role: "read_only" }, + policy: { allowedCorridors: ["BR"], allowedCustomerTypes: null }, + profileId: visible.profileId + }) + ]); + expect(await getManagedProfile(member.id, visible.profileId)).toMatchObject({ + membership: { isOwner: false, role: "read_only" }, + policy: { allowedCorridors: ["BR"], allowedCustomerTypes: null } + }); + }); + it("logically deletes idempotently and revokes every active child credential", async () => { const manager = await createManager(); const child = await provisionManagedProfile({ @@ -106,7 +148,7 @@ describe("managed profile lifecycle", () => { ); }); - it("hides another manager's children and denies inactive managers all lifecycle access", async () => { + it("hides another manager's children and returns an empty active list for inactive owners", async () => { const manager = await createManager(); const otherManager = await createManager(); const child = await provisionManagedProfile({ @@ -125,9 +167,11 @@ describe("managed profile lifecycle", () => { }); await ManagedProfileManager.update({ isActive: false }, { where: { profileId: manager.id } }); - await expect(listManagedProfiles(manager.id, { limit: 50, offset: 0, status: "active" })).rejects.toBeInstanceOf( - ManagedProfileLifecycleError - ); + expect(await listManagedProfiles(manager.id, { limit: 50, offset: 0, status: "active" })).toMatchObject({ + actor: { canProvisionManagedProfiles: false, hasMemberships: false, profileId: manager.id }, + managedProfiles: [], + total: 0 + }); await expect(getManagedProfile(manager.id, child.profileId)).rejects.toMatchObject({ code: "MANAGED_PROFILE_ACCESS_DENIED" }); @@ -136,6 +180,44 @@ describe("managed profile lifecycle", () => { }); }); + it("keeps hasMemberships independent of pagination and owner-only retained filters", async () => { + const owner = await createManager(); + const otherOwner = await createManager(); + const owned = await provisionManagedProfile({ + managerProfileId: owner.id, customerType: "individual", creationSource: "manager", + contactEmail: "owned-retained@example.com", externalSubjectId: "owned-retained" + }); + const delegated = await provisionManagedProfile({ + managerProfileId: otherOwner.id, customerType: "individual", creationSource: "manager", + contactEmail: "delegated-active@example.com", externalSubjectId: "delegated-active" + }); + await ManagedProfileMembership.create({ managedProfileId: delegated.profileId, memberProfileId: owner.id, role: "read_only" }); + await deleteManagedProfile(owner.id, owned.profileId); + for (const status of ["active", "all", "deleted"] as const) { + const page = await listManagedProfiles(owner.id, { limit: 1, offset: 100, status }); + expect(page).toMatchObject({ + actor: { canProvisionManagedProfiles: true, hasMemberships: true, profileId: owner.id }, + managedProfiles: [], total: 1 + }); + const firstPage = await listManagedProfiles(owner.id, { limit: 1, offset: 0, status }); + expect(firstPage.managedProfiles.map(row => row.profileId)).toEqual([status === "active" ? delegated.profileId : owned.profileId]); + } + await ManagedProfileManager.update({ isActive: false }, { where: { profileId: otherOwner.id } }); + expect(await listManagedProfiles(owner.id, { limit: 1, offset: 0, status: "all" })).toMatchObject({ + actor: { canProvisionManagedProfiles: true, hasMemberships: false }, total: 1 + }); + await ManagedProfileManager.update({ isActive: true }, { where: { profileId: otherOwner.id } }); + await CustomerEntity.update({ status: "archived" }, { where: { profileId: delegated.profileId } }); + expect(await listManagedProfiles(owner.id, { limit: 1, offset: 0, status: "active" })).toMatchObject({ + actor: { canProvisionManagedProfiles: true, hasMemberships: false }, managedProfiles: [], total: 0 + }); + await CustomerEntity.update({ status: "active" }, { where: { profileId: delegated.profileId } }); + await CustomerEntity.create({ profileId: delegated.profileId, status: "active", type: "business" }); + expect(await listManagedProfiles(owner.id, { limit: 1, offset: 0, status: "active" })).toMatchObject({ + actor: { canProvisionManagedProfiles: true, hasMemberships: false }, managedProfiles: [], total: 0 + }); + }); + it("prevents a managed child from creating a second customer-entity type", async () => { const manager = await createManager(); const child = await provisionManagedProfile({ @@ -179,8 +261,8 @@ describe("managed profile lifecycle", () => { let deletionSettled = false; try { const creation = createManagedProfileCredential({ + actorProfileId: manager.id, environment: "test", - managerProfileId: manager.id, profileId: child.profileId }); await creationAtInsert; @@ -226,8 +308,8 @@ describe("managed profile lifecycle", () => { managerProfileId: manager.id }); const credential = await createManagedProfileCredential({ + actorProfileId: manager.id, environment: "test", - managerProfileId: manager.id, profileId: child.profileId }); diff --git a/apps/api/src/api/services/managed-profile-lifecycle.service.ts b/apps/api/src/api/services/managed-profile-lifecycle.service.ts index 961b46710..06b2117e8 100644 --- a/apps/api/src/api/services/managed-profile-lifecycle.service.ts +++ b/apps/api/src/api/services/managed-profile-lifecycle.service.ts @@ -1,4 +1,4 @@ -import { Op, Transaction } from "sequelize"; +import { type Includeable, literal, Op, Transaction } from "sequelize"; import sequelize from "../../config/database"; import ApiCredential from "../../models/apiCredential.model"; import CustomerEntity, { type CustomerEntityType } from "../../models/customerEntity.model"; @@ -7,6 +7,7 @@ import ManagedProfile, { type ManagedProfileStatus } from "../../models/managedProfile.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; +import ManagedProfileMembership, { type ManagedProfileMembershipRole } from "../../models/managedProfileMembership.model"; import User from "../../models/user.model"; import { provisionManagedProfile } from "./managed-profile-provisioning.service"; @@ -16,6 +17,8 @@ export class ManagedProfileLifecycleError extends Error { | "MANAGED_PROFILE_ACCESS_DENIED" | "MANAGED_PROFILE_CONFLICT" | "MANAGED_PROFILE_INVALID_INPUT" + | "MANAGED_PROFILE_MEMBERSHIP_INVALID" + | "MANAGED_PROFILE_OWNER_REQUIRED" | "MANAGED_PROFILE_NOT_FOUND", message: string ) { @@ -37,23 +40,28 @@ export interface ManagedProfileLifecycleResult { } export interface ManagedProfileListResult { + actor: ManagedProfileActor; limit: number; - managedProfiles: ManagedProfileLifecycleResult[]; + managedProfiles: ManagedProfileAccessResult[]; offset: number; total: number; } -async function requireActiveManager(managerProfileId: string, transaction?: Transaction, lock = false): Promise { - const manager = await ManagedProfileManager.findByPk(managerProfileId, { - ...(lock ? { lock: Transaction.LOCK.UPDATE } : {}), - transaction - }); - if (!manager?.isActive) { - throw new ManagedProfileLifecycleError( - "MANAGED_PROFILE_ACCESS_DENIED", - "The authenticated profile is not an active managed-profile manager" - ); - } +export interface ManagedProfileActor { + canProvisionManagedProfiles: boolean; + hasMemberships: boolean; + profileId: string; +} + +export interface ManagedProfileAccessResult extends ManagedProfileLifecycleResult { + membership: { + isOwner: boolean; + role: ManagedProfileMembershipRole; + }; + policy: { + allowedCorridors: ManagedProfileManager["allowedCorridors"]; + allowedCustomerTypes: ManagedProfileManager["allowedCustomerTypes"]; + }; } async function toResult(relationship: ManagedProfile, transaction?: Transaction): Promise { @@ -89,6 +97,25 @@ function toResultWithCustomerType( }; } +function toAccessResult( + relationship: ManagedProfile, + customerType: CustomerEntityType, + membership: ManagedProfileMembership, + manager: ManagedProfileManager +): ManagedProfileAccessResult { + return { + ...toResultWithCustomerType(relationship, customerType), + membership: { + isOwner: relationship.managerProfileId === membership.memberProfileId, + role: membership.role + }, + policy: { + allowedCorridors: manager.allowedCorridors, + allowedCustomerTypes: manager.allowedCustomerTypes + } + }; +} + export async function createManagedProfile(input: { contactEmail: string; creationSource: ManagedProfileCreationSource; @@ -104,16 +131,71 @@ export async function createManagedProfile(input: { return { created: provisioned.created, managedProfile: await toResult(relationship) }; } +function eligibleMembershipIncludes(actorProfileId: string): Includeable[] { + return [ + { + as: "memberships", + model: ManagedProfileMembership, + required: true, + where: { memberProfileId: actorProfileId, revokedAt: null, role: ["manager", "read_only"] } + }, + { as: "manager", model: ManagedProfileManager, required: true, where: { isActive: true } }, + { + as: "profile", + include: [ + { + as: "activeCustomerEntity", + model: CustomerEntity, + required: true, + where: { profileId: { [Op.col]: "ManagedProfile.profile_id" }, status: "active" } + } + ], + model: User, + required: true, + where: { + kind: "managed", + [Op.and]: literal( + '(SELECT COUNT(*) FROM "customer_entities" AS "entities" WHERE "entities"."profile_id" = "ManagedProfile"."profile_id") = 1' + ) + } + } + ]; +} + +export async function getManagedProfileActor(actorProfileId: string): Promise { + const [actorManager, activeMembershipCount] = await Promise.all([ + ManagedProfileManager.findByPk(actorProfileId), + ManagedProfile.count({ + distinct: true, + include: eligibleMembershipIncludes(actorProfileId), + where: { status: "active" } + }) + ]); + return { + canProvisionManagedProfiles: actorManager?.isActive === true, + hasMemberships: activeMembershipCount > 0, + profileId: actorProfileId + }; +} + export async function listManagedProfiles( - managerProfileId: string, + actorProfileId: string, options: { limit: number; offset: number; status: ManagedProfileStatus | "all" } ): Promise { - await requireActiveManager(managerProfileId); + const actor = await getManagedProfileActor(actorProfileId); + if (options.status !== "active" && !actor.canProvisionManagedProfiles) { + throw new ManagedProfileLifecycleError( + "MANAGED_PROFILE_OWNER_REQUIRED", + "Retained managed profiles require an active owner configuration" + ); + } const where = { - managerProfileId, + ...(options.status === "active" ? {} : { managerProfileId: actorProfileId }), ...(options.status === "all" ? {} : { status: options.status }) }; const { count, rows } = await ManagedProfile.findAndCountAll({ + distinct: true, + include: eligibleMembershipIncludes(actorProfileId), limit: options.limit, offset: options.offset, order: [["createdAt", "DESC"]], @@ -132,6 +214,7 @@ export async function listManagedProfiles( } return { + actor, limit: options.limit, managedProfiles: rows.map(relationship => { const profileEntities = entitiesByProfileId.get(relationship.profileId) ?? []; @@ -141,27 +224,77 @@ export async function listManagedProfiles( "The managed profile does not have exactly one customer entity" ); } - return toResultWithCustomerType(relationship, profileEntities[0].type); + const memberships = relationship.get("memberships") as ManagedProfileMembership[] | undefined; + const manager = relationship.get("manager") as ManagedProfileManager | undefined; + const membership = memberships?.[0]; + if (!membership || !manager) { + throw new ManagedProfileLifecycleError("MANAGED_PROFILE_CONFLICT", "Managed profile access data is incomplete"); + } + return toAccessResult(relationship, profileEntities[0].type, membership, manager); }), offset: options.offset, total: count }; } -export async function getManagedProfile(managerProfileId: string, profileId: string): Promise { - await requireActiveManager(managerProfileId); - const relationship = await ManagedProfile.findOne({ where: { managerProfileId, profileId } }); - if (!relationship) { - throw new ManagedProfileLifecycleError("MANAGED_PROFILE_NOT_FOUND", "Managed profile was not found"); +export async function getManagedProfile( + actorProfileId: string, + profileId: string, + { bootstrap = false }: { bootstrap?: boolean } = {} +): Promise { + const [membership, relationship, subject, entities] = await Promise.all([ + ManagedProfileMembership.findOne({ + where: { managedProfileId: profileId, memberProfileId: actorProfileId, revokedAt: null } + }), + ManagedProfile.findOne({ where: { profileId } }), + User.findByPk(profileId, { attributes: ["kind", "activeCustomerEntityId"] }), + CustomerEntity.findAll({ where: { profileId } }) + ]); + const manager = relationship && (await ManagedProfileManager.findByPk(relationship.managerProfileId)); + if ( + !membership || + !["manager", "read_only"].includes(membership.role) || + !relationship || + !manager?.isActive || + subject?.kind !== "managed" || + entities.length !== 1 || + entities[0].id !== subject.activeCustomerEntityId || + entities[0].status !== "active" || + (relationship.status === "deleted" && (bootstrap || relationship.managerProfileId !== actorProfileId)) + ) { + // A selector expresses bootstrap intent, not prior access. Only stored history can invalidate selection. + if ( + bootstrap && + (membership || + (await ManagedProfileMembership.count({ + where: { managedProfileId: profileId, memberProfileId: actorProfileId } + })) > 0) + ) { + throw new ManagedProfileLifecycleError( + "MANAGED_PROFILE_MEMBERSHIP_INVALID", + "The managed-profile membership is no longer eligible" + ); + } + if (!membership || !relationship || relationship.status === "deleted") { + throw new ManagedProfileLifecycleError("MANAGED_PROFILE_NOT_FOUND", "Managed profile was not found"); + } + throw new ManagedProfileLifecycleError("MANAGED_PROFILE_ACCESS_DENIED", "Managed profile access is denied"); } - return toResult(relationship); + return toAccessResult(relationship, entities[0].type, membership, manager); } export async function deleteManagedProfile(managerProfileId: string, profileId: string): Promise { await sequelize.transaction(async transaction => { + const locator = await ManagedProfile.findOne({ transaction, where: { profileId } }); + if (!locator) { + throw new ManagedProfileLifecycleError("MANAGED_PROFILE_NOT_FOUND", "Managed profile was not found"); + } // Provisioning serializes on the manager row, so taking it here too keeps a concurrent // re-provision from observing this child mid-deletion. Manager first in both paths. - await requireActiveManager(managerProfileId, transaction, true); + const owner = await ManagedProfileManager.findByPk(locator.managerProfileId, { + lock: Transaction.LOCK.UPDATE, + transaction + }); const profile = await User.findByPk(profileId, { attributes: ["id"], lock: Transaction.LOCK.UPDATE, @@ -173,11 +306,28 @@ export async function deleteManagedProfile(managerProfileId: string, profileId: const relationship = await ManagedProfile.findOne({ lock: Transaction.LOCK.UPDATE, transaction, - where: { managerProfileId, profileId } + where: { managerProfileId: locator.managerProfileId, profileId } }); if (!relationship) { throw new ManagedProfileLifecycleError("MANAGED_PROFILE_NOT_FOUND", "Managed profile was not found"); } + if (relationship.managerProfileId !== managerProfileId) { + const membership = await ManagedProfileMembership.findOne({ + lock: Transaction.LOCK.UPDATE, + transaction, + where: { managedProfileId: profileId, memberProfileId: managerProfileId, revokedAt: null } + }); + if (membership && ["manager", "read_only"].includes(membership.role)) { + throw new ManagedProfileLifecycleError( + "MANAGED_PROFILE_OWNER_REQUIRED", + "Only the immutable owner may delete a managed profile" + ); + } + throw new ManagedProfileLifecycleError("MANAGED_PROFILE_NOT_FOUND", "Managed profile was not found"); + } + if (!owner?.isActive) { + throw new ManagedProfileLifecycleError("MANAGED_PROFILE_ACCESS_DENIED", "Managed profile access is denied"); + } if (relationship.status === "deleted") return; const deletedAt = new Date(); diff --git a/apps/api/src/config/express.ts b/apps/api/src/config/express.ts index 6312d4767..22ade1fc6 100644 --- a/apps/api/src/config/express.ts +++ b/apps/api/src/config/express.ts @@ -13,6 +13,7 @@ import { requestContext } from "../api/observability/requestContext"; import routes from "../api/routes/v1"; import aveniaWebhookRoutes from "../api/routes/v1/avenia-webhook.route"; import brlaKycImportRoutes from "../api/routes/v1/brla-kyc-import.route"; +import { managedProfileRampBearerRoutes } from "../api/routes/v1/ramp.route"; import { corsOptions } from "./corsConfig"; import { config } from "./vars"; @@ -57,6 +58,10 @@ app.use(helmet()); // Authenticate and authorize this sensitive token-bearing request before buffering JSON. app.use(["/v1/brl/kyc/import-token", "/v1/brla/kyc/import-token"], brlaKycImportRoutes); +// Selected-child ramps require a secret credential. Reject bearer attempts before +// buffering a body whose quote/ramp data cannot make the request authorized. +app.use("/v1/ramp", managedProfileRampBearerRoutes); + // Mounted ahead of the JSON parser: Avenia signs the raw request body, and a payload // that has been parsed and re-serialised does not reproduce those bytes exactly. // Own, small limit: webhook events are a few KB, and this unauthenticated route should diff --git a/apps/api/src/test-utils/test-app.ts b/apps/api/src/test-utils/test-app.ts index b6425ba7e..360f43ac6 100644 --- a/apps/api/src/test-utils/test-app.ts +++ b/apps/api/src/test-utils/test-app.ts @@ -33,6 +33,12 @@ export async function startTestApp(): Promise { baseUrl, close: () => new Promise((resolve, reject) => { + // Bun fetch keep-alives can otherwise prevent the close callback from settling. + server.closeAllConnections(); + if (!server.listening) { + resolve(); + return; + } server.close(error => (error ? reject(error) : resolve())); }), request: (path, init) => fetch(`${baseUrl}${path}`, init) diff --git a/apps/api/src/tests/alfredpay-managed-customer.integration.test.ts b/apps/api/src/tests/alfredpay-managed-customer.integration.test.ts index 935594435..4d4191cf6 100644 --- a/apps/api/src/tests/alfredpay-managed-customer.integration.test.ts +++ b/apps/api/src/tests/alfredpay-managed-customer.integration.test.ts @@ -5,6 +5,7 @@ import sequelize from "../config/database"; import CustomerEntity from "../models/customerEntity.model"; import ManagedProfile from "../models/managedProfile.model"; import ManagedProfileManager from "../models/managedProfileManager.model"; +import ManagedProfileMembership from "../models/managedProfileMembership.model"; import ProviderCustomer from "../models/providerCustomer.model"; import User from "../models/user.model"; import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; @@ -83,7 +84,7 @@ describe("managed Alfredpay customer creation", () => { const createCustomer = mock(async () => ({ customerId: "alfred-child", createdAt: new Date().toISOString() })); provider(createCustomer); - const response = await fetch(`${baseUrl}/createIndividualCustomer`, { + const bearerResponse = await fetch(`${baseUrl}/createIndividualCustomer`, { body: JSON.stringify({ country: "MX" }), headers: { Authorization: "Bearer manager-token", @@ -93,6 +94,19 @@ describe("managed Alfredpay customer creation", () => { method: "POST" }); + expect(bearerResponse.status).toBe(403); + expect(await bearerResponse.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_REQUIRES_API_CREDENTIAL" } }); + expect(createCustomer).not.toHaveBeenCalled(); + const credential = await createTestApiKey({ userId: manager.id }); + const response = await fetch(`${baseUrl}/createIndividualCustomer`, { + body: JSON.stringify({ country: "MX" }), + headers: { + "X-API-Key": credential.plaintextKey, + "Content-Type": "application/json", + "X-Managed-Profile-Id": child.profileId + }, + method: "POST" + }); expect(response.status).toBe(200); expect(createCustomer).toHaveBeenCalledWith("child@example.com", DomesticCustomerType.INDIVIDUAL, "MX"); expect(JSON.stringify(createCustomer.mock.calls)).not.toContain("manager@example.com"); @@ -212,8 +226,8 @@ describe("managed Alfredpay customer creation", () => { const manager = await createManager(["MX"], ["business"]); const child = await createChild(manager.id, "business", "direct-business@example.com"); const credential = await createManagedProfileCredential({ + actorProfileId: manager.id, environment: "test", - managerProfileId: manager.id, profileId: child.profileId }); await ManagedProfileManager.update( @@ -285,6 +299,10 @@ describe("managed Alfredpay customer creation", () => { }, { transaction } ); + await ManagedProfileMembership.create( + { managedProfileId: childId, memberProfileId: manager.id, role: "manager" }, + { transaction } + ); }); const credential = await createTestApiKey({ userId: manager.id }); const createCustomer = mock(async () => ({ customerId: "unexpected", createdAt: new Date().toISOString() })); diff --git a/apps/api/src/tests/managed-profile-quote-ramp-lifecycle.integration.test.ts b/apps/api/src/tests/managed-profile-quote-ramp-lifecycle.integration.test.ts index 7f3970e42..008a601ca 100644 --- a/apps/api/src/tests/managed-profile-quote-ramp-lifecycle.integration.test.ts +++ b/apps/api/src/tests/managed-profile-quote-ramp-lifecycle.integration.test.ts @@ -4,16 +4,19 @@ import { privateKeyToAccount } from "viem/accounts"; import ApiCredential from "../models/apiCredential.model"; import ManagedProfile from "../models/managedProfile.model"; import ManagedProfileManager from "../models/managedProfileManager.model"; +import ManagedProfileMembership from "../models/managedProfileMembership.model"; import ProfilePartnerAssignment from "../models/profilePartnerAssignment.model"; import QuoteTicket from "../models/quoteTicket.model"; import RampState from "../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; import { createTestApiKey, createTestPartner, createTestTaxId, createTestUser } from "../test-utils/factories"; import { type FakeWorld, installFakeWorld } from "../test-utils/fake-world"; +import { installFakeSupabaseAuth, testUserToken } from "../test-utils/fake-world/fake-auth"; import { startTestApp, type TestApp } from "../test-utils/test-app"; describe("managed-profile quote and registered-ramp lifecycle", () => { let app: TestApp; + let auth: { restore: () => void }; let world: FakeWorld; const DESTINATION = "0x7ba99e99bc669b3508aff9cc0a898e869459f877"; @@ -25,12 +28,14 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { beforeAll(async () => { world = installFakeWorld(); + auth = installFakeSupabaseAuth(); await setupTestDatabase(); app = await startTestApp(); }); afterAll(async () => { await app?.close(); + auth?.restore(); world?.restore(); }); @@ -78,6 +83,12 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { const childId = await createChild("managed-lifecycle-child"); const siblingId = await createChild("managed-lifecycle-sibling"); + const member = await createTestUser({ email: "managed-lifecycle-member@example.com" }); + await ManagedProfileMembership.bulkCreate([ + { createdByProfileId: manager.id, managedProfileId: childId, memberProfileId: member.id, role: "manager" }, + { createdByProfileId: manager.id, managedProfileId: siblingId, memberProfileId: member.id, role: "manager" } + ]); + const memberCredential = await createTestApiKey({ userId: member.id }); const pricingPartner = await createTestPartner({ fiatCurrency: FiatToken.BRL, markupCurrency: FiatToken.BRL, @@ -122,7 +133,11 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { const childCredential = await createCredential(childId, "child primary"); const childSecondCredential = await createCredential(childId, "child secondary"); const siblingCredential = await createCredential(siblingId, "sibling primary"); - const delegatedHeaders = { ...managerHeaders, "X-Managed-Profile-Id": childId }; + const delegatedHeaders = { + "Content-Type": "application/json", + "X-API-Key": memberCredential.plaintextKey, + "X-Managed-Profile-Id": childId + }; const delegatedOnboarding = await jsonRequest("/v1/onboarding/status", { headers: delegatedHeaders, @@ -173,7 +188,7 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { expect(delegatedQuote?.userId).toBe(childId); expect(delegatedQuote?.partnerId).toBeNull(); expect(delegatedQuote?.pricingPartnerId).toBe(pricingPartner.id); - expect(delegatedQuote?.apiCredentialId).toBe(managerCredential.record.id); + expect(delegatedQuote?.apiCredentialId).toBe(memberCredential.record.id); expect(Number(delegatedQuote?.outputAmount)).toBe(99.9); expect(Number(delegatedQuoteResponse.partnerFeeFiat)).toBe(5); expect(Number(delegatedQuoteResponse.partnerFeeUsd)).toBe(1); @@ -205,6 +220,21 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { expect(delegatedSiblingQuote?.apiCredentialId).toBe(managerCredential.record.id); expect(Number(delegatedSiblingQuoteResponse.partnerFeeFiat)).toBe(2); + const bearerHeaders = { + Authorization: `Bearer ${testUserToken(member.id, member.email)}`, + "Content-Type": "application/json", + "X-Managed-Profile-Id": childId + }; + for (const path of ["register", "update", "start"]) { + const denied = await jsonRequest(`/v1/ramp/${path}`, { + body: JSON.stringify({ quoteId: delegatedQuoteId, rampId: crypto.randomUUID(), signingAccounts: [] }), + headers: bearerHeaders, + method: "POST" + }); + expect(denied.status).toBe(403); + expect((denied.body.error as { code: string }).code).toBe("MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL"); + } + const pendingQuoteResponse = await createQuote({ "Content-Type": "application/json", "X-API-Key": childCredential.secretKey @@ -223,7 +253,7 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { const delegatedRegistration = await registerQuote( delegatedQuoteId, - managerCredential.plaintextKey, + memberCredential.plaintextKey, EPHEMERALS[0], childId ); @@ -266,16 +296,19 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { expect((await QuoteTicket.findByPk(delegatedQuoteId))?.status).toBe("consumed"); expect((await QuoteTicket.findByPk(directQuoteId))?.status).toBe("consumed"); - const siblingStatus = await app.request(`/v1/ramp/${siblingRampId}`, { - headers: { "X-API-Key": childCredential.secretKey } + const siblingStatus = await jsonRequest(`/v1/ramp/${siblingRampId}`, { + headers: { "X-API-Key": childCredential.secretKey }, + method: "GET" }); expect(siblingStatus.status).toBe(403); - const childStatusViaSibling = await app.request(`/v1/ramp/${delegatedRampId}`, { - headers: { "X-API-Key": siblingCredential.secretKey } + const childStatusViaSibling = await jsonRequest(`/v1/ramp/${delegatedRampId}`, { + headers: { "X-API-Key": siblingCredential.secretKey }, + method: "GET" }); expect(childStatusViaSibling.status).toBe(403); - const managerStatus = await app.request(`/v1/ramp/${delegatedRampId}`, { - headers: { "X-API-Key": managerCredential.plaintextKey } + const managerStatus = await jsonRequest(`/v1/ramp/${delegatedRampId}`, { + headers: { "X-API-Key": managerCredential.plaintextKey }, + method: "GET" }); expect(managerStatus.status).toBe(403); @@ -318,17 +351,17 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { method: "POST" }); expect(updateAfterNarrowing.status).toBe(403); - expect((updateAfterNarrowing.body.error as { code: string }).code).toBe("MANAGED_PROFILE_ACCESS_DENIED"); + expect((updateAfterNarrowing.body.error as { code: string }).code).toBe("MANAGED_PROFILE_POLICY_DENIED"); const startAfterNarrowing = await jsonRequest("/v1/ramp/start", { body: JSON.stringify({ rampId: delegatedRampId }), headers: { "Content-Type": "application/json", "X-API-Key": childCredential.secretKey }, method: "POST" }); expect(startAfterNarrowing.status).toBe(403); - expect((startAfterNarrowing.body.error as { code: string }).code).toBe("MANAGED_PROFILE_ACCESS_DENIED"); + expect((startAfterNarrowing.body.error as { code: string }).code).toBe("MANAGED_PROFILE_POLICY_DENIED"); expect((await RampState.findByPk(delegatedRampId))?.currentPhase).toBe("initial"); - const deletion = await app.request(`/v1/managed-profiles/${childId}`, { + const deletion = await jsonRequest(`/v1/managed-profiles/${childId}`, { headers: managerHeaders, method: "DELETE" }); @@ -353,7 +386,7 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { expect((await ApiCredential.findByPk(managerCredential.record.id))?.revokedAt).toBeNull(); expect(retainedDelegatedQuote?.status).toBe("consumed"); expect(retainedDelegatedQuote?.userId).toBe(childId); - expect(retainedDelegatedQuote?.apiCredentialId).toBe(managerCredential.record.id); + expect(retainedDelegatedQuote?.apiCredentialId).toBe(memberCredential.record.id); expect(retainedDirectQuote?.status).toBe("consumed"); expect(retainedDirectQuote?.userId).toBe(childId); expect(retainedDirectQuote?.apiCredentialId).toBe(childCredential.id); @@ -373,7 +406,7 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { }); expect(deletedDelegationHistory.status).toBe(403); expect((deletedDelegationHistory.body.error as { code: string }).code).toBe("MANAGED_PROFILE_ACCESS_DENIED"); - const revokedChildHistory = await app.request("/v1/ramp/history", { + const revokedChildHistory = await jsonRequest("/v1/ramp/history", { headers: { "X-API-Key": childCredential.secretKey }, method: "GET" }); diff --git a/docs/architecture-identity-model.md b/docs/architecture-identity-model.md index c357f0a74..eb0d7d65a 100644 --- a/docs/architecture-identity-model.md +++ b/docs/architecture-identity-model.md @@ -128,13 +128,31 @@ member profiles, and permit only one active grant for each child/member pair. The immutable controlling manager remains the child owner and supplies corridor/customer-type policy, pricing fallback, external identity namespace, and lifecycle authority. Memberships grant -other authenticated actors access without transferring ownership. Lifecycle reads return the actor -profile ID plus each child's actor-specific role, owner flag, and controlling-owner policy. An -active configured owner can still receive an empty list before provisioning a child. Manager -members may perform supported delegated mutations and manage child credentials; read-only members -may use supported reads only. Browser bearer sessions cannot perform child provider/KYC/KYB or ramp -mutations; these require an eligible secret API credential. Child-owned credentials remain shared -company principals independent of the human who created or possesses them. +other authenticated actors access without transferring ownership. List/detail return +`actor: { profileId, canProvisionManagedProfiles, hasMemberships }` plus each child's actor-specific +role, owner flag and controlling-owner policy. Provisioning capability reflects the actor's own +active manager configuration. Membership presence is the unpaginated eligible active-child count +across owners, independent of page/status/detail target; deleted children, inactive owners and +invalid entity layouts do not count. Eligibility requires an unrevoked allowed-role membership and +a managed child selecting its sole active owned entity. The default active list returns `200` with +an empty list even when both actor flags are false. Both `status=deleted` and `status=all` require +the actor's own active owner configuration and return only its owned children, excluding even active +invited children owned by others. Retained results still require valid membership/entity layout. + +Detail bootstrap is an exactly matching `X-Managed-Profile-Id` on the child `GET`. Only stored +membership history permits `MANAGED_PROFILE_MEMBERSHIP_INVALID` after membership, owner, child or +entity eligibility is lost; even the owner cannot bootstrap a deleted child. Never-member callers +receive identical masked `404`s for existing and unknown children. Ordinary retained detail reads +require the active immutable owner, valid membership/entity layout and no selector; invited members +and ineligible retained reads receive masked `404`. Bearer and member-secret callers use the same +checks. A non-owner active member's deletion attempt returns `MANAGED_PROFILE_OWNER_REQUIRED`. + +Manager members may perform supported delegated mutations and manage child credentials; read-only +members may use supported reads only. Browser bearers cannot perform `credential_manage` provider/ +KYC/KYB mutations (`MANAGED_PROFILE_REQUIRES_API_CREDENTIAL`, the shipped spelling) or ramp mutations +(`MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL`, no drain exception). Child credential and domestic +fiat-account mutations are `manage`, allowing manager-member bearers. Child-owned credentials remain +shared company principals independent of the human who created or possesses them. Membership invitations are scoped to one child and expire after seven days. The exact current verified Supabase email must explicitly accept; OTP authentication alone never grants membership. @@ -214,7 +232,7 @@ attributable to their controlling manager without a duplicate operation-level actor/subject record. Distinguishing direct child-credential requests from delegated manager requests in durable operation records is not required by the current model. Generic profile and admin partner credential creation reject managed subjects; only the -controlling manager's child-credential route may issue one. A committed manager, +child-scoped credential route, authorized by an active `manager` membership, may issue one. A committed manager, relationship, corridor, or customer-type policy change blocks subsequent authorization decisions but does not cancel a request that was already authorized and remains in flight. diff --git a/docs/security-spec/01-auth/api-keys.md b/docs/security-spec/01-auth/api-keys.md index 81f51ba1b..44b2b6cf1 100644 --- a/docs/security-spec/01-auth/api-keys.md +++ b/docs/security-spec/01-auth/api-keys.md @@ -35,7 +35,10 @@ Every credential has a non-null `profile_id`. A null `partner_id` is profile-man | Read or manage an authorized managed child, subject to membership role | No | Yes | Yes | | Register, update, or start a selected child's ramp | No | Yes | No | | Use a child-owned credential as the managed child | Public capabilities only | Yes | N/A | -| Manage a directly owned child's credentials | No | Yes | Yes | +| List child credentials through either active membership role | No | Yes | Yes | +| Issue/revoke child credentials through any active `manager` membership | No | Yes | Yes | +| Selected-child provider/KYC/KYB `credential_manage` mutations | No | Yes | No | +| Membership/invitation administration (role-gated) | No | No | Yes | | Manage webhooks | No | Yes | No | | Create, list, or revoke profile-managed credentials | No | No | Yes | | List or revoke partner-managed credentials of the session's own profile | No | No | Yes | @@ -49,7 +52,7 @@ Possession of a public key never authorizes exact financial usage, provider iden A profile may have at most five non-revoked, non-expired credentials. Creation locks the profile row and performs the active count and insert in one transaction, preventing concurrent requests from exceeding the cap. `DELETE /v1/api-credentials/:credentialId` updates the one row's `revoked_at`, atomically disabling both values without a request body or second key ID. -Admin partner credential operations use the same lifecycle service and require an explicit existing `profile_id` subject. The legacy `POST /v1/admin/managed-profiles` flow provisions a genuine Supabase identity and Vortex profile from explicit `partnerId`, `externalUserId`, email, and `individual`, `business`, or `technical` subject type. The `(partner_id, external_user_id)` and `profile_id` associations are unique; an existing email is reconciled only when its immutable Supabase metadata matches the same association. Individual/business subjects receive the matching customer entity. OTP verification marks the identity claimed without duplicating it. Technical subjects receive no customer entity and are explicitly rejected from customer/ramp operations. The separate headless provisioning service atomically creates a null-login-email managed profile, its active customer entity, immutable provider contact email, and manager relationship. A manager session or secret credential may issue profile-managed credentials for a directly owned active child only through the manager-scoped child-credential route; generic profile-managed and admin partner-managed credential creation reject managed subjects. +Admin partner credential operations use the same lifecycle service and require an explicit existing `profile_id` subject. The legacy `POST /v1/admin/managed-profiles` flow provisions a genuine Supabase identity and Vortex profile from explicit `partnerId`, `externalUserId`, email, and `individual`, `business`, or `technical` subject type. The `(partner_id, external_user_id)` and `profile_id` associations are unique; an existing email is reconciled only when its immutable Supabase metadata matches the same association. Individual/business subjects receive the matching customer entity. OTP verification marks the identity claimed without duplicating it. Technical subjects receive no customer entity and are explicitly rejected from customer/ramp operations. The separate headless provisioning service atomically creates a null-login-email managed profile, its active customer entity, immutable provider contact email, and manager relationship. Any authenticated profile with an active `manager` membership for that child may issue/revoke its profile-managed credentials through the child-scoped credential routes, using its own Supabase session or profile-bound secret. The actor need not own the child or have personal manager configuration; the immutable owner's configuration, child relationship and entity layout must remain valid. Generic profile-managed and admin partner-managed credential creation reject managed subjects. ### Public And Secret Consistency @@ -57,7 +60,7 @@ When both `X-Public-Key` and `X-API-Key` are supplied, both values are resolved ### Sanitized Ramp Info -`GET /v1/ramp-info` accepts `X-Public-Key` or the corresponding `X-API-Key`. It derives the profile from `CredentialContext.profileId`, except that a manager secret may select one authorized child through `X-Managed-Profile-Id`. A public key cannot authorize that selector. Direct child credentials resolve their own child profile. The endpoint does not accept body/query user, profile, email, tax-ID, or customer-entity selectors, and Supabase sessions are not accepted. +`GET /v1/ramp-info` accepts `X-Public-Key` or the corresponding `X-API-Key`. It derives the profile from `CredentialContext.profileId`, except that a `manager` or `read_only` member's secret may select one authorized child through `X-Managed-Profile-Id`. A public key cannot authorize that selector. Direct child credentials resolve their own child profile. The endpoint does not accept body/query user, profile, email, tax-ID, or customer-entity selectors, and Supabase sessions are not accepted. Its response is an allowlisted per-corridor projection: @@ -98,11 +101,15 @@ Its response is an allowlisted per-corridor projection: 19. **Startup MUST fail closed**: after migrations and before listening, the API verifies required `api_credentials` columns, nullability, indexes, constraints, and that the legacy `api_keys` table is absent. Any failure prevents serving traffic. 20. **`ramp-info` MUST be subject-derived and sanitized**: it accepts no user selector and returns only the documented KYC state and buy/sell booleans. 21. **Managed-profile selection MUST be authorization-derived**: `X-Managed-Profile-Id` is accepted only on delegated routes after a Supabase session or secret credential establishes an authenticated member actor. Secret-key middleware explicitly records the authenticated credential profile; delegated authorization MUST NOT infer authentication by inspecting `CredentialContext.strength`. Authorization requires the actor's active `managed_profile_memberships` row, an active immutable controlling manager, a direct active relationship, a managed child with exactly one customer entity matching its active entity, and, for policy-bound operations, every required corridor, canonical corridor/type support, and inclusion under any non-null owner customer-type narrowing. `read_only` membership permits only explicitly read-classified routes; `manager` membership permits read and management. Provider/KYC/KYB `credential_manage` and ramp capabilities additionally require the manager member's secret credential. Null customer types add no restriction beyond the canonical matrix. The verified child becomes the effective operation subject without replacing the authenticated actor. A direct child credential does not require a member row and cannot present the selector to act for another child. -22. **Managed-profile lifecycle mutations MUST remain owner-scoped and logically deleted**: `POST` and `DELETE /v1/managed-profiles` derive the owner from authentication and do not delegate through membership. During admin impersonation, list/read operations remain available but creation and deletion return `403 IMPERSONATION_NOT_ALLOWED`. Creation requires immutable `externalSubjectId`, `contactEmail`, and customer type values, rejects a customer type outside the owner's non-null `allowedCustomerTypes` narrowing, accepts no corridor grant, is idempotent by `(manager_profile_id, external_subject_id)`, and rejects reuse of a normalized `(manager_profile_id, contact_email)` by another child. List/read operations return only active memberships of the authenticated actor, include each membership role and immutable owner policy, and may return retained deleted children on direct read; foreign children return `404`. Deletion locks the child profile and relationship, atomically marks the relationship deleted and revokes all active child credentials, preserves customer/provider/KYC/ramp records, and returns `204` on repeated requests. Deleted external subject IDs and contact emails remain permanently reserved within that owner. Database triggers enforce immutable ownership, external subject, and contact email. -23. **Child credentials MUST remain membership- and relationship-controlled**: `GET /v1/managed-profiles/:profileId/api-credentials` requires an active `manager` or `read_only` membership; `POST` and `DELETE` require `manager`. Every operation is scoped to the path child, and this is the only credential-issuance path that accepts a managed subject. A mismatched `X-Managed-Profile-Id` and path child is denied. During admin impersonation, credential listing remains available but creation and revocation return `403 IMPERSONATION_NOT_ALLOWED`. It issues only `partner_id = NULL` credentials under the child's shared five-active-credential cap. Credential creation locks the child profile and relationship in the same order as logical deletion. Public and secret validation of a managed child's credential dynamically requires the unique relationship and owner manager to remain active. Corridor-bound routes apply the owner's current corridor and optional customer-type narrowing plus the canonical corridor capability matrix, and deletion revokes both halves. Direct child credentials cannot manage webhooks or manager lifecycle resources. +22. **Managed-profile lifecycle mutations MUST remain owner-scoped and logically deleted**: `POST /v1/managed-profiles` and `DELETE /v1/managed-profiles/:profileId` derive the owner from authentication and do not delegate through membership. A non-owner active member's child deletion returns `403 MANAGED_PROFILE_OWNER_REQUIRED`; outsiders receive masked `404`. During admin impersonation, list/read operations remain available but creation and deletion return `403 IMPERSONATION_NOT_ALLOWED`. Creation requires immutable `externalSubjectId`, `contactEmail`, and customer type values, rejects a customer type outside the owner's non-null `allowedCustomerTypes` narrowing, accepts no corridor grant, is idempotent by `(manager_profile_id, external_subject_id)`, and rejects reuse of a normalized `(manager_profile_id, contact_email)` by another child. List/detail return actor identity, independent provisioning/membership flags, each membership role and immutable owner policy. Default active lists return `200` even when empty; `hasMemberships` counts unpaginated eligible active children, excluding deleted children, inactive owners and invalid entity layouts. Both `status=deleted` and `status=all` require the actor's own active manager configuration and return only its owned children. Retained deleted-child detail reads require the active immutable owner, valid membership/entity layout and no selector; invited members and ineligible retained reads receive masked `404`. Explicit matching-selector bootstrap additionally requires stored membership history before ineligibility returns `MANAGED_PROFILE_MEMBERSHIP_INVALID`; even the owner's deleted-child bootstrap is invalid. Never-member existing and unknown children are masked identically. Bearer and member-secret callers follow the same read rules; see [Managed-Profile Memberships](managed-profile-memberships.md#lifecycle-reads). Deletion locks owner configuration before the child profile and relationship, atomically marks deletion and revokes child credentials, retains compliance/financial records, and returns `204` on retries while owner configuration remains active. Deleted external subject IDs and contact emails remain reserved; database triggers enforce their immutability and ownership. +23. **Child credentials MUST remain membership- and relationship-controlled**: `GET /v1/managed-profiles/:profileId/api-credentials` requires an active `manager` or `read_only` membership; `POST` and `DELETE` require any active `manager` membership, not child ownership or the actor's personal manager configuration. Member-owned secrets and Supabase sessions are supported (`manage` capability). Every operation is scoped to the path child, and this is the only credential-issuance path that accepts a managed subject. A mismatched selector/path is denied. Impersonation permits listing but rejects creation/revocation with `403 IMPERSONATION_NOT_ALLOWED`. Issuance forces `partner_id = NULL` under the child's shared five-active-credential cap. Creation and revocation recheck live actor membership, owner configuration and child entity under the owner-first, child-aggregate, membership lock order, so removal/downgrade before service mutation returns `CREDENTIAL_ACCESS_DENIED`. Public/secret validation of a child credential independently requires the active controlling relationship and owner; member removal does not revoke these shared company credentials. Corridor-bound routes apply the owner's current policy and canonical capability matrix. Child deletion revokes both halves. Direct child credentials cannot administer credentials, membership, webhooks or manager lifecycle resources. 24. **Selected-child ramp execution MUST require a member-owned secret credential before body buffering**: Supabase bearer sessions may perform non-ramp operations allowed by their membership role, but `POST /v1/ramp/register`, `/update`, and `/start` with `X-Managed-Profile-Id` return `403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL`. The guard authenticates the bearer, rejects impersonation first, verifies membership/role, and responds before the global JSON parser or body-derived corridor resolver. Manager-member secret credentials continue through full corridor and ownership authorization. A direct child secret credential remains supported without a selector. 25. **Membership persistence MUST preserve ownership and audit history**: every retained relationship has an active owner `manager` membership; new provisioning atomically creates the profile, customer entity, relationship, owner membership, and append-only `member_added` event. The database permits only authenticated members, one active membership per child/member, and valid `manager|read_only` roles; it prevents changing the immutable owner or downgrading/removing the active owner's membership. Membership and invitation mutations write append-only events atomically. Human membership administration is bearer-only and cannot be performed by either a member-owned or child-owned API credential. +Selected-child provider/KYC/KYB mutations retain the shipped bearer-denial code +`MANAGED_PROFILE_REQUIRES_API_CREDENTIAL`. This secret-only `credential_manage` capability +must not be confused with child credential or domestic fiat-account `manage` operations. + ## Threat Vectors & Mitigations | Threat | Mitigation | @@ -110,14 +117,14 @@ Its response is an allowlisted per-corridor projection: | Secret exposed in browser or telemetry | Public capability exists for browser use; secret values are server-only, returned once, and forbidden from logs/events. | | Database read leaks usable secret | Only a high-entropy secret's SHA-256 digest and non-secret lookup prefix are stored. | | Public key escalates to financial access | Route-level capability matrix rejects public keys from sensitive reads and mutations. | -| Caller supplies another manager's child ID | Delegated middleware scopes the active relationship by both authenticated manager and child profile before deriving an effective subject. | +| Caller supplies another owner's child ID | Delegated middleware requires the actor's live membership for that exact child and the immutable owner's active relationship before deriving the subject. | | Public key from one credential is combined with another secret | Resolve both and return `403 CREDENTIAL_MISMATCH` before business logic. | | Concurrent creation exceeds the cap | Lock the profile, count active non-expired credentials, and insert in one transaction. | | Revocation leaves one half active | One row and one `revoked_at` update disable both values. | | Partner deactivation leaves one half active | Public and secret validation both require the credential's partner to be active. | | Legacy or ambiguous rows remain reachable | No legacy runtime lookup; migration 061 rejects active legacy rows and removes the table, and startup requires it to be absent. Production migration uses explicit immutable-ID mappings, never names. | | Shared managed identity crosses customer ownership | Require one genuine managed profile per subject and immutable partner/external-user association. | -| Public eligibility read leaks PII or exact limits | `ramp-info` uses an explicit projection, accepts no body/query subject selector, and permits the managed-child header only with a manager secret. | +| Public eligibility read leaks PII or exact limits | `ramp-info` uses an explicit projection, accepts no body/query subject selector, and permits the managed-child header only with an eligible member's secret. | | Deleted or deactivated child credential remains usable | Both credential halves dynamically require the active relationship and manager; logical deletion also revokes every child credential. | ## Audit Checklist diff --git a/docs/security-spec/01-auth/managed-profile-memberships.md b/docs/security-spec/01-auth/managed-profile-memberships.md index 5ddf86229..726c39553 100644 --- a/docs/security-spec/01-auth/managed-profile-memberships.md +++ b/docs/security-spec/01-auth/managed-profile-memberships.md @@ -41,6 +41,30 @@ Invitee routes are bearer-authenticated and do not accept managed selection: | `GET /v1/managed-profile-member-invitations/:invitationId` | Preview status only after exact verified-email authentication | | `POST /v1/managed-profile-member-invitations/:invitationId/accept` | Explicitly accept and create/reactivate membership | +### Lifecycle Reads + +List and detail responses include `actor: { profileId, canProvisionManagedProfiles, +hasMemberships }` plus child-specific membership and immutable-owner policy. Provisioning +capability reflects the actor's own active manager configuration. `hasMemberships` derives +from the unpaginated eligible **active-child** count across owners, independently of the +requested page, status filter or detail target. Eligibility requires an unrevoked allowed-role +membership, active owner configuration, active child relationship, managed subject, and exactly +one active owned customer entity selected by the child. Deleted children, inactive owners and +invalid entity layouts do not count. + +The default `GET /v1/managed-profiles` returns `200` with an empty list even when both actor +flags are false. Both `status=deleted` and `status=all` require the actor's own active manager +configuration (`403 MANAGED_PROFILE_OWNER_REQUIRED` otherwise) and are owner-scoped only; +even active invited children owned by others are excluded from `all`. Retained results still +require valid membership and entity layout and never contribute to `hasMemberships`. + +`GET /v1/managed-profiles/:profileId` with an exactly matching `X-Managed-Profile-Id` is explicit +bootstrap. Stored membership history, not the selector itself, proves prior access. A retained +deleted child can be read only by its immutable owner with active configuration and valid +membership/entity layout **without a selector**. Invited members and ineligible ordinary +retained reads receive masked `404 MANAGED_PROFILE_NOT_FOUND`. These rules apply equally to +bearer and member-secret callers; direct child credentials cannot use lifecycle routes. + ## Security Invariants 1. **Authorization MUST be live and child-scoped** — every delegated request resolves the @@ -60,13 +84,18 @@ Invitee routes are bearer-authenticated and do not accept managed selection: credential. 6. **Lifecycle MUST remain owner-only** — membership does not authorize child provisioning, sibling creation, child deletion, owner-policy changes, pricing administration, or global - profile-role changes. + profile-role changes. A non-owner with an active `manager` or `read_only` membership receives + `403 MANAGED_PROFILE_OWNER_REQUIRED` on child deletion; non-owner outsiders receive masked + `404`. Retained filters and direct retained reads MUST obey the owner-only rules above. 7. **Owner policy MUST govern every member** — corridor and customer-type authorization, pricing fallback, provider-contact namespace, and direct-child credential policy resolve from the immutable owner, never the acting member's personal manager configuration. 8. **Selected-child provider and ramp mutations MUST require a secret** — a Supabase bearer may use allowed read/manage operations but cannot use `credential_manage` or register, - update, or start a child ramp. Ramp denial occurs before the global body parser and uses + update, or start a child ramp. Provider denial retains the shipped code + `MANAGED_PROFILE_REQUIRES_API_CREDENTIAL`; child credential and domestic fiat-account + management are `manage`, not secret-only `credential_manage`. Ramp denial occurs before + the global body parser and uses `MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL`. There is no drain exception. 9. **Child credentials MUST remain independent company principals** — possession grants the credential's currently supported direct-child provider, fiat-account, quote, and ramp @@ -99,10 +128,18 @@ Invitee routes are bearer-authenticated and do not accept managed selection: 17. **Direct database access MUST be denied to clients** — membership, invitation, event, and related sequence objects have RLS enabled with no client policy and explicit `anon`/`authenticated` privilege revocation. -18. **Errors MUST not expand discovery** — generic probing receives - `MANAGED_PROFILE_ACCESS_DENIED`. `MANAGED_PROFILE_MEMBERSHIP_INVALID` is reserved for an - authenticated actor's previously selected child bootstrap and is the only error that may - clear dashboard selection. Role and policy denial leave valid selection intact. +18. **Errors MUST not expand discovery** — detail bootstrap MUST require an exactly matching + selector and stored membership history (active or revoked) before returning + `403 MANAGED_PROFILE_MEMBERSHIP_INVALID` for ineligibility. Revocation, child deletion, + owner deactivation and invalid entity layout invalidate evidenced bootstrap; a deleted + child invalidates even its owner's bootstrap. Never-member callers receive the same masked + `404 MANAGED_PROFILE_NOT_FOUND` for existing and unknown children, with or without a matching + selector. A mismatched selector receives `403 MANAGED_PROFILE_ACCESS_DENIED`. Ordinary + detail reads never return membership-invalid: missing membership is masked `404`; active + members of active children with disabled owners or invalid layouts receive access denial. + Other delegated and membership-administration probes retain their route-specific access + denial. Only membership-invalid may clear dashboard selection in response to an API error; + `404`, role/policy denial and transient failures leave it intact. 19. **Invitations MUST use the durable email queue** — creation writes one direct-recipient outbox row in the invitation transaction. Only the managed-profile invitation type may set `recipient_email`; existing retry, idempotency, non-production allowlist, escaping, @@ -143,5 +180,11 @@ Invitee routes are bearer-authenticated and do not accept managed selection: - [ ] Every state change writes one immutable event with the correct actor and subject. - [ ] Direct-recipient email is accepted only for the membership-invitation type. - [ ] Dashboard clears selection only after bootstrap returns membership-invalid. +- [ ] Actor flags remain independent of pagination/status; empty default lists return `200`, + and deleted/inactive-owner/invalid-entity records do not count toward `hasMemberships`. +- [ ] Both retained list filters are owner-scoped; bearer and member-secret retained reads + require the active immutable owner without a selector. +- [ ] Bootstrap requires matching selector plus membership history; deleted owner bootstrap + invalidates, and never-member existing/unknown probes are indistinguishable `404`s. - [ ] Desktop and mobile tests cover role badges, read-only controls, Team, API keys, and blocked transfer entry points. From ea0471ff2fd4707ed246cda96a1ac0acbf86b025 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 7 Sep 2026 17:11:02 -0300 Subject: [PATCH 04/11] feat(api): queue managed profile invitation emails --- .../src/api/services/email/dispatch.test.ts | 107 +++++++++++++- apps/api/src/api/services/email/index.ts | 1 + ...aged-profile-membership-invitation.test.ts | 120 ++++++++++++++++ .../managed-profile-membership-invitation.ts | 39 ++++++ .../services/email/notification.service.ts | 72 +++++++--- .../src/api/services/email/templates/index.ts | 6 + .../managed-profile-membership-invitation.ts | 18 +++ apps/api/src/config/vars.test.ts | 40 ++++++ apps/api/src/config/vars.ts | 24 ++++ ...2-create-email-notifications-table.test.ts | 2 + ...ail-notification-direct-recipients.test.ts | 130 ++++++++++++++++++ ...70-email-notification-direct-recipients.ts | 34 +++++ .../api/src/models/emailNotification.model.ts | 27 ++-- docs/architecture-email-notifications.md | 50 ++++++- docs/security-spec/05-integrations/resend.md | 11 +- 15 files changed, 646 insertions(+), 35 deletions(-) create mode 100644 apps/api/src/api/services/email/managed-profile-membership-invitation.test.ts create mode 100644 apps/api/src/api/services/email/managed-profile-membership-invitation.ts create mode 100644 apps/api/src/api/services/email/templates/managed-profile-membership-invitation.ts create mode 100644 apps/api/src/database/070-email-notification-direct-recipients.test.ts create mode 100644 apps/api/src/database/migrations/070-email-notification-direct-recipients.ts diff --git a/apps/api/src/api/services/email/dispatch.test.ts b/apps/api/src/api/services/email/dispatch.test.ts index 4e6f62ced..bb64d270e 100644 --- a/apps/api/src/api/services/email/dispatch.test.ts +++ b/apps/api/src/api/services/email/dispatch.test.ts @@ -1,6 +1,7 @@ -import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; import { Op } from "sequelize"; import sequelize from "../../../config/database"; +import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import EmailNotification, { NotificationProvider, @@ -60,7 +61,8 @@ interface FakeRow { type: NotificationType; provider: NotificationProvider; resourceId: string; - userId: string; + userId: string | null; + recipientEmail: string | null; lastError: string | null; nextAttemptAt: Date; updatedAt: Date; @@ -79,6 +81,7 @@ function row(overrides: Partial = {}): FakeRow { lastError: null, nextAttemptAt: HOUR_AGO, provider: NotificationProvider.Vortex, + recipientEmail: null, resourceId: "ramp-1", status: NotificationStatus.Pending, type: NotificationType.RampCompleted, @@ -196,6 +199,106 @@ describe("dispatchPendingNotifications", () => { }); }); +describe("direct invitation recipients", () => { + const invitation = (overrides: Partial = {}) => + row({ + recipientEmail: RECIPIENT, + resourceId: "private-invitation-uuid", + type: NotificationType.ManagedProfileMembershipInvitation, + userId: null, + ...overrides + }); + + it("sends to the direct recipient without profile or preference lookups", async () => { + preferences = { emailEnabled: false, prefs: {} }; + const userLookup = spyOn(User, "findByPk"); + const preferenceLookup = spyOn(NotificationPreference, "findOne"); + try { + const pending = invitation(); + await dispatchPendingNotifications(); + expect(pending.status).toBe(NotificationStatus.Sent); + expect(sends[0].to).toBe(RECIPIENT); + expect(sends[0].idempotencyKey).toBe(pending.id); + expect(userLookup).not.toHaveBeenCalled(); + expect(preferenceLookup).not.toHaveBeenCalled(); + } finally { + userLookup.mockRestore(); + preferenceLookup.mockRestore(); + } + }); + + it.each([{ allowlist: [] }, { allowlist: ["someone-else@example.com"] }])( + "still applies the non-production allowlist: %j", + async ({ allowlist }) => { + config.integrations.resend.recipientAllowlist = [...allowlist]; + const pending = invitation(); + await dispatchPendingNotifications(); + expect(pending.status).toBe(NotificationStatus.Skipped); + expect(sends).toHaveLength(0); + } + ); + + it.each([ + { type: NotificationType.RampCompleted }, + { userId: "user-1" }, + { provider: NotificationProvider.Avenia }, + { recipientEmail: null } + ])("fails closed on an invalid recipient source: %j", async overrides => { + const pending = invitation(overrides); + await dispatchPendingNotifications(); + expect(pending.status).toBe(NotificationStatus.Failed); + expect(sends).toHaveLength(0); + }); + + it("leaves invitations queued when Resend is not configured", async () => { + config.integrations.resend.apiKey = undefined; + const pending = invitation(); + await dispatchPendingNotifications(); + expect(pending.status).toBe(NotificationStatus.Pending); + expect(pending.attempts).toBe(0); + }); + + it("retries using the same idempotency key without storing sensitive provider errors", async () => { + sendFailure = new Error(`${RECIPIENT} https://dashboard.example.com/member-invitations/private-invitation-uuid`); + const pending = invitation(); + await dispatchPendingNotifications(); + expect(pending.status).toBe(NotificationStatus.Failed); + expect(pending.lastError).toBe("Invitation email delivery failed"); + expect(pending.nextAttemptAt.getTime()).toBeGreaterThan(Date.now()); + pending.nextAttemptAt = HOUR_AGO; + sendFailure = null; + await dispatchPendingNotifications(); + expect(pending.status).toBe(NotificationStatus.Sent); + expect(sends[0].idempotencyKey).toBe(pending.id); + }); + + it.each([false, true])("alerts on exhaustion without invitation identity (stale claim: %s)", async stale => { + sendFailure = new Error(`${RECIPIENT} private-invitation-uuid secret-token https://dashboard.example.com`); + const logs: unknown[] = []; + const errorLog = spyOn(logger, "error").mockImplementation((...args: unknown[]) => { + logs.push(args); + return logger; + }); + try { + const pending = invitation({ + attempts: stale ? 6 : 5, + status: stale ? NotificationStatus.Sending : NotificationStatus.Pending + }); + await dispatchPendingNotifications(); + expect(pending.status).toBe(NotificationStatus.Abandoned); + expect(slackAlerts).toHaveLength(1); + expect(slackAlerts[0]).toContain("after 6 attempts"); + const telemetry = JSON.stringify({ logs, slackAlerts }); + for (const sensitive of [RECIPIENT, pending.resourceId, "secret-token", "https://dashboard.example.com"]) { + expect(telemetry).not.toContain(sensitive); + expect(pending.lastError).not.toContain(sensitive); + } + } finally { + errorLog.mockRestore(); + } + }); +}); + describe("recipient preferences", () => { it("skips a recipient who has disabled email entirely", async () => { preferences = { emailEnabled: false, prefs: {} }; diff --git a/apps/api/src/api/services/email/index.ts b/apps/api/src/api/services/email/index.ts index b708e2d73..e1a4bca65 100644 --- a/apps/api/src/api/services/email/index.ts +++ b/apps/api/src/api/services/email/index.ts @@ -1,2 +1,3 @@ +export { enqueueManagedProfileInvitation } from "./managed-profile-membership-invitation"; export { dispatchPendingNotifications, enqueueNotification } from "./notification.service"; export { enqueueRampCompletedEmail, reconcileMissedRampCompletedEmails } from "./ramp-completion"; diff --git a/apps/api/src/api/services/email/managed-profile-membership-invitation.test.ts b/apps/api/src/api/services/email/managed-profile-membership-invitation.test.ts new file mode 100644 index 000000000..581b0cc6d --- /dev/null +++ b/apps/api/src/api/services/email/managed-profile-membership-invitation.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import type { Transaction } from "sequelize"; +import { config } from "../../../config/vars"; +import EmailNotification, { NotificationProvider, NotificationType } from "../../../models/emailNotification.model"; +import { SupabaseAuthService } from "../auth"; +import { enqueueManagedProfileInvitation } from "./managed-profile-membership-invitation"; +import { enqueueNotification } from "./notification.service"; +import { renderNotification } from "./templates"; +import { renderManagedProfileInvitation } from "./templates/managed-profile-membership-invitation"; + +const invitationId = "a48e12c3-4398-4e4b-9846-48a083ed7b41"; +const recipientEmail = "invitee@example.com"; +const transaction = {} as Transaction; +const originalUrl = config.dashboardPublicUrl; + +afterEach(() => { + config.dashboardPublicUrl = originalUrl; +}); + +describe("managed-profile invitation email", () => { + it("queues only minimal facts using the supplied transaction, without resolving a profile", async () => { + config.dashboardPublicUrl = "https://dashboard.example.com"; + const write = spyOn(EmailNotification, "findOrCreate").mockResolvedValue([{} as EmailNotification, true]); + const locale = spyOn(SupabaseAuthService, "getUserLocale"); + try { + await enqueueManagedProfileInvitation({ invitationId, recipientEmail: " INVITEE@example.com " }, transaction); + const key = { + provider: NotificationProvider.Vortex, + resourceId: invitationId, + type: NotificationType.ManagedProfileMembershipInvitation + }; + expect(write).toHaveBeenCalledWith({ + defaults: { + ...key, + locale: "en-US", + payload: { invitationUrl: `https://dashboard.example.com/member-invitations/${invitationId}` }, + recipientEmail, + userId: null + }, + logging: false, + transaction, + where: key + }); + expect(locale).not.toHaveBeenCalled(); + } finally { + write.mockRestore(); + locale.mockRestore(); + } + }); + + it("fails closed without a transaction or configured origin", async () => { + config.dashboardPublicUrl = undefined; + await expect(enqueueManagedProfileInvitation({ invitationId, recipientEmail }, transaction)).rejects.toThrow( + "DASHBOARD_PUBLIC_URL is required" + ); + await expect( + enqueueManagedProfileInvitation({ invitationId, recipientEmail }, undefined as unknown as Transaction) + ).rejects.toThrow("requires the invitation transaction"); + }); + + it.each([ + { invitationId: "../../redirect", recipientEmail }, + { invitationId, recipientEmail: "invitee@example.com\r\nBcc: attacker@example.com" }, + { invitationId, recipientEmail: "not-an-email" } + ])("rejects invalid input without exposing it in errors", async input => { + await expect(enqueueManagedProfileInvitation(input, transaction)).rejects.toThrow("Invalid invitation email input"); + }); + + it("sanitizes database failures while propagating failure to the transaction owner", async () => { + config.dashboardPublicUrl = "https://dashboard.example.com"; + const write = spyOn(EmailNotification, "findOrCreate").mockRejectedValue(new Error(`${recipientEmail} ${invitationId}`)); + try { + await expect(enqueueManagedProfileInvitation({ invitationId, recipientEmail }, transaction)).rejects.toThrow( + "Could not enqueue managed-profile invitation email" + ); + } finally { + write.mockRestore(); + } + }); + + it("does not allow the generic profile producer to create invitations", async () => { + await expect( + enqueueNotification({ + payload: {}, + provider: NotificationProvider.Vortex, + resourceId: invitationId, + type: NotificationType.ManagedProfileMembershipInvitation, + userId: "profile-id" + }) + ).rejects.toThrow("requires its dedicated producer"); + }); + + it("renders the invitation link in HTML and text with seven-day, explicit-acceptance copy", () => { + const invitationUrl = `https://dashboard.example.com/member-invitations/${invitationId}`; + const rendered = renderNotification(EmailNotification.build({ + locale: "en-US", + payload: { childId: "must-not-appear", invitationUrl, role: "read_only" }, + provider: NotificationProvider.Vortex, + recipientEmail, + resourceId: invitationId, + type: NotificationType.ManagedProfileMembershipInvitation, + userId: null + })); + for (const body of [rendered.html, rendered.text]) { + expect(body).toContain(invitationUrl); + expect(body).toContain("Seven days after the invitation was created"); + expect(body).toContain("explicitly accept"); + expect(body).not.toContain("must-not-appear"); + expect(body).not.toContain("read_only"); + expect(body).not.toContain(recipientEmail); + } + expect(rendered.subject).not.toContain(invitationId); + }); + + it("escapes the link attribute rather than interpolating HTML", () => { + const rendered = renderManagedProfileInvitation('https://dashboard.example.com/"&'); + expect(rendered.html).toContain(""<img>&"); + expect(rendered.html).not.toContain('href="https://dashboard.example.com/"'); + }); +}); diff --git a/apps/api/src/api/services/email/managed-profile-membership-invitation.ts b/apps/api/src/api/services/email/managed-profile-membership-invitation.ts new file mode 100644 index 000000000..1b0dcc831 --- /dev/null +++ b/apps/api/src/api/services/email/managed-profile-membership-invitation.ts @@ -0,0 +1,39 @@ +import type { Transaction } from "sequelize"; +import { z } from "zod"; +import { config } from "../../../config/vars"; +import EmailNotification, { NotificationProvider, NotificationType } from "../../../models/emailNotification.model"; + +/** Await inside the invitation-creation transaction; failures must roll back the invitation and its event. */ +export async function enqueueManagedProfileInvitation( + { invitationId, recipientEmail }: { invitationId: string; recipientEmail: string }, + transaction: Transaction +): Promise { + if (!transaction) throw new Error("Invitation email requires the invitation transaction"); + const id = z.string().toLowerCase().uuid().safeParse(invitationId); + const email = z.string().trim().toLowerCase().max(254).email().safeParse(recipientEmail); + if (!id.success || !email.success) throw new Error("Invalid invitation email input"); + if (!config.dashboardPublicUrl) throw new Error("DASHBOARD_PUBLIC_URL is required for invitation email"); + + const key = { + provider: NotificationProvider.Vortex, + resourceId: id.data, + type: NotificationType.ManagedProfileMembershipInvitation + }; + try { + await EmailNotification.findOrCreate({ + defaults: { + ...key, + locale: "en-US", + payload: { invitationUrl: `${config.dashboardPublicUrl}/member-invitations/${id.data}` }, + recipientEmail: email.data, + userId: null + }, + logging: false, + transaction, + where: key + }); + } catch { + // Sequelize errors can contain SQL, the target email, and the invitation link. + throw new Error("Could not enqueue managed-profile invitation email"); + } +} diff --git a/apps/api/src/api/services/email/notification.service.ts b/apps/api/src/api/services/email/notification.service.ts index 914aff889..c408d1b5e 100644 --- a/apps/api/src/api/services/email/notification.service.ts +++ b/apps/api/src/api/services/email/notification.service.ts @@ -2,7 +2,12 @@ import { literal, Op, type Transaction } from "sequelize"; import sequelize from "../../../config/database"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; -import EmailNotification, { NotificationKey, NotificationStatus } from "../../../models/emailNotification.model"; +import EmailNotification, { + NotificationKey, + NotificationProvider, + NotificationStatus, + NotificationType +} from "../../../models/emailNotification.model"; import NotificationPreference from "../../../models/notificationPreference.model"; import User from "../../../models/user.model"; import { SupabaseAuthService } from "../auth"; @@ -10,6 +15,8 @@ import { SlackNotifier } from "../slack.service"; import { EmailNotConfiguredError, sendEmail } from "./resend.transport"; import { renderNotification } from "./templates"; +export { enqueueManagedProfileInvitation } from "./managed-profile-membership-invitation"; + const BACKOFF_MINUTES = [1, 5, 15, 60, 180]; // One initial send plus one retry per backoff step. Deriving it keeps the last step // reachable: a flat 5 abandoned the row on the attempt that should have waited 180 minutes. @@ -24,6 +31,7 @@ interface EnqueueParams extends NotificationKey { } function describeKey({ provider, type, resourceId }: NotificationKey): string { + if (type === NotificationType.ManagedProfileMembershipInvitation) return "managed-profile invitation notification"; return `${provider}/${type} notification for resource ${resourceId}`; } @@ -32,9 +40,13 @@ function describeKey({ provider, type, resourceId }: NotificationKey): string { * enqueuing the same event twice is a no-op, so callers can fire without guarding. */ export async function enqueueNotification( - { userId, payload, ...key }: EnqueueParams, + { userId, payload, provider, type, resourceId }: EnqueueParams, transaction?: Transaction ): Promise { + if (type === NotificationType.ManagedProfileMembershipInvitation) { + throw new Error("Invitation email requires its dedicated producer"); + } + const key = { provider, resourceId, type }; // Duplicates are the common case (webhook replays, re-polled attempts), so check the // key before resolving the locale — that resolution is a Supabase admin API call. if (await EmailNotification.findOne({ transaction, where: { ...key } })) { @@ -59,7 +71,15 @@ export async function enqueueNotification( * that re-enqueue anything without a row (reconciliation) stop re-surfacing the resource; * it is never due for dispatch. Idempotent on the key, like enqueueNotification. */ -export async function recordSkippedNotification(key: NotificationKey, userId: string, reason: string): Promise { +export async function recordSkippedNotification( + { provider, type, resourceId }: NotificationKey, + userId: string, + reason: string +): Promise { + if (type === NotificationType.ManagedProfileMembershipInvitation) { + throw new Error("Invitation email requires its dedicated producer"); + } + const key = { provider, resourceId, type }; const [, created] = await EmailNotification.findOrCreate({ defaults: { ...key, lastError: reason, locale: "en-US", status: NotificationStatus.Skipped, userId }, where: { ...key } @@ -87,7 +107,9 @@ async function alertAbandoned(notification: EmailNotification, reason: string | text: `Abandoned ${describeKey(notification)} after ${notification.attempts} attempts: ${reason}` }); } catch (error) { - logger.error(`Failed to send Slack alert for abandoned notification ${notification.id}: ${error}`); + const message = + notification.type === NotificationType.ManagedProfileMembershipInvitation ? "Alert delivery failed" : String(error); + logger.error(`Failed to send Slack alert for abandoned notification ${notification.id}: ${message}`); } } @@ -143,6 +165,7 @@ async function claimDueNotifications(): Promise { * the queue is still honoured. */ async function emailIsAllowed(notification: EmailNotification): Promise { + if (!notification.userId) throw new Error("Profile notification requires a user ID"); const preferences = await NotificationPreference.findOne({ where: { profileId: notification.userId } }); if (!preferences) { @@ -153,18 +176,30 @@ async function emailIsAllowed(notification: EmailNotification): Promise } async function deliver(notification: EmailNotification): Promise { - if (!(await emailIsAllowed(notification))) { - logger.info(`Skipping notification ${notification.id}: the recipient has disabled email for this notification`); - await notification.update({ - lastError: "Recipient has disabled email notifications", - status: NotificationStatus.Skipped - }); - return; + const invitation = notification.type === NotificationType.ManagedProfileMembershipInvitation; + const directRecipient = notification.recipientEmail != null; + if ( + (directRecipient && + (notification.userId != null || !invitation || notification.provider !== NotificationProvider.Vortex)) || + (!directRecipient && (notification.userId == null || invitation)) + ) { + throw new Error("Invalid notification recipient source"); } - const user = await User.findByPk(notification.userId); + let recipientEmail = notification.recipientEmail; + if (notification.userId != null) { + if (!(await emailIsAllowed(notification))) { + logger.info(`Skipping notification ${notification.id}: the recipient has disabled email for this notification`); + await notification.update({ + lastError: "Recipient has disabled email notifications", + status: NotificationStatus.Skipped + }); + return; + } + recipientEmail = (await User.findByPk(notification.userId))?.email ?? null; + } - if (!user?.email) { + if (!recipientEmail) { await notification.update({ lastError: "No email address on the recipient profile", status: NotificationStatus.Skipped @@ -175,7 +210,7 @@ async function deliver(notification: EmailNotification): Promise { const { deploymentEnv } = config; const { recipientAllowlist } = config.integrations.resend; - if (deploymentEnv !== "production" && !recipientAllowlist.includes(user.email.toLowerCase())) { + if (deploymentEnv !== "production" && !recipientAllowlist.includes(recipientEmail.toLowerCase())) { logger.info(`Skipping notification ${notification.id}: ${deploymentEnv} allowlist does not include the recipient`); await notification.update({ lastError: `Recipient not in EMAIL_RECIPIENT_ALLOWLIST (${deploymentEnv})`, @@ -188,7 +223,7 @@ async function deliver(notification: EmailNotification): Promise { // The row id is the idempotency key: a crash after Resend accepts but before `sent` is // persisted leaves the row to be reclaimed, and the retry must collapse into the original // send rather than mail the user twice. - const messageId = await sendEmail({ ...rendered, idempotencyKey: notification.id, to: user.email }); + const messageId = await sendEmail({ ...rendered, idempotencyKey: notification.id, to: recipientEmail }); await notification.update({ lastError: null, @@ -199,7 +234,12 @@ async function deliver(notification: EmailNotification): Promise { } async function handleDeliveryFailure(notification: EmailNotification, error: unknown): Promise { - const message = error instanceof Error ? error.message : String(error); + const message = + notification.type === NotificationType.ManagedProfileMembershipInvitation || notification.recipientEmail != null + ? "Invitation email delivery failed" + : error instanceof Error + ? error.message + : String(error); const retryAt = nextRetryAt(notification.attempts); await notification.update({ diff --git a/apps/api/src/api/services/email/templates/index.ts b/apps/api/src/api/services/email/templates/index.ts index a590fa3b8..acae20e49 100644 --- a/apps/api/src/api/services/email/templates/index.ts +++ b/apps/api/src/api/services/email/templates/index.ts @@ -7,6 +7,7 @@ import { VerificationKind, VerificationPayload } from "../types"; +import { renderManagedProfileInvitation } from "./managed-profile-membership-invitation"; import { renderRampCompleted } from "./ramp-completed"; import { renderVerificationStatus } from "./verification-status"; @@ -19,6 +20,11 @@ const VERIFICATION_KINDS: Partial> = export function renderNotification(notification: EmailNotification): RenderedEmail { const locale: EmailLocale = toEmailLocale(notification.locale); + if (notification.type === NotificationType.ManagedProfileMembershipInvitation) { + if (typeof notification.payload.invitationUrl !== "string") throw new Error("Invalid invitation email payload"); + return renderManagedProfileInvitation(notification.payload.invitationUrl); + } + if (notification.type === NotificationType.RampCompleted) { return renderRampCompleted(locale, notification.payload as unknown as RampCompletedPayload); } diff --git a/apps/api/src/api/services/email/templates/managed-profile-membership-invitation.ts b/apps/api/src/api/services/email/templates/managed-profile-membership-invitation.ts new file mode 100644 index 000000000..7c26df902 --- /dev/null +++ b/apps/api/src/api/services/email/templates/managed-profile-membership-invitation.ts @@ -0,0 +1,18 @@ +import type { RenderedEmail } from "../types"; +import { type EmailBody, renderHtml, renderText } from "./layout"; + +export function renderManagedProfileInvitation(invitationUrl: string): RenderedEmail { + const body: EmailBody = { + details: [{ label: "Expires", value: "Seven days after the invitation was created" }], + heading: "You have been invited to a managed profile", + intro: + "Sign in to Vortex with the email address that received this invitation to review it. Access is granted only after you explicitly accept.", + links: [{ href: invitationUrl, label: "Review invitation" }], + outro: "If you were not expecting this invitation, you can ignore this email." + }; + return { + html: renderHtml(body), + subject: "Your Vortex managed-profile invitation", + text: `${renderText(body)}\nReview invitation: ${invitationUrl}\n` + }; +} diff --git a/apps/api/src/config/vars.test.ts b/apps/api/src/config/vars.test.ts index 2d42c2957..3519cf259 100644 --- a/apps/api/src/config/vars.test.ts +++ b/apps/api/src/config/vars.test.ts @@ -6,6 +6,7 @@ const bunExecutable = Bun.argv[0]; const requiredProductionEnv = { ADMIN_SECRET: "test-admin-secret", + DASHBOARD_PUBLIC_URL: "https://dashboard.example.com", FLOW_VARIANT: "monerium", METRICS_DASHBOARD_SECRET: "test-metrics-dashboard-secret", MONERIUM_CLIENT_ID: "test-monerium-client-id", @@ -45,6 +46,45 @@ async function importVarsWithEnv(env: Record) { } describe("vars deployment environment validation", () => { + it("requires the dashboard origin in production, but permits it to be absent in tests", async () => { + const production = await importVarsWithEnv({ DASHBOARD_PUBLIC_URL: "", NODE_ENV: "production" }); + expect(production.exitCode).toBe(1); + expect(production.stderr).toContain("DASHBOARD_PUBLIC_URL"); + const test = await importVarsWithEnv({ DASHBOARD_PUBLIC_URL: "", NODE_ENV: "test" }); + expect(test.exitCode).toBe(0); + }); + + it.each([ + "not-a-url", + "//dashboard.example.com", + "javascript:alert(1)", + "http://dashboard.example.com", + "https://user:secret@dashboard.example.com", + "https://dashboard.example.com/path", + "https://dashboard.example.com/?redirect=evil", + "https://dashboard.example.com/#fragment", + "https://dashboard.example.com/?", + "http://localhost:5174" + ])("rejects an unsafe production dashboard origin: %s", async value => { + const result = await importVarsWithEnv({ DASHBOARD_PUBLIC_URL: value, NODE_ENV: "production" }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("DASHBOARD_PUBLIC_URL must be an HTTPS origin"); + expect(result.stderr).not.toContain(value); + }); + + it.each(["http://localhost:5174", "http://127.0.0.1:5174", "http://[::1]:5174", "https://dashboard.example.com/"])( + "allows a trusted dashboard origin in tests: %s", + async value => { + const result = await importVarsWithEnv({ DASHBOARD_PUBLIC_URL: value, NODE_ENV: "test" }); + expect(result.exitCode).toBe(0); + } + ); + + it("does not allow remote HTTP even in tests", async () => { + const result = await importVarsWithEnv({ DASHBOARD_PUBLIC_URL: "http://evil.example.com", NODE_ENV: "test" }); + expect(result.exitCode).toBe(1); + }); + it("allows sandbox mode with a production runtime when the deployment is explicitly sandbox", async () => { const result = await importVarsWithEnv({ DEPLOYMENT_ENV: "sandbox", diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index c85ca01da..27d5a09cf 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -132,6 +132,27 @@ function readEmailAllowlist(): string[] { .filter(entry => entry.length > 0); } +function readDashboardPublicUrl(): string | undefined { + const raw = process.env.DASHBOARD_PUBLIC_URL?.trim(); + if (!raw) return undefined; + + const invalid = new Error("DASHBOARD_PUBLIC_URL must be an HTTPS origin (HTTP loopback only in development/test)"); + let url: URL; + try { + url = new URL(raw); + } catch { + throw invalid; + } + const localHttp = + url.protocol === "http:" && + ["development", "test"].includes(readDeploymentEnv()) && + ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if ((url.protocol !== "https:" && !localHttp) || url.username || url.password || url.href !== `${url.origin}/`) { + throw invalid; + } + return url.origin; +} + export const RECIPIENT_INVITE_DISCOUNT_HARD_CAP_BPS = 300; function readRecipientInviteDiscountLimit(): number { @@ -146,6 +167,7 @@ function readRecipientInviteDiscountLimit(): number { interface Config { env: string; + dashboardPublicUrl: string | undefined; deploymentEnv: DeploymentEnv; /** Login email of the seeded sales-demo account. Sandbox only; see docs/operations-demo-environment.md. */ demoAccountEmail: string; @@ -269,6 +291,7 @@ export const config: Config = { adminSecret: process.env.ADMIN_SECRET || "", amplitudeWss: process.env.AMPLITUDE_WSS || "wss://rpc-amplitude.pendulumchain.tech", backendTestStarterAccount: process.env.BACKEND_TEST_STARTER_ACCOUNT, + dashboardPublicUrl: readDashboardPublicUrl(), database: { database: process.env.DB_NAME || "vortex", dialect: "postgres", @@ -428,6 +451,7 @@ if (config.env === "production") { if (!config.metricsDashboardSecret) missing.push("METRICS_DASHBOARD_SECRET"); if (!process.env.FLOW_VARIANT) missing.push("FLOW_VARIANT"); if (!config.monerium.clientId) missing.push("MONERIUM_CLIENT_ID"); + if (!config.dashboardPublicUrl) missing.push("DASHBOARD_PUBLIC_URL"); if (!process.env.MONERIUM_REDIRECT_URI) missing.push("MONERIUM_REDIRECT_URI"); if (missing.length > 0) { diff --git a/apps/api/src/database/062-create-email-notifications-table.test.ts b/apps/api/src/database/062-create-email-notifications-table.test.ts index 6ed681f6f..770309f1e 100644 --- a/apps/api/src/database/062-create-email-notifications-table.test.ts +++ b/apps/api/src/database/062-create-email-notifications-table.test.ts @@ -4,6 +4,7 @@ import EmailNotification, { NotificationStatus } from "../models/emailNotificati import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; import { createTestRampState, createTestUser } from "../test-utils/factories"; import { down, up } from "./migrations/062-create-email-notifications-table"; +import { up as extendRecipients } from "./migrations/070-email-notification-direct-recipients"; describe("062-create-email-notifications-table backfill", () => { beforeAll(async () => { @@ -22,6 +23,7 @@ describe("062-create-email-notifications-table backfill", () => { const queryInterface = sequelize.getQueryInterface(); await down(queryInterface); await up(queryInterface); + await extendRecipients(queryInterface); const rows = await EmailNotification.findAll(); expect(rows).toHaveLength(1); diff --git a/apps/api/src/database/070-email-notification-direct-recipients.test.ts b/apps/api/src/database/070-email-notification-direct-recipients.test.ts new file mode 100644 index 000000000..a3ebffabd --- /dev/null +++ b/apps/api/src/database/070-email-notification-direct-recipients.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import type { Transaction } from "sequelize"; +import { enqueueManagedProfileInvitation } from "../api/services/email/managed-profile-membership-invitation"; +import sequelize from "../config/database"; +import { config } from "../config/vars"; +import EmailNotification, { + NotificationProvider, + NotificationStatus, + NotificationType +} from "../models/emailNotification.model"; +import User from "../models/user.model"; +import { setupTestDatabase } from "../test-utils/db"; + +describe("070 email notification recipient constraints", () => { + let transaction: Transaction; + let userId: string; + const originalUrl = config.dashboardPublicUrl; + + beforeAll(setupTestDatabase); + beforeEach(async () => { + transaction = await sequelize.transaction({ logging: false }); + const user = await User.create( + { email: `${crypto.randomUUID()}@example.com`, id: crypto.randomUUID() }, + { logging: false, transaction } + ); + userId = user.id; + config.dashboardPublicUrl = "https://dashboard.example.com"; + }); + afterEach(async () => { + await transaction?.rollback(); + config.dashboardPublicUrl = originalUrl; + }); + + async function insert(overrides: Record = {}) { + // Raw SQL deliberately bypasses model validation to prove the database boundary. + return sequelize.query( + `INSERT INTO email_notifications + (id, provider, type, user_id, recipient_email, resource_id, locale, payload, status, + attempts, next_attempt_at, created_at, updated_at) + VALUES (:id, :provider, :type, :userId, :recipientEmail, :resourceId, 'en-US', '{}', 'pending', 0, NOW(), NOW(), NOW())`, + { + logging: false, + replacements: { + id: crypto.randomUUID(), + provider: "vortex", + recipientEmail: "invitee@example.com", + resourceId: crypto.randomUUID(), + type: "managed_profile_membership_invitation", + userId: null, + ...overrides + }, + transaction + } + ); + } + + it("retains profile-addressed notifications and permits direct invitations without a profile", async () => { + await insert({ recipientEmail: null, type: "ramp_completed", userId }); + await insert(); + expect(await EmailNotification.count({ transaction, where: { userId } })).toBe(1); + }); + + it.each([ + { recipientEmail: null }, + { recipientEmail: null, type: "ramp_completed" }, + { type: "ramp_completed" }, + { type: "verification_approved" }, + { type: "unknown_type" }, + { provider: "avenia" }, + { recipientEmail: "" }, + { recipientEmail: "INVITEE@example.com" }, + { recipientEmail: " invitee@example.com " }, + { recipientEmail: "invitee@example.com\nBcc:attacker@example.com" } + ])("rejects an invalid recipient/type combination: %j", async overrides => { + await expect(insert(overrides)).rejects.toMatchObject({ + original: { code: "23514", constraint: "email_notifications_recipient_source_check" } + }); + }); + + it("rejects both recipient sources", async () => { + await expect(insert({ userId })).rejects.toMatchObject({ original: { code: "23514" } }); + }); + + it("does not let invitations fall back to profile addressing", async () => { + await expect(insert({ recipientEmail: null, userId })).rejects.toMatchObject({ original: { code: "23514" } }); + }); + + it("preserves the profile foreign key", async () => { + await expect(insert({ recipientEmail: null, type: "ramp_completed", userId: crypto.randomUUID() })).rejects.toMatchObject({ + original: { code: "23503" } + }); + }); + + it("preserves uniqueness on the invitation UUID resource key", async () => { + const resourceId = crypto.randomUUID(); + await insert({ resourceId }); + await expect(insert({ resourceId })).rejects.toMatchObject({ original: { code: "23505" } }); + }); + + it("enqueues idempotently inside the caller transaction without resetting sent rows or retargeting", async () => { + const invitationId = crypto.randomUUID(); + await enqueueManagedProfileInvitation({ invitationId, recipientEmail: " INVITEE@example.com " }, transaction); + const where = { resourceId: invitationId, type: NotificationType.ManagedProfileMembershipInvitation }; + const queued = await EmailNotification.findOne({ transaction, where }); + expect(queued?.userId).toBeNull(); + expect(queued?.recipientEmail).toBe("invitee@example.com"); + expect(queued?.provider).toBe(NotificationProvider.Vortex); + expect(await EmailNotification.count({ where })).toBe(0); + await queued!.update({ status: NotificationStatus.Sent }, { transaction }); + await enqueueManagedProfileInvitation( + { invitationId: invitationId.toUpperCase(), recipientEmail: "different@example.com" }, + transaction + ); + expect(await EmailNotification.count({ transaction, where })).toBe(1); + await queued!.reload({ transaction }); + expect(queued?.status).toBe(NotificationStatus.Sent); + expect(queued?.recipientEmail).toBe("invitee@example.com"); + }); + + it("rolls back the outbox row with the invitation owner's transaction", async () => { + const invitationId = crypto.randomUUID(); + await expect( + sequelize.transaction(async callerTransaction => { + await enqueueManagedProfileInvitation({ invitationId, recipientEmail: "invitee@example.com" }, callerTransaction); + throw new Error("invitation event write failed"); + }) + ).rejects.toThrow("invitation event write failed"); + expect(await EmailNotification.count({ where: { resourceId: invitationId } })).toBe(0); + }); +}); diff --git a/apps/api/src/database/migrations/070-email-notification-direct-recipients.ts b/apps/api/src/database/migrations/070-email-notification-direct-recipients.ts new file mode 100644 index 000000000..79254b351 --- /dev/null +++ b/apps/api/src/database/migrations/070-email-notification-direct-recipients.ts @@ -0,0 +1,34 @@ +import type { QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.transaction(async transaction => { + await queryInterface.sequelize.query( + `ALTER TABLE email_notifications + ALTER COLUMN user_id DROP NOT NULL, + ADD COLUMN recipient_email VARCHAR(254), + ADD CONSTRAINT email_notifications_recipient_source_check CHECK ( + (user_id IS NOT NULL AND recipient_email IS NULL + AND type <> 'managed_profile_membership_invitation') + OR + (user_id IS NULL AND recipient_email IS NOT NULL + AND type = 'managed_profile_membership_invitation' AND provider = 'vortex' + AND recipient_email = lower(btrim(recipient_email)) + AND recipient_email ~ '^[^[:space:]@]+@[^[:space:]@]+[.][^[:space:]@]+$') + )`, + { transaction } + ); + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + // Refuse rollback while direct-recipient rows exist rather than discard the outbox/audit trail. + await queryInterface.sequelize.transaction(async transaction => { + await queryInterface.sequelize.query( + `ALTER TABLE email_notifications + ALTER COLUMN user_id SET NOT NULL, + DROP CONSTRAINT email_notifications_recipient_source_check, + DROP COLUMN recipient_email`, + { transaction } + ); + }); +} diff --git a/apps/api/src/models/emailNotification.model.ts b/apps/api/src/models/emailNotification.model.ts index 783532e09..795aa8317 100644 --- a/apps/api/src/models/emailNotification.model.ts +++ b/apps/api/src/models/emailNotification.model.ts @@ -10,11 +10,13 @@ export enum NotificationProvider { Vortex = "vortex" } -// The stored type values live in @vortexfi/shared: they are the wire contract with the -// dashboard's notification-preference toggles, which write prefs keyed by these strings. -// Re-exported under the model's historical name for the API's existing imports. -export { EmailNotificationType as NotificationType }; -type NotificationType = EmailNotificationType; +// Profile notification types are shared with the dashboard's preference toggles. +// Invitations are server-only account-access mail, not a preference-controlled type. +export const NotificationType = { + ...EmailNotificationType, + ManagedProfileMembershipInvitation: "managed_profile_membership_invitation" +} as const; +export type NotificationType = (typeof NotificationType)[keyof typeof NotificationType]; export enum NotificationStatus { Abandoned = "abandoned", @@ -39,7 +41,8 @@ export interface EmailNotificationAttributes { id: string; provider: NotificationProvider; type: NotificationType; - userId: string; + userId: string | null; + recipientEmail: string | null; resourceId: string; locale: string; payload: Record; @@ -65,6 +68,7 @@ export type EmailNotificationCreationAttributes = Optional< | "sentAt" | "providerMessageId" | "lastError" + | "recipientEmail" >; class EmailNotification @@ -77,7 +81,9 @@ class EmailNotification declare type: NotificationType; - declare userId: string; + declare userId: string | null; + + declare recipientEmail: string | null; declare resourceId: string; @@ -149,6 +155,11 @@ EmailNotification.init( field: "provider_message_id", type: DataTypes.STRING(255) }, + recipientEmail: { + allowNull: true, + field: "recipient_email", + type: DataTypes.STRING(254) + }, resourceId: { allowNull: false, field: "resource_id", @@ -175,7 +186,7 @@ EmailNotification.init( type: DataTypes.DATE }, userId: { - allowNull: false, + allowNull: true, field: "user_id", onDelete: "CASCADE", onUpdate: "CASCADE", diff --git a/docs/architecture-email-notifications.md b/docs/architecture-email-notifications.md index cedc6eaf1..f834fe1b7 100644 --- a/docs/architecture-email-notifications.md +++ b/docs/architecture-email-notifications.md @@ -121,6 +121,15 @@ before that address has a Vortex profile. `DASHBOARD_PUBLIC_URL` supplies the tr origin; request headers and body fields never choose it. Replaying the same pending invite uses the invitation ID dedupe key and does not send twice. +The email module exports +`enqueueManagedProfileInvitation({ invitationId, recipientEmail }, transaction): Promise` +from `services/email` and `services/email/notification.service`. The transaction is mandatory: +await this call inside invitation creation and propagate failure so the invitation, event, and +outbox commit or roll back together. The helper normalizes the email without looking up a +profile, snapshots only `invitationUrl` in the payload, and uses English generic copy with no +role, child ID, or inviter identity. The link is `/member-invitations/:invitationId`; its seven-day +lifetime is enforced by invitation acceptance, not restarted by email retries or delayed delivery. + **Ramp completion** — `enqueueRampCompletedEmail()` in `apps/api/src/api/services/email/ramp-completion.ts`, called from the terminal `complete` branch of `apps/api/src/api/services/phases/phase-processor.ts`. @@ -309,20 +318,28 @@ skipped in the loop, so they never spend provider calls. The table _is_ the design. It is simultaneously the queue, the retry ledger, the audit trail, and the idempotency key. -Migration `062-create-email-notifications-table.ts`, extended by migration 069 for +Migration `062-create-email-notifications-table.ts`, extended by migration 070 for membership invitations; model `apps/api/src/models/emailNotification.model.ts`. +Migration 069 owns membership storage only. Migration +`070-email-notification-direct-recipients.ts` makes `user_id` nullable and adds `recipient_email`. +Its check constraint requires exactly one recipient source and reserves direct email for +`vortex/managed_profile_membership_invitation`; that type cannot address a profile instead. +Direct addresses must be normalized, nonempty email addresses. The profile foreign key and +existing unique key remain intact. Rollback refuses to discard direct-recipient rows: operators +must resolve their retention before restoring the old non-null profile schema. + | Column | Purpose | | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `provider` / `type` / `resource_id` | unique together — the idempotency key. All three `NOT NULL` because Postgres treats NULLs as distinct and would let duplicates through | | `user_id` / `recipient_email` | Exactly one is set. Existing types resolve a profile recipient; only managed-profile invitations may store a normalized direct recipient | -| `locale` | resolved at enqueue from Supabase `user_metadata.locale` | +| `locale` | Profile mail resolves Supabase `user_metadata.locale`; direct invitations use `en-US` without identity lookup | | `payload` | JSONB snapshot of the facts at enqueue time | | `status` | see the lifecycle below | | `attempts` | incremented **at claim time**, not after success | | `next_attempt_at` | backoff schedule; also the dispatch ordering key | | `sent_at`, `provider_message_id` | proof of delivery | -| `last_error` | truncated to 2000 chars, never contains the API key | +| `last_error` | Profile failures truncated to 2000 chars; invitation failures use fixed text, never recipient/URL/provider error payload | Indexes: unique `uniq_email_notifications_provider_type_resource`, plus `idx_email_notifications_dispatch` and `idx_email_notifications_user_id`. @@ -483,9 +500,34 @@ exception. | `EMAIL_FROM_ADDRESS` | Defaults to `Vortex Finance ` | | `EMAIL_REPLY_TO_ADDRESS` | Optional | | `EMAIL_RECIPIENT_ALLOWLIST` | Comma-separated. Enforced whenever `DEPLOYMENT_ENV !== "production"`. **Empty = nothing is ever sent outside production** | -| `DASHBOARD_PUBLIC_URL` | Trusted absolute dashboard origin used to build membership invitation links | +| `DASHBOARD_PUBLIC_URL` | Required whenever `NODE_ENV=production` (also the runtime default when unset), including staging/sandbox deployments using that runtime. HTTPS origin only; HTTP loopback permitted only for effective `DEPLOYMENT_ENV=development|test`. No credentials, non-root path, query or fragment. No default; when absent in other runtimes, invitation enqueue fails and rolls back the invitation/event/outbox transaction | | `AVENIA_WEBHOOK_URL` | Public https URL of this backend's `/v1/webhooks/avenia`. Read only by `bun register:avenia-webhook`; the receiver itself needs no config | +### Dashboard Origin Deployment + +Set `DASHBOARD_PUBLIC_URL` in the **API runtime environment before deploying** the membership +invitation feature. It is a trusted, non-secret link origin, not a dashboard build variable or +CORS allowlist. Use the dashboard for the same deployment, not the API or widget URL. Example +configuration (replace the documentation domain with the intended deployed dashboard): + +```dotenv +DASHBOARD_PUBLIC_URL=https://dashboard.example.com +``` + +For local development with effective `DEPLOYMENT_ENV=development` or `test`, +`DASHBOARD_PUBLIC_URL=http://localhost:5174` is permitted; `127.0.0.1` and `[::1]` are also accepted +loopback hosts. Do not use HTTP for staging/sandbox deployment environments. A root trailing slash +is accepted and normalized away; do not append `/member-invitations`, credentials, a query or a +fragment. The producer appends `/member-invitations/:invitationId` itself. + +Missing production-runtime configuration prevents startup, even if outbound mail is disabled. +Invalid configured URLs fail configuration loading in every runtime. Absence in a non-production +runtime allows startup but does not allow invitation creation to commit without its email. +`DASHBOARD_ORIGINS`/`DASHBOARD_PREVIEW_SITE` configure CORS separately and never supply this origin. +After rollout, verify an invitation to an authorized test recipient opens the correct dashboard +and preserves the explicit verified-email acceptance step. Outside `DEPLOYMENT_ENV=production`, +configure `EMAIL_RECIPIENT_ALLOWLIST` as well; an empty allowlist intentionally suppresses delivery. + Domain requirements on `vortexfinance.co`: Resend DKIM CNAMEs, exactly **one** SPF record (Resend merged into any existing sender — two records fail SPF outright), and a published DMARC policy. diff --git a/docs/security-spec/05-integrations/resend.md b/docs/security-spec/05-integrations/resend.md index 10e04179c..64d282af0 100644 --- a/docs/security-spec/05-integrations/resend.md +++ b/docs/security-spec/05-integrations/resend.md @@ -25,7 +25,7 @@ Every notification is persisted to the `email_notifications` table before any se - `apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts` — enqueues Alfredpay KYC/KYB outcomes - `apps/api/src/api/workers/alfredpay-status.worker.ts` — drives the Alfredpay poll for unattended accounts - `apps/api/src/api/services/phases/phase-processor.ts` — enqueues ramp completions on the terminal `complete` transition -- `apps/api/src/models/emailNotification.model.ts`, migration `062-create-email-notifications-table.ts` +- `apps/api/src/models/emailNotification.model.ts`, migrations `062-create-email-notifications-table.ts` and `070-email-notification-direct-recipients.ts` The table is `email_notifications`, not `notifications`: migration 043 already owns `notifications` for the in-app notification centre. The two tables are unrelated, but they share one opt-out: @@ -59,7 +59,7 @@ rejection normally carries a new one and is therefore a new notification, not a duplicate. Known limit: an in-place retry that retains the submission id would dedupe a second rejection of that same submission — Alfredpay exposes no per-outcome id to key on. -**Data sent to Resend:** recipient address, subject, and rendered body. Bodies contain the ramp id, output amount, currency, network, and completion timestamp, or a KYC/KYB outcome and its rejection reason — the copy names identity or business verification according to our own `provider_customers.customer_type`, since neither the Avenia attempt nor the Alfredpay status distinguishes them. No tax ids, no wallet keys, no session tokens, no API keys. +**Data sent to Resend:** recipient address, subject, and rendered body. Bodies contain the ramp id, output amount, currency, network, and completion timestamp, or a KYC/KYB outcome and its rejection reason — the copy names identity or business verification according to our own `provider_customers.customer_type`, since neither the Avenia attempt nor the Alfredpay status distinguishes them. Invitation bodies contain only generic English access/expiry instructions and the invitation link, not the role, child ID, or inviter identity. No tax ids, no wallet keys, no session tokens, no API keys. ## Security Invariants @@ -70,7 +70,7 @@ second rejection of that same submission — Alfredpay exposes no per-outcome id 5. **A notification MUST NOT be lost when a send fails.** Enqueue only writes a row; the cron worker is the only sender. Failures are recorded with a backoff schedule (1/5/15/60/180 minutes) and retried up to 6 attempts — one initial send plus one retry per backoff step — after which the row is `abandoned` and a Slack alert fires. 6. **A crashed send MUST NOT stall the queue, and MUST NOT retry without bound.** Rows left in `sending` for more than 15 minutes are recovered on the next cycle: those still under the attempt cap are released back to `failed` and become eligible again, while those at or above it are set `abandoned` with the same Slack alert a normal exhaustion raises. The split is what caps a crash loop — a process dying between claim and resolution records no failure, so the cap in `handleDeliveryFailure` never runs for it. `claimDueNotifications` additionally refuses to claim any row at the cap. 7. **Outside production, mail MUST NOT reach arbitrary recipients.** When `DEPLOYMENT_ENV !== "production"`, a recipient not present in `EMAIL_RECIPIENT_ALLOWLIST` is recorded as `skipped` and no request is made to Resend. An empty allowlist means nothing is sent. -8. **The Resend API key MUST be environment-only** and MUST NOT appear in logs, error text, or the `email_notifications.last_error` column. Only the response status and a truncated body are persisted on failure. +8. **The Resend API key MUST be environment-only** and MUST NOT appear in logs, error text, or the `email_notifications.last_error` column. Profile mail persists response status and a truncated body on failure. Invitation mail instead uses fixed failure text because provider/SQL errors may echo recipient identity or the link; logs and abandonment alerts omit its resource UUID and raw errors. Its enqueue disables SQL logging and propagates only a sanitized failure. 9. **Template output MUST be escaped.** All payload values are HTML-escaped before interpolation, so a value carried from an upstream provider (for example an Avenia rejection message) cannot inject markup into the mail body. 10. **Provider text MUST be bounded before it reaches a user.** Avenia's `resultMessage` and Alfredpay's `metadata.failureReason` are each truncated to 200 characters and included only on rejection. 11. **A missing `RESEND_API_KEY` MUST NOT destroy queued mail.** With no key configured the worker logs a warning and leaves rows `pending`; the backlog flushes once the key is set. It must never mark them sent, skipped, or abandoned. @@ -78,7 +78,8 @@ second rejection of that same submission — Alfredpay exposes no per-outcome id 13. **A settled Alfredpay verification MUST NOT lose its notification to the status write that ends its polling.** Alfredpay has no webhook, and the onboarding refresh, background worker, and legacy status endpoints eventually exclude a terminal stored status — so an account written terminal while its enqueue failed could be excluded from every subsequent poll and never notified. Every observer queues through the same idempotent helper before the status write: a failure leaves the account non-terminal and a later poll retries the outcome and the email together. Invariant 3 makes the retry idempotent. 14. **The sending domain MUST be SPF/DKIM/DMARC aligned.** `vortexfinance.co` carries one SPF record (Resend merged into any existing sender), Resend's DKIM CNAMEs, and a DMARC policy. Because auth mail and transactional mail share the root domain, a reputation incident in either affects both — this was accepted deliberately in exchange for sender recognisability. 15. **A profile recipient who has opted out MUST NOT be emailed.** Dispatch resolves `notification_preferences` for profile-addressed rows before every send and records the row as `skipped` when opted out. `email_enabled = false` silences profile notifications; `prefs[] = false` silences one type. Managed-profile invitations are the only exception because the direct recipient may not yet have a profile and the message communicates a pending access grant. They remain protected by exact verified-email acceptance and do not themselves grant access. -16. **Membership invitation links MUST use a trusted origin and minimal payload.** The API builds links from `DASHBOARD_PUBLIC_URL` plus the invitation UUID. Request headers and request body cannot select the origin. The template may identify the invited role and child display label but must not contain API secrets, bearer tokens, provider identity, financial data, or internal membership IDs other than the invitation locator. +16. **Membership invitation links MUST use a trusted origin and minimal payload.** The API builds `/member-invitations/:invitationId` links from validated `DASHBOARD_PUBLIC_URL`. It must be an HTTPS origin without credentials, path, query, or fragment; HTTP loopback is allowed only in development/test. Production-runtime startup requires it; other runtimes allow it to be absent but invitation enqueue then fails closed. Request headers and request body cannot select the origin. The payload stores only the link; generic English copy omits role, child ID, inviter identity, API secrets, bearer tokens, provider identity, and financial data. Seven-day expiry is measured from invitation creation and enforced at acceptance, never extended by mail retries. +17. **Invitation enqueue MUST join the invitation transaction.** Await `enqueueManagedProfileInvitation({ invitationId, recipientEmail }, transaction): Promise` from the email module with the invitation-creation transaction; failure must abort that transaction. The helper performs no profile/locale lookup and deduplicates on `(vortex, managed_profile_membership_invitation, invitation UUID)`. Generic profile producers reject this type. Migration 070 requires the invitation type to use a normalized direct recipient with no `user_id` and forbids direct recipients for every other type/provider. It retains the existing profile foreign key and idempotency index. Its rollback fails rather than deleting direct-recipient rows. ## Threat Vectors & Mitigations @@ -106,7 +107,7 @@ second rejection of that same submission — Alfredpay exposes no per-outcome id - [ ] `RESEND_API_KEY` is read only from `config.integrations.resend.apiKey` and never logged - [ ] Profile recipients resolve through `user_id`; only managed-profile invitations use normalized `recipient_email` through their dedicated producer - [ ] `enqueueRampCompletedEmail` returns early when `RampState.userId` is null, and tombstones API-credential ramps as `skipped` -- [ ] Migration `062` creates the base queue and migration 069 enforces exactly one recipient source plus the direct-email type allowlist +- [ ] Migration `062` creates the base queue, 069 owns membership storage, and 070 enforces exactly one recipient source plus the direct-email type allowlist - [ ] `enqueueNotification` uses `findOrCreate` keyed on `(provider, type, resourceId)` - [ ] Dispatch claims rows with `lock: transaction.LOCK.UPDATE` and `skipLocked: true` before sending - [ ] `attempts` is incremented at claim time, not after a successful send From 831953e6e4ae1cc77d2e886d572aef9fc4310039 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 7 Sep 2026 17:18:54 -0300 Subject: [PATCH 05/11] feat(api): add managed profile team and invitation endpoints --- .../managedProfileMemberships.controller.ts | 126 ++++ .../src/api/middlewares/bearerPrincipal.ts | 4 +- apps/api/src/api/middlewares/supabaseAuth.ts | 3 + apps/api/src/api/routes/v1/index.ts | 3 + .../managed-profile-memberships.route.test.ts | 298 +++++++++ .../v1/managed-profile-memberships.route.ts | 63 ++ .../src/api/services/auth/supabase.service.ts | 2 + .../auth/supabase.verify-token.test.ts | 3 +- ...managed-profile-membership.service.test.ts | 626 ++++++++++++++++++ .../managed-profile-membership.service.ts | 516 +++++++++++++++ .../07-operations/api-surface.md | 43 +- 11 files changed, 1677 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/api/controllers/managedProfileMemberships.controller.ts create mode 100644 apps/api/src/api/routes/v1/managed-profile-memberships.route.test.ts create mode 100644 apps/api/src/api/routes/v1/managed-profile-memberships.route.ts create mode 100644 apps/api/src/api/services/managed-profile-membership.service.test.ts create mode 100644 apps/api/src/api/services/managed-profile-membership.service.ts diff --git a/apps/api/src/api/controllers/managedProfileMemberships.controller.ts b/apps/api/src/api/controllers/managedProfileMemberships.controller.ts new file mode 100644 index 000000000..db193d7b1 --- /dev/null +++ b/apps/api/src/api/controllers/managedProfileMemberships.controller.ts @@ -0,0 +1,126 @@ +import type { Request, RequestHandler, Response } from "express"; +import logger from "../../config/logger"; +import { + cancelManagedProfileInvitation, + changeManagedProfileMember, + createManagedProfileInvitation, + listManagedProfileInvitations, + listManagedProfileMemberEvents, + listManagedProfileMembers, + ManagedProfileMembershipError, + readOrAcceptManagedProfileInvitation, + removeManagedProfileMember +} from "../services/managed-profile-membership.service"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function handle(action: (req: Request, res: Response, actorProfileId: string) => Promise): RequestHandler { + return async (req, res) => { + try { + if (!req.userId || req.impersonation || req.credential) { + throw new ManagedProfileMembershipError("MANAGED_PROFILE_ACCESS_DENIED", 403, "A Supabase session is required"); + } + for (const [key, value] of Object.entries(req.params)) { + if (typeof value !== "string" || !UUID_PATTERN.test(value)) { + throw new ManagedProfileMembershipError("MANAGED_PROFILE_INVALID_INPUT", 400, "Path identifiers must be UUIDs"); + } + req.params[key] = value.toLowerCase(); + } + await action(req, res, req.userId); + } catch (error) { + if (error instanceof ManagedProfileMembershipError) { + res.status(error.status).json({ error: { code: error.code, message: error.message, status: error.status } }); + return; + } + // Database errors can contain invitation email values; do not log the raw exception. + logger.error("Managed-profile membership request failed"); + res + .status(500) + .json({ error: { code: "INTERNAL_SERVER_ERROR", message: "Unable to process membership request", status: 500 } }); + } + }; +} + +function page(req: Request) { + const limit = req.query.limit === undefined ? 50 : req.query.limit; + const offset = req.query.offset === undefined ? 0 : req.query.offset; + if ( + (typeof limit !== "number" && (typeof limit !== "string" || !/^\d+$/.test(limit))) || + (typeof offset !== "number" && (typeof offset !== "string" || !/^\d+$/.test(offset))) || + !Number.isSafeInteger(Number(limit)) || + Number(limit) < 1 || + Number(limit) > 100 || + !Number.isSafeInteger(Number(offset)) || + Number(offset) < 0 + ) + throw new ManagedProfileMembershipError("INVALID_PAGINATION", 400, "limit must be 1-100 and offset a non-negative integer"); + return { limit: Number(limit), offset: Number(offset) }; +} + +export const readMembers = handle(async (req, res, actor) => { + const { limit, offset } = page(req); + res.json(await listManagedProfileMembers(actor, req.params.profileId as string, limit, offset)); +}); + +export const patchMember = handle(async (req, res, actor) => { + res.json( + await changeManagedProfileMember( + actor, + req.params.profileId as string, + req.params.memberProfileId as string, + req.body?.role + ) + ); +}); + +export const deleteMember = handle(async (req, res, actor) => { + await removeManagedProfileMember(actor, req.params.profileId as string, req.params.memberProfileId as string); + res.status(204).send(); +}); + +export const readInvitations = handle(async (req, res, actor) => { + const { limit, offset } = page(req); + res.json(await listManagedProfileInvitations(actor, req.params.profileId as string, limit, offset)); +}); + +export const postInvitation = handle(async (req, res, actor) => { + const result = await createManagedProfileInvitation(actor, req.params.profileId as string, { + email: req.body?.email, + role: req.body?.role + }); + res.status(result.created ? 201 : 200).json({ invitation: result.invitation }); +}); + +export const deleteInvitation = handle(async (req, res, actor) => { + await cancelManagedProfileInvitation(actor, req.params.profileId as string, req.params.invitationId as string); + res.status(204).send(); +}); + +export const readMemberEvents = handle(async (req, res, actor) => { + const { limit } = page(req); + const cursor = req.query.cursor; + if (cursor !== undefined && (typeof cursor !== "string" || !UUID_PATTERN.test(cursor))) { + throw new ManagedProfileMembershipError("INVALID_PAGINATION", 400, "Cursor must be an event UUID"); + } + res.json(await listManagedProfileMemberEvents(actor, req.params.profileId as string, limit, cursor as string | undefined)); +}); + +export const previewInvitation = handle(async (req, res, actor) => { + res.json( + await readOrAcceptManagedProfileInvitation( + { email: req.userEmail, emailConfirmedAt: req.emailConfirmedAt, profileId: actor }, + req.params.invitationId as string, + false + ) + ); +}); + +export const acceptInvitation = handle(async (req, res, actor) => { + res.json( + await readOrAcceptManagedProfileInvitation( + { email: req.userEmail, emailConfirmedAt: req.emailConfirmedAt, profileId: actor }, + req.params.invitationId as string, + true + ) + ); +}); diff --git a/apps/api/src/api/middlewares/bearerPrincipal.ts b/apps/api/src/api/middlewares/bearerPrincipal.ts index be80834f4..67077d1fd 100644 --- a/apps/api/src/api/middlewares/bearerPrincipal.ts +++ b/apps/api/src/api/middlewares/bearerPrincipal.ts @@ -11,7 +11,7 @@ export type { ImpersonationContext }; * controllers) then scopes to the target with no further changes. */ export type BearerPrincipal = - | { valid: true; userId: string; userEmail?: string; impersonation?: ImpersonationContext } + | { valid: true; userId: string; userEmail?: string; emailConfirmedAt?: string; impersonation?: ImpersonationContext } | { valid: false }; /** @@ -36,7 +36,7 @@ export async function resolveBearerPrincipal(token: string): Promise { + const originalDashboardUrl = config.dashboardPublicUrl; + let server: ReturnType; + let baseUrl: string; + let owner: User; + let invitee: User; + let child: string; + + beforeAll(async () => { + await setupTestDatabase(); + const app = express(); + app.use(express.json()); + app.use("/v1/managed-profiles", membershipRoutes); + app.get("/v1/managed-profiles", (_req, res) => res.json({ lifecycle: true })); + app.use("/v1/managed-profile-member-invitations", managedProfileInviteeRoutes); + server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Could not bind test server"); + baseUrl = `http://127.0.0.1:${address.port}`; + }); + beforeEach(async () => { + config.dashboardPublicUrl = "https://dashboard.example.com"; + await resetTestDatabase(); + owner = await createTestUser(); + invitee = await createTestUser({ email: "stale@example.com" }); + await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: owner.id }); + child = ( + await provisionManagedProfile({ + contactEmail: "child@example.com", + creationSource: "manager", + customerType: "individual", + externalSubjectId: "company", + managerProfileId: owner.id + }) + ).profileId; + spyOn(SupabaseAuthService, "verifyToken").mockImplementation(async token => { + if (token === "owner") return { user_id: owner.id, email: owner.email!, valid: true }; + if (token === "invitee") + return { user_id: invitee.id, email: "invitee@example.com", email_confirmed_at: "2026-09-01T00:00:00Z", valid: true }; + if (token === "unverified") return { user_id: invitee.id, email: "invitee@example.com", valid: true }; + if (token === "child") + return { user_id: child, email: "invitee@example.com", email_confirmed_at: "2026-09-01T00:00:00Z", valid: true }; + return { valid: false }; + }); + }); + afterEach(() => { + mock.restore(); + config.dashboardPublicUrl = originalDashboardUrl; + }); + afterAll(() => server.close()); + + function request(path: string, method = "GET", body?: unknown, token = "owner", extraHeaders: Record = {}) { + return fetch(`${baseUrl}/v1/${path}`, { + method, + headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json", ...extraHeaders }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }) + }); + } + const team = (suffix: string) => `managed-profiles/${child}/${suffix}`; + const invitePath = (id: string) => `managed-profile-member-invitations/${id}`; + + it("mounts all nine endpoints and preserves the lifecycle prefix", async () => { + expect(await (await request("managed-profiles", "GET", undefined, "")).json()).toEqual({ lifecycle: true }); + const created = await request(team("member-invitations"), "POST", { email: " Invitee@Example.com ", role: "manager" }); + expect(created.status).toBe(201); + const { invitation } = await created.json(); + const duplicate = await request(team("member-invitations"), "POST", { email: "invitee@example.com", role: "manager" }); + expect(duplicate.status).toBe(200); + expect((await duplicate.json()).invitation.id).toBe(invitation.id); + const preview = await request(invitePath(invitation.id), "GET", undefined, "invitee"); + expect(preview.status).toBe(200); + expect((await preview.json()).invitation.status).toBe("pending"); + expect((await request(`${invitePath(invitation.id)}/accept`, "POST", {}, "invitee")).status).toBe(200); + const members = await request(team("members")); + expect(members.status).toBe(200); + expect((await members.json()).members).toEqual( + expect.arrayContaining([ + expect.objectContaining({ memberProfileId: owner.id, isOwner: true }), + expect.objectContaining({ memberProfileId: invitee.id, role: "manager", isOwner: false }) + ]) + ); + expect((await request(team(`members/${invitee.id}`), "PATCH", { role: "read_only" })).status).toBe(200); + const invitations = await request(team("member-invitations")); + expect((await invitations.json()).invitations[0].status).toBe("accepted"); + const events = await request(team("member-events?limit=2")); + const eventPage = await events.json(); + expect(eventPage.events).toHaveLength(2); + expect(eventPage.pagination.nextCursor).toBeString(); + expect((await request(team(`member-events?limit=2&cursor=${eventPage.pagination.nextCursor}`))).status).toBe(200); + expect((await request(team(`members/${invitee.id}`), "DELETE")).status).toBe(204); + expect((await request(team(`members/${invitee.id}`), "DELETE")).status).toBe(204); + const fresh = await request(team("member-invitations"), "POST", { email: "invitee@example.com", role: "read_only" }); + const freshId = (await fresh.json()).invitation.id; + expect((await request(team(`member-invitations/${freshId}`), "DELETE")).status).toBe(204); + }); + + it("requires bearer authentication on every route and rejects API credentials even with a bearer", async () => { + const id = crypto.randomUUID(); + const paths = [ + [team("members"), "GET"], + [team(`members/${id}`), "PATCH"], + [team(`members/${id}`), "DELETE"], + [team("member-invitations"), "GET"], + [team("member-invitations"), "POST"], + [team(`member-invitations/${id}`), "DELETE"], + [team("member-events"), "GET"], + [invitePath(id), "GET"], + [`${invitePath(id)}/accept`, "POST"] + ]; + for (const [path, method] of paths) { + expect((await request(path, method, undefined, "")).status).toBe(401); + expect((await request(path, method, undefined, "invalid")).status).toBe(401); + for (const header of ["X-API-Key", "X-Public-Key"]) { + expect((await request(path, method, undefined, "owner", { [header]: "sk_test_secret" })).status).toBe(403); + } + } + }); + + it("rejects impersonation for all team and invitee routes", async () => { + spyOn(impersonation, "resolveSession").mockResolvedValue({ + actorProfileId: crypto.randomUUID(), + targetProfileId: owner.id, + targetEmail: owner.email!, + sessionId: crypto.randomUUID(), + expiresAt: new Date(Date.now() + 10000) + }); + const id = crypto.randomUUID(); + for (const [path, method] of [ + [team("members"), "GET"], + [team(`members/${id}`), "PATCH"], + [team(`members/${id}`), "DELETE"], + [team("member-invitations"), "GET"], + [team("member-invitations"), "POST"], + [team(`member-invitations/${id}`), "DELETE"], + [team("member-events"), "GET"], + [invitePath(id), "GET"], + [`${invitePath(id)}/accept`, "POST"] + ]) { + const response = await request(path, method, undefined, "vtx_imp_test"); + expect(response.status).toBe(403); + expect((await response.json()).error.code).toBe("IMPERSONATION_NOT_ALLOWED"); + } + }); + + it("rejects mismatched selectors and any invitee selector, including an empty one", async () => { + const { invitation } = await createManagedProfileInvitation(owner.id, child, { + email: "invitee@example.com", + role: "manager" + }); + expect((await request(team("members"), "GET", undefined, "owner", { "X-Managed-Profile-Id": child })).status).toBe(200); + expect( + (await request(team("members"), "GET", undefined, "owner", { "X-Managed-Profile-Id": crypto.randomUUID() })).status + ).toBe(403); + for (const selector of [child, ""]) { + for (const [path, method] of [ + [invitePath(invitation.id), "GET"], + [`${invitePath(invitation.id)}/accept`, "POST"] + ]) { + expect((await request(path, method, undefined, "invitee", { "X-Managed-Profile-Id": selector })).status).toBe(400); + } + } + expect((await Invitation.findByPk(invitation.id))?.acceptedAt).toBeNull(); + }); + + it("requires the exact current verified principal on preview and accept, never request email", async () => { + const { invitation } = await createManagedProfileInvitation(owner.id, child, { + email: "invitee@example.com", + role: "manager" + }); + for (const token of ["owner", "unverified", "child"]) { + const preview = await request(invitePath(invitation.id), "GET", undefined, token); + expect(preview.status).toBe(403); + expect(await preview.json()).toEqual({ + error: { code: "MANAGED_PROFILE_ACCESS_DENIED", message: "Managed-profile access is denied", status: 403 } + }); + const response = await request( + `${invitePath(invitation.id)}/accept`, + "POST", + { email: "invitee@example.com", email_confirmed_at: "2026-09-01T00:00:00Z" }, + token + ); + expect(response.status).toBe(403); + } + expect( + (await request(`${invitePath(invitation.id)}/accept`, "POST", { email: "wrong@example.com" }, "invitee")).status + ).toBe(200); + }); + + it("permits read_only reads but no member or invitation mutation", async () => { + await Membership.create({ managedProfileId: child, memberProfileId: invitee.id, role: "read_only" }); + for (const suffix of ["members", "member-invitations", "member-events"]) { + expect((await request(team(suffix), "GET", undefined, "invitee")).status).toBe(200); + } + for (const [path, method, body] of [ + [team("member-invitations"), "POST", { email: "other@example.com", role: "manager" }], + [team(`member-invitations/${crypto.randomUUID()}`), "DELETE", undefined], + [team(`members/${owner.id}`), "PATCH", { role: "read_only" }], + [team(`members/${owner.id}`), "DELETE", undefined] + ] as const) { + const response = await request(path, method, body, "invitee"); + expect(response.status).toBe(403); + expect((await response.json()).error.code).toBe("MANAGED_PROFILE_MANAGER_REQUIRED"); + } + }); + + it("validates roles, identifiers, pagination limits, and cursor syntax before database access", async () => { + const invalidRole = await request(team("member-invitations"), "POST", { email: "other@example.com", role: "owner" }); + expect(invalidRole.status).toBe(400); + expect((await invalidRole.json()).error.code).toBe("INVALID_MEMBERSHIP_ROLE"); + expect((await request("managed-profiles/not-a-uuid/members")).status).toBe(400); + expect((await request(team("members/not-a-uuid"), "DELETE")).status).toBe(400); + for (const query of [ + "limit=0", + "limit=101", + "limit=-1", + "limit=1.5", + "limit=", + "limit=1&limit=2", + "limit=true", + "offset=-1", + "offset=1.5", + "offset=9007199254740992" + ]) { + for (const suffix of ["members", "member-invitations", "member-events"]) { + expect((await request(team(`${suffix}?${query}`))).status).toBe(400); + } + } + for (const query of ["cursor=", "cursor=not-a-uuid", "cursor=1&cursor=2"]) { + expect((await request(team(`member-events?${query}`))).status).toBe(400); + } + const page = await request(team("members?limit=1&offset=1")); + expect(await page.json()).toMatchObject({ members: [], pagination: { limit: 1, offset: 1, total: 1 } }); + }); + + it("protects the owner with typed conflicts even for uppercase UUID paths", async () => { + for (const method of ["PATCH", "DELETE"]) { + const response = await request( + `managed-profiles/${child.toUpperCase()}/members/${owner.id.toUpperCase()}`, + method, + method === "PATCH" ? { role: "read_only" } : undefined, + "owner", + { "X-Managed-Profile-Id": child } + ); + expect(response.status).toBe(409); + expect((await response.json()).error.code).toBe("MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED"); + } + }); + + it("returns sanitised infrastructure errors without logging invitation input", async () => { + spyOn(notifications, "enqueueManagedProfileInvitation").mockRejectedValue( + new Error("invitee@example.com invitation-url bearer-secret") + ); + const log = spyOn(logger, "error").mockImplementation(() => logger); + // Other producers may log while this request runs; only our sanitised message is request-owned. + logger.error("Unrelated background notification failure"); + const response = await request(team("member-invitations"), "POST", { email: "invitee@example.com", role: "manager" }); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + error: { code: "INTERNAL_SERVER_ERROR", message: "Unable to process membership request", status: 500 } + }); + expect(log).toHaveBeenCalledWith("Managed-profile membership request failed"); + expect( + log.mock.calls.filter(([message]) => (message as unknown) === "Managed-profile membership request failed") + ).toHaveLength(1); + const captured = inspect(log.mock.calls, { depth: null }); + for (const sensitive of ["invitee@example.com", "invitation-url", "bearer-secret"]) { + expect(captured).not.toContain(sensitive); + } + expect(await Invitation.count()).toBe(0); + }); + + it("applies a rate limit keyed by the authenticated profile", async () => { + let response!: Response; + for (let i = 0; i < 121; i++) response = await request(team("members?limit=0")); + expect(response.status).toBe(429); + expect(response.headers.get("ratelimit-limit")).toBe("120"); + const otherActor = await request(team("members?limit=0"), "GET", undefined, "invitee"); + expect(otherActor.status).toBe(400); + }); +}); diff --git a/apps/api/src/api/routes/v1/managed-profile-memberships.route.ts b/apps/api/src/api/routes/v1/managed-profile-memberships.route.ts new file mode 100644 index 000000000..63e6f07f9 --- /dev/null +++ b/apps/api/src/api/routes/v1/managed-profile-memberships.route.ts @@ -0,0 +1,63 @@ +import { type RequestHandler, Router } from "express"; +import rateLimit from "express-rate-limit"; +import { + acceptInvitation, + deleteInvitation, + deleteMember, + patchMember, + postInvitation, + previewInvitation, + readInvitations, + readMemberEvents, + readMembers +} from "../../controllers/managedProfileMemberships.controller"; +import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; +import { rejectManagedProfileSelection } from "../../middlewares/managedProfileAuth"; +import { requireAuth } from "../../middlewares/supabaseAuth"; + +const rejectApiCredentials: RequestHandler = (req, res, next) => { + if (req.get("X-API-Key") !== undefined || req.get("X-Public-Key") !== undefined || req.credential) { + res.status(403).json({ + error: { code: "MANAGED_PROFILE_ACCESS_DENIED", message: "Membership routes require a Supabase session", status: 403 } + }); + return; + } + next(); +}; + +const selectedChildMatchesPath: RequestHandler = (req, res, next) => { + const selected = req.get("X-Managed-Profile-Id"); + if (selected !== undefined && selected.toLowerCase() !== String(req.params.profileId).toLowerCase()) { + res + .status(403) + .json({ error: { code: "MANAGED_PROFILE_ACCESS_DENIED", message: "Managed-profile access is denied", status: 403 } }); + return; + } + next(); +}; + +const authenticatedLimiter = rateLimit({ + keyGenerator: req => req.userId as string, + legacyHeaders: false, + max: 120, + standardHeaders: true, + windowMs: 60 * 1000 +}); +const session = [rejectApiCredentials, requireAuth, rejectImpersonation, authenticatedLimiter]; +const member = [...session, selectedChildMatchesPath]; + +// Route-local checks deliberately leave unrelated lifecycle routes on this prefix untouched. +const router = Router(); +router.get("/:profileId/members", ...member, readMembers); +router.patch("/:profileId/members/:memberProfileId", ...member, patchMember); +router.delete("/:profileId/members/:memberProfileId", ...member, deleteMember); +router.get("/:profileId/member-invitations", ...member, readInvitations); +router.post("/:profileId/member-invitations", ...member, postInvitation); +router.delete("/:profileId/member-invitations/:invitationId", ...member, deleteInvitation); +router.get("/:profileId/member-events", ...member, readMemberEvents); + +export const managedProfileInviteeRoutes = Router(); +managedProfileInviteeRoutes.get("/:invitationId", ...session, rejectManagedProfileSelection, previewInvitation); +managedProfileInviteeRoutes.post("/:invitationId/accept", ...session, rejectManagedProfileSelection, acceptInvitation); + +export default router; diff --git a/apps/api/src/api/services/auth/supabase.service.ts b/apps/api/src/api/services/auth/supabase.service.ts index 25d6cf407..7ad84c15c 100644 --- a/apps/api/src/api/services/auth/supabase.service.ts +++ b/apps/api/src/api/services/auth/supabase.service.ts @@ -187,6 +187,7 @@ export class SupabaseAuthService { valid: boolean; user_id?: string; email?: string; + email_confirmed_at?: string; }> { // Access-token verification is an Auth operation and does not require broad // service-role privileges. The project anon key identifies the trusted Supabase @@ -205,6 +206,7 @@ export class SupabaseAuthService { return { email: data.user.email, + email_confirmed_at: data.user.email_confirmed_at, user_id: data.user.id, valid: true }; diff --git a/apps/api/src/api/services/auth/supabase.verify-token.test.ts b/apps/api/src/api/services/auth/supabase.verify-token.test.ts index 2a984c444..f71f941e5 100644 --- a/apps/api/src/api/services/auth/supabase.verify-token.test.ts +++ b/apps/api/src/api/services/auth/supabase.verify-token.test.ts @@ -9,13 +9,14 @@ afterEach(() => { describe("SupabaseAuthService.verifyToken", () => { it("uses the least-privileged Auth client and returns the authoritative user", async () => { const getUser = spyOn(supabase.auth, "getUser").mockResolvedValue({ - data: { user: { email: "user@example.com", id: "user-1" } }, + data: { user: { email: "user@example.com", email_confirmed_at: "2026-09-07T00:00:00Z", id: "user-1" } }, error: null } as never); const adminGetUser = spyOn(supabaseAdmin.auth, "getUser"); await expect(SupabaseAuthService.verifyToken("access-token")).resolves.toEqual({ email: "user@example.com", + email_confirmed_at: "2026-09-07T00:00:00Z", user_id: "user-1", valid: true }); diff --git a/apps/api/src/api/services/managed-profile-membership.service.test.ts b/apps/api/src/api/services/managed-profile-membership.service.test.ts new file mode 100644 index 000000000..b62af730a --- /dev/null +++ b/apps/api/src/api/services/managed-profile-membership.service.test.ts @@ -0,0 +1,626 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { Transaction } from "sequelize"; +import sequelize from "../../config/database"; +import { config } from "../../config/vars"; +import EmailNotification from "../../models/emailNotification.model"; +import ManagedProfile from "../../models/managedProfile.model"; +import ManagedProfileManager from "../../models/managedProfileManager.model"; +import Membership from "../../models/managedProfileMembership.model"; +import MembershipEvent from "../../models/managedProfileMembershipEvent.model"; +import Invitation from "../../models/managedProfileMembershipInvitation.model"; +import User from "../../models/user.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestUser } from "../../test-utils/factories"; +import { SupabaseAuthService } from "./auth"; +import * as notifications from "./email/notification.service"; +import { deleteManagedProfile } from "./managed-profile-lifecycle.service"; +import { + cancelManagedProfileInvitation, + changeManagedProfileMember, + createManagedProfileInvitation, + listManagedProfileInvitations, + listManagedProfileMemberEvents, + listManagedProfileMembers, + readOrAcceptManagedProfileInvitation, + removeManagedProfileMember +} from "./managed-profile-membership.service"; +import { provisionManagedProfile } from "./managed-profile-provisioning.service"; + +describe("managed-profile membership transactions", () => { + const originalDashboardUrl = config.dashboardPublicUrl; + let owner: User; + let invitee: User; + let child: string; + const email = "invitee@example.com"; + const principal = () => ({ profileId: invitee.id, email, emailConfirmedAt: "2026-09-01T00:00:00Z" }); + const invite = (role = "manager", recipient = email, actor = owner.id) => + createManagedProfileInvitation(actor, child, { email: recipient, role }); + const accept = (id: string) => readOrAcceptManagedProfileInvitation(principal(), id, true); + const eventCount = (action: string) => MembershipEvent.count({ where: { managedProfileId: child, action } }); + + beforeAll(setupTestDatabase); + beforeEach(async () => { + config.dashboardPublicUrl = "https://dashboard.example.com"; + await resetTestDatabase(); + owner = await createTestUser(); + invitee = await createTestUser({ email: "stale@example.com" }); + await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: owner.id }); + child = ( + await provisionManagedProfile({ + contactEmail: "child@example.com", + creationSource: "manager", + customerType: "business", + externalSubjectId: "company", + managerProfileId: owner.id + }) + ).profileId; + }); + afterEach(() => { + mock.restore(); + config.dashboardPublicUrl = originalDashboardUrl; + }); + + it("normalizes creation, persists seven-day expiry and atomically queues one mail for concurrent duplicates", async () => { + const start = Date.now(); + const settled = await Promise.allSettled([invite("manager", " Invitee@Example.COM "), invite()]); + const results = settled.map(result => { + if (result.status === "rejected") throw result.reason; + return result.value; + }); + expect(results.map(result => result.created).sort()).toEqual([false, true]); + expect(results[0].invitation.id).toBe(results[1].invitation.id); + expect(results[0].invitation.email).toBe(email); + expect(results[0].invitation.expiresAt.getTime()).toBeGreaterThanOrEqual(start + 7 * 86400000); + expect(await Invitation.count()).toBe(1); + expect(await eventCount("invited")).toBe(1); + expect(await EmailNotification.count({ where: { recipientEmail: email, resourceId: results[0].invitation.id } })).toBe(1); + }); + + it("does not distinguish existing and unregistered nonmember emails", async () => { + const existing = await invite("read_only", invitee.email!); + const unknown = await invite("read_only", "unregistered@example.com"); + expect(existing.created).toBe(true); + expect(unknown.created).toBe(true); + expect(Object.keys(existing.invitation)).toEqual(Object.keys(unknown.invitation)); + expect(await EmailNotification.count()).toBe(2); + }); + + it("does not let a member's stale local email veto the current mailbox holder's invitation", async () => { + const staleMember = await createTestUser({ email }); + await Membership.create({ managedProfileId: child, memberProfileId: staleMember.id, role: "manager" }); + const lookup = spyOn(SupabaseAuthService, "getUserProfile").mockResolvedValue({ + id: staleMember.id, + email: "changed@example.com", + email_confirmed_at: "2026-09-01T00:00:00Z" + } as never); + + const { invitation } = await invite(); + expect(lookup).toHaveBeenCalledWith(staleMember.id); + expect(await accept(invitation.id)).toMatchObject({ member: { memberProfileId: invitee.id, role: "manager" } }); + expect(await Membership.count({ where: { managedProfileId: child, revokedAt: null } })).toBe(3); + expect(await EmailNotification.count({ where: { recipientEmail: email } })).toBe(1); + }); + + it("returns 409 only after confirming an existing member's current verified Supabase email", async () => { + await Membership.create({ managedProfileId: child, memberProfileId: invitee.id, role: "manager" }); + const lookup = spyOn(SupabaseAuthService, "getUserProfile").mockResolvedValue({ + id: invitee.id, + email: " STALE@EXAMPLE.COM ", + email_confirmed_at: "2026-09-01T00:00:00Z" + } as never); + await expect(invite("manager", invitee.email!)).rejects.toMatchObject({ code: "MEMBERSHIP_ALREADY_EXISTS", status: 409 }); + expect(lookup).toHaveBeenCalledWith(invitee.id); + expect(await Invitation.count()).toBe(0); + expect(await EmailNotification.count()).toBe(0); + }); + + for (const confirmation of ["unverified", "wrong_profile", "invalid_timestamp", "unavailable"] as const) { + it(`does not veto an invitation on a ${confirmation} member email lookup`, async () => { + await Membership.create({ managedProfileId: child, memberProfileId: invitee.id, role: "manager" }); + const lookup = spyOn(SupabaseAuthService, "getUserProfile"); + if (confirmation === "unavailable") lookup.mockRejectedValue(new Error("Auth lookup unavailable")); + else + lookup.mockResolvedValue({ + id: confirmation === "wrong_profile" ? crypto.randomUUID() : invitee.id, + email: invitee.email, + email_confirmed_at: + confirmation === "unverified" + ? undefined + : confirmation === "invalid_timestamp" + ? "invalid" + : "2026-09-01T00:00:00Z" + } as never); + expect((await invite("manager", invitee.email!)).created).toBe(true); + expect(await EmailNotification.count()).toBe(1); + expect(await Membership.count()).toBe(2); + expect(await eventCount("invitation_accepted")).toBe(0); + }); + } + + for (const accepting of [false, true]) { + it(`allows reciprocal owners to ${accepting ? "accept" : "preview"} concurrently without deadlock`, async () => { + await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: invitee.id }); + const otherChild = ( + await provisionManagedProfile({ + contactEmail: "other-child@example.com", + creationSource: "manager", + customerType: "business", + externalSubjectId: "other-company", + managerProfileId: invitee.id + }) + ).profileId; + const toInvitee = await invite(); + const toOwner = await createManagedProfileInvitation(invitee.id, otherChild, { email: owner.email!, role: "manager" }); + const original = ManagedProfile.findOne.bind(ManagedProfile); + let release!: () => void; + const bothChildrenLocked = new Promise(resolve => { + release = resolve; + }); + let arrivals = 0; + spyOn(ManagedProfile, "findOne").mockImplementation(async options => { + const row = await original(options); + if (options?.lock) { + if (++arrivals === 2) release(); + await bothChildrenLocked; + } + return row; + }); + + // Both transactions hold their owner/child locks before either requests the opposite human profile. + const results = await Promise.allSettled([ + readOrAcceptManagedProfileInvitation(principal(), toInvitee.invitation.id, accepting), + readOrAcceptManagedProfileInvitation( + { profileId: owner.id, email: owner.email!, emailConfirmedAt: principal().emailConfirmedAt }, + toOwner.invitation.id, + accepting + ) + ]); + expect(results.map(result => (result.status === "rejected" ? result.reason : result.status))).toEqual([ + "fulfilled", + "fulfilled" + ]); + expect(arrivals).toBe(2); + expect(await Membership.count()).toBe(accepting ? 4 : 2); + expect(await MembershipEvent.count({ where: { action: "invitation_accepted" } })).toBe(accepting ? 2 : 0); + }); + } + + it("keeps owner and invitee profile changes blocked while their kinds authorize a preview", async () => { + const { invitation } = await invite(); + const original = User.findByPk.bind(User); + let reached!: () => void; + let release!: () => void; + const actorLocked = new Promise(resolve => { + reached = resolve; + }); + const finishPreview = new Promise(resolve => { + release = resolve; + }); + spyOn(User, "findByPk").mockImplementation(async (id, options) => { + const row = await original(id, options); + if (id === invitee.id && options?.lock) { + reached(); + await finishPreview; + } + return row; + }); + const preview = readOrAcceptManagedProfileInvitation(principal(), invitation.id, false).then( + result => result, + error => error + ); + try { + await actorLocked; + for (const id of [owner.id, invitee.id]) { + // Non-key profile changes (including kind) take NO KEY UPDATE, which KEY SHARE alone would allow. + await expect( + sequelize.transaction(transaction => + sequelize.query("SELECT id FROM profiles WHERE id = :id FOR NO KEY UPDATE NOWAIT", { + replacements: { id }, + transaction + }) + ) + ).rejects.toMatchObject({ original: { code: "55P03" } }); + } + } finally { + release(); + await preview; + } + expect(await preview).toMatchObject({ invitation: { status: "pending" } }); + }); + + for (const operation of ["invite", "role_change"] as const) { + it(`allows reciprocal owner-managers to ${operation} without foreign-key deadlocks`, async () => { + await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: invitee.id }); + const otherChild = ( + await provisionManagedProfile({ + contactEmail: "other-child@example.com", + creationSource: "manager", + customerType: "business", + externalSubjectId: "other-company", + managerProfileId: invitee.id + }) + ).profileId; + const target = await createTestUser(); + await Membership.bulkCreate([ + { managedProfileId: child, memberProfileId: invitee.id, role: "manager" }, + { managedProfileId: otherChild, memberProfileId: owner.id, role: "manager" }, + { managedProfileId: child, memberProfileId: target.id, role: "manager" }, + { managedProfileId: otherChild, memberProfileId: target.id, role: "manager" } + ]); + const original = ManagedProfile.findOne.bind(ManagedProfile); + let release!: () => void; + const bothChildrenLocked = new Promise(resolve => { + release = resolve; + }); + let arrivals = 0; + spyOn(ManagedProfile, "findOne").mockImplementation(async options => { + const row = await original(options); + if (options?.lock) { + if (++arrivals === 2) release(); + await bothChildrenLocked; + } + return row; + }); + const results = await Promise.allSettled( + operation === "invite" + ? [ + createManagedProfileInvitation(invitee.id, child, { email: "new-member@example.com", role: "manager" }), + createManagedProfileInvitation(owner.id, otherChild, { email: "new-member@example.com", role: "manager" }) + ] + : [ + changeManagedProfileMember(invitee.id, child, target.id, "read_only"), + changeManagedProfileMember(owner.id, otherChild, target.id, "read_only") + ] + ); + expect(results.map(result => (result.status === "rejected" ? result.reason : result.status))).toEqual([ + "fulfilled", + "fulfilled" + ]); + expect(arrivals).toBe(2); + expect(await MembershipEvent.count({ where: { action: operation === "invite" ? "invited" : "role_changed" } })).toBe(2); + }); + } + + it("requires cancellation before changing a pending role and never sends a duplicate", async () => { + const first = await invite(); + await expect(invite("read_only")).rejects.toMatchObject({ code: "INVITATION_ROLE_CONFLICT" }); + expect(await EmailNotification.count()).toBe(1); + await cancelManagedProfileInvitation(owner.id, child, first.invitation.id); + const replacement = await invite("read_only"); + expect(replacement.invitation.id).not.toBe(first.invitation.id); + expect(await eventCount("invitation_cancelled")).toBe(1); + }); + + it("rolls invitation and audit back when enqueue fails", async () => { + spyOn(notifications, "enqueueManagedProfileInvitation").mockRejectedValue(new Error("queue unavailable")); + await expect(invite()).rejects.toThrow("queue unavailable"); + expect(await Invitation.count()).toBe(0); + expect(await eventCount("invited")).toBe(0); + }); + + it("rejects unknown roles and malformed emails without writes", async () => { + for (const role of ["owner", "admin", "MANAGER", "", null, {}]) { + await expect(createManagedProfileInvitation(owner.id, child, { email, role })).rejects.toMatchObject({ + code: "INVALID_MEMBERSHIP_ROLE" + }); + await expect(changeManagedProfileMember(owner.id, child, invitee.id, role)).rejects.toMatchObject({ + code: "INVALID_MEMBERSHIP_ROLE" + }); + } + for (const recipient of ["invalid", "a\nb@example.com", "a@@example.com", `${"a".repeat(256)}@example.com`, undefined]) { + await expect( + createManagedProfileInvitation(owner.id, child, { email: recipient, role: "manager" }) + ).rejects.toMatchObject({ code: "INVALID_INVITATION_EMAIL" }); + } + expect(await Invitation.count()).toBe(0); + }); + + it("uses the current verified email, not stale profile email, and preview does not grant access", async () => { + const { invitation } = await invite(); + expect( + await readOrAcceptManagedProfileInvitation({ ...principal(), email: " INVITEE@example.com " }, invitation.id, false) + ).toMatchObject({ invitation: { status: "pending", role: "manager" }, managedProfile: { profileId: child } }); + expect(await Membership.count({ where: { memberProfileId: invitee.id } })).toBe(0); + const result = await accept(invitation.id); + expect(result).toMatchObject({ + member: { memberProfileId: invitee.id, role: "manager", isOwner: false }, + managedProfileId: child + }); + expect(await eventCount("invitation_accepted")).toBe(1); + const events = await MembershipEvent.findAll({ where: { invitationId: invitation.id, memberProfileId: invitee.id } }); + expect(events).toHaveLength(2); + expect(events.every(event => event.actorProfileId === invitee.id && event.subjectEmail === null)).toBe(true); + }); + + it("denies mismatched, unverified, malformed confirmation, and managed invitees without details or expiry writes", async () => { + const { invitation } = await invite(); + await Invitation.update({ expiresAt: new Date(Date.now() - 1000) }, { where: { id: invitation.id } }); + for (const actor of [ + { ...principal(), email: invitee.email! }, + { ...principal(), emailConfirmedAt: undefined }, + { ...principal(), emailConfirmedAt: "invalid" }, + { ...principal(), email: undefined }, + { ...principal(), profileId: child } + ]) { + for (const accepting of [false, true]) { + await expect(readOrAcceptManagedProfileInvitation(actor, invitation.id, accepting)).rejects.toMatchObject({ + code: "MANAGED_PROFILE_ACCESS_DENIED" + }); + } + } + expect(await eventCount("invitation_expired")).toBe(0); + await expect(accept(crypto.randomUUID())).rejects.toMatchObject({ code: "MANAGED_PROFILE_ACCESS_DENIED" }); + }); + + it("serializes concurrent acceptance and replays without duplicate memberships or events", async () => { + const { invitation } = await invite(); + const [first, second] = await Promise.all([accept(invitation.id), accept(invitation.id)]); + expect(second).toEqual(first); + expect(await accept(invitation.id)).toEqual(first); + expect(await Membership.count({ where: { memberProfileId: invitee.id, revokedAt: null } })).toBe(1); + expect(await eventCount("member_added")).toBe(2); // Includes provisioning's owner event. + expect(await eventCount("invitation_accepted")).toBe(1); + }); + + it("does not replay an accepted grant after downgrade or removal and creates a fresh row for a new grant", async () => { + const { invitation } = await invite(); + await accept(invitation.id); + const original = await Membership.findOne({ where: { memberProfileId: invitee.id, revokedAt: null } }); + await changeManagedProfileMember(owner.id, child, invitee.id, "read_only"); + await expect(accept(invitation.id)).rejects.toMatchObject({ code: "INVITATION_ACCEPTED" }); + await removeManagedProfileMember(owner.id, child, invitee.id); + await expect(accept(invitation.id)).rejects.toMatchObject({ code: "INVITATION_ACCEPTED" }); + const replacement = await invite(); + await accept(replacement.invitation.id); + const active = await Membership.findOne({ where: { memberProfileId: invitee.id, revokedAt: null } }); + expect(active?.id).not.toBe(original?.id); + expect(await Membership.count({ where: { memberProfileId: invitee.id } })).toBe(2); + expect((await original?.reload())?.revokedAt).toBeInstanceOf(Date); + }); + + it("rejects acceptance if a different existing membership would be overwritten", async () => { + const { invitation } = await invite(); + await Membership.create({ managedProfileId: child, memberProfileId: invitee.id, role: "read_only" }); + await expect(accept(invitation.id)).rejects.toMatchObject({ code: "MEMBERSHIP_ALREADY_EXISTS" }); + expect(await eventCount("invitation_accepted")).toBe(0); + expect((await Invitation.findByPk(invitation.id))?.acceptedAt).toBeNull(); + }); + + for (const observe of ["accept", "preview", "list", "cancel", "create"] as const) { + it(`persists expiry exactly once when observed by ${observe}, including rejected mutations`, async () => { + const { invitation } = await invite(); + const deadline = new Date(Date.now() - 1000); + await Invitation.update({ expiresAt: deadline }, { where: { id: invitation.id } }); + if (observe === "accept") await expect(accept(invitation.id)).rejects.toMatchObject({ code: "INVITATION_EXPIRED" }); + if (observe === "cancel") + await expect(cancelManagedProfileInvitation(owner.id, child, invitation.id)).rejects.toMatchObject({ + code: "INVITATION_EXPIRED" + }); + if (observe === "preview") + expect(await readOrAcceptManagedProfileInvitation(principal(), invitation.id, false)).toMatchObject({ + invitation: { status: "expired" } + }); + if (observe === "list") + expect((await listManagedProfileInvitations(owner.id, child, 50, 0)).invitations[0].status).toBe("expired"); + if (observe === "create") expect((await invite()).invitation.id).not.toBe(invitation.id); + await expect(accept(invitation.id)).rejects.toMatchObject({ code: "INVITATION_EXPIRED" }); + expect((await Invitation.findByPk(invitation.id))?.expiredAt).toEqual(deadline); + expect(await eventCount("invitation_expired")).toBe(1); + }); + } + + it("commits expired predecessor even when replacement conflicts with an active membership", async () => { + const { invitation } = await invite("manager", invitee.email!); + await Invitation.update({ expiresAt: new Date(Date.now() - 1000) }, { where: { id: invitation.id } }); + await Membership.create({ managedProfileId: child, memberProfileId: invitee.id, role: "manager" }); + spyOn(SupabaseAuthService, "getUserProfile").mockResolvedValue({ + id: invitee.id, + email: invitee.email, + email_confirmed_at: "2026-09-01T00:00:00Z" + } as never); + await expect(invite("manager", invitee.email!)).rejects.toMatchObject({ code: "MEMBERSHIP_ALREADY_EXISTS" }); + expect(await eventCount("invitation_expired")).toBe(1); + expect((await Invitation.findByPk(invitation.id))?.expiredAt).toBeInstanceOf(Date); + }); + + it("serializes cancellation versus acceptance with exactly one terminal event", async () => { + const { invitation } = await invite(); + const results = await Promise.allSettled([ + cancelManagedProfileInvitation(owner.id, child, invitation.id), + accept(invitation.id) + ]); + expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1); + expect((await eventCount("invitation_cancelled")) + (await eventCount("invitation_accepted"))).toBe(1); + const terminal = await Invitation.findByPk(invitation.id); + if (terminal?.cancelledAt) await expect(accept(invitation.id)).rejects.toMatchObject({ code: "INVITATION_CANCELLED" }); + await expect(cancelManagedProfileInvitation(owner.id, child, invitation.id)).rejects.toMatchObject({ status: 409 }); + }); + + it("rolls acceptance and membership back if the atomic history write fails", async () => { + const { invitation } = await invite(); + spyOn(MembershipEvent, "bulkCreate").mockRejectedValue(new Error("audit unavailable")); + await expect(accept(invitation.id)).rejects.toThrow("audit unavailable"); + expect(await Membership.count({ where: { memberProfileId: invitee.id } })).toBe(0); + expect((await Invitation.findByPk(invitation.id))?.acceptedAt).toBeNull(); + }); + + it("allows invited managers but rechecks downgraded and revoked authority for every operation", async () => { + const { invitation } = await invite(); + await accept(invitation.id); + await invite("read_only", "other@example.com", invitee.id); + await changeManagedProfileMember(owner.id, child, invitee.id, "read_only"); + expect((await listManagedProfileMembers(invitee.id, child, 50, 0)).members).toHaveLength(2); + expect((await listManagedProfileInvitations(invitee.id, child, 50, 0)).invitations).toHaveLength(2); + expect((await listManagedProfileMemberEvents(invitee.id, child, 50)).events.length).toBeGreaterThan(0); + for (const mutation of [ + () => invite("manager", "denied@example.com", invitee.id), + () => changeManagedProfileMember(invitee.id, child, owner.id, "read_only"), + () => removeManagedProfileMember(invitee.id, child, owner.id), + () => cancelManagedProfileInvitation(invitee.id, child, invitation.id) + ]) + await expect(mutation()).rejects.toMatchObject({ code: "MANAGED_PROFILE_MANAGER_REQUIRED" }); + await removeManagedProfileMember(owner.id, child, invitee.id); + await expect(listManagedProfileMembers(invitee.id, child, 50, 0)).rejects.toMatchObject({ + code: "MANAGED_PROFILE_ACCESS_DENIED" + }); + await expect(invite("manager", "denied@example.com", invitee.id)).rejects.toMatchObject({ + code: "MANAGED_PROFILE_ACCESS_DENIED" + }); + }); + + it("protects the owner under concurrent changes and keeps role/removal histories idempotent", async () => { + const attempts = await Promise.allSettled([ + changeManagedProfileMember(owner.id, child, owner.id, "read_only"), + removeManagedProfileMember(owner.id, child, owner.id) + ]); + expect( + attempts.every( + result => result.status === "rejected" && result.reason.code === "MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED" + ) + ).toBe(true); + await accept((await invite()).invitation.id); + await changeManagedProfileMember(owner.id, child, invitee.id, "read_only"); + await changeManagedProfileMember(owner.id, child, invitee.id, "read_only"); + const changed = await MembershipEvent.findOne({ where: { action: "role_changed" } }); + expect(changed).toMatchObject({ + actorProfileId: owner.id, + memberProfileId: invitee.id, + previousRole: "manager", + role: "read_only" + }); + expect(await eventCount("role_changed")).toBe(1); + await removeManagedProfileMember(owner.id, child, invitee.id); + await removeManagedProfileMember(owner.id, child, invitee.id); + expect(await eventCount("member_removed")).toBe(1); + await expect(removeManagedProfileMember(owner.id, child, crypto.randomUUID())).rejects.toMatchObject({ + code: "MEMBER_NOT_FOUND" + }); + }); + + it("rolls role changes, removal, and cancellation back when their audit insert fails", async () => { + await accept((await invite()).invitation.id); + const pending = await invite("read_only", "other@example.com"); + spyOn(MembershipEvent, "create").mockRejectedValue(new Error("audit unavailable")); + await expect(changeManagedProfileMember(owner.id, child, invitee.id, "read_only")).rejects.toThrow("audit unavailable"); + await expect(removeManagedProfileMember(owner.id, child, invitee.id)).rejects.toThrow("audit unavailable"); + await expect(cancelManagedProfileInvitation(owner.id, child, pending.invitation.id)).rejects.toThrow("audit unavailable"); + expect(await Membership.findOne({ where: { memberProfileId: invitee.id, revokedAt: null } })).toMatchObject({ + role: "manager" + }); + expect((await Invitation.findByPk(pending.invitation.id))?.cancelledAt).toBeNull(); + }); + + for (const action of ["downgrade", "revoke"] as const) { + it(`rechecks live manager authority after a concurrent ${action}`, async () => { + await accept((await invite()).invitation.id); + const transaction = await sequelize.transaction(); + await ManagedProfileManager.findByPk(owner.id, { lock: Transaction.LOCK.UPDATE, transaction }); + let reached!: () => void; + const waiting = new Promise(resolve => { + reached = resolve; + }); + const original = ManagedProfileManager.findByPk.bind(ManagedProfileManager); + spyOn(ManagedProfileManager, "findByPk").mockImplementation((...args) => { + reached(); + return original(...args); + }); + const creating = invite("manager", "denied@example.com", invitee.id).then( + result => result, + error => error + ); + try { + await waiting; + await Membership.update( + action === "downgrade" ? { role: "read_only" } : { revokedAt: new Date(), revokedByProfileId: owner.id }, + { transaction, where: { managedProfileId: child, memberProfileId: invitee.id, revokedAt: null } } + ); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + expect(await creating).toMatchObject({ + code: action === "downgrade" ? "MANAGED_PROFILE_MANAGER_REQUIRED" : "MANAGED_PROFILE_ACCESS_DENIED" + }); + expect(await Invitation.count()).toBe(1); + }); + } + + it("does not leak another child's members, invitations, or events", async () => { + for (const read of [ + () => listManagedProfileMembers(invitee.id, child, 50, 0), + () => listManagedProfileInvitations(invitee.id, child, 50, 0), + () => listManagedProfileMemberEvents(invitee.id, child, 50) + ]) + await expect(read()).rejects.toMatchObject({ code: "MANAGED_PROFILE_ACCESS_DENIED" }); + const foreign = await createTestUser(); + await expect(listManagedProfileMembers(owner.id, foreign.id, 50, 0)).rejects.toMatchObject({ + code: "MANAGED_PROFILE_ACCESS_DENIED" + }); + }); + + it("paginates tied event timestamps without missing or repeating rows and rejects foreign cursors", async () => { + const timestamp = new Date(); + await MembershipEvent.bulkCreate( + Array.from({ length: 5 }, () => ({ action: "invited" as const, managedProfileId: child, createdAt: timestamp })) + ); + const ids: string[] = []; + let cursor: string | undefined; + do { + const page = await listManagedProfileMemberEvents(owner.id, child, 2, cursor); + ids.push(...page.events.map(event => event.id)); + cursor = page.pagination.nextCursor ?? undefined; + } while (cursor); + expect(new Set(ids).size).toBe(6); + expect(ids).toHaveLength(6); + await expect(listManagedProfileMemberEvents(owner.id, child, 2, crypto.randomUUID())).rejects.toMatchObject({ + code: "INVALID_PAGINATION" + }); + }); + + for (const deactivate of ["owner", "child"] as const) { + it(`rechecks ${deactivate} state after waiting on the lifecycle owner lock`, async () => { + const { invitation } = await invite(); + const transaction = await sequelize.transaction(); + await ManagedProfileManager.findByPk(owner.id, { lock: Transaction.LOCK.UPDATE, transaction }); + let reached!: () => void; + const waiting = new Promise(resolve => { + reached = resolve; + }); + const original = ManagedProfileManager.findByPk.bind(ManagedProfileManager); + spyOn(ManagedProfileManager, "findByPk").mockImplementation((...args) => { + reached(); + return original(...args); + }); + const accepting = accept(invitation.id).then( + result => result, + error => error + ); + try { + await waiting; + if (deactivate === "owner") + await ManagedProfileManager.update({ isActive: false }, { transaction, where: { profileId: owner.id } }); + else + await ManagedProfile.update( + { status: "deleted", deletedAt: new Date() }, + { transaction, where: { profileId: child } } + ); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + expect(await accepting).toMatchObject({ code: "MANAGED_PROFILE_ACCESS_DENIED" }); + expect(await eventCount("invitation_accepted")).toBe(0); + }); + } + + it("denies all team reads and acceptance after owner lifecycle deletion", async () => { + const { invitation } = await invite(); + await deleteManagedProfile(owner.id, child); + await expect(accept(invitation.id)).rejects.toMatchObject({ code: "MANAGED_PROFILE_ACCESS_DENIED" }); + await expect(listManagedProfileMembers(owner.id, child, 50, 0)).rejects.toMatchObject({ + code: "MANAGED_PROFILE_ACCESS_DENIED" + }); + }); +}); diff --git a/apps/api/src/api/services/managed-profile-membership.service.ts b/apps/api/src/api/services/managed-profile-membership.service.ts new file mode 100644 index 000000000..86bad7365 --- /dev/null +++ b/apps/api/src/api/services/managed-profile-membership.service.ts @@ -0,0 +1,516 @@ +import { Op, Transaction } from "sequelize"; +import { z } from "zod"; +import sequelize from "../../config/database"; +import CustomerEntity from "../../models/customerEntity.model"; +import ManagedProfile from "../../models/managedProfile.model"; +import ManagedProfileManager from "../../models/managedProfileManager.model"; +import Membership, { type ManagedProfileMembershipRole } from "../../models/managedProfileMembership.model"; +import MembershipEvent from "../../models/managedProfileMembershipEvent.model"; +import Invitation from "../../models/managedProfileMembershipInvitation.model"; +import User from "../../models/user.model"; +import { SupabaseAuthService } from "./auth"; +import { enqueueManagedProfileInvitation } from "./email/notification.service"; + +export class ManagedProfileMembershipError extends Error { + constructor( + readonly code: string, + readonly status: number, + message: string + ) { + super(message); + this.name = "ManagedProfileMembershipError"; + } +} + +function accessDenied(): ManagedProfileMembershipError { + return new ManagedProfileMembershipError("MANAGED_PROFILE_ACCESS_DENIED", 403, "Managed-profile access is denied"); +} + +export function requireMembershipRole(role: unknown): ManagedProfileMembershipRole { + if (role !== "manager" && role !== "read_only") { + throw new ManagedProfileMembershipError("INVALID_MEMBERSHIP_ROLE", 400, "Role must be manager or read_only"); + } + return role; +} + +export function normalizeMembershipEmail(email: unknown): string { + const result = z.string().trim().toLowerCase().max(254).email().safeParse(email); + if (!result.success) { + throw new ManagedProfileMembershipError("INVALID_INVITATION_EMAIL", 400, "A valid email is required"); + } + return result.data; +} + +// Match provisioning/deletion: owner configuration, owner profile, child profile/aggregate, +// then invitation/membership rows. The initial relationship lookup is only a lock locator. +async function lockChild(profileId: string, transaction: Transaction): Promise { + const locator = await ManagedProfile.findOne({ transaction, where: { profileId } }); + if (!locator) throw accessDenied(); + const options = { lock: Transaction.LOCK.UPDATE, transaction }; + const owner = await ManagedProfileManager.findByPk(locator.managerProfileId, options); + // Human profiles are read-only here. SHARE protects kind changes while allowing + // reciprocal owners/invitees and the KEY SHARE locks taken by audit/profile FKs. + const ownerProfile = await User.findByPk(locator.managerProfileId, { lock: Transaction.LOCK.SHARE, transaction }); + const child = await User.findByPk(profileId, options); + const relationship = await ManagedProfile.findOne({ ...options, where: { profileId } }); + if ( + !owner?.isActive || + ownerProfile?.kind !== "authenticated" || + child?.kind !== "managed" || + !child.activeCustomerEntityId || + relationship?.status !== "active" || + relationship.managerProfileId !== locator.managerProfileId + ) + throw accessDenied(); + const entities = await CustomerEntity.findAll({ ...options, where: { profileId } }); + if (entities.length !== 1 || entities[0].id !== child.activeCustomerEntityId || entities[0].status !== "active") { + throw accessDenied(); + } + const ownerMembership = await Membership.findOne({ + ...options, + where: { managedProfileId: profileId, memberProfileId: ownerProfile.id, revokedAt: null } + }); + if (ownerMembership?.role !== "manager") throw accessDenied(); + return relationship; +} + +async function requireMember(actorProfileId: string, profileId: string, manage: boolean, transaction: Transaction) { + const actor = await User.findByPk(actorProfileId, { lock: Transaction.LOCK.SHARE, transaction }); + const member = await Membership.findOne({ + lock: Transaction.LOCK.UPDATE, + transaction, + where: { managedProfileId: profileId, memberProfileId: actorProfileId, revokedAt: null } + }); + if (actor?.kind !== "authenticated" || !member || !["manager", "read_only"].includes(member.role)) throw accessDenied(); + if (manage && member.role !== "manager") { + throw new ManagedProfileMembershipError( + "MANAGED_PROFILE_MANAGER_REQUIRED", + 403, + "An active manager membership is required" + ); + } +} + +function invitationStatus(invitation: Invitation) { + return invitation.acceptedAt + ? "accepted" + : invitation.cancelledAt + ? "cancelled" + : invitation.expiredAt + ? "expired" + : "pending"; +} + +function invitationResult(invitation: Invitation) { + return { + acceptedAt: invitation.acceptedAt, + cancelledAt: invitation.cancelledAt, + createdAt: invitation.createdAt, + email: invitation.email, + expiredAt: invitation.expiredAt, + expiresAt: invitation.expiresAt, + id: invitation.id, + invitedByProfileId: invitation.invitedByProfileId, + managedProfileId: invitation.managedProfileId, + role: invitation.role, + status: invitationStatus(invitation) + }; +} + +function memberResult(member: Membership, ownerProfileId: string) { + return { + createdAt: member.createdAt, + id: member.id, + isOwner: member.memberProfileId === ownerProfileId, + memberProfileId: member.memberProfileId, + role: member.role, + updatedAt: member.updatedAt + }; +} + +async function expireInvitation(invitation: Invitation, transaction: Transaction): Promise { + if (invitationStatus(invitation) !== "pending" || invitation.expiresAt.getTime() > Date.now()) return; + await invitation.update({ expiredAt: invitation.expiresAt }, { logging: false, transaction }); + await MembershipEvent.create( + { + action: "invitation_expired", + invitationId: invitation.id, + managedProfileId: invitation.managedProfileId, + role: invitation.role + }, + { transaction } + ); +} + +function terminalInvitation(invitation: Invitation) { + return new ManagedProfileMembershipError( + `INVITATION_${invitationStatus(invitation).toUpperCase()}`, + 409, + "The invitation is no longer pending" + ); +} + +export async function listManagedProfileMembers(actorProfileId: string, profileId: string, limit: number, offset: number) { + return sequelize.transaction(async transaction => { + const child = await lockChild(profileId, transaction); + await requireMember(actorProfileId, profileId, false, transaction); + const { rows, count } = await Membership.findAndCountAll({ + limit, + offset, + order: [ + ["createdAt", "ASC"], + ["id", "ASC"] + ], + transaction, + where: { managedProfileId: profileId, revokedAt: null } + }); + const profiles = await User.findAll({ + attributes: ["id", "email"], + transaction, + where: { id: { [Op.in]: rows.map(row => row.memberProfileId) } } + }); + const emails = new Map(profiles.map(profile => [profile.id, profile.email])); + return { + members: rows.map(row => ({ + ...memberResult(row, child.managerProfileId), + email: emails.get(row.memberProfileId) ?? null + })), + pagination: { limit, offset, total: count } + }; + }); +} + +export async function changeManagedProfileMember( + actorProfileId: string, + profileId: string, + memberProfileId: string, + role: unknown +) { + const nextRole = requireMembershipRole(role); + return sequelize.transaction(async transaction => { + const child = await lockChild(profileId, transaction); + await requireMember(actorProfileId, profileId, true, transaction); + if (memberProfileId === child.managerProfileId) { + throw new ManagedProfileMembershipError( + "MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED", + 409, + "The owner membership cannot be changed" + ); + } + const member = await Membership.findOne({ + lock: Transaction.LOCK.UPDATE, + transaction, + where: { managedProfileId: profileId, memberProfileId, revokedAt: null } + }); + if (!member) throw new ManagedProfileMembershipError("MEMBER_NOT_FOUND", 404, "Member was not found"); + if (member.role !== nextRole) { + const previousRole = member.role; + await member.update({ role: nextRole }, { transaction }); + await MembershipEvent.create( + { action: "role_changed", actorProfileId, managedProfileId: profileId, memberProfileId, previousRole, role: nextRole }, + { transaction } + ); + } + return { member: memberResult(member, child.managerProfileId) }; + }); +} + +export async function removeManagedProfileMember(actorProfileId: string, profileId: string, memberProfileId: string) { + await sequelize.transaction(async transaction => { + const child = await lockChild(profileId, transaction); + await requireMember(actorProfileId, profileId, true, transaction); + if (memberProfileId === child.managerProfileId) { + throw new ManagedProfileMembershipError( + "MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED", + 409, + "The owner membership cannot be removed" + ); + } + const member = await Membership.findOne({ + lock: Transaction.LOCK.UPDATE, + transaction, + where: { managedProfileId: profileId, memberProfileId, revokedAt: null } + }); + if (!member) { + const removed = await Membership.findOne({ + transaction, + where: { + managedProfileId: profileId, + memberProfileId, + revokedAt: { [Op.ne]: null }, + revokedByProfileId: actorProfileId + } + }); + if (removed) return; + throw new ManagedProfileMembershipError("MEMBER_NOT_FOUND", 404, "Member was not found"); + } + await member.update({ revokedAt: new Date(), revokedByProfileId: actorProfileId }, { transaction }); + await MembershipEvent.create( + { action: "member_removed", actorProfileId, managedProfileId: profileId, memberProfileId, role: member.role }, + { transaction } + ); + }); +} + +export async function createManagedProfileInvitation( + actorProfileId: string, + profileId: string, + input: { email: unknown; role: unknown } +) { + const email = normalizeMembershipEmail(input.email); + const role = requireMembershipRole(input.role); + const result = await sequelize.transaction(async transaction => { + await lockChild(profileId, transaction); + await requireMember(actorProfileId, profileId, true, transaction); + const pending = await Invitation.findOne({ + lock: Transaction.LOCK.UPDATE, + logging: false, + transaction, + where: { acceptedAt: null, cancelledAt: null, email, expiredAt: null, managedProfileId: profileId } + }); + if (pending) { + await expireInvitation(pending, transaction); + if (invitationStatus(pending) === "pending") { + if (pending.role !== role) + return new ManagedProfileMembershipError( + "INVITATION_ROLE_CONFLICT", + 409, + "Cancel the pending invitation before changing its role" + ); + return { created: false, invitation: invitationResult(pending) }; + } + } + // Only disclose membership already visible in this child's roster, never profile existence. + const existingProfile = await User.findOne({ + logging: false, + transaction, + where: { + kind: "authenticated", + [Op.and]: sequelize.where(sequelize.fn("lower", sequelize.fn("trim", sequelize.col("email"))), email) + } + }); + if ( + existingProfile && + (await Membership.findOne({ + transaction, + where: { managedProfileId: profileId, memberProfileId: existingProfile.id, revokedAt: null } + })) + ) { + // Local email is only a hint: it may belong to someone else since the member + // changed their login. An unconfirmed lookup must not veto an email-bound invite; + // acceptance still requires the current verified principal and checks membership. + const current = await SupabaseAuthService.getUserProfile(existingProfile.id).catch(() => null); + if ( + current?.id === existingProfile.id && + current.email_confirmed_at && + Number.isFinite(Date.parse(current.email_confirmed_at)) && + current.email?.trim().toLowerCase() === email + ) { + return new ManagedProfileMembershipError("MEMBERSHIP_ALREADY_EXISTS", 409, "An active membership already exists"); + } + } + const invitation = await Invitation.create( + { + email, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + invitedByProfileId: actorProfileId, + managedProfileId: profileId, + role + }, + { logging: false, transaction } + ); + await MembershipEvent.create( + { action: "invited", actorProfileId, invitationId: invitation.id, managedProfileId: profileId, role }, + { transaction } + ); + await enqueueManagedProfileInvitation({ invitationId: invitation.id, recipientEmail: email }, transaction); + return { created: true, invitation: invitationResult(invitation) }; + }); + if (result instanceof ManagedProfileMembershipError) throw result; + return result; +} + +export async function listManagedProfileInvitations(actorProfileId: string, profileId: string, limit: number, offset: number) { + return sequelize.transaction(async transaction => { + await lockChild(profileId, transaction); + await requireMember(actorProfileId, profileId, false, transaction); + const elapsed = await Invitation.findAll({ + lock: Transaction.LOCK.UPDATE, + logging: false, + transaction, + where: { + acceptedAt: null, + cancelledAt: null, + expiredAt: null, + expiresAt: { [Op.lte]: new Date() }, + managedProfileId: profileId + } + }); + for (const invitation of elapsed) await expireInvitation(invitation, transaction); + const { rows, count } = await Invitation.findAndCountAll({ + limit, + logging: false, + offset, + order: [ + ["createdAt", "DESC"], + ["id", "DESC"] + ], + transaction, + where: { managedProfileId: profileId } + }); + return { invitations: rows.map(invitationResult), pagination: { limit, offset, total: count } }; + }); +} + +export async function cancelManagedProfileInvitation(actorProfileId: string, profileId: string, invitationId: string) { + const result = await sequelize.transaction(async transaction => { + await lockChild(profileId, transaction); + await requireMember(actorProfileId, profileId, true, transaction); + const invitation = await Invitation.findOne({ + lock: Transaction.LOCK.UPDATE, + logging: false, + transaction, + where: { id: invitationId, managedProfileId: profileId } + }); + if (!invitation) throw new ManagedProfileMembershipError("INVITATION_NOT_FOUND", 404, "Invitation was not found"); + await expireInvitation(invitation, transaction); + if (invitationStatus(invitation) !== "pending") return terminalInvitation(invitation); + await invitation.update({ cancelledAt: new Date(), cancelledByProfileId: actorProfileId }, { logging: false, transaction }); + await MembershipEvent.create( + { action: "invitation_cancelled", actorProfileId, invitationId, managedProfileId: profileId, role: invitation.role }, + { transaction } + ); + }); + if (result instanceof ManagedProfileMembershipError) throw result; +} + +export interface ManagedProfileInvitee { + profileId: string; + email?: string; + emailConfirmedAt?: string; +} + +export async function readOrAcceptManagedProfileInvitation( + principal: ManagedProfileInvitee, + invitationId: string, + accept: boolean +) { + if (!principal.emailConfirmedAt || !Number.isFinite(Date.parse(principal.emailConfirmedAt))) throw accessDenied(); + let email: string; + try { + email = normalizeMembershipEmail(principal.email); + } catch { + throw accessDenied(); + } + const result = await sequelize.transaction(async transaction => { + const locator = await Invitation.findByPk(invitationId, { logging: false, transaction }); + if (!locator || locator.email !== email) throw accessDenied(); + const child = await lockChild(locator.managedProfileId, transaction); + const invitation = await Invitation.findByPk(invitationId, { lock: Transaction.LOCK.UPDATE, logging: false, transaction }); + const actor = await User.findByPk(principal.profileId, { lock: Transaction.LOCK.SHARE, transaction }); + if (!invitation || invitation.email !== email || actor?.kind !== "authenticated") throw accessDenied(); + await expireInvitation(invitation, transaction); + if (!accept) { + const inviter = await User.findByPk(invitation.invitedByProfileId, { attributes: ["id", "email"], transaction }); + return { + invitation: invitationResult(invitation), + inviter: { email: inviter?.email ?? null, profileId: invitation.invitedByProfileId }, + managedProfile: { externalSubjectId: child.externalSubjectId, profileId: child.profileId } + }; + } + const membership = await Membership.findOne({ + lock: Transaction.LOCK.UPDATE, + transaction, + where: { managedProfileId: child.profileId, memberProfileId: actor.id, revokedAt: null } + }); + if (invitation.acceptedAt && invitation.acceptedByProfileId === actor.id && membership?.role === invitation.role) { + return { managedProfileId: child.profileId, member: memberResult(membership, child.managerProfileId) }; + } + // Return conflicts from the callback so observed expiry and its event commit before rejection. + if (invitationStatus(invitation) !== "pending") return terminalInvitation(invitation); + if (membership) + return new ManagedProfileMembershipError("MEMBERSHIP_ALREADY_EXISTS", 409, "An active membership already exists"); + const role = requireMembershipRole(invitation.role); + const member = await Membership.create( + { createdByProfileId: invitation.invitedByProfileId, managedProfileId: child.profileId, memberProfileId: actor.id, role }, + { transaction } + ); + await invitation.update({ acceptedAt: new Date(), acceptedByProfileId: actor.id }, { logging: false, transaction }); + await MembershipEvent.bulkCreate( + [ + { + action: "member_added", + actorProfileId: actor.id, + invitationId, + managedProfileId: child.profileId, + memberProfileId: actor.id, + role + }, + { + action: "invitation_accepted", + actorProfileId: actor.id, + invitationId, + managedProfileId: child.profileId, + memberProfileId: actor.id, + role + } + ], + { transaction } + ); + return { managedProfileId: child.profileId, member: memberResult(member, child.managerProfileId) }; + }); + if (result instanceof ManagedProfileMembershipError) throw result; + return result; +} + +export async function listManagedProfileMemberEvents( + actorProfileId: string, + profileId: string, + limit: number, + cursor?: string +) { + return sequelize.transaction(async transaction => { + await lockChild(profileId, transaction); + await requireMember(actorProfileId, profileId, false, transaction); + if (cursor && !(await MembershipEvent.findOne({ transaction, where: { id: cursor, managedProfileId: profileId } }))) { + throw new ManagedProfileMembershipError( + "INVALID_PAGINATION", + 400, + "Cursor must identify an event in this managed profile" + ); + } + const events = await MembershipEvent.findAll({ + limit: limit + 1, + order: [ + ["createdAt", "DESC"], + ["id", "DESC"] + ], + transaction, + where: { + managedProfileId: profileId, + ...(cursor + ? { + // Compare in PostgreSQL, preserving timestamp precision and a stable UUID tie-breaker. + [Op.and]: sequelize.literal( + `("created_at", "id") < (SELECT "created_at", "id" FROM "managed_profile_membership_events" WHERE "id" = ${sequelize.escape(cursor)})` + ) + } + : {}) + } + }); + const page = events.slice(0, limit); + return { + events: page.map(event => ({ + action: event.action, + actorProfileId: event.actorProfileId, + createdAt: event.createdAt, + id: event.id, + invitationId: event.invitationId, + memberProfileId: event.memberProfileId, + previousRole: event.previousRole, + role: event.role + })), + pagination: { limit, nextCursor: events.length > limit ? page[page.length - 1].id : null } + }; + }); +} diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index 588895c32..32669f20f 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -14,7 +14,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api **Input validation** (`middlewares/validators.ts`): -- Hand-written validators for each endpoint (no schema library like Zod/Joi) +- Predominantly hand-written validators; membership email normalization/validation also uses Zod - Validators check field presence, type, and basic format (e.g., valid address, valid enum) - Applied as Express middleware on route definitions @@ -45,7 +45,31 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - During an active window, mutable quote/ramp operations return HTTP `503 Service Unavailable` before controller/service work starts. - Rejections include `Retry-After`, `Cache-Control: no-store`, and downtime metadata (`maintenance_start`, `maintenance_end`, affected operations) in the error payload so direct API clients can pause and retry after the window. -**Route structure:** 43 `*.route.ts` files under `api/routes/` (36 under `v1/`), plus `v1/index.ts`, each mounting controllers with appropriate auth middleware. `api/routes/api-surface-inventory.test.ts` derives this count from the tree so the audit inventory cannot silently stale. +**Route structure:** 44 `*.route.ts` files under `api/routes/` (35 directly under `v1/`, seven under `v1/admin/`, and two under `v1/admin-console/`), plus `v1/index.ts`, each mounting controllers with appropriate auth middleware. `api/routes/api-surface-inventory.test.ts` derives the total from the tree and pins the multipart inventory below. + +**Managed membership inventory:** `v1/managed-profile-memberships.route.ts` supplies both the +child-path and invitee routers mounted by `v1/index.ts`. Its nine operations are: + +| Operation | Authority | +|---|---| +| `GET /v1/managed-profiles/:profileId/members` | Either active membership role | +| `PATCH /v1/managed-profiles/:profileId/members/:memberProfileId` | Active manager member; non-owner target | +| `DELETE /v1/managed-profiles/:profileId/members/:memberProfileId` | Active manager member; non-owner target | +| `GET /v1/managed-profiles/:profileId/member-invitations` | Either active membership role | +| `POST /v1/managed-profiles/:profileId/member-invitations` | Active manager member | +| `DELETE /v1/managed-profiles/:profileId/member-invitations/:invitationId` | Active manager member | +| `GET /v1/managed-profiles/:profileId/member-events` | Either active membership role | +| `GET /v1/managed-profile-member-invitations/:invitationId` | Exact current verified-email invitee | +| `POST /v1/managed-profile-member-invitations/:invitationId/accept` | Exact current verified-email invitee; explicit acceptance | + +All nine require Supabase bearer authentication and reject API-key headers (even alongside a +bearer) and impersonation. Child-path selectors must match the path case-insensitively; invitee +routes reject any selector. The routers share a 120-request/minute limit per authenticated actor, +in addition to the global IP limit. They run after the global body parser. Controller/service +errors use `{ error: { code, message, status } }`; session errors remain flat `{ error: string }` +and rate-limit responses are text. The membership route tests exercise all nine operations; +`docs:api:check` pins their exact public method/path, auth and response inventory. Full rules live +in [Managed-Profile Memberships](../01-auth/managed-profile-memberships.md). **Multipart uploads:** Four operations use in-memory Multer buffering. Alfredpay's `POST /v1/alfredpay/submitKycFile`, `submitKybFile`, and `submitKybRelatedPersonFile` (also mounted under the country aliases `/v1/mx`, `/v1/co`, and `/v1/ar`) allow one file up to 5MB; secret/Bearer authentication and the managed relationship/entity-type gate run before buffering, while multipart country authorization runs after parsing. On a country alias, the path-derived country replaces any multipart country field before that authorization. Mykobo's `POST /v1/mykobo/profiles` is Supabase-authenticated before buffering and accepts up to four named files (`front`, `back`, `face`, `utility_bill`), each up to 10MB. These routes bound individual file size but do not currently configure a MIME/type `fileFilter`; the Mykobo request can buffer up to 40MB in aggregate. @@ -72,26 +96,31 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api 19. **Multiple credential representations MUST be consistent** — a quote-body/query `apiKey` and `X-Public-Key` must be equal; a public and secret header must resolve to the same immutable credential ID. Mismatch returns `403 CREDENTIAL_MISMATCH` before business logic. 20. **Credential management MUST be owner-scoped and bounded** — `POST/GET/DELETE /v1/api-credentials` requires a Supabase session scoped to the subject profile (creation mints profile-managed credentials; list/revoke cover the profile's partner-managed credentials too), permits at most five active non-expired credentials per profile under a profile-row lock, and revokes one whole credential by immutable ID with no DELETE body. 21. **Credential startup MUST fail closed** — the process must not listen unless the complete `api_credentials` schema, nullability, indexes, and constraints exist and the legacy `api_keys` table is absent. Runtime auth must not fall back to legacy rows, hashes, prefixes, or pairing heuristics. -22. **`ramp-info` MUST expose only a sanitized subject-derived projection** — `GET /v1/ramp-info` may accept public or secret credential capability, but not a Supabase session. It derives the profile from `CredentialContext`; a manager secret may additionally use the authorization-derived `X-Managed-Profile-Id` selector, while a public key may not. It accepts no body/query profile, user, or PII identifier and returns only per-corridor `kycStatus`, `canBuy`, and `canSell`. +22. **`ramp-info` MUST expose only a sanitized subject-derived projection** — `GET /v1/ramp-info` may accept public or secret credential capability, but not a Supabase session. It derives the profile from `CredentialContext`; an active `manager` or `read_only` member's secret may additionally select its authorized child, while a public key may not. It accepts no body/query profile, user, or PII identifier and returns only per-corridor `kycStatus`, `canBuy`, and `canSell`. 23. **Managed-profile provisioning MUST use immutable associations** — `POST /v1/admin/managed-profiles` requires admin auth, normalizes email, and binds a genuine Supabase/profile identity to unique `(partner_id, external_user_id)` and unique `profile_id` records. Existing Auth identities may be reconciled only when their immutable metadata matches. Technical subjects must not receive customer entities or register ramps. 24. **Managed-profile context MUST be route-authorized** — On supported child-oriented routes, `X-Managed-Profile-Id` is a selector only. A Supabase session or secret API credential establishes an authenticated member actor; middleware verifies that actor's active `manager` or `read_only` membership, the immutable owner's active manager configuration, a direct active relationship, and the managed child with its single active customer entity before attaching an immutable actor/subject context. Every call site declares a read, management, credential-management, or ramp capability. Read-only members are rejected from every non-read capability even when presenting their own secret credential. Corridor- or customer-type-scoped operations additionally enforce the owner's current customer-type narrowing, every required corridor, and canonical corridor capability; policy-free status and historical reads remain available. Public API keys cannot establish the member actor, raw headers never alter `req.userId`, and ownership checks use the verified child subject. Target-specific authorization must resolve the target under that verified child before evaluating its stored corridor, preserving the route's missing-resource response for foreign targets and never falling back to the actor's resource. A child-owned credential instead authenticates directly as its child and dynamically derives the same controlling relationship and current policy; it cannot select another child and does not require a membership row. Admin impersonation may compose with managed selection only on permitted read/management operations; the admin remains attributable through the impersonation context. Owner-manager or relationship deactivation, membership revocation, and policy narrowing block authorization decisions begun after the committed change but do not cancel already-authorized requests in flight. The header is explicitly CORS-allowlisted, and relationship plus route-specific entity-type authorization precedes multipart buffering; multipart country authorization follows body parsing. -25. **Headless profile lifecycle MUST fail closed** — Create and delete derive the immutable owner from a Supabase session or profile-bound secret credential and cannot be delegated to another `manager` member. List/read return only the authenticated actor's active memberships with role and immutable owner policy; direct reads may inspect retained deleted children. Creation requires an immutable provider contact email separate from the child's null login email; normalized contact emails are unique and permanently reserved within each owner. Child credential listing permits either active role, while create/revoke requires `manager`; every operation is path-child scoped, and only this route may issue credentials for a managed subject. Generic profile-managed and admin partner-managed creation reject managed subjects. Credential creation and logical deletion lock the child profile and relationship in a common order; deletion is idempotent, revokes child credentials in the same transaction, and leaves retained provider, KYC, quote, ramp, and callback state intact. Provisioning atomically creates the owner membership and append-only event. Managed profiles cannot create a second customer-entity type after provisioning. +25. **Headless profile lifecycle MUST fail closed** — Child creation/deletion remain immutable-owner-only; a non-owner active member's deletion returns `403 MANAGED_PROFILE_OWNER_REQUIRED`. List/detail return actor identity and independent provisioning/membership flags, child membership role and owner policy. Default active lists return an empty `200` for actors without eligible memberships; `hasMemberships` uses the unpaginated eligible active-child count, excluding deleted children, inactive owners and invalid entity layouts. Both `status=deleted` and `status=all` require the actor's own active manager configuration and contain only owned children. Retained deleted-child detail requires the active immutable owner, valid membership/entity layout and no selector; invited members and ineligible retained reads receive masked `404`. Explicit matching-selector detail bootstrap requires stored membership history before returning `MANAGED_PROFILE_MEMBERSHIP_INVALID`; deleted-child bootstrap invalidates even for its owner, while never-member existing/unknown probes receive identical `404`s. Bearer and member-secret callers follow the same rules. Creation uses immutable, owner-scoped reserved contact/external identifiers and atomically creates the owner membership/event. Any active `manager` member may issue/revoke child credentials; either role may list. Issuance/revocation recheck live authority under owner-first, child-aggregate, membership locks; generic credential routes reject managed subjects. Child deletion follows owner-first locking, revokes child credentials atomically, preserves financial/compliance history, and is idempotent while owner configuration remains active. Managed profiles cannot create a second customer-entity type. See [Managed-Profile Memberships](../01-auth/managed-profile-memberships.md#lifecycle-reads) for the complete read contract. 26. **Managed selector handling MUST be explicit per route** — Recipient invite preview and acceptance reject `X-Managed-Profile-Id` rather than redeeming as a selected child; sender-side recipient routes are delegated only after managed-profile authorization. Direct child credentials are rejected from webhook and manager lifecycle routes. Managed children have one immutable active customer entity from provisioning, so `PUT /v1/onboarding/active-entity` is not a delegated child operation. The legacy Monerium and Mykobo routes are the accepted exception: they ignore the selector and remain scoped to the Supabase-authenticated manager. Managed clients must not send the header to those routes, and dashboard child mode disables those actions. 27. **Public onboarding discovery MUST keep OpenAPI authoritative for request schemas** — `GET /v1/onboarding/requirements` is unauthenticated and returns only the reviewed static Avenia/Alfredpay flow identity, document requirements, ordered non-GET API/hosted/upload actions, workflow value bindings, and documentation/OpenAPI links. Initial reads, readiness getters, redirect getters, and status polling MUST NOT be advertised; integration documentation and OpenAPI own those completion details. No top-level field catalog or independent request schema is returned. `fixedBody`, `fixedQuery`, and `derivedValues` may bind provider discriminators or prior step outputs only to body/query fields accepted by the referenced OpenAPI operation. The endpoint MUST NOT inspect profile state, return customer or provider identifiers, accept an owner selector, or advertise unsupported combinations such as AR business or Monerium flows. Every advertised API step, request-schema fragment, and workflow-binding target is checked against the reviewed OpenAPI document so stale mappings fail the documentation gate. 28. **Selected-child bearer ramps MUST fail before body buffering** — The three mutable ramp entrypoints authenticate selected-child Supabase bearer requests ahead of the global JSON parser, reject impersonation, verify membership role, and return `403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL` for a valid manager member without reading the body. Member-owned secret credentials and direct child credentials continue through the normal parsed route and full body-derived corridor/ownership checks. +Selected-child provider `credential_manage` bearer denial uses the shipped +`MANAGED_PROFILE_REQUIRES_API_CREDENTIAL` code; child credential and domestic fiat-account +`manage` mutations still permit eligible manager-member bearer sessions. Membership and invitation +routes in the inventory above are bearer-only regardless of secret credential strength. + ## Threat Vectors & Mitigations | Threat | Mitigation | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **⚠️ Memory exhaustion via large request body** — Attacker sends a 20MB JSON payload repeatedly to exhaust server memory | Rate limiting (100 req/min) provides some protection, but 100 requests × 20MB = 2GB of memory pressure per minute per IP. **The 20MB limit should be reduced to 1-10MB.** | | **CORS bypass** — Attacker's site makes cross-origin requests to the API | Explicit origin whitelist prevents this. However, the whitelist includes `staging--pendulum-pay.netlify.app` — if the staging site is compromised or has XSS, it becomes a CORS-allowed origin in production. | -| **Rate limit bypass via IP rotation** — Attacker uses multiple IPs to exceed per-IP rate limits | No mitigation beyond the per-IP limit. No account-based rate limiting, no endpoint-specific limits, no progressive penalties. High-value endpoints (ramp creation, quote generation) get the same limit as read-only endpoints. | +| **Rate limit bypass via IP rotation** — Attacker uses multiple IPs to exceed per-IP rate limits | Membership routes also share a per-actor limit; this does not protect unrelated high-value ramp/quote routes from IP rotation. | | **Input validation bypass** — Validator doesn't check a field that the controller uses | Hand-written validators are prone to omissions. No schema library enforces completeness. New fields added to controllers may not get corresponding validators. | | **Mass assignment** — Extra fields in the request body are passed to database operations | Validators check for expected fields but don't strip unknown fields. If a controller passes `req.body` directly to a database query (e.g., Sequelize `create(req.body)`), extra fields could set unintended columns. | | **Error response information leak** — The `errors` array in error responses reveals internal validation logic or database field names | Error handler wraps errors in `APIError`. The `errors` array content depends on what validators put there. Validator messages reference field names from the API schema, not necessarily database internals, but should be audited. | | **Staging CORS origin in production** — `staging--pendulum-pay.netlify.app` is in the CORS whitelist | If the staging site has an XSS vulnerability, an attacker could use it to make authenticated cross-origin requests to the production API. Staging origins should ideally be removed from production CORS config. | -| **No per-endpoint rate limiting** — Sensitive endpoints (ramp creation, admin operations) have the same rate limit as public read endpoints | An attacker can create 100 ramps per minute per IP. For endpoints that trigger expensive operations (XCM, SquidRouter), this could amplify costs. | +| **High-value route rate limits** — Ramp operations share the global IP limit with public reads | The membership-specific per-actor limiter does not add protection to ramp routes that trigger expensive operations. | | **Cookie-based auth without CSRF protection** — Cookie parser is enabled for Supabase auth tokens | If auth tokens are stored in cookies (not just headers), cross-site requests from CORS-allowed origins could carry auth cookies automatically. Verify whether CSRF tokens or `SameSite` cookie attributes are used. | | **Observability side effects** — Event persistence failure breaks a partner-facing API call | Observability helpers must catch persistence/logging errors and run best-effort only. See `client-observability.md`. | | **Direct API bypass of UI maintenance mode** — Partner SDK or custom API clients ignore the frontend and continue creating quotes or mutating ramps during planned downtime | Mutable quote/ramp routes run the maintenance guard server-side and fail closed with `503 Service Unavailable`, `Retry-After`, and the active window's start/end timestamps. | @@ -105,7 +134,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - [ ] **⚠️ FINDING F-035**: `bodyParser.json({ limit: "20mb" })` — verify this limit is intentional. Recommend reducing to 1-10MB for a JSON API. **FAIL F-035** — 20MB limit remains high for a JSON API. - [ ] **FINDING F-036**: `staging--pendulum-pay.netlify.app` is in the production CORS whitelist — verify this is intentional and assess the risk of staging-site compromise. **FAIL F-036** — staging origin always in CORS whitelist regardless of `NODE_ENV`. -- [ ] **FINDING**: All validators are hand-written (no Zod/Joi) — verify every mutable endpoint has a corresponding validator middleware. **PARTIAL F-037** — hand-written validators exist but multiple sensitive endpoints lack authentication/validation entirely. +- [ ] Review validation coverage beyond the membership routes: most validators are hand-written, while membership email uses Zod. The bounded membership controller/service checks do not establish completeness for other mutable endpoints (F-037). - [x] Verify CORS does not use wildcard (`*`) or dynamic origin reflection — check `express.ts` for `origin: true` or callback patterns. **PASS** — explicit origin whitelist used; no wildcard or dynamic reflection. - [x] Verify rate limiting cannot be bypassed by removing or spoofing `X-Forwarded-For` headers — check how `express-rate-limit` identifies clients. **PASS** — `express-rate-limit` uses IP-based identification. - [x] Verify `Helmet` is configured with secure defaults — check for any disabled protections. **PASS** — Helmet enabled with default security headers. From 667cd4b51256fd1e74480cb70216ee108d52c2a5 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 7 Sep 2026 17:19:11 -0300 Subject: [PATCH 06/11] docs(api): publish managed profile membership contract --- docs/api/apidog/page-manifest.json | 16 +- docs/api/openapi/vortex.openapi.d.ts | 1187 ++++++++-- docs/api/openapi/vortex.openapi.json | 1978 ++++++++++++++--- .../03-authentication-and-partner-keys.md | 74 +- docs/api/pages/04-ramp-lifecycle.md | 2 + docs/api/pages/06-quotes-and-pricing.md | 2 + docs/api/pages/09-fiat-corridors.md | 10 +- docs/api/pages/12-ai-agent-integration.md | 4 + docs/api/pages/14-managed-profiles.md | 200 +- docs/api/scripts/check-openapi.ts | 340 ++- 10 files changed, 3263 insertions(+), 550 deletions(-) diff --git a/docs/api/apidog/page-manifest.json b/docs/api/apidog/page-manifest.json index 47f48b95f..6ba54b9df 100644 --- a/docs/api/apidog/page-manifest.json +++ b/docs/api/apidog/page-manifest.json @@ -46,6 +46,13 @@ "/v1/managed-profiles/{profileId}", "/v1/managed-profiles/{profileId}/api-credentials", "/v1/managed-profiles/{profileId}/api-credentials/{credentialId}", + "/v1/managed-profiles/{profileId}/members", + "/v1/managed-profiles/{profileId}/members/{memberProfileId}", + "/v1/managed-profiles/{profileId}/member-invitations", + "/v1/managed-profiles/{profileId}/member-invitations/{invitationId}", + "/v1/managed-profiles/{profileId}/member-events", + "/v1/managed-profile-member-invitations/{invitationId}", + "/v1/managed-profile-member-invitations/{invitationId}/accept", "/v1/onboarding/active-entity", "/v1/onboarding/requirements", "/v1/onboarding/status", @@ -74,6 +81,7 @@ "Vortex Widget", "Ramp", "Account Management", + "Managed Profiles", "Authentication", "Webhooks", "Public Key", @@ -138,7 +146,7 @@ "OTP sign-in", "crypto ramp authentication" ], - "metaDescription": "How Vortex authenticates clients with pk_*/sk_* keys or Supabase sessions, including managed-child delegation and secure BRL KYC token import.", + "metaDescription": "Authenticate with Vortex API keys or Supabase sessions: managed-child membership roles, bearer-only invitations, and secret-only provider and ramp mutations.", "metaTitle": "Authentication And API Keys — Vortex API" }, "slug": "authentication-and-partner-keys", @@ -322,9 +330,11 @@ "sub-accounts", "delegated ramps", "B2B crypto payouts", - "KYC on behalf" + "KYC on behalf", + "membership invitations", + "read-only access" ], - "metaDescription": "Create and operate headless Vortex profiles for your own customers: provisioning, delegated KYC/KYB onboarding, child credentials, and ramping on their behalf.", + "metaDescription": "Create headless Vortex customer profiles, invite manager or read-only members, accept access with verified email, and operate child credentials and ramps safely.", "metaTitle": "Managed Profiles — Onboard And Ramp For Your Customers" }, "slug": "managed-profiles", diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 2ef3faf89..4500fd261 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -114,6 +114,8 @@ export interface paths { * `quoteId` is optional: pass it in the normal ramp flow, or omit it for the quote-less KYB deep link where business verification starts before any quote exists. * * **Auth:** secret `X-API-Key` or Supabase Bearer session. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["createSubaccount"]; delete?: never; @@ -132,6 +134,8 @@ export interface paths { /** * Get user's KYC status * @description **Auth:** secret `X-API-Key` or Supabase Bearer session. + * + * **Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: operations["fetchSubaccountKycStatus"]; put?: never; @@ -154,6 +158,8 @@ export interface paths { * @description Returns the selfie/liveness-check URL for the subaccount associated with this tax ID. * * **Auth:** secret `X-API-Key` or Supabase Bearer session. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: operations["brGetSelfieLivenessUrl"]; put?: never; @@ -178,6 +184,8 @@ export interface paths { * @description Returns a presigned upload URL for the user's ID document and a provider-hosted URL for selfie liveness capture. Only `ID` and `DRIVERS-LICENSE` are accepted for `documentType` (passport not supported here). * * **Auth:** secret `X-API-Key` or Supabase Bearer session. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["brGetUploadUrls"]; delete?: never; @@ -195,7 +203,9 @@ export interface paths { }; /** * Get user information - * @description Fetches the authenticated subject's subaccount information. The response contains only the EVM wallet address and KYC level. Omit the deprecated taxId query to derive the canonical account from the authenticated subject; when supplied, taxId is only an ownership-checked cross-check. Managed-profile selection requires the manager's secret key or Bearer session. + * @description Fetches the authenticated subject's subaccount information. The response contains only the EVM wallet address and KYC level. Omit the deprecated taxId query to derive the canonical account from the authenticated subject; when supplied, taxId is only an ownership-checked cross-check. Managed-profile selection requires an active member's secret key or Bearer session. + * + * **Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: operations["getBrUser"]; put?: never; @@ -215,7 +225,9 @@ export interface paths { }; /** * Get user's remaining transaction limits - * @description Returns the authenticated subject's remaining BRL limit for the required ramp direction. Omit the deprecated taxId query to derive the canonical account from the authenticated subject; when supplied, taxId is only an ownership-checked cross-check. Managed-profile selection requires the manager's secret key or Bearer session. + * @description Returns the authenticated subject's remaining BRL limit for the required ramp direction. Omit the deprecated taxId query to derive the canonical account from the authenticated subject; when supplied, taxId is only an ownership-checked cross-check. Managed-profile selection requires an active member's secret key or Bearer session. + * + * **Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: operations["getBrUserRemainingLimit"]; put?: never; @@ -236,6 +248,8 @@ export interface paths { /** * Get KYB attempt status * @description Refreshes an owned KYB attempt and persists its normalized verification state. + * + * **Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: operations["getBrKybAttemptStatus"]; put?: never; @@ -258,6 +272,8 @@ export interface paths { /** * Create KYB document * @description Creates a document target. Ordinary documents return presigned upload URLs; `SELFIE-FROM-LIVENESS` returns a provider-hosted liveness URL instead. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["createBrKybDocument"]; delete?: never; @@ -276,6 +292,8 @@ export interface paths { /** * Get KYB document * @description Reads readiness and upload status for an owned KYB document. + * + * **Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: operations["getBrKybDocument"]; put?: never; @@ -298,6 +316,8 @@ export interface paths { /** * Submit API-driven KYB * @description Submits the API-driven Level 1 KYB attempt after validating the owned corporate documents and UBO references. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["submitBrKybLevel1Api"]; delete?: never; @@ -318,6 +338,8 @@ export interface paths { /** * Start hosted KYB * @description Starts or resumes the provider's hosted KYB level-1 flow for an owned company subaccount. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["startBrKybLevel1Hosted"]; delete?: never; @@ -338,6 +360,8 @@ export interface paths { /** * Create KYB UBO * @description Registers a UBO after verifying that referenced identity documents are ready and owned by the company subaccount. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["createBrKybUbo"]; delete?: never; @@ -357,13 +381,15 @@ export interface paths { put?: never; /** * Import an individual KYC token - * @description Imports an opaque Sumsub share token into the authenticated subject's existing individual KYC case. This alternative path is enabled by approved Vortex policy despite unresolved legal/consent wording and provider-environment confirmations; no live sandbox verification is claimed. Authentication and profile-bound principal enforcement run before managed-profile authorization and strict body validation. Use either a profile-bound secret `X-API-Key` or a Supabase Bearer session. A controlling manager may add `X-Managed-Profile-Id`; direct managed-child credentials are rejected even without the selector. Public and ownerless credentials are insufficient. + * @description Imports an opaque Sumsub share token into the authenticated subject's existing individual KYC case. This alternative path is enabled by approved Vortex policy despite unresolved legal/consent wording and provider-environment confirmations; no live sandbox verification is claimed. Authentication and profile-bound principal enforcement run before managed-profile authorization and strict body validation. For a direct non-managed profile, use a profile-bound secret `X-API-Key` or Supabase Bearer session. For a selected child, an active manager member must use a member-owned secret plus `X-Managed-Profile-Id`; direct managed-child credentials are rejected even without the selector. Public and ownerless credentials are insufficient. * * The body accepts only `importToken` and literal `consentAttested: true`; CPF, tax ID, subaccount ID, applicant ID, entity ID, provider-customer ID, profile ID, and other caller identity selectors are forbidden. The provisional server-controlled consent policy is `sumsub-share-v1`. Every token claim appends actor, subject, policy version, and timestamp consent evidence without storing the raw token. * * The first normal KYC artifact, status read, or token-import claim permanently selects that case's method. Import the token before reading KYC or onboarding status because a status read selects a nullable method as `standard`. The same idempotency key and token returns a stored confirmed attempt or safely reconciles a durable submitted/ambiguous claim through provider reads, without another provider POST or replaying the token. A different token under the same key returns `409`. A provider `401` means the feature precondition is unavailable, records a failed attempt, returns `412`, and may be retried only with a new idempotency key; the new claim appends consent evidence while preserving prior attestations. Every other post-send provider, transport, malformed-response, timeout, or local-confirmation failure is ambiguous, returns `502`, and is never replayed automatically. * * Acceptance is pending only. Vortex polls the exact returned provider attempt; `EXPIRED` remains non-approved and locally pending for reconciliation, and its external status is retained. Only a provider `COMPLETED` plus `APPROVED` completes KYC. The provider webhook is notification-only and cannot approve the case. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["importBrKycToken"]; delete?: never; @@ -384,6 +410,8 @@ export interface paths { /** * Record an initial KYC attempt * @description Validates an authenticated BRL onboarding preflight event. The asserted CPF or CNPJ is not persisted because quote ownership does not prove tax-ID ownership. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["recordInitialBrKycAttempt"]; delete?: never; @@ -406,6 +434,8 @@ export interface paths { * @description Submits the user's KYC level 1 payload to the provider after documents have been uploaded via `/v1/brl/getUploadUrls`. Includes a built-in 5-second delay to allow upstream document propagation. * * **Auth:** secret `X-API-Key` or Supabase Bearer session. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["brNewKyc"]; delete?: never; @@ -446,6 +476,8 @@ export interface paths { /** * Get customer status * @description Returns the local onboarding state after refreshing the latest provider submission when available. + * + * **Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: operations["getDomesticStatus"]; put?: never; @@ -468,6 +500,8 @@ export interface paths { /** * Create a business customer * @description Creates a business customer for the effective profile. Managed profiles use their immutable contact email. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["createDomesticBusinessCustomer"]; delete?: never; @@ -488,6 +522,8 @@ export interface paths { /** * Create an individual customer * @description Creates an individual customer for the effective profile. Managed profiles use their immutable contact email. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["createDomesticIndividualCustomer"]; delete?: never; @@ -506,12 +542,16 @@ export interface paths { /** * List fiat accounts * @description Lists payout fiat accounts for the effective customer. + * + * **Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: operations["listDomesticFiatAccounts"]; put?: never; /** * Create a fiat account * @description Creates a payout fiat account for the effective customer. Required optional fields depend on the selected account type and corridor. + * + * **Managed selection (manage):** Requires a live manager membership. A member-owned secret or Supabase bearer is supported; read_only receives 403 MANAGED_PROFILE_MANAGER_REQUIRED. Fiat-account management is not secret-only credential_manage. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["createDomesticFiatAccount"]; delete?: never; @@ -533,6 +573,8 @@ export interface paths { /** * Delete a fiat account * @description Deletes one payout fiat account belonging to the effective customer. + * + * **Managed selection (manage):** Requires a live manager membership. A member-owned secret or Supabase bearer is supported; read_only receives 403 MANAGED_PROFILE_MANAGER_REQUIRED. Fiat-account management is not secret-only credential_manage. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ delete: operations["deleteDomesticFiatAccount"]; options?: never; @@ -550,6 +592,8 @@ export interface paths { /** * Find KYB submission details * @description Returns only KYB submission IDs and related-person IDs needed for document uploads. + * + * **Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: operations["findDomesticKybCustomerAndBusiness"]; put?: never; @@ -570,6 +614,8 @@ export interface paths { /** * Get a KYB redirect link * @description Creates a hosted business KYB redirect link when no verification is already in review or complete. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: operations["getDomesticKybRedirectLink"]; put?: never; @@ -590,6 +636,8 @@ export interface paths { /** * Get a KYC redirect link * @description Creates a hosted individual KYC redirect link when no verification is already in review or complete. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: operations["getDomesticKycRedirectLink"]; put?: never; @@ -610,6 +658,8 @@ export interface paths { /** * Get KYC or KYB status * @description Returns and persists the latest KYC or KYB submission status. Omit `type` for individual KYC. + * + * **Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: operations["getDomesticKycStatus"]; put?: never; @@ -632,6 +682,8 @@ export interface paths { /** * Mark a redirect finished * @description Records that the effective customer finished the hosted KYC or KYB redirect flow. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["notifyDomesticKycRedirectFinished"]; delete?: never; @@ -652,6 +704,8 @@ export interface paths { /** * Mark a redirect opened * @description Records that the effective customer's hosted KYC or KYB redirect was opened. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["notifyDomesticKycRedirectOpened"]; delete?: never; @@ -672,6 +726,8 @@ export interface paths { /** * Retry KYC or KYB * @description Retries a failed KYC or KYB submission. Hosted flows return a redirect link; API-based MX, CO, and AR individual KYC returns `{ success: true }`. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["retryDomesticKyc"]; delete?: never; @@ -692,6 +748,8 @@ export interface paths { /** * Send a KYB submission * @description Finalizes an API-based business KYB submission. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["sendDomesticKybSubmission"]; delete?: never; @@ -712,6 +770,8 @@ export interface paths { /** * Send a KYC submission * @description Finalizes an API-based individual KYC submission. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["sendDomesticKycSubmission"]; delete?: never; @@ -732,6 +792,8 @@ export interface paths { /** * Upload a KYB file * @description Uploads one business KYB document. Files are buffered in memory and limited to 5 MiB. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["submitDomesticKybFile"]; delete?: never; @@ -752,6 +814,8 @@ export interface paths { /** * Submit KYB information * @description Creates or updates an API-based business KYB submission, including the provider's compliance questionnaire. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["submitDomesticKybInformation"]; delete?: never; @@ -772,6 +836,8 @@ export interface paths { /** * Upload a related-person KYB file * @description Uploads the front or back identity document for one KYB related person. Files are limited to 5 MiB. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["submitDomesticKybRelatedPersonFile"]; delete?: never; @@ -792,6 +858,8 @@ export interface paths { /** * Upload a KYC file * @description Uploads one individual KYC document. Files are buffered in memory and limited to 5 MiB. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["submitDomesticKycFile"]; delete?: never; @@ -812,6 +880,8 @@ export interface paths { /** * Submit KYC information * @description Creates or resumes an API-based individual KYC submission. + * + * **Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["submitDomesticKycInformation"]; delete?: never; @@ -834,6 +904,8 @@ export interface paths { * @description Returns onramp and offramp limits for the authenticated user's requested fiat corridors. Bank-transfer-corridor usage is calculated from completed Vortex ramps in the current UTC calendar month and may be delayed by the 60-second in-memory cache. BR maximums, usage, and period are read from the provider. * * **Auth:** requires either `X-API-Key: sk_*` linked to a user or `Authorization: Bearer `. Unlinked partner keys are rejected. + * + * **Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["getUserLimits"]; delete?: never; @@ -842,6 +914,46 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/managed-profile-member-invitations/{invitationId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Preview a managed-profile membership invitation + * @description Supabase bearer-only; API-key headers and impersonation are rejected. No managed selector is accepted. The invitation UUID is only a locator. Preview requires the current Supabase principal's exact normalized email and a valid email_confirmed_at, not request email or cached profiles.email. Unknown invitations and mismatched/unverified email return generic 403 without child, inviter, role, or status details. Authorized preview returns invitation, inviter, and child summary, including terminal status. Observed seven-day expiry is persisted. Preview and OTP verification grant no membership. + */ + get: operations["previewManagedProfileMemberInvitation"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/managed-profile-member-invitations/{invitationId}/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Accept a managed-profile membership invitation + * @description Explicit acceptance by a Supabase bearer principal with the exact current verified invitation email (email_confirmed_at required). No request body, request email, or managed selector is needed or used as authority. API-key headers and impersonation are rejected. Transactionally rechecks invitation, seven-day expiry, child, owner and actor; creates an active membership and invitation_accepted/member_added events. A previously revoked member gets a new membership row. Replay returns 200 only when the same accepting profile still has an active membership with the invitation's role; it creates no duplicate events. Otherwise terminal invitations return 409. No child access follows merely from OTP verification. + */ + post: operations["acceptManagedProfileMemberInvitation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/managed-profiles": { parameters: { query?: never; @@ -851,17 +963,19 @@ export interface paths { }; /** * List managed profiles - * @description Lists children owned by the authenticated active manager, newest first, together with the manager's current corridor and customer-type policy. Policy is manager-scoped and applies to all children; it is not a per-child grant. The default filter returns only active children. Use `status=deleted` or `status=all` to include retained logical-deletion records. + * @description Lists eligible children newest first. Returns actor { profileId, canProvisionManagedProfiles, hasMemberships }, each child's membership (role, isOwner) and immutable-owner policy; there is no singular manager response. Default status=active lists eligible active memberships across all owners and returns 200 with an empty list even when both actor flags are false. Eligibility excludes revoked/invalid roles, inactive owners, deleted children and invalid child entity layouts. hasMemberships is based on an unpaginated eligible active count, independent of the requested status filter and page. * - * **Auth:** controlling manager Supabase Bearer session or secret API key. Public API keys and direct managed-child credentials are rejected. + * Both status=deleted and status=all require the actor's own active manager configuration (otherwise 403 MANAGED_PROFILE_OWNER_REQUIRED) and are owner-scoped only: they exclude children owned by others, even active invited children in status=all. Retained rows still require eligible membership, owner and entity layout, but never contribute to hasMemberships. + * + * **Auth:** profile-bound secret API key or Supabase Bearer session. Public keys and direct child credentials are rejected. Membership does not grant provisioning or child deletion. */ get: operations["listManagedProfiles"]; put?: never; /** * Create a managed profile - * @description Creates one headless individual or business child for the authenticated active manager. `externalSubjectId`, normalized `contactEmail`, and `customerType` are immutable. An exact retry for the same external subject is idempotent and returns the existing active child with `200`; the first creation returns `201`. Reusing either reserved identifier with different data returns `409`, including after deletion. No corridor grant is accepted. + * @description Creates one headless individual or business child owned by the authenticated active manager. Membership alone does not grant provisioning or sibling creation under another owner. externalSubjectId, normalized contactEmail, and customerType are immutable. An exact retry returns the existing active child with 200; first creation returns 201. Reserved identifiers with different data return 409, including after deletion. No corridor grant is accepted. Unlike list/read, creation returns only the base managedProfile, without actor, membership or policy decoration. * - * **Auth:** controlling manager Supabase Bearer session or secret API key. Public API keys and direct managed-child credentials are rejected. + * **Auth:** enabled owner's Supabase Bearer session or secret API key. Public keys, direct child credentials and impersonation are rejected. */ post: operations["createManagedProfile"]; delete?: never; @@ -879,18 +993,22 @@ export interface paths { }; /** * Get a managed profile - * @description Returns one owned child, including a retained logically deleted child. Foreign children are hidden with `404`. + * @description Returns actor { profileId, canProvisionManagedProfiles, hasMemberships } and managedProfile with membership and immutable-owner policy. Actor flags use the same independent, unpaginated eligible active count as the list endpoint, not this child's status. Active child reads require a live manager or read_only membership, active owner configuration and valid child entity layout. + * + * Bootstrap is explicitly GET detail with an exactly matching X-Managed-Profile-Id. Stored membership history (active or revoked) is required before ineligibility returns 403 MANAGED_PROFILE_MEMBERSHIP_INVALID. Revoked membership, deleted child, disabled owner or invalid entity layout invalidate an evidenced bootstrap. A deleted child invalidates even the owner's bootstrap. A caller who was never a member receives the same masked 404 MANAGED_PROFILE_NOT_FOUND for an existing or unknown child, with or without a matching selector. A mismatched selector returns 403 MANAGED_PROFILE_ACCESS_DENIED. * - * **Auth:** controlling manager Supabase Bearer session or secret API key. + * Retained deleted-child reads are allowed only to the immutable owner with active configuration, valid membership/entity layout and no selector. Invited members and ineligible retained reads are masked with 404. Without bootstrap, missing membership is 404; an active member of an active child with disabled owner or invalid entity layout receives 403 MANAGED_PROFILE_ACCESS_DENIED. + * + * **Auth:** Supabase Bearer session or member-owned secret API key; both follow the same bootstrap/history and retained-read checks. Direct child credentials are rejected. */ get: operations["getManagedProfile"]; put?: never; post?: never; /** * Delete a managed profile - * @description Logically deletes an owned child and atomically revokes all of its credentials. Customer, provider, KYC, ramp, external-subject, and contact-email records are retained. Repeating deletion of the same owned child is idempotent and returns `204`. + * @description Owner-only logical deletion, atomically revoking all child credentials. A non-owner with an active manager or read_only membership receives 403 MANAGED_PROFILE_OWNER_REQUIRED; outsiders receive the same masked 404 whether the child exists or not. Customer, provider, KYC, ramp, external-subject and contact-email records are retained. Repeating deletion of the same owned child returns 204 while the owner's configuration remains active. * - * **Auth:** controlling manager Supabase Bearer session or secret API key. + * **Auth:** immutable owner's Supabase Bearer session or profile-bound secret API key; impersonation and direct child credentials are rejected. */ delete: operations["deleteManagedProfile"]; options?: never; @@ -907,17 +1025,17 @@ export interface paths { }; /** * List a managed profile's API credentials - * @description Lists all credentials owned by one active child, newest first, including revoked and expired records. Public values and safe secret prefixes are returned; secret values are never returned. + * @description Lists all credentials owned by one active child, newest first, including revoked and expired records. Public values and safe secret prefixes are returned, never secret values. Requires read capability: manager or read_only membership, active owner and valid child. An optional selector must exactly match the path profileId. * - * **Auth:** active controlling manager Supabase Bearer session or secret API key. + * **Auth:** member Supabase Bearer session or member-owned secret API key. Direct child credentials cannot administer credentials. */ get: operations["listManagedProfileApiCredentials"]; put?: never; /** * Create a managed profile API credential - * @description Issues one child-owned public/secret credential pair. The secret is returned only in this creation response and cannot be retrieved later. Expiry defaults to one year and cannot exceed two years. The child's shared cap is five active, non-expired credentials. + * @description Issues one child-owned public/secret credential pair. The secret is returned only once. Expiry defaults to one year and cannot exceed two years. The child's shared cap is five active, non-expired credentials. Requires manage capability (manager membership), not the secret-only credential_manage provider capability. An optional selector must exactly match the path profileId. Child credentials are shared company principals: member removal does not revoke them. * - * **Auth:** active controlling manager Supabase Bearer session or secret API key. + * **Auth:** manager member's Supabase Bearer session or member-owned secret API key. Impersonation and direct child credentials are rejected. */ post: operations["createManagedProfileApiCredential"]; delete?: never; @@ -938,9 +1056,9 @@ export interface paths { post?: never; /** * Revoke a managed profile API credential - * @description Revokes one credential owned by an active child, disabling both its public and secret values. Repeating revocation of the same owned credential is idempotent and returns `204`. + * @description Revokes one credential owned by an active child, disabling both values. Repeating revocation of the same owned credential returns 204. Requires manage capability (manager membership), not secret-only credential_manage. An optional selector must exactly match the path profileId. Revoke shared child credentials separately when offboarding members who possessed them. * - * **Auth:** active controlling manager Supabase Bearer session or secret API key. + * **Auth:** manager member's Supabase Bearer session or member-owned secret API key. Impersonation and direct child credentials are rejected. */ delete: operations["revokeManagedProfileApiCredential"]; options?: never; @@ -948,6 +1066,114 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/managed-profiles/{profileId}/member-events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read managed-profile access history + * @description Supabase bearer-only with a live manager or read_only membership; API-key headers and impersonation are rejected. Append-only access history ordered by createdAt then id descending. Pass pagination.nextCursor to fetch strictly older events; null ends pagination. Cursor must identify an event in this child. Event payloads omit emails, secrets, and invitation URLs. Offset, if supplied, is validated as a non-negative integer but ignored; use cursor pagination. + */ + get: operations["listManagedProfileMemberEvents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/managed-profiles/{profileId}/member-invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List managed-profile membership invitations + * @description Supabase bearer-only with a live manager or read_only membership; API-key headers and impersonation are rejected. Returns pending and terminal invitations, ordered by createdAt then id descending. Observed seven-day expiry is persisted with one event. No status filter is supported. + */ + get: operations["listManagedProfileMemberInvitations"]; + put?: never; + /** + * Invite a managed-profile member + * @description Supabase bearer-only with a live manager membership; API-key headers and impersonation are rejected. Email is trimmed/lowercased and role is manager or read_only. Creates one pending invitation per child/email, expiring after seven days, and queues its email transactionally. Identical pending creation returns 200 without another delivery; a different pending role requires cancellation first. Response does not reveal whether an unrelated profile exists. MEMBERSHIP_ALREADY_EXISTS discloses only an active membership already visible in this child's roster. Returns invitation metadata, never a secret acceptance token or URL. + */ + post: operations["createManagedProfileMemberInvitation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/managed-profiles/{profileId}/member-invitations/{invitationId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Cancel a pending membership invitation + * @description Supabase bearer-only with a live manager membership; API-key headers and impersonation are rejected. Cancels a pending invitation for this child. Expiry is observed before cancellation. Terminal invitations, including repeated cancellation, return 409 rather than 204. Cancellation never removes an accepted membership. + */ + delete: operations["cancelManagedProfileMemberInvitation"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/managed-profiles/{profileId}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List active managed-profile members + * @description Supabase bearer-only with a live manager or read_only membership. Rejects API-key headers and impersonation. Lists active members including immutable owner metadata and nullable profile email, ordered by createdAt then id ascending. Membership does not transfer ownership or grant access to siblings. + */ + get: operations["listManagedProfileMembers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/managed-profiles/{profileId}/members/{memberProfileId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Revoke a non-owner member + * @description Supabase bearer-only with a live manager membership; API-key headers and impersonation are rejected. Cannot revoke the immutable owner. Non-owner managers may remove themselves. A retry returns 204 if the same actor previously revoked that member and still has manager authority; otherwise a missing active target returns 404. Child-owned shared credentials are independent principals and are NOT revoked by member removal. Revoke exposed child credentials separately. + */ + delete: operations["removeManagedProfileMember"]; + options?: never; + head?: never; + /** + * Change a non-owner member role + * @description Supabase bearer-only with a live manager membership; API-key headers and impersonation are rejected. Only manager and read_only are accepted. The immutable owner cannot be changed, even to the same role. Repeating an unchanged non-owner role returns 200 without another event. Non-owner managers may downgrade themselves. The response member does not contain email. + */ + patch: operations["changeManagedProfileMemberRole"]; + trace?: never; + }; "/v1/onboarding/active-entity": { parameters: { query?: never; @@ -998,6 +1224,8 @@ export interface paths { /** * Get aggregate onboarding status * @description Returns the effective profile's customer entities and aggregated provider/KYC state. Non-terminal provider statuses may be refreshed before the response is built. + * + * **Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: operations["getOnboardingStatus"]; put?: never; @@ -1067,6 +1295,8 @@ export interface paths { /** * Create a new quote * @description Generates a quote for a specified ramp transaction, detailing input and output amounts, fees, and expiration. + * + * **Managed selection (read):** A live manager or read_only membership may create quotes through this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["createQuote"]; delete?: never; @@ -1130,6 +1360,8 @@ export interface paths { /** * Create a quote for the best network * @description Generates a new quote for the network that yields the highest output amount for the given parameters. This endpoint compares the output for a given input amount over all supported networks and returns the 'best' quote, defined as the one with the highest output. + * + * **Managed selection (read):** A live manager or read_only membership may create quotes through this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["createBestQuote"]; delete?: never; @@ -1147,9 +1379,11 @@ export interface paths { }; /** * Get sanitized ramp eligibility - * @description Returns only sanitized per-corridor KYC state and buy/sell eligibility for the profile derived from the validated API credential. A manager secret may select one directly managed child with `X-Managed-Profile-Id`; public keys cannot use the selector. The endpoint never returns PII, provider/customer IDs, KYC failure reasons, bank/wallet data, ramp history, or exact financial limits. When both public and secret headers are supplied they must belong to the same credential. Supabase Bearer sessions do not authorize this endpoint. + * @description Returns only sanitized per-corridor KYC state and buy/sell eligibility for the profile derived from the validated API credential. A manager or read_only member's secret may select one authorized child with `X-Managed-Profile-Id`; public keys cannot use the selector. The endpoint never returns PII, provider/customer IDs, KYC failure reasons, bank/wallet data, ramp history, or exact financial limits. When both public and secret headers are supplied they must belong to the same credential. Supabase Bearer sessions do not authorize this endpoint. * * **Auth:** `X-Public-Key` or `X-API-Key`. + * + * **Managed selection (read):** A live manager or read_only membership may use this operation. Selection requires a member-owned secret, never a bearer or public key. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: operations["getRampInfo"]; put?: never; @@ -1170,12 +1404,14 @@ export interface paths { /** * Get ramp status * @description Fetches an updated ramp process. + * + * **Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path: { @@ -1270,7 +1506,7 @@ export interface paths { "application/json": components["schemas"]["ErrorManagedSelectorResponse"]; }; }; - /** @description Ramp ownership or managed-profile authorization failed. */ + /** @description Ramp ownership or managed-profile authorization failed. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: { headers: { [name: string]: unknown; @@ -1301,6 +1537,8 @@ export interface paths { * @description Returns the chronological error log for a ramp. * * **Auth:** requires either `X-API-Key: sk_*` (partner) OR `Authorization: Bearer ` (user). Ownership is enforced. + * + * **Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: operations["getRampErrorLogs"]; put?: never; @@ -1321,6 +1559,8 @@ export interface paths { /** * Get authenticated user ramp history * @description Fetches all non-initial ramps owned by the authenticated user across wallet addresses. Requires a Supabase session or user-scoped secret API key. Partner-only credentials are not sufficient. + * + * **Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: { parameters: { @@ -1331,7 +1571,7 @@ export interface paths { offset?: number; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -1358,6 +1598,7 @@ export interface paths { }; }; 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: components["responses"]["ManagedSelectorForbidden"]; }; }; @@ -1379,6 +1620,8 @@ export interface paths { /** * Get ramp history for wallet address * @description Fetches the transaction history for a given wallet address. The response returns the last 20 items by default. This can be adjusted by using the `limit` and `offset` query parameters. + * + * **Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ get: { parameters: { @@ -1389,7 +1632,7 @@ export interface paths { offset?: number; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path: { @@ -1418,6 +1661,7 @@ export interface paths { }; }; 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: components["responses"]["ManagedSelectorForbidden"]; }; }; @@ -1441,6 +1685,8 @@ export interface paths { /** * Register new ramp process * @description Initiates a new on-ramp or off-ramp process by providing quote details, signing accounts, and additional data. + * + * **Managed selection (ramp):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child bearer-only register/update/start is always denied before the global body parser, including already registered or in-flight ramps; there is no drain exception. An otherwise authorized manager receives 403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL; read_only, invalid membership and impersonation retain their respective denials. Direct child secrets remain supported without a selector. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["registerRamp"]; delete?: never; @@ -1463,6 +1709,8 @@ export interface paths { * @description Starts a ramp process. * * It is assumed all required information from the client has already been sent using the `update` endpoint. This endpoint is only used to tell the backend any external operation (like a bank transfer) has been completed, and the ramp can start. + * + * **Managed selection (ramp):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child bearer-only register/update/start is always denied before the global body parser, including already registered or in-flight ramps; there is no drain exception. An otherwise authorized manager receives 403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL; read_only, invalid membership and impersonation retain their respective denials. Direct child secrets remain supported without a selector. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["startRamp"]; delete?: never; @@ -1494,6 +1742,8 @@ export interface paths { * If the originating chain is any EVM chain, then `squidRouterSwapHash` must be provided. `squidRouterApproveHash` is only required when an approval transaction was actually submitted; if the wallet already holds a sufficient allowance for the router, it can be omitted. No-permit flows use the corresponding `squidRouterNoPermit*Hash` fields. * * For onramps, no additional data is required after registering the ramp. + * + * **Managed selection (ramp):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child bearer-only register/update/start is always denied before the global body parser, including already registered or in-flight ramps; there is no drain exception. An otherwise authorized manager receives 403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL; read_only, invalid membership and impersonation retain their respective denials. Direct child secrets remain supported without a selector. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy. */ post: operations["updateRamp"]; delete?: never; @@ -2008,6 +2258,11 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + AcceptManagedProfileInvitationResponse: { + /** Format: uuid */ + managedProfileId: string; + member: components["schemas"]["ManagedProfileMember"]; + }; AccountMeta: { /** @description The account address. */ address: string; @@ -2213,6 +2468,9 @@ export interface components { BrValidatePixKeyResponse: { valid: boolean; }; + ChangeManagedProfileMemberRequest: { + role: components["schemas"]["ManagedProfileMembershipRole"]; + }; CleanupPhase: { /** @enum {string} */ string?: "moonbeamCleanup" | "pendulumCleanup" | "stellarCleanup"; @@ -2257,6 +2515,14 @@ export interface components { /** @description `PIX`, `SEPA`, `CBU`. Only required if `rampType` is "SELL". */ to?: components["schemas"]["PaymentMethod"]; }; + CreateManagedProfileInvitationRequest: { + /** + * Format: email + * @description Trimmed and lowercased before validation and comparison; length limit applies after normalization. + */ + email: string; + role: components["schemas"]["ManagedProfileMembershipRole"]; + }; CreateManagedProfileRequest: { /** * Format: email @@ -2691,9 +2957,27 @@ export interface components { ListApiCredentialsResponse: { credentials: components["schemas"]["ApiCredential"][]; }; + ListManagedProfileInvitationsResponse: { + invitations: components["schemas"]["ManagedProfileInvitation"][]; + pagination: components["schemas"]["ManagedProfilePagination"]; + }; + ListManagedProfileMemberEventsResponse: { + events: components["schemas"]["ManagedProfileMemberEvent"][]; + pagination: { + limit: number; + /** Format: uuid */ + nextCursor: string | null; + }; + }; + ListManagedProfileMembersResponse: { + members: (components["schemas"]["ManagedProfileMember"] & { + email: string | null; + })[]; + pagination: components["schemas"]["ManagedProfilePagination"]; + }; ListManagedProfilesResponse: { - managedProfiles: components["schemas"]["ManagedProfile"][]; - manager: components["schemas"]["ManagedProfileManagerPolicy"]; + actor: components["schemas"]["ManagedProfileActor"]; + managedProfiles: components["schemas"]["ManagedProfileAccess"][]; pagination: components["schemas"]["ManagedProfilePagination"]; }; LivenessDocumentEntry: { @@ -2735,32 +3019,111 @@ export interface components { /** Format: date-time */ updatedAt: string; }; + ManagedProfileAccess: components["schemas"]["ManagedProfile"] & { + membership: { + isOwner: boolean; + role: components["schemas"]["ManagedProfileMembershipRole"]; + }; + policy: components["schemas"]["ManagedProfilePolicy"]; + }; + ManagedProfileAccessResponse: { + actor: components["schemas"]["ManagedProfileActor"]; + managedProfile: components["schemas"]["ManagedProfileAccess"]; + }; + /** @description Actor projection shared by list and detail responses. Flags are independent of pagination, the requested status filter, and the particular child being read. */ + ManagedProfileActor: { + /** @description True exactly when the actor has its own active managed-profile manager configuration. Membership alone does not grant provisioning. */ + canProvisionManagedProfiles: boolean; + /** @description True when the unpaginated eligible active-child count is greater than zero, across all owners and independent of status=deleted/all owner-scoped results. Requires an unrevoked manager or read_only membership, active child relationship and owner configuration, a managed child, and exactly one active owned customer entity selected by that child. Excludes deleted children, inactive owners and invalid entity layouts. May remain true for an empty page, or false while retained records are returned. */ + hasMemberships: boolean; + /** Format: uuid */ + profileId: string; + }; ManagedProfileErrorResponse: { error: { - /** @description Machine-readable error code. Managed-profile lifecycle codes include `MANAGED_PROFILE_INVALID_INPUT`, `MANAGED_PROFILE_ACCESS_DENIED`, `MANAGED_PROFILE_NOT_FOUND`, `MANAGED_PROFILE_CONFLICT`, `MANAGED_PROFILE_MANAGER_NOT_FOUND`, and `MANAGED_PROFILE_MANAGER_INACTIVE`. Credential codes include `INVALID_CREDENTIAL_NAME`, `INVALID_CREDENTIAL_EXPIRY`, `CREDENTIAL_ACCESS_DENIED`, `CREDENTIAL_NOT_FOUND`, and `CREDENTIAL_LIMIT_REACHED`. Authentication middleware may return `AUTHENTICATION_REQUIRED`, `INVALID_SECRET_KEY`, `INVALID_API_KEY`, `INVALID_BEARER_TOKEN`, `INVALID_PUBLIC_KEY`, or `CREDENTIAL_MISMATCH`. */ + /** @description Machine-readable error code. Lifecycle codes include MANAGED_PROFILE_INVALID_INPUT, MANAGED_PROFILE_ACCESS_DENIED, MANAGED_PROFILE_NOT_FOUND, MANAGED_PROFILE_CONFLICT, MANAGED_PROFILE_MANAGER_NOT_FOUND, MANAGED_PROFILE_MANAGER_INACTIVE, MANAGED_PROFILE_OWNER_REQUIRED (retained filters or non-owner member deletion), and MANAGED_PROFILE_MEMBERSHIP_INVALID (explicit bootstrap with membership history). Credential codes include INVALID_CREDENTIAL_NAME, INVALID_CREDENTIAL_EXPIRY, CREDENTIAL_ACCESS_DENIED, CREDENTIAL_NOT_FOUND, and CREDENTIAL_LIMIT_REACHED. Authentication middleware may return AUTHENTICATION_REQUIRED, INVALID_SECRET_KEY, INVALID_API_KEY, INVALID_BEARER_TOKEN, INVALID_PUBLIC_KEY, or CREDENTIAL_MISMATCH. */ code: string; message: string; status: number; }; }; - /** @description The authenticated manager's current policy. This policy is manager-scoped and applies to every managed child; corridors and customer types are not grants copied onto each child. */ - ManagedProfileManagerPolicy: { - allowedCorridors: ("AR" | "BR" | "CO" | "EU" | "MX" | "US")[]; - allowedCustomerTypes: ("individual" | "business")[] | null; + ManagedProfileInvitation: { + /** Format: date-time */ + acceptedAt: string | null; + /** Format: date-time */ + cancelledAt: string | null; + /** Format: date-time */ + createdAt: string; + /** Format: email */ + email: string; + /** Format: date-time */ + expiredAt: string | null; + /** Format: date-time */ + expiresAt: string; /** Format: uuid */ - profileId: string; + id: string; + /** Format: uuid */ + invitedByProfileId: string; + /** Format: uuid */ + managedProfileId: string; + role: components["schemas"]["ManagedProfileMembershipRole"]; + /** @enum {string} */ + status: "pending" | "accepted" | "cancelled" | "expired"; + }; + ManagedProfileInvitationResponse: { + invitation: components["schemas"]["ManagedProfileInvitation"]; + }; + ManagedProfileMember: { + /** Format: date-time */ + createdAt: string; + /** Format: uuid */ + id: string; + isOwner: boolean; + /** Format: uuid */ + memberProfileId: string; + role: components["schemas"]["ManagedProfileMembershipRole"]; + /** Format: date-time */ + updatedAt: string; + }; + ManagedProfileMemberEvent: { + /** @enum {string} */ + action: "member_added" | "invited" | "invitation_cancelled" | "invitation_expired" | "invitation_accepted" | "role_changed" | "member_removed"; + /** Format: uuid */ + actorProfileId: string | null; + /** Format: date-time */ + createdAt: string; + /** Format: uuid */ + id: string; + /** Format: uuid */ + invitationId: string | null; + /** Format: uuid */ + memberProfileId: string | null; + /** @enum {string|null} */ + previousRole: "manager" | "read_only" | null; + /** @enum {string|null} */ + role: "manager" | "read_only" | null; + }; + ManagedProfileMemberResponse: { + member: components["schemas"]["ManagedProfileMember"]; }; + /** @enum {string} */ + ManagedProfileMembershipRole: "manager" | "read_only"; ManagedProfilePagination: { limit: number; offset: number; total: number; }; + /** @description The child's immutable owner's current policy, returned per child because one actor can belong to children with different owners. Not a copied child grant or the acting member's personal policy. EU may occur in stored policy but managed EUR flows remain unsupported. */ + ManagedProfilePolicy: { + allowedCorridors: ("AR" | "BR" | "CO" | "EU" | "MX" | "US")[]; + allowedCustomerTypes: ("individual" | "business")[] | null; + }; ManagedProfileResponse: { managedProfile: components["schemas"]["ManagedProfile"]; }; ManagedSelectorErrorResponse: { error: { - /** @description Machine-readable middleware code such as `INVALID_MANAGED_PROFILE_ID`, `MANAGED_PROFILE_CUSTOMER_TYPE_MISMATCH`, `AUTHENTICATION_REQUIRED`, `INVALID_SECRET_KEY`, `INVALID_API_KEY`, `INVALID_BEARER_TOKEN`, `CREDENTIAL_MISMATCH`, `MANAGED_PROFILE_ACCESS_DENIED`, or `IMPERSONATION_NOT_ALLOWED`. */ + /** @description Machine-readable middleware code such as INVALID_MANAGED_PROFILE_ID, MANAGED_PROFILE_CUSTOMER_TYPE_MISMATCH, AUTHENTICATION_REQUIRED, INVALID_SECRET_KEY, INVALID_API_KEY, INVALID_BEARER_TOKEN, CREDENTIAL_MISMATCH, MANAGED_PROFILE_ACCESS_DENIED, MANAGED_PROFILE_MANAGER_REQUIRED, MANAGED_PROFILE_OWNER_REQUIRED, MANAGED_PROFILE_POLICY_DENIED, MANAGED_PROFILE_REQUIRES_API_CREDENTIAL, MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL, or IMPERSONATION_NOT_ALLOWED. MANAGED_PROFILE_MEMBERSHIP_INVALID requires explicit detail bootstrap with an exactly matching selector and stored membership history; it is not bearer-only and never reveals a child to a caller who was never a member. */ code: string; message: string; status: number; @@ -2977,6 +3340,19 @@ export interface components { } & { [key: string]: unknown; }; + PreviewManagedProfileInvitationResponse: { + invitation: components["schemas"]["ManagedProfileInvitation"]; + inviter: { + email: string | null; + /** Format: uuid */ + profileId: string; + }; + managedProfile: { + externalSubjectId: string; + /** Format: uuid */ + profileId: string; + }; + }; QuoteResponse: { anchorFeeFiat: string; anchorFeeUSD: string; @@ -3359,34 +3735,110 @@ export interface components { "application/json": components["schemas"]["ManagedSelectorErrorResponse"]; }; }; - RecordNotFound: { + /** @description Authentication service unavailable (transient Supabase verification failure). */ + MembershipAuthUnavailable: { headers: { [name: string]: unknown; }; content: { - "application/json": { - code: number; - message: string; - }; + /** + * @example { + * "error": "Authentication service unavailable" + * } + */ + "application/json": components["schemas"]["FlatErrorResponse"]; }; }; - }; - parameters: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ - ManagedProfileId: string; - }; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export interface operations { - listApiCredentials: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + /** @description MANAGED_PROFILE_INVALID_INPUT for non-UUID path identifiers; INVALID_PAGINATION for invalid pagination; INVALID_MEMBERSHIP_ROLE for a role other than manager or read_only; INVALID_INVITATION_EMAIL for invalid creation email. Invitee routes reject any managed selector with MANAGED_PROFILE_UNSUPPORTED. Malformed JSON is rejected by the global parser before route authentication. */ + MembershipBadRequest: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileErrorResponse"] | components["schemas"]["MalformedJsonErrorResponse"]; + }; + }; + /** @description MANAGED_PROFILE_ACCESS_DENIED for API-key headers (even with a bearer), inaccessible child/membership, mismatched selector, or invitee email mismatch/unverified email/unknown invitation. MANAGED_PROFILE_MANAGER_REQUIRED for read_only mutations. IMPERSONATION_NOT_ALLOWED for impersonation. No invitation details are disclosed to mismatched invitees. */ + MembershipForbidden: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileErrorResponse"]; + }; + }; + /** @description INTERNAL_SERVER_ERROR: Unable to process membership request. */ + MembershipInternalError: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileErrorResponse"]; + }; + }; + /** @description Request body exceeds the global 20 MB limit. Rejected before membership authentication. */ + MembershipPayloadTooLarge: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PayloadTooLargeErrorResponse"]; + }; + }; + /** @description Authenticated membership routes share a limit of 120 requests per minute per actor. Standard rate-limit headers are returned. */ + MembershipRateLimited: { + headers: { + [name: string]: unknown; + }; + content: { + "text/html": string; + }; + }; + /** @description Missing or invalid authorization header, Invalid or expired token, or Authentication failed. This authentication middleware uses a flat string error, not a structured membership error. */ + MembershipUnauthorized: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FlatErrorResponse"]; + }; + }; + RecordNotFound: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + code: number; + message: string; + }; + }; + }; + }; + parameters: { + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ + ManagedProfileId: string; + MembershipInvitationId: string; + MembershipLimit: number; + /** @description Authenticated member profile UUID, not the membership row ID. */ + MembershipMemberProfileId: string; + MembershipOffset: number; + MembershipProfileId: string; + /** @description Optional selector must match the path profileId case-insensitively. It grants no authority. Membership endpoints remain Supabase bearer-only even when selected. */ + MembershipSelection: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + listApiCredentials: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; requestBody?: never; responses: { @@ -3642,7 +4094,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -3678,6 +4130,7 @@ export interface operations { }; }; 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["BrlaManagedSelectorForbidden"]; /** @description Internal Server Error. */ 500: { @@ -3697,7 +4150,7 @@ export interface operations { taxId: string; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -3724,6 +4177,7 @@ export interface operations { }; }; 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: components["responses"]["BrlaManagedSelectorForbidden"]; /** @description No KYC process started. */ 404: { @@ -3770,7 +4224,7 @@ export interface operations { taxId: string; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -3797,6 +4251,7 @@ export interface operations { }; }; 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["BrlaManagedSelectorForbidden"]; /** @description The immutable KYC method or canonical case state conflicts with liveness creation. */ 409: { @@ -3831,7 +4286,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -3862,6 +4317,7 @@ export interface operations { }; }; 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["BrlaManagedSelectorForbidden"]; /** @description The immutable KYC method or canonical case state conflicts with upload creation. */ 409: { @@ -3902,7 +4358,7 @@ export interface operations { taxId?: string; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -3929,6 +4385,7 @@ export interface operations { }; }; 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: components["responses"]["BrlaManagedSelectorForbidden"]; /** @description Subaccount not found. */ 404: { @@ -3962,7 +4419,7 @@ export interface operations { direction: components["schemas"]["RampDirection"]; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -3989,6 +4446,7 @@ export interface operations { }; }; 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: components["responses"]["BrlaManagedSelectorForbidden"]; /** @description Subaccount not found or limits not found. */ 404: { @@ -4016,7 +4474,7 @@ export interface operations { attemptId: string; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -4044,7 +4502,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Attempt does not belong to the effective profile. */ + /** @description Attempt does not belong to the effective profile. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: components["responses"]["BrlaManagedSelectorForbidden"]; /** @description KYB attempt or account not found. */ 404: { @@ -4086,7 +4544,7 @@ export interface operations { subAccountId: string; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -4118,7 +4576,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Managed profile or corridor is not authorized. */ + /** @description Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["BrlaManagedSelectorForbidden"]; /** @description Subaccount not found. */ 404: { @@ -4142,7 +4600,7 @@ export interface operations { subAccountId: string; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path: { @@ -4172,7 +4630,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Document does not belong to the effective profile. */ + /** @description Document does not belong to the effective profile. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: components["responses"]["BrlaManagedSelectorForbidden"]; /** @description Document not found. */ 404: { @@ -4196,7 +4654,7 @@ export interface operations { subAccountId: string; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -4228,7 +4686,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Managed profile or corridor is not authorized. */ + /** @description Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["BrlaManagedSelectorForbidden"]; /** @description Subaccount or referenced document not found. */ 404: { @@ -4259,7 +4717,7 @@ export interface operations { subAccountId: string; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -4287,7 +4745,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Managed profile or corridor is not authorized. */ + /** @description Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["BrlaManagedSelectorForbidden"]; /** @description Subaccount not found. */ 404: { @@ -4331,7 +4789,7 @@ export interface operations { subAccountId: string; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -4363,7 +4821,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Managed profile or corridor is not authorized. */ + /** @description Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["BrlaManagedSelectorForbidden"]; /** @description Subaccount or referenced document not found. */ 404: { @@ -4392,7 +4850,7 @@ export interface operations { parameters: { query?: never; header: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; /** @description Caller-generated key for one token-import attempt. It must contain 1 to 128 visible ASCII characters. Reuse it only with the same token. */ "Idempotency-Key": string; @@ -4433,7 +4891,7 @@ export interface operations { "application/json": components["schemas"]["ManagedSelectorErrorResponse"]; }; }; - /** @description The selected child is unauthorized, the caller used direct managed-child credentials, or transactional authorization was revoked before provider submission. */ + /** @description The selected child is unauthorized, the caller used direct managed-child credentials, or transactional authorization was revoked before provider submission. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: { headers: { [name: string]: unknown; @@ -4502,7 +4960,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -4534,7 +4992,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Managed profile or corridor is not authorized. */ + /** @description Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Attempt recording failed. */ 500: { @@ -4551,7 +5009,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -4582,6 +5040,7 @@ export interface operations { }; }; 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["BrlaManagedSelectorForbidden"]; /** @description The immutable KYC method, approval state, or durable submission state conflicts with this request. */ 409: { @@ -4661,7 +5120,7 @@ export interface operations { type?: components["schemas"]["DomesticCustomerType"]; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -4689,6 +5148,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Customer not found. */ 404: { @@ -4714,7 +5174,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -4746,7 +5206,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Managed profile or corridor is not authorized. */ + /** @description Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description An upstream customer exists with a conflicting country or type. */ 409: { @@ -4781,7 +5241,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -4813,7 +5273,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Managed profile or corridor is not authorized. */ + /** @description Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description An upstream customer exists with a conflicting country or type. */ 409: { @@ -4850,7 +5310,7 @@ export interface operations { country: components["schemas"]["DomesticCountry"]; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -4877,6 +5337,7 @@ export interface operations { }; }; 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Customer not found. */ 404: { @@ -4902,7 +5363,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -4933,6 +5394,7 @@ export interface operations { }; }; 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Customer not found. */ 404: { @@ -4960,7 +5422,7 @@ export interface operations { country: components["schemas"]["DomesticCountry"]; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path: { @@ -4987,6 +5449,7 @@ export interface operations { }; }; 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Customer not found. */ 404: { @@ -5014,7 +5477,7 @@ export interface operations { country: "CO" | "MX"; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -5042,6 +5505,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Business customer not found. */ 404: { @@ -5069,7 +5533,7 @@ export interface operations { country: components["schemas"]["DomesticCountry"]; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -5097,6 +5561,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Business customer not found. */ 404: { @@ -5124,7 +5589,7 @@ export interface operations { country: components["schemas"]["DomesticCountry"]; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -5152,6 +5617,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Customer not found. */ 404: { @@ -5180,7 +5646,7 @@ export interface operations { type?: components["schemas"]["DomesticCustomerType"]; }; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -5208,6 +5674,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Customer or verification attempt not found. */ 404: { @@ -5233,7 +5700,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -5265,6 +5732,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Customer not found. */ 404: { @@ -5290,7 +5758,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -5322,6 +5790,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Customer not found. */ 404: { @@ -5347,7 +5816,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -5379,6 +5848,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Customer not found. */ 404: { @@ -5404,7 +5874,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -5436,7 +5906,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Managed profile or corridor is not authorized. */ + /** @description Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Business customer not found. */ 404: { @@ -5462,7 +5932,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -5494,7 +5964,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Managed profile or corridor is not authorized. */ + /** @description Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Customer not found. */ 404: { @@ -5520,7 +5990,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -5552,7 +6022,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Managed profile or corridor is not authorized. */ + /** @description Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Business customer not found. */ 404: { @@ -5578,7 +6048,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -5610,7 +6080,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Managed profile or corridor is not authorized. */ + /** @description Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Business customer not found. */ 404: { @@ -5645,7 +6115,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -5677,7 +6147,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Managed profile or corridor is not authorized. */ + /** @description Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Business customer not found. */ 404: { @@ -5703,7 +6173,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -5735,7 +6205,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Managed profile or corridor is not authorized. */ + /** @description Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Customer not found. */ 404: { @@ -5761,7 +6231,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -5793,7 +6263,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Managed profile or corridor is not authorized. */ + /** @description Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Customer not found. */ 404: { @@ -5819,7 +6289,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -5858,7 +6328,7 @@ export interface operations { "application/json": components["schemas"]["ManagedSelectorErrorResponse"]; }; }; - /** @description The credential is not linked to a user. */ + /** @description The credential is not linked to a user. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: { headers: { [name: string]: unknown; @@ -5876,6 +6346,75 @@ export interface operations { }; }; }; + previewManagedProfileMemberInvitation: { + parameters: { + query?: never; + header?: never; + path: { + invitationId: components["parameters"]["MembershipInvitationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Verified-email invitation preview. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PreviewManagedProfileInvitationResponse"]; + }; + }; + /** @description MANAGED_PROFILE_INVALID_INPUT for a non-UUID invitationId; MANAGED_PROFILE_UNSUPPORTED for any managed selector. Malformed JSON is rejected before route authentication. */ + 400: components["responses"]["MembershipBadRequest"]; + 401: components["responses"]["MembershipUnauthorized"]; + 403: components["responses"]["MembershipForbidden"]; + 413: components["responses"]["MembershipPayloadTooLarge"]; + 429: components["responses"]["MembershipRateLimited"]; + 500: components["responses"]["MembershipInternalError"]; + 503: components["responses"]["MembershipAuthUnavailable"]; + }; + }; + acceptManagedProfileMemberInvitation: { + parameters: { + query?: never; + header?: never; + path: { + invitationId: components["parameters"]["MembershipInvitationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Membership accepted, or unchanged valid replay. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AcceptManagedProfileInvitationResponse"]; + }; + }; + /** @description MANAGED_PROFILE_INVALID_INPUT for a non-UUID invitationId; MANAGED_PROFILE_UNSUPPORTED for any managed selector. Malformed JSON is rejected before route authentication. */ + 400: components["responses"]["MembershipBadRequest"]; + 401: components["responses"]["MembershipUnauthorized"]; + 403: components["responses"]["MembershipForbidden"]; + /** @description INVITATION_ACCEPTED, INVITATION_CANCELLED, INVITATION_EXPIRED, or MEMBERSHIP_ALREADY_EXISTS. Accepted replay is successful only under the documented active-role condition. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileErrorResponse"]; + }; + }; + 413: components["responses"]["MembershipPayloadTooLarge"]; + 429: components["responses"]["MembershipRateLimited"]; + 500: components["responses"]["MembershipInternalError"]; + 503: components["responses"]["MembershipAuthUnavailable"]; + }; + }; listManagedProfiles: { parameters: { query?: { @@ -5883,7 +6422,7 @@ export interface operations { limit?: number; /** @description Number of records to skip. */ offset?: number; - /** @description Lifecycle records to include. */ + /** @description active lists eligible active memberships across owners. Both deleted and all require the actor's own active manager configuration and return only that owner's children, never invited children owned by someone else. */ status?: "active" | "deleted" | "all"; }; header?: never; @@ -5892,7 +6431,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description The authenticated manager's current policy, a page of owned managed profiles, and offset pagination metadata. */ + /** @description Actor identity and independent capability flags, eligible membership-decorated children, and offset pagination. An empty default list is successful, including when both flags are false. */ 200: { headers: { [name: string]: unknown; @@ -5919,7 +6458,7 @@ export interface operations { "application/json": components["schemas"]["ManagedProfileErrorResponse"]; }; }; - /** @description `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated profile is not an active manager, or is a direct managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials. */ + /** @description MANAGED_PROFILE_OWNER_REQUIRED: status=deleted or status=all without the actor's own active manager configuration. MANAGED_PROFILE_ACCESS_DENIED: direct child credential. CREDENTIAL_MISMATCH: inconsistent key headers. No memberships is not a 403 for the default active list. */ 403: { headers: { [name: string]: unknown; @@ -5928,7 +6467,7 @@ export interface operations { "application/json": components["schemas"]["ManagedProfileErrorResponse"]; }; }; - /** @description `MANAGED_PROFILE_CONFLICT`: a retained child has an invalid customer-entity layout. */ + /** @description MANAGED_PROFILE_CONFLICT: access data becomes incomplete during response construction. Invalid child entity layouts are normally excluded by the eligibility query. */ 409: { headers: { [name: string]: unknown; @@ -5997,7 +6536,7 @@ export interface operations { "application/json": components["schemas"]["ManagedProfileErrorResponse"]; }; }; - /** @description `MANAGED_PROFILE_MANAGER_NOT_FOUND`, `MANAGED_PROFILE_MANAGER_INACTIVE`, or `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated profile is not an active managed-profile manager, or is a direct managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials. */ + /** @description `MANAGED_PROFILE_MANAGER_NOT_FOUND`, `MANAGED_PROFILE_MANAGER_INACTIVE`, or `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated profile is not an active managed-profile manager, or is a direct managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials. IMPERSONATION_NOT_ALLOWED: lifecycle mutations reject impersonation. */ 403: { headers: { [name: string]: unknown; @@ -6029,7 +6568,10 @@ export interface operations { getManagedProfile: { parameters: { query?: never; - header?: never; + header?: { + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ + "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; + }; path: { /** @description Managed child profile ID. */ profileId: string; @@ -6038,16 +6580,16 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Owned active or deleted managed profile. */ + /** @description Actor with both independent flags and an eligible active child, or an owner-only retained child read without a selector. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ManagedProfileResponse"]; + "application/json": components["schemas"]["ManagedProfileAccessResponse"]; }; }; - /** @description `MANAGED_PROFILE_INVALID_INPUT`: profileId is not a UUID. */ + /** @description `MANAGED_PROFILE_INVALID_INPUT`: profileId is not a UUID. INVALID_MANAGED_PROFILE_ID may be returned by path-child authorization before controller validation. */ 400: { headers: { [name: string]: unknown; @@ -6065,7 +6607,7 @@ export interface operations { "application/json": components["schemas"]["ManagedProfileErrorResponse"]; }; }; - /** @description `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated profile is not an active manager, or is a direct managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials. */ + /** @description MANAGED_PROFILE_MEMBERSHIP_INVALID: explicit matching-selector bootstrap has stored membership history but is no longer eligible (including revoked membership, deleted child even for its owner, disabled owner or invalid entity). MANAGED_PROFILE_ACCESS_DENIED: selector/path mismatch, direct child credentials, or an ordinary active-child member read with inactive owner/invalid layout. CREDENTIAL_MISMATCH: inconsistent key headers. The bootstrap rule applies to bearer and member-secret callers. */ 403: { headers: { [name: string]: unknown; @@ -6074,7 +6616,7 @@ export interface operations { "application/json": components["schemas"]["ManagedProfileErrorResponse"]; }; }; - /** @description `MANAGED_PROFILE_NOT_FOUND`: the child does not exist or is not owned by this manager. */ + /** @description MANAGED_PROFILE_NOT_FOUND: unknown child or ordinary read without active membership; invited-member or otherwise ineligible retained read without a selector. Callers with no membership history receive the same masked 404 for existing and unknown children, even with a matching selector. */ 404: { headers: { [name: string]: unknown; @@ -6083,15 +6625,6 @@ export interface operations { "application/json": components["schemas"]["ManagedProfileErrorResponse"]; }; }; - /** @description `MANAGED_PROFILE_CONFLICT`: the retained child has an invalid customer-entity layout. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ManagedProfileErrorResponse"]; - }; - }; /** @description Internal server error. */ 500: { headers: { @@ -6140,7 +6673,7 @@ export interface operations { "application/json": components["schemas"]["ManagedProfileErrorResponse"]; }; }; - /** @description `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated profile is not an active manager, or is a direct managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials. */ + /** @description MANAGED_PROFILE_OWNER_REQUIRED: a non-owner has an active manager or read_only membership. MANAGED_PROFILE_ACCESS_DENIED: the immutable owner's configuration is inactive, or a direct child credential was used. CREDENTIAL_MISMATCH: inconsistent key headers. IMPERSONATION_NOT_ALLOWED: lifecycle mutations reject impersonation. */ 403: { headers: { [name: string]: unknown; @@ -6149,7 +6682,7 @@ export interface operations { "application/json": components["schemas"]["ManagedProfileErrorResponse"]; }; }; - /** @description `MANAGED_PROFILE_NOT_FOUND`: the child does not exist or is not owned by this manager. */ + /** @description MANAGED_PROFILE_NOT_FOUND: missing child or a non-owner without active membership. Outsiders receive the same masked error for existing and unknown children. */ 404: { headers: { [name: string]: unknown; @@ -6172,7 +6705,10 @@ export interface operations { listManagedProfileApiCredentials: { parameters: { query?: never; - header?: never; + header?: { + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ + "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; + }; path: { /** @description Managed child profile ID. */ profileId: string; @@ -6190,7 +6726,7 @@ export interface operations { "application/json": components["schemas"]["ListApiCredentialsResponse"]; }; }; - /** @description `MANAGED_PROFILE_INVALID_INPUT`: profileId is not a UUID. */ + /** @description `MANAGED_PROFILE_INVALID_INPUT`: profileId is not a UUID. INVALID_MANAGED_PROFILE_ID may be returned by path-child authorization before controller validation. */ 400: { headers: { [name: string]: unknown; @@ -6208,7 +6744,7 @@ export interface operations { "application/json": components["schemas"]["ManagedProfileErrorResponse"]; }; }; - /** @description `CREDENTIAL_ACCESS_DENIED`: the manager is inactive. `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated credential belongs directly to a managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials. */ + /** @description MANAGED_PROFILE_ACCESS_DENIED for inactive owner, invalid child layout, direct child credential or selector/path mismatch; CREDENTIAL_MISMATCH for inconsistent key headers. MANAGED_PROFILE_MANAGER_REQUIRED for read_only mutations; IMPERSONATION_NOT_ALLOWED for credential mutations under impersonation. CREDENTIAL_ACCESS_DENIED if service-level owner or membership authority is no longer active. */ 403: { headers: { [name: string]: unknown; @@ -6217,7 +6753,7 @@ export interface operations { "application/json": components["schemas"]["ManagedProfileErrorResponse"]; }; }; - /** @description `CREDENTIAL_NOT_FOUND`: the active child does not exist or is not owned by this manager. */ + /** @description MANAGED_PROFILE_NOT_FOUND: inactive/missing child or missing membership. CREDENTIAL_NOT_FOUND: credential is outside this child's scope or the service cannot resolve the child. */ 404: { headers: { [name: string]: unknown; @@ -6240,7 +6776,10 @@ export interface operations { createManagedProfileApiCredential: { parameters: { query?: never; - header?: never; + header?: { + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ + "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; + }; path: { /** @description Managed child profile ID. */ profileId: string; @@ -6262,7 +6801,7 @@ export interface operations { "application/json": components["schemas"]["CreateApiCredentialResponse"]; }; }; - /** @description Invalid profileId, credential name, or expiry. */ + /** @description Invalid profileId, credential name, or expiry. INVALID_MANAGED_PROFILE_ID may be returned by path-child authorization before controller validation. */ 400: { headers: { [name: string]: unknown; @@ -6280,7 +6819,7 @@ export interface operations { "application/json": components["schemas"]["ManagedProfileErrorResponse"]; }; }; - /** @description `CREDENTIAL_ACCESS_DENIED`: the manager is inactive. `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated credential belongs directly to a managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials. */ + /** @description MANAGED_PROFILE_ACCESS_DENIED for inactive owner, invalid child layout, direct child credential or selector/path mismatch; CREDENTIAL_MISMATCH for inconsistent key headers. MANAGED_PROFILE_MANAGER_REQUIRED for read_only mutations; IMPERSONATION_NOT_ALLOWED for credential mutations under impersonation. CREDENTIAL_ACCESS_DENIED if service-level owner or membership authority is no longer active. */ 403: { headers: { [name: string]: unknown; @@ -6289,7 +6828,7 @@ export interface operations { "application/json": components["schemas"]["ManagedProfileErrorResponse"]; }; }; - /** @description `CREDENTIAL_NOT_FOUND`: the active child does not exist or is not owned by this manager. */ + /** @description MANAGED_PROFILE_NOT_FOUND: inactive/missing child or missing membership. CREDENTIAL_NOT_FOUND: credential is outside this child's scope or the service cannot resolve the child. */ 404: { headers: { [name: string]: unknown; @@ -6321,7 +6860,10 @@ export interface operations { revokeManagedProfileApiCredential: { parameters: { query?: never; - header?: never; + header?: { + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ + "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; + }; path: { /** @description Managed child profile ID. */ profileId: string; @@ -6339,7 +6881,7 @@ export interface operations { }; content?: never; }; - /** @description `MANAGED_PROFILE_INVALID_INPUT`: profileId or credentialId is not a UUID. */ + /** @description `MANAGED_PROFILE_INVALID_INPUT`: profileId or credentialId is not a UUID. INVALID_MANAGED_PROFILE_ID may be returned by path-child authorization before controller validation. */ 400: { headers: { [name: string]: unknown; @@ -6357,7 +6899,7 @@ export interface operations { "application/json": components["schemas"]["ManagedProfileErrorResponse"]; }; }; - /** @description `CREDENTIAL_ACCESS_DENIED`: the manager is inactive. `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated credential belongs directly to a managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials. */ + /** @description MANAGED_PROFILE_ACCESS_DENIED for inactive owner, invalid child layout, direct child credential or selector/path mismatch; CREDENTIAL_MISMATCH for inconsistent key headers. MANAGED_PROFILE_MANAGER_REQUIRED for read_only mutations; IMPERSONATION_NOT_ALLOWED for credential mutations under impersonation. CREDENTIAL_ACCESS_DENIED if service-level owner or membership authority is no longer active. */ 403: { headers: { [name: string]: unknown; @@ -6366,7 +6908,7 @@ export interface operations { "application/json": components["schemas"]["ManagedProfileErrorResponse"]; }; }; - /** @description `CREDENTIAL_NOT_FOUND`: the active child or credential does not exist, or is not owned by this manager. */ + /** @description MANAGED_PROFILE_NOT_FOUND: inactive/missing child or missing membership. CREDENTIAL_NOT_FOUND: credential is outside this child's scope or the service cannot resolve the child. */ 404: { headers: { [name: string]: unknown; @@ -6386,6 +6928,338 @@ export interface operations { }; }; }; + listManagedProfileMemberEvents: { + parameters: { + query?: { + limit?: components["parameters"]["MembershipLimit"]; + cursor?: string; + }; + header?: { + /** @description Optional selector must match the path profileId case-insensitively. It grants no authority. Membership endpoints remain Supabase bearer-only even when selected. */ + "X-Managed-Profile-Id"?: components["parameters"]["MembershipSelection"]; + }; + path: { + profileId: components["parameters"]["MembershipProfileId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Access events and cursor pagination. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListManagedProfileMemberEventsResponse"]; + }; + }; + /** @description MANAGED_PROFILE_INVALID_INPUT for a non-UUID profileId; INVALID_PAGINATION for invalid limit/offset or event cursor. Malformed JSON is rejected before route authentication. */ + 400: components["responses"]["MembershipBadRequest"]; + 401: components["responses"]["MembershipUnauthorized"]; + 403: components["responses"]["MembershipForbidden"]; + 413: components["responses"]["MembershipPayloadTooLarge"]; + 429: components["responses"]["MembershipRateLimited"]; + 500: components["responses"]["MembershipInternalError"]; + 503: components["responses"]["MembershipAuthUnavailable"]; + }; + }; + listManagedProfileMemberInvitations: { + parameters: { + query?: { + limit?: components["parameters"]["MembershipLimit"]; + offset?: components["parameters"]["MembershipOffset"]; + }; + header?: { + /** @description Optional selector must match the path profileId case-insensitively. It grants no authority. Membership endpoints remain Supabase bearer-only even when selected. */ + "X-Managed-Profile-Id"?: components["parameters"]["MembershipSelection"]; + }; + path: { + profileId: components["parameters"]["MembershipProfileId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description All invitation statuses and offset pagination. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListManagedProfileInvitationsResponse"]; + }; + }; + /** @description MANAGED_PROFILE_INVALID_INPUT for a non-UUID profileId; INVALID_PAGINATION for invalid limit/offset or event cursor. Malformed JSON is rejected before route authentication. */ + 400: components["responses"]["MembershipBadRequest"]; + 401: components["responses"]["MembershipUnauthorized"]; + 403: components["responses"]["MembershipForbidden"]; + 413: components["responses"]["MembershipPayloadTooLarge"]; + 429: components["responses"]["MembershipRateLimited"]; + 500: components["responses"]["MembershipInternalError"]; + 503: components["responses"]["MembershipAuthUnavailable"]; + }; + }; + createManagedProfileMemberInvitation: { + parameters: { + query?: never; + header?: { + /** @description Optional selector must match the path profileId case-insensitively. It grants no authority. Membership endpoints remain Supabase bearer-only even when selected. */ + "X-Managed-Profile-Id"?: components["parameters"]["MembershipSelection"]; + }; + path: { + profileId: components["parameters"]["MembershipProfileId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "email": "operator@example.com", + * "role": "manager" + * } + */ + "application/json": components["schemas"]["CreateManagedProfileInvitationRequest"]; + }; + }; + responses: { + /** @description Identical pending invitation; no duplicate email. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileInvitationResponse"]; + }; + }; + /** @description Invitation created and email queued. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileInvitationResponse"]; + }; + }; + /** @description MANAGED_PROFILE_INVALID_INPUT for a non-UUID profileId; INVALID_MEMBERSHIP_ROLE or INVALID_INVITATION_EMAIL for invalid input. Malformed JSON is rejected before route authentication. */ + 400: components["responses"]["MembershipBadRequest"]; + 401: components["responses"]["MembershipUnauthorized"]; + 403: components["responses"]["MembershipForbidden"]; + /** @description INVITATION_ROLE_CONFLICT or MEMBERSHIP_ALREADY_EXISTS. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileErrorResponse"]; + }; + }; + 413: components["responses"]["MembershipPayloadTooLarge"]; + 429: components["responses"]["MembershipRateLimited"]; + 500: components["responses"]["MembershipInternalError"]; + 503: components["responses"]["MembershipAuthUnavailable"]; + }; + }; + cancelManagedProfileMemberInvitation: { + parameters: { + query?: never; + header?: { + /** @description Optional selector must match the path profileId case-insensitively. It grants no authority. Membership endpoints remain Supabase bearer-only even when selected. */ + "X-Managed-Profile-Id"?: components["parameters"]["MembershipSelection"]; + }; + path: { + profileId: components["parameters"]["MembershipProfileId"]; + invitationId: components["parameters"]["MembershipInvitationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invitation cancelled; empty body. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description MANAGED_PROFILE_INVALID_INPUT for non-UUID path IDs. Malformed JSON is rejected before route authentication. */ + 400: components["responses"]["MembershipBadRequest"]; + 401: components["responses"]["MembershipUnauthorized"]; + 403: components["responses"]["MembershipForbidden"]; + /** @description INVITATION_NOT_FOUND: no invitation with this ID in the authorized child. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileErrorResponse"]; + }; + }; + /** @description INVITATION_ACCEPTED, INVITATION_CANCELLED, or INVITATION_EXPIRED. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileErrorResponse"]; + }; + }; + 413: components["responses"]["MembershipPayloadTooLarge"]; + 429: components["responses"]["MembershipRateLimited"]; + 500: components["responses"]["MembershipInternalError"]; + 503: components["responses"]["MembershipAuthUnavailable"]; + }; + }; + listManagedProfileMembers: { + parameters: { + query?: { + limit?: components["parameters"]["MembershipLimit"]; + offset?: components["parameters"]["MembershipOffset"]; + }; + header?: { + /** @description Optional selector must match the path profileId case-insensitively. It grants no authority. Membership endpoints remain Supabase bearer-only even when selected. */ + "X-Managed-Profile-Id"?: components["parameters"]["MembershipSelection"]; + }; + path: { + profileId: components["parameters"]["MembershipProfileId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Active members and offset pagination. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListManagedProfileMembersResponse"]; + }; + }; + /** @description MANAGED_PROFILE_INVALID_INPUT for a non-UUID profileId; INVALID_PAGINATION for invalid limit/offset or event cursor. Malformed JSON is rejected before route authentication. */ + 400: components["responses"]["MembershipBadRequest"]; + 401: components["responses"]["MembershipUnauthorized"]; + 403: components["responses"]["MembershipForbidden"]; + 413: components["responses"]["MembershipPayloadTooLarge"]; + 429: components["responses"]["MembershipRateLimited"]; + 500: components["responses"]["MembershipInternalError"]; + 503: components["responses"]["MembershipAuthUnavailable"]; + }; + }; + removeManagedProfileMember: { + parameters: { + query?: never; + header?: { + /** @description Optional selector must match the path profileId case-insensitively. It grants no authority. Membership endpoints remain Supabase bearer-only even when selected. */ + "X-Managed-Profile-Id"?: components["parameters"]["MembershipSelection"]; + }; + path: { + profileId: components["parameters"]["MembershipProfileId"]; + /** @description Authenticated member profile UUID, not the membership row ID. */ + memberProfileId: components["parameters"]["MembershipMemberProfileId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Member revoked; empty body. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description MANAGED_PROFILE_INVALID_INPUT for non-UUID path IDs. Malformed JSON is rejected before route authentication. */ + 400: components["responses"]["MembershipBadRequest"]; + 401: components["responses"]["MembershipUnauthorized"]; + 403: components["responses"]["MembershipForbidden"]; + /** @description MEMBER_NOT_FOUND: no active target or same-actor revocation retry. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileErrorResponse"]; + }; + }; + /** @description MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED: the owner membership cannot be removed. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileErrorResponse"]; + }; + }; + 413: components["responses"]["MembershipPayloadTooLarge"]; + 429: components["responses"]["MembershipRateLimited"]; + 500: components["responses"]["MembershipInternalError"]; + 503: components["responses"]["MembershipAuthUnavailable"]; + }; + }; + changeManagedProfileMemberRole: { + parameters: { + query?: never; + header?: { + /** @description Optional selector must match the path profileId case-insensitively. It grants no authority. Membership endpoints remain Supabase bearer-only even when selected. */ + "X-Managed-Profile-Id"?: components["parameters"]["MembershipSelection"]; + }; + path: { + profileId: components["parameters"]["MembershipProfileId"]; + /** @description Authenticated member profile UUID, not the membership row ID. */ + memberProfileId: components["parameters"]["MembershipMemberProfileId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "role": "read_only" + * } + */ + "application/json": components["schemas"]["ChangeManagedProfileMemberRequest"]; + }; + }; + responses: { + /** @description Current member role. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileMemberResponse"]; + }; + }; + /** @description MANAGED_PROFILE_INVALID_INPUT for non-UUID path IDs; INVALID_MEMBERSHIP_ROLE unless role is manager or read_only. Malformed JSON is rejected before route authentication. */ + 400: components["responses"]["MembershipBadRequest"]; + 401: components["responses"]["MembershipUnauthorized"]; + 403: components["responses"]["MembershipForbidden"]; + /** @description MEMBER_NOT_FOUND: no active target member. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileErrorResponse"]; + }; + }; + /** @description MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED: the owner membership cannot be changed. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileErrorResponse"]; + }; + }; + 413: components["responses"]["MembershipPayloadTooLarge"]; + 429: components["responses"]["MembershipRateLimited"]; + 500: components["responses"]["MembershipInternalError"]; + 503: components["responses"]["MembershipAuthUnavailable"]; + }; + }; selectActiveCustomerEntity: { parameters: { query?: never; @@ -6490,7 +7364,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -6518,6 +7392,7 @@ export interface operations { }; /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Onboarding aggregation failed. */ 500: { @@ -6534,7 +7409,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -6659,7 +7534,7 @@ export interface operations { "application/json": components["schemas"]["ErrorManagedSelectorResponse"]; }; }; - /** @description Partner authorization or managed-profile authorization failed. */ + /** @description Partner authorization or managed-profile authorization failed. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: { headers: { [name: string]: unknown; @@ -6698,7 +7573,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -6785,7 +7660,7 @@ export interface operations { "application/json": components["schemas"]["ErrorManagedSelectorResponse"]; }; }; - /** @description Partner authorization or managed-profile authorization failed. */ + /** @description Partner authorization or managed-profile authorization failed. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: { headers: { [name: string]: unknown; @@ -6824,7 +7699,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -6859,7 +7734,7 @@ export interface operations { "application/json": components["schemas"]["ApiCredentialManagedSelectorErrorResponse"]; }; }; - /** @description `CREDENTIAL_MISMATCH`: presented public and secret values belong to different credentials. */ + /** @description `CREDENTIAL_MISMATCH`: presented public and secret values belong to different credentials. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: { headers: { [name: string]: unknown; @@ -6874,7 +7749,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path: { @@ -6912,7 +7787,7 @@ export interface operations { "application/json": components["schemas"]["ErrorManagedSelectorResponse"]; }; }; - /** @description Ramp does not belong to authenticated principal. */ + /** @description Ramp does not belong to authenticated principal. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED. */ 403: { headers: { [name: string]: unknown; @@ -6934,7 +7809,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -7108,7 +7983,7 @@ export interface operations { }; }; 401: components["responses"]["ManagedSelectorUnauthorized"]; - /** @description Quote ownership, managed-profile authorization, or impersonation policy failed. */ + /** @description Quote ownership, managed-profile authorization, or impersonation policy failed. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer ramp mutation: MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL; no drain exception. */ 403: { headers: { [name: string]: unknown; @@ -7137,7 +8012,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -7257,7 +8132,7 @@ export interface operations { "application/json": components["schemas"]["ErrorManagedSelectorResponse"]; }; }; - /** @description Ramp ownership, managed-profile authorization, or impersonation policy failed. */ + /** @description Ramp ownership, managed-profile authorization, or impersonation policy failed. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer ramp mutation: MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL; no drain exception. */ 403: { headers: { [name: string]: unknown; @@ -7286,7 +8161,7 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + /** @description Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; path?: never; @@ -7419,7 +8294,7 @@ export interface operations { "application/json": components["schemas"]["ErrorManagedSelectorResponse"]; }; }; - /** @description Ramp ownership, managed-profile authorization, or impersonation policy failed. */ + /** @description Ramp ownership, managed-profile authorization, or impersonation policy failed. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer ramp mutation: MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL; no drain exception. */ 403: { headers: { [name: string]: unknown; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index cb34238b2..483e8fbf4 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2,7 +2,66 @@ "components": { "parameters": { "ManagedProfileId": { - "description": "Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`.", + "description": "Selects one child through the actor's live manager or read_only membership. Use a member-owned secret X-API-Key or, for allowed read/manage operations, a Supabase Bearer session. Public keys and direct child credentials cannot select a child. The immutable owner's active relationship and current corridor/type policy govern access, not the member's personal manager policy. read_only permits only read capability (including quote creation). credential_manage provider/KYC mutations require a manager membership and member-owned secret (403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL for bearer); ramp register/update/start likewise require a secret (403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL), with bearer denial before body parsing and no drain exception. Role denial is MANAGED_PROFILE_MANAGER_REQUIRED; policy denial is MANAGED_PROFILE_POLICY_DENIED. On GET /v1/managed-profiles/{profileId}, an exactly matching selector explicitly requests bootstrap: stored membership history is required before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID, for bearer or member-secret callers. A caller who was never a member receives the same masked 404 for an existing or unknown child. Retained deleted-child reads require the active immutable owner and no selector. Other delegated probes use MANAGED_PROFILE_ACCESS_DENIED where applicable. Invalid UUIDs return 400 INVALID_MANAGED_PROFILE_ID. See each operation for restrictions.", + "in": "header", + "name": "X-Managed-Profile-Id", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + }, + "MembershipInvitationId": { + "in": "path", + "name": "invitationId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + "MembershipLimit": { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "MembershipMemberProfileId": { + "description": "Authenticated member profile UUID, not the membership row ID.", + "in": "path", + "name": "memberProfileId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + "MembershipOffset": { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "type": "integer" + } + }, + "MembershipProfileId": { + "in": "path", + "name": "profileId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + "MembershipSelection": { + "description": "Optional selector must match the path profileId case-insensitively. It grants no authority. Membership endpoints remain Supabase bearer-only even when selected.", "in": "header", "name": "X-Managed-Profile-Id", "required": false, @@ -62,6 +121,87 @@ }, "description": "A valid secret API key or Bearer session is required." }, + "MembershipAuthUnavailable": { + "content": { + "application/json": { + "example": { + "error": "Authentication service unavailable" + }, + "schema": { + "$ref": "#/components/schemas/FlatErrorResponse" + } + } + }, + "description": "Authentication service unavailable (transient Supabase verification failure)." + }, + "MembershipBadRequest": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + }, + { + "$ref": "#/components/schemas/MalformedJsonErrorResponse" + } + ] + } + } + }, + "description": "MANAGED_PROFILE_INVALID_INPUT for non-UUID path identifiers; INVALID_PAGINATION for invalid pagination; INVALID_MEMBERSHIP_ROLE for a role other than manager or read_only; INVALID_INVITATION_EMAIL for invalid creation email. Invitee routes reject any managed selector with MANAGED_PROFILE_UNSUPPORTED. Malformed JSON is rejected by the global parser before route authentication." + }, + "MembershipForbidden": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "MANAGED_PROFILE_ACCESS_DENIED for API-key headers (even with a bearer), inaccessible child/membership, mismatched selector, or invitee email mismatch/unverified email/unknown invitation. MANAGED_PROFILE_MANAGER_REQUIRED for read_only mutations. IMPERSONATION_NOT_ALLOWED for impersonation. No invitation details are disclosed to mismatched invitees." + }, + "MembershipInternalError": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "INTERNAL_SERVER_ERROR: Unable to process membership request." + }, + "MembershipPayloadTooLarge": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PayloadTooLargeErrorResponse" + } + } + }, + "description": "Request body exceeds the global 20 MB limit. Rejected before membership authentication." + }, + "MembershipRateLimited": { + "content": { + "text/html": { + "schema": { + "example": "Too many requests, please try again later.", + "type": "string" + } + } + }, + "description": "Authenticated membership routes share a limit of 120 requests per minute per actor. Standard rate-limit headers are returned." + }, + "MembershipUnauthorized": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlatErrorResponse" + } + } + }, + "description": "Missing or invalid authorization header, Invalid or expired token, or Authentication failed. This authentication middleware uses a flat string error, not a structured membership error." + }, "RecordNotFound": { "content": { "application/json": { @@ -83,6 +223,19 @@ } }, "schemas": { + "AcceptManagedProfileInvitationResponse": { + "properties": { + "managedProfileId": { + "format": "uuid", + "type": "string" + }, + "member": { + "$ref": "#/components/schemas/ManagedProfileMember" + } + }, + "required": ["managedProfileId", "member"], + "type": "object" + }, "AccountMeta": { "properties": { "address": { @@ -778,6 +931,15 @@ "required": ["valid"], "type": "object" }, + "ChangeManagedProfileMemberRequest": { + "properties": { + "role": { + "$ref": "#/components/schemas/ManagedProfileMembershipRole" + } + }, + "required": ["role"], + "type": "object" + }, "CleanupPhase": { "properties": { "string": { @@ -876,6 +1038,21 @@ "required": ["rampType", "inputAmount", "inputCurrency", "outputCurrency"], "type": "object" }, + "CreateManagedProfileInvitationRequest": { + "properties": { + "email": { + "description": "Trimmed and lowercased before validation and comparison; length limit applies after normalization.", + "format": "email", + "maxLength": 254, + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/ManagedProfileMembershipRole" + } + }, + "required": ["email", "role"], + "type": "object" + }, "CreateManagedProfileRequest": { "properties": { "contactEmail": { @@ -2188,18 +2365,92 @@ "required": ["credentials"], "type": "object" }, + "ListManagedProfileInvitationsResponse": { + "properties": { + "invitations": { + "items": { + "$ref": "#/components/schemas/ManagedProfileInvitation" + }, + "type": "array" + }, + "pagination": { + "$ref": "#/components/schemas/ManagedProfilePagination" + } + }, + "required": ["invitations", "pagination"], + "type": "object" + }, + "ListManagedProfileMemberEventsResponse": { + "properties": { + "events": { + "items": { + "$ref": "#/components/schemas/ManagedProfileMemberEvent" + }, + "type": "array" + }, + "pagination": { + "properties": { + "limit": { + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "nextCursor": { + "format": "uuid", + "type": ["string", "null"] + } + }, + "required": ["limit", "nextCursor"], + "type": "object" + } + }, + "required": ["events", "pagination"], + "type": "object" + }, + "ListManagedProfileMembersResponse": { + "properties": { + "members": { + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/ManagedProfileMember" + }, + { + "properties": { + "email": { + "type": ["string", "null"] + } + }, + "required": ["email"], + "type": "object" + } + ] + }, + "type": "array" + }, + "pagination": { + "$ref": "#/components/schemas/ManagedProfilePagination" + } + }, + "required": ["members", "pagination"], + "type": "object" + }, "ListManagedProfilesResponse": { "properties": { + "actor": { + "$ref": "#/components/schemas/ManagedProfileActor" + }, "managedProfiles": { "items": { - "$ref": "#/components/schemas/ManagedProfile" + "$ref": "#/components/schemas/ManagedProfileAccess" }, "type": "array" }, - "manager": { "$ref": "#/components/schemas/ManagedProfileManagerPolicy" }, - "pagination": { "$ref": "#/components/schemas/ManagedProfilePagination" } + "pagination": { + "$ref": "#/components/schemas/ManagedProfilePagination" + } }, - "required": ["manager", "managedProfiles", "pagination"], + "required": ["actor", "managedProfiles", "pagination"], "type": "object" }, "LivenessDocumentEntry": { @@ -2296,80 +2547,71 @@ ], "type": "object" }, - "ManagedProfileErrorResponse": { - "properties": { - "error": { + "ManagedProfileAccess": { + "allOf": [ + { + "$ref": "#/components/schemas/ManagedProfile" + }, + { "properties": { - "code": { - "description": "Machine-readable error code. Managed-profile lifecycle codes include `MANAGED_PROFILE_INVALID_INPUT`, `MANAGED_PROFILE_ACCESS_DENIED`, `MANAGED_PROFILE_NOT_FOUND`, `MANAGED_PROFILE_CONFLICT`, `MANAGED_PROFILE_MANAGER_NOT_FOUND`, and `MANAGED_PROFILE_MANAGER_INACTIVE`. Credential codes include `INVALID_CREDENTIAL_NAME`, `INVALID_CREDENTIAL_EXPIRY`, `CREDENTIAL_ACCESS_DENIED`, `CREDENTIAL_NOT_FOUND`, and `CREDENTIAL_LIMIT_REACHED`. Authentication middleware may return `AUTHENTICATION_REQUIRED`, `INVALID_SECRET_KEY`, `INVALID_API_KEY`, `INVALID_BEARER_TOKEN`, `INVALID_PUBLIC_KEY`, or `CREDENTIAL_MISMATCH`.", - "type": "string" - }, - "message": { - "type": "string" + "membership": { + "properties": { + "isOwner": { + "type": "boolean" + }, + "role": { + "$ref": "#/components/schemas/ManagedProfileMembershipRole" + } + }, + "required": ["role", "isOwner"], + "type": "object" }, - "status": { - "type": "integer" + "policy": { + "$ref": "#/components/schemas/ManagedProfilePolicy" } }, - "required": ["code", "message", "status"], + "required": ["membership", "policy"], "type": "object" } - }, - "required": ["error"], - "type": "object" + ] }, - "ManagedProfileManagerPolicy": { - "description": "The authenticated manager's current policy. This policy is manager-scoped and applies to every managed child; corridors and customer types are not grants copied onto each child.", + "ManagedProfileAccessResponse": { "properties": { - "allowedCorridors": { - "items": { "enum": ["AR", "BR", "CO", "EU", "MX", "US"], "type": "string" }, - "type": "array", - "uniqueItems": true - }, - "allowedCustomerTypes": { - "items": { "enum": ["individual", "business"], "type": "string" }, - "type": ["array", "null"], - "uniqueItems": true + "actor": { + "$ref": "#/components/schemas/ManagedProfileActor" }, - "profileId": { "format": "uuid", "type": "string" } + "managedProfile": { + "$ref": "#/components/schemas/ManagedProfileAccess" + } }, - "required": ["profileId", "allowedCorridors", "allowedCustomerTypes"], + "required": ["actor", "managedProfile"], "type": "object" }, - "ManagedProfilePagination": { + "ManagedProfileActor": { + "description": "Actor projection shared by list and detail responses. Flags are independent of pagination, the requested status filter, and the particular child being read.", "properties": { - "limit": { - "maximum": 100, - "minimum": 1, - "type": "integer" + "canProvisionManagedProfiles": { + "description": "True exactly when the actor has its own active managed-profile manager configuration. Membership alone does not grant provisioning.", + "type": "boolean" }, - "offset": { - "minimum": 0, - "type": "integer" + "hasMemberships": { + "description": "True when the unpaginated eligible active-child count is greater than zero, across all owners and independent of status=deleted/all owner-scoped results. Requires an unrevoked manager or read_only membership, active child relationship and owner configuration, a managed child, and exactly one active owned customer entity selected by that child. Excludes deleted children, inactive owners and invalid entity layouts. May remain true for an empty page, or false while retained records are returned.", + "type": "boolean" }, - "total": { - "minimum": 0, - "type": "integer" - } - }, - "required": ["limit", "offset", "total"], - "type": "object" - }, - "ManagedProfileResponse": { - "properties": { - "managedProfile": { - "$ref": "#/components/schemas/ManagedProfile" + "profileId": { + "format": "uuid", + "type": "string" } }, - "required": ["managedProfile"], + "required": ["profileId", "canProvisionManagedProfiles", "hasMemberships"], "type": "object" }, - "ManagedSelectorErrorResponse": { + "ManagedProfileErrorResponse": { "properties": { "error": { "properties": { "code": { - "description": "Machine-readable middleware code such as `INVALID_MANAGED_PROFILE_ID`, `MANAGED_PROFILE_CUSTOMER_TYPE_MISMATCH`, `AUTHENTICATION_REQUIRED`, `INVALID_SECRET_KEY`, `INVALID_API_KEY`, `INVALID_BEARER_TOKEN`, `CREDENTIAL_MISMATCH`, `MANAGED_PROFILE_ACCESS_DENIED`, or `IMPERSONATION_NOT_ALLOWED`.", + "description": "Machine-readable error code. Lifecycle codes include MANAGED_PROFILE_INVALID_INPUT, MANAGED_PROFILE_ACCESS_DENIED, MANAGED_PROFILE_NOT_FOUND, MANAGED_PROFILE_CONFLICT, MANAGED_PROFILE_MANAGER_NOT_FOUND, MANAGED_PROFILE_MANAGER_INACTIVE, MANAGED_PROFILE_OWNER_REQUIRED (retained filters or non-owner member deletion), and MANAGED_PROFILE_MEMBERSHIP_INVALID (explicit bootstrap with membership history). Credential codes include INVALID_CREDENTIAL_NAME, INVALID_CREDENTIAL_EXPIRY, CREDENTIAL_ACCESS_DENIED, CREDENTIAL_NOT_FOUND, and CREDENTIAL_LIMIT_REACHED. Authentication middleware may return AUTHENTICATION_REQUIRED, INVALID_SECRET_KEY, INVALID_API_KEY, INVALID_BEARER_TOKEN, INVALID_PUBLIC_KEY, or CREDENTIAL_MISMATCH.", "type": "string" }, "message": { @@ -2386,50 +2628,280 @@ "required": ["error"], "type": "object" }, - "Networks": { - "description": "Supported blockchain networks.", - "enum": ["assethub", "arbitrum", "avalanche", "base", "bsc", "ethereum", "polygon", "moonbeam"], - "type": "string" - }, - "OnboardingApiErrorResponse": { - "properties": { - "error": { - "oneOf": [ - { - "type": "string" - }, - { - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - }, - "required": ["code", "message", "status"], - "type": "object" - } - ] - } - }, - "required": ["error"], - "type": "object" - }, - "OnboardingDocumentRequirement": { + "ManagedProfileInvitation": { "properties": { - "acceptedMediaTypes": { - "items": { - "type": "string" - }, - "type": "array" - }, - "collection": { - "enum": ["direct-upload", "hosted"], - "type": "string" + "acceptedAt": { + "format": "date-time", + "type": ["string", "null"] + }, + "cancelledAt": { + "format": "date-time", + "type": ["string", "null"] + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "email": { + "format": "email", + "type": "string" + }, + "expiredAt": { + "format": "date-time", + "type": ["string", "null"] + }, + "expiresAt": { + "format": "date-time", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "invitedByProfileId": { + "format": "uuid", + "type": "string" + }, + "managedProfileId": { + "format": "uuid", + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/ManagedProfileMembershipRole" + }, + "status": { + "enum": ["pending", "accepted", "cancelled", "expired"], + "type": "string" + } + }, + "required": [ + "id", + "managedProfileId", + "invitedByProfileId", + "email", + "role", + "status", + "createdAt", + "expiresAt", + "acceptedAt", + "cancelledAt", + "expiredAt" + ], + "type": "object" + }, + "ManagedProfileInvitationResponse": { + "properties": { + "invitation": { + "$ref": "#/components/schemas/ManagedProfileInvitation" + } + }, + "required": ["invitation"], + "type": "object" + }, + "ManagedProfileMember": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "isOwner": { + "type": "boolean" + }, + "memberProfileId": { + "format": "uuid", + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/ManagedProfileMembershipRole" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": ["id", "memberProfileId", "role", "isOwner", "createdAt", "updatedAt"], + "type": "object" + }, + "ManagedProfileMemberEvent": { + "properties": { + "action": { + "enum": [ + "member_added", + "invited", + "invitation_cancelled", + "invitation_expired", + "invitation_accepted", + "role_changed", + "member_removed" + ], + "type": "string" + }, + "actorProfileId": { + "format": "uuid", + "type": ["string", "null"] + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "invitationId": { + "format": "uuid", + "type": ["string", "null"] + }, + "memberProfileId": { + "format": "uuid", + "type": ["string", "null"] + }, + "previousRole": { + "enum": ["manager", "read_only", null], + "type": ["string", "null"] + }, + "role": { + "enum": ["manager", "read_only", null], + "type": ["string", "null"] + } + }, + "required": ["id", "action", "actorProfileId", "memberProfileId", "invitationId", "previousRole", "role", "createdAt"], + "type": "object" + }, + "ManagedProfileMemberResponse": { + "properties": { + "member": { + "$ref": "#/components/schemas/ManagedProfileMember" + } + }, + "required": ["member"], + "type": "object" + }, + "ManagedProfileMembershipRole": { + "enum": ["manager", "read_only"], + "type": "string" + }, + "ManagedProfilePagination": { + "properties": { + "limit": { + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "offset": { + "minimum": 0, + "type": "integer" + }, + "total": { + "minimum": 0, + "type": "integer" + } + }, + "required": ["limit", "offset", "total"], + "type": "object" + }, + "ManagedProfilePolicy": { + "description": "The child's immutable owner's current policy, returned per child because one actor can belong to children with different owners. Not a copied child grant or the acting member's personal policy. EU may occur in stored policy but managed EUR flows remain unsupported.", + "properties": { + "allowedCorridors": { + "items": { + "enum": ["AR", "BR", "CO", "EU", "MX", "US"], + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "allowedCustomerTypes": { + "items": { + "enum": ["individual", "business"], + "type": "string" + }, + "type": ["array", "null"], + "uniqueItems": true + } + }, + "required": ["allowedCorridors", "allowedCustomerTypes"], + "type": "object" + }, + "ManagedProfileResponse": { + "properties": { + "managedProfile": { + "$ref": "#/components/schemas/ManagedProfile" + } + }, + "required": ["managedProfile"], + "type": "object" + }, + "ManagedSelectorErrorResponse": { + "properties": { + "error": { + "properties": { + "code": { + "description": "Machine-readable middleware code such as INVALID_MANAGED_PROFILE_ID, MANAGED_PROFILE_CUSTOMER_TYPE_MISMATCH, AUTHENTICATION_REQUIRED, INVALID_SECRET_KEY, INVALID_API_KEY, INVALID_BEARER_TOKEN, CREDENTIAL_MISMATCH, MANAGED_PROFILE_ACCESS_DENIED, MANAGED_PROFILE_MANAGER_REQUIRED, MANAGED_PROFILE_OWNER_REQUIRED, MANAGED_PROFILE_POLICY_DENIED, MANAGED_PROFILE_REQUIRES_API_CREDENTIAL, MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL, or IMPERSONATION_NOT_ALLOWED. MANAGED_PROFILE_MEMBERSHIP_INVALID requires explicit detail bootstrap with an exactly matching selector and stored membership history; it is not bearer-only and never reveals a child to a caller who was never a member.", + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": ["code", "message", "status"], + "type": "object" + } + }, + "required": ["error"], + "type": "object" + }, + "Networks": { + "description": "Supported blockchain networks.", + "enum": ["assethub", "arbitrum", "avalanche", "base", "bsc", "ethereum", "polygon", "moonbeam"], + "type": "string" + }, + "OnboardingApiErrorResponse": { + "properties": { + "error": { + "oneOf": [ + { + "type": "string" + }, + { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": ["code", "message", "status"], + "type": "object" + } + ] + } + }, + "required": ["error"], + "type": "object" + }, + "OnboardingDocumentRequirement": { + "properties": { + "acceptedMediaTypes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "collection": { + "enum": ["direct-upload", "hosted"], + "type": "string" }, "description": { "type": "string" @@ -2539,10 +3011,14 @@ "type": "string" }, "documents": { - "items": { "$ref": "#/components/schemas/OnboardingDocumentRequirement" }, + "items": { + "$ref": "#/components/schemas/OnboardingDocumentRequirement" + }, "type": "array" }, - "flow": { "type": "string" }, + "flow": { + "type": "string" + }, "mode": { "enum": ["api", "hosted", "hybrid"], "type": "string" @@ -2555,9 +3031,13 @@ "enum": ["alfredpay", "avenia"], "type": "string" }, - "requirementsVersion": { "type": "string" }, + "requirementsVersion": { + "type": "string" + }, "steps": { - "items": { "$ref": "#/components/schemas/OnboardingRequirementStep" }, + "items": { + "$ref": "#/components/schemas/OnboardingRequirementStep" + }, "type": "array" } }, @@ -2914,6 +3394,41 @@ }, "type": "object" }, + "PreviewManagedProfileInvitationResponse": { + "properties": { + "invitation": { + "$ref": "#/components/schemas/ManagedProfileInvitation" + }, + "inviter": { + "properties": { + "email": { + "type": ["string", "null"] + }, + "profileId": { + "format": "uuid", + "type": "string" + } + }, + "required": ["profileId", "email"], + "type": "object" + }, + "managedProfile": { + "properties": { + "externalSubjectId": { + "type": "string" + }, + "profileId": { + "format": "uuid", + "type": "string" + } + }, + "required": ["profileId", "externalSubjectId"], + "type": "object" + } + }, + "required": ["invitation", "inviter", "managedProfile"], + "type": "object" + }, "QuoteResponse": { "properties": { "anchorFeeFiat": { @@ -4178,7 +4693,7 @@ "/v1/brl/createSubaccount": { "post": { "deprecated": false, - "description": "`companyName`, `startDate` and `cnpj` are only required when taxIdType is `CNPJ`\n\n`quoteId` is optional: pass it in the normal ramp flow, or omit it for the quote-less KYB deep link where business verification starts before any quote exists.\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", + "description": "`companyName`, `startDate` and `cnpj` are only required when taxIdType is `CNPJ`\n\n`quoteId` is optional: pass it in the normal ramp flow, or omit it for the quote-less KYB deep link where business verification starts before any quote exists.\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "createSubaccount", "parameters": [ { @@ -4222,7 +4737,8 @@ "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, "403": { - "$ref": "#/components/responses/BrlaManagedSelectorForbidden" + "$ref": "#/components/responses/BrlaManagedSelectorForbidden", + "description": "Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "500": { "content": { @@ -4245,13 +4761,14 @@ } ], "summary": "Create user or retry KYC", - "tags": ["Account Management"] + "tags": ["Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/brl/getKycStatus": { "get": { "deprecated": false, - "description": "\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", + "description": "\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.\n\n**Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "fetchSubaccountKycStatus", "parameters": [ { @@ -4294,7 +4811,8 @@ "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, "403": { - "$ref": "#/components/responses/BrlaManagedSelectorForbidden" + "$ref": "#/components/responses/BrlaManagedSelectorForbidden", + "description": "Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." }, "404": { "content": { @@ -4350,13 +4868,14 @@ } ], "summary": "Get user's KYC status", - "tags": ["Account Management"] + "tags": ["Account Management"], + "x-managed-profile-capability": "read" } }, "/v1/brl/getSelfieLivenessUrl": { "get": { "deprecated": false, - "description": "Returns the selfie/liveness-check URL for the subaccount associated with this tax ID.\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", + "description": "Returns the selfie/liveness-check URL for the subaccount associated with this tax ID.\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "brGetSelfieLivenessUrl", "parameters": [ { @@ -4399,7 +4918,8 @@ "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, "403": { - "$ref": "#/components/responses/BrlaManagedSelectorForbidden" + "$ref": "#/components/responses/BrlaManagedSelectorForbidden", + "description": "Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "409": { "content": { @@ -4444,13 +4964,14 @@ } ], "summary": "Get selfie liveness URL", - "tags": ["Account Management"] + "tags": ["Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/brl/getUploadUrls": { "post": { "deprecated": false, - "description": "Returns a presigned upload URL for the user's ID document and a provider-hosted URL for selfie liveness capture. Only `ID` and `DRIVERS-LICENSE` are accepted for `documentType` (passport not supported here).\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", + "description": "Returns a presigned upload URL for the user's ID document and a provider-hosted URL for selfie liveness capture. Only `ID` and `DRIVERS-LICENSE` are accepted for `documentType` (passport not supported here).\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "brGetUploadUrls", "parameters": [ { @@ -4494,7 +5015,8 @@ "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, "403": { - "$ref": "#/components/responses/BrlaManagedSelectorForbidden" + "$ref": "#/components/responses/BrlaManagedSelectorForbidden", + "description": "Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "409": { "content": { @@ -4539,13 +5061,14 @@ } ], "summary": "Get KYC document upload URLs", - "tags": ["Account Management"] + "tags": ["Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/brl/getUser": { "get": { "deprecated": false, - "description": "Fetches the authenticated subject's subaccount information. The response contains only the EVM wallet address and KYC level. Omit the deprecated taxId query to derive the canonical account from the authenticated subject; when supplied, taxId is only an ownership-checked cross-check. Managed-profile selection requires the manager's secret key or Bearer session.", + "description": "Fetches the authenticated subject's subaccount information. The response contains only the EVM wallet address and KYC level. Omit the deprecated taxId query to derive the canonical account from the authenticated subject; when supplied, taxId is only an ownership-checked cross-check. Managed-profile selection requires an active member's secret key or Bearer session.\n\n**Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "getBrUser", "parameters": [ { @@ -4589,7 +5112,8 @@ "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, "403": { - "$ref": "#/components/responses/BrlaManagedSelectorForbidden" + "$ref": "#/components/responses/BrlaManagedSelectorForbidden", + "description": "Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." }, "404": { "content": { @@ -4623,13 +5147,14 @@ } ], "summary": "Get user information", - "tags": ["Account Management"] + "tags": ["Account Management"], + "x-managed-profile-capability": "read" } }, "/v1/brl/getUserRemainingLimit": { "get": { "deprecated": false, - "description": "Returns the authenticated subject's remaining BRL limit for the required ramp direction. Omit the deprecated taxId query to derive the canonical account from the authenticated subject; when supplied, taxId is only an ownership-checked cross-check. Managed-profile selection requires the manager's secret key or Bearer session.", + "description": "Returns the authenticated subject's remaining BRL limit for the required ramp direction. Omit the deprecated taxId query to derive the canonical account from the authenticated subject; when supplied, taxId is only an ownership-checked cross-check. Managed-profile selection requires an active member's secret key or Bearer session.\n\n**Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "getBrUserRemainingLimit", "parameters": [ { @@ -4682,7 +5207,8 @@ "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, "403": { - "$ref": "#/components/responses/BrlaManagedSelectorForbidden" + "$ref": "#/components/responses/BrlaManagedSelectorForbidden", + "description": "Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." }, "404": { "content": { @@ -4716,12 +5242,13 @@ } ], "summary": "Get user's remaining transaction limits", - "tags": ["Account Management"] + "tags": ["Account Management"], + "x-managed-profile-capability": "read" } }, "/v1/brl/kyb/attempt-status": { "get": { - "description": "Refreshes an owned KYB attempt and persists its normalized verification state.", + "description": "Refreshes an owned KYB attempt and persists its normalized verification state.\n\n**Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "getBrKybAttemptStatus", "parameters": [ { @@ -4763,7 +5290,7 @@ }, "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden", - "description": "Attempt does not belong to the effective profile." + "description": "Attempt does not belong to the effective profile. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." }, "404": { "content": { @@ -4801,12 +5328,13 @@ } ], "summary": "Get KYB attempt status", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "read" } }, "/v1/brl/kyb/documents": { "post": { - "description": "Creates a document target. Ordinary documents return presigned upload URLs; `SELFIE-FROM-LIVENESS` returns a provider-hosted liveness URL instead.", + "description": "Creates a document target. Ordinary documents return presigned upload URLs; `SELFIE-FROM-LIVENESS` returns a provider-hosted liveness URL instead.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "createBrKybDocument", "parameters": [ { @@ -4858,7 +5386,7 @@ }, "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden", - "description": "Managed profile or corridor is not authorized." + "description": "Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "404": { "description": "Subaccount not found." @@ -4876,12 +5404,13 @@ } ], "summary": "Create KYB document", - "tags": ["KYC and KYB"] + "tags": ["KYC and KYB"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/brl/kyb/documents/{documentId}": { "get": { - "description": "Reads readiness and upload status for an owned KYB document.", + "description": "Reads readiness and upload status for an owned KYB document.\n\n**Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "getBrKybDocument", "parameters": [ { @@ -4931,7 +5460,7 @@ }, "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden", - "description": "Document does not belong to the effective profile." + "description": "Document does not belong to the effective profile. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." }, "404": { "description": "Document not found." @@ -4949,12 +5478,13 @@ } ], "summary": "Get KYB document", - "tags": ["KYC and KYB"] + "tags": ["KYC and KYB"], + "x-managed-profile-capability": "read" } }, "/v1/brl/kyb/new-level-1/api": { "post": { - "description": "Submits the API-driven Level 1 KYB attempt after validating the owned corporate documents and UBO references.", + "description": "Submits the API-driven Level 1 KYB attempt after validating the owned corporate documents and UBO references.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "submitBrKybLevel1Api", "parameters": [ { @@ -5006,7 +5536,7 @@ }, "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden", - "description": "Managed profile or corridor is not authorized." + "description": "Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "404": { "description": "Subaccount or referenced document not found." @@ -5027,12 +5557,13 @@ } ], "summary": "Submit API-driven KYB", - "tags": ["KYC and KYB"] + "tags": ["KYC and KYB"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/brl/kyb/new-level-1/web-sdk": { "post": { - "description": "Starts or resumes the provider's hosted KYB level-1 flow for an owned company subaccount.", + "description": "Starts or resumes the provider's hosted KYB level-1 flow for an owned company subaccount.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "startBrKybLevel1Hosted", "parameters": [ { @@ -5074,7 +5605,7 @@ }, "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden", - "description": "Managed profile or corridor is not authorized." + "description": "Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "404": { "content": { @@ -5119,12 +5650,13 @@ } ], "summary": "Start hosted KYB", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/brl/kyb/ubos": { "post": { - "description": "Registers a UBO after verifying that referenced identity documents are ready and owned by the company subaccount.", + "description": "Registers a UBO after verifying that referenced identity documents are ready and owned by the company subaccount.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "createBrKybUbo", "parameters": [ { @@ -5176,7 +5708,7 @@ }, "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden", - "description": "Managed profile or corridor is not authorized." + "description": "Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "404": { "description": "Subaccount or referenced document not found." @@ -5197,12 +5729,13 @@ } ], "summary": "Create KYB UBO", - "tags": ["KYC and KYB"] + "tags": ["KYC and KYB"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/brl/kyc/import-token": { "post": { - "description": "Imports an opaque Sumsub share token into the authenticated subject's existing individual KYC case. This alternative path is enabled by approved Vortex policy despite unresolved legal/consent wording and provider-environment confirmations; no live sandbox verification is claimed. Authentication and profile-bound principal enforcement run before managed-profile authorization and strict body validation. Use either a profile-bound secret `X-API-Key` or a Supabase Bearer session. A controlling manager may add `X-Managed-Profile-Id`; direct managed-child credentials are rejected even without the selector. Public and ownerless credentials are insufficient.\n\nThe body accepts only `importToken` and literal `consentAttested: true`; CPF, tax ID, subaccount ID, applicant ID, entity ID, provider-customer ID, profile ID, and other caller identity selectors are forbidden. The provisional server-controlled consent policy is `sumsub-share-v1`. Every token claim appends actor, subject, policy version, and timestamp consent evidence without storing the raw token.\n\nThe first normal KYC artifact, status read, or token-import claim permanently selects that case's method. Import the token before reading KYC or onboarding status because a status read selects a nullable method as `standard`. The same idempotency key and token returns a stored confirmed attempt or safely reconciles a durable submitted/ambiguous claim through provider reads, without another provider POST or replaying the token. A different token under the same key returns `409`. A provider `401` means the feature precondition is unavailable, records a failed attempt, returns `412`, and may be retried only with a new idempotency key; the new claim appends consent evidence while preserving prior attestations. Every other post-send provider, transport, malformed-response, timeout, or local-confirmation failure is ambiguous, returns `502`, and is never replayed automatically.\n\nAcceptance is pending only. Vortex polls the exact returned provider attempt; `EXPIRED` remains non-approved and locally pending for reconciliation, and its external status is retained. Only a provider `COMPLETED` plus `APPROVED` completes KYC. The provider webhook is notification-only and cannot approve the case.", + "description": "Imports an opaque Sumsub share token into the authenticated subject's existing individual KYC case. This alternative path is enabled by approved Vortex policy despite unresolved legal/consent wording and provider-environment confirmations; no live sandbox verification is claimed. Authentication and profile-bound principal enforcement run before managed-profile authorization and strict body validation. For a direct non-managed profile, use a profile-bound secret `X-API-Key` or Supabase Bearer session. For a selected child, an active manager member must use a member-owned secret plus `X-Managed-Profile-Id`; direct managed-child credentials are rejected even without the selector. Public and ownerless credentials are insufficient.\n\nThe body accepts only `importToken` and literal `consentAttested: true`; CPF, tax ID, subaccount ID, applicant ID, entity ID, provider-customer ID, profile ID, and other caller identity selectors are forbidden. The provisional server-controlled consent policy is `sumsub-share-v1`. Every token claim appends actor, subject, policy version, and timestamp consent evidence without storing the raw token.\n\nThe first normal KYC artifact, status read, or token-import claim permanently selects that case's method. Import the token before reading KYC or onboarding status because a status read selects a nullable method as `standard`. The same idempotency key and token returns a stored confirmed attempt or safely reconciles a durable submitted/ambiguous claim through provider reads, without another provider POST or replaying the token. A different token under the same key returns `409`. A provider `401` means the feature precondition is unavailable, records a failed attempt, returns `412`, and may be retried only with a new idempotency key; the new claim appends consent evidence while preserving prior attestations. Every other post-send provider, transport, malformed-response, timeout, or local-confirmation failure is ambiguous, returns `502`, and is never replayed automatically.\n\nAcceptance is pending only. Vortex polls the exact returned provider attempt; `EXPIRED` remains non-approved and locally pending for reconciliation, and its external status is retained. Only a provider `COMPLETED` plus `APPROVED` completes KYC. The provider webhook is notification-only and cannot approve the case.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "importBrKycToken", "parameters": [ { @@ -5287,7 +5820,7 @@ } } }, - "description": "The selected child is unauthorized, the caller used direct managed-child credentials, or transactional authorization was revoked before provider submission." + "description": "The selected child is unauthorized, the caller used direct managed-child credentials, or transactional authorization was revoked before provider submission. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "409": { "content": { @@ -5366,12 +5899,13 @@ } ], "summary": "Import an individual KYC token", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/brl/kyc/record-attempt": { "post": { - "description": "Validates an authenticated BRL onboarding preflight event. The asserted CPF or CNPJ is not persisted because quote ownership does not prove tax-ID ownership.", + "description": "Validates an authenticated BRL onboarding preflight event. The asserted CPF or CNPJ is not persisted because quote ownership does not prove tax-ID ownership.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "recordInitialBrKycAttempt", "parameters": [ { @@ -5416,7 +5950,7 @@ }, "403": { "$ref": "#/components/responses/ManagedSelectorForbidden", - "description": "Managed profile or corridor is not authorized." + "description": "Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "500": { "content": { @@ -5438,13 +5972,14 @@ } ], "summary": "Record an initial KYC attempt", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/brl/newKyc": { "post": { "deprecated": false, - "description": "Submits the user's KYC level 1 payload to the provider after documents have been uploaded via `/v1/brl/getUploadUrls`. Includes a built-in 5-second delay to allow upstream document propagation.\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", + "description": "Submits the user's KYC level 1 payload to the provider after documents have been uploaded via `/v1/brl/getUploadUrls`. Includes a built-in 5-second delay to allow upstream document propagation.\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "brNewKyc", "parameters": [ { @@ -5488,7 +6023,8 @@ "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, "403": { - "$ref": "#/components/responses/BrlaManagedSelectorForbidden" + "$ref": "#/components/responses/BrlaManagedSelectorForbidden", + "description": "Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "409": { "content": { @@ -5533,7 +6069,8 @@ } ], "summary": "Submit KYC level 1 data", - "tags": ["Account Management"] + "tags": ["Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/brl/validatePixKey": { @@ -5594,7 +6131,7 @@ }, "/v1/domestic/alfredpayStatus": { "get": { - "description": "Returns the local onboarding state after refreshing the latest provider submission when available.", + "description": "Returns the local onboarding state after refreshing the latest provider submission when available.\n\n**Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "getDomesticStatus", "parameters": [ { @@ -5644,7 +6181,8 @@ "description": "Authentication required." }, "403": { - "$ref": "#/components/responses/ManagedSelectorForbidden" + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." }, "404": { "content": { @@ -5676,12 +6214,13 @@ } ], "summary": "Get customer status", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "read" } }, "/v1/domestic/createBusinessCustomer": { "post": { - "description": "Creates a business customer for the effective profile. Managed profiles use their immutable contact email.", + "description": "Creates a business customer for the effective profile. Managed profiles use their immutable contact email.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "createDomesticBusinessCustomer", "parameters": [ { @@ -5725,7 +6264,7 @@ }, "403": { "$ref": "#/components/responses/ManagedSelectorForbidden", - "description": "Managed profile or corridor is not authorized." + "description": "Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "409": { "content": { @@ -5767,12 +6306,13 @@ } ], "summary": "Create a business customer", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/domestic/createIndividualCustomer": { "post": { - "description": "Creates an individual customer for the effective profile. Managed profiles use their immutable contact email.", + "description": "Creates an individual customer for the effective profile. Managed profiles use their immutable contact email.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "createDomesticIndividualCustomer", "parameters": [ { @@ -5816,7 +6356,7 @@ }, "403": { "$ref": "#/components/responses/ManagedSelectorForbidden", - "description": "Managed profile or corridor is not authorized." + "description": "Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "409": { "content": { @@ -5858,12 +6398,13 @@ } ], "summary": "Create an individual customer", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/domestic/fiatAccounts": { "get": { - "description": "Lists payout fiat accounts for the effective customer.", + "description": "Lists payout fiat accounts for the effective customer.\n\n**Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "listDomesticFiatAccounts", "parameters": [ { @@ -5906,7 +6447,8 @@ "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, "403": { - "$ref": "#/components/responses/ManagedSelectorForbidden" + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." }, "404": { "content": { @@ -5938,10 +6480,11 @@ } ], "summary": "List fiat accounts", - "tags": ["Account Management"] + "tags": ["Account Management"], + "x-managed-profile-capability": "read" }, "post": { - "description": "Creates a payout fiat account for the effective customer. Required optional fields depend on the selected account type and corridor.", + "description": "Creates a payout fiat account for the effective customer. Required optional fields depend on the selected account type and corridor.\n\n**Managed selection (manage):** Requires a live manager membership. A member-owned secret or Supabase bearer is supported; read_only receives 403 MANAGED_PROFILE_MANAGER_REQUIRED. Fiat-account management is not secret-only credential_manage. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "createDomesticFiatAccount", "parameters": [ { @@ -5983,7 +6526,8 @@ "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, "403": { - "$ref": "#/components/responses/ManagedSelectorForbidden" + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED." }, "404": { "content": { @@ -6015,12 +6559,13 @@ } ], "summary": "Create a fiat account", - "tags": ["Account Management"] + "tags": ["Account Management"], + "x-managed-profile-capability": "manage" } }, "/v1/domestic/fiatAccounts/{fiatAccountId}": { "delete": { - "description": "Deletes one payout fiat account belonging to the effective customer.", + "description": "Deletes one payout fiat account belonging to the effective customer.\n\n**Managed selection (manage):** Requires a live manager membership. A member-owned secret or Supabase bearer is supported; read_only receives 403 MANAGED_PROFILE_MANAGER_REQUIRED. Fiat-account management is not secret-only credential_manage. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "deleteDomesticFiatAccount", "parameters": [ { @@ -6061,7 +6606,8 @@ "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, "403": { - "$ref": "#/components/responses/ManagedSelectorForbidden" + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED." }, "404": { "content": { @@ -6093,12 +6639,13 @@ } ], "summary": "Delete a fiat account", - "tags": ["Account Management"] + "tags": ["Account Management"], + "x-managed-profile-capability": "manage" } }, "/v1/domestic/findKybCustomerAndBusiness": { "get": { - "description": "Returns only KYB submission IDs and related-person IDs needed for document uploads.", + "description": "Returns only KYB submission IDs and related-person IDs needed for document uploads.\n\n**Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "findDomesticKybCustomerAndBusiness", "parameters": [ { @@ -6144,7 +6691,8 @@ "description": "Authentication required." }, "403": { - "$ref": "#/components/responses/ManagedSelectorForbidden" + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." }, "404": { "content": { @@ -6176,12 +6724,13 @@ } ], "summary": "Find KYB submission details", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "read" } }, "/v1/domestic/getKybRedirectLink": { "get": { - "description": "Creates a hosted business KYB redirect link when no verification is already in review or complete.", + "description": "Creates a hosted business KYB redirect link when no verification is already in review or complete.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "getDomesticKybRedirectLink", "parameters": [ { @@ -6222,7 +6771,8 @@ "description": "Authentication required." }, "403": { - "$ref": "#/components/responses/ManagedSelectorForbidden" + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "404": { "content": { @@ -6254,12 +6804,13 @@ } ], "summary": "Get a KYB redirect link", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/domestic/getKycRedirectLink": { "get": { - "description": "Creates a hosted individual KYC redirect link when no verification is already in review or complete.", + "description": "Creates a hosted individual KYC redirect link when no verification is already in review or complete.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "getDomesticKycRedirectLink", "parameters": [ { @@ -6300,7 +6851,8 @@ "description": "Authentication required." }, "403": { - "$ref": "#/components/responses/ManagedSelectorForbidden" + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "404": { "content": { @@ -6332,12 +6884,13 @@ } ], "summary": "Get a KYC redirect link", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/domestic/getKycStatus": { "get": { - "description": "Returns and persists the latest KYC or KYB submission status. Omit `type` for individual KYC.", + "description": "Returns and persists the latest KYC or KYB submission status. Omit `type` for individual KYC.\n\n**Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "getDomesticKycStatus", "parameters": [ { @@ -6386,7 +6939,8 @@ "description": "Authentication required." }, "403": { - "$ref": "#/components/responses/ManagedSelectorForbidden" + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." }, "404": { "content": { @@ -6418,12 +6972,13 @@ } ], "summary": "Get KYC or KYB status", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "read" } }, "/v1/domestic/kycRedirectFinished": { "post": { - "description": "Records that the effective customer finished the hosted KYC or KYB redirect flow.", + "description": "Records that the effective customer finished the hosted KYC or KYB redirect flow.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "notifyDomesticKycRedirectFinished", "parameters": [ { @@ -6466,7 +7021,8 @@ "description": "Authentication required." }, "403": { - "$ref": "#/components/responses/ManagedSelectorForbidden" + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "404": { "content": { @@ -6498,12 +7054,13 @@ } ], "summary": "Mark a redirect finished", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/domestic/kycRedirectOpened": { "post": { - "description": "Records that the effective customer's hosted KYC or KYB redirect was opened.", + "description": "Records that the effective customer's hosted KYC or KYB redirect was opened.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "notifyDomesticKycRedirectOpened", "parameters": [ { @@ -6546,7 +7103,8 @@ "description": "Authentication required." }, "403": { - "$ref": "#/components/responses/ManagedSelectorForbidden" + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "404": { "content": { @@ -6578,12 +7136,13 @@ } ], "summary": "Mark a redirect opened", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/domestic/retryKyc": { "post": { - "description": "Retries a failed KYC or KYB submission. Hosted flows return a redirect link; API-based MX, CO, and AR individual KYC returns `{ success: true }`.", + "description": "Retries a failed KYC or KYB submission. Hosted flows return a redirect link; API-based MX, CO, and AR individual KYC returns `{ success: true }`.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "retryDomesticKyc", "parameters": [ { @@ -6634,7 +7193,8 @@ "description": "Authentication required." }, "403": { - "$ref": "#/components/responses/ManagedSelectorForbidden" + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed access denied. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "404": { "content": { @@ -6666,12 +7226,13 @@ } ], "summary": "Retry KYC or KYB", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/domestic/sendKybSubmission": { "post": { - "description": "Finalizes an API-based business KYB submission.", + "description": "Finalizes an API-based business KYB submission.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "sendDomesticKybSubmission", "parameters": [ { @@ -6715,7 +7276,7 @@ }, "403": { "$ref": "#/components/responses/ManagedSelectorForbidden", - "description": "Managed profile or corridor is not authorized." + "description": "Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "404": { "content": { @@ -6747,12 +7308,13 @@ } ], "summary": "Send a KYB submission", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/domestic/sendKycSubmission": { "post": { - "description": "Finalizes an API-based individual KYC submission.", + "description": "Finalizes an API-based individual KYC submission.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "sendDomesticKycSubmission", "parameters": [ { @@ -6796,7 +7358,7 @@ }, "403": { "$ref": "#/components/responses/ManagedSelectorForbidden", - "description": "Managed profile or corridor is not authorized." + "description": "Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "404": { "content": { @@ -6828,12 +7390,13 @@ } ], "summary": "Send a KYC submission", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/domestic/submitKybFile": { "post": { - "description": "Uploads one business KYB document. Files are buffered in memory and limited to 5 MiB.", + "description": "Uploads one business KYB document. Files are buffered in memory and limited to 5 MiB.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "submitDomesticKybFile", "parameters": [ { @@ -6877,7 +7440,7 @@ }, "403": { "$ref": "#/components/responses/ManagedSelectorForbidden", - "description": "Managed profile or corridor is not authorized." + "description": "Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "404": { "content": { @@ -6909,12 +7472,13 @@ } ], "summary": "Upload a KYB file", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/domestic/submitKybInformation": { "post": { - "description": "Creates or updates an API-based business KYB submission, including the provider's compliance questionnaire.", + "description": "Creates or updates an API-based business KYB submission, including the provider's compliance questionnaire.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "submitDomesticKybInformation", "parameters": [ { @@ -6958,7 +7522,7 @@ }, "403": { "$ref": "#/components/responses/ManagedSelectorForbidden", - "description": "Managed profile or corridor is not authorized." + "description": "Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "404": { "content": { @@ -7000,12 +7564,13 @@ } ], "summary": "Submit KYB information", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/domestic/submitKybRelatedPersonFile": { "post": { - "description": "Uploads the front or back identity document for one KYB related person. Files are limited to 5 MiB.", + "description": "Uploads the front or back identity document for one KYB related person. Files are limited to 5 MiB.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "submitDomesticKybRelatedPersonFile", "parameters": [ { @@ -7049,7 +7614,7 @@ }, "403": { "$ref": "#/components/responses/ManagedSelectorForbidden", - "description": "Managed profile or corridor is not authorized." + "description": "Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "404": { "content": { @@ -7081,12 +7646,13 @@ } ], "summary": "Upload a related-person KYB file", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/domestic/submitKycFile": { "post": { - "description": "Uploads one individual KYC document. Files are buffered in memory and limited to 5 MiB.", + "description": "Uploads one individual KYC document. Files are buffered in memory and limited to 5 MiB.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "submitDomesticKycFile", "parameters": [ { @@ -7130,7 +7696,7 @@ }, "403": { "$ref": "#/components/responses/ManagedSelectorForbidden", - "description": "Managed profile or corridor is not authorized." + "description": "Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "404": { "content": { @@ -7162,12 +7728,13 @@ } ], "summary": "Upload a KYC file", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/domestic/submitKycInformation": { "post": { - "description": "Creates or resumes an API-based individual KYC submission.", + "description": "Creates or resumes an API-based individual KYC submission.\n\n**Managed selection (credential_manage):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child Supabase bearer calls return 403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL; read_only cannot mutate even with a secret. This includes link/artifact creation exposed as GET. Non-managed self calls retain their existing bearer alternative. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "submitDomesticKycInformation", "parameters": [ { @@ -7211,7 +7778,7 @@ }, "403": { "$ref": "#/components/responses/ManagedSelectorForbidden", - "description": "Managed profile or corridor is not authorized." + "description": "Managed profile or corridor is not authorized. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer provider mutation: MANAGED_PROFILE_REQUIRES_API_CREDENTIAL." }, "404": { "content": { @@ -7243,13 +7810,14 @@ } ], "summary": "Submit KYC information", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/limits": { "post": { "deprecated": false, - "description": "Returns onramp and offramp limits for the authenticated user's requested fiat corridors. Bank-transfer-corridor usage is calculated from completed Vortex ramps in the current UTC calendar month and may be delayed by the 60-second in-memory cache. BR maximums, usage, and period are read from the provider.\n\n**Auth:** requires either `X-API-Key: sk_*` linked to a user or `Authorization: Bearer `. Unlinked partner keys are rejected.", + "description": "Returns onramp and offramp limits for the authenticated user's requested fiat corridors. Bank-transfer-corridor usage is calculated from completed Vortex ramps in the current UTC calendar month and may be delayed by the 60-second in-memory cache. BR maximums, usage, and period are read from the provider.\n\n**Auth:** requires either `X-API-Key: sk_*` linked to a user or `Authorization: Bearer `. Unlinked partner keys are rejected.\n\n**Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "getUserLimits", "parameters": [ { @@ -7305,7 +7873,7 @@ } } }, - "description": "The credential is not linked to a user." + "description": "The credential is not linked to a user. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." }, "502": { "description": "Provider limits are unavailable or invalid." @@ -7320,12 +7888,127 @@ } ], "summary": "Get user ramp limits", - "tags": ["Account Management"] + "tags": ["Account Management"], + "x-managed-profile-capability": "read" + } + }, + "/v1/managed-profile-member-invitations/{invitationId}": { + "get": { + "description": "Supabase bearer-only; API-key headers and impersonation are rejected. No managed selector is accepted. The invitation UUID is only a locator. Preview requires the current Supabase principal's exact normalized email and a valid email_confirmed_at, not request email or cached profiles.email. Unknown invitations and mismatched/unverified email return generic 403 without child, inviter, role, or status details. Authorized preview returns invitation, inviter, and child summary, including terminal status. Observed seven-day expiry is persisted. Preview and OTP verification grant no membership.", + "operationId": "previewManagedProfileMemberInvitation", + "parameters": [ + { + "$ref": "#/components/parameters/MembershipInvitationId" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewManagedProfileInvitationResponse" + } + } + }, + "description": "Verified-email invitation preview." + }, + "400": { + "$ref": "#/components/responses/MembershipBadRequest", + "description": "MANAGED_PROFILE_INVALID_INPUT for a non-UUID invitationId; MANAGED_PROFILE_UNSUPPORTED for any managed selector. Malformed JSON is rejected before route authentication." + }, + "401": { + "$ref": "#/components/responses/MembershipUnauthorized" + }, + "403": { + "$ref": "#/components/responses/MembershipForbidden" + }, + "413": { + "$ref": "#/components/responses/MembershipPayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/MembershipRateLimited" + }, + "500": { + "$ref": "#/components/responses/MembershipInternalError" + }, + "503": { + "$ref": "#/components/responses/MembershipAuthUnavailable" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Preview a managed-profile membership invitation", + "tags": ["Managed Profiles"] + } + }, + "/v1/managed-profile-member-invitations/{invitationId}/accept": { + "post": { + "description": "Explicit acceptance by a Supabase bearer principal with the exact current verified invitation email (email_confirmed_at required). No request body, request email, or managed selector is needed or used as authority. API-key headers and impersonation are rejected. Transactionally rechecks invitation, seven-day expiry, child, owner and actor; creates an active membership and invitation_accepted/member_added events. A previously revoked member gets a new membership row. Replay returns 200 only when the same accepting profile still has an active membership with the invitation's role; it creates no duplicate events. Otherwise terminal invitations return 409. No child access follows merely from OTP verification.", + "operationId": "acceptManagedProfileMemberInvitation", + "parameters": [ + { + "$ref": "#/components/parameters/MembershipInvitationId" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AcceptManagedProfileInvitationResponse" + } + } + }, + "description": "Membership accepted, or unchanged valid replay." + }, + "400": { + "$ref": "#/components/responses/MembershipBadRequest", + "description": "MANAGED_PROFILE_INVALID_INPUT for a non-UUID invitationId; MANAGED_PROFILE_UNSUPPORTED for any managed selector. Malformed JSON is rejected before route authentication." + }, + "401": { + "$ref": "#/components/responses/MembershipUnauthorized" + }, + "403": { + "$ref": "#/components/responses/MembershipForbidden" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "INVITATION_ACCEPTED, INVITATION_CANCELLED, INVITATION_EXPIRED, or MEMBERSHIP_ALREADY_EXISTS. Accepted replay is successful only under the documented active-role condition." + }, + "413": { + "$ref": "#/components/responses/MembershipPayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/MembershipRateLimited" + }, + "500": { + "$ref": "#/components/responses/MembershipInternalError" + }, + "503": { + "$ref": "#/components/responses/MembershipAuthUnavailable" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Accept a managed-profile membership invitation", + "tags": ["Managed Profiles"] } }, "/v1/managed-profiles": { "get": { - "description": "Lists children owned by the authenticated active manager, newest first, together with the manager's current corridor and customer-type policy. Policy is manager-scoped and applies to all children; it is not a per-child grant. The default filter returns only active children. Use `status=deleted` or `status=all` to include retained logical-deletion records.\n\n**Auth:** controlling manager Supabase Bearer session or secret API key. Public API keys and direct managed-child credentials are rejected.", + "description": "Lists eligible children newest first. Returns actor { profileId, canProvisionManagedProfiles, hasMemberships }, each child's membership (role, isOwner) and immutable-owner policy; there is no singular manager response. Default status=active lists eligible active memberships across all owners and returns 200 with an empty list even when both actor flags are false. Eligibility excludes revoked/invalid roles, inactive owners, deleted children and invalid child entity layouts. hasMemberships is based on an unpaginated eligible active count, independent of the requested status filter and page.\n\nBoth status=deleted and status=all require the actor's own active manager configuration (otherwise 403 MANAGED_PROFILE_OWNER_REQUIRED) and are owner-scoped only: they exclude children owned by others, even active invited children in status=all. Retained rows still require eligible membership, owner and entity layout, but never contribute to hasMemberships.\n\n**Auth:** profile-bound secret API key or Supabase Bearer session. Public keys and direct child credentials are rejected. Membership does not grant provisioning or child deletion.", "operationId": "listManagedProfiles", "parameters": [ { @@ -7352,7 +8035,7 @@ } }, { - "description": "Lifecycle records to include.", + "description": "active lists eligible active memberships across owners. Both deleted and all require the actor's own active manager configuration and return only that owner's children, never invited children owned by someone else.", "in": "query", "name": "status", "required": false, @@ -7367,12 +8050,50 @@ "200": { "content": { "application/json": { + "examples": { + "noEligibleMemberships": { + "summary": "Ordinary actor with no eligible memberships still receives 200", + "value": { + "actor": { + "canProvisionManagedProfiles": false, + "hasMemberships": false, + "profileId": "00000000-0000-0000-0000-000000000003" + }, + "managedProfiles": [], + "pagination": { "limit": 50, "offset": 0, "total": 0 } + } + }, + "ownerBeforeProvisioning": { + "summary": "Active owner configuration without eligible children", + "value": { + "actor": { + "canProvisionManagedProfiles": true, + "hasMemberships": false, + "profileId": "00000000-0000-0000-0000-000000000001" + }, + "managedProfiles": [], + "pagination": { "limit": 50, "offset": 0, "total": 0 } + } + }, + "pageBeyondResults": { + "summary": "An empty page does not clear hasMemberships", + "value": { + "actor": { + "canProvisionManagedProfiles": false, + "hasMemberships": true, + "profileId": "00000000-0000-0000-0000-000000000003" + }, + "managedProfiles": [], + "pagination": { "limit": 1, "offset": 100, "total": 1 } + } + } + }, "schema": { "$ref": "#/components/schemas/ListManagedProfilesResponse" } } }, - "description": "The authenticated manager's current policy, a page of owned managed profiles, and offset pagination metadata." + "description": "Actor identity and independent capability flags, eligible membership-decorated children, and offset pagination. An empty default list is successful, including when both flags are false." }, "400": { "content": { @@ -7402,7 +8123,7 @@ } } }, - "description": "`MANAGED_PROFILE_ACCESS_DENIED`: the authenticated profile is not an active manager, or is a direct managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials." + "description": "MANAGED_PROFILE_OWNER_REQUIRED: status=deleted or status=all without the actor's own active manager configuration. MANAGED_PROFILE_ACCESS_DENIED: direct child credential. CREDENTIAL_MISMATCH: inconsistent key headers. No memberships is not a 403 for the default active list." }, "409": { "content": { @@ -7412,7 +8133,7 @@ } } }, - "description": "`MANAGED_PROFILE_CONFLICT`: a retained child has an invalid customer-entity layout." + "description": "MANAGED_PROFILE_CONFLICT: access data becomes incomplete during response construction. Invalid child entity layouts are normally excluded by the eligibility query." }, "500": { "content": { @@ -7437,7 +8158,7 @@ "tags": ["Managed Profiles"] }, "post": { - "description": "Creates one headless individual or business child for the authenticated active manager. `externalSubjectId`, normalized `contactEmail`, and `customerType` are immutable. An exact retry for the same external subject is idempotent and returns the existing active child with `200`; the first creation returns `201`. Reusing either reserved identifier with different data returns `409`, including after deletion. No corridor grant is accepted.\n\n**Auth:** controlling manager Supabase Bearer session or secret API key. Public API keys and direct managed-child credentials are rejected.", + "description": "Creates one headless individual or business child owned by the authenticated active manager. Membership alone does not grant provisioning or sibling creation under another owner. externalSubjectId, normalized contactEmail, and customerType are immutable. An exact retry returns the existing active child with 200; first creation returns 201. Reserved identifiers with different data return 409, including after deletion. No corridor grant is accepted. Unlike list/read, creation returns only the base managedProfile, without actor, membership or policy decoration.\n\n**Auth:** enabled owner's Supabase Bearer session or secret API key. Public keys, direct child credentials and impersonation are rejected.", "operationId": "createManagedProfile", "parameters": [], "requestBody": { @@ -7499,7 +8220,7 @@ } } }, - "description": "`MANAGED_PROFILE_MANAGER_NOT_FOUND`, `MANAGED_PROFILE_MANAGER_INACTIVE`, or `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated profile is not an active managed-profile manager, or is a direct managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials." + "description": "`MANAGED_PROFILE_MANAGER_NOT_FOUND`, `MANAGED_PROFILE_MANAGER_INACTIVE`, or `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated profile is not an active managed-profile manager, or is a direct managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials. IMPERSONATION_NOT_ALLOWED: lifecycle mutations reject impersonation." }, "409": { "content": { @@ -7536,7 +8257,7 @@ }, "/v1/managed-profiles/{profileId}": { "delete": { - "description": "Logically deletes an owned child and atomically revokes all of its credentials. Customer, provider, KYC, ramp, external-subject, and contact-email records are retained. Repeating deletion of the same owned child is idempotent and returns `204`.\n\n**Auth:** controlling manager Supabase Bearer session or secret API key.", + "description": "Owner-only logical deletion, atomically revoking all child credentials. A non-owner with an active manager or read_only membership receives 403 MANAGED_PROFILE_OWNER_REQUIRED; outsiders receive the same masked 404 whether the child exists or not. Customer, provider, KYC, ramp, external-subject and contact-email records are retained. Repeating deletion of the same owned child returns 204 while the owner's configuration remains active.\n\n**Auth:** immutable owner's Supabase Bearer session or profile-bound secret API key; impersonation and direct child credentials are rejected.", "operationId": "deleteManagedProfile", "parameters": [ { @@ -7582,7 +8303,7 @@ } } }, - "description": "`MANAGED_PROFILE_ACCESS_DENIED`: the authenticated profile is not an active manager, or is a direct managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials." + "description": "MANAGED_PROFILE_OWNER_REQUIRED: a non-owner has an active manager or read_only membership. MANAGED_PROFILE_ACCESS_DENIED: the immutable owner's configuration is inactive, or a direct child credential was used. CREDENTIAL_MISMATCH: inconsistent key headers. IMPERSONATION_NOT_ALLOWED: lifecycle mutations reject impersonation." }, "404": { "content": { @@ -7592,7 +8313,7 @@ } } }, - "description": "`MANAGED_PROFILE_NOT_FOUND`: the child does not exist or is not owned by this manager." + "description": "MANAGED_PROFILE_NOT_FOUND: missing child or a non-owner without active membership. Outsiders receive the same masked error for existing and unknown children." }, "500": { "content": { @@ -7617,9 +8338,12 @@ "tags": ["Managed Profiles"] }, "get": { - "description": "Returns one owned child, including a retained logically deleted child. Foreign children are hidden with `404`.\n\n**Auth:** controlling manager Supabase Bearer session or secret API key.", + "description": "Returns actor { profileId, canProvisionManagedProfiles, hasMemberships } and managedProfile with membership and immutable-owner policy. Actor flags use the same independent, unpaginated eligible active count as the list endpoint, not this child's status. Active child reads require a live manager or read_only membership, active owner configuration and valid child entity layout.\n\nBootstrap is explicitly GET detail with an exactly matching X-Managed-Profile-Id. Stored membership history (active or revoked) is required before ineligibility returns 403 MANAGED_PROFILE_MEMBERSHIP_INVALID. Revoked membership, deleted child, disabled owner or invalid entity layout invalidate an evidenced bootstrap. A deleted child invalidates even the owner's bootstrap. A caller who was never a member receives the same masked 404 MANAGED_PROFILE_NOT_FOUND for an existing or unknown child, with or without a matching selector. A mismatched selector returns 403 MANAGED_PROFILE_ACCESS_DENIED.\n\nRetained deleted-child reads are allowed only to the immutable owner with active configuration, valid membership/entity layout and no selector. Invited members and ineligible retained reads are masked with 404. Without bootstrap, missing membership is 404; an active member of an active child with disabled owner or invalid entity layout receives 403 MANAGED_PROFILE_ACCESS_DENIED.\n\n**Auth:** Supabase Bearer session or member-owned secret API key; both follow the same bootstrap/history and retained-read checks. Direct child credentials are rejected.", "operationId": "getManagedProfile", "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, { "description": "Managed child profile ID.", "in": "path", @@ -7636,11 +8360,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ManagedProfileResponse" + "$ref": "#/components/schemas/ManagedProfileAccessResponse" } } }, - "description": "Owned active or deleted managed profile." + "description": "Actor with both independent flags and an eligible active child, or an owner-only retained child read without a selector." }, "400": { "content": { @@ -7650,7 +8374,7 @@ } } }, - "description": "`MANAGED_PROFILE_INVALID_INPUT`: profileId is not a UUID." + "description": "`MANAGED_PROFILE_INVALID_INPUT`: profileId is not a UUID. INVALID_MANAGED_PROFILE_ID may be returned by path-child authorization before controller validation." }, "401": { "content": { @@ -7670,7 +8394,7 @@ } } }, - "description": "`MANAGED_PROFILE_ACCESS_DENIED`: the authenticated profile is not an active manager, or is a direct managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials." + "description": "MANAGED_PROFILE_MEMBERSHIP_INVALID: explicit matching-selector bootstrap has stored membership history but is no longer eligible (including revoked membership, deleted child even for its owner, disabled owner or invalid entity). MANAGED_PROFILE_ACCESS_DENIED: selector/path mismatch, direct child credentials, or an ordinary active-child member read with inactive owner/invalid layout. CREDENTIAL_MISMATCH: inconsistent key headers. The bootstrap rule applies to bearer and member-secret callers." }, "404": { "content": { @@ -7680,17 +8404,7 @@ } } }, - "description": "`MANAGED_PROFILE_NOT_FOUND`: the child does not exist or is not owned by this manager." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ManagedProfileErrorResponse" - } - } - }, - "description": "`MANAGED_PROFILE_CONFLICT`: the retained child has an invalid customer-entity layout." + "description": "MANAGED_PROFILE_NOT_FOUND: unknown child or ordinary read without active membership; invited-member or otherwise ineligible retained read without a selector. Callers with no membership history receive the same masked 404 for existing and unknown children, even with a matching selector." }, "500": { "content": { @@ -7712,14 +8426,18 @@ } ], "summary": "Get a managed profile", - "tags": ["Managed Profiles"] + "tags": ["Managed Profiles"], + "x-managed-profile-capability": "read" } }, "/v1/managed-profiles/{profileId}/api-credentials": { "get": { - "description": "Lists all credentials owned by one active child, newest first, including revoked and expired records. Public values and safe secret prefixes are returned; secret values are never returned.\n\n**Auth:** active controlling manager Supabase Bearer session or secret API key.", + "description": "Lists all credentials owned by one active child, newest first, including revoked and expired records. Public values and safe secret prefixes are returned, never secret values. Requires read capability: manager or read_only membership, active owner and valid child. An optional selector must exactly match the path profileId.\n\n**Auth:** member Supabase Bearer session or member-owned secret API key. Direct child credentials cannot administer credentials.", "operationId": "listManagedProfileApiCredentials", "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, { "description": "Managed child profile ID.", "in": "path", @@ -7750,7 +8468,7 @@ } } }, - "description": "`MANAGED_PROFILE_INVALID_INPUT`: profileId is not a UUID." + "description": "`MANAGED_PROFILE_INVALID_INPUT`: profileId is not a UUID. INVALID_MANAGED_PROFILE_ID may be returned by path-child authorization before controller validation." }, "401": { "content": { @@ -7770,7 +8488,7 @@ } } }, - "description": "`CREDENTIAL_ACCESS_DENIED`: the manager is inactive. `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated credential belongs directly to a managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials." + "description": "MANAGED_PROFILE_ACCESS_DENIED for inactive owner, invalid child layout, direct child credential or selector/path mismatch; CREDENTIAL_MISMATCH for inconsistent key headers. MANAGED_PROFILE_MANAGER_REQUIRED for read_only mutations; IMPERSONATION_NOT_ALLOWED for credential mutations under impersonation. CREDENTIAL_ACCESS_DENIED if service-level owner or membership authority is no longer active." }, "404": { "content": { @@ -7780,7 +8498,7 @@ } } }, - "description": "`CREDENTIAL_NOT_FOUND`: the active child does not exist or is not owned by this manager." + "description": "MANAGED_PROFILE_NOT_FOUND: inactive/missing child or missing membership. CREDENTIAL_NOT_FOUND: credential is outside this child's scope or the service cannot resolve the child." }, "500": { "content": { @@ -7802,12 +8520,130 @@ } ], "summary": "List a managed profile's API credentials", - "tags": ["Managed Profiles"] + "tags": ["Managed Profiles"], + "x-managed-profile-capability": "read" }, "post": { - "description": "Issues one child-owned public/secret credential pair. The secret is returned only in this creation response and cannot be retrieved later. Expiry defaults to one year and cannot exceed two years. The child's shared cap is five active, non-expired credentials.\n\n**Auth:** active controlling manager Supabase Bearer session or secret API key.", + "description": "Issues one child-owned public/secret credential pair. The secret is returned only once. Expiry defaults to one year and cannot exceed two years. The child's shared cap is five active, non-expired credentials. Requires manage capability (manager membership), not the secret-only credential_manage provider capability. An optional selector must exactly match the path profileId. Child credentials are shared company principals: member removal does not revoke them.\n\n**Auth:** manager member's Supabase Bearer session or member-owned secret API key. Impersonation and direct child credentials are rejected.", "operationId": "createManagedProfileApiCredential", "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "description": "Managed child profile ID.", + "in": "path", + "name": "profileId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateApiCredentialRequest" + } + } + }, + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateApiCredentialResponse" + } + } + }, + "description": "Credential created. This is the only response containing `secretKey`." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "Invalid profileId, credential name, or expiry. INVALID_MANAGED_PROFILE_ID may be returned by path-child authorization before controller validation." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "Missing, invalid, expired, or revoked manager authentication." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "MANAGED_PROFILE_ACCESS_DENIED for inactive owner, invalid child layout, direct child credential or selector/path mismatch; CREDENTIAL_MISMATCH for inconsistent key headers. MANAGED_PROFILE_MANAGER_REQUIRED for read_only mutations; IMPERSONATION_NOT_ALLOWED for credential mutations under impersonation. CREDENTIAL_ACCESS_DENIED if service-level owner or membership authority is no longer active." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "MANAGED_PROFILE_NOT_FOUND: inactive/missing child or missing membership. CREDENTIAL_NOT_FOUND: credential is outside this child's scope or the service cannot resolve the child." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "`CREDENTIAL_LIMIT_REACHED`: the child already holds five active, non-expired credentials." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "Internal server error." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Create a managed profile API credential", + "tags": ["Managed Profiles"], + "x-managed-profile-capability": "manage" + } + }, + "/v1/managed-profiles/{profileId}/api-credentials/{credentialId}": { + "delete": { + "description": "Revokes one credential owned by an active child, disabling both values. Repeating revocation of the same owned credential returns 204. Requires manage capability (manager membership), not secret-only credential_manage. An optional selector must exactly match the path profileId. Revoke shared child credentials separately when offboarding members who possessed them.\n\n**Auth:** manager member's Supabase Bearer session or member-owned secret API key. Impersonation and direct child credentials are rejected.", + "operationId": "revokeManagedProfileApiCredential", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, { "description": "Managed child profile ID.", "in": "path", @@ -7817,50 +8653,270 @@ "format": "uuid", "type": "string" } + }, + { + "description": "Child credential ID.", + "in": "path", + "name": "credentialId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Credential is revoked; both values are unusable. Repeated revocation also returns this status." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "`MANAGED_PROFILE_INVALID_INPUT`: profileId or credentialId is not a UUID. INVALID_MANAGED_PROFILE_ID may be returned by path-child authorization before controller validation." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "Missing, invalid, expired, or revoked manager authentication." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "MANAGED_PROFILE_ACCESS_DENIED for inactive owner, invalid child layout, direct child credential or selector/path mismatch; CREDENTIAL_MISMATCH for inconsistent key headers. MANAGED_PROFILE_MANAGER_REQUIRED for read_only mutations; IMPERSONATION_NOT_ALLOWED for credential mutations under impersonation. CREDENTIAL_ACCESS_DENIED if service-level owner or membership authority is no longer active." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "MANAGED_PROFILE_NOT_FOUND: inactive/missing child or missing membership. CREDENTIAL_NOT_FOUND: credential is outside this child's scope or the service cannot resolve the child." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "Internal server error." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Revoke a managed profile API credential", + "tags": ["Managed Profiles"], + "x-managed-profile-capability": "manage" + } + }, + "/v1/managed-profiles/{profileId}/member-events": { + "get": { + "description": "Supabase bearer-only with a live manager or read_only membership; API-key headers and impersonation are rejected. Append-only access history ordered by createdAt then id descending. Pass pagination.nextCursor to fetch strictly older events; null ends pagination. Cursor must identify an event in this child. Event payloads omit emails, secrets, and invitation URLs. Offset, if supplied, is validated as a non-negative integer but ignored; use cursor pagination.", + "operationId": "listManagedProfileMemberEvents", + "parameters": [ + { + "$ref": "#/components/parameters/MembershipProfileId" + }, + { + "$ref": "#/components/parameters/MembershipSelection" + }, + { + "$ref": "#/components/parameters/MembershipLimit" + }, + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListManagedProfileMemberEventsResponse" + } + } + }, + "description": "Access events and cursor pagination." + }, + "400": { + "$ref": "#/components/responses/MembershipBadRequest", + "description": "MANAGED_PROFILE_INVALID_INPUT for a non-UUID profileId; INVALID_PAGINATION for invalid limit/offset or event cursor. Malformed JSON is rejected before route authentication." + }, + "401": { + "$ref": "#/components/responses/MembershipUnauthorized" + }, + "403": { + "$ref": "#/components/responses/MembershipForbidden" + }, + "413": { + "$ref": "#/components/responses/MembershipPayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/MembershipRateLimited" + }, + "500": { + "$ref": "#/components/responses/MembershipInternalError" + }, + "503": { + "$ref": "#/components/responses/MembershipAuthUnavailable" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Read managed-profile access history", + "tags": ["Managed Profiles"] + } + }, + "/v1/managed-profiles/{profileId}/member-invitations": { + "get": { + "description": "Supabase bearer-only with a live manager or read_only membership; API-key headers and impersonation are rejected. Returns pending and terminal invitations, ordered by createdAt then id descending. Observed seven-day expiry is persisted with one event. No status filter is supported.", + "operationId": "listManagedProfileMemberInvitations", + "parameters": [ + { + "$ref": "#/components/parameters/MembershipProfileId" + }, + { + "$ref": "#/components/parameters/MembershipSelection" + }, + { + "$ref": "#/components/parameters/MembershipLimit" + }, + { + "$ref": "#/components/parameters/MembershipOffset" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListManagedProfileInvitationsResponse" + } + } + }, + "description": "All invitation statuses and offset pagination." + }, + "400": { + "$ref": "#/components/responses/MembershipBadRequest", + "description": "MANAGED_PROFILE_INVALID_INPUT for a non-UUID profileId; INVALID_PAGINATION for invalid limit/offset or event cursor. Malformed JSON is rejected before route authentication." + }, + "401": { + "$ref": "#/components/responses/MembershipUnauthorized" + }, + "403": { + "$ref": "#/components/responses/MembershipForbidden" + }, + "413": { + "$ref": "#/components/responses/MembershipPayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/MembershipRateLimited" + }, + "500": { + "$ref": "#/components/responses/MembershipInternalError" + }, + "503": { + "$ref": "#/components/responses/MembershipAuthUnavailable" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "List managed-profile membership invitations", + "tags": ["Managed Profiles"] + }, + "post": { + "description": "Supabase bearer-only with a live manager membership; API-key headers and impersonation are rejected. Email is trimmed/lowercased and role is manager or read_only. Creates one pending invitation per child/email, expiring after seven days, and queues its email transactionally. Identical pending creation returns 200 without another delivery; a different pending role requires cancellation first. Response does not reveal whether an unrelated profile exists. MEMBERSHIP_ALREADY_EXISTS discloses only an active membership already visible in this child's roster. Returns invitation metadata, never a secret acceptance token or URL.", + "operationId": "createManagedProfileMemberInvitation", + "parameters": [ + { + "$ref": "#/components/parameters/MembershipProfileId" + }, + { + "$ref": "#/components/parameters/MembershipSelection" } ], "requestBody": { "content": { "application/json": { + "example": { + "email": "operator@example.com", + "role": "manager" + }, "schema": { - "$ref": "#/components/schemas/CreateApiCredentialRequest" + "$ref": "#/components/schemas/CreateManagedProfileInvitationRequest" } } }, - "required": false + "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateApiCredentialResponse" + "$ref": "#/components/schemas/ManagedProfileInvitationResponse" } } }, - "description": "Credential created. This is the only response containing `secretKey`." + "description": "Identical pending invitation; no duplicate email." }, - "400": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ManagedProfileErrorResponse" + "$ref": "#/components/schemas/ManagedProfileInvitationResponse" } } }, - "description": "Invalid profileId, credential name, or expiry." + "description": "Invitation created and email queued." + }, + "400": { + "$ref": "#/components/responses/MembershipBadRequest", + "description": "MANAGED_PROFILE_INVALID_INPUT for a non-UUID profileId; INVALID_MEMBERSHIP_ROLE or INVALID_INVITATION_EMAIL for invalid input. Malformed JSON is rejected before route authentication." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ManagedProfileErrorResponse" - } - } - }, - "description": "Missing, invalid, expired, or revoked manager authentication." + "$ref": "#/components/responses/MembershipUnauthorized" }, "403": { + "$ref": "#/components/responses/MembershipForbidden" + }, + "409": { "content": { "application/json": { "schema": { @@ -7868,7 +8924,58 @@ } } }, - "description": "`CREDENTIAL_ACCESS_DENIED`: the manager is inactive. `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated credential belongs directly to a managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials." + "description": "INVITATION_ROLE_CONFLICT or MEMBERSHIP_ALREADY_EXISTS." + }, + "413": { + "$ref": "#/components/responses/MembershipPayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/MembershipRateLimited" + }, + "500": { + "$ref": "#/components/responses/MembershipInternalError" + }, + "503": { + "$ref": "#/components/responses/MembershipAuthUnavailable" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Invite a managed-profile member", + "tags": ["Managed Profiles"] + } + }, + "/v1/managed-profiles/{profileId}/member-invitations/{invitationId}": { + "delete": { + "description": "Supabase bearer-only with a live manager membership; API-key headers and impersonation are rejected. Cancels a pending invitation for this child. Expiry is observed before cancellation. Terminal invitations, including repeated cancellation, return 409 rather than 204. Cancellation never removes an accepted membership.", + "operationId": "cancelManagedProfileMemberInvitation", + "parameters": [ + { + "$ref": "#/components/parameters/MembershipProfileId" + }, + { + "$ref": "#/components/parameters/MembershipInvitationId" + }, + { + "$ref": "#/components/parameters/MembershipSelection" + } + ], + "responses": { + "204": { + "description": "Invitation cancelled; empty body." + }, + "400": { + "$ref": "#/components/responses/MembershipBadRequest", + "description": "MANAGED_PROFILE_INVALID_INPUT for non-UUID path IDs. Malformed JSON is rejected before route authentication." + }, + "401": { + "$ref": "#/components/responses/MembershipUnauthorized" + }, + "403": { + "$ref": "#/components/responses/MembershipForbidden" }, "404": { "content": { @@ -7878,7 +8985,7 @@ } } }, - "description": "`CREDENTIAL_NOT_FOUND`: the active child does not exist or is not owned by this manager." + "description": "INVITATION_NOT_FOUND: no invitation with this ID in the authorized child." }, "409": { "content": { @@ -7888,62 +8995,121 @@ } } }, - "description": "`CREDENTIAL_LIMIT_REACHED`: the child already holds five active, non-expired credentials." + "description": "INVITATION_ACCEPTED, INVITATION_CANCELLED, or INVITATION_EXPIRED." + }, + "413": { + "$ref": "#/components/responses/MembershipPayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/MembershipRateLimited" }, "500": { + "$ref": "#/components/responses/MembershipInternalError" + }, + "503": { + "$ref": "#/components/responses/MembershipAuthUnavailable" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Cancel a pending membership invitation", + "tags": ["Managed Profiles"] + } + }, + "/v1/managed-profiles/{profileId}/members": { + "get": { + "description": "Supabase bearer-only with a live manager or read_only membership. Rejects API-key headers and impersonation. Lists active members including immutable owner metadata and nullable profile email, ordered by createdAt then id ascending. Membership does not transfer ownership or grant access to siblings.", + "operationId": "listManagedProfileMembers", + "parameters": [ + { + "$ref": "#/components/parameters/MembershipProfileId" + }, + { + "$ref": "#/components/parameters/MembershipSelection" + }, + { + "$ref": "#/components/parameters/MembershipLimit" + }, + { + "$ref": "#/components/parameters/MembershipOffset" + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ManagedProfileErrorResponse" + "$ref": "#/components/schemas/ListManagedProfileMembersResponse" } } }, - "description": "Internal server error." + "description": "Active members and offset pagination." + }, + "400": { + "$ref": "#/components/responses/MembershipBadRequest", + "description": "MANAGED_PROFILE_INVALID_INPUT for a non-UUID profileId; INVALID_PAGINATION for invalid limit/offset or event cursor. Malformed JSON is rejected before route authentication." + }, + "401": { + "$ref": "#/components/responses/MembershipUnauthorized" + }, + "403": { + "$ref": "#/components/responses/MembershipForbidden" + }, + "413": { + "$ref": "#/components/responses/MembershipPayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/MembershipRateLimited" + }, + "500": { + "$ref": "#/components/responses/MembershipInternalError" + }, + "503": { + "$ref": "#/components/responses/MembershipAuthUnavailable" } }, "security": [ - { - "SecretApiKey": [] - }, { "BearerAuth": [] } ], - "summary": "Create a managed profile API credential", + "summary": "List active managed-profile members", "tags": ["Managed Profiles"] } }, - "/v1/managed-profiles/{profileId}/api-credentials/{credentialId}": { + "/v1/managed-profiles/{profileId}/members/{memberProfileId}": { "delete": { - "description": "Revokes one credential owned by an active child, disabling both its public and secret values. Repeating revocation of the same owned credential is idempotent and returns `204`.\n\n**Auth:** active controlling manager Supabase Bearer session or secret API key.", - "operationId": "revokeManagedProfileApiCredential", + "description": "Supabase bearer-only with a live manager membership; API-key headers and impersonation are rejected. Cannot revoke the immutable owner. Non-owner managers may remove themselves. A retry returns 204 if the same actor previously revoked that member and still has manager authority; otherwise a missing active target returns 404. Child-owned shared credentials are independent principals and are NOT revoked by member removal. Revoke exposed child credentials separately.", + "operationId": "removeManagedProfileMember", "parameters": [ { - "description": "Managed child profile ID.", - "in": "path", - "name": "profileId", - "required": true, - "schema": { - "format": "uuid", - "type": "string" - } + "$ref": "#/components/parameters/MembershipProfileId" }, { - "description": "Child credential ID.", - "in": "path", - "name": "credentialId", - "required": true, - "schema": { - "format": "uuid", - "type": "string" - } + "$ref": "#/components/parameters/MembershipMemberProfileId" + }, + { + "$ref": "#/components/parameters/MembershipSelection" } ], "responses": { "204": { - "description": "Credential is revoked; both values are unusable. Repeated revocation also returns this status." + "description": "Member revoked; empty body." }, "400": { + "$ref": "#/components/responses/MembershipBadRequest", + "description": "MANAGED_PROFILE_INVALID_INPUT for non-UUID path IDs. Malformed JSON is rejected before route authentication." + }, + "401": { + "$ref": "#/components/responses/MembershipUnauthorized" + }, + "403": { + "$ref": "#/components/responses/MembershipForbidden" + }, + "404": { "content": { "application/json": { "schema": { @@ -7951,9 +9117,9 @@ } } }, - "description": "`MANAGED_PROFILE_INVALID_INPUT`: profileId or credentialId is not a UUID." + "description": "MEMBER_NOT_FOUND: no active target or same-actor revocation retry." }, - "401": { + "409": { "content": { "application/json": { "schema": { @@ -7961,17 +9127,76 @@ } } }, - "description": "Missing, invalid, expired, or revoked manager authentication." + "description": "MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED: the owner membership cannot be removed." }, - "403": { + "413": { + "$ref": "#/components/responses/MembershipPayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/MembershipRateLimited" + }, + "500": { + "$ref": "#/components/responses/MembershipInternalError" + }, + "503": { + "$ref": "#/components/responses/MembershipAuthUnavailable" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Revoke a non-owner member", + "tags": ["Managed Profiles"] + }, + "patch": { + "description": "Supabase bearer-only with a live manager membership; API-key headers and impersonation are rejected. Only manager and read_only are accepted. The immutable owner cannot be changed, even to the same role. Repeating an unchanged non-owner role returns 200 without another event. Non-owner managers may downgrade themselves. The response member does not contain email.", + "operationId": "changeManagedProfileMemberRole", + "parameters": [ + { + "$ref": "#/components/parameters/MembershipProfileId" + }, + { + "$ref": "#/components/parameters/MembershipMemberProfileId" + }, + { + "$ref": "#/components/parameters/MembershipSelection" + } + ], + "requestBody": { + "content": { + "application/json": { + "example": { + "role": "read_only" + }, + "schema": { + "$ref": "#/components/schemas/ChangeManagedProfileMemberRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ManagedProfileErrorResponse" + "$ref": "#/components/schemas/ManagedProfileMemberResponse" } } }, - "description": "`CREDENTIAL_ACCESS_DENIED`: the manager is inactive. `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated credential belongs directly to a managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials." + "description": "Current member role." + }, + "400": { + "$ref": "#/components/responses/MembershipBadRequest", + "description": "MANAGED_PROFILE_INVALID_INPUT for non-UUID path IDs; INVALID_MEMBERSHIP_ROLE unless role is manager or read_only. Malformed JSON is rejected before route authentication." + }, + "401": { + "$ref": "#/components/responses/MembershipUnauthorized" + }, + "403": { + "$ref": "#/components/responses/MembershipForbidden" }, "404": { "content": { @@ -7981,9 +9206,9 @@ } } }, - "description": "`CREDENTIAL_NOT_FOUND`: the active child or credential does not exist, or is not owned by this manager." + "description": "MEMBER_NOT_FOUND: no active target member." }, - "500": { + "409": { "content": { "application/json": { "schema": { @@ -7991,18 +9216,27 @@ } } }, - "description": "Internal server error." + "description": "MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED: the owner membership cannot be changed." + }, + "413": { + "$ref": "#/components/responses/MembershipPayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/MembershipRateLimited" + }, + "500": { + "$ref": "#/components/responses/MembershipInternalError" + }, + "503": { + "$ref": "#/components/responses/MembershipAuthUnavailable" } }, "security": [ - { - "SecretApiKey": [] - }, { "BearerAuth": [] } ], - "summary": "Revoke a managed profile API credential", + "summary": "Change a non-owner member role", "tags": ["Managed Profiles"] } }, @@ -8119,7 +9353,7 @@ }, "/v1/onboarding/status": { "get": { - "description": "Returns the effective profile's customer entities and aggregated provider/KYC state. Non-terminal provider statuses may be refreshed before the response is built.", + "description": "Returns the effective profile's customer entities and aggregated provider/KYC state. Non-terminal provider statuses may be refreshed before the response is built.\n\n**Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "getOnboardingStatus", "parameters": [ { @@ -8152,7 +9386,8 @@ "description": "Authentication required." }, "403": { - "$ref": "#/components/responses/ManagedSelectorForbidden" + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." }, "500": { "content": { @@ -8174,7 +9409,8 @@ } ], "summary": "Get aggregate onboarding status", - "tags": ["KYC and KYB", "Account Management"] + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "read" } }, "/v1/public-key": { @@ -8213,7 +9449,7 @@ "/v1/quotes": { "post": { "deprecated": false, - "description": "Generates a quote for a specified ramp transaction, detailing input and output amounts, fees, and expiration.", + "description": "Generates a quote for a specified ramp transaction, detailing input and output amounts, fees, and expiration.\n\n**Managed selection (read):** A live manager or read_only membership may create quotes through this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "createQuote", "parameters": [ { @@ -8440,7 +9676,7 @@ } } }, - "description": "Partner authorization or managed-profile authorization failed." + "description": "Partner authorization or managed-profile authorization failed. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." }, "500": { "content": { @@ -8498,7 +9734,8 @@ } ], "summary": "Create a new quote", - "tags": ["Quotes"] + "tags": ["Quotes"], + "x-managed-profile-capability": "read" } }, "/v1/quotes/{id}": { @@ -8537,7 +9774,7 @@ "/v1/quotes/best": { "post": { "deprecated": false, - "description": "Generates a new quote for the network that yields the highest output amount for the given parameters. This endpoint compares the output for a given input amount over all supported networks and returns the 'best' quote, defined as the one with the highest output. ", + "description": "Generates a new quote for the network that yields the highest output amount for the given parameters. This endpoint compares the output for a given input amount over all supported networks and returns the 'best' quote, defined as the one with the highest output. \n\n**Managed selection (read):** A live manager or read_only membership may create quotes through this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "createBestQuote", "parameters": [ { @@ -8727,7 +9964,7 @@ } } }, - "description": "Partner authorization or managed-profile authorization failed." + "description": "Partner authorization or managed-profile authorization failed. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." }, "500": { "content": { @@ -8778,13 +10015,14 @@ } ], "summary": "Create a quote for the best network", - "tags": ["Quotes"] + "tags": ["Quotes"], + "x-managed-profile-capability": "read" } }, "/v1/ramp-info": { "get": { "deprecated": false, - "description": "Returns only sanitized per-corridor KYC state and buy/sell eligibility for the profile derived from the validated API credential. A manager secret may select one directly managed child with `X-Managed-Profile-Id`; public keys cannot use the selector. The endpoint never returns PII, provider/customer IDs, KYC failure reasons, bank/wallet data, ramp history, or exact financial limits. When both public and secret headers are supplied they must belong to the same credential. Supabase Bearer sessions do not authorize this endpoint.\n\n**Auth:** `X-Public-Key` or `X-API-Key`.", + "description": "Returns only sanitized per-corridor KYC state and buy/sell eligibility for the profile derived from the validated API credential. A manager or read_only member's secret may select one authorized child with `X-Managed-Profile-Id`; public keys cannot use the selector. The endpoint never returns PII, provider/customer IDs, KYC failure reasons, bank/wallet data, ramp history, or exact financial limits. When both public and secret headers are supplied they must belong to the same credential. Supabase Bearer sessions do not authorize this endpoint.\n\n**Auth:** `X-Public-Key` or `X-API-Key`.\n\n**Managed selection (read):** A live manager or read_only membership may use this operation. Selection requires a member-owned secret, never a bearer or public key. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "getRampInfo", "parameters": [ { @@ -8830,7 +10068,7 @@ } } }, - "description": "`CREDENTIAL_MISMATCH`: presented public and secret values belong to different credentials." + "description": "`CREDENTIAL_MISMATCH`: presented public and secret values belong to different credentials. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." } }, "security": [ @@ -8842,13 +10080,14 @@ } ], "summary": "Get sanitized ramp eligibility", - "tags": ["Account Management"] + "tags": ["Account Management"], + "x-managed-profile-capability": "read" } }, "/v1/ramp/{id}": { "get": { "deprecated": false, - "description": "Fetches an updated ramp process.", + "description": "Fetches an updated ramp process.\n\n**Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "parameters": [ { "$ref": "#/components/parameters/ManagedProfileId" @@ -9049,7 +10288,7 @@ } } }, - "description": "Ramp ownership or managed-profile authorization failed." + "description": "Ramp ownership or managed-profile authorization failed. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." } }, "security": [ @@ -9062,13 +10301,14 @@ } ], "summary": "Get ramp status", - "tags": ["Ramp"] + "tags": ["Ramp"], + "x-managed-profile-capability": "read" } }, "/v1/ramp/{id}/errors": { "get": { "deprecated": false, - "description": "Returns the chronological error log for a ramp.\n\n**Auth:** requires either `X-API-Key: sk_*` (partner) OR `Authorization: Bearer ` (user). Ownership is enforced.", + "description": "Returns the chronological error log for a ramp.\n\n**Auth:** requires either `X-API-Key: sk_*` (partner) OR `Authorization: Bearer ` (user). Ownership is enforced.\n\n**Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "getRampErrorLogs", "parameters": [ { @@ -9125,7 +10365,7 @@ } } }, - "description": "Ramp does not belong to authenticated principal.", + "description": "Ramp does not belong to authenticated principal. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED.", "headers": {} }, "404": { @@ -9144,13 +10384,14 @@ } ], "summary": "Get ramp error logs", - "tags": ["Ramp"] + "tags": ["Ramp"], + "x-managed-profile-capability": "read" } }, "/v1/ramp/history": { "get": { "deprecated": false, - "description": "Fetches all non-initial ramps owned by the authenticated user across wallet addresses. Requires a Supabase session or user-scoped secret API key. Partner-only credentials are not sufficient.", + "description": "Fetches all non-initial ramps owned by the authenticated user across wallet addresses. Requires a Supabase session or user-scoped secret API key. Partner-only credentials are not sufficient.\n\n**Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "parameters": [ { "$ref": "#/components/parameters/ManagedProfileId" @@ -9202,7 +10443,8 @@ "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, "403": { - "$ref": "#/components/responses/ManagedSelectorForbidden" + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." } }, "security": [ @@ -9214,13 +10456,14 @@ } ], "summary": "Get authenticated user ramp history", - "tags": ["Ramp"] + "tags": ["Ramp"], + "x-managed-profile-capability": "read" } }, "/v1/ramp/history/{walletAddress}": { "get": { "deprecated": false, - "description": "Fetches the transaction history for a given wallet address. The response returns the last 20 items by default. This can be adjusted by using the `limit` and `offset` query parameters. ", + "description": "Fetches the transaction history for a given wallet address. The response returns the last 20 items by default. This can be adjusted by using the `limit` and `offset` query parameters. \n\n**Managed selection (read):** A live manager or read_only membership may use this operation. Use a member-owned secret or the operation's supported bearer alternative. Read capability does not waive endpoint-specific owner corridor/type policy or resource ownership checks. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "parameters": [ { "$ref": "#/components/parameters/ManagedProfileId" @@ -9281,7 +10524,8 @@ "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, "403": { - "$ref": "#/components/responses/ManagedSelectorForbidden" + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed access denied. Missing or invalid membership uses MANAGED_PROFILE_ACCESS_DENIED. Where owner corridor/type policy is checked, denial uses MANAGED_PROFILE_POLICY_DENIED." } }, "security": [ @@ -9293,13 +10537,14 @@ } ], "summary": "Get ramp history for wallet address", - "tags": ["Ramp"] + "tags": ["Ramp"], + "x-managed-profile-capability": "read" } }, "/v1/ramp/register": { "post": { "deprecated": false, - "description": "Initiates a new on-ramp or off-ramp process by providing quote details, signing accounts, and additional data.", + "description": "Initiates a new on-ramp or off-ramp process by providing quote details, signing accounts, and additional data.\n\n**Managed selection (ramp):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child bearer-only register/update/start is always denied before the global body parser, including already registered or in-flight ramps; there is no drain exception. An otherwise authorized manager receives 403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL; read_only, invalid membership and impersonation retain their respective denials. Direct child secrets remain supported without a selector. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "registerRamp", "parameters": [ { @@ -9599,7 +10844,7 @@ } } }, - "description": "Quote ownership, managed-profile authorization, or impersonation policy failed." + "description": "Quote ownership, managed-profile authorization, or impersonation policy failed. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer ramp mutation: MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL; no drain exception." }, "500": { "content": { @@ -9625,13 +10870,14 @@ } ], "summary": "Register new ramp process", - "tags": ["Ramp"] + "tags": ["Ramp"], + "x-managed-profile-capability": "ramp" } }, "/v1/ramp/start": { "post": { "deprecated": false, - "description": "Starts a ramp process. \n\nIt is assumed all required information from the client has already been sent using the `update` endpoint. This endpoint is only used to tell the backend any external operation (like a bank transfer) has been completed, and the ramp can start.", + "description": "Starts a ramp process. \n\nIt is assumed all required information from the client has already been sent using the `update` endpoint. This endpoint is only used to tell the backend any external operation (like a bank transfer) has been completed, and the ramp can start.\n\n**Managed selection (ramp):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child bearer-only register/update/start is always denied before the global body parser, including already registered or in-flight ramps; there is no drain exception. An otherwise authorized manager receives 403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL; read_only, invalid membership and impersonation retain their respective denials. Direct child secrets remain supported without a selector. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "startRamp", "parameters": [ { @@ -9863,7 +11109,7 @@ } } }, - "description": "Ramp ownership, managed-profile authorization, or impersonation policy failed." + "description": "Ramp ownership, managed-profile authorization, or impersonation policy failed. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer ramp mutation: MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL; no drain exception." }, "500": { "content": { @@ -9891,13 +11137,14 @@ } ], "summary": "Start ramp process ", - "tags": ["Ramp"] + "tags": ["Ramp"], + "x-managed-profile-capability": "ramp" } }, "/v1/ramp/update": { "post": { "deprecated": false, - "description": "Submits presigned transactions and supported client-reported transaction hashes to an existing ramp process before starting it. \nThis endpoint can be called many times, and data can be incrementally added to the ramp. \n\nNote: For both pre-signed transactions and `additionalData`, existing properties will be overridden by new values.\n\n### Required data for ramps.\nThe signed counterpart of the initial unsignedTxs object must be provided for all ramps, as required by the object.\nFor offramps, the `additionalData` field must contain the confirmation hash corresponding to the initial transaction in which the user sends the funds. \nIf the originating chain is `AssetHub`, then `assethubToPendulumHash` must be provided. \nIf the originating chain is any EVM chain, then `squidRouterSwapHash` must be provided. `squidRouterApproveHash` is only required when an approval transaction was actually submitted; if the wallet already holds a sufficient allowance for the router, it can be omitted. No-permit flows use the corresponding `squidRouterNoPermit*Hash` fields.\n\nFor onramps, no additional data is required after registering the ramp.", + "description": "Submits presigned transactions and supported client-reported transaction hashes to an existing ramp process before starting it. \nThis endpoint can be called many times, and data can be incrementally added to the ramp. \n\nNote: For both pre-signed transactions and `additionalData`, existing properties will be overridden by new values.\n\n### Required data for ramps.\nThe signed counterpart of the initial unsignedTxs object must be provided for all ramps, as required by the object.\nFor offramps, the `additionalData` field must contain the confirmation hash corresponding to the initial transaction in which the user sends the funds. \nIf the originating chain is `AssetHub`, then `assethubToPendulumHash` must be provided. \nIf the originating chain is any EVM chain, then `squidRouterSwapHash` must be provided. `squidRouterApproveHash` is only required when an approval transaction was actually submitted; if the wallet already holds a sufficient allowance for the router, it can be omitted. No-permit flows use the corresponding `squidRouterNoPermit*Hash` fields.\n\nFor onramps, no additional data is required after registering the ramp.\n\n**Managed selection (ramp):** Requires a live manager membership and member-owned secret X-API-Key. Selected-child bearer-only register/update/start is always denied before the global body parser, including already registered or in-flight ramps; there is no drain exception. An otherwise authorized manager receives 403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL; read_only, invalid membership and impersonation retain their respective denials. Direct child secrets remain supported without a selector. The immutable owner's current policy governs all members; policy denial is MANAGED_PROFILE_POLICY_DENIED, not the acting member's personal policy.", "operationId": "updateRamp", "parameters": [ { @@ -10142,7 +11389,7 @@ } } }, - "description": "Ramp ownership, managed-profile authorization, or impersonation policy failed." + "description": "Ramp ownership, managed-profile authorization, or impersonation policy failed. Managed role denial: MANAGED_PROFILE_MANAGER_REQUIRED; owner-policy denial: MANAGED_PROFILE_POLICY_DENIED. Bearer ramp mutation: MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL; no drain exception." }, "500": { "content": { @@ -10170,7 +11417,8 @@ } ], "summary": "Update ramp process", - "tags": ["Ramp"] + "tags": ["Ramp"], + "x-managed-profile-capability": "ramp" } }, "/v1/session/create": { @@ -10665,22 +11913,44 @@ "headers": {} }, "400": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } + }, "description": "Managed-profile selection is unsupported on webhook endpoints.", "headers": {} }, "401": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiCredentialErrorResponse" + } + } + }, "description": "Missing or invalid secret API key.", "headers": {} }, "403": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } + }, "description": "Direct managed-child credentials are unsupported, or supplied credentials conflict.", "headers": {} } }, - "security": [{ "SecretApiKey": [] }], + "security": [ + { + "SecretApiKey": [] + } + ], "summary": "Register Webhook", "tags": ["Webhooks"] } @@ -10722,22 +11992,44 @@ "headers": {} }, "400": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } + }, "description": "Managed-profile selection is unsupported on webhook endpoints.", "headers": {} }, "401": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiCredentialErrorResponse" + } + } + }, "description": "Missing or invalid secret API key.", "headers": {} }, "403": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } + }, "description": "Direct managed-child credentials are unsupported, or supplied credentials conflict.", "headers": {} } }, - "security": [{ "SecretApiKey": [] }], + "security": [ + { + "SecretApiKey": [] + } + ], "summary": "Delete Webhook", "tags": ["Webhooks"] } diff --git a/docs/api/pages/03-authentication-and-partner-keys.md b/docs/api/pages/03-authentication-and-partner-keys.md index 5b592be9b..b103c273e 100644 --- a/docs/api/pages/03-authentication-and-partner-keys.md +++ b/docs/api/pages/03-authentication-and-partner-keys.md @@ -14,14 +14,17 @@ Both values share one immutable credential ID, subject profile, optional partner | Quote/widget attribution | Yes | Yes | Yes | | Sanitized `GET /v1/ramp-info` | Yes | Yes | No | | Exact limits and provider-account reads | No | Yes | Yes | -| Ramp register/update/start/status/history/errors | No | Yes | Yes | -| Act for an authorized managed child | No | Yes | Yes | -| Manage a directly owned child's credentials | No | Yes | Yes | -| Import an individual-KYC share token (BR) | No | Yes | Yes | +| Non-managed ramp register/update/start | No | Yes | Yes | +| Managed-child ramp register/update/start | No | Manager member or direct child | No | +| Managed-child reads, including quotes and ramp status/history/errors | No | Either member role or supported direct child | Either member role | +| Child credentials and domestic fiat-account mutations | No | Manager member | Manager member | +| Selected-child provider/KYC/KYB mutations, including BR token import | No | Manager member | No | +| Membership/invitation administration | No | No | Manager member; either role for lists | +| Membership invitation preview/acceptance | No | No | Exact verified-email invitee | | Webhook management (non-managed subjects only) | No | Yes | No | | Profile-managed credential lifecycle | No | No | Yes | -`GET /v1/ramp-info` requires `X-Public-Key` or `X-API-Key`; a Supabase Bearer session does not authorize this endpoint. It returns only per-corridor `kycStatus`, `canBuy`, and `canSell`. A manager secret may supply `X-Managed-Profile-Id`; public keys may not. It accepts no body/query profile or user selector and does not expose PII, provider identifiers, KYC failure reasons, account details, ramp history, or exact limits. +`GET /v1/ramp-info` requires `X-Public-Key` or `X-API-Key`; a Supabase Bearer session does not authorize this endpoint. It returns only per-corridor `kycStatus`, `canBuy`, and `canSell`. A `manager` or `read_only` member's secret may supply `X-Managed-Profile-Id`; public keys may not. It accepts no body/query profile or user selector and does not expose PII, provider identifiers, KYC failure reasons, account details, ramp history, or exact limits. Direct non-managed provider/KYC calls retain their existing secret-or-bearer authentication; selected-child restrictions do not remove that self-service alternative. ## Subject And Partner Binding @@ -31,44 +34,77 @@ Ramp registration requires a real profile subject in every corridor. KYC and pro ## Act For A Managed Child -Vortex may enable an authenticated profile as a managed-profile manager and assign its allowed corridors and, optionally, a narrower set of customer types. On supported child-oriented endpoints, that manager can select one directly managed headless child: +Vortex may enable an authenticated profile to provision and own managed children, assigning its allowed corridors and optional customer-type narrowing. Each child has one immutable controlling owner and separate child-scoped memberships for authenticated profiles. An active `manager` or `read_only` member can select that child on supported operations, even without personal owner configuration: ```http X-API-Key: sk_live_... X-Managed-Profile-Id: 00000000-0000-0000-0000-000000000002 ``` -A Supabase Bearer session may replace the secret key. A public `pk_*` value cannot authenticate delegation. Vortex verifies the active manager, direct active child relationship, child's single active customer entity, allowed country, optional customer-type narrowing, and canonical country/type support for corridor-bound mutations. An omitted or null customer-type policy adds no restriction beyond the canonical corridor capability matrix; a configured non-empty list only narrows that matrix. The manager remains the authenticated actor; ownership, KYC/provider lookup, and ramp history resolve from the child subject. Quote pricing uses the child's active profile assignment when present, otherwise the controlling manager profile's active assignment, then default Vortex pricing. This precedence is identical for manager-delegated requests and direct child credentials. +A Supabase Bearer session may replace the member-owned secret only for supported `read` and `manage` operations. A public `pk_*` value cannot authenticate delegation. Vortex verifies live membership and role, active controlling owner and child relationship, the child's single active customer entity, and applicable owner corridor/type policy. An omitted or null customer-type policy adds no restriction beyond the canonical corridor capability matrix; a configured list only narrows it. The member remains the actor; ownership, KYC/provider lookup, and ramp history resolve from the child. Pricing uses the child's active assignment, then the immutable owner's active assignment, then default Vortex pricing, identically for delegation and direct child credentials. The acting member's own manager policy or pricing does not replace the owner's. -The header is supported for quote creation; ramp registration, update, start, status, history, and errors; exact limits and sanitized ramp info; aggregate onboarding status; BR customer/KYC operations; customer creation, KYC/KYB, and fiat-account operations on the AR, CO, MX, and US corridors; and sender-side recipient operations. Sender-side recipient operations are invite creation, recipient and pending-invitation listing, invitation archive/unarchive, recipient relationship updates, and recipient eligibility reads. These recipient operations currently require a Supabase Bearer session; an `sk_*` key does not authorize them. Invite preview and acceptance remain invitee-scoped and do not support `X-Managed-Profile-Id`; a headless managed child cannot authenticate as an invitee or accept an invitation. Corridor removal blocks mutations and disallowed exact-limit requests but not quote discovery or historical/status reads. The EUR corridor's flows remain bound to a verified login email and do not support managed children. +### Delegated Capabilities -`POST /v1/brl/kyc/import-token` is a deliberate exception to direct child credential access. A controlling manager may call it with the manager's secret key or Supabase session plus `X-Managed-Profile-Id`, but a credential owned by the managed child is rejected with `403 MANAGED_PROFILE_ACCESS_DENIED`, even without the selector. Direct non-managed profiles may import for themselves with their own secret key or session. Public keys and ownerless credentials cannot import. The legacy `/v1/brla/kyc/import-token` path remains an equivalent migration alias. +| Capability | Allowed membership | Selected-child authentication | Routes | +|---|---|---|---| +| `read` | `manager`, `read_only` | Member secret or supported bearer alternative | Quote creation/best quote; ramp status/history/errors; exact limits; onboarding status; BR account/status/document reads; domestic status, customer lookup and fiat-account lists; recipient lists/eligibility; child credential lists | +| `manage` | `manager` | Member secret or bearer | Child credential creation/revocation; domestic fiat-account creation/deletion; sender-side recipient mutations | +| `credential_manage` | `manager` | Member secret only | BR subaccount creation, selfie/upload artifacts, KYC submission/preflight/import, KYB document/UBO creation and submission; domestic customer creation, KYC/KYB redirect links/notifications, retries, information/files and submissions | +| `ramp` | `manager` | Member secret only | `POST /v1/ramp/register`, `/update`, `/start` | + +Capability is not inferred from the HTTP method: quote creation is `read`; creating a selfie or hosted KYC/KYB link with `GET` is `credential_manage`. Child API-key management is `manage`, despite its name, and supports a real bearer session. `read_only` cannot mutate through its own secret key. `GET /v1/ramp-info` is the credential-only read exception. + +Selected-child provider mutations with a bearer return `403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL`. Selected-child bearer-only ramp mutations are unconditionally denied before global body parsing, including already registered/in-flight ramps: an otherwise authorized manager receives `403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL`. There is **no drain exception**. Invalid membership, read-only role and impersonation can fail earlier with their own denial codes. Background processing of an already-started ramp does not grant a bearer permission to call register, update or start. + +Sender-side recipient operations accept a member-owned secret **only when `X-Managed-Profile-Id` is present**. They also accept bearer sessions. `GET /v1/recipients` and `GET /v1/recipients/:id/eligibility` are `read`; `POST /v1/recipients/invite`, `PATCH /v1/recipients/invitations/:id` (archive/unarchive), and `PATCH /v1/recipients/:id` are `manage`. Without a selector, sender routes remain bearer-only. Direct child credentials are rejected even without a selector. Recipient invite preview/acceptance remain invitee-scoped, bearer-authenticated, and reject managed selection; headless children cannot accept. Recipient invitations establish payment relationships, not managed-profile team membership. + +Corridor removal blocks corridor-bound mutations and disallowed exact-limit/recipient eligibility requests, but not quote discovery or historical/status reads. Recipient lists also enforce owner customer-type narrowing. The EUR corridor's flows remain bound to a verified login email and do not support managed children. + +`POST /v1/brl/kyc/import-token` is a deliberate exception to direct child credential access. An active `manager` member may call it with a member-owned secret and `X-Managed-Profile-Id`; a selected-child bearer is insufficient. A credential owned by the managed child is rejected with `403 MANAGED_PROFILE_ACCESS_DENIED`, even without the selector. Direct non-managed profiles may import for themselves with their own secret key or session. Public keys and ownerless credentials cannot import. The legacy `/v1/brla/kyc/import-token` path remains an equivalent migration alias. Authentication, direct-child rejection, and managed authorization run before strict validation of `Idempotency-Key` and the request body. An unauthenticated caller therefore receives an authentication error rather than learning whether a bearer-like personal-data transfer token or attestation is well formed. The request has no profile, user, CPF, subaccount, applicant, entity, or provider-customer selector in its body or query; identity is derived only from the authenticated effective profile. Webhook registration and deletion do not support managed children. `X-Managed-Profile-Id` returns `400 MANAGED_PROFILE_UNSUPPORTED`, and a direct child credential returns `403 MANAGED_PROFILE_ACCESS_DENIED`. Managed-child integrations must poll the child-scoped ramp status/history endpoints. A manager credential without the selector remains manager-owned and therefore cannot register a webhook for a child-owned quote. -`X-Managed-Profile-Id` is only a selector. Supplying another manager's child, an inactive/deleted child, a child with an invalid entity layout, or a disallowed mutation corridor returns `403 MANAGED_PROFILE_ACCESS_DENIED`. +`X-Managed-Profile-Id` is only a selector. On general delegated operations, missing membership or an invalid relationship/layout uses `403 MANAGED_PROFILE_ACCESS_DENIED`; role denial is `MANAGED_PROFILE_MANAGER_REQUIRED` and owner-policy denial is `MANAGED_PROFILE_POLICY_DENIED`. Detail bootstrap is explicitly `GET /v1/managed-profiles/:profileId` with an **exactly matching** selector. It returns `403 MANAGED_PROFILE_MEMBERSHIP_INVALID` only when stored membership history (active or revoked) proves prior access and that child is no longer eligible: revoked membership, deleted child, disabled owner or invalid entity layout. A deleted child invalidates even its owner's bootstrap. Bearer sessions and member-owned secrets follow the same checks; this is not a bearer-only error. + +A caller who was never a member receives the same masked `404 MANAGED_PROFILE_NOT_FOUND` for existing and unknown children, with or without a matching selector. A mismatched detail selector receives `403 MANAGED_PROFILE_ACCESS_DENIED`. An ordinary detail read without a selector returns `404` for missing membership; an active member's ordinary read of an active child with disabled owner or invalid layout remains `403 MANAGED_PROFILE_ACCESS_DENIED`. A dashboard may clear selection on membership-invalid, not on `404`, role/policy denial or a transient error. Membership administration routes retain their generic `403` access denial. ### Manage Headless Profiles This section is the authoritative contract; for a step-by-step walkthrough with examples, see [Managed Profiles](https://api-docs.vortexfinance.co/managed-profiles). -An active manager may use its Supabase session or profile-bound secret credential on these endpoints: +Lifecycle endpoints accept a Supabase session or profile-bound secret credential, with the authority below. Public keys and direct child credentials are rejected. Provisioning, deletion and child credential mutations reject impersonation. | Endpoint | Purpose | |---|---| -| `POST /v1/managed-profiles` | Create an `individual` or `business` child from immutable `externalSubjectId` and provider `contactEmail` values | -| `GET /v1/managed-profiles` | List children and the manager's current policy; defaults to active records with `limit=50&offset=0` | -| `GET /v1/managed-profiles/:profileId` | Read an owned active or deleted child | +| `POST /v1/managed-profiles` | Enabled owner only: create an `individual` or `business` child from immutable `externalSubjectId` and provider `contactEmail` | +| `GET /v1/managed-profiles` | List eligible active memberships and actor flags, including an empty `200`; retained filters are owner-only | +| `GET /v1/managed-profiles/:profileId` | Either role for eligible active children; matching selector explicitly requests bootstrap; retained deleted reads are owner-only without selection | | `DELETE /v1/managed-profiles/:profileId` | Logically delete an owned child and revoke its credentials | -| `POST /v1/managed-profiles/:profileId/api-credentials` | Issue a child-owned public/secret credential pair | -| `GET /v1/managed-profiles/:profileId/api-credentials` | List the child's credentials without secret values | -| `DELETE /v1/managed-profiles/:profileId/api-credentials/:credentialId` | Revoke one child credential | +| `POST /v1/managed-profiles/:profileId/api-credentials` | Manager member: issue a child-owned public/secret credential pair | +| `GET /v1/managed-profiles/:profileId/api-credentials` | Either role: list the child's credentials without secret values | +| `DELETE /v1/managed-profiles/:profileId/api-credentials/:credentialId` | Manager member: revoke one child credential | Creation is not tied to one corridor and may create only an `individual` or `business` child. Every later corridor-bound operation checks the manager's current corridors, optional customer-type narrowing, and Vortex's canonical corridor/type support. Tightening policy blocks later authorization decisions but does not cancel a request already authorized or background processing for a ramp that already started. `POST` returns `201` for a new child and `200` for an identical retry. A deleted external subject remains reserved and cannot create a replacement child. Deletion is idempotent (`204`), preserves compliance and financial history, and blocks new child activity. -Lists accept `status=active|deleted|all`, `limit=1..100`, and a non-negative `offset`; the default status is `active`. The response always includes `manager.profileId`, `manager.allowedCorridors`, and `manager.allowedCustomerTypes` alongside `managedProfiles` and `pagination`. This policy belongs to the manager and applies to every child; it is not copied onto individual managed profiles. Inactive managers lose create, list, read, delete, and delegated-operation access. Requests for another manager's child return `404` on lifecycle routes. +Lists accept `status=active|deleted|all`, `limit=1..100`, and a non-negative `offset`; defaults are `active`, `50`, `0`. Both list and detail responses include `actor: { profileId, canProvisionManagedProfiles, hasMemberships }`. `canProvisionManagedProfiles` reflects the actor's own active manager configuration, not its membership role. `hasMemberships` is true when the **unpaginated eligible active-child count** is greater than zero, across all owners and independent of the requested status filter or particular detail read. Eligibility requires an unrevoked allowed-role membership, active owner configuration and child relationship, a managed child, and exactly one active owned entity selected by that child. Deleted children, inactive owners and invalid entity layouts do not count. An empty page can still have `hasMemberships: true`; a retained record can be returned with `hasMemberships: false`. + +The default active list returns `200` even without owner configuration or eligible memberships: `managedProfiles: []`, `pagination.total: 0`, and both flags false. An enabled owner with no children has `canProvisionManagedProfiles: true` and `hasMemberships: false`. Both `status=deleted` **and `status=all`** require the actor's own active manager configuration (`403 MANAGED_PROFILE_OWNER_REQUIRED` otherwise) and return **only children owned by that actor**. In particular, `all` is not a superset of the cross-owner active list: it excludes even active invited children owned by others. Retained results still require valid membership and entity layout. + +An ordinary retained deleted-child detail read must omit `X-Managed-Profile-Id` and requires the immutable owner, active owner configuration and valid membership/entity layout. Invited members and ineligible retained reads receive masked `404`, identically with bearer or member-secret authentication. A matching selector requests bootstrap instead and cannot access retained records. Each listed/read child includes `membership: { role, isOwner }` and `policy: { allowedCorridors, allowedCustomerTypes }`; there is no singular `manager` field. Creation still returns only `{ managedProfile }` without actor/membership/policy decorations. Disabling an owner removes eligibility for its children, not memberships under other active owners. + +The immutable owner membership is always `manager` and cannot be removed or changed. Other manager members may administer non-owner members, but cannot provision siblings for the owner, delete the child, change owner policy/pricing, or change global profile roles. Child secrets remain independent shared company principals: member removal/downgrade does not revoke them. Revoke any exposed child credentials separately; child deletion revokes them all. + +Deleting a child as a non-owner with an active `manager` or `read_only` membership returns `403 MANAGED_PROFILE_OWNER_REQUIRED`. A non-owner without active membership receives masked `404`; the immutable owner with disabled configuration receives `403 MANAGED_PROFILE_ACCESS_DENIED`. + +### Membership Invitations + +All nine team-membership operations require a real Supabase bearer session and reject `X-API-Key`, `X-Public-Key` (even with a valid bearer), and impersonation. Child-path membership routes allow an optional selector only when it matches the path case-insensitively. Invitee preview/acceptance reject any managed selector. See [Managed Profiles](https://api-docs.vortexfinance.co/managed-profiles) for the complete route table, examples, pagination and conflicts. + +Creation invites one trimmed/lowercased email to `manager` or `read_only` for seven days and queues one durable email. It does not reveal unrelated profile existence. Repeating an identical pending invite returns `200` without another delivery; a different role returns `409 INVITATION_ROLE_CONFLICT` and requires cancellation first. An already visible active member returns `409 MEMBERSHIP_ALREADY_EXISTS`. + +The UUID is only a locator. Preview and explicit acceptance bind the **current verified Supabase email** (`email_confirmed_at` required), not request email or cached profile email. An unknown UUID or mismatched/unverified caller receives generic `403` without invitation details. OTP verification alone grants nothing. Acceptance atomically creates membership and audit events; replay succeeds only for the same accepter with an active membership still matching the invitation role. Cancellation and expiry are terminal; repeated cancellation returns `409`. Member removal does not consume or revoke shared child secrets. The child contact email is normalized and immutable, is unique among the manager's children, is used for provider customer creation, and never becomes a Supabase login identity. A deleted child's contact email remains reserved for that manager. Partners must supply an email identity they are authorized to use; uniqueness is not global across managers. A child-owned credential authenticates directly as that child without `X-Managed-Profile-Id`. Every use dynamically requires the active manager relationship; corridor-bound mutations and exact-limit reads use the controlling manager's current corridor/type policy. A direct child credential cannot select another managed child. Logical deletion immediately invalidates and revokes both halves. @@ -151,7 +187,7 @@ A secret may be configured without a public value when only authenticated operat - `GET /v1/api-credentials` returns one item per credential. It includes the public value and safe secret prefix, never the secret value. - `DELETE /v1/api-credentials/{credentialId}` returns `204` and atomically revokes both values. It takes no request body and no second key ID. -Both endpoints require the subject's Supabase Bearer session. Secret API credentials cannot create or revoke other credentials. +Both self-profile endpoints require the subject's Supabase Bearer session. Secret API credentials cannot manage their own profile's credentials through `/v1/api-credentials`; eligible manager-member secrets can administer child credentials through the separate managed-profile routes above. ## Common Errors diff --git a/docs/api/pages/04-ramp-lifecycle.md b/docs/api/pages/04-ramp-lifecycle.md index bb00e2306..74982a0b7 100644 --- a/docs/api/pages/04-ramp-lifecycle.md +++ b/docs/api/pages/04-ramp-lifecycle.md @@ -34,6 +34,8 @@ Use `POST /v1/ramp/start` after required signatures, transaction hashes, and fia If a BRL PIX payment is confirmed by the payment partner but the client cannot call start (for example because the managed profile was deleted or its corridor policy changed after registration), Vortex automatically starts the already-signed persisted ramp. This recovery is tied to the exact provider ticket issued at registration; it does not authorize new ramps or bypass payment verification. +For a selected managed child, `POST /v1/ramp/register`, `/update`, and `/start` require an active `manager` membership and that member's own secret API credential. Supabase bearer-only calls are denied before body parsing, including existing/in-flight ramps: an otherwise authorized manager receives `403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL`. There is no drain exception; background recovery is not permission for a bearer to mutate a ramp. Direct child secrets remain supported without a selector. Both `manager` and `read_only` memberships can create child-scoped quotes and read supported ramp status/history/errors. The immutable owner's current policy governs every member. See [Authentication And API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys). + ## 5. Track Status Use `GET /v1/ramp/{id}` to retrieve current state, or configure webhooks to receive lifecycle events asynchronously. `GET /v1/ramp/{id}/errors` returns the error log for a ramp and is useful for support tooling. diff --git a/docs/api/pages/06-quotes-and-pricing.md b/docs/api/pages/06-quotes-and-pricing.md index 83850900c..cbc1d24f4 100644 --- a/docs/api/pages/06-quotes-and-pricing.md +++ b/docs/api/pages/06-quotes-and-pricing.md @@ -114,4 +114,6 @@ Pass the credential's public value through `X-Public-Key` to apply partner prici Managed profiles default to the controlling manager profile's pricing assignment. Assigning pricing directly to a managed child overrides the manager's pricing just as a profile assignment does for any regular profile. The same precedence applies whether the manager delegates with `X-Managed-Profile-Id` or the child authenticates with its own credential: child assignment, manager assignment, then default Vortex pricing. +Here, controlling manager means the child's immutable owner, not the acting member. Non-owner members do not substitute their own pricing. Quote creation and best-quote discovery are `read` capabilities for both `manager` and `read_only` memberships; they do not authorize ramp register/update/start, which require a manager member's secret (or a direct child secret without selection). + --- diff --git a/docs/api/pages/09-fiat-corridors.md b/docs/api/pages/09-fiat-corridors.md index a7f87f05e..4d19f8bca 100644 --- a/docs/api/pages/09-fiat-corridors.md +++ b/docs/api/pages/09-fiat-corridors.md @@ -39,7 +39,7 @@ Use `/v1/brl/*` for BRL account and verification operations. The previous `/v1/b Level 1 onboarding collects basic identity information and enables lower-limit BRL flows. Level 2 adds document and liveness verification and may be required for higher limits or stricter compliance rules. The user must have completed KYC on the same account whose key registers the ramp; otherwise the ramp may fail or require additional account-management steps. -A normal partner key cannot select an arbitrary user. An enabled managed-profile manager may use a secret `sk_*` key or Supabase session with `X-Managed-Profile-Id` to drive supported BR KYC operations for its directly managed child when the manager has the `BR` corridor and the child's immutable type is allowed by both current manager policy and Vortex's BR capability matrix. A null manager customer-type policy adds no further restriction. A public `pk_*` key is insufficient. When possible, use the Vortex application or hosted widget to complete onboarding before ramp execution. Business users can be sent straight into verification with the [KYB Deep Link](https://api-docs.vortexfinance.co/kyb-deep-link). +A normal partner key cannot select an arbitrary user. An active `manager` member may use its member-owned secret `sk_*` key and `X-Managed-Profile-Id` for supported BR KYC mutations when the immutable owner's current policy allows `BR` and the child's immutable type. Selected-child bearer sessions cannot perform provider mutations, including selfie/upload artifact creation; `manager` and `read_only` bearers may use supported status/account/document reads. Null owner customer-type policy adds no restriction beyond Vortex's BR capability matrix. Public keys cannot select children. When possible, use the Vortex application or hosted widget for onboarding. Business users can start verification with the [KYB Deep Link](https://api-docs.vortexfinance.co/kyb-deep-link). ### Individual KYC By API @@ -57,7 +57,7 @@ Track the outcome through `GET /v1/brl/getKycStatus?taxId=` or `GE An API-only alternative can import a caller-supplied Sumsub share token into an existing individual provider account. The path is enabled under approved Vortex policy even though final legal/consent wording, provider environment enablement, recipient IDs, and provider retry confirmations remain unresolved. This documentation does not claim that a live sandbox import has been verified. -The direct profile or controlling manager first provisions exactly one active Brazilian individual provider customer through the normal account flow. Import the token before reading KYC or aggregate onboarding status: a status read permanently selects a still-null method as `standard`, after which token import returns `409`. Then call: +The direct profile or authorized manager member first provisions exactly one active Brazilian individual provider customer through the normal account flow. Import the token before reading KYC or aggregate onboarding status: a status read permanently selects a still-null method as `standard`, after which token import returns `409`. Then call: ```http POST /v1/brl/kyc/import-token @@ -72,7 +72,7 @@ Content-Type: application/json } ``` -Use either a profile-bound secret key or a Supabase Bearer session. Omit `X-Managed-Profile-Id` for a direct non-managed profile. For a managed child, only its controlling manager may import with the selector; direct managed-child credentials are rejected. Authentication and authorization happen before strict body validation, and Vortex transactionally rechecks the manager's active status, exact active relationship, current BR and individual permissions, and the child's active entity before preparing or submitting the import. Revocation before submission prevents the provider import call. The body allows exactly the two fields shown, `importToken` must contain 1 to 1024 UTF-8 bytes, and `consentAttested` must be literal `true`. Do not send CPF, tax ID, `subAccountId`, Sumsub applicant ID, profile/entity IDs, provider-customer IDs, or any other identity selector. +For a direct non-managed profile, use a profile-bound secret or Supabase Bearer session without `X-Managed-Profile-Id`. For a selected child, an active `manager` member must use a member-owned secret; selected-child bearer sessions and direct child credentials are rejected. Authentication and authorization happen before strict body validation. Vortex rechecks membership authority, the active owner/relationship, current owner BR and individual permissions, and the child's active entity before preparing or submitting the import. Revocation before submission prevents the provider import call. The body allows exactly the two fields shown, `importToken` must contain 1 to 1024 UTF-8 bytes, and `consentAttested` must be literal `true`. Do not send CPF, tax ID, `subAccountId`, Sumsub applicant ID, profile/entity IDs, provider-customer IDs, or other identity selectors. Vortex records every token-claim attestation in the case's submission JSON as an append-only actor, subject, timestamp, and provisional consent-policy entry. A provider-`401` retry under a new key appends rather than replacing the earlier evidence. These attestations are not a substitute for the caller's legal basis, applicant disclosures, biometric or special-category consent, or cross-organization transfer obligations. @@ -105,7 +105,7 @@ Treat the share token as a secret. Keep it only long enough to make this request ### Business KYB Level 1 By API -Brazilian business verification (mode `api` in discovery) runs entirely through the API. Authentication is the same as the rest of the family: a profile-bound secret key or Supabase Bearer session, or a controlling manager using `X-Managed-Profile-Id` for a directly managed business child with the `BR` corridor. The subaccount must be a company (CNPJ) account; KYB operations on an individual account return `400`. +Brazilian business verification (mode `api` in discovery) runs entirely through the API. Direct non-managed profiles use a profile-bound secret or Supabase Bearer session. Selected-child KYB mutations require a `manager` member's secret with `X-Managed-Profile-Id` and the owner's current `BR` policy; bearer sessions support reads only. Direct child secrets support these KYB operations without selection. The subaccount must be a company (CNPJ) account; KYB operations on an individual account return `400`. The sequence, including the readiness reads that discovery intentionally omits: @@ -163,7 +163,7 @@ Onboarding can be completed three ways: Argentina business onboarding is not supported. After finalizing any flow, track the outcome through `GET /v1/onboarding/status`; provider review is asynchronous and there is no synchronous approval response. -Ramp registration resolves KYC and payment identity from the effective profile, not from payment or identity fields in the request. Authenticate as the user through a user-scoped key or Supabase Bearer session. Alternatively, an enabled managed-profile manager may use its secret key or session with `X-Managed-Profile-Id`; Vortex verifies the direct child relationship, corridor, immutable customer type, optional manager narrowing, and canonical corridor/type support before resolving the child's KYC/provider records. See [Authentication And API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys). Quotes remain available anonymously for rate discovery; eligibility is enforced at registration time, not quote time. +Ramp registration resolves KYC and payment identity from the effective profile, not from request identity fields. Non-managed users authenticate with a user-scoped key or Supabase Bearer session. Selected-child register/update/start require an active `manager` member's secret and `X-Managed-Profile-Id`, never a bearer; there is no drain exception. Vortex verifies membership, active owner/child relationship, immutable customer type and current owner corridor/type policy. See [Authentication And API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys). Quotes remain available anonymously for rate discovery; selected-child quote creation is a `read` capability available to either membership role. Eligibility is enforced at registration time, not quote time. ### Fiat Accounts diff --git a/docs/api/pages/12-ai-agent-integration.md b/docs/api/pages/12-ai-agent-integration.md index 1ad2e3246..18dc958a7 100644 --- a/docs/api/pages/12-ai-agent-integration.md +++ b/docs/api/pages/12-ai-agent-integration.md @@ -238,4 +238,8 @@ Non-negotiable rules for an agent implementing these flows: Platforms that onboard their own users headlessly — no Vortex login or UI for the end customer — create **managed child profiles** and run every onboarding and ramp operation on the child's behalf, either with the manager credential plus `X-Managed-Profile-Id` or with child-owned credentials. All discovery-published onboarding steps and the full ramp lifecycle accept this delegation, subject to the manager's corridor policy; webhooks do not (poll instead). The walkthrough with examples is [Managed Profiles](https://api-docs.vortexfinance.co/managed-profiles); the authoritative contract is in [Authentication And API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys). Agents implementing this pattern must key their idempotency and state on the manager-scoped `externalSubjectId` → `profileId` mapping, and must complete a BR child's Sumsub token import **before** any status read for that child (the method-lock rule above). +Resolve delegated authority from live child membership, not from owner identity alone. List/read lifecycle responses identify `actor` and decorate each child with `membership: { role, isOwner }` and its immutable owner's `policy`; creation remains undecorated. `read_only` includes quote creation but never mutations, even through a secret. Selected-child provider/KYC mutations require a `manager` member's own secret; ramp register/update/start also require that secret, with no bearer drain exception. Direct child credentials cannot select children, import BR share tokens, or administer membership. The nine membership/invitation routes are Supabase bearer-only and reject API-key headers; invitee acceptance has no request email or selector and requires the current verified Supabase email plus explicit acceptance. Member removal does not revoke shared child secrets. Follow the capability matrix and invitation examples linked above rather than treating a bearer as interchangeable with a secret. + +Both list and detail expose `actor: { profileId, canProvisionManagedProfiles, hasMemberships }`. Use these flags, not a `200` or the current page length, for access discovery: the default list can be empty with both flags false, and `hasMemberships` counts eligible active children independently of pagination/status. Both `status=deleted` and `status=all` require the actor's own active manager configuration and include only its owned children. Detail bootstrap requires an exactly matching selector and stored membership history before returning membership-invalid; never-member existing and unknown children both return masked `404`. Retained deleted-child detail is owner-only without a selector. Bearer and member-secret callers follow the same read rules. Keep the shipped provider-mutation code `MANAGED_PROFILE_REQUIRES_API_CREDENTIAL`. + --- diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 0b236ccb9..68ddf0b3e 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -13,7 +13,7 @@ Manager status is granted by Vortex, not self-service. During partner onboarding - **Allowed corridors** — the countries (`BR`, `AR`, `CO`, `MX`, `US`) your children may operate in. - **Optional customer-type narrowing** — restrict children to `individual` or `business`; a null policy allows both wherever the corridor's canonical capability matrix does. -Every delegated operation re-checks this policy at request time, so a corridor removed from your manager record immediately blocks new mutations for children in that corridor (in-flight ramps continue). EUR is not available for managed children — its flows are bound to a verified login email. +Delegated operations resolve the immutable owner's current policy, not the acting member's personal policy. Removing a corridor blocks new corridor-bound mutations and disallowed exact-limit reads; quote discovery and historical/status reads remain available. Already-started background ramps may continue, but this never grants a bearer permission to update or start a child ramp. EUR is not available for managed children because its flows are bound to a verified login email. ## Create A Managed Child @@ -53,18 +53,73 @@ Content-Type: application/json `profileId` is the value you pass as `X-Managed-Profile-Id` in every delegated call. Persist the pair (`externalSubjectId`, `profileId`) in your system of record. -Lifecycle endpoints: `GET /v1/managed-profiles` lists children (`status=active|deleted|all`, `limit=1..100`, `offset`; defaults `active`, `50`, `0`); `GET /v1/managed-profiles/{profileId}` reads one; `DELETE /v1/managed-profiles/{profileId}` logically deletes it — idempotent `204`, revokes the child's credentials, blocks new activity, and preserves compliance and financial history. A deleted child's `externalSubjectId` and `contactEmail` stay reserved and cannot seed a replacement. +Lifecycle endpoints: `GET /v1/managed-profiles` defaults to eligible active memberships across all owners (`limit=1..100`, non-negative `offset`; defaults `50`, `0`). `status=deleted` and `status=all` both require the actor's own active manager configuration and are owner-scoped only, excluding children owned by others even if active. `GET /v1/managed-profiles/{profileId}` reads one eligible active child; retained deleted-child reads require the active immutable owner and no selector. List/read responses identify the actor and decorate each child with the actor's `manager` or `read_only` membership, `isOwner`, and owner policy. `DELETE` remains owner-only: an active non-owner member receives `403 MANAGED_PROFILE_OWNER_REQUIRED`. Deletion is idempotent while the owner configuration is active, revokes child credentials, blocks new activity, and preserves compliance and financial history. Deleted `externalSubjectId` and `contactEmail` values stay reserved. + +For example, a non-owner member can bootstrap its selected child: + +```http +GET /v1/managed-profiles/00000000-0000-0000-0000-000000000002 +Authorization: Bearer +X-Managed-Profile-Id: 00000000-0000-0000-0000-000000000002 +``` + +```json +{ + "actor": { + "profileId": "00000000-0000-0000-0000-000000000003", + "canProvisionManagedProfiles": false, + "hasMemberships": true + }, + "managedProfile": { + "profileId": "00000000-0000-0000-0000-000000000002", + "externalSubjectId": "customer-4711", + "customerType": "individual", + "contactEmail": "customer-4711@platform.example", + "status": "active", + "creationSource": "manager", + "deletedAt": null, + "createdAt": "2026-08-19T12:00:00.000Z", + "updatedAt": "2026-08-19T12:00:00.000Z", + "membership": { "role": "manager", "isOwner": false }, + "policy": { "allowedCorridors": ["BR"], "allowedCustomerTypes": null } + } +} +``` + +The list response uses the same actor and decorated child shape inside `managedProfiles`, adding `pagination: { limit, offset, total }`. `canProvisionManagedProfiles` reflects the actor's own active manager configuration. `hasMemberships` is based on an unpaginated eligible active-child count across all owners, independent of the page, status filter and detail target. Eligibility excludes revoked/invalid roles, deleted children, inactive owners and invalid entity layouts; a child must select its sole active owned customer entity. An empty page can therefore retain `hasMemberships: true`, while an owner reading retained records can have `hasMemberships: false`. + +The default list returns `200` even for an actor with no owner configuration or eligible memberships: + +```json +{ + "actor": { + "profileId": "00000000-0000-0000-0000-000000000003", + "canProvisionManagedProfiles": false, + "hasMemberships": false + }, + "managedProfiles": [], + "pagination": { "limit": 50, "offset": 0, "total": 0 } +} +``` + +An enabled owner before provisioning instead receives flags `true`, `false`. Neither an empty page nor a `200` by itself proves managed access. There is no singular `manager` object; creation remains undecorated. Membership never transfers ownership or grants sibling provisioning, child deletion, policy/pricing administration, or global profile roles. + +### Bootstrap And Retained Reads + +Bootstrap means `GET` detail with `X-Managed-Profile-Id` **exactly matching** the path. The selector expresses intent, not prior access: stored membership history (active or revoked) is required before ineligibility returns `403 MANAGED_PROFILE_MEMBERSHIP_INVALID`. This covers revoked membership, deleted children, disabled owners and invalid entity layouts. Deleted children invalidate bootstrap even for their owner. Both bearer and member-secret callers use this rule; callers who were never members receive the same masked `404 MANAGED_PROFILE_NOT_FOUND` for an existing or unknown child, whether or not they send a matching selector. A mismatched selector is `403 MANAGED_PROFILE_ACCESS_DENIED`. + +To inspect a retained deleted child, the immutable owner uses an ordinary detail read **without a selector**, with active owner configuration and valid membership/entity layout. Invited members and ineligible retained reads receive `404`. Ordinary reads never produce membership-invalid: missing membership is `404`, while an active member's active-child read with disabled owner or invalid layout is `403 MANAGED_PROFILE_ACCESS_DENIED`. Clear a dashboard selection on evidenced bootstrap membership-invalid, not on role/policy denial, generic `404` or transient failures. ## Two Ways To Act For A Child -**Delegation header (recommended).** Your manager credential plus a selector: +**Delegation header (recommended).** Your member-owned secret credential plus a selector: ```http X-API-Key: sk_live_... X-Managed-Profile-Id: 00000000-0000-0000-0000-000000000002 ``` -You remain the authenticated actor; ownership, KYC/provider identity, and ramp history resolve from the child. Vortex verifies the active manager, the direct active relationship, the child's entity layout, and corridor/type policy on every request. An invalid selector — another manager's child, a deleted child, a disallowed corridor mutation — returns `403 MANAGED_PROFILE_ACCESS_DENIED`. +You remain the authenticated actor; ownership, KYC/provider identity, and ramp history resolve from the child. Vortex verifies active membership and role, active controlling owner and relationship, valid child entity layout, and applicable owner policy. `read_only` permits supported reads, including quote creation; `manager` permits supported mutations. Provider/KYC/KYB mutations require a member-owned secret, including GET endpoints that create verification links or artifacts; the shipped bearer-denial code is `MANAGED_PROFILE_REQUIRES_API_CREDENTIAL`. Selected-child ramp register/update/start also require a secret: bearer-only calls are denied before body parsing, unconditionally and with no drain exception. Only explicit, historically evidenced detail bootstrap uses membership-invalid as described above; other delegated failures do not authorize clearing selection. **Child-owned credentials.** Issue the child its own key pair when a subsystem should act as the child directly, without the header: @@ -73,9 +128,128 @@ POST /v1/managed-profiles/00000000-0000-0000-0000-000000000002/api-credentials X-API-Key: sk_live_... ``` -The response is the standard credential resource — the secret value is returned exactly once; store it immediately. `GET .../api-credentials` lists them without secrets; `DELETE .../api-credentials/{credentialId}` revokes one. A child credential authenticates as the child without any selector, but every use still requires your manager relationship to be active and applies your current corridor/type policy — and it cannot select any other child. +The response is the standard credential resource — the secret value is returned exactly once; store it immediately. `GET .../api-credentials` lists them without secrets for either role; creating or deleting requires a `manager` membership. A child credential authenticates as the child without any selector, but every use still requires the controlling relationship and manager to be active and applies the owner's current corridor/type policy — and it cannot select any other child. + +Child credential creation/revocation supports a member bearer session as well as a member-owned secret (`manage` capability); it is not a secret-only provider mutation (`credential_manage`). Impersonation is rejected for credential mutations. Child secrets are independent shared company principals: removing or downgrading a member does **not** revoke them. Revoke any child secrets the departing member possessed separately. + +One deliberate exception: `POST /v1/brl/kyc/import-token` (the Sumsub share-token import) rejects direct child credentials with `403`. An active `manager` member must import with its own secret and the delegation header; a selected-child bearer cannot import. + +## Invite And Manage Members + +Members are authenticated human profiles; the child remains headless. There are exactly two roles: `manager` can perform supported child mutations and administer non-owner members; `read_only` can only read, even when using a secret key. The immutable owner is always an active `manager` member and cannot be changed or revoked. + +All nine endpoints below require `Authorization: Bearer `. Do not attach API-key headers, even alongside a bearer. API credentials, direct child credentials, and impersonation are rejected. On child-path routes, an optional `X-Managed-Profile-Id` must match the path UUID case-insensitively. On invitee routes it is forbidden. + +| Endpoint | Authority | Success | +|---|---|---| +| `GET /v1/managed-profiles/{profileId}/members` | Either role | `200 { members, pagination }` | +| `PATCH /v1/managed-profiles/{profileId}/members/{memberProfileId}` | Manager member | `200 { member }` | +| `DELETE /v1/managed-profiles/{profileId}/members/{memberProfileId}` | Manager member | `204`, no body | +| `GET /v1/managed-profiles/{profileId}/member-invitations` | Either role | `200 { invitations, pagination }` | +| `POST /v1/managed-profiles/{profileId}/member-invitations` | Manager member | `201 { invitation }`, or `200` for identical pending retry | +| `DELETE /v1/managed-profiles/{profileId}/member-invitations/{invitationId}` | Manager member | `204`, no body | +| `GET /v1/managed-profiles/{profileId}/member-events` | Either role | `200 { events, pagination }` | +| `GET /v1/managed-profile-member-invitations/{invitationId}` | Exact verified-email invitee | `200 { invitation, inviter, managedProfile }` | +| `POST /v1/managed-profile-member-invitations/{invitationId}/accept` | Exact verified-email invitee | `200 { managedProfileId, member }` | + +### 1. Create An Invitation + +```http +POST /v1/managed-profiles/00000000-0000-0000-0000-000000000002/member-invitations +Authorization: Bearer +Content-Type: application/json + +{ "email": "operator@example.com", "role": "manager" } +``` + +```json +{ + "invitation": { + "id": "00000000-0000-0000-0000-000000000004", + "managedProfileId": "00000000-0000-0000-0000-000000000002", + "invitedByProfileId": "00000000-0000-0000-0000-000000000001", + "email": "operator@example.com", + "role": "manager", + "status": "pending", + "createdAt": "2026-09-07T12:00:00.000Z", + "expiresAt": "2026-09-14T12:00:00.000Z", + "acceptedAt": null, + "cancelledAt": null, + "expiredAt": null + } +} +``` + +Email is trimmed/lowercased and validated (maximum 254 normalized characters). The invitation expires after seven days and its email is queued transactionally. Creation does not reveal whether an unrelated profile exists. Only one pending invitation per child/email is allowed regardless of role. Identical retries return the same invitation without another email; changing the pending role requires cancellation and a new invite. A visible active member yields `409 MEMBERSHIP_ALREADY_EXISTS`. No secret acceptance token or URL is returned. + +### 2. Sign In, Preview, Then Accept + +The invitee signs in using the invited email and obtains a Supabase session. The invitation UUID is only a locator, not a bearer secret. Preview and acceptance compare the normalized invitation email with the **current Supabase principal's verified email**, requiring `email_confirmed_at`. Request-body email and cached `profiles.email` are not authority. OTP verification alone does not grant child access. + +```http +GET /v1/managed-profile-member-invitations/00000000-0000-0000-0000-000000000004 +Authorization: Bearer +``` + +Authorized preview returns the invitation above, `inviter: { profileId, email }` (nullable inviter email), and `managedProfile: { profileId, externalSubjectId }`. It can return `pending`, `accepted`, `cancelled`, or `expired`. An unknown UUID or mismatched/unverified email returns generic `403 MANAGED_PROFILE_ACCESS_DENIED` without child, inviter, role, or status details. + +After showing the child and role to the invitee, explicitly accept with **no request body and no managed selector**: + +```http +POST /v1/managed-profile-member-invitations/00000000-0000-0000-0000-000000000004/accept +Authorization: Bearer +``` + +```json +{ + "managedProfileId": "00000000-0000-0000-0000-000000000002", + "member": { + "id": "00000000-0000-0000-0000-000000000005", + "memberProfileId": "00000000-0000-0000-0000-000000000003", + "role": "manager", + "isOwner": false, + "createdAt": "2026-09-07T12:05:00.000Z", + "updatedAt": "2026-09-07T12:05:00.000Z" + } +} +``` + +Acceptance transactionally creates one active membership and `invitation_accepted`/`member_added` events. A previously revoked member receives a new membership row. Replay returns `200` only if the same accepter still has an active membership matching the invitation role; it does not restore access after removal/downgrade or duplicate events. Otherwise an accepted invitation returns `409 INVITATION_ACCEPTED`. Cancelled/expired invitations cannot be accepted. + +### 3. Change Access And Audit + +Use the **member profile UUID**, not `member.id`, in member mutation paths: + +```http +PATCH /v1/managed-profiles/00000000-0000-0000-0000-000000000002/members/00000000-0000-0000-0000-000000000003 +Authorization: Bearer +Content-Type: application/json + +{ "role": "read_only" } +``` + +This returns `{ member }` in the acceptance member shape, with updated role/timestamp and no email. Repeating an unchanged non-owner role writes no new event. `DELETE` on the same path revokes membership; a same-actor retry returns `204` only while that actor retains manager authority. Non-owner managers can downgrade/remove themselves. Owner changes, including a same-role patch, return `409 MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED`. -One deliberate exception: `POST /v1/brl/kyc/import-token` (the Sumsub share-token import) rejects direct child credentials with `403`. Only the controlling manager may import, using the delegation header. +Member and invitation lists use `limit=1..100` and non-negative `offset` (defaults `50`, `0`), returning `{ limit, offset, total }`. Members are active-only, oldest first by creation time/UUID, and add nullable `email` to each member. Invitations include pending and terminal records, newest first by creation time/UUID. There is no invitation status filter. Creation, preview, listing, acceptance and cancellation persist observed expiry once. + +Events use `?limit=50&cursor=`, newest first by creation time/UUID, returning `pagination: { limit, nextCursor }`. The cursor must belong to this child; `null` means there are no older events. `offset` is validated if supplied but ignored. Each event contains `id`, `action`, `createdAt`, and nullable `actorProfileId`, `memberProfileId`, `invitationId`, `previousRole`, `role`. Actions are `invited`, `invitation_accepted`, `invitation_cancelled`, `invitation_expired`, `member_added`, `role_changed`, `member_removed`. Events omit email, invitation URLs and secrets. + +Membership routes share a per-actor limit of 120 requests/minute (`429` text response with standard rate-limit headers). Authentication errors use `{ "error": "..." }`: `401` for missing/invalid sessions and `503` for transient authentication unavailability. Service errors use `{ "error": { "code", "message", "status" } }`. + +Unlike token import and selected-child bearer ramp rejection, membership routes run after the global body parser. Malformed JSON returns `400`; bodies exceeding 20 MB return `413`, before membership authentication. Parser errors use `{ code, message, statusCode, type }`, not the service error wrapper. + +| Status/code | Meaning | +|---|---| +| `400 MANAGED_PROFILE_INVALID_INPUT` | Path IDs must be UUIDs. | +| `400 INVALID_PAGINATION` | Invalid page size/offset or event cursor, including a cursor outside the child. | +| `400 INVALID_MEMBERSHIP_ROLE` / `INVALID_INVITATION_EMAIL` | Invalid role or creation email. | +| `403 MANAGED_PROFILE_ACCESS_DENIED` | Missing child authority, forbidden API-key headers, mismatched selector, or unauthorized invitee. | +| `403 IMPERSONATION_NOT_ALLOWED` | All membership operations reject impersonation. | +| `404 MEMBER_NOT_FOUND` / `INVITATION_NOT_FOUND` | The authorized child's mutation target is absent. Invitee probing uses `403`, not `404`. | +| `409 INVITATION_ROLE_CONFLICT` | Cancel the pending invite before changing its role. | +| `409 MEMBERSHIP_ALREADY_EXISTS` | An active child membership already exists. | +| `409 INVITATION_ACCEPTED` / `INVITATION_CANCELLED` / `INVITATION_EXPIRED` | Terminal invitation; repeated cancellation also conflicts. | +| `500 INTERNAL_SERVER_ERROR` | Membership request could not be processed. | ## Onboard A Child @@ -128,16 +302,22 @@ Register, sign, and start exactly as described in [Ramp Lifecycle](https://api-d Two things behave differently for managed children: -- **Pricing** is resolved as: the child's own partner-pricing assignment if one exists, otherwise **your (the manager's) active assignment**, otherwise default Vortex pricing — identically for header-delegated calls and direct child credentials. Children automatically inherit your negotiated fees. +- **Pricing** is resolved as: the child's own partner-pricing assignment if one exists, otherwise **the immutable owner's active assignment**, otherwise default Vortex pricing, identically for delegated calls and direct child credentials. A non-owner member's personal pricing does not override the owner. - **Webhooks are not supported for managed subjects** — registration returns `400 MANAGED_PROFILE_UNSUPPORTED` with the header and `403` with a child credential. Poll the child-scoped ramp status and history endpoints instead. ## Common Errors | Response | Meaning | |---|---| -| `403 MANAGED_PROFILE_ACCESS_DENIED` | The selector or child credential failed a check: inactive manager, not your child, deleted child, invalid entity layout, or a corridor/type your policy does not allow. | -| `400 MANAGED_PROFILE_UNSUPPORTED` | The endpoint does not support delegation (currently webhook management). | -| `404` on lifecycle routes | The `profileId` does not identify a child of the authenticated manager. | +| `403 MANAGED_PROFILE_ACCESS_DENIED` | The relationship, controlling manager, selector, child credential, or entity layout is invalid or inactive. | +| `403 MANAGED_PROFILE_MEMBERSHIP_INVALID` | Matching-selector detail bootstrap has membership history but is no longer eligible, including deleted child or disabled owner; applies to bearer and member-secret callers. | +| `403 MANAGED_PROFILE_OWNER_REQUIRED` | Retained list filters require the actor's own active owner configuration; active non-owner members cannot delete the child. | +| `403 MANAGED_PROFILE_MANAGER_REQUIRED` | The operation requires a `manager` membership; `read_only` is insufficient. | +| `403 MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL` | A selected-child ramp mutation requires your member-owned secret credential, not a bearer session. | +| `403 MANAGED_PROFILE_REQUIRES_API_CREDENTIAL` | A selected-child provider/KYC/KYB mutation requires your member-owned secret, not a bearer session. | +| `403 MANAGED_PROFILE_POLICY_DENIED` | The controlling owner's current corridor or customer-type policy does not allow the operation. | +| `400 MANAGED_PROFILE_UNSUPPORTED` | The endpoint does not support delegation, including webhook management and invitee preview/acceptance. | +| `404 MANAGED_PROFILE_NOT_FOUND` on path-child routes | Unknown/never-member child, missing membership on an ordinary detail read, or ineligible/invited-member retained read. Unknown and never-member existing children are masked identically. | | `200` instead of `201` on create | Idempotent retry — the identical child already exists. | | `409 CREDENTIAL_LIMIT_REACHED` | The child already has five active, non-expired credentials. | diff --git a/docs/api/scripts/check-openapi.ts b/docs/api/scripts/check-openapi.ts index 402517208..3125af029 100644 --- a/docs/api/scripts/check-openapi.ts +++ b/docs/api/scripts/check-openapi.ts @@ -6,7 +6,66 @@ const GENERATED_TYPES_FILE = "docs/api/openapi/vortex.openapi.d.ts"; const GENERATOR_FILE = "docs/api/scripts/generate-openapi-types.ts"; const MANIFEST_FILE = "docs/api/apidog/page-manifest.json"; +const MEMBERSHIP_OPERATIONS = [ + [ + "/v1/managed-profiles/{profileId}/members", + "get", + "ListManagedProfileMembersResponse", + ["200", "400", "401", "403", "429", "500", "503"] + ], + [ + "/v1/managed-profiles/{profileId}/members/{memberProfileId}", + "patch", + "ManagedProfileMemberResponse", + ["200", "400", "401", "403", "404", "409", "429", "500", "503"] + ], + [ + "/v1/managed-profiles/{profileId}/members/{memberProfileId}", + "delete", + null, + ["204", "400", "401", "403", "404", "409", "429", "500", "503"] + ], + [ + "/v1/managed-profiles/{profileId}/member-invitations", + "get", + "ListManagedProfileInvitationsResponse", + ["200", "400", "401", "403", "429", "500", "503"] + ], + [ + "/v1/managed-profiles/{profileId}/member-invitations", + "post", + "ManagedProfileInvitationResponse", + ["200", "201", "400", "401", "403", "409", "429", "500", "503"] + ], + [ + "/v1/managed-profiles/{profileId}/member-invitations/{invitationId}", + "delete", + null, + ["204", "400", "401", "403", "404", "409", "429", "500", "503"] + ], + [ + "/v1/managed-profiles/{profileId}/member-events", + "get", + "ListManagedProfileMemberEventsResponse", + ["200", "400", "401", "403", "429", "500", "503"] + ], + [ + "/v1/managed-profile-member-invitations/{invitationId}", + "get", + "PreviewManagedProfileInvitationResponse", + ["200", "400", "401", "403", "429", "500", "503"] + ], + [ + "/v1/managed-profile-member-invitations/{invitationId}/accept", + "post", + "AcceptManagedProfileInvitationResponse", + ["200", "400", "401", "403", "409", "429", "500", "503"] + ] +] as const; +const MEMBERSHIP_PATHS = [...new Set(MEMBERSHIP_OPERATIONS.map(([path]) => path))]; + const REQUIRED_PATHS = [ + ...MEMBERSHIP_PATHS, "/v1/api-credentials", "/v1/api-credentials/{credentialId}", "/v1/domestic/alfredpayStatus", @@ -75,7 +134,7 @@ const REQUIRED_PATHS = [ const MANAGED_PROFILE_OPERATIONS = [ ["/v1/managed-profiles", "get", ["200", "400", "401", "403", "409", "500"]], ["/v1/managed-profiles", "post", ["200", "201", "400", "401", "403", "409", "500"]], - ["/v1/managed-profiles/{profileId}", "get", ["200", "400", "401", "403", "404", "409", "500"]], + ["/v1/managed-profiles/{profileId}", "get", ["200", "400", "401", "403", "404", "500"]], ["/v1/managed-profiles/{profileId}", "delete", ["204", "400", "401", "403", "404", "500"]], ["/v1/managed-profiles/{profileId}/api-credentials", "get", ["200", "400", "401", "403", "404", "500"]], ["/v1/managed-profiles/{profileId}/api-credentials", "post", ["201", "400", "401", "403", "404", "409", "500"]], @@ -128,6 +187,7 @@ const BRLA_IMPORT_KYC_TOKEN_ERRORS = [ ] as const; const MANAGED_PROFILE_PATHS = [ + ...MEMBERSHIP_PATHS, "/v1/managed-profiles", "/v1/managed-profiles/{profileId}", "/v1/managed-profiles/{profileId}/api-credentials", @@ -425,7 +485,7 @@ if (exposedAdminPaths.length > 0) { const unexpectedManagedProfilePaths = paths.filter( path => - path.startsWith("/v1/managed-profiles") && !MANAGED_PROFILE_PATHS.includes(path as (typeof MANAGED_PROFILE_PATHS)[number]) + path.startsWith("/v1/managed-profile") && !MANAGED_PROFILE_PATHS.includes(path as (typeof MANAGED_PROFILE_PATHS)[number]) ); if (unexpectedManagedProfilePaths.length > 0) { throw new Error(`OpenAPI file exposes unexpected managed-profile paths:\n${unexpectedManagedProfilePaths.join("\n")}`); @@ -452,11 +512,11 @@ const documentedManagedProfileOperations = MANAGED_PROFILE_PATHS.flatMap(path => .filter(method => httpMethods.has(method)) .map(method => `${method.toUpperCase()} ${path}`); }); -const requiredManagedProfileOperations = MANAGED_PROFILE_OPERATIONS.map( - ([path, method]) => `${method.toUpperCase()} ${path}` -).sort(); +const requiredManagedProfileOperations = [...MANAGED_PROFILE_OPERATIONS, ...MEMBERSHIP_OPERATIONS] + .map(([path, method]) => `${method.toUpperCase()} ${path}`) + .sort(); if (JSON.stringify(documentedManagedProfileOperations.sort()) !== JSON.stringify(requiredManagedProfileOperations)) { - throw new Error("OpenAPI file must expose exactly the seven approved public managed-profile operations."); + throw new Error("OpenAPI file must expose exactly seven lifecycle and nine membership/invitation operations."); } function operationAt(path: string, method: string): JsonObject { @@ -515,7 +575,7 @@ const listManagedProfilesResponseProperties = (listManagedProfilesResponseSchema const listManagedProfilesResponseRequired = Array.isArray(listManagedProfilesResponseSchema.required) ? listManagedProfilesResponseSchema.required : []; -const managerPolicySchema = schemas.ManagedProfileManagerPolicy as JsonObject; +const managerPolicySchema = schemas.ManagedProfilePolicy as JsonObject; const managerPolicyProperties = (managerPolicySchema.properties ?? {}) as JsonObject; const managerPolicyRequired = Array.isArray(managerPolicySchema.required) ? managerPolicySchema.required : []; if ( @@ -530,19 +590,235 @@ if ( throw new Error("GET /v1/managed-profiles must document the controller's pagination and status defaults."); } if ( - JSON.stringify(listManagedProfilesResponseProperties.manager) !== - JSON.stringify({ $ref: "#/components/schemas/ManagedProfileManagerPolicy" }) || - !listManagedProfilesResponseRequired.includes("manager") || - JSON.stringify(managerPolicyRequired.sort()) !== - JSON.stringify(["allowedCorridors", "allowedCustomerTypes", "profileId"].sort()) || - JSON.stringify((managerPolicyProperties.profileId as JsonObject)?.format) !== JSON.stringify("uuid") || + "manager" in listManagedProfilesResponseProperties || + "ManagedProfileManagerPolicy" in schemas || + JSON.stringify(listManagedProfilesResponseProperties.actor) !== + JSON.stringify({ $ref: "#/components/schemas/ManagedProfileActor" }) || + !listManagedProfilesResponseRequired.includes("actor") || + !transitivelyReferences(listManagedProfilesResponseProperties.managedProfiles, "#/components/schemas/ManagedProfileAccess") || + JSON.stringify(managerPolicyRequired.sort()) !== JSON.stringify(["allowedCorridors", "allowedCustomerTypes"].sort()) || + "profileId" in managerPolicyProperties || JSON.stringify(((managerPolicyProperties.allowedCorridors as JsonObject)?.items as JsonObject)?.enum) !== JSON.stringify(["AR", "BR", "CO", "EU", "MX", "US"]) || JSON.stringify((managerPolicyProperties.allowedCustomerTypes as JsonObject)?.type) !== JSON.stringify(["array", "null"]) || JSON.stringify(((managerPolicyProperties.allowedCustomerTypes as JsonObject)?.items as JsonObject)?.enum) !== JSON.stringify(["individual", "business"]) ) { - throw new Error("GET /v1/managed-profiles must return the required manager-scoped policy contract."); + throw new Error("GET /v1/managed-profiles must return actor and per-child membership/owner-policy, not a singular manager."); +} + +function assertRequiredFields(name: string, fields: string[]): void { + const schema = schemas[name] as JsonObject; + if ( + !schema || + JSON.stringify(Object.keys((schema.properties ?? {}) as JsonObject).sort()) !== JSON.stringify([...fields].sort()) || + JSON.stringify([...((schema.required as string[]) ?? [])].sort()) !== JSON.stringify([...fields].sort()) + ) { + throw new Error(`${name} must declare exactly the implemented required fields: ${fields.join(", ")}.`); + } +} + +assertRequiredFields("ManagedProfileActor", ["profileId", "canProvisionManagedProfiles", "hasMemberships"]); +const actorProperties = (schemas.ManagedProfileActor as JsonObject).properties as JsonObject; +if ( + (actorProperties.profileId as JsonObject).format !== "uuid" || + (actorProperties.canProvisionManagedProfiles as JsonObject).type !== "boolean" || + (actorProperties.hasMemberships as JsonObject).type !== "boolean" || + !String((actorProperties.hasMemberships as JsonObject).description).includes("unpaginated eligible active-child count") || + !String((actorProperties.hasMemberships as JsonObject).description).includes( + "Excludes deleted children, inactive owners and invalid entity layouts" + ) +) { + throw new Error("ManagedProfileActor must expose independent provisioning and eligible active-membership flags."); +} +assertRequiredFields("ManagedProfileAccessResponse", ["actor", "managedProfile"]); +assertRequiredFields("ManagedProfileMember", ["id", "memberProfileId", "role", "isOwner", "createdAt", "updatedAt"]); +assertRequiredFields("ManagedProfileMemberResponse", ["member"]); +assertRequiredFields("ListManagedProfileMembersResponse", ["members", "pagination"]); +assertRequiredFields("CreateManagedProfileInvitationRequest", ["email", "role"]); +assertRequiredFields("ChangeManagedProfileMemberRequest", ["role"]); +assertRequiredFields("ManagedProfileInvitation", [ + "id", + "managedProfileId", + "invitedByProfileId", + "email", + "role", + "status", + "createdAt", + "expiresAt", + "acceptedAt", + "cancelledAt", + "expiredAt" +]); +assertRequiredFields("ManagedProfileInvitationResponse", ["invitation"]); +assertRequiredFields("ListManagedProfileInvitationsResponse", ["invitations", "pagination"]); +assertRequiredFields("PreviewManagedProfileInvitationResponse", ["invitation", "inviter", "managedProfile"]); +assertRequiredFields("AcceptManagedProfileInvitationResponse", ["managedProfileId", "member"]); +assertRequiredFields("ManagedProfileMemberEvent", [ + "id", + "action", + "actorProfileId", + "memberProfileId", + "invitationId", + "previousRole", + "role", + "createdAt" +]); +assertRequiredFields("ListManagedProfileMemberEventsResponse", ["events", "pagination"]); +if ( + JSON.stringify((schemas.ManagedProfileMembershipRole as JsonObject).enum) !== JSON.stringify(["manager", "read_only"]) || + !schemaHasProperty(schemas.ManagedProfileAccess, "membership") || + !schemaHasProperty(schemas.ManagedProfileAccess, "policy") || + schemaHasProperty(schemas.ManagedProfileResponse, "actor") || + schemaHasProperty(schemas.ManagedProfile, "membership") || + !transitivelyReferences( + operationAt("/v1/managed-profiles/{profileId}", "get").responses, + "#/components/schemas/ManagedProfileAccessResponse" + ) +) { + throw new Error("Managed lifecycle must distinguish undecorated create from actor/membership/policy list and read."); +} + +const detailManagedProfile = operationAt("/v1/managed-profiles/{profileId}", "get"); +const detailResponseProperties = (schemas.ManagedProfileAccessResponse as JsonObject).properties as JsonObject; +if ( + !transitivelyReferences(detailResponseProperties.actor, "#/components/schemas/ManagedProfileActor") || + !collectRefs(detailManagedProfile.parameters).includes("#/components/parameters/ManagedProfileId") +) { + throw new Error("Managed detail must return the shared actor projection and advertise explicit selector bootstrap."); +} +for (const [value, statements] of [ + [ + listManagedProfiles.description, + [ + "returns 200 with an empty list even when both actor flags are false", + "Both status=deleted and status=all require the actor's own active manager configuration", + "owner-scoped only", + "even active invited children in status=all" + ] + ], + [ + detailManagedProfile.description, + [ + "Bootstrap is explicitly GET detail with an exactly matching X-Managed-Profile-Id", + "Stored membership history (active or revoked) is required", + "A deleted child invalidates even the owner's bootstrap", + "same masked 404 MANAGED_PROFILE_NOT_FOUND for an existing or unknown child", + "Retained deleted-child reads are allowed only to the immutable owner", + "no selector", + "both follow the same bootstrap/history and retained-read checks" + ] + ], + [(listManagedProfiles.responses as JsonObject)["403"], ["MANAGED_PROFILE_OWNER_REQUIRED"]], + [ + (operationAt("/v1/managed-profiles/{profileId}", "delete").responses as JsonObject)["403"], + ["MANAGED_PROFILE_OWNER_REQUIRED"] + ] +] as const) { + for (const statement of statements) { + if (!JSON.stringify(value).includes(statement)) throw new Error(`Managed lifecycle must document: ${statement}`); + } +} +const listSuccess = (listManagedProfiles.responses as JsonObject)["200"] as JsonObject; +const listSuccessMedia = (listSuccess.content as JsonObject)["application/json"] as JsonObject; +const listExamples = listSuccessMedia.examples as JsonObject; +for (const [name, canProvision, hasMemberships, total] of [ + ["noEligibleMemberships", false, false, 0], + ["ownerBeforeProvisioning", true, false, 0], + ["pageBeyondResults", false, true, 1] +] as const) { + const value = (listExamples?.[name] as JsonObject)?.value as JsonObject; + const actor = value?.actor as JsonObject; + if ( + actor?.canProvisionManagedProfiles !== canProvision || + actor?.hasMemberships !== hasMemberships || + (value?.pagination as JsonObject)?.total !== total || + JSON.stringify(value?.managedProfiles) !== "[]" + ) { + throw new Error(`Managed list example ${name} must preserve independent actor flags on empty pages.`); + } +} + +for (const [path, method, responseSchema, statuses] of MEMBERSHIP_OPERATIONS) { + const operation = operationAt(path, method); + const responses = operation.responses as JsonObject; + if ( + JSON.stringify(operation.security) !== JSON.stringify([{ BearerAuth: [] }]) || + JSON.stringify(Object.keys(responses).sort()) !== JSON.stringify([...statuses, "413"].sort()) || + (responseSchema && !transitivelyReferences(responses["200"], `#/components/schemas/${responseSchema}`)) || + !transitivelyReferences(responses["400"], "#/components/schemas/MalformedJsonErrorResponse") || + !transitivelyReferences(responses["413"], "#/components/schemas/PayloadTooLargeErrorResponse") || + !transitivelyReferences(responses["401"], "#/components/schemas/FlatErrorResponse") || + !transitivelyReferences(responses["503"], "#/components/schemas/FlatErrorResponse") || + !transitivelyReferences(responses["403"], "#/components/schemas/ManagedProfileErrorResponse") + ) { + throw new Error(`${method.toUpperCase()} ${path} must preserve bearer-only auth, exact statuses and response shapes.`); + } + const invitee = path.startsWith("/v1/managed-profile-member-invitations/"); + const parameters = operation.parameters as JsonObject[]; + if ( + parameters.some(p => p.$ref === "#/components/parameters/ManagedProfileId") || + (!invitee && !parameters.some(p => p.$ref === "#/components/parameters/MembershipSelection")) || + (invitee && parameters.some(p => p.$ref === "#/components/parameters/MembershipSelection")) || + ((invitee || method === "get" || method === "delete") && operation.requestBody) + ) { + throw new Error(`${method.toUpperCase()} ${path} must not advertise request-email acceptance or secret-key selection.`); + } + if ("204" in responses && "content" in (responses["204"] as JsonObject)) { + throw new Error(`${method.toUpperCase()} ${path} 204 must have no body.`); + } +} +const invitationCreate = operationAt("/v1/managed-profiles/{profileId}/member-invitations", "post"); +if ( + !transitivelyReferences(invitationCreate.requestBody, "#/components/schemas/CreateManagedProfileInvitationRequest") || + !transitivelyReferences( + (invitationCreate.responses as JsonObject)["201"], + "#/components/schemas/ManagedProfileInvitationResponse" + ) || + !transitivelyReferences( + operationAt("/v1/managed-profiles/{profileId}/members/{memberProfileId}", "patch").requestBody, + "#/components/schemas/ChangeManagedProfileMemberRequest" + ) +) { + throw new Error("Membership mutation request and idempotent invitation-create response schemas must match the controller."); +} + +const invitationProperties = (schemas.ManagedProfileInvitation as JsonObject).properties as JsonObject; +const eventProperties = (schemas.ManagedProfileMemberEvent as JsonObject).properties as JsonObject; +const eventPagination = ((schemas.ListManagedProfileMemberEventsResponse as JsonObject).properties as JsonObject) + .pagination as JsonObject; +if ( + JSON.stringify((invitationProperties.status as JsonObject).enum) !== + JSON.stringify(["pending", "accepted", "cancelled", "expired"]) || + JSON.stringify((eventProperties.action as JsonObject).enum) !== + JSON.stringify([ + "member_added", + "invited", + "invitation_cancelled", + "invitation_expired", + "invitation_accepted", + "role_changed", + "member_removed" + ]) || + JSON.stringify(eventPagination.required) !== JSON.stringify(["limit", "nextCursor"]) || + JSON.stringify(((eventPagination.properties as JsonObject).nextCursor as JsonObject).type) !== + JSON.stringify(["string", "null"]) +) { + throw new Error("Membership invitation states, access-event actions, and nullable cursor must match the service."); +} +for (const field of ["acceptedAt", "cancelledAt", "expiredAt"]) { + if (JSON.stringify((invitationProperties[field] as JsonObject).type) !== JSON.stringify(["string", "null"])) { + throw new Error(`ManagedProfileInvitation.${field} must be nullable.`); + } +} +const membershipParameters = (openapi.components as JsonObject).parameters as JsonObject; +if ( + JSON.stringify((membershipParameters.MembershipLimit as JsonObject).schema) !== + JSON.stringify({ default: 50, maximum: 100, minimum: 1, type: "integer" }) || + JSON.stringify((membershipParameters.MembershipOffset as JsonObject).schema) !== + JSON.stringify({ default: 0, minimum: 0, type: "integer" }) +) { + throw new Error("Membership offset pagination must retain controller defaults and bounds."); } const createCredential = operationAt("/v1/managed-profiles/{profileId}/api-credentials", "post"); @@ -589,6 +865,42 @@ if ( } const managedProfileHeaderRef = "#/components/parameters/ManagedProfileId"; +const credentialManagedOperations = [ + ["/v1/brl/createSubaccount", "post"], + ["/v1/brl/getSelfieLivenessUrl", "get"], + ["/v1/brl/getUploadUrls", "post"], + ["/v1/brl/kyb/documents", "post"], + ["/v1/brl/kyb/new-level-1/api", "post"], + ["/v1/brl/kyb/new-level-1/web-sdk", "post"], + ["/v1/brl/kyb/ubos", "post"], + ["/v1/brl/kyc/import-token", "post"], + ["/v1/brl/kyc/record-attempt", "post"], + ["/v1/brl/newKyc", "post"], + ...ALFREDPAY_OPERATIONS.filter( + ([path, method]) => !path.includes("/fiatAccounts") && (method === "post" || path.endsWith("RedirectLink")) + ) +]; +for (const [path, method] of [...DELEGATED_OPERATIONS, ["/v1/brl/kyc/import-token", "post"]]) { + const operation = operationAt(path, method); + const expected = credentialManagedOperations.some(([p, m]) => p === path && m === method) + ? "credential_manage" + : path.includes("/fiatAccounts") && method !== "get" + ? "manage" + : path.startsWith("/v1/ramp/") && method === "post" + ? "ramp" + : "read"; + if (operation["x-managed-profile-capability"] !== expected || !String(operation.description).includes(`(${expected})`)) { + throw new Error(`${method.toUpperCase()} ${path} must document managed capability ${expected}.`); + } + if ( + (expected === "credential_manage" && !String(operation.description).includes("MANAGED_PROFILE_REQUIRES_API_CREDENTIAL")) || + (expected === "ramp" && + (!String(operation.description).includes("MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL") || + !String(operation.description).includes("no drain exception"))) + ) { + throw new Error(`${method.toUpperCase()} ${path} must document the selected-child secret-only restriction.`); + } +} if (!pointerExists(openapi, managedProfileHeaderRef)) { throw new Error(`OpenAPI file is missing reusable managed-profile header: ${managedProfileHeaderRef}`); } From 44d0a380000bad763a091d6352435941cf49ef40 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 7 Sep 2026 17:20:19 -0300 Subject: [PATCH 07/11] feat(dashboard): apply managed profile roles and child key controls --- apps/dashboard/e2e/api-keys.spec.ts | 22 ++ apps/dashboard/e2e/managed-profiles.spec.ts | 313 +++++++++++++++++- apps/dashboard/e2e/support/mockBackend.ts | 225 ++++++++++++- .../components/admin/admin-account-ui.test.ts | 2 + .../src/components/admin/admin-account-ui.ts | 2 + .../api-keys/ApiCredentialsTable.tsx | 15 +- .../api-keys/CreateApiCredentialDialog.tsx | 12 +- .../src/components/layout/AppSidebar.tsx | 12 +- .../layout/ManagedProfileBanner.tsx | 11 +- .../managed-profiles/ManagedProfilesList.tsx | 27 +- .../managed-profile-ui.test.ts | 27 +- .../managed-profiles/managed-profile-ui.ts | 10 +- .../components/onboarding/CorridorCard.tsx | 10 +- .../onboarding/PayoutAccountsSection.tsx | 30 +- .../alfredpay/FiatAccountDialog.tsx | 59 ++-- .../src/components/quote/QuoteExplorer.tsx | 11 + .../recipients/RecipientActionsDialog.tsx | 30 +- .../components/recipients/RecipientsTable.tsx | 20 +- apps/dashboard/src/domain/api-credentials.ts | 3 + apps/dashboard/src/hooks/useApiCredentials.ts | 18 +- .../src/hooks/useManagedProfileBootstrap.ts | 44 +++ .../src/hooks/useManagedProfiles.test.ts | 13 +- .../dashboard/src/hooks/useManagedProfiles.ts | 7 +- apps/dashboard/src/router.tsx | 9 - apps/dashboard/src/routes/_app.tsx | 2 + apps/dashboard/src/routes/_app/api-keys.tsx | 31 +- .../src/routes/_app/managed-profiles.tsx | 16 +- apps/dashboard/src/routes/_app/overview.tsx | 2 + apps/dashboard/src/routes/_app/recipients.tsx | 15 +- .../src/routes/_app/transactions.tsx | 5 +- apps/dashboard/src/routes/_app/transfer.tsx | 6 +- .../src/services/api/api-client.test.ts | 32 +- apps/dashboard/src/services/api/api-client.ts | 13 - .../services/api/api-credentials.service.ts | 19 +- .../src/services/api/avenia.service.test.ts | 2 + ...anaged-profile-memberships.service.test.ts | 146 ++++++++ .../managed-profile-memberships.service.ts | 131 ++++++++ .../services/api/managed-profiles.service.ts | 32 +- apps/dashboard/src/services/auth.test.ts | 25 ++ apps/dashboard/src/services/auth.ts | 11 +- .../src/stores/managed-profile.store.test.ts | 83 ++++- .../src/stores/managed-profile.store.ts | 24 ++ 42 files changed, 1350 insertions(+), 177 deletions(-) create mode 100644 apps/dashboard/src/hooks/useManagedProfileBootstrap.ts create mode 100644 apps/dashboard/src/services/api/managed-profile-memberships.service.test.ts create mode 100644 apps/dashboard/src/services/api/managed-profile-memberships.service.ts diff --git a/apps/dashboard/e2e/api-keys.spec.ts b/apps/dashboard/e2e/api-keys.spec.ts index 3e927a954..1a9a83c8e 100644 --- a/apps/dashboard/e2e/api-keys.spec.ts +++ b/apps/dashboard/e2e/api-keys.spec.ts @@ -48,3 +48,25 @@ test("API keys are available without an active sender entity", async ({ page }) await expect(page.getByRole("heading", { name: "API keys" })).toBeVisible(); await expect(page.getByText("No API credentials yet")).toBeVisible(); }); + +test("API keys remain listable but cannot be changed during impersonation", async ({ page }) => { + await mockBackend(page); + await seedSession(page); + await page.addInitScript(() => { + localStorage.setItem( + "vortex_dashboard_impersonation_session", + JSON.stringify({ + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + sessionId: "impersonation-api-keys", + targetEmail: "target@example.test", + targetProfileId: "target-profile", + token: "vtx_imp_api_keys" + }) + ); + }); + await page.goto("/api-keys"); + + await expect(page.getByText("Credentials are read-only during impersonation.")).toBeVisible(); + await expect(page.getByText("No API credentials yet")).toBeVisible(); + await expect(page.getByRole("button", { name: "Create credential" })).toHaveCount(0); +}); diff --git a/apps/dashboard/e2e/managed-profiles.spec.ts b/apps/dashboard/e2e/managed-profiles.spec.ts index 838f78910..a0dbeb23b 100644 --- a/apps/dashboard/e2e/managed-profiles.spec.ts +++ b/apps/dashboard/e2e/managed-profiles.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; import { E2E_MANAGED_PROFILE_ID, mockBackend } from "./support/mockBackend"; -import { seedSession } from "./support/session"; +import { E2E_USER_ID, seedSession } from "./support/session"; const CHILD_EMAIL = "managed-child-with-a-long-identifier@example.test"; const CHILD_EXTERNAL_ID = `customer-${"long-identifier-".repeat(8)}`; @@ -8,14 +8,26 @@ const CHILD = { contactEmail: CHILD_EMAIL, customerType: "individual" as const, externalSubjectId: CHILD_EXTERNAL_ID, - profileId: E2E_MANAGED_PROFILE_ID + membership: { isOwner: true, role: "manager" as const }, + policy: { allowedCorridors: ["MX" as const], allowedCustomerTypes: null }, + profileId: E2E_MANAGED_PROFILE_ID, + status: "active" as const }; test("ordinary users cannot navigate to managed profiles", async ({ page }) => { const backend = await mockBackend(page); await seedSession(page); + const listResponse = page.waitForResponse(response => new URL(response.url()).pathname === "/v1/managed-profiles"); await page.goto("/overview"); + const response = await listResponse; + expect(response.status()).toBe(200); + expect((await response.json()).actor).toEqual({ + canProvisionManagedProfiles: false, + hasMemberships: false, + profileId: E2E_USER_ID + }); + await expect(page.getByRole("heading", { name: "Onboarding" })).toBeVisible(); await expect(page.getByRole("link", { name: "Managed profiles" })).toHaveCount(0); @@ -42,6 +54,8 @@ test("a manager selects and stops acting for a managed profile", async ({ page } await expect(page).toHaveURL(/\/overview$/); await expect(page.getByText(`Acting for ${CHILD_EMAIL}`)).toBeVisible(); + await expect(page.getByText("Manager", { exact: true })).toBeVisible(); + await expect(page.getByText("Owner", { exact: true })).toBeVisible(); await expect(page.getByText("KYC/KYB is read-only while acting for another profile.")).toBeVisible(); await expect(page.getByRole("button", { name: "KYC is read-only while acting" })).toBeDisabled(); await page.goto("/overview?onboarding=MX"); @@ -49,23 +63,59 @@ test("a manager selects and stops acting for a managed profile", async ({ page } await page.reload(); await expect(page.getByText(`Acting for ${CHILD_EMAIL}`)).toBeVisible(); - await expect(page.getByRole("link", { name: "API keys" })).toHaveCount(0); + await expect(page.getByRole("link", { name: "API keys" })).toBeVisible(); + await expect(page.getByRole("link", { name: "New transfer" })).toHaveCount(0); await expect(page.getByRole("link", { name: "Settings" })).toHaveCount(0); await expect(page.getByRole("link", { name: "Admin" })).toHaveCount(0); await expect(page.getByRole("link", { name: "Managed profiles" })).toHaveCount(0); - await page.waitForLoadState("networkidle"); - const apiCredentialRequestCount = backend.apiRequests.filter(request => request.path === "/v1/api-credentials").length; await page.goto("/api-keys"); + await expect(page.getByRole("heading", { name: "API keys" })).toBeVisible(); + await expect( + page.getByText(/shared company principal for supported provider, fiat-account, quote, and ramp operations/) + ).toBeVisible(); + await expect(page.getByText(/remain valid after a human member is removed or downgraded/)).toBeVisible(); + await expect(page.getByRole("button", { name: "Create credential" })).toBeVisible(); + await expect(page.getByText("No API credentials yet")).toBeVisible(); + await page.getByRole("button", { name: "Create credential" }).click(); + const createCredentialDialog = page.getByRole("dialog"); + await expect( + createCredentialDialog.getByText( + /shared company principal for supported provider, fiat-account, quote, and ramp operations/ + ) + ).toBeVisible(); + await expect(createCredentialDialog.getByText(/remain valid after a human member is removed or downgraded/)).toBeVisible(); + await createCredentialDialog.getByLabel("Name").fill("Child backend"); + await createCredentialDialog.getByRole("button", { name: "Create credential" }).click(); + await createCredentialDialog.getByLabel("I saved the secret key").click(); + await createCredentialDialog.getByRole("button", { name: "Done" }).click(); + await page.getByRole("button", { name: "Revoke Child backend" }).click(); + await expect( + page + .getByRole("dialog") + .getByText(/shared company principal for supported provider, fiat-account, quote, and ramp operations/) + ).toBeVisible(); + await expect(page.getByRole("dialog").getByText(/remain valid after a human member is removed or downgraded/)).toBeVisible(); + await page.getByRole("dialog").getByRole("button", { name: "Revoke credential" }).click(); + expect( + backend.apiRequests.some( + request => request.path === `/v1/managed-profiles/${E2E_MANAGED_PROFILE_ID}/api-credentials` && request.method === "GET" + ) + ).toBe(true); + expect(backend.apiCredentialRequests.map(request => `${request.method} ${request.path}`)).toEqual([ + `POST /v1/managed-profiles/${E2E_MANAGED_PROFILE_ID}/api-credentials`, + `DELETE /v1/managed-profiles/${E2E_MANAGED_PROFILE_ID}/api-credentials/credential-e2e-1` + ]); + + await page.goto("/transfer"); await expect(page).toHaveURL(/\/overview$/); - await expect(page.getByRole("heading", { name: "API keys" })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "New transfer" })).toHaveCount(0); const delegatedStatuses = backend.apiRequests.filter(request => request.path === "/v1/onboarding/status"); expect(delegatedStatuses.some(request => request.managedProfileId === E2E_MANAGED_PROFILE_ID)).toBe(true); const lifecycleRequests = backend.apiRequests.filter(request => request.path === "/v1/managed-profiles"); expect(lifecycleRequests.length).toBeGreaterThan(0); expect(lifecycleRequests.every(request => request.managedProfileId === undefined)).toBe(true); - expect(backend.apiRequests.filter(request => request.path === "/v1/api-credentials")).toHaveLength(apiCredentialRequestCount); await page.getByRole("button", { name: "Stop acting" }).click(); await expect(page).toHaveURL(/\/managed-profiles$/); @@ -74,6 +124,167 @@ test("a manager selects and stops acting for a managed profile", async ({ page } expect(backend.unexpectedExternalRequests).toEqual([]); }); +test("read-only child membership keeps reads but removes mutations and transfer entry points", async ({ page }) => { + const backend = await mockBackend(page, { + managedProfiles: [ + { + ...CHILD, + membership: { isOwner: false, role: "read_only" } + } + ], + pendingInvitations: [ + { + alias: "Read-only recipient", + country: "MX", + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + id: "readonly-invite", + inviteeEmail: null, + inviteeType: "individual", + isExpired: false, + payoutCurrency: "mxn", + rail: "mxn", + token: "readonly-token" + } + ] + }); + await seedSession(page); + await page.goto("/managed-profiles"); + await page.getByRole("button", { name: `Actions for ${CHILD_EMAIL}` }).click(); + await page.getByRole("menuitem", { name: "Act for this profile" }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Act for this profile" }).click(); + + await expect(page.getByText("Read only", { exact: true })).toBeVisible(); + await expect(page.getByText("Owner", { exact: true })).toHaveCount(0); + await expect(page.getByRole("link", { name: "New transfer" })).toHaveCount(0); + + await page.goto("/api-keys"); + await expect(page.getByText("Credentials are read-only for this membership.")).toBeVisible(); + await expect( + page.getByText(/shared company principal for supported provider, fiat-account, quote, and ramp operations/) + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Create credential" })).toHaveCount(0); + + await page.goto("/recipients"); + await expect(page.getByText("Recipient management is read-only for this membership.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Add recipient" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Create transfer" })).toHaveCount(0); + await page.getByRole("cell", { name: "Read-only recipient" }).click(); + await expect(page.getByRole("button", { name: "Remove from list" })).toHaveCount(0); + await page.keyboard.press("Escape"); + + await page.goto("/quote"); + await page.getByRole("tab", { name: "Sell crypto" }).click(); + await page.getByLabel("Fiat currency").click(); + await page.getByRole("option", { name: /MXN/ }).click(); + await page.getByLabel("You pay").fill("10"); + await expect(page.getByText("New transfers are unavailable while acting for a managed profile.")).toBeVisible({ + timeout: 20_000 + }); + await expect(page.getByRole("link", { name: "Continue to transfer" })).toHaveCount(0); + + await page.goto("/transactions"); + await expect(page.getByRole("link", { name: /Start a transfer|Start a pay-in/ })).toHaveCount(0); + await page.goto("/transfer"); + await expect(page).toHaveURL(/\/overview$/); + + const card = page.getByTestId("corridor-card-MX"); + await card.getByRole("button", { name: "View pay-out accounts" }).click(); + await expect(page.getByRole("button", { name: /Remove account|Add another account/ })).toHaveCount(0); + expect(backend.archiveInvitationRequests).toEqual([]); + expect(backend.unmatchedRequests).toEqual([]); +}); + +test("open recipient and payout controls close when the membership is downgraded", async ({ page }) => { + const child = { + ...CHILD, + membership: { isOwner: true, role: "manager" as "manager" | "read_only" } + }; + const backend = await mockBackend(page, { + managedProfiles: [child], + pendingInvitations: [ + { + alias: "Downgrade recipient", + country: "MX", + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + id: "downgrade-invite", + inviteeEmail: null, + inviteeType: "individual", + isExpired: false, + payoutCurrency: "mxn", + rail: "mxn", + token: "downgrade-token" + } + ] + }); + await seedSession(page); + await page.goto("/managed-profiles"); + await page.getByRole("button", { name: `Actions for ${CHILD_EMAIL}` }).click(); + await page.getByRole("menuitem", { name: "Act for this profile" }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Act for this profile" }).click(); + + async function refreshMembership() { + const previousReads = backend.apiRequests.filter( + request => request.path === `/v1/managed-profiles/${E2E_MANAGED_PROFILE_ID}` + ).length; + await page.evaluate(() => window.dispatchEvent(new Event("visibilitychange"))); + await expect + .poll( + () => backend.apiRequests.filter(request => request.path === `/v1/managed-profiles/${E2E_MANAGED_PROFILE_ID}`).length + ) + .toBeGreaterThan(previousReads); + } + + await page.goto("/recipients"); + await page.getByRole("cell", { name: "Downgrade recipient" }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + child.membership = { isOwner: false, role: "read_only" }; + await refreshMembership(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByText("Recipient management is read-only for this membership.")).toBeVisible(); + + child.membership = { isOwner: true, role: "manager" }; + await refreshMembership(); + await page.goto("/overview"); + await page.getByTestId("corridor-card-MX").getByRole("button", { name: "View pay-out accounts" }).click(); + await page.getByRole("button", { name: "Add another account" }).click(); + await expect(page.getByRole("heading", { name: "Add pay-out account" })).toBeVisible(); + child.membership = { isOwner: false, role: "read_only" }; + await refreshMembership(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByText("Read only", { exact: true })).toBeVisible(); + expect(backend.archiveInvitationRequests).toEqual([]); + expect(backend.fiatAccountRequests).toEqual([]); + expect(backend.unmatchedRequests).toEqual([]); +}); + +test("only the child bootstrap membership-invalid response clears persisted child mode", async ({ page }) => { + await mockBackend(page, { previousManagedProfileIds: [E2E_MANAGED_PROFILE_ID] }); + await seedSession(page); + await page.addInitScript( + ({ managedProfileId, managerProfileId }) => { + localStorage.setItem( + "vortex_dashboard_managed_profile_selection", + JSON.stringify({ + customerType: "individual", + externalSubjectId: "removed-child", + isOwner: false, + managerProfileId, + membershipRole: "read_only", + targetEmail: "removed@example.test", + targetProfileId: managedProfileId + }) + ); + }, + { managedProfileId: E2E_MANAGED_PROFILE_ID, managerProfileId: E2E_USER_ID } + ); + + await page.goto("/overview"); + await expect.poll(() => page.evaluate(() => localStorage.getItem("vortex_dashboard_managed_profile_selection"))).toBeNull(); + await expect(page.getByText("Acting for removed@example.test")).toHaveCount(0); +}); + test("admin impersonation keeps verification status visible but blocks onboarding deep links", async ({ page }) => { await mockBackend(page, { onboardingState: "started" }); await seedSession(page); @@ -119,3 +330,91 @@ test("long managed identifiers and the acting banner fit a mobile viewport", asy expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth); await expect(page.getByRole("button", { name: "Stop acting" })).toBeVisible(); }); + +for (const mobile of [false, true]) { + for (const hasMemberships of [false, true]) { + test(`provisioning actor retains navigation with ${hasMemberships ? "active" : "no"} memberships (${mobile ? "mobile" : "desktop"})`, async ({ + page + }) => { + if (mobile) await page.setViewportSize({ height: 844, width: 390 }); + const backend = await mockBackend(page, { + canProvisionManagedProfiles: true, + managedProfiles: hasMemberships ? [CHILD] : [] + }); + await seedSession(page); + await page.goto("/managed-profiles"); + await expect(page.getByRole("heading", { name: "Managed profiles" })).toBeVisible(); + if (!hasMemberships) { + await expect(page.getByText("No managed profiles", { exact: true })).toBeVisible(); + await expect(page.getByText(/You can provision managed profiles through the API/)).toBeVisible(); + } + if (mobile) await page.getByRole("button", { name: "Toggle Sidebar" }).first().click(); + await expect(page.getByRole("link", { exact: true, name: "Managed profiles" })).toBeVisible(); + expect(backend.unmatchedRequests).toEqual([]); + }); + } +} + +test("membership navigation survives an empty paginated page", async ({ page }) => { + const profiles = Array.from({ length: 21 }, (_, index) => ({ + ...CHILD, + contactEmail: `child-${index}@example.test`, + membership: { isOwner: false, role: "read_only" as const }, + profileId: `child-${index}` + })); + const backend = await mockBackend(page, { managedProfiles: profiles }); + await seedSession(page); + await page.goto("/managed-profiles"); + await page.getByRole("button", { exact: true, name: "Next" }).click(); + await expect(page.getByRole("cell", { exact: true, name: "child-20@example.test" })).toBeVisible(); + profiles.splice(20, 1); + const listResponse = page.waitForResponse(response => { + const url = new URL(response.url()); + return url.pathname === "/v1/managed-profiles" && url.searchParams.get("offset") === "20"; + }); + await page.evaluate(() => window.dispatchEvent(new Event("visibilitychange"))); + const result = await (await listResponse).json(); + expect(result.managedProfiles).toEqual([]); + expect(result.actor).toEqual({ canProvisionManagedProfiles: false, hasMemberships: true, profileId: E2E_USER_ID }); + await expect(page.getByText("No managed profiles", { exact: true })).toBeVisible(); + await expect(page.getByRole("link", { exact: true, name: "Managed profiles" })).toBeVisible(); + await expect(page).toHaveURL(/\/managed-profiles$/); + await page.getByRole("button", { exact: true, name: "Previous" }).click(); + await expect(page.getByRole("cell", { exact: true, name: "child-0@example.test" })).toBeVisible(); + expect(backend.unmatchedRequests).toEqual([]); +}); + +test("own provisioning access is independent of an inactive controlling owner", async ({ page }) => { + const backend = await mockBackend(page, { + canProvisionManagedProfiles: true, + managedProfileOwnerActive: false, + managedProfiles: [{ ...CHILD, membership: { isOwner: false, role: "read_only" } }] + }); + await seedSession(page); + const listResponse = page.waitForResponse(response => new URL(response.url()).pathname === "/v1/managed-profiles"); + await page.goto("/managed-profiles"); + expect((await (await listResponse).json()).actor).toEqual({ + canProvisionManagedProfiles: true, + hasMemberships: false, + profileId: E2E_USER_ID + }); + await expect(page.getByText("No managed profiles", { exact: true })).toBeVisible(); + await expect(page.getByRole("link", { exact: true, name: "Managed profiles" })).toBeVisible(); + expect(backend.unmatchedRequests).toEqual([]); +}); + +test("list errors remain retryable UI rather than ordinary-user capability detection", async ({ page }) => { + await mockBackend(page, { canProvisionManagedProfiles: true }); + let fail = true; + await page.route("**/v1/managed-profiles?*", async route => { + if (fail) await route.fulfill({ json: { code: "MANAGED_PROFILE_ACCESS_DENIED", message: "Denied" }, status: 403 }); + else await route.fallback(); + }); + await seedSession(page); + await page.goto("/managed-profiles"); + await expect(page.getByText(/Could not load managed profiles/)).toBeVisible(); + await expect(page.getByRole("button", { name: "Retry profile access" })).toBeVisible(); + fail = false; + await page.getByRole("button", { exact: true, name: "Try again" }).click(); + await expect(page.getByText("No managed profiles", { exact: true })).toBeVisible(); +}); diff --git a/apps/dashboard/e2e/support/mockBackend.ts b/apps/dashboard/e2e/support/mockBackend.ts index 21280da49..cc9299b38 100644 --- a/apps/dashboard/e2e/support/mockBackend.ts +++ b/apps/dashboard/e2e/support/mockBackend.ts @@ -1,4 +1,10 @@ import type { Page } from "@playwright/test"; +import type { + InvitationPreview, + MemberEvent, + MemberInvitation, + TeamMember +} from "../../src/services/api/managed-profile-memberships.service"; import { MOCK_WALLET_ADDRESS } from "./mockWallet"; import { E2E_USER_ID } from "./session"; @@ -253,6 +259,12 @@ export function buildSellUnsignedTxs(evmEphemeral: string) { } interface MockBackendOptions { + team?: { members: TeamMember[]; invitations: MemberInvitation[]; events: MemberEvent[] }; + memberInvitation?: { + preview: InvitationPreview; + previewError?: { code: string; status: number }; + acceptError?: { code: string; status: number }; + }; apiCredentials?: Array>; approvedCorridors?: Array<"AR" | "BR" | "CO" | "MX" | "US">; limits?: Array>; @@ -276,12 +288,24 @@ interface MockBackendOptions { pendingInvitations?: Array>; // Capability roles returned on GET /v1/onboarding/status (default: none). roles?: string[]; - // Enables manager lifecycle access. The default 403 mirrors an ordinary dashboard user. + // The actor's own active manager configuration, independent of child membership. + canProvisionManagedProfiles?: boolean; + // Whether the fixtures' controlling owners are active, independent of the actor's own configuration. + managedProfileOwnerActive?: boolean; + // Stored membership history for children no longer in the active fixture roster. + previousManagedProfileIds?: string[]; + // Ordinary users receive 200 with no profiles and both navigation flags false. managedProfiles?: Array<{ contactEmail: string | null; customerType: "business" | "individual"; externalSubjectId: string; + membership: { isOwner: boolean; role: "manager" | "read_only" }; + policy: { + allowedCorridors: Array<"AR" | "BR" | "CO" | "EU" | "MX" | "US">; + allowedCustomerTypes: Array<"business" | "individual"> | null; + }; profileId: string; + status: "active" | "deleted"; }>; // Response for POST /v1/recipients/invite/:token/accept (default: an accepted MX individual invite). acceptInvite?: { status: number; body: Record }; @@ -397,6 +421,12 @@ function answerRpc(chainIdHex: string) { * changed default RPC URL fails the suite instead of silently reaching the network. */ export async function mockBackend(page: Page, options: MockBackendOptions = {}) { + const knownManagedProfileIds = new Set([ + ...(options.managedProfiles ?? []).map(profile => profile.profileId), + ...(options.previousManagedProfileIds ?? []) + ]); + const membershipRequests: Array<{ method: string; path: string; body: Record | null; search: string }> = []; + const team = options.team ?? { events: [], invitations: [], members: [] }; const apiRequests: Array<{ managedProfileId: string | undefined; method: string; path: string }> = []; const apiCredentialRequests: Array<{ body: Record | null; method: string; path: string }> = []; const limitsRequests: Array> = []; @@ -470,6 +500,14 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) apiRequests.push({ managedProfileId: request.headers()["x-managed-profile-id"], method, path }); const fulfillJson = (body: unknown, code = 200) => route.fulfill({ json: body as object, status: code }); + const eligibleProfiles = (options.managedProfiles ?? []).filter( + profile => profile.status === "active" && options.managedProfileOwnerActive !== false + ); + const managedProfileActor = { + canProvisionManagedProfiles: options.canProvisionManagedProfiles === true, + hasMemberships: eligibleProfiles.length > 0, + profileId: E2E_USER_ID + }; // Auth shapes mirror apps/api/src/api/controllers/auth.controller.ts: snake_case on the // wire, mapped to camelCase by src/services/api/auth.api.ts. @@ -503,17 +541,180 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) return; } - if (path === "/v1/managed-profiles" && method === "GET") { - if (!options.managedProfiles) { - await fulfillJson({ code: "MANAGED_PROFILE_ACCESS_DENIED", message: "Managed profile access denied" }, 403); + const teamPath = path.match(/^\/v1\/managed-profiles\/([^/]+)\/(members|member-invitations|member-events)(?:\/([^/]+))?$/); + const inviteePath = path.match(/^\/v1\/managed-profile-member-invitations\/([^/]+)(\/accept)?$/); + if (teamPath || inviteePath) { + const body = request.postData() ? (request.postDataJSON() as Record) : null; + membershipRequests.push({ body, method, path, search: url.search }); + if (!request.headers().authorization || request.headers().authorization.startsWith("Bearer vtx_imp_")) { + await fulfillJson({ error: { code: "MANAGED_PROFILE_ACCESS_DENIED", message: "A Supabase session is required" } }, 403); + return; + } + if (inviteePath) { + const fixture = options.memberInvitation; + const failure = method === "POST" ? fixture?.acceptError : fixture?.previewError; + if (failure || !fixture || request.headers()["x-managed-profile-id"]) { + await fulfillJson( + { error: { code: failure?.code ?? "MANAGED_PROFILE_ACCESS_DENIED", message: "Invitation unavailable" } }, + failure?.status ?? 403 + ); + return; + } + if (method === "GET") { + await fulfillJson(fixture.preview); + } else { + const invitation = fixture.preview.invitation; + if (invitation.status !== "pending") { + await fulfillJson( + { error: { code: `INVITATION_${invitation.status.toUpperCase()}`, message: "Invitation is no longer pending" } }, + 409 + ); + return; + } + invitation.status = "accepted"; + invitation.acceptedAt = new Date().toISOString(); + const member: TeamMember = { + createdAt: invitation.acceptedAt, + email: invitation.email, + id: "accepted-member", + isOwner: false, + memberProfileId: E2E_USER_ID, + role: invitation.role, + updatedAt: invitation.acceptedAt + }; + team.members.push(member); + await fulfillJson({ managedProfileId: invitation.managedProfileId, member }); + } + return; + } + if (teamPath) { + const [, profileId, resource, targetId] = teamPath; + const child = eligibleProfiles.find(profile => profile.profileId === profileId); + if (!child || (method !== "GET" && child.membership.role !== "manager")) { + await fulfillJson( + { error: { code: "MANAGED_PROFILE_ACCESS_DENIED", message: "Managed-profile access is denied" } }, + 403 + ); + return; + } + const limit = Number(url.searchParams.get("limit") ?? 50); + const offset = Number(url.searchParams.get("offset") ?? 0); + if (method === "GET") { + if (resource === "member-events") { + const cursor = url.searchParams.get("cursor"); + const start = cursor ? team.events.findIndex(event => event.id === cursor) + 1 : 0; + const events = team.events.slice(start, start + limit); + await fulfillJson({ + events, + pagination: { limit, nextCursor: team.events.length > start + limit ? events.at(-1)?.id : null } + }); + } else { + const rows = resource === "members" ? team.members : team.invitations; + await fulfillJson({ + [resource === "members" ? "members" : "invitations"]: rows.slice(offset, offset + limit), + pagination: { limit, offset, total: rows.length } + }); + } + return; + } + const now = new Date().toISOString(); + let action: MemberEvent["action"] = "invited"; + let role = body?.role === "manager" ? ("manager" as const) : ("read_only" as const); + let previousRole: MemberEvent["previousRole"] = null; + if (resource === "members") { + const index = team.members.findIndex(member => member.memberProfileId === targetId); + const member = team.members[index]; + if (!member || member.isOwner) { + await fulfillJson( + { error: { code: "MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED", message: "Owner access cannot be changed" } }, + 409 + ); + return; + } + previousRole = member.role; + if (method === "PATCH") { + action = "role_changed"; + member.role = role; + if (targetId === E2E_USER_ID) child.membership.role = role; + await fulfillJson({ member }); + } else { + action = "member_removed"; + role = member.role; + team.members.splice(index, 1); + if (targetId === E2E_USER_ID) options.managedProfiles?.splice(options.managedProfiles.indexOf(child), 1); + await route.fulfill({ status: 204 }); + } + } else if (method === "POST") { + const invitation: MemberInvitation = { + acceptedAt: null, + cancelledAt: null, + createdAt: now, + email: String(body?.email).trim().toLowerCase(), + expiredAt: null, + expiresAt: new Date(Date.now() + 7 * 86400000).toISOString(), + id: `team-invitation-${team.invitations.length + 1}`, + invitedByProfileId: E2E_USER_ID, + managedProfileId: profileId, + role, + status: "pending" + }; + team.invitations.unshift(invitation); + await fulfillJson({ invitation }, 201); + } else { + const invitation = team.invitations.find(item => item.id === targetId); + if (!invitation || invitation.status !== "pending") { + await fulfillJson({ error: { code: "INVITATION_CANCELLED", message: "Invitation is no longer pending" } }, 409); + return; + } + invitation.status = "cancelled"; + invitation.cancelledAt = now; + action = "invitation_cancelled"; + role = invitation.role; + await route.fulfill({ status: 204 }); + } + team.events.unshift({ + action, + actorProfileId: E2E_USER_ID, + createdAt: now, + id: `team-event-${team.events.length + 1}`, + invitationId: resource === "member-invitations" ? (targetId ?? team.invitations[0].id) : null, + memberProfileId: resource === "members" ? targetId : null, + previousRole, + role + }); return; } + } + + if (/^\/v1\/managed-profiles\/[^/]+$/.test(path) && method === "GET") { + const profileId = path.split("/").at(-1); + const managedProfile = eligibleProfiles.find(profile => profile.profileId === profileId); + if (!managedProfile) { + const bootstrap = request.headers()["x-managed-profile-id"] === profileId; + const relationship = options.managedProfiles?.find(profile => profile.profileId === profileId); + const code = + bootstrap && knownManagedProfileIds.has(profileId ?? "") + ? "MANAGED_PROFILE_MEMBERSHIP_INVALID" + : relationship?.status === "active" + ? "MANAGED_PROFILE_ACCESS_DENIED" + : "MANAGED_PROFILE_NOT_FOUND"; + await fulfillJson( + { error: { code, message: "Managed profile is unavailable" } }, + code === "MANAGED_PROFILE_NOT_FOUND" ? 404 : 403 + ); + return; + } + await fulfillJson({ actor: managedProfileActor, managedProfile }); + return; + } + + if (path === "/v1/managed-profiles" && method === "GET") { const limit = Number(url.searchParams.get("limit") ?? 20); const offset = Number(url.searchParams.get("offset") ?? 0); await fulfillJson({ - managedProfiles: options.managedProfiles.slice(offset, offset + limit), - manager: { allowedCorridors: ["MX"], allowedCustomerTypes: null, profileId: E2E_USER_ID }, - pagination: { limit, offset, total: options.managedProfiles.length } + actor: managedProfileActor, + managedProfiles: eligibleProfiles.slice(offset, offset + limit), + pagination: { limit, offset, total: eligibleProfiles.length } }); return; } @@ -611,12 +812,14 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) return; } - if (path === "/v1/api-credentials" && method === "GET") { + const isChildCredentialPath = /^\/v1\/managed-profiles\/[^/]+\/api-credentials(?:\/[^/]+)?$/.test(path); + + if ((path === "/v1/api-credentials" || isChildCredentialPath) && method === "GET") { await fulfillJson({ credentials: apiCredentials }); return; } - if (path === "/v1/api-credentials" && method === "POST") { + if ((path === "/v1/api-credentials" || isChildCredentialPath) && method === "POST") { const body = request.postDataJSON() as Record; apiCredentialRequests.push({ body, method, path }); const credentialId = `credential-e2e-${apiCredentialRequests.length}`; @@ -645,7 +848,7 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) return; } - if (path.startsWith("/v1/api-credentials/") && method === "DELETE") { + if ((path.startsWith("/v1/api-credentials/") || isChildCredentialPath) && method === "DELETE") { apiCredentialRequests.push({ body: request.postData() ? (request.postDataJSON() as Record) : null, method, @@ -1173,12 +1376,14 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) kyc, kycFormSubmissions, limitsRequests, + membershipRequests, monerium, quoteRequests, registerRequests, requestOtpRequests, startRequests, status, + team, unexpectedExternalRequests, unmatchedRequests, updateRequests, diff --git a/apps/dashboard/src/components/admin/admin-account-ui.test.ts b/apps/dashboard/src/components/admin/admin-account-ui.test.ts index 8b1c60f7d..a6eb84eb2 100644 --- a/apps/dashboard/src/components/admin/admin-account-ui.test.ts +++ b/apps/dashboard/src/components/admin/admin-account-ui.test.ts @@ -29,6 +29,8 @@ describe("admin account identity", () => { managedProfile: { customerType: "business", externalSubjectId: "customer-42", + isOwner: true, + membershipRole: "manager", targetEmail: "child@example.com", targetProfileId: "child-profile-id" } diff --git a/apps/dashboard/src/components/admin/admin-account-ui.ts b/apps/dashboard/src/components/admin/admin-account-ui.ts index 763134294..d11027b58 100644 --- a/apps/dashboard/src/components/admin/admin-account-ui.ts +++ b/apps/dashboard/src/components/admin/admin-account-ui.ts @@ -30,6 +30,8 @@ export function toAdminImpersonationTarget(account: AdminAccountIdentity): Admin managedProfile: { customerType: managed.customerType, externalSubjectId: managed.externalSubjectId, + isOwner: true, + membershipRole: "manager", targetEmail: managed.contactEmail ?? managed.externalSubjectId, targetProfileId: account.id } diff --git a/apps/dashboard/src/components/api-keys/ApiCredentialsTable.tsx b/apps/dashboard/src/components/api-keys/ApiCredentialsTable.tsx index bb844a8a0..dc484a728 100644 --- a/apps/dashboard/src/components/api-keys/ApiCredentialsTable.tsx +++ b/apps/dashboard/src/components/api-keys/ApiCredentialsTable.tsx @@ -1,5 +1,5 @@ import { Copy, KeyRound, Trash2 } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -7,8 +7,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Skeleton } from "@/components/ui/skeleton"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { type ApiCredential, keyPreview, toApiCredentials } from "@/domain/api-credentials"; +import { type ApiCredential, CHILD_CREDENTIAL_WARNING, keyPreview, toApiCredentials } from "@/domain/api-credentials"; import { useApiCredentials, useRevokeApiCredential } from "@/hooks/useApiCredentials"; +import { useManagedProfileSelection } from "@/stores/managed-profile.store"; function formatDate(value: string | null): string { if (!value) return "Never"; @@ -19,11 +20,15 @@ function formatEnvironment(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1); } -export function ApiCredentialsTable() { +export function ApiCredentialsTable({ canMutate = true }: { canMutate?: boolean }) { const [selected, setSelected] = useState(null); const apiCredentials = useApiCredentials(); const credentials = toApiCredentials(apiCredentials.data?.credentials ?? []); + useEffect(() => { + if (!canMutate) setSelected(null); + }, [canMutate]); + return ( <> @@ -114,7 +119,7 @@ export function ApiCredentialsTable() { - {credential.status !== "revoked" && ( + {canMutate && credential.status !== "revoked" && ( - + {createCredential.data ? ( Create API credential - Use this credential from a trusted server to authenticate Vortex SDK requests. + {managedProfile + ? CHILD_CREDENTIAL_WARNING + : "Use this credential from a trusted server to authenticate Vortex SDK requests."}
diff --git a/apps/dashboard/src/components/layout/AppSidebar.tsx b/apps/dashboard/src/components/layout/AppSidebar.tsx index 95964ab2d..f5974927b 100644 --- a/apps/dashboard/src/components/layout/AppSidebar.tsx +++ b/apps/dashboard/src/components/layout/AppSidebar.tsx @@ -12,6 +12,7 @@ import { Users, UsersRound } from "lucide-react"; +import { canAccessManagedProfiles } from "@/components/managed-profiles/managed-profile-ui"; import { Sidebar, SidebarContent, @@ -24,7 +25,7 @@ import { SidebarRail } from "@/components/ui/sidebar"; import { useOnboardingStatusQuery } from "@/hooks/useApprovedCorridors"; -import { isManagedProfilesAccessDenied, useManagedProfiles } from "@/hooks/useManagedProfiles"; +import { useManagedProfiles } from "@/hooks/useManagedProfiles"; import { useImpersonationSession } from "@/stores/impersonation.store"; import { useManagedProfileSelection } from "@/stores/managed-profile.store"; import { VortexLogo } from "./VortexLogo"; @@ -42,7 +43,7 @@ const NAV_ITEMS = [ const ADMIN_NAV_ITEM = { icon: UserCog, label: "Admin", to: "/admin" } as const; const MANAGED_PROFILES_NAV_ITEM = { icon: UsersRound, label: "Managed profiles", to: "/managed-profiles" } as const; -const CHILD_NAV_ITEMS = NAV_ITEMS.filter(item => item.to !== "/api-keys" && item.to !== "/settings"); +const CHILD_NAV_ITEMS = NAV_ITEMS.filter(item => item.to !== "/transfer" && item.to !== "/settings"); export function AppSidebar() { const pathname = useRouterState({ select: state => state.location.pathname }); @@ -53,13 +54,12 @@ export function AppSidebar() { const isAdmin = onboardingStatus?.roles.includes("vortex_admin") ?? false; // An operator acting as a customer must see exactly the customer's navigation. const isActingForChild = !!managedProfile; - const isManager = !!managedProfiles.data?.manager; - const managerCheckFailed = managedProfiles.isError && !isManagedProfilesAccessDenied(managedProfiles.error); + const hasManagedProfileAccess = canAccessManagedProfiles(managedProfiles.data?.actor); const navItems = isActingForChild ? CHILD_NAV_ITEMS : [ ...NAV_ITEMS, - ...(isManager ? [MANAGED_PROFILES_NAV_ITEM] : []), + ...(hasManagedProfileAccess ? [MANAGED_PROFILES_NAV_ITEM] : []), ...(isAdmin && !isImpersonating ? [ADMIN_NAV_ITEM] : []) ]; @@ -84,7 +84,7 @@ export function AppSidebar() { ))} - {managerCheckFailed && !isActingForChild && ( + {managedProfiles.isError && !isActingForChild && ( - - Acting for {label} - +
+ + Acting for {label} + + {selection.membershipRole === "manager" ? "Manager" : "Read only"} + {selection.isOwner && Owner} +
+ {canMutate && ( + + )} ) : ( + {canMutate && ( + + )} ))} - - - + {canMutate && ( + + + + )} ) : ( <> diff --git a/apps/dashboard/src/components/quote/QuoteExplorer.tsx b/apps/dashboard/src/components/quote/QuoteExplorer.tsx index 4567847a0..768fb1a84 100644 --- a/apps/dashboard/src/components/quote/QuoteExplorer.tsx +++ b/apps/dashboard/src/components/quote/QuoteExplorer.tsx @@ -32,6 +32,7 @@ import { } from "@/lib/amount"; import { springSnappy } from "@/lib/motion"; import { useQuote } from "@/services/api/hooks"; +import { useManagedProfileSelection } from "@/stores/managed-profile.store"; import { QuoteSummary } from "../transfer/QuoteSummary"; import { TokenCombobox } from "../transfer/TokenCombobox"; import { AmountInput, AmountPanel } from "./AmountPanel"; @@ -306,6 +307,8 @@ interface QuoteCtaProps { * corridor routes to its onboarding rather than to a transfer form that would reject it. */ function QuoteCta({ amount, corridorId, isApproved, isBuy, network, token }: QuoteCtaProps) { + const managedProfile = useManagedProfileSelection(); + if (!isApproved) { return ( - + {canMutate && ( + + )} diff --git a/apps/dashboard/src/components/recipients/RecipientsTable.tsx b/apps/dashboard/src/components/recipients/RecipientsTable.tsx index 0084d189a..761207e75 100644 --- a/apps/dashboard/src/components/recipients/RecipientsTable.tsx +++ b/apps/dashboard/src/components/recipients/RecipientsTable.tsx @@ -1,7 +1,7 @@ import { useNavigate } from "@tanstack/react-router"; import { ArrowRight } from "lucide-react"; import { motion } from "motion/react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; @@ -10,16 +10,21 @@ import { recipientLabel } from "@/domain/recipient"; import { RECIPIENT_STATUS_META } from "@/domain/status"; import { PAYMENT_METHOD_LABEL } from "@/domain/transfer"; import type { Recipient } from "@/domain/types"; +import { useManagedProfileSelection } from "@/stores/managed-profile.store"; import { RecipientActionsDialog } from "./RecipientActionsDialog"; const MotionRow = motion.create(TableRow); -export function RecipientsTable({ recipients }: { recipients: Recipient[] }) { +export function RecipientsTable({ canMutate = true, recipients }: { canMutate?: boolean; recipients: Recipient[] }) { const [selectedId, setSelectedId] = useState(null); // Derived from the live list, not a snapshot: a refetch while the modal is open (e.g. the // invite got accepted and its token cleared) must not keep offering stale data. const selected = selectedId ? (recipients.find(recipient => recipient.id === selectedId) ?? null) : null; + useEffect(() => { + if (!canMutate) setSelectedId(null); + }, [canMutate]); + return ( <> @@ -93,16 +98,23 @@ export function RecipientsTable({ recipients }: { recipients: Recipient[] }) { })}
- {selected && !open && setSelectedId(null)} recipient={selected} />} + {selected && ( + !open && setSelectedId(null)} + recipient={selected} + /> + )} ); } function RecipientAction({ recipient }: { recipient: Recipient }) { const navigate = useNavigate(); + const managedProfile = useManagedProfileSelection(); // Only your own payout accounts are sendable today. - if (recipient.isSelf && recipient.status === "approved") { + if (!managedProfile && recipient.isSelf && recipient.status === "approved") { return ( } + + {selection.membershipRole === "read_only" && ( +

Team access is read-only for this membership.

+ )} + + + Members + + + {members.isPending ? ( + + ) : members.isError ? ( +
+

Could not load members.

+ +
+ ) : ( + <> +
    + {members.data.members.map(member => ( +
  • +
    +

    {member.email ?? member.memberProfileId}

    +
    + {ROLE_LABEL[member.role]} + {member.isOwner && Owner} + {member.memberProfileId === selection.managerProfileId && You} +
    + {member.isOwner && ( +

    Owner access cannot be changed or removed.

    + )} +
    + {canManage && !member.isOwner && ( +
    + + +
    + )} +
  • + ))} +
+ {members.data.members.length === 0 &&

No members on this page.

} +
+ + +
+ + )} +
+
+ + + Invitations + + + {invitations.isPending ? ( + + ) : invitations.isError ? ( +
+

Could not load invitations.

+ +
+ ) : ( + <> +
    + {invitations.data.invitations.map(invitation => ( +
  • +
    +

    {invitation.email}

    +
    + {ROLE_LABEL[invitation.role]} + + {invitation.status} + +
    +

    Expires {new Date(invitation.expiresAt).toLocaleString()}

    +
    + {canManage && invitation.status === "pending" && ( + + )} +
  • + ))} +
+ {invitations.data.invitations.length === 0 && ( +

No invitations on this page.

+ )} +
+ + +
+ + )} +
+
+ + + Access history + + + {events.isPending ? ( + + ) : events.isError ? ( +
+

Could not load access history.

+ +
+ ) : ( + <> +
    + {events.data.events.map(event => ( +
  • +

    {EVENT_LABEL[event.action]}

    +

    + {new Date(event.createdAt).toLocaleString()} + {event.role && + ` - ${event.previousRole ? `${ROLE_LABEL[event.previousRole]} to ` : ""}${ROLE_LABEL[event.role]}`} +

    +

    + Actor: {event.actorProfileId ?? "System"} + {event.memberProfileId && ` - Member: ${event.memberProfileId}`} +

    +
  • + ))} +
+ {events.data.events.length === 0 &&

No access events yet.

} +
+ + +
+ + )} +
+
+ {canManage && action && ( + setAction(null)} profileId={profileId} refreshTeam={refreshTeam} /> + )} + + ); +} + +function TeamActionDialog({ + action, + onClose, + profileId, + refreshTeam +}: { + action: Action; + onClose: () => void; + profileId: string; + refreshTeam: () => Promise; +}) { + const form = useForm({ + defaultValues: { email: "", role: "read_only" }, + resolver: standardSchemaResolver(inviteSchema) + }); + const nextRole = "member" in action && action.member.role === "manager" ? "read_only" : "manager"; + const title = { cancel: "Cancel invitation", change: "Change role", invite: "Invite member", remove: "Remove member" }[ + action.type + ]; + const mutation = useMutation({ + mutationFn: async (values: InviteValues) => { + switch (action.type) { + case "invite": + await service.invite(profileId, values); + break; + case "cancel": + await service.cancel(profileId, action.invitation.id); + break; + case "change": + if (!action.member.isOwner) await service.changeRole(profileId, action.member.memberProfileId, nextRole); + break; + case "remove": + if (!action.member.isOwner) await service.remove(profileId, action.member.memberProfileId); + break; + } + }, + onSettled: refreshTeam, + onSuccess: () => { + toast.success("Team updated"); + onClose(); + } + }); + return ( + !open && !mutation.isPending && onClose()} open> + + + {title} + + {action.type === "invite" + ? "Send an email invitation for this profile. Access is granted only after the recipient explicitly accepts." + : action.type === "cancel" + ? `Cancel the pending invitation for ${action.invitation.email}? It will no longer be usable.` + : action.type === "change" + ? `Change ${action.member.email ?? action.member.memberProfileId} to ${ROLE_LABEL[nextRole]}?` + : `Remove access for ${action.member.email ?? action.member.memberProfileId}?`} + + + {(action.type === "remove" || (action.type === "change" && nextRole === "read_only")) && ( +

{CHILD_CREDENTIAL_WARNING}

+ )} + {(action.type === "invite" || (action.type === "change" && nextRole === "manager")) && ( +

+ Managers can administer non-owner team access and child credentials. Read-only members can view data but cannot make + changes. +

+ )} + {mutation.isError && ( +

+ {isApiError(mutation.error) ? mutation.error.message : "Could not update the team. Try again."} +

+ )} + {action.type === "invite" && ( + + mutation.mutate(values))}> + ( + + Email + + + + + + )} + /> + ( + + Role + + + + )} + /> + + + )} + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 245f14038..44234bfa3 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -20,9 +20,11 @@ import { Route as AppOverviewRouteImport } from './routes/_app/overview' import { Route as AppQuoteRouteImport } from './routes/_app/quote' import { Route as AppRecipientsRouteImport } from './routes/_app/recipients' import { Route as AppSettingsRouteImport } from './routes/_app/settings' +import { Route as AppTeamRouteImport } from './routes/_app/team' import { Route as AppTransactionsRouteImport } from './routes/_app/transactions' import { Route as AppTransferRouteImport } from './routes/_app/transfer' import { Route as InviteTokenRouteImport } from './routes/invite.$token' +import { Route as MemberInvitationsInvitationIdRouteImport } from './routes/member-invitations.$invitationId' import { Route as MoneriumCallbackRouteImport } from './routes/monerium.callback' import { Route as AppAdminIndexRouteImport } from './routes/_app/admin.index' import { Route as AppAdminProfileIdRouteImport } from './routes/_app/admin.$profileId' @@ -81,6 +83,11 @@ const AppSettingsRoute = AppSettingsRouteImport.update({ path: '/settings', getParentRoute: () => AppRoute, } as any) +const AppTeamRoute = AppTeamRouteImport.update({ + id: '/team', + path: '/team', + getParentRoute: () => AppRoute, +} as any) const AppTransactionsRoute = AppTransactionsRouteImport.update({ id: '/transactions', path: '/transactions', @@ -96,6 +103,12 @@ const InviteTokenRoute = InviteTokenRouteImport.update({ path: '/invite/$token', getParentRoute: () => rootRouteImport, } as any) +const MemberInvitationsInvitationIdRoute = + MemberInvitationsInvitationIdRouteImport.update({ + id: '/member-invitations/$invitationId', + path: '/member-invitations/$invitationId', + getParentRoute: () => rootRouteImport, + } as any) const MoneriumCallbackRoute = MoneriumCallbackRouteImport.update({ id: '/monerium/callback', path: '/monerium/callback', @@ -123,9 +136,11 @@ export interface FileRoutesByFullPath { '/quote': typeof AppQuoteRoute '/recipients': typeof AppRecipientsRoute '/settings': typeof AppSettingsRoute + '/team': typeof AppTeamRoute '/transactions': typeof AppTransactionsRoute '/transfer': typeof AppTransferRoute '/invite/$token': typeof InviteTokenRoute + '/member-invitations/$invitationId': typeof MemberInvitationsInvitationIdRoute '/monerium/callback': typeof MoneriumCallbackRoute '/admin/$profileId': typeof AppAdminProfileIdRoute '/admin/': typeof AppAdminIndexRoute @@ -140,9 +155,11 @@ export interface FileRoutesByTo { '/quote': typeof AppQuoteRoute '/recipients': typeof AppRecipientsRoute '/settings': typeof AppSettingsRoute + '/team': typeof AppTeamRoute '/transactions': typeof AppTransactionsRoute '/transfer': typeof AppTransferRoute '/invite/$token': typeof InviteTokenRoute + '/member-invitations/$invitationId': typeof MemberInvitationsInvitationIdRoute '/monerium/callback': typeof MoneriumCallbackRoute '/admin/$profileId': typeof AppAdminProfileIdRoute '/admin': typeof AppAdminIndexRoute @@ -160,9 +177,11 @@ export interface FileRoutesById { '/_app/quote': typeof AppQuoteRoute '/_app/recipients': typeof AppRecipientsRoute '/_app/settings': typeof AppSettingsRoute + '/_app/team': typeof AppTeamRoute '/_app/transactions': typeof AppTransactionsRoute '/_app/transfer': typeof AppTransferRoute '/invite/$token': typeof InviteTokenRoute + '/member-invitations/$invitationId': typeof MemberInvitationsInvitationIdRoute '/monerium/callback': typeof MoneriumCallbackRoute '/_app/admin/$profileId': typeof AppAdminProfileIdRoute '/_app/admin/': typeof AppAdminIndexRoute @@ -180,9 +199,11 @@ export interface FileRouteTypes { | '/quote' | '/recipients' | '/settings' + | '/team' | '/transactions' | '/transfer' | '/invite/$token' + | '/member-invitations/$invitationId' | '/monerium/callback' | '/admin/$profileId' | '/admin/' @@ -197,9 +218,11 @@ export interface FileRouteTypes { | '/quote' | '/recipients' | '/settings' + | '/team' | '/transactions' | '/transfer' | '/invite/$token' + | '/member-invitations/$invitationId' | '/monerium/callback' | '/admin/$profileId' | '/admin' @@ -216,9 +239,11 @@ export interface FileRouteTypes { | '/_app/quote' | '/_app/recipients' | '/_app/settings' + | '/_app/team' | '/_app/transactions' | '/_app/transfer' | '/invite/$token' + | '/member-invitations/$invitationId' | '/monerium/callback' | '/_app/admin/$profileId' | '/_app/admin/' @@ -229,6 +254,7 @@ export interface RootRouteChildren { AppRoute: typeof AppRouteWithChildren LoginRoute: typeof LoginRoute InviteTokenRoute: typeof InviteTokenRoute + MemberInvitationsInvitationIdRoute: typeof MemberInvitationsInvitationIdRoute MoneriumCallbackRoute: typeof MoneriumCallbackRoute } @@ -311,6 +337,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppSettingsRouteImport parentRoute: typeof AppRoute } + '/_app/team': { + id: '/_app/team' + path: '/team' + fullPath: '/team' + preLoaderRoute: typeof AppTeamRouteImport + parentRoute: typeof AppRoute + } '/_app/transactions': { id: '/_app/transactions' path: '/transactions' @@ -332,6 +365,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof InviteTokenRouteImport parentRoute: typeof rootRouteImport } + '/member-invitations/$invitationId': { + id: '/member-invitations/$invitationId' + path: '/member-invitations/$invitationId' + fullPath: '/member-invitations/$invitationId' + preLoaderRoute: typeof MemberInvitationsInvitationIdRouteImport + parentRoute: typeof rootRouteImport + } '/monerium/callback': { id: '/monerium/callback' path: '/monerium/callback' @@ -379,6 +419,7 @@ interface AppRouteChildren { AppQuoteRoute: typeof AppQuoteRoute AppRecipientsRoute: typeof AppRecipientsRoute AppSettingsRoute: typeof AppSettingsRoute + AppTeamRoute: typeof AppTeamRoute AppTransactionsRoute: typeof AppTransactionsRoute AppTransferRoute: typeof AppTransferRoute } @@ -392,6 +433,7 @@ const AppRouteChildren: AppRouteChildren = { AppQuoteRoute: AppQuoteRoute, AppRecipientsRoute: AppRecipientsRoute, AppSettingsRoute: AppSettingsRoute, + AppTeamRoute: AppTeamRoute, AppTransactionsRoute: AppTransactionsRoute, AppTransferRoute: AppTransferRoute, } @@ -403,6 +445,7 @@ const rootRouteChildren: RootRouteChildren = { AppRoute: AppRouteWithChildren, LoginRoute: LoginRoute, InviteTokenRoute: InviteTokenRoute, + MemberInvitationsInvitationIdRoute: MemberInvitationsInvitationIdRoute, MoneriumCallbackRoute: MoneriumCallbackRoute, } export const routeTree = rootRouteImport diff --git a/apps/dashboard/src/routes/_app/team.tsx b/apps/dashboard/src/routes/_app/team.tsx new file mode 100644 index 000000000..461121f3a --- /dev/null +++ b/apps/dashboard/src/routes/_app/team.tsx @@ -0,0 +1,26 @@ +import { createFileRoute, Navigate } from "@tanstack/react-router"; +import { Team } from "@/components/managed-profiles/Team"; +import { useImpersonationSession } from "@/stores/impersonation.store"; +import { useManagedProfileSelection } from "@/stores/managed-profile.store"; + +export const Route = createFileRoute("/_app/team")({ component: TeamPage }); + +function TeamPage() { + const selection = useManagedProfileSelection(); + const impersonation = useImpersonationSession(); + if (!selection) return ; + if (impersonation) + return ( +
+

Team

+

Team access is unavailable during impersonation. Exit impersonation to use your own membership.

+
+ ); + // A live downgrade or child switch also discards any open mutation dialog. + return ( + + ); +} diff --git a/apps/dashboard/src/routes/login.tsx b/apps/dashboard/src/routes/login.tsx index 284cce33c..625c4e470 100644 --- a/apps/dashboard/src/routes/login.tsx +++ b/apps/dashboard/src/routes/login.tsx @@ -1,18 +1,21 @@ import { createFileRoute, Navigate, useNavigate } from "@tanstack/react-router"; import { AuthCard } from "@/components/auth/AuthCard"; +import { safeLoginReturnTo } from "@/components/auth/login-return"; import { VortexLogo } from "@/components/layout/VortexLogo"; import { useAuthStore } from "@/stores/auth.store"; export const Route = createFileRoute("/login")({ - component: LoginPage + component: LoginPage, + validateSearch: (search: Record): { returnTo?: string } => ({ returnTo: safeLoginReturnTo(search.returnTo) }) }); function LoginPage() { const user = useAuthStore(state => state.user); const navigate = useNavigate(); + const { returnTo } = Route.useSearch(); if (user) { - return ; + return ; } return ( @@ -23,7 +26,7 @@ function LoginPage() { navigate({ to: "/overview" })} + onAuthenticated={() => navigate({ replace: true, to: returnTo ?? "/overview" })} title="Connect with Vortex" /> diff --git a/apps/dashboard/src/routes/member-invitations.$invitationId.tsx b/apps/dashboard/src/routes/member-invitations.$invitationId.tsx new file mode 100644 index 000000000..2464dfe37 --- /dev/null +++ b/apps/dashboard/src/routes/member-invitations.$invitationId.tsx @@ -0,0 +1,219 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { VortexLogo } from "@/components/layout/VortexLogo"; +import { toManagedProfileSelection } from "@/components/managed-profiles/managed-profile-ui"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { MANAGED_PROFILES_QUERY_KEY } from "@/hooks/useManagedProfiles"; +import { isApiError } from "@/services/api/api-client"; +import { + ManagedProfileMembershipsService as service, + shouldRetryMembershipQuery +} from "@/services/api/managed-profile-memberships.service"; +import { ManagedProfilesService } from "@/services/api/managed-profiles.service"; +import { AuthService } from "@/services/auth"; +import { useAuthStore } from "@/stores/auth.store"; +import { useImpersonationSession } from "@/stores/impersonation.store"; +import { selectManagedProfile } from "@/stores/managed-profile.store"; + +export const Route = createFileRoute("/member-invitations/$invitationId")({ component: InvitationPage }); + +function InvitationPage() { + const { invitationId } = Route.useParams(); + const user = useAuthStore(state => state.user); + const impersonation = useImpersonationSession(); + return ( +
+
+
+ +
+ + + Team invitation + Access a managed profile with your own Vortex account. + + + {impersonation ? ( + <> +

Invitations are unavailable during impersonation. Exit impersonation before continuing.

+ + + ) : !user ? ( + <> +

+ Sign in with the email that received this invitation to review it. Signing in does not accept the invitation. +

+ + + ) : ( + + )} +
+
+
+
+ ); +} + +function InvitationDetails({ invitationId, userId }: { invitationId: string; userId: string }) { + const client = useQueryClient(); + const navigate = useNavigate(); + const logout = useAuthStore(state => state.logout); + const [opening, setOpening] = useState(false); + const [openError, setOpenError] = useState(null); + const preview = useQuery({ + gcTime: 0, + queryFn: ({ signal }) => service.preview(invitationId, signal), + queryKey: ["member-invitation", userId, invitationId], + refetchOnWindowFocus: "always", + retry: shouldRetryMembershipQuery + }); + const accept = useMutation({ + mutationFn: () => service.accept(invitationId), + onSuccess: async () => { + await Promise.all([ + client.invalidateQueries({ queryKey: [MANAGED_PROFILES_QUERY_KEY] }), + client.invalidateQueries({ queryKey: ["managed-profile-bootstrap"] }), + client.invalidateQueries({ queryKey: ["managed-profile-team"] }) + ]); + } + }); + const error = accept.error ?? preview.error; + const code = isApiError(error) ? error.data.code : undefined; + // A denial takes precedence over any cached preview, including a denial at accept time. + if (isApiError(error) && (error.status === 403 || error.status === 401)) { + return ( + <> +

Invitation unavailable for this account

+

Use the verified email that received the invitation. The link may also be unavailable.

+ + + ); + } + const status = accept.data + ? "success" + : code === "INVITATION_EXPIRED" + ? "expired" + : code === "INVITATION_CANCELLED" + ? "cancelled" + : code === "INVITATION_ACCEPTED" + ? "accepted" + : preview.data?.invitation.status; + if (status === "expired" || status === "cancelled") { + return ( + <> +

Invitation {status}

+

This invitation can no longer be accepted. Ask a manager for a new invitation.

+ + + ); + } + + async function openProfile() { + const profileId = accept.data?.managedProfileId ?? preview.data?.managedProfile.profileId; + if (!profileId || opening) return; + setOpening(true); + setOpenError(null); + try { + // Preview roles are not selection authority; membership may have changed since acceptance. + const result = await ManagedProfilesService.get(profileId); + if (AuthService.getEffectiveBearerProfileId() !== userId || AuthService.getAcceptedImpersonationSessionSnapshot()) return; + if ( + result.actor.profileId !== userId || + result.managedProfile.profileId !== profileId || + result.managedProfile.status !== "active" + ) + throw new Error("Invalid membership"); + if (!selectManagedProfile(toManagedProfileSelection(result.managedProfile))) { + setOpenError("Finish or cancel the current transfer signing step before changing profiles."); + return; + } + await navigate({ to: "/overview" }); + } catch { + setOpenError("Could not open this profile. Your membership may have changed. Try again or contact a manager."); + } finally { + setOpening(false); + } + } + + if (status === "success" || status === "accepted" || code === "MEMBERSHIP_ALREADY_EXISTS") { + return ( + <> +

+ {status === "success" + ? "Invitation accepted" + : code === "MEMBERSHIP_ALREADY_EXISTS" + ? "You already have access" + : "Invitation already accepted"} +

+

Open the profile to view your current access. Opening it does not change your role.

+ {openError && ( +

+ {openError} +

+ )} + + + + ); + } + if (error) + return ( + <> +

Could not load invitation

+

+ Could not confirm the invitation status. Check your connection and try again. +

+ + + ); + if (preview.isPending || !preview.data) return ; + + return ( + <> +

Join {preview.data.managedProfile.externalSubjectId}

+

Invited by {preview.data.inviter.email ?? preview.data.inviter.profileId}

+

+ Role: {preview.data.invitation.role === "manager" ? "Manager" : "Read only"} +

+

+ {preview.data.invitation.role === "manager" + ? "You can manage this profile, including non-owner team access and child credentials." + : "You can view this profile's data, but cannot make changes."} +

+

Expires {new Date(preview.data.invitation.expiresAt).toLocaleString()}

+ + + ); +} diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index a97eaada3..521d3331d 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -162,14 +162,24 @@ must continue to verify the active membership and role, controlling owner, direc relationship, child entity, corridor, and customer-type policy on every delegated authorization decision. -- As an active managed-profile member, I see **Managed profiles** in the sidebar. Ordinary users do - not see the item. Access detection uses the authenticated lifecycle API rather than a client-side - role claim. +- I see **Managed profiles** when the lifecycle response's actor has + `canProvisionManagedProfiles || hasMemberships`, including an enabled owner before its first child. + Both flags false hides the item. List/detail return `actor: { profileId, + canProvisionManagedProfiles, hasMemberships }`; a default empty list is `200`, not an access-denial + signal. `hasMemberships` counts unpaginated eligible active children independently of page/status, + excluding deleted children, inactive owners and invalid entity layouts. Neither page length nor + a successful response is authority. - As a member, I can keep using the dashboard as my own account when no child is selected. - As a member, I open **Managed profiles** and see only children assigned to me. Each row identifies the child, shows its immutable customer type, my `Manager` or `Read only` role and `Owner` badge where applicable, and the controlling owner's authorized corridors. Corridors remain owner policy, not per-membership grants. +- Retained API views (`status=deleted` and `status=all`) require my own active owner configuration + and contain only my owned children; even `all` excludes active invited children owned by others. + A retained deleted-child detail is owner-only with active configuration and valid membership/entity + layout, without a selector. Invited members receive masked `404`; retained records are never + selectable child-mode subjects. A non-owner active member's child deletion is + `403 MANAGED_PROFILE_OWNER_REQUIRED`. - As a member, I use a row's three-dot menu to open a confirmation dialog and choose **Act for this profile**. The product must not call this action “Log in as” or “Impersonate”. - Confirming stores the selection, clears account-scoped query and notification state, disconnects @@ -185,11 +195,15 @@ decision. state remains keyed by its effective subject and is neither displayed nor submitted while a child is selected. The backend independently rejects selected-child bearer register, update, and start requests, so hidden navigation is not the authorization boundary. -- The dashboard bootstraps a persisted selection from a dedicated child lifecycle read and - revalidates it whenever the window regains focus. It refreshes role and owner metadata in place. - If the membership is revoked, the relationship is deleted, the controlling owner is disabled, or - the response identity does not match the selection, it clears child mode and returns to the - member's selection page rather than silently retrying against the member's own resources. +- The dashboard bootstraps a persisted selection using `GET /v1/managed-profiles/:profileId` with + an exactly matching `X-Managed-Profile-Id`, and revalidates when the window regains focus. It + refreshes role and owner metadata in place. Only stored membership history permits the API to + return `MANAGED_PROFILE_MEMBERSHIP_INVALID` after revocation, child deletion, owner deactivation + or invalid entity layout; deletion invalidates even the owner's bootstrap. Never-member callers + get identical masked `404`s for existing and unknown children. The dashboard clears child mode + for membership-invalid, not generic `404`, role/policy denial or transient failures. It also + rejects a successful response with mismatched actor/child identity or non-active status rather + than silently retrying against the member's own resources. **Child-mode navigation.** Onboarding status, Recipients, Get a quote, Transactions, child API keys, Team, and Limits remain available where their API routes support managed-child authorization. New @@ -202,6 +216,12 @@ remain member-scoped or unavailable and must not be shown as child operations. T must never attach the header indiscriminately, because an endpoint that ignores it would otherwise operate on the manager while the UI claims to show the child. +The provider-mutation bearer denial retains its shipped spelling +`MANAGED_PROFILE_REQUIRES_API_CREDENTIAL`; ramp denial is +`MANAGED_PROFILE_RAMP_REQUIRES_API_CREDENTIAL` with no in-flight drain exception. Child API-key +and domestic fiat-account mutations are `manage` capability and remain available to manager +members through a real bearer session, subject to the existing impersonation restrictions. + The legacy Monerium and Mykobo routes are the known instance of that ignored-header behavior: they always use the authenticated manager identity. Dashboard services do not opt them into managed selection, and child-mode onboarding actions remain disabled. From fa73cb325285fca08738868e603768fbd6746a2e Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 7 Sep 2026 17:23:51 -0300 Subject: [PATCH 09/11] test(dashboard): wait for child credential revocation to finish --- apps/dashboard/e2e/managed-profiles.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/dashboard/e2e/managed-profiles.spec.ts b/apps/dashboard/e2e/managed-profiles.spec.ts index a0dbeb23b..02119a27f 100644 --- a/apps/dashboard/e2e/managed-profiles.spec.ts +++ b/apps/dashboard/e2e/managed-profiles.spec.ts @@ -97,6 +97,7 @@ test("a manager selects and stops acting for a managed profile", async ({ page } ).toBeVisible(); await expect(page.getByRole("dialog").getByText(/remain valid after a human member is removed or downgraded/)).toBeVisible(); await page.getByRole("dialog").getByRole("button", { name: "Revoke credential" }).click(); + await expect(page.getByRole("row").filter({ hasText: "Child backend" }).getByText("Revoked", { exact: true })).toBeVisible(); expect( backend.apiRequests.some( request => request.path === `/v1/managed-profiles/${E2E_MANAGED_PROFILE_ID}/api-credentials` && request.method === "GET" From dceb4866bc81832fda4492e0c0219c89c25e7a60 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 7 Sep 2026 21:04:27 -0300 Subject: [PATCH 10/11] feat(api): make managed profile teams organization-wide --- .../api/controllers/brla.controller.test.ts | 2 +- .../managedProfileMemberships.controller.ts | 46 +- .../managedProfiles.controller.test.ts | 79 +- .../middlewares/managedProfileAuth.test.ts | 47 +- .../src/api/middlewares/managedProfileAuth.ts | 8 +- .../admin-console/admin-console.route.test.ts | 9 +- .../routes/v1/api-credentials.route.test.ts | 6 +- apps/api/src/api/routes/v1/index.ts | 4 +- ...ed-profile-memberships.integration.test.ts | 152 +++ .../managed-profile-memberships.route.test.ts | 220 +++- .../v1/managed-profile-memberships.route.ts | 37 +- apps/api/src/api/routes/v1/ramp.route.test.ts | 6 +- .../services/apiCredential.service.test.ts | 4 +- .../src/api/services/apiCredential.service.ts | 2 +- .../avenia/avenia-kyc-import.service.test.ts | 4 +- .../avenia/avenia-kyc-import.service.ts | 2 +- .../avenia-standard-kyc.service.test.ts | 4 +- .../avenia/avenia-standard-kyc.service.ts | 2 +- ...aged-profile-membership-invitation.test.ts | 21 +- .../managed-profile-membership-invitation.ts | 6 +- .../managed-profile-lifecycle.service.test.ts | 101 +- .../managed-profile-lifecycle.service.ts | 55 +- .../managed-profile-manager.service.test.ts | 111 ++ .../managed-profile-manager.service.ts | 106 +- ...managed-profile-membership.service.test.ts | 605 +++++----- .../managed-profile-membership.service.ts | 317 +++--- ...naged-profile-provisioning.service.test.ts | 22 +- .../managed-profile-provisioning.service.ts | 21 - ...naged-profile-memberships-rollback.test.ts | 2 +- ...managed-profile-memberships-schema.test.ts | 117 +- .../database/managed-profiles-schema.test.ts | 27 +- .../069-create-managed-profile-memberships.ts | 121 +- apps/api/src/models/index.ts | 30 +- .../models/managedProfileMembership.model.ts | 21 +- .../managedProfileMembershipEvent.model.ts | 24 +- ...anagedProfileMembershipInvitation.model.ts | 16 +- ...edpay-managed-customer.integration.test.ts | 10 +- ...e-quote-ramp-lifecycle.integration.test.ts | 41 +- .../src/tests/recipients.integration.test.ts | 3 +- apps/dashboard/e2e/managed-profiles.spec.ts | 27 +- .../e2e/onboarding-monerium-eu.spec.ts | 19 +- apps/dashboard/e2e/support/mockBackend.ts | 127 ++- apps/dashboard/e2e/support/session.ts | 2 +- apps/dashboard/e2e/team-memberships.spec.ts | 454 +++++++- .../src/components/layout/AppSidebar.tsx | 17 +- .../managed-profiles/ManagedProfilesList.tsx | 4 +- .../src/components/managed-profiles/Team.tsx | 118 +- .../managed-profile-ui.test.ts | 2 + .../managed-profiles/managed-profile-ui.ts | 2 +- apps/dashboard/src/hooks/useOrganization.ts | 32 + .../src/routes/_app/managed-profiles.tsx | 2 +- apps/dashboard/src/routes/_app/team.tsx | 29 +- .../member-invitations.$invitationId.tsx | 161 +-- apps/dashboard/src/services/api/api-client.ts | 2 +- ...anaged-profile-memberships.service.test.ts | 109 +- .../managed-profile-memberships.service.ts | 76 +- docs/README.md | 3 +- docs/adr-0003-managed-headless-profiles.md | 8 +- docs/adr-0005-managed-profile-memberships.md | 4 +- docs/adr-0006-organization-wide-teams.md | 86 ++ docs/api/README.md | 13 + docs/api/apidog/page-manifest.json | 20 +- docs/api/openapi/vortex.openapi.d.ts | 1006 +++++++++-------- docs/api/openapi/vortex.openapi.json | 966 ++++++++-------- .../03-authentication-and-partner-keys.md | 16 +- docs/api/pages/12-ai-agent-integration.md | 10 +- docs/api/pages/14-managed-profiles.md | 94 +- docs/api/scripts/check-openapi.test.ts | 111 ++ docs/api/scripts/check-openapi.ts | 178 ++- docs/architecture-email-notifications.md | 11 +- docs/architecture-identity-model.md | 92 +- docs/operations-testing.md | 44 + docs/product-dashboard.md | 67 +- .../01-auth/admin-impersonation.md | 13 +- docs/security-spec/01-auth/api-keys.md | 18 +- .../01-auth/managed-profile-memberships.md | 161 ++- .../03-ramp-engine/recipient-transfers.md | 5 +- docs/security-spec/05-integrations/brla.md | 27 +- docs/security-spec/05-integrations/resend.md | 2 +- .../07-operations/api-surface.md | 60 +- .../07-operations/notifications.md | 3 +- docs/security-spec/README.md | 9 +- docs/security-spec/RISK-REGISTER.md | 6 + 83 files changed, 4432 insertions(+), 2195 deletions(-) create mode 100644 apps/api/src/api/routes/v1/managed-profile-memberships.integration.test.ts create mode 100644 apps/api/src/api/services/managed-profile-manager.service.test.ts create mode 100644 apps/dashboard/src/hooks/useOrganization.ts create mode 100644 docs/adr-0006-organization-wide-teams.md create mode 100644 docs/api/scripts/check-openapi.test.ts diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index a686ca864..292c665d5 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -427,7 +427,7 @@ describe("importKycToken", () => { })) as unknown as typeof ManagedProfile.findByPk; ManagedProfileMembership.findByPk = mock(async () => ({ id: "membership-1", - managedProfileId: "child-1", + ownerProfileId: "manager-1", memberProfileId: "manager-1", revokedAt: null, role: "manager" diff --git a/apps/api/src/api/controllers/managedProfileMemberships.controller.ts b/apps/api/src/api/controllers/managedProfileMemberships.controller.ts index db193d7b1..104311336 100644 --- a/apps/api/src/api/controllers/managedProfileMemberships.controller.ts +++ b/apps/api/src/api/controllers/managedProfileMemberships.controller.ts @@ -4,6 +4,7 @@ import { cancelManagedProfileInvitation, changeManagedProfileMember, createManagedProfileInvitation, + getManagedProfileOrganization, listManagedProfileInvitations, listManagedProfileMemberEvents, listManagedProfileMembers, @@ -17,7 +18,7 @@ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{ function handle(action: (req: Request, res: Response, actorProfileId: string) => Promise): RequestHandler { return async (req, res) => { try { - if (!req.userId || req.impersonation || req.credential) { + if (!req.userId || req.impersonation || req.credential || req.get("X-Managed-Profile-Id") !== undefined) { throw new ManagedProfileMembershipError("MANAGED_PROFILE_ACCESS_DENIED", 403, "A Supabase session is required"); } for (const [key, value] of Object.entries(req.params)) { @@ -59,14 +60,14 @@ function page(req: Request) { export const readMembers = handle(async (req, res, actor) => { const { limit, offset } = page(req); - res.json(await listManagedProfileMembers(actor, req.params.profileId as string, limit, offset)); + res.json(await listManagedProfileMembers(actor, await organization(req, actor), limit, offset)); }); export const patchMember = handle(async (req, res, actor) => { res.json( await changeManagedProfileMember( actor, - req.params.profileId as string, + await organization(req, actor), req.params.memberProfileId as string, req.body?.role ) @@ -74,17 +75,17 @@ export const patchMember = handle(async (req, res, actor) => { }); export const deleteMember = handle(async (req, res, actor) => { - await removeManagedProfileMember(actor, req.params.profileId as string, req.params.memberProfileId as string); + await removeManagedProfileMember(actor, await organization(req, actor), req.params.memberProfileId as string); res.status(204).send(); }); export const readInvitations = handle(async (req, res, actor) => { const { limit, offset } = page(req); - res.json(await listManagedProfileInvitations(actor, req.params.profileId as string, limit, offset)); + res.json(await listManagedProfileInvitations(actor, await organization(req, actor), limit, offset)); }); export const postInvitation = handle(async (req, res, actor) => { - const result = await createManagedProfileInvitation(actor, req.params.profileId as string, { + const result = await createManagedProfileInvitation(actor, await organization(req, actor), { email: req.body?.email, role: req.body?.role }); @@ -92,7 +93,7 @@ export const postInvitation = handle(async (req, res, actor) => { }); export const deleteInvitation = handle(async (req, res, actor) => { - await cancelManagedProfileInvitation(actor, req.params.profileId as string, req.params.invitationId as string); + await cancelManagedProfileInvitation(actor, await organization(req, actor), req.params.invitationId as string); res.status(204).send(); }); @@ -102,7 +103,7 @@ export const readMemberEvents = handle(async (req, res, actor) => { if (cursor !== undefined && (typeof cursor !== "string" || !UUID_PATTERN.test(cursor))) { throw new ManagedProfileMembershipError("INVALID_PAGINATION", 400, "Cursor must be an event UUID"); } - res.json(await listManagedProfileMemberEvents(actor, req.params.profileId as string, limit, cursor as string | undefined)); + res.json(await listManagedProfileMemberEvents(actor, await organization(req, actor), limit, cursor as string | undefined)); }); export const previewInvitation = handle(async (req, res, actor) => { @@ -124,3 +125,32 @@ export const acceptInvitation = handle(async (req, res, actor) => { ) ); }); + +async function organization(req: Request, actor: string): Promise { + const expectedOwnerProfileId = req.query.expectedOwnerProfileId; + if (typeof expectedOwnerProfileId !== "string" || !UUID_PATTERN.test(expectedOwnerProfileId)) { + throw new ManagedProfileMembershipError( + "MANAGED_PROFILE_INVALID_INPUT", + 400, + "expectedOwnerProfileId must be a single organization owner UUID" + ); + } + const current = await getManagedProfileOrganization(actor); + if (!current) { + throw new ManagedProfileMembershipError("MANAGED_PROFILE_ACCESS_DENIED", 403, "Managed-profile access is denied"); + } + // This is a context precondition, not authority. The service still locks and + // authorizes the actor against this exact organization before accessing its data. + if (current.ownerProfileId !== expectedOwnerProfileId.toLowerCase()) { + throw new ManagedProfileMembershipError( + "ORGANIZATION_CONTEXT_CHANGED", + 409, + "Your organization has changed. Refresh the team page and try again." + ); + } + return current.ownerProfileId; +} + +export const readOrganization = handle(async (_req, res, actor) => { + res.json({ organization: await getManagedProfileOrganization(actor) }); +}); diff --git a/apps/api/src/api/controllers/managedProfiles.controller.test.ts b/apps/api/src/api/controllers/managedProfiles.controller.test.ts index c7c74b331..1899b1827 100644 --- a/apps/api/src/api/controllers/managedProfiles.controller.test.ts +++ b/apps/api/src/api/controllers/managedProfiles.controller.test.ts @@ -11,6 +11,7 @@ import { SupabaseAuthService } from "../services/auth"; import * as bearerPrincipal from "../middlewares/bearerPrincipal"; import * as credentialService from "../services/apiCredential.service"; import { getManagedProfile, listManagedProfiles } from "../services/managed-profile-lifecycle.service"; +import { configureManagedProfileManager } from "../services/managed-profile-manager.service"; import { changeManagedProfileMember, createManagedProfileInvitation, @@ -45,7 +46,7 @@ describe("managed profile lifecycle routes", () => { async function createManager(isActive = true) { const manager = await createTestUser(); - await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive, profileId: manager.id }); + await configureManagedProfileManager({ allowedCorridors: ["BR"], allowedCustomerTypes: null, isActive, profileId: manager.id }); const credential = await createTestApiKey({ userId: manager.id }); return { headers: { "Content-Type": "application/json", "X-API-Key": credential.plaintextKey }, manager }; } @@ -61,7 +62,7 @@ describe("managed profile lifecycle routes", () => { expect(childResponse.status).toBe(201); const profileId = ((await childResponse.json()) as { managedProfile: { profileId: string } }).managedProfile.profileId; const member = await createTestUser(); - const { invitation } = await createManagedProfileInvitation(owner.manager.id, profileId, { email: member.email, role: "manager" }); + const { invitation } = await createManagedProfileInvitation(owner.manager.id, owner.manager.id, { email: member.email, role: "manager" }); await readOrAcceptManagedProfileInvitation({ profileId: member.id, email: member.email, emailConfirmedAt: new Date().toISOString() }, invitation.id, true); const secret = await createTestApiKey({ userId: member.id }); spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ user_id: member.id, valid: true }); @@ -92,7 +93,7 @@ describe("managed profile lifecycle routes", () => { for (const role of ["manager", "read_only"] as const) { it(`restricts retained profiles and invalidates historical bootstrap for a ${role} ${auth} actor`, async () => { const { member, owner, profileId, secret } = await invitedManager(); - if (role === "read_only") await changeManagedProfileMember(owner.manager.id, profileId, member.id, role); + if (role === "read_only") await changeManagedProfileMember(owner.manager.id, owner.manager.id, member.id, role); spyOn(SupabaseAuthService, "verifyToken").mockImplementation(async token => ({ user_id: token, valid: true })); const headers: Record = auth === "bearer" ? { Authorization: `Bearer ${member.id}` } @@ -140,7 +141,7 @@ describe("managed profile lifecycle routes", () => { const retained = await fetch(childUrl, { headers: ownerHeaders }); expect(retained.status).toBe(200); expect(await retained.json()).toMatchObject({ - actor: { canProvisionManagedProfiles: true, hasMemberships: false }, + actor: { canProvisionManagedProfiles: true, hasMemberships: true }, managedProfile: { profileId, status: "deleted" } }); for (const status of ["all", "deleted"] as const) { @@ -153,7 +154,7 @@ describe("managed profile lifecycle routes", () => { const owned = await fetch(`${baseUrl}?status=${status}`, { headers: ownerHeaders }); expect(owned.status).toBe(200); expect(await owned.json()).toMatchObject({ - actor: { canProvisionManagedProfiles: true, hasMemberships: false }, + actor: { canProvisionManagedProfiles: true, hasMemberships: true }, managedProfiles: [{ profileId, status: "deleted" }], pagination: { total: 1 } }); } @@ -169,7 +170,7 @@ describe("managed profile lifecycle routes", () => { it(`uses membership history, not the ${auth} selector, to distinguish invalid bootstrap from unknown-child probing`, async () => { const { member, owner, profileId, secret } = await invitedManager(); - await removeManagedProfileMember(owner.manager.id, profileId, member.id); + await removeManagedProfileMember(owner.manager.id, owner.manager.id, member.id); const stranger = await createTestUser(); const strangerCredential = await createTestApiKey({ userId: stranger.id }); spyOn(SupabaseAuthService, "verifyToken").mockImplementation(async token => ({ user_id: token, valid: true })); @@ -207,8 +208,8 @@ describe("managed profile lifecycle routes", () => { expect(initial.status).toBe(201); const existing = (await initial.json()) as { id: string }; const beforeService = async () => { - if (change === "remove") await removeManagedProfileMember(owner.manager.id, profileId, member.id); - else await changeManagedProfileMember(owner.manager.id, profileId, member.id, "read_only"); + if (change === "remove") await removeManagedProfileMember(owner.manager.id, owner.manager.id, member.id); + else await changeManagedProfileMember(owner.manager.id, owner.manager.id, member.id, "read_only"); }; // This boundary is reached only after the real route middleware has authorized the member. const create = credentialService.createManagedProfileCredential; @@ -254,7 +255,7 @@ describe("managed profile lifecycle routes", () => { } const revokeResponse = await fetch(`${url}/${keys[0].id}`, { headers: { Authorization: "Bearer member-token" }, method: "DELETE" }); expect(revokeResponse.status).toBe(204); - await removeManagedProfileMember(owner.manager.id, profileId, member.id); + await removeManagedProfileMember(owner.manager.id, owner.manager.id, member.id); expect((await ApiCredential.findByPk(keys[1].id))?.revokedAt).toBeNull(); expect(await credentialService.validatePublicKey(keys[1].publicKey)).toMatchObject({ profileId, strength: "public" }); expect(await credentialService.validateSecretKey(keys[1].secretKey)).toMatchObject({ profileId, strength: "secret" }); @@ -262,6 +263,58 @@ describe("managed profile lifecycle routes", () => { expect((await fetch(`${url}/${keys[1].id}`, { headers: { "X-API-Key": secret.plaintextKey }, method: "DELETE" })).status).toBe(404); }); + it("applies credential authority to all organization children and masks children created after removal", async () => { + const { member, owner, profileId, secret } = await invitedManager(); + const children = [profileId]; + const createChild = async (suffix: string, headers = owner.headers) => { + const response = await fetch(baseUrl, { + headers, method: "POST", + body: JSON.stringify({ contactEmail: `${suffix}@example.com`, customerType: "individual", externalSubjectId: suffix }) + }); + expect(response.status).toBe(201); + return ((await response.json()) as { managedProfile: { profileId: string } }).managedProfile.profileId; + }; + children.push(await createChild("org-sibling"), await createChild("org-later")); + const foreignOwner = await createManager(); + const foreign = await createChild("foreign-child", foreignOwner.headers); + const headers = { "Content-Type": "application/json", "X-API-Key": secret.plaintextKey }; + const credentials: Array<{ id: string; secretKey: string }> = []; + for (const child of children) { + const response = await fetch(`${baseUrl}/${child}/api-credentials`, { + headers, method: "POST", body: JSON.stringify({ name: "Shared company integration" }) + }); + expect(response.status).toBe(201); + credentials.push(await response.json() as { id: string; secretKey: string }); + expect((await fetch(`${baseUrl}/${child}`, { headers, method: "DELETE" })).status).toBe(403); + } + expect((await fetch(`${baseUrl}/${foreign}/api-credentials`, { headers, method: "POST", body: "{}" })).status).toBe(404); + for (const auth of [headers, { Authorization: "Bearer member-token", "Content-Type": "application/json" }]) { + expect((await fetch(baseUrl, { + headers: auth, method: "POST", + body: JSON.stringify({ contactEmail: "denied-create@example.com", customerType: "individual", externalSubjectId: "denied-create" }) + })).status).toBe(403); + } + await changeManagedProfileMember(owner.manager.id, owner.manager.id, member.id, "read_only"); + for (const [index, child] of children.entries()) { + expect((await fetch(`${baseUrl}/${child}/api-credentials`, { headers })).status).toBe(200); + expect((await fetch(`${baseUrl}/${child}/api-credentials`, { headers, method: "POST", body: "{}" })).status).toBe(403); + expect((await fetch(`${baseUrl}/${child}/api-credentials/${credentials[index].id}`, { headers, method: "DELETE" })).status).toBe(403); + } + await removeManagedProfileMember(owner.manager.id, owner.manager.id, member.id); + for (const [index, child] of children.entries()) { + expect((await fetch(`${baseUrl}/${child}/api-credentials`, { headers })).status).toBe(404); + expect(await credentialService.validateSecretKey(credentials[index].secretKey)).toMatchObject({ profileId: child }); + } + const future = await createChild("after-org-removal"); + for (const child of [future, foreign, crypto.randomUUID()]) { + const response = await fetch(`${baseUrl}/${child}`, { headers: { ...headers, "X-Managed-Profile-Id": child } }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + error: { code: "MANAGED_PROFILE_NOT_FOUND", message: "Managed profile was not found", status: 404 } + }); + } + }); + it("requires authentication but returns the empty actor projection without active enablement", async () => { expect((await fetch(baseUrl)).status).toBe(401); const inactive = await createManager(false); @@ -329,7 +382,7 @@ describe("managed profile lifecycle routes", () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ - actor: { canProvisionManagedProfiles: true, hasMemberships: false, profileId: manager.id }, + actor: { canProvisionManagedProfiles: true, hasMemberships: true, profileId: manager.id }, managedProfiles: [], pagination: { limit: 50, offset: 0, total: 0 } }); @@ -415,13 +468,13 @@ describe("managed profile lifecycle routes", () => { await ManagedProfileMembership.bulkCreate([ { createdByProfileId: owner.manager.id, - managedProfileId: profileId, + ownerProfileId: owner.manager.id, memberProfileId: managerMember.manager.id, role: "manager" }, { createdByProfileId: owner.manager.id, - managedProfileId: profileId, + ownerProfileId: owner.manager.id, memberProfileId: readOnlyMember.id, role: "read_only" } @@ -469,7 +522,7 @@ describe("managed profile lifecycle routes", () => { expect((await fetch(revokeUrl, { headers: readOnlyHeaders, method: "DELETE" })).status).toBe(403); expect((await fetch(revokeUrl, { headers: bearerHeaders, method: "DELETE" })).status).toBe(204); - const membership = await ManagedProfileMembership.findOne({ where: { managedProfileId: profileId, memberProfileId: member.id } }); + const membership = await ManagedProfileMembership.findOne({ where: { ownerProfileId: owner.manager.id, memberProfileId: member.id } }); await membership!.update({ role: "read_only" }); expect((await fetch(`${baseUrl}/${profileId}/api-credentials`, { headers: bearerHeaders })).status).toBe(200); expect((await fetch(`${baseUrl}/${profileId}/api-credentials`, { diff --git a/apps/api/src/api/middlewares/managedProfileAuth.test.ts b/apps/api/src/api/middlewares/managedProfileAuth.test.ts index d4a043838..8575dce85 100644 --- a/apps/api/src/api/middlewares/managedProfileAuth.test.ts +++ b/apps/api/src/api/middlewares/managedProfileAuth.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; +import { Op } from "sequelize"; import CustomerEntity from "../../models/customerEntity.model"; import ManagedProfile from "../../models/managedProfile.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; @@ -66,7 +67,9 @@ describe("authorizeManagedProfile", () => { isActive: true })) as never; ManagedProfileMembership.findOne = mock(async () => ({ id: "membership-1", role: "manager" })) as never; - ManagedProfile.findOne = mock(async () => ({ id: "relationship-1", managerProfileId: OWNER_ID })) as never; + ManagedProfile.findOne = mock(async () => ({ + id: "relationship-1", managerProfileId: OWNER_ID, createdAt: new Date("2026-01-01"), deletedAt: null + })) as never; User.findByPk = mock(async () => ({ activeCustomerEntityId: "entity-1", kind: "managed" })) as never; CustomerEntity.findAll = mock(async () => [{ id: "entity-1", status: "active", type: "individual" }]) as never; } @@ -162,6 +165,9 @@ describe("authorizeManagedProfile", () => { userId: MANAGER_ID }); expect(Object.isFrozen(req.managedProfileContext)).toBe(true); + expect(ManagedProfileMembership.findOne).toHaveBeenCalledWith({ + where: { ownerProfileId: OWNER_ID, memberProfileId: MANAGER_ID, revokedAt: null } + }); expect(next).toHaveBeenCalledTimes(1); }); @@ -187,6 +193,38 @@ describe("authorizeManagedProfile", () => { expect(denied.body).toMatchObject({ error: { code: "MANAGED_PROFILE_MANAGER_REQUIRED" } }); }); + it("uses one live organization grant for every child and denies foreign owners", async () => { + allowManagedProfile(); + const siblingIds = [CHILD_ID, "44444444-4444-4444-8444-444444444444", "55555555-5555-4555-8555-555555555555"]; + const foreignId = "66666666-6666-4666-8666-666666666666"; + let role = "manager"; + let revoked = false; + ManagedProfile.findOne = mock(async ({ where }: { where: { profileId: string } }) => ({ + id: where.profileId, managerProfileId: where.profileId === foreignId ? "foreign-owner" : OWNER_ID + })) as never; + ManagedProfileMembership.findOne = mock(async ({ where }: { where: { ownerProfileId: string; memberProfileId: string; revokedAt: null } }) => + where.ownerProfileId === OWNER_ID && where.memberProfileId === MANAGER_ID && where.revokedAt === null && !revoked + ? { id: "one-org-grant", role } : null + ) as never; + for (const state of ["manager", "read_only", "revoked"]) { + role = state; + revoked = state === "revoked"; + for (const subject of [...siblingIds, foreignId]) { + for (const capability of Object.values(ManagedProfileCapability)) { + const next = mock(() => {}); + const res = response(); + await authorizeManagedProfile({ capability })(request({ + get: () => subject, userId: undefined, authenticatedCredentialProfileId: MANAGER_ID, + credential: { profileId: MANAGER_ID, strength: "secret" } + }) as never, res as never, next); + const allowed = subject !== foreignId && !revoked && (role === "manager" || capability === ManagedProfileCapability.Read); + expect(next.mock.calls.length).toBe(allowed ? 1 : 0); + expect(res.statusCode).toBe(allowed ? 200 : 403); + } + } + } + }); + it("rejects provider mutations and ramp execution for a selected-child bearer session", async () => { allowManagedProfile(); @@ -275,7 +313,12 @@ describe("authorizeManagedProfile", () => { })(request() as never, historicalResponse as never, mock(() => {})); expect(historicalResponse.body).toMatchObject({ error: { code: "MANAGED_PROFILE_MEMBERSHIP_INVALID" } }); expect(ManagedProfileMembership.count).toHaveBeenCalledWith({ - where: { managedProfileId: CHILD_ID, memberProfileId: MANAGER_ID } + where: { + ownerProfileId: OWNER_ID, + memberProfileId: MANAGER_ID, + createdAt: { [Op.lte]: expect.any(Date) }, + [Op.or]: [{ revokedAt: null }, { revokedAt: { [Op.gt]: new Date("2026-01-01") } }] + } }); }); diff --git a/apps/api/src/api/middlewares/managedProfileAuth.ts b/apps/api/src/api/middlewares/managedProfileAuth.ts index 356d0a107..9a8378c3f 100644 --- a/apps/api/src/api/middlewares/managedProfileAuth.ts +++ b/apps/api/src/api/middlewares/managedProfileAuth.ts @@ -174,10 +174,7 @@ export function authorizeManagedProfile(options: ManagedProfileAuthOptions) { next(); return; } - const [membership, relationship, subject] = await Promise.all([ - ManagedProfileMembership.findOne({ - where: { managedProfileId: subjectProfileId, memberProfileId: actorProfileId, revokedAt: null } - }), + const [relationship, subject] = await Promise.all([ ManagedProfile.findOne({ where: { profileId: subjectProfileId, status: "active" } }), @@ -189,6 +186,9 @@ export function authorizeManagedProfile(options: ManagedProfileAuthOptions) { else sendAccessDenied(res); return; } + const membership = await ManagedProfileMembership.findOne({ + where: { memberProfileId: actorProfileId, ownerProfileId: relationship.managerProfileId, revokedAt: null } + }); if (!membership || !isMembershipRole(membership.role)) { if (options.subjectProfileId) sendManagedProfileNotFound(res); else sendAccessDenied(res); diff --git a/apps/api/src/api/routes/v1/admin-console/admin-console.route.test.ts b/apps/api/src/api/routes/v1/admin-console/admin-console.route.test.ts index 27a59901d..ed449e84a 100644 --- a/apps/api/src/api/routes/v1/admin-console/admin-console.route.test.ts +++ b/apps/api/src/api/routes/v1/admin-console/admin-console.route.test.ts @@ -2,13 +2,13 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn import express from "express"; import { config } from "../../../../config/vars"; import AdminImpersonationSession from "../../../../models/adminImpersonationSession.model"; -import ManagedProfileManager from "../../../../models/managedProfileManager.model"; import ProfileRole from "../../../../models/profileRole.model"; import { resetTestDatabase, setupTestDatabase } from "../../../../test-utils/db"; import { createTestAlfredpayCustomer, createTestUser } from "../../../../test-utils/factories"; import { SupabaseAuthService } from "../../../services/auth"; import { createSession } from "../../../services/impersonation.service"; import { createManagedProfile } from "../../../services/managed-profile-lifecycle.service"; +import { configureManagedProfileManager } from "../../../services/managed-profile-manager.service"; import accountsRoutes from "./accounts.route"; import impersonationRoutes from "./impersonation.route"; @@ -77,7 +77,12 @@ describe("admin-console routes", () => { it("identifies a managed profile and its authenticated manager", async () => { const admin = await createAdmin(); const manager = await createTestUser(); - await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: manager.id }); + await configureManagedProfileManager({ + allowedCorridors: ["BR"], + allowedCustomerTypes: null, + isActive: true, + profileId: manager.id + }); const { managedProfile } = await createManagedProfile({ contactEmail: "managed-child@example.com", creationSource: "vortex", diff --git a/apps/api/src/api/routes/v1/api-credentials.route.test.ts b/apps/api/src/api/routes/v1/api-credentials.route.test.ts index f026606e0..cdc490895 100644 --- a/apps/api/src/api/routes/v1/api-credentials.route.test.ts +++ b/apps/api/src/api/routes/v1/api-credentials.route.test.ts @@ -1,12 +1,12 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; import express from "express"; import { config } from "../../../config/vars"; -import ManagedProfileManager from "../../../models/managedProfileManager.model"; import ProfileRole from "../../../models/profileRole.model"; import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; import { createTestUser } from "../../../test-utils/factories"; import { SupabaseAuthService } from "../../services/auth"; import { createSession } from "../../services/impersonation.service"; +import { configureManagedProfileManager } from "../../services/managed-profile-manager.service"; import apiCredentialsRoutes from "./api-credentials.route"; import managedProfilesRoutes from "./managed-profiles.route"; @@ -108,7 +108,9 @@ describe("rejectImpersonation wiring on credential routes", () => { const actor = await createTestUser(); const target = await createTestUser(); await ProfileRole.create({ role: "vortex_admin", userId: actor.id }); - await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: target.id }); + await configureManagedProfileManager({ + allowedCorridors: ["BR"], allowedCustomerTypes: null, isActive: true, profileId: target.id + }); const { token } = await createSession({ actorProfileId: actor.id, targetProfileId: target.id }); const headers = { Authorization: `Bearer ${token}` }; diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index f1d88a7bf..538f092d0 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -226,8 +226,8 @@ router.use("/onboarding", onboardingRoutes); /** One-record API credential management for authenticated Supabase users. */ router.use("/api-credentials", apiCredentialsRoutes); -router.use("/managed-profiles", managedProfileMembershipRoutes); -router.use("/managed-profile-member-invitations", managedProfileInviteeRoutes); +router.use("/organization", managedProfileMembershipRoutes); +router.use("/organization-member-invitations", managedProfileInviteeRoutes); router.use("/managed-profiles", managedProfilesRoutes); /** diff --git a/apps/api/src/api/routes/v1/managed-profile-memberships.integration.test.ts b/apps/api/src/api/routes/v1/managed-profile-memberships.integration.test.ts new file mode 100644 index 000000000..b723dd6cf --- /dev/null +++ b/apps/api/src/api/routes/v1/managed-profile-memberships.integration.test.ts @@ -0,0 +1,152 @@ +import { afterAll, beforeAll, describe, expect, it, spyOn } from "bun:test"; +import { config } from "../../../config/vars"; +import Membership from "../../../models/managedProfileMembership.model"; +import MembershipEvent from "../../../models/managedProfileMembershipEvent.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { createTestUser } from "../../../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../../../test-utils/fake-world"; +import { startTestApp, type TestApp } from "../../../test-utils/test-app"; +import { SupabaseAuthService } from "../../services/auth"; + +describe("organization production route integration", () => { + let app: TestApp; + let world: FakeWorld; + + beforeAll(async () => { + world = installFakeWorld(); + await setupTestDatabase(); + app = await startTestApp(); + }); + afterAll(async () => { + await app?.close(); + world?.restore(); + }); + + it("configures an empty organization through admin HTTP and inherits accepted membership across existing and future children", async () => { + await resetTestDatabase(); + const owner = await createTestUser({ id: "abcdefab-1234-4567-89ab-abcdefabcdef", email: "owner@example.com" }); + const invitee = await createTestUser({ email: "invitee@example.com" }); + const outsider = await createTestUser({ email: "outsider@example.com" }); + const originalDashboardUrl = config.dashboardPublicUrl; + config.dashboardPublicUrl = "https://dashboard.example.com"; + const auth = spyOn(SupabaseAuthService, "verifyToken").mockImplementation(async token => { + const profile = [owner, invitee, outsider].find(profile => profile.id === token); + return profile + ? { valid: true, user_id: profile.id, email: profile.email!, email_confirmed_at: "2026-09-01T00:00:00Z" } + : { valid: false }; + }); + const request = (path: string, token: string, method = "GET", body?: unknown) => + app.request(`/v1/${path}`, { + method, + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }) + }); + try { + const states = [ + [owner.id.toUpperCase(), true, 201], + [owner.id.toUpperCase(), true, 200], + ["AbCdEfAb-1234-4567-89Ab-aBcDeFaBcDeF", false, 200], + [owner.id.toUpperCase(), true, 200] + ] as const; + for (const [id, isActive, status] of states) { + const response = await request(`admin/managed-profile-managers/${id}`, config.adminSecret!, "PUT", { + allowedCorridors: ["BR"], + allowedCustomerTypes: null, + isActive + }); + expect(response.status).toBe(status); + expect(await response.json()).toMatchObject({ manager: { profileId: owner.id, isActive } }); + } + expect(await Membership.count()).toBe(1); + expect(await MembershipEvent.count()).toBe(1); + expect(await Membership.findOne()).toMatchObject({ + ownerProfileId: owner.id, + memberProfileId: owner.id, + createdByProfileId: null + }); + expect(await MembershipEvent.findOne()).toMatchObject({ + ownerProfileId: owner.id, + memberProfileId: owner.id, + actorProfileId: null, + action: "member_added" + }); + expect(await (await request("managed-profiles", owner.id)).json()).toMatchObject({ + managedProfiles: [], + pagination: { total: 0 } + }); + + const invited = await request(`organization/member-invitations?expectedOwnerProfileId=${owner.id}`, owner.id, "POST", { + email: invitee.email, + role: "read_only" + }); + expect(invited.status).toBe(201); + const { invitation } = await invited.json(); + // A child created while the invitation is pending is covered upon acceptance. + const existing = await request( + `admin/managed-profile-managers/${owner.id}/managed-profiles`, + config.adminSecret!, + "POST", + { contactEmail: "existing@example.com", customerType: "business", externalSubjectId: "existing" } + ); + expect(existing.status).toBe(201); + const existingId = (await existing.json()).managedProfile.profileId; + const accepted = await request(`organization-member-invitations/${invitation.id}/accept`, invitee.id, "POST", {}); + expect(accepted.status).toBe(200); + expect(await accepted.json()).toMatchObject({ + ownerProfileId: owner.id, + member: { memberProfileId: invitee.id, role: "read_only", isOwner: false } + }); + const discovery = await request("organization", invitee.id); + expect(discovery.status).toBe(200); + expect(await discovery.json()).toEqual({ + organization: { ownerProfileId: owner.id, ownerEmail: owner.email, membership: { role: "read_only", isOwner: false } } + }); + const future = await request("managed-profiles", owner.id, "POST", { + contactEmail: "future@example.com", + customerType: "individual", + externalSubjectId: "future" + }); + expect(future.status).toBe(201); + const futureId = (await future.json()).managedProfile.profileId; + + expect( + ( + await request(`admin/managed-profile-managers/${outsider.id}`, config.adminSecret!, "PUT", { + allowedCorridors: ["BR"], + isActive: true + }) + ).status + ).toBe(201); + const foreign = await request("managed-profiles", outsider.id, "POST", { + contactEmail: "foreign@example.com", + customerType: "business", + externalSubjectId: "foreign" + }); + expect(foreign.status).toBe(201); + const foreignId = (await foreign.json()).managedProfile.profileId; + const listed = await request("managed-profiles", invitee.id); + expect(listed.status).toBe(200); + const roster = await listed.json(); + expect(roster.pagination.total).toBe(2); + expect(roster.managedProfiles.map((child: { profileId: string }) => child.profileId).sort()).toEqual( + [existingId, futureId].sort() + ); + expect( + roster.managedProfiles.every( + (child: { membership: { role: string; isOwner: boolean } }) => + child.membership.role === "read_only" && !child.membership.isOwner + ) + ).toBe(true); + expect((await request(`managed-profiles/${foreignId}`, invitee.id)).status).toBe(404); + expect(await Membership.count({ where: { memberProfileId: invitee.id, revokedAt: null } })).toBe(1); + expect((await request(`managed-profiles/${existingId}/members`, owner.id)).status).toBe(404); + expect((await request(`managed-profile-member-invitations/${invitation.id}`, invitee.id)).status).toBe(404); + expect((await request(`managed-profile-member-invitations/${invitation.id}/accept`, invitee.id, "POST", {})).status).toBe( + 404 + ); + } finally { + auth.mockRestore(); + config.dashboardPublicUrl = originalDashboardUrl; + } + }); +}); diff --git a/apps/api/src/api/routes/v1/managed-profile-memberships.route.test.ts b/apps/api/src/api/routes/v1/managed-profile-memberships.route.test.ts index a003f4f32..fd753265d 100644 --- a/apps/api/src/api/routes/v1/managed-profile-memberships.route.test.ts +++ b/apps/api/src/api/routes/v1/managed-profile-memberships.route.test.ts @@ -3,8 +3,9 @@ import { inspect } from "node:util"; import express from "express"; import { config } from "../../../config/vars"; import logger from "../../../config/logger"; -import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import EmailNotification from "../../../models/emailNotification.model"; import Membership from "../../../models/managedProfileMembership.model"; +import MembershipEvent from "../../../models/managedProfileMembershipEvent.model"; import Invitation from "../../../models/managedProfileMembershipInvitation.model"; import type User from "../../../models/user.model"; import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; @@ -12,6 +13,8 @@ import { createTestUser } from "../../../test-utils/factories"; import { SupabaseAuthService } from "../../services/auth"; import * as notifications from "../../services/email/notification.service"; import * as impersonation from "../../services/impersonation.service"; +import { configureManagedProfileManager } from "../../services/managed-profile-manager.service"; +import * as memberships from "../../services/managed-profile-membership.service"; import { createManagedProfileInvitation } from "../../services/managed-profile-membership.service"; import { provisionManagedProfile } from "../../services/managed-profile-provisioning.service"; import membershipRoutes, { managedProfileInviteeRoutes } from "./managed-profile-memberships.route"; @@ -28,9 +31,9 @@ describe("managed-profile membership HTTP API", () => { await setupTestDatabase(); const app = express(); app.use(express.json()); - app.use("/v1/managed-profiles", membershipRoutes); + app.use("/v1/organization", membershipRoutes); app.get("/v1/managed-profiles", (_req, res) => res.json({ lifecycle: true })); - app.use("/v1/managed-profile-member-invitations", managedProfileInviteeRoutes); + app.use("/v1/organization-member-invitations", managedProfileInviteeRoutes); server = app.listen(0); const address = server.address(); if (!address || typeof address === "string") throw new Error("Could not bind test server"); @@ -41,7 +44,12 @@ describe("managed-profile membership HTTP API", () => { await resetTestDatabase(); owner = await createTestUser(); invitee = await createTestUser({ email: "stale@example.com" }); - await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: owner.id }); + await configureManagedProfileManager({ + allowedCorridors: ["BR"], + allowedCustomerTypes: null, + isActive: true, + profileId: owner.id + }); child = ( await provisionManagedProfile({ contactEmail: "child@example.com", @@ -74,11 +82,22 @@ describe("managed-profile membership HTTP API", () => { ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); } - const team = (suffix: string) => `managed-profiles/${child}/${suffix}`; - const invitePath = (id: string) => `managed-profile-member-invitations/${id}`; + const team = (suffix: string, expectedOwnerProfileId = owner.id) => + `organization/${suffix}${suffix.includes("?") ? "&" : "?"}expectedOwnerProfileId=${expectedOwnerProfileId}`; + const invitePath = (id: string) => `organization-member-invitations/${id}`; - it("mounts all nine endpoints and preserves the lifecycle prefix", async () => { + it("mounts organization endpoints without old aliases and preserves the lifecycle prefix", async () => { expect(await (await request("managed-profiles", "GET", undefined, "")).json()).toEqual({ lifecycle: true }); + expect(await (await request("organization")).json()).toEqual({ + organization: { + ownerProfileId: owner.id, + ownerEmail: owner.email, + membership: { role: "manager", isOwner: true } + } + }); + expect(await (await request("organization", "GET", undefined, "invitee")).json()).toEqual({ organization: null }); + expect((await request(`managed-profiles/${child}/members`)).status).toBe(404); + expect((await request(`managed-profile-member-invitations/${crypto.randomUUID()}`)).status).toBe(404); const created = await request(team("member-invitations"), "POST", { email: " Invitee@Example.com ", role: "manager" }); expect(created.status).toBe(201); const { invitation } = await created.json(); @@ -87,8 +106,13 @@ describe("managed-profile membership HTTP API", () => { expect((await duplicate.json()).invitation.id).toBe(invitation.id); const preview = await request(invitePath(invitation.id), "GET", undefined, "invitee"); expect(preview.status).toBe(200); - expect((await preview.json()).invitation.status).toBe("pending"); - expect((await request(`${invitePath(invitation.id)}/accept`, "POST", {}, "invitee")).status).toBe(200); + expect(await preview.json()).toMatchObject({ + invitation: { status: "pending", ownerProfileId: owner.id }, + organization: { ownerProfileId: owner.id, ownerEmail: owner.email } + }); + const accepted = await request(`${invitePath(invitation.id)}/accept`, "POST", {}, "invitee"); + expect(accepted.status).toBe(200); + expect(await accepted.json()).toMatchObject({ ownerProfileId: owner.id, member: { memberProfileId: invitee.id } }); const members = await request(team("members")); expect(members.status).toBe(200); expect((await members.json()).members).toEqual( @@ -115,6 +139,7 @@ describe("managed-profile membership HTTP API", () => { it("requires bearer authentication on every route and rejects API credentials even with a bearer", async () => { const id = crypto.randomUUID(); const paths = [ + ["organization", "GET"], [team("members"), "GET"], [team(`members/${id}`), "PATCH"], [team(`members/${id}`), "DELETE"], @@ -134,6 +159,35 @@ describe("managed-profile membership HTTP API", () => { } }); + it("serves an owner's organization and team before any children exist", async () => { + await configureManagedProfileManager({ + allowedCorridors: ["BR"], + allowedCustomerTypes: null, + isActive: true, + profileId: invitee.id + }); + expect(await (await request("organization", "GET", undefined, "invitee")).json()).toMatchObject({ + organization: { + ownerProfileId: invitee.id, + membership: { role: "manager", isOwner: true } + } + }); + expect(await (await request(team("members", invitee.id), "GET", undefined, "invitee")).json()).toMatchObject({ + members: [{ memberProfileId: invitee.id, isOwner: true }], + pagination: { total: 1 } + }); + expect( + ( + await request( + team("member-invitations", invitee.id), + "POST", + { email: "new@example.com", role: "read_only" }, + "invitee" + ) + ).status + ).toBe(201); + }); + it("rejects impersonation for all team and invitee routes", async () => { spyOn(impersonation, "resolveSession").mockResolvedValue({ actorProfileId: crypto.randomUUID(), @@ -144,6 +198,7 @@ describe("managed-profile membership HTTP API", () => { }); const id = crypto.randomUUID(); for (const [path, method] of [ + ["organization", "GET"], [team("members"), "GET"], [team(`members/${id}`), "PATCH"], [team(`members/${id}`), "DELETE"], @@ -160,17 +215,21 @@ describe("managed-profile membership HTTP API", () => { } }); - it("rejects mismatched selectors and any invitee selector, including an empty one", async () => { - const { invitation } = await createManagedProfileInvitation(owner.id, child, { + it("rejects every selector on organization and invitee routes, including an empty one", async () => { + const { invitation } = await createManagedProfileInvitation(owner.id, owner.id, { email: "invitee@example.com", role: "manager" }); - expect((await request(team("members"), "GET", undefined, "owner", { "X-Managed-Profile-Id": child })).status).toBe(200); - expect( - (await request(team("members"), "GET", undefined, "owner", { "X-Managed-Profile-Id": crypto.randomUUID() })).status - ).toBe(403); - for (const selector of [child, ""]) { + for (const selector of [child, crypto.randomUUID(), ""]) { for (const [path, method] of [ + ["organization", "GET"], + [team("members"), "GET"], + [team(`members/${invitee.id}`), "PATCH"], + [team(`members/${invitee.id}`), "DELETE"], + [team("member-invitations"), "GET"], + [team("member-invitations"), "POST"], + [team(`member-invitations/${invitation.id}`), "DELETE"], + [team("member-events"), "GET"], [invitePath(invitation.id), "GET"], [`${invitePath(invitation.id)}/accept`, "POST"] ]) { @@ -181,7 +240,7 @@ describe("managed-profile membership HTTP API", () => { }); it("requires the exact current verified principal on preview and accept, never request email", async () => { - const { invitation } = await createManagedProfileInvitation(owner.id, child, { + const { invitation } = await createManagedProfileInvitation(owner.id, owner.id, { email: "invitee@example.com", role: "manager" }); @@ -205,7 +264,7 @@ describe("managed-profile membership HTTP API", () => { }); it("permits read_only reads but no member or invitation mutation", async () => { - await Membership.create({ managedProfileId: child, memberProfileId: invitee.id, role: "read_only" }); + await Membership.create({ ownerProfileId: owner.id, memberProfileId: invitee.id, role: "read_only" }); for (const suffix of ["members", "member-invitations", "member-events"]) { expect((await request(team(suffix), "GET", undefined, "invitee")).status).toBe(200); } @@ -225,7 +284,7 @@ describe("managed-profile membership HTTP API", () => { const invalidRole = await request(team("member-invitations"), "POST", { email: "other@example.com", role: "owner" }); expect(invalidRole.status).toBe(400); expect((await invalidRole.json()).error.code).toBe("INVALID_MEMBERSHIP_ROLE"); - expect((await request("managed-profiles/not-a-uuid/members")).status).toBe(400); + expect((await request("organization-member-invitations/not-a-uuid")).status).toBe(400); expect((await request(team("members/not-a-uuid"), "DELETE")).status).toBe(400); for (const query of [ "limit=0", @@ -253,11 +312,10 @@ describe("managed-profile membership HTTP API", () => { it("protects the owner with typed conflicts even for uppercase UUID paths", async () => { for (const method of ["PATCH", "DELETE"]) { const response = await request( - `managed-profiles/${child.toUpperCase()}/members/${owner.id.toUpperCase()}`, + team(`members/${owner.id.toUpperCase()}`, owner.id.toUpperCase()), method, method === "PATCH" ? { role: "read_only" } : undefined, - "owner", - { "X-Managed-Profile-Id": child } + "owner" ); expect(response.status).toBe(409); expect((await response.json()).error.code).toBe("MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED"); @@ -287,6 +345,124 @@ describe("managed-profile membership HTTP API", () => { expect(await Invitation.count()).toBe(0); }); + it("requires a scalar expected organization UUID on all seven scoped operations", async () => { + const id = crypto.randomUUID(); + for (const [suffix, method] of [ + ["members", "GET"], + [`members/${id}`, "PATCH"], + [`members/${id}`, "DELETE"], + ["member-invitations", "GET"], + ["member-invitations", "POST"], + [`member-invitations/${id}`, "DELETE"], + ["member-events", "GET"] + ]) { + for (const query of [ + "", + "?expectedOwnerProfileId=", + "?expectedOwnerProfileId=not-a-uuid", + `?expectedOwnerProfileId=${owner.id}&expectedOwnerProfileId=${owner.id}`, + `?expectedOwnerProfileId[value]=${owner.id}` + ]) { + const response = await request( + `organization/${suffix}${query}`, + method, + method === "POST" || method === "PATCH" ? { email: "other@example.com", role: "manager" } : undefined + ); + expect(response.status).toBe(400); + expect((await response.json()).error.code).toBe("MANAGED_PROFILE_INVALID_INPUT"); + } + } + expect(await Invitation.count()).toBe(0); + expect(await EmailNotification.count()).toBe(0); + }); + + it("does not treat the expected organization as authority for an unaffiliated actor", async () => { + const response = await request(team("members"), "GET", undefined, "invitee"); + expect(response.status).toBe(403); + expect((await response.json()).error.code).toBe("MANAGED_PROFILE_ACCESS_DENIED"); + }); + + it("rejects a delayed A-bound request after the actor joins B without retargeting any scoped operation", async () => { + await Membership.create({ ownerProfileId: owner.id, memberProfileId: invitee.id, role: "manager" }); + const otherOwner = await createTestUser(); + await configureManagedProfileManager({ + allowedCorridors: ["BR"], + allowedCustomerTypes: null, + isActive: true, + profileId: otherOwner.id + }); + const offer = await createManagedProfileInvitation(otherOwner.id, otherOwner.id, { + email: "invitee@example.com", + role: "manager" + }); + const resolveOrganization = memberships.getManagedProfileOrganization; + let reached!: () => void; + let release!: () => void; + const resolving = new Promise(resolve => { + reached = resolve; + }); + const resume = new Promise(resolve => { + release = resolve; + }); + let delayed = false; + spyOn(memberships, "getManagedProfileOrganization").mockImplementation(async actor => { + if (actor === invitee.id && !delayed) { + delayed = true; + reached(); + await resume; + } + return resolveOrganization(actor); + }); + const pending = request( + team("member-invitations"), + "POST", + { email: "intended-for-a@example.com", role: "manager" }, + "invitee" + ); + await resolving; + let before!: { invitations: number; events: number; emails: number }; + try { + await memberships.removeManagedProfileMember(owner.id, owner.id, invitee.id); + await memberships.readOrAcceptManagedProfileInvitation( + { profileId: invitee.id, email: "invitee@example.com", emailConfirmedAt: "2026-09-01T00:00:00Z" }, + offer.invitation.id, + true + ); + before = { + invitations: await Invitation.count({ where: { ownerProfileId: otherOwner.id } }), + events: await MembershipEvent.count({ where: { ownerProfileId: otherOwner.id } }), + emails: await EmailNotification.count() + }; + } finally { + release(); + } + const response = await pending; + expect(response.status).toBe(409); + expect((await response.json()).error).toMatchObject({ + code: "ORGANIZATION_CONTEXT_CHANGED", + message: expect.stringMatching(/refresh/i) + }); + for (const [suffix, method] of [ + ["members", "GET"], + [`members/${invitee.id}`, "PATCH"], + [`members/${invitee.id}`, "DELETE"], + ["member-invitations", "GET"], + [`member-invitations/${offer.invitation.id}`, "DELETE"], + ["member-events", "GET"] + ]) { + const scoped = await request(team(suffix), method, method === "PATCH" ? { role: "read_only" } : undefined, "invitee"); + expect(scoped.status).toBe(409); + expect((await scoped.json()).error.code).toBe("ORGANIZATION_CONTEXT_CHANGED"); + } + expect(await Invitation.count({ where: { ownerProfileId: otherOwner.id } })).toBe(before.invitations); + expect(await MembershipEvent.count({ where: { ownerProfileId: otherOwner.id } })).toBe(before.events); + expect(await EmailNotification.count()).toBe(before.emails); + expect(await Membership.findOne({ where: { memberProfileId: invitee.id, revokedAt: null } })).toMatchObject({ + ownerProfileId: otherOwner.id, + role: "manager" + }); + }); + it("applies a rate limit keyed by the authenticated profile", async () => { let response!: Response; for (let i = 0; i < 121; i++) response = await request(team("members?limit=0")); diff --git a/apps/api/src/api/routes/v1/managed-profile-memberships.route.ts b/apps/api/src/api/routes/v1/managed-profile-memberships.route.ts index 63e6f07f9..8102aecd9 100644 --- a/apps/api/src/api/routes/v1/managed-profile-memberships.route.ts +++ b/apps/api/src/api/routes/v1/managed-profile-memberships.route.ts @@ -9,7 +9,8 @@ import { previewInvitation, readInvitations, readMemberEvents, - readMembers + readMembers, + readOrganization } from "../../controllers/managedProfileMemberships.controller"; import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; import { rejectManagedProfileSelection } from "../../middlewares/managedProfileAuth"; @@ -25,17 +26,6 @@ const rejectApiCredentials: RequestHandler = (req, res, next) => { next(); }; -const selectedChildMatchesPath: RequestHandler = (req, res, next) => { - const selected = req.get("X-Managed-Profile-Id"); - if (selected !== undefined && selected.toLowerCase() !== String(req.params.profileId).toLowerCase()) { - res - .status(403) - .json({ error: { code: "MANAGED_PROFILE_ACCESS_DENIED", message: "Managed-profile access is denied", status: 403 } }); - return; - } - next(); -}; - const authenticatedLimiter = rateLimit({ keyGenerator: req => req.userId as string, legacyHeaders: false, @@ -43,21 +33,20 @@ const authenticatedLimiter = rateLimit({ standardHeaders: true, windowMs: 60 * 1000 }); -const session = [rejectApiCredentials, requireAuth, rejectImpersonation, authenticatedLimiter]; -const member = [...session, selectedChildMatchesPath]; +const session = [rejectApiCredentials, requireAuth, rejectImpersonation, rejectManagedProfileSelection, authenticatedLimiter]; -// Route-local checks deliberately leave unrelated lifecycle routes on this prefix untouched. const router = Router(); -router.get("/:profileId/members", ...member, readMembers); -router.patch("/:profileId/members/:memberProfileId", ...member, patchMember); -router.delete("/:profileId/members/:memberProfileId", ...member, deleteMember); -router.get("/:profileId/member-invitations", ...member, readInvitations); -router.post("/:profileId/member-invitations", ...member, postInvitation); -router.delete("/:profileId/member-invitations/:invitationId", ...member, deleteInvitation); -router.get("/:profileId/member-events", ...member, readMemberEvents); +router.get("/", ...session, readOrganization); +router.get("/members", ...session, readMembers); +router.patch("/members/:memberProfileId", ...session, patchMember); +router.delete("/members/:memberProfileId", ...session, deleteMember); +router.get("/member-invitations", ...session, readInvitations); +router.post("/member-invitations", ...session, postInvitation); +router.delete("/member-invitations/:invitationId", ...session, deleteInvitation); +router.get("/member-events", ...session, readMemberEvents); export const managedProfileInviteeRoutes = Router(); -managedProfileInviteeRoutes.get("/:invitationId", ...session, rejectManagedProfileSelection, previewInvitation); -managedProfileInviteeRoutes.post("/:invitationId/accept", ...session, rejectManagedProfileSelection, acceptInvitation); +managedProfileInviteeRoutes.get("/:invitationId", ...session, previewInvitation); +managedProfileInviteeRoutes.post("/:invitationId/accept", ...session, acceptInvitation); export default router; diff --git a/apps/api/src/api/routes/v1/ramp.route.test.ts b/apps/api/src/api/routes/v1/ramp.route.test.ts index a21642cb4..8dfd2c96c 100644 --- a/apps/api/src/api/routes/v1/ramp.route.test.ts +++ b/apps/api/src/api/routes/v1/ramp.route.test.ts @@ -2,7 +2,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test" import express from "express"; import { connect } from "node:net"; import { config } from "../../../config/vars"; -import ManagedProfileManager from "../../../models/managedProfileManager.model"; import ProfileRole from "../../../models/profileRole.model"; import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; import { installFakeSupabaseAuth, testUserToken } from "../../../test-utils/fake-world/fake-auth"; @@ -10,6 +9,7 @@ import { createTestUser } from "../../../test-utils/factories"; import { handler as errorHandler } from "../../middlewares/error"; import { createSession } from "../../services/impersonation.service"; import { createManagedProfile } from "../../services/managed-profile-lifecycle.service"; +import { configureManagedProfileManager } from "../../services/managed-profile-manager.service"; import quoteRoutes from "./quote.route"; import rampRoutes, { managedProfileRampBearerRoutes } from "./ramp.route"; @@ -74,7 +74,7 @@ describe("ramp routes under impersonation", () => { it("rejects selected-child ramp registration, update, and start for a normal Supabase bearer", async () => { const manager = await createTestUser(); - await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: manager.id }); + await configureManagedProfileManager({ allowedCorridors: ["BR"], allowedCustomerTypes: null, isActive: true, profileId: manager.id }); const { managedProfile } = await createManagedProfile({ contactEmail: "bearer-ramp-child@example.com", creationSource: "manager", @@ -98,7 +98,7 @@ describe("ramp routes under impersonation", () => { it("rejects selected-child Supabase ramp requests without draining the body", async () => { const manager = await createTestUser(); - await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: manager.id }); + await configureManagedProfileManager({ allowedCorridors: ["BR"], allowedCustomerTypes: null, isActive: true, profileId: manager.id }); const { managedProfile } = await createManagedProfile({ contactEmail: "undrained-bearer-ramp-child@example.com", creationSource: "manager", diff --git a/apps/api/src/api/services/apiCredential.service.test.ts b/apps/api/src/api/services/apiCredential.service.test.ts index fb9f27311..d50c3850b 100644 --- a/apps/api/src/api/services/apiCredential.service.test.ts +++ b/apps/api/src/api/services/apiCredential.service.test.ts @@ -101,7 +101,7 @@ describe("api credential service", () => { expect(User.findByPk).toHaveBeenCalledWith("child", lockOptions); expect(CustomerEntity.findAll).toHaveBeenCalledWith({ ...lockOptions, where: { profileId: "child" } }); expect(ManagedProfileMembership.findOne).toHaveBeenCalledWith({ - ...lockOptions, where: { managedProfileId: "child", memberProfileId: "member", revokedAt: null } + ...lockOptions, where: { ownerProfileId: "owner", memberProfileId: "member", revokedAt: null } }); if (operation === "create") { expect(ApiCredential.count).toHaveBeenCalledWith(expect.objectContaining({ transaction })); @@ -244,6 +244,7 @@ describe("api credential service", () => { update: mock(async () => credential) }); ApiCredential.findOne = mock(async () => credential) as never; + ManagedProfileMembership.findOne = mock(async () => null) as never; ManagedProfile.findOne = mock(async () => ({ id: "relationship-1", managerProfileId: "manager-1" })) as never; ManagedProfileManager.findByPk = mock(async () => ({ allowedCorridors: ["BR", "MX"], @@ -263,6 +264,7 @@ describe("api credential service", () => { expect(Object.isFrozen(result?.managedProfile)).toBe(true); expect(Object.isFrozen(result?.managedProfile?.allowedCorridors)).toBe(true); expect(Object.isFrozen(result?.managedProfile?.allowedCustomerTypes)).toBe(true); + expect(ManagedProfileMembership.findOne).not.toHaveBeenCalled(); }); it("represents a missing manager customer-type restriction as null", async () => { diff --git a/apps/api/src/api/services/apiCredential.service.ts b/apps/api/src/api/services/apiCredential.service.ts index dd5f54df6..3de4093f1 100644 --- a/apps/api/src/api/services/apiCredential.service.ts +++ b/apps/api/src/api/services/apiCredential.service.ts @@ -186,7 +186,7 @@ async function lockManagedCredentialAuthority( const actor = await User.findByPk(actorProfileId, { transaction }); const membership = await ManagedProfileMembership.findOne({ ...options, - where: { managedProfileId: profileId, memberProfileId: actorProfileId, revokedAt: null } + where: { memberProfileId: actorProfileId, ownerProfileId: relationship.managerProfileId, revokedAt: null } }); if ( entities.length !== 1 || diff --git a/apps/api/src/api/services/avenia/avenia-kyc-import.service.test.ts b/apps/api/src/api/services/avenia/avenia-kyc-import.service.test.ts index a639457cc..93426aa42 100644 --- a/apps/api/src/api/services/avenia/avenia-kyc-import.service.test.ts +++ b/apps/api/src/api/services/avenia/avenia-kyc-import.service.test.ts @@ -115,7 +115,7 @@ function harness(options: HarnessOptions = {}) { return { managerProfileId: "manager-1", profileId: "subject-1", status: "active" }; }) as never; const membership = { - managedProfileId: "subject-1", + ownerProfileId: "manager-1", memberProfileId: "member-1", revokedAt: options.membershipRevoked ? new Date() : null, role: options.membershipRole ?? "manager" @@ -178,7 +178,7 @@ describe("importBrKycToken", () => { }); it.each([ - { managedProfileId: "another-child" }, + { ownerProfileId: "another-owner" }, { memberProfileId: "another-member" }, { role: "read_only" }, { revokedAt: new Date() } diff --git a/apps/api/src/api/services/avenia/avenia-kyc-import.service.ts b/apps/api/src/api/services/avenia/avenia-kyc-import.service.ts index 26f04c17c..c0701e7f2 100644 --- a/apps/api/src/api/services/avenia/avenia-kyc-import.service.ts +++ b/apps/api/src/api/services/avenia/avenia-kyc-import.service.ts @@ -83,7 +83,7 @@ async function assertCurrentImportAuthorization(args: ImportAveniaKycTokenArgs, relationship.profileId !== args.subjectProfileId || relationship.status !== "active" || !membership || - membership.managedProfileId !== args.subjectProfileId || + membership.ownerProfileId !== relationship.managerProfileId || membership.memberProfileId !== args.actorProfileId || membership.role !== "manager" || membership.revokedAt !== null || diff --git a/apps/api/src/api/services/avenia/avenia-standard-kyc.service.test.ts b/apps/api/src/api/services/avenia/avenia-standard-kyc.service.test.ts index b68acb325..1ca70ea03 100644 --- a/apps/api/src/api/services/avenia/avenia-standard-kyc.service.test.ts +++ b/apps/api/src/api/services/avenia/avenia-standard-kyc.service.test.ts @@ -130,7 +130,7 @@ function harness(options: HarnessOptions = {}) { return { managerProfileId: "manager-1", profileId: "subject-1", status: "active" }; }) as never; const membership = { - managedProfileId: "subject-1", + ownerProfileId: "manager-1", memberProfileId: "member-1", revokedAt: options.membershipRevoked ? new Date() : null, role: options.membershipRole ?? "manager" @@ -259,7 +259,7 @@ describe("submitStandardAveniaKyc", () => { }); it.each([ - { managedProfileId: "another-child" }, + { ownerProfileId: "another-owner" }, { memberProfileId: "another-member" }, { role: "read_only" }, { revokedAt: new Date() } diff --git a/apps/api/src/api/services/avenia/avenia-standard-kyc.service.ts b/apps/api/src/api/services/avenia/avenia-standard-kyc.service.ts index a8b39e6f9..fd798dc53 100644 --- a/apps/api/src/api/services/avenia/avenia-standard-kyc.service.ts +++ b/apps/api/src/api/services/avenia/avenia-standard-kyc.service.ts @@ -83,7 +83,7 @@ async function assertCurrentAuthorization(args: SubmitStandardAveniaKycArgs, tra relationship.status !== "active" || (args.actorProfileId !== args.subjectProfileId && (!membership || - membership.managedProfileId !== args.subjectProfileId || + membership.ownerProfileId !== relationship.managerProfileId || membership.memberProfileId !== args.actorProfileId || membership.role !== "manager" || membership.revokedAt !== null)) || diff --git a/apps/api/src/api/services/email/managed-profile-membership-invitation.test.ts b/apps/api/src/api/services/email/managed-profile-membership-invitation.test.ts index 581b0cc6d..d565cfd6f 100644 --- a/apps/api/src/api/services/email/managed-profile-membership-invitation.test.ts +++ b/apps/api/src/api/services/email/managed-profile-membership-invitation.test.ts @@ -92,19 +92,22 @@ describe("managed-profile invitation email", () => { it("renders the invitation link in HTML and text with seven-day, explicit-acceptance copy", () => { const invitationUrl = `https://dashboard.example.com/member-invitations/${invitationId}`; - const rendered = renderNotification(EmailNotification.build({ - locale: "en-US", - payload: { childId: "must-not-appear", invitationUrl, role: "read_only" }, - provider: NotificationProvider.Vortex, - recipientEmail, - resourceId: invitationId, - type: NotificationType.ManagedProfileMembershipInvitation, - userId: null - })); + const rendered = renderNotification( + EmailNotification.build({ + locale: "en-US", + payload: { childId: "must-not-appear", invitationUrl, role: "read_only" }, + provider: NotificationProvider.Vortex, + recipientEmail, + resourceId: invitationId, + type: NotificationType.ManagedProfileMembershipInvitation, + userId: null + }) + ); for (const body of [rendered.html, rendered.text]) { expect(body).toContain(invitationUrl); expect(body).toContain("Seven days after the invitation was created"); expect(body).toContain("explicitly accept"); + expect(body).toContain("all current and future managed profiles in the organization"); expect(body).not.toContain("must-not-appear"); expect(body).not.toContain("read_only"); expect(body).not.toContain(recipientEmail); diff --git a/apps/api/src/api/services/email/templates/managed-profile-membership-invitation.ts b/apps/api/src/api/services/email/templates/managed-profile-membership-invitation.ts index 7c26df902..bdf99c4a0 100644 --- a/apps/api/src/api/services/email/templates/managed-profile-membership-invitation.ts +++ b/apps/api/src/api/services/email/templates/managed-profile-membership-invitation.ts @@ -4,15 +4,15 @@ import { type EmailBody, renderHtml, renderText } from "./layout"; export function renderManagedProfileInvitation(invitationUrl: string): RenderedEmail { const body: EmailBody = { details: [{ label: "Expires", value: "Seven days after the invitation was created" }], - heading: "You have been invited to a managed profile", + heading: "You have been invited to a Vortex organization", intro: - "Sign in to Vortex with the email address that received this invitation to review it. Access is granted only after you explicitly accept.", + "Sign in to Vortex with the email address that received this invitation to review it. Access is granted only after you explicitly accept and applies to all current and future managed profiles in the organization.", links: [{ href: invitationUrl, label: "Review invitation" }], outro: "If you were not expecting this invitation, you can ignore this email." }; return { html: renderHtml(body), - subject: "Your Vortex managed-profile invitation", + subject: "Your Vortex organization invitation", text: `${renderText(body)}\nReview invitation: ${invitationUrl}\n` }; } diff --git a/apps/api/src/api/services/managed-profile-lifecycle.service.test.ts b/apps/api/src/api/services/managed-profile-lifecycle.service.test.ts index d6af1daeb..f5d278970 100644 --- a/apps/api/src/api/services/managed-profile-lifecycle.service.test.ts +++ b/apps/api/src/api/services/managed-profile-lifecycle.service.test.ts @@ -11,14 +11,16 @@ import { getOrCreateCustomerEntityForProfile } from "./customer-entity.service"; import { createCredential, createManagedProfileCredential, revokeManagedProfileCredential } from "./apiCredential.service"; import { deleteManagedProfile, + getManagedProfileActor, getManagedProfile, listManagedProfiles } from "./managed-profile-lifecycle.service"; import { provisionManagedProfile } from "./managed-profile-provisioning.service"; +import { configureManagedProfileManager } from "./managed-profile-manager.service"; async function createManager(isActive = true) { const manager = await createTestUser(); - await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive, profileId: manager.id }); + await configureManagedProfileManager({ allowedCorridors: ["BR"], allowedCustomerTypes: null, isActive, profileId: manager.id }); return manager; } @@ -64,7 +66,7 @@ describe("managed profile lifecycle", () => { }); }); - it("lists only the actor's memberships with per-child role and owner policy", async () => { + it("inherits one organization role across siblings and later children, but not foreign owners", async () => { const owner = await createManager(); const otherOwner = await createManager(); const member = await createTestUser(); @@ -75,7 +77,7 @@ describe("managed profile lifecycle", () => { externalSubjectId: "visible-child", managerProfileId: owner.id }); - await provisionManagedProfile({ + const foreign = await provisionManagedProfile({ contactEmail: "hidden@example.com", creationSource: "manager", customerType: "individual", @@ -83,7 +85,7 @@ describe("managed profile lifecycle", () => { managerProfileId: otherOwner.id }); await ManagedProfileMembership.create({ - managedProfileId: visible.profileId, + ownerProfileId: owner.id, memberProfileId: member.id, role: "read_only" }); @@ -102,6 +104,28 @@ describe("managed profile lifecycle", () => { membership: { isOwner: false, role: "read_only" }, policy: { allowedCorridors: ["BR"], allowedCustomerTypes: null } }); + const later = await provisionManagedProfile({ + contactEmail: "later@example.com", creationSource: "manager", customerType: "individual", + externalSubjectId: "later", managerProfileId: owner.id + }); + const sibling = await provisionManagedProfile({ + contactEmail: "sibling@example.com", creationSource: "manager", customerType: "individual", + externalSubjectId: "sibling", managerProfileId: owner.id + }); + expect((await listManagedProfiles(member.id, { limit: 50, offset: 0, status: "active" })).total).toBe(3); + for (const child of [visible, later, sibling]) { + expect(await getManagedProfile(member.id, child.profileId)).toMatchObject({ membership: { role: "read_only" } }); + await expect(deleteManagedProfile(member.id, child.profileId)).rejects.toMatchObject({ code: "MANAGED_PROFILE_OWNER_REQUIRED" }); + } + await expect(getManagedProfile(member.id, foreign.profileId)).rejects.toMatchObject({ code: "MANAGED_PROFILE_NOT_FOUND" }); + await expect(deleteManagedProfile(member.id, foreign.profileId)).rejects.toMatchObject({ code: "MANAGED_PROFILE_NOT_FOUND" }); + await ManagedProfileMembership.update({ revokedAt: new Date(), revokedByProfileId: owner.id }, { where: { ownerProfileId: owner.id, memberProfileId: member.id } }); + for (const child of [visible, later, sibling]) { + await expect(getManagedProfile(member.id, child.profileId)).rejects.toMatchObject({ code: "MANAGED_PROFILE_NOT_FOUND" }); + } + expect(await listManagedProfiles(member.id, { limit: 50, offset: 0, status: "active" })).toMatchObject({ + actor: { hasMemberships: false }, total: 0 + }); }); it("logically deletes idempotently and revokes every active child credential", async () => { @@ -183,38 +207,79 @@ describe("managed profile lifecycle", () => { it("keeps hasMemberships independent of pagination and owner-only retained filters", async () => { const owner = await createManager(); const otherOwner = await createManager(); + const member = await createTestUser(); + await ManagedProfileMembership.create({ ownerProfileId: owner.id, memberProfileId: member.id, role: "read_only" }); + expect(await getManagedProfileActor(member.id)).toMatchObject({ canProvisionManagedProfiles: false, hasMemberships: true }); + expect(await getManagedProfileActor(owner.id)).toMatchObject({ canProvisionManagedProfiles: true, hasMemberships: true }); const owned = await provisionManagedProfile({ managerProfileId: owner.id, customerType: "individual", creationSource: "manager", contactEmail: "owned-retained@example.com", externalSubjectId: "owned-retained" }); - const delegated = await provisionManagedProfile({ + const active = await provisionManagedProfile({ + managerProfileId: owner.id, customerType: "individual", creationSource: "manager", + contactEmail: "own-active@example.com", externalSubjectId: "own-active" + }); + await provisionManagedProfile({ managerProfileId: otherOwner.id, customerType: "individual", creationSource: "manager", contactEmail: "delegated-active@example.com", externalSubjectId: "delegated-active" }); - await ManagedProfileMembership.create({ managedProfileId: delegated.profileId, memberProfileId: owner.id, role: "read_only" }); await deleteManagedProfile(owner.id, owned.profileId); for (const status of ["active", "all", "deleted"] as const) { const page = await listManagedProfiles(owner.id, { limit: 1, offset: 100, status }); expect(page).toMatchObject({ actor: { canProvisionManagedProfiles: true, hasMemberships: true, profileId: owner.id }, - managedProfiles: [], total: 1 + managedProfiles: [], total: status === "all" ? 2 : 1 }); const firstPage = await listManagedProfiles(owner.id, { limit: 1, offset: 0, status }); - expect(firstPage.managedProfiles.map(row => row.profileId)).toEqual([status === "active" ? delegated.profileId : owned.profileId]); + expect(firstPage.managedProfiles.map(row => row.profileId)).toEqual([status === "deleted" ? owned.profileId : active.profileId]); } - await ManagedProfileManager.update({ isActive: false }, { where: { profileId: otherOwner.id } }); - expect(await listManagedProfiles(owner.id, { limit: 1, offset: 0, status: "all" })).toMatchObject({ - actor: { canProvisionManagedProfiles: true, hasMemberships: false }, total: 1 - }); - await ManagedProfileManager.update({ isActive: true }, { where: { profileId: otherOwner.id } }); - await CustomerEntity.update({ status: "archived" }, { where: { profileId: delegated.profileId } }); + for (const status of ["all", "deleted"] as const) { + await expect(listManagedProfiles(member.id, { limit: 50, offset: 0, status })).rejects.toMatchObject({ code: "MANAGED_PROFILE_OWNER_REQUIRED" }); + } + await expect(getManagedProfile(member.id, owned.profileId)).rejects.toMatchObject({ code: "MANAGED_PROFILE_NOT_FOUND" }); + await CustomerEntity.update({ status: "archived" }, { where: { profileId: active.profileId } }); expect(await listManagedProfiles(owner.id, { limit: 1, offset: 0, status: "active" })).toMatchObject({ - actor: { canProvisionManagedProfiles: true, hasMemberships: false }, managedProfiles: [], total: 0 + actor: { canProvisionManagedProfiles: true, hasMemberships: true }, managedProfiles: [], total: 0 }); - await CustomerEntity.update({ status: "active" }, { where: { profileId: delegated.profileId } }); - await CustomerEntity.create({ profileId: delegated.profileId, status: "active", type: "business" }); + await CustomerEntity.update({ status: "active" }, { where: { profileId: active.profileId } }); + await CustomerEntity.create({ profileId: active.profileId, status: "active", type: "business" }); expect(await listManagedProfiles(owner.id, { limit: 1, offset: 0, status: "active" })).toMatchObject({ - actor: { canProvisionManagedProfiles: true, hasMemberships: false }, managedProfiles: [], total: 0 + actor: { canProvisionManagedProfiles: true, hasMemberships: true }, managedProfiles: [], total: 0 + }); + }); + + it("masks future children and membership gaps while invalidating evidenced bootstrap", async () => { + const owner = await createManager(); + const member = await createTestUser(); + const membership = await ManagedProfileMembership.create({ + ownerProfileId: owner.id, memberProfileId: member.id, role: "manager" + }); + const existing = await provisionManagedProfile({ + managerProfileId: owner.id, customerType: "individual", creationSource: "manager", + contactEmail: "before-revoke@example.com", externalSubjectId: "before-revoke" + }); + await new Promise(resolve => setTimeout(resolve, 5)); + await membership.update({ revokedAt: new Date(), revokedByProfileId: owner.id }); + await expect(getManagedProfile(member.id, existing.profileId, { bootstrap: true })).rejects.toMatchObject({ + code: "MANAGED_PROFILE_MEMBERSHIP_INVALID" + }); + const future = await provisionManagedProfile({ + managerProfileId: owner.id, customerType: "individual", creationSource: "manager", + contactEmail: "after-revoke@example.com", externalSubjectId: "after-revoke" + }); + for (const profileId of [future.profileId, crypto.randomUUID()]) { + await expect(getManagedProfile(member.id, profileId, { bootstrap: true })).rejects.toMatchObject({ + code: "MANAGED_PROFILE_NOT_FOUND" + }); + } + await deleteManagedProfile(owner.id, future.profileId); + await new Promise(resolve => setTimeout(resolve, 5)); + await ManagedProfileMembership.create({ ownerProfileId: owner.id, memberProfileId: member.id, role: "manager" }); + await expect(getManagedProfile(member.id, future.profileId, { bootstrap: true })).rejects.toMatchObject({ + code: "MANAGED_PROFILE_NOT_FOUND" + }); + await expect(getManagedProfile(owner.id, future.profileId, { bootstrap: true })).rejects.toMatchObject({ + code: "MANAGED_PROFILE_MEMBERSHIP_INVALID" }); }); diff --git a/apps/api/src/api/services/managed-profile-lifecycle.service.ts b/apps/api/src/api/services/managed-profile-lifecycle.service.ts index 06b2117e8..68cefff74 100644 --- a/apps/api/src/api/services/managed-profile-lifecycle.service.ts +++ b/apps/api/src/api/services/managed-profile-lifecycle.service.ts @@ -9,6 +9,7 @@ import ManagedProfile, { import ManagedProfileManager from "../../models/managedProfileManager.model"; import ManagedProfileMembership, { type ManagedProfileMembershipRole } from "../../models/managedProfileMembership.model"; import User from "../../models/user.model"; +import { getManagedProfileOrganization } from "./managed-profile-membership.service"; import { provisionManagedProfile } from "./managed-profile-provisioning.service"; export class ManagedProfileLifecycleError extends Error { @@ -134,12 +135,19 @@ export async function createManagedProfile(input: { function eligibleMembershipIncludes(actorProfileId: string): Includeable[] { return [ { - as: "memberships", - model: ManagedProfileMembership, + as: "manager", + include: [ + { + as: "memberships", + model: ManagedProfileMembership, + required: true, + where: { memberProfileId: actorProfileId, revokedAt: null, role: ["manager", "read_only"] } + } + ], + model: ManagedProfileManager, required: true, - where: { memberProfileId: actorProfileId, revokedAt: null, role: ["manager", "read_only"] } + where: { isActive: true } }, - { as: "manager", model: ManagedProfileManager, required: true, where: { isActive: true } }, { as: "profile", include: [ @@ -163,17 +171,13 @@ function eligibleMembershipIncludes(actorProfileId: string): Includeable[] { } export async function getManagedProfileActor(actorProfileId: string): Promise { - const [actorManager, activeMembershipCount] = await Promise.all([ + const [actorManager, organization] = await Promise.all([ ManagedProfileManager.findByPk(actorProfileId), - ManagedProfile.count({ - distinct: true, - include: eligibleMembershipIncludes(actorProfileId), - where: { status: "active" } - }) + getManagedProfileOrganization(actorProfileId) ]); return { canProvisionManagedProfiles: actorManager?.isActive === true, - hasMemberships: activeMembershipCount > 0, + hasMemberships: organization !== null, profileId: actorProfileId }; } @@ -224,8 +228,8 @@ export async function listManagedProfiles( "The managed profile does not have exactly one customer entity" ); } - const memberships = relationship.get("memberships") as ManagedProfileMembership[] | undefined; const manager = relationship.get("manager") as ManagedProfileManager | undefined; + const memberships = manager?.get("memberships") as ManagedProfileMembership[] | undefined; const membership = memberships?.[0]; if (!membership || !manager) { throw new ManagedProfileLifecycleError("MANAGED_PROFILE_CONFLICT", "Managed profile access data is incomplete"); @@ -242,14 +246,16 @@ export async function getManagedProfile( profileId: string, { bootstrap = false }: { bootstrap?: boolean } = {} ): Promise { - const [membership, relationship, subject, entities] = await Promise.all([ - ManagedProfileMembership.findOne({ - where: { managedProfileId: profileId, memberProfileId: actorProfileId, revokedAt: null } - }), + const [relationship, subject, entities] = await Promise.all([ ManagedProfile.findOne({ where: { profileId } }), User.findByPk(profileId, { attributes: ["kind", "activeCustomerEntityId"] }), CustomerEntity.findAll({ where: { profileId } }) ]); + const membership = + relationship && + (await ManagedProfileMembership.findOne({ + where: { memberProfileId: actorProfileId, ownerProfileId: relationship.managerProfileId, revokedAt: null } + })); const manager = relationship && (await ManagedProfileManager.findByPk(relationship.managerProfileId)); if ( !membership || @@ -262,13 +268,18 @@ export async function getManagedProfile( entities[0].status !== "active" || (relationship.status === "deleted" && (bootstrap || relationship.managerProfileId !== actorProfileId)) ) { - // A selector expresses bootstrap intent, not prior access. Only stored history can invalidate selection. + // A selector is not prior access: the child must have existed during a stored organization membership interval. if ( bootstrap && - (membership || - (await ManagedProfileMembership.count({ - where: { managedProfileId: profileId, memberProfileId: actorProfileId } - })) > 0) + relationship && + (await ManagedProfileMembership.count({ + where: { + createdAt: { [Op.lte]: relationship.deletedAt ?? new Date() }, + memberProfileId: actorProfileId, + ownerProfileId: relationship.managerProfileId, + [Op.or]: [{ revokedAt: null }, { revokedAt: { [Op.gt]: relationship.createdAt } }] + } + })) > 0 ) { throw new ManagedProfileLifecycleError( "MANAGED_PROFILE_MEMBERSHIP_INVALID", @@ -315,7 +326,7 @@ export async function deleteManagedProfile(managerProfileId: string, profileId: const membership = await ManagedProfileMembership.findOne({ lock: Transaction.LOCK.UPDATE, transaction, - where: { managedProfileId: profileId, memberProfileId: managerProfileId, revokedAt: null } + where: { memberProfileId: managerProfileId, ownerProfileId: relationship.managerProfileId, revokedAt: null } }); if (membership && ["manager", "read_only"].includes(membership.role)) { throw new ManagedProfileLifecycleError( diff --git a/apps/api/src/api/services/managed-profile-manager.service.test.ts b/apps/api/src/api/services/managed-profile-manager.service.test.ts new file mode 100644 index 000000000..7b33efbab --- /dev/null +++ b/apps/api/src/api/services/managed-profile-manager.service.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { UniqueConstraintError } from "sequelize"; +import ManagedProfileManager from "../../models/managedProfileManager.model"; +import Membership from "../../models/managedProfileMembership.model"; +import MembershipEvent from "../../models/managedProfileMembershipEvent.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestUser } from "../../test-utils/factories"; +import { configureManagedProfileManager } from "./managed-profile-manager.service"; +import { provisionManagedProfile } from "./managed-profile-provisioning.service"; + +const configure = (profileId: string, isActive = true) => + configureManagedProfileManager({ + profileId, + isActive, + allowedCorridors: ["BR"], + allowedCustomerTypes: null + }); + +describe("organization manager configuration", () => { + beforeAll(setupTestDatabase); + beforeEach(resetTestDatabase); + afterEach(() => mock.restore()); + + it("creates exactly one owner membership and event under concurrent first configuration", async () => { + const owner = await createTestUser(); + const results = await Promise.all([configure(owner.id), configure(owner.id)]); + expect(results.map(result => result.created).sort()).toEqual([false, true]); + expect(await ManagedProfileManager.count()).toBe(1); + expect(await Membership.count()).toBe(1); + expect(await Membership.findOne()).toMatchObject({ + ownerProfileId: owner.id, + memberProfileId: owner.id, + role: "manager", + createdByProfileId: null, + revokedAt: null + }); + expect(await MembershipEvent.findOne()).toMatchObject({ + action: "member_added", + ownerProfileId: owner.id, + memberProfileId: owner.id, + actorProfileId: null + }); + await configure(owner.id, false); + await configure(owner.id); + expect(await Membership.count()).toBe(1); + expect(await MembershipEvent.count()).toBe(1); + }); + + it("creates a protected owner membership even for an initially inactive zero-child organization", async () => { + const owner = await createTestUser(); + await configure(owner.id, false); + const membership = await Membership.findOne(); + await expect(membership!.update({ role: "read_only" })).rejects.toThrow("Organization owner membership"); + await expect(membership!.update({ revokedAt: new Date(), revokedByProfileId: owner.id })).rejects.toThrow( + "Organization owner membership" + ); + await expect(membership!.destroy()).rejects.toThrow("Organization owner membership"); + }); + + for (const role of ["manager", "read_only"] as const) { + it(`rejects configuration for an existing ${role} member, even when its organization is inactive`, async () => { + const owner = await createTestUser(); + const member = await createTestUser(); + await configure(owner.id, false); + await Membership.create({ ownerProfileId: owner.id, memberProfileId: member.id, role }); + await expect(configure(member.id)).rejects.toMatchObject({ code: "ORGANIZATION_MEMBERSHIP_CONFLICT" }); + expect(await ManagedProfileManager.count()).toBe(1); + }); + } + + it("rolls first configuration and owner membership back when audit fails", async () => { + const owner = await createTestUser(); + spyOn(MembershipEvent, "create").mockRejectedValue(new Error("audit unavailable")); + await expect(configure(owner.id)).rejects.toThrow("audit unavailable"); + expect(await ManagedProfileManager.count()).toBe(0); + expect(await Membership.count()).toBe(0); + }); + + it("maps a database membership race to the manager error handled as HTTP 409", async () => { + const owner = await createTestUser(); + spyOn(Membership, "create").mockRejectedValue( + new UniqueConstraintError({ + parent: Object.assign(new Error("duplicate membership"), { + constraint: "uq_managed_profile_memberships_active", + sql: "", + code: "23505" + }) + }) + ); + await expect(configure(owner.id)).rejects.toMatchObject({ + name: "ManagedProfileManagerError", + code: "ORGANIZATION_MEMBERSHIP_CONFLICT" + }); + expect(await ManagedProfileManager.count()).toBe(0); + expect(await MembershipEvent.count()).toBe(0); + }); + + it("rejects missing and non-authenticated owners", async () => { + await expect(configure(crypto.randomUUID())).rejects.toMatchObject({ code: "PROFILE_NOT_FOUND" }); + const owner = await createTestUser(); + await configure(owner.id); + const profile = await provisionManagedProfile({ + contactEmail: "child@example.com", + creationSource: "manager", + customerType: "business", + externalSubjectId: "company", + managerProfileId: owner.id + }); + await expect(configure(profile.profileId)).rejects.toMatchObject({ code: "MANAGED_PROFILE_MANAGER_PROFILE_INVALID" }); + }); +}); diff --git a/apps/api/src/api/services/managed-profile-manager.service.ts b/apps/api/src/api/services/managed-profile-manager.service.ts index 832c3d2fe..290b35d3d 100644 --- a/apps/api/src/api/services/managed-profile-manager.service.ts +++ b/apps/api/src/api/services/managed-profile-manager.service.ts @@ -1,12 +1,18 @@ import type { CorridorCountry, CorridorCustomerType } from "@vortexfi/shared"; -import { Transaction } from "sequelize"; +import { Transaction, UniqueConstraintError } from "sequelize"; import sequelize from "../../config/database"; import ManagedProfileManager from "../../models/managedProfileManager.model"; +import Membership from "../../models/managedProfileMembership.model"; +import MembershipEvent from "../../models/managedProfileMembershipEvent.model"; import User from "../../models/user.model"; export class ManagedProfileManagerError extends Error { constructor( - readonly code: "MANAGED_PROFILE_MANAGER_NOT_FOUND" | "MANAGED_PROFILE_MANAGER_PROFILE_INVALID" | "PROFILE_NOT_FOUND", + readonly code: + | "MANAGED_PROFILE_MANAGER_NOT_FOUND" + | "MANAGED_PROFILE_MANAGER_PROFILE_INVALID" + | "PROFILE_NOT_FOUND" + | "ORGANIZATION_MEMBERSHIP_CONFLICT", message: string ) { super(message); @@ -40,37 +46,81 @@ export async function configureManagedProfileManager(input: { isActive: boolean; profileId: string; }): Promise<{ created: boolean; manager: ManagedProfileManagerResult }> { - return sequelize.transaction(async transaction => { - const profile = await User.findByPk(input.profileId, { - lock: Transaction.LOCK.UPDATE, - transaction - }); - if (!profile) { - throw new ManagedProfileManagerError("PROFILE_NOT_FOUND", "Profile was not found"); - } - if (profile.kind !== "authenticated") { - throw new ManagedProfileManagerError( - "MANAGED_PROFILE_MANAGER_PROFILE_INVALID", - "Only authenticated profiles can be managed profile managers" - ); - } + const configured = await sequelize + .transaction(async transaction => { + // Same order as invitation acceptance and child lifecycle: existing configuration, + // then the person. The person lock also serializes first-time configuration creation. + const existing = await ManagedProfileManager.findByPk(input.profileId, { + lock: Transaction.LOCK.UPDATE, + transaction + }); + const profile = await User.findByPk(input.profileId, { + lock: Transaction.LOCK.NO_KEY_UPDATE, + transaction + }); + if (!profile) { + throw new ManagedProfileManagerError("PROFILE_NOT_FOUND", "Profile was not found"); + } + if (profile.kind !== "authenticated") { + throw new ManagedProfileManagerError( + "MANAGED_PROFILE_MANAGER_PROFILE_INVALID", + "Only authenticated profiles can be managed profile managers" + ); + } + + const membership = await Membership.findOne({ + transaction, + where: { memberProfileId: profile.id, revokedAt: null } + }); + if (membership && membership.ownerProfileId !== profile.id) { + throw new ManagedProfileManagerError( + "ORGANIZATION_MEMBERSHIP_CONFLICT", + "A profile may belong to only one organization" + ); + } + // If a first configuration appeared while waiting on the person, release and + // reacquire in configuration-first order rather than upgrading in reverse order. + if (!existing && (await ManagedProfileManager.findByPk(input.profileId, { transaction }))) return null; + if (existing) { + await existing.update( + { + allowedCorridors: input.allowedCorridors, + allowedCustomerTypes: input.allowedCustomerTypes, + isActive: input.isActive + }, + { transaction } + ); + return { created: false, manager: result(existing) }; + } - const existing = await ManagedProfileManager.findByPk(input.profileId, { transaction }); - if (existing) { - await existing.update( + const manager = await ManagedProfileManager.create(input, { transaction }); + // ADMIN_SECRET authenticates the system, not the owner receiving this grant. + await Membership.create({ memberProfileId: profile.id, ownerProfileId: profile.id, role: "manager" }, { transaction }); + await MembershipEvent.create( { - allowedCorridors: input.allowedCorridors, - allowedCustomerTypes: input.allowedCustomerTypes, - isActive: input.isActive + action: "member_added", + memberProfileId: profile.id, + ownerProfileId: profile.id, + role: "manager" }, { transaction } ); - return { created: false, manager: result(existing) }; - } - - const manager = await ManagedProfileManager.create(input, { transaction }); - return { created: true, manager: result(manager) }; - }); + return { created: true, manager: result(manager) }; + }) + .catch(error => { + if ( + error instanceof UniqueConstraintError && + "constraint" in error.original && + error.original.constraint === "uq_managed_profile_memberships_active" + ) { + throw new ManagedProfileManagerError( + "ORGANIZATION_MEMBERSHIP_CONFLICT", + "A profile may belong to only one organization" + ); + } + throw error; + }); + return configured ?? configureManagedProfileManager(input); } export async function getManagedProfileManager(profileId: string): Promise { diff --git a/apps/api/src/api/services/managed-profile-membership.service.test.ts b/apps/api/src/api/services/managed-profile-membership.service.test.ts index b62af730a..718d4a98e 100644 --- a/apps/api/src/api/services/managed-profile-membership.service.test.ts +++ b/apps/api/src/api/services/managed-profile-membership.service.test.ts @@ -1,9 +1,8 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; -import { Transaction } from "sequelize"; +import { Transaction, UniqueConstraintError } from "sequelize"; import sequelize from "../../config/database"; import { config } from "../../config/vars"; import EmailNotification from "../../models/emailNotification.model"; -import ManagedProfile from "../../models/managedProfile.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; import Membership from "../../models/managedProfileMembership.model"; import MembershipEvent from "../../models/managedProfileMembershipEvent.model"; @@ -14,10 +13,12 @@ import { createTestUser } from "../../test-utils/factories"; import { SupabaseAuthService } from "./auth"; import * as notifications from "./email/notification.service"; import { deleteManagedProfile } from "./managed-profile-lifecycle.service"; +import { configureManagedProfileManager } from "./managed-profile-manager.service"; import { cancelManagedProfileInvitation, changeManagedProfileMember, createManagedProfileInvitation, + getManagedProfileOrganization, listManagedProfileInvitations, listManagedProfileMemberEvents, listManagedProfileMembers, @@ -26,17 +27,23 @@ import { } from "./managed-profile-membership.service"; import { provisionManagedProfile } from "./managed-profile-provisioning.service"; -describe("managed-profile membership transactions", () => { +describe("organization membership transactions", () => { const originalDashboardUrl = config.dashboardPublicUrl; let owner: User; let invitee: User; - let child: string; const email = "invitee@example.com"; const principal = () => ({ profileId: invitee.id, email, emailConfirmedAt: "2026-09-01T00:00:00Z" }); + const configure = (profileId: string, isActive = true) => + configureManagedProfileManager({ + allowedCorridors: ["BR"], + allowedCustomerTypes: null, + isActive, + profileId + }); const invite = (role = "manager", recipient = email, actor = owner.id) => - createManagedProfileInvitation(actor, child, { email: recipient, role }); + createManagedProfileInvitation(actor, owner.id, { email: recipient, role }); const accept = (id: string) => readOrAcceptManagedProfileInvitation(principal(), id, true); - const eventCount = (action: string) => MembershipEvent.count({ where: { managedProfileId: child, action } }); + const eventCount = (action: string) => MembershipEvent.count({ where: { ownerProfileId: owner.id, action } }); beforeAll(setupTestDatabase); beforeEach(async () => { @@ -44,39 +51,51 @@ describe("managed-profile membership transactions", () => { await resetTestDatabase(); owner = await createTestUser(); invitee = await createTestUser({ email: "stale@example.com" }); - await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: owner.id }); - child = ( - await provisionManagedProfile({ - contactEmail: "child@example.com", - creationSource: "manager", - customerType: "business", - externalSubjectId: "company", - managerProfileId: owner.id - }) - ).profileId; + await configure(owner.id); }); afterEach(() => { mock.restore(); config.dashboardPublicUrl = originalDashboardUrl; }); - it("normalizes creation, persists seven-day expiry and atomically queues one mail for concurrent duplicates", async () => { - const start = Date.now(); - const settled = await Promise.allSettled([invite("manager", " Invitee@Example.COM "), invite()]); - const results = settled.map(result => { - if (result.status === "rejected") throw result.reason; - return result.value; + it("resolves live organization metadata with zero children and retains grants while inactive", async () => { + expect(await getManagedProfileOrganization(owner.id)).toEqual({ + ownerProfileId: owner.id, + ownerEmail: owner.email, + membership: { role: "manager", isOwner: true } + }); + expect(await getManagedProfileOrganization(invitee.id)).toBeNull(); + await accept((await invite("read_only")).invitation.id); + expect(await getManagedProfileOrganization(invitee.id)).toMatchObject({ + ownerProfileId: owner.id, + membership: { role: "read_only", isOwner: false } + }); + await configure(owner.id, false); + expect(await getManagedProfileOrganization(owner.id)).toBeNull(); + expect(await getManagedProfileOrganization(invitee.id)).toBeNull(); + await expect(listManagedProfileMembers(owner.id, owner.id, 50, 0)).rejects.toMatchObject({ + code: "MANAGED_PROFILE_ACCESS_DENIED" }); + expect(await Membership.count({ where: { revokedAt: null } })).toBe(2); + await configure(owner.id); + expect(await getManagedProfileOrganization(invitee.id)).toMatchObject({ membership: { role: "read_only" } }); + }); + + it("normalizes concurrent duplicate offers and atomically queues one seven-day invitation", async () => { + const start = Date.now(); + const results = await Promise.all([invite("manager", " Invitee@Example.COM "), invite()]); expect(results.map(result => result.created).sort()).toEqual([false, true]); expect(results[0].invitation.id).toBe(results[1].invitation.id); expect(results[0].invitation.email).toBe(email); + expect(results[0].invitation.ownerProfileId).toBe(owner.id); expect(results[0].invitation.expiresAt.getTime()).toBeGreaterThanOrEqual(start + 7 * 86400000); expect(await Invitation.count()).toBe(1); expect(await eventCount("invited")).toBe(1); expect(await EmailNotification.count({ where: { recipientEmail: email, resourceId: results[0].invitation.id } })).toBe(1); }); - it("does not distinguish existing and unregistered nonmember emails", async () => { + it("does not distinguish existing, other-organization, and unregistered nonmember emails", async () => { + await configure(invitee.id); const existing = await invite("read_only", invitee.email!); const unknown = await invite("read_only", "unregistered@example.com"); expect(existing.created).toBe(true); @@ -85,44 +104,37 @@ describe("managed-profile membership transactions", () => { expect(await EmailNotification.count()).toBe(2); }); - it("does not let a member's stale local email veto the current mailbox holder's invitation", async () => { + it("lets the current mailbox holder accept despite another member's stale local email", async () => { const staleMember = await createTestUser({ email }); - await Membership.create({ managedProfileId: child, memberProfileId: staleMember.id, role: "manager" }); - const lookup = spyOn(SupabaseAuthService, "getUserProfile").mockResolvedValue({ + await Membership.create({ ownerProfileId: owner.id, memberProfileId: staleMember.id, role: "manager" }); + spyOn(SupabaseAuthService, "getUserProfile").mockResolvedValue({ id: staleMember.id, email: "changed@example.com", email_confirmed_at: "2026-09-01T00:00:00Z" } as never); - - const { invitation } = await invite(); - expect(lookup).toHaveBeenCalledWith(staleMember.id); - expect(await accept(invitation.id)).toMatchObject({ member: { memberProfileId: invitee.id, role: "manager" } }); - expect(await Membership.count({ where: { managedProfileId: child, revokedAt: null } })).toBe(3); + expect(await accept((await invite()).invitation.id)).toMatchObject({ + member: { memberProfileId: invitee.id, role: "manager" } + }); + expect(await Membership.count({ where: { ownerProfileId: owner.id, revokedAt: null } })).toBe(3); expect(await EmailNotification.count({ where: { recipientEmail: email } })).toBe(1); }); - it("returns 409 only after confirming an existing member's current verified Supabase email", async () => { - await Membership.create({ managedProfileId: child, memberProfileId: invitee.id, role: "manager" }); - const lookup = spyOn(SupabaseAuthService, "getUserProfile").mockResolvedValue({ - id: invitee.id, - email: " STALE@EXAMPLE.COM ", - email_confirmed_at: "2026-09-01T00:00:00Z" - } as never); - await expect(invite("manager", invitee.email!)).rejects.toMatchObject({ code: "MEMBERSHIP_ALREADY_EXISTS", status: 409 }); - expect(lookup).toHaveBeenCalledWith(invitee.id); - expect(await Invitation.count()).toBe(0); - expect(await EmailNotification.count()).toBe(0); - }); - - for (const confirmation of ["unverified", "wrong_profile", "invalid_timestamp", "unavailable"] as const) { - it(`does not veto an invitation on a ${confirmation} member email lookup`, async () => { - await Membership.create({ managedProfileId: child, memberProfileId: invitee.id, role: "manager" }); + for (const confirmation of [ + "confirmed", + "changed", + "unverified", + "wrong_profile", + "invalid_timestamp", + "unavailable" + ] as const) { + it(`only vetoes a roster member email after current verified confirmation: ${confirmation}`, async () => { + await Membership.create({ ownerProfileId: owner.id, memberProfileId: invitee.id, role: "manager" }); const lookup = spyOn(SupabaseAuthService, "getUserProfile"); if (confirmation === "unavailable") lookup.mockRejectedValue(new Error("Auth lookup unavailable")); else lookup.mockResolvedValue({ id: confirmation === "wrong_profile" ? crypto.randomUUID() : invitee.id, - email: invitee.email, + email: confirmation === "changed" ? "changed@example.com" : " STALE@EXAMPLE.COM ", email_confirmed_at: confirmation === "unverified" ? undefined @@ -130,43 +142,23 @@ describe("managed-profile membership transactions", () => { ? "invalid" : "2026-09-01T00:00:00Z" } as never); - expect((await invite("manager", invitee.email!)).created).toBe(true); - expect(await EmailNotification.count()).toBe(1); + if (confirmation === "confirmed") { + await expect(invite("manager", invitee.email!)).rejects.toMatchObject({ + code: "MEMBERSHIP_ALREADY_EXISTS", + status: 409 + }); + expect(await Invitation.count()).toBe(0); + } else expect((await invite("manager", invitee.email!)).created).toBe(true); + expect(lookup).toHaveBeenCalledWith(invitee.id); expect(await Membership.count()).toBe(2); - expect(await eventCount("invitation_accepted")).toBe(0); }); } for (const accepting of [false, true]) { - it(`allows reciprocal owners to ${accepting ? "accept" : "preview"} concurrently without deadlock`, async () => { - await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: invitee.id }); - const otherChild = ( - await provisionManagedProfile({ - contactEmail: "other-child@example.com", - creationSource: "manager", - customerType: "business", - externalSubjectId: "other-company", - managerProfileId: invitee.id - }) - ).profileId; + it(`allows reciprocal owner previews but rejects reciprocal acceptance without deadlock: ${accepting}`, async () => { + await configure(invitee.id); const toInvitee = await invite(); - const toOwner = await createManagedProfileInvitation(invitee.id, otherChild, { email: owner.email!, role: "manager" }); - const original = ManagedProfile.findOne.bind(ManagedProfile); - let release!: () => void; - const bothChildrenLocked = new Promise(resolve => { - release = resolve; - }); - let arrivals = 0; - spyOn(ManagedProfile, "findOne").mockImplementation(async options => { - const row = await original(options); - if (options?.lock) { - if (++arrivals === 2) release(); - await bothChildrenLocked; - } - return row; - }); - - // Both transactions hold their owner/child locks before either requests the opposite human profile. + const toOwner = await createManagedProfileInvitation(invitee.id, invitee.id, { email: owner.email!, role: "manager" }); const results = await Promise.allSettled([ readOrAcceptManagedProfileInvitation(principal(), toInvitee.invitation.id, accepting), readOrAcceptManagedProfileInvitation( @@ -175,119 +167,100 @@ describe("managed-profile membership transactions", () => { accepting ) ]); - expect(results.map(result => (result.status === "rejected" ? result.reason : result.status))).toEqual([ - "fulfilled", - "fulfilled" - ]); - expect(arrivals).toBe(2); - expect(await Membership.count()).toBe(accepting ? 4 : 2); - expect(await MembershipEvent.count({ where: { action: "invitation_accepted" } })).toBe(accepting ? 2 : 0); + for (const result of results) { + if (accepting) { + expect(result.status).toBe("rejected"); + if (result.status === "rejected") + expect(result.reason).toMatchObject({ code: "ORGANIZATION_MEMBERSHIP_CONFLICT", status: 409 }); + } else expect(result.status).toBe("fulfilled"); + } + expect(await Membership.count()).toBe(2); + expect(await MembershipEvent.count({ where: { action: "invitation_accepted" } })).toBe(0); }); } - it("keeps owner and invitee profile changes blocked while their kinds authorize a preview", async () => { - const { invitation } = await invite(); - const original = User.findByPk.bind(User); - let reached!: () => void; - let release!: () => void; - const actorLocked = new Promise(resolve => { - reached = resolve; - }); - const finishPreview = new Promise(resolve => { - release = resolve; - }); - spyOn(User, "findByPk").mockImplementation(async (id, options) => { - const row = await original(id, options); - if (id === invitee.id && options?.lock) { - reached(); - await finishPreview; - } - return row; - }); - const preview = readOrAcceptManagedProfileInvitation(principal(), invitation.id, false).then( - result => result, - error => error - ); - try { - await actorLocked; - for (const id of [owner.id, invitee.id]) { - // Non-key profile changes (including kind) take NO KEY UPDATE, which KEY SHARE alone would allow. - await expect( - sequelize.transaction(transaction => - sequelize.query("SELECT id FROM profiles WHERE id = :id FOR NO KEY UPDATE NOWAIT", { - replacements: { id }, - transaction - }) - ) - ).rejects.toMatchObject({ original: { code: "55P03" } }); - } - } finally { - release(); - await preview; + it("rejects cross-owner team operations rather than allowing reciprocal owner-managers", async () => { + await configure(invitee.id); + for (const [actor, organization] of [ + [owner.id, invitee.id], + [invitee.id, owner.id] + ]) { + await expect( + createManagedProfileInvitation(actor, organization, { email: "other@example.com", role: "manager" }) + ).rejects.toMatchObject({ code: "MANAGED_PROFILE_ACCESS_DENIED" }); + await expect(changeManagedProfileMember(actor, organization, actor, "read_only")).rejects.toMatchObject({ + code: "MANAGED_PROFILE_ACCESS_DENIED" + }); } - expect(await preview).toMatchObject({ invitation: { status: "pending" } }); }); - for (const operation of ["invite", "role_change"] as const) { - it(`allows reciprocal owner-managers to ${operation} without foreign-key deadlocks`, async () => { - await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: invitee.id }); - const otherChild = ( - await provisionManagedProfile({ - contactEmail: "other-child@example.com", - creationSource: "manager", - customerType: "business", - externalSubjectId: "other-company", - managerProfileId: invitee.id - }) - ).profileId; - const target = await createTestUser(); - await Membership.bulkCreate([ - { managedProfileId: child, memberProfileId: invitee.id, role: "manager" }, - { managedProfileId: otherChild, memberProfileId: owner.id, role: "manager" }, - { managedProfileId: child, memberProfileId: target.id, role: "manager" }, - { managedProfileId: otherChild, memberProfileId: target.id, role: "manager" } - ]); - const original = ManagedProfile.findOne.bind(ManagedProfile); + it("serializes concurrent two-organization acceptance for the same person", async () => { + const other = await createTestUser(); + await configure(other.id); + const first = await invite("read_only"); + const second = await createManagedProfileInvitation(other.id, other.id, { email, role: "manager" }); + const results = await Promise.allSettled([accept(first.invitation.id), accept(second.invitation.id)]); + expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1); + const rejected = results.find(result => result.status === "rejected"); + expect(rejected?.status === "rejected" && rejected.reason).toMatchObject({ + code: "ORGANIZATION_MEMBERSHIP_CONFLICT", + status: 409 + }); + expect(await Membership.count({ where: { memberProfileId: invitee.id, revokedAt: null } })).toBe(1); + expect(await Invitation.count({ where: { acceptedByProfileId: invitee.id } })).toBe(1); + expect(await MembershipEvent.count({ where: { action: "invitation_accepted" } })).toBe(1); + }); + + for (const first of ["accept", "configure"] as const) { + it(`serializes acceptance versus first configuration when ${first} takes the person lock first`, async () => { + const { invitation } = await invite(); + const original = User.findByPk.bind(User); + let reached!: () => void; let release!: () => void; - const bothChildrenLocked = new Promise(resolve => { + const locked = new Promise(resolve => { + reached = resolve; + }); + const proceed = new Promise(resolve => { release = resolve; }); - let arrivals = 0; - spyOn(ManagedProfile, "findOne").mockImplementation(async options => { - const row = await original(options); - if (options?.lock) { - if (++arrivals === 2) release(); - await bothChildrenLocked; + let paused = false; + spyOn(User, "findByPk").mockImplementation(async (id, options) => { + const row = await original(id, options); + if (!paused && id === invitee.id && options?.lock === Transaction.LOCK.NO_KEY_UPDATE) { + paused = true; + reached(); + await proceed; } return row; }); - const results = await Promise.allSettled( - operation === "invite" - ? [ - createManagedProfileInvitation(invitee.id, child, { email: "new-member@example.com", role: "manager" }), - createManagedProfileInvitation(owner.id, otherChild, { email: "new-member@example.com", role: "manager" }) - ] - : [ - changeManagedProfileMember(invitee.id, child, target.id, "read_only"), - changeManagedProfileMember(owner.id, otherChild, target.id, "read_only") - ] + const winning = first === "accept" ? accept(invitation.id) : configure(invitee.id); + await locked; + const losing = (first === "accept" ? configure(invitee.id) : accept(invitation.id)).then( + value => value, + error => error ); - expect(results.map(result => (result.status === "rejected" ? result.reason : result.status))).toEqual([ - "fulfilled", - "fulfilled" - ]); - expect(arrivals).toBe(2); - expect(await MembershipEvent.count({ where: { action: operation === "invite" ? "invited" : "role_changed" } })).toBe(2); + release(); + await winning; + expect(await losing).toMatchObject({ code: "ORGANIZATION_MEMBERSHIP_CONFLICT" }); + expect(await Membership.count({ where: { memberProfileId: invitee.id, revokedAt: null } })).toBe(1); + expect(await ManagedProfileManager.count({ where: { profileId: invitee.id } })).toBe(first === "configure" ? 1 : 0); }); } + it("keeps inactive owners affiliated and unable to accept another organization", async () => { + await configure(invitee.id, false); + await expect(accept((await invite()).invitation.id)).rejects.toMatchObject({ + code: "ORGANIZATION_MEMBERSHIP_CONFLICT", + status: 409 + }); + }); + it("requires cancellation before changing a pending role and never sends a duplicate", async () => { const first = await invite(); await expect(invite("read_only")).rejects.toMatchObject({ code: "INVITATION_ROLE_CONFLICT" }); expect(await EmailNotification.count()).toBe(1); - await cancelManagedProfileInvitation(owner.id, child, first.invitation.id); - const replacement = await invite("read_only"); - expect(replacement.invitation.id).not.toBe(first.invitation.id); + await cancelManagedProfileInvitation(owner.id, owner.id, first.invitation.id); + expect((await invite("read_only")).invitation.id).not.toBe(first.invitation.id); expect(await eventCount("invitation_cancelled")).toBe(1); }); @@ -300,54 +273,62 @@ describe("managed-profile membership transactions", () => { it("rejects unknown roles and malformed emails without writes", async () => { for (const role of ["owner", "admin", "MANAGER", "", null, {}]) { - await expect(createManagedProfileInvitation(owner.id, child, { email, role })).rejects.toMatchObject({ + await expect(createManagedProfileInvitation(owner.id, owner.id, { email, role })).rejects.toMatchObject({ code: "INVALID_MEMBERSHIP_ROLE" }); - await expect(changeManagedProfileMember(owner.id, child, invitee.id, role)).rejects.toMatchObject({ + await expect(changeManagedProfileMember(owner.id, owner.id, invitee.id, role)).rejects.toMatchObject({ code: "INVALID_MEMBERSHIP_ROLE" }); } for (const recipient of ["invalid", "a\nb@example.com", "a@@example.com", `${"a".repeat(256)}@example.com`, undefined]) { await expect( - createManagedProfileInvitation(owner.id, child, { email: recipient, role: "manager" }) + createManagedProfileInvitation(owner.id, owner.id, { email: recipient, role: "manager" }) ).rejects.toMatchObject({ code: "INVALID_INVITATION_EMAIL" }); } expect(await Invitation.count()).toBe(0); }); - it("uses the current verified email, not stale profile email, and preview does not grant access", async () => { + it("uses the current verified email and returns organization preview without granting access", async () => { const { invitation } = await invite(); expect( await readOrAcceptManagedProfileInvitation({ ...principal(), email: " INVITEE@example.com " }, invitation.id, false) - ).toMatchObject({ invitation: { status: "pending", role: "manager" }, managedProfile: { profileId: child } }); + ).toMatchObject({ + invitation: { status: "pending", role: "manager", ownerProfileId: owner.id }, + organization: { ownerProfileId: owner.id, ownerEmail: owner.email }, + inviter: { profileId: owner.id, email: owner.email } + }); expect(await Membership.count({ where: { memberProfileId: invitee.id } })).toBe(0); - const result = await accept(invitation.id); - expect(result).toMatchObject({ - member: { memberProfileId: invitee.id, role: "manager", isOwner: false }, - managedProfileId: child + expect(await accept(invitation.id)).toMatchObject({ + ownerProfileId: owner.id, + member: { memberProfileId: invitee.id, role: "manager", isOwner: false } }); - expect(await eventCount("invitation_accepted")).toBe(1); const events = await MembershipEvent.findAll({ where: { invitationId: invitation.id, memberProfileId: invitee.id } }); expect(events).toHaveLength(2); expect(events.every(event => event.actorProfileId === invitee.id && event.subjectEmail === null)).toBe(true); }); - it("denies mismatched, unverified, malformed confirmation, and managed invitees without details or expiry writes", async () => { + it("denies mismatched, unverified, malformed confirmation and nonhuman invitees without expiry writes", async () => { const { invitation } = await invite(); + const managed = await provisionManagedProfile({ + contactEmail: "child@example.com", + creationSource: "manager", + customerType: "business", + externalSubjectId: "company", + managerProfileId: owner.id + }); await Invitation.update({ expiresAt: new Date(Date.now() - 1000) }, { where: { id: invitation.id } }); for (const actor of [ { ...principal(), email: invitee.email! }, { ...principal(), emailConfirmedAt: undefined }, { ...principal(), emailConfirmedAt: "invalid" }, { ...principal(), email: undefined }, - { ...principal(), profileId: child } - ]) { + { ...principal(), profileId: managed.profileId } + ]) for (const accepting of [false, true]) { await expect(readOrAcceptManagedProfileInvitation(actor, invitation.id, accepting)).rejects.toMatchObject({ code: "MANAGED_PROFILE_ACCESS_DENIED" }); } - } expect(await eventCount("invitation_expired")).toBe(0); await expect(accept(crypto.randomUUID())).rejects.toMatchObject({ code: "MANAGED_PROFILE_ACCESS_DENIED" }); }); @@ -358,31 +339,27 @@ describe("managed-profile membership transactions", () => { expect(second).toEqual(first); expect(await accept(invitation.id)).toEqual(first); expect(await Membership.count({ where: { memberProfileId: invitee.id, revokedAt: null } })).toBe(1); - expect(await eventCount("member_added")).toBe(2); // Includes provisioning's owner event. + expect(await eventCount("member_added")).toBe(2); expect(await eventCount("invitation_accepted")).toBe(1); }); - it("does not replay an accepted grant after downgrade or removal and creates a fresh row for a new grant", async () => { + it("does not replay a downgraded or removed grant and creates a fresh row for a new grant", async () => { const { invitation } = await invite(); await accept(invitation.id); const original = await Membership.findOne({ where: { memberProfileId: invitee.id, revokedAt: null } }); - await changeManagedProfileMember(owner.id, child, invitee.id, "read_only"); + await changeManagedProfileMember(owner.id, owner.id, invitee.id, "read_only"); await expect(accept(invitation.id)).rejects.toMatchObject({ code: "INVITATION_ACCEPTED" }); - await removeManagedProfileMember(owner.id, child, invitee.id); + await removeManagedProfileMember(owner.id, owner.id, invitee.id); await expect(accept(invitation.id)).rejects.toMatchObject({ code: "INVITATION_ACCEPTED" }); - const replacement = await invite(); - await accept(replacement.invitation.id); - const active = await Membership.findOne({ where: { memberProfileId: invitee.id, revokedAt: null } }); - expect(active?.id).not.toBe(original?.id); + await accept((await invite()).invitation.id); + expect((await Membership.findOne({ where: { memberProfileId: invitee.id, revokedAt: null } }))?.id).not.toBe(original?.id); expect(await Membership.count({ where: { memberProfileId: invitee.id } })).toBe(2); - expect((await original?.reload())?.revokedAt).toBeInstanceOf(Date); }); - it("rejects acceptance if a different existing membership would be overwritten", async () => { + it("never overwrites an existing same-organization membership", async () => { const { invitation } = await invite(); - await Membership.create({ managedProfileId: child, memberProfileId: invitee.id, role: "read_only" }); + await Membership.create({ ownerProfileId: owner.id, memberProfileId: invitee.id, role: "read_only" }); await expect(accept(invitation.id)).rejects.toMatchObject({ code: "MEMBERSHIP_ALREADY_EXISTS" }); - expect(await eventCount("invitation_accepted")).toBe(0); expect((await Invitation.findByPk(invitation.id))?.acceptedAt).toBeNull(); }); @@ -393,7 +370,7 @@ describe("managed-profile membership transactions", () => { await Invitation.update({ expiresAt: deadline }, { where: { id: invitation.id } }); if (observe === "accept") await expect(accept(invitation.id)).rejects.toMatchObject({ code: "INVITATION_EXPIRED" }); if (observe === "cancel") - await expect(cancelManagedProfileInvitation(owner.id, child, invitation.id)).rejects.toMatchObject({ + await expect(cancelManagedProfileInvitation(owner.id, owner.id, invitation.id)).rejects.toMatchObject({ code: "INVITATION_EXPIRED" }); if (observe === "preview") @@ -401,7 +378,7 @@ describe("managed-profile membership transactions", () => { invitation: { status: "expired" } }); if (observe === "list") - expect((await listManagedProfileInvitations(owner.id, child, 50, 0)).invitations[0].status).toBe("expired"); + expect((await listManagedProfileInvitations(owner.id, owner.id, 50, 0)).invitations[0].status).toBe("expired"); if (observe === "create") expect((await invite()).invitation.id).not.toBe(invitation.id); await expect(accept(invitation.id)).rejects.toMatchObject({ code: "INVITATION_EXPIRED" }); expect((await Invitation.findByPk(invitation.id))?.expiredAt).toEqual(deadline); @@ -409,10 +386,10 @@ describe("managed-profile membership transactions", () => { }); } - it("commits expired predecessor even when replacement conflicts with an active membership", async () => { + it("commits expired predecessor even when replacement conflicts with a confirmed member", async () => { const { invitation } = await invite("manager", invitee.email!); await Invitation.update({ expiresAt: new Date(Date.now() - 1000) }, { where: { id: invitation.id } }); - await Membership.create({ managedProfileId: child, memberProfileId: invitee.id, role: "manager" }); + await Membership.create({ ownerProfileId: owner.id, memberProfileId: invitee.id, role: "manager" }); spyOn(SupabaseAuthService, "getUserProfile").mockResolvedValue({ id: invitee.id, email: invitee.email, @@ -420,23 +397,22 @@ describe("managed-profile membership transactions", () => { } as never); await expect(invite("manager", invitee.email!)).rejects.toMatchObject({ code: "MEMBERSHIP_ALREADY_EXISTS" }); expect(await eventCount("invitation_expired")).toBe(1); - expect((await Invitation.findByPk(invitation.id))?.expiredAt).toBeInstanceOf(Date); }); it("serializes cancellation versus acceptance with exactly one terminal event", async () => { const { invitation } = await invite(); const results = await Promise.allSettled([ - cancelManagedProfileInvitation(owner.id, child, invitation.id), + cancelManagedProfileInvitation(owner.id, owner.id, invitation.id), accept(invitation.id) ]); expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1); expect((await eventCount("invitation_cancelled")) + (await eventCount("invitation_accepted"))).toBe(1); - const terminal = await Invitation.findByPk(invitation.id); - if (terminal?.cancelledAt) await expect(accept(invitation.id)).rejects.toMatchObject({ code: "INVITATION_CANCELLED" }); - await expect(cancelManagedProfileInvitation(owner.id, child, invitation.id)).rejects.toMatchObject({ status: 409 }); + if ((await Invitation.findByPk(invitation.id))?.cancelledAt) + await expect(accept(invitation.id)).rejects.toMatchObject({ code: "INVITATION_CANCELLED" }); + await expect(cancelManagedProfileInvitation(owner.id, owner.id, invitation.id)).rejects.toMatchObject({ status: 409 }); }); - it("rolls acceptance and membership back if the atomic history write fails", async () => { + it("rolls acceptance and membership back if atomic history fails", async () => { const { invitation } = await invite(); spyOn(MembershipEvent, "bulkCreate").mockRejectedValue(new Error("audit unavailable")); await expect(accept(invitation.id)).rejects.toThrow("audit unavailable"); @@ -444,34 +420,101 @@ describe("managed-profile membership transactions", () => { expect((await Invitation.findByPk(invitation.id))?.acceptedAt).toBeNull(); }); - it("allows invited managers but rechecks downgraded and revoked authority for every operation", async () => { + it("maps a database active-membership unique race to a typed organization conflict", async () => { const { invitation } = await invite(); - await accept(invitation.id); - await invite("read_only", "other@example.com", invitee.id); - await changeManagedProfileMember(owner.id, child, invitee.id, "read_only"); - expect((await listManagedProfileMembers(invitee.id, child, 50, 0)).members).toHaveLength(2); - expect((await listManagedProfileInvitations(invitee.id, child, 50, 0)).invitations).toHaveLength(2); - expect((await listManagedProfileMemberEvents(invitee.id, child, 50)).events.length).toBeGreaterThan(0); + spyOn(Membership, "create").mockRejectedValue( + new UniqueConstraintError({ + parent: Object.assign(new Error("duplicate membership"), { + constraint: "uq_managed_profile_memberships_active", + sql: "", + code: "23505" + }) + }) + ); + await expect(accept(invitation.id)).rejects.toMatchObject({ code: "ORGANIZATION_MEMBERSHIP_CONFLICT", status: 409 }); + expect((await Invitation.findByPk(invitation.id))?.acceptedAt).toBeNull(); + expect(await eventCount("invitation_accepted")).toBe(0); + }); + + it("blocks owner and invitee profile changes while their kinds authorize a preview", async () => { + const { invitation } = await invite(); + const original = User.findByPk.bind(User); + let reached!: () => void; + let release!: () => void; + const actorLocked = new Promise(resolve => { + reached = resolve; + }); + const finish = new Promise(resolve => { + release = resolve; + }); + spyOn(User, "findByPk").mockImplementation(async (id, options) => { + const row = await original(id, options); + if (id === invitee.id && options?.lock) { + reached(); + await finish; + } + return row; + }); + const preview = readOrAcceptManagedProfileInvitation(principal(), invitation.id, false); + try { + await actorLocked; + for (const id of [owner.id, invitee.id]) { + await expect( + sequelize.transaction(transaction => + sequelize.query("SELECT id FROM profiles WHERE id = :id FOR NO KEY UPDATE NOWAIT", { + replacements: { id }, + transaction + }) + ) + ).rejects.toMatchObject({ original: { code: "55P03" } }); + } + } finally { + release(); + await preview; + } + expect(await preview).toMatchObject({ invitation: { status: "pending" } }); + }); + + it("rechecks manager role and revocation for every team operation", async () => { + await accept((await invite()).invitation.id); + const pending = await invite("read_only", "other@example.com", invitee.id); + await changeManagedProfileMember(owner.id, owner.id, invitee.id, "read_only"); + expect((await listManagedProfileMembers(invitee.id, owner.id, 50, 0)).members).toHaveLength(2); + expect((await listManagedProfileInvitations(invitee.id, owner.id, 50, 0)).invitations).toHaveLength(2); + expect((await listManagedProfileMemberEvents(invitee.id, owner.id, 50)).events.length).toBeGreaterThan(0); for (const mutation of [ () => invite("manager", "denied@example.com", invitee.id), - () => changeManagedProfileMember(invitee.id, child, owner.id, "read_only"), - () => removeManagedProfileMember(invitee.id, child, owner.id), - () => cancelManagedProfileInvitation(invitee.id, child, invitation.id) + () => changeManagedProfileMember(invitee.id, owner.id, owner.id, "read_only"), + () => removeManagedProfileMember(invitee.id, owner.id, owner.id), + () => cancelManagedProfileInvitation(invitee.id, owner.id, pending.invitation.id) ]) await expect(mutation()).rejects.toMatchObject({ code: "MANAGED_PROFILE_MANAGER_REQUIRED" }); - await removeManagedProfileMember(owner.id, child, invitee.id); - await expect(listManagedProfileMembers(invitee.id, child, 50, 0)).rejects.toMatchObject({ + await removeManagedProfileMember(owner.id, owner.id, invitee.id); + expect(await getManagedProfileOrganization(invitee.id)).toBeNull(); + for (const read of [listManagedProfileMembers, listManagedProfileInvitations]) { + await expect(read(invitee.id, owner.id, 50, 0)).rejects.toMatchObject({ code: "MANAGED_PROFILE_ACCESS_DENIED" }); + } + await expect(listManagedProfileMemberEvents(invitee.id, owner.id, 50)).rejects.toMatchObject({ code: "MANAGED_PROFILE_ACCESS_DENIED" }); await expect(invite("manager", "denied@example.com", invitee.id)).rejects.toMatchObject({ code: "MANAGED_PROFILE_ACCESS_DENIED" }); + // A pending invitation is an organization's durable offer, not the inviter's personal grant. + const other = await createTestUser({ email: "other@example.com" }); + expect( + await readOrAcceptManagedProfileInvitation( + { ...principal(), profileId: other.id, email: other.email! }, + pending.invitation.id, + true + ) + ).toMatchObject({ ownerProfileId: owner.id, member: { role: "read_only" } }); }); - it("protects the owner under concurrent changes and keeps role/removal histories idempotent", async () => { + it("protects the owner concurrently and keeps role/removal histories idempotent", async () => { const attempts = await Promise.allSettled([ - changeManagedProfileMember(owner.id, child, owner.id, "read_only"), - removeManagedProfileMember(owner.id, child, owner.id) + changeManagedProfileMember(owner.id, owner.id, owner.id, "read_only"), + removeManagedProfileMember(owner.id, owner.id, owner.id) ]); expect( attempts.every( @@ -479,39 +522,38 @@ describe("managed-profile membership transactions", () => { ) ).toBe(true); await accept((await invite()).invitation.id); - await changeManagedProfileMember(owner.id, child, invitee.id, "read_only"); - await changeManagedProfileMember(owner.id, child, invitee.id, "read_only"); - const changed = await MembershipEvent.findOne({ where: { action: "role_changed" } }); - expect(changed).toMatchObject({ + for (let i = 0; i < 2; i++) await changeManagedProfileMember(owner.id, owner.id, invitee.id, "read_only"); + expect(await MembershipEvent.findOne({ where: { action: "role_changed" } })).toMatchObject({ actorProfileId: owner.id, memberProfileId: invitee.id, previousRole: "manager", role: "read_only" }); expect(await eventCount("role_changed")).toBe(1); - await removeManagedProfileMember(owner.id, child, invitee.id); - await removeManagedProfileMember(owner.id, child, invitee.id); + for (let i = 0; i < 2; i++) await removeManagedProfileMember(owner.id, owner.id, invitee.id); expect(await eventCount("member_removed")).toBe(1); - await expect(removeManagedProfileMember(owner.id, child, crypto.randomUUID())).rejects.toMatchObject({ + await expect(removeManagedProfileMember(owner.id, owner.id, crypto.randomUUID())).rejects.toMatchObject({ code: "MEMBER_NOT_FOUND" }); }); - it("rolls role changes, removal, and cancellation back when their audit insert fails", async () => { + it("rolls role changes, removal and cancellation back when audit fails", async () => { await accept((await invite()).invitation.id); const pending = await invite("read_only", "other@example.com"); spyOn(MembershipEvent, "create").mockRejectedValue(new Error("audit unavailable")); - await expect(changeManagedProfileMember(owner.id, child, invitee.id, "read_only")).rejects.toThrow("audit unavailable"); - await expect(removeManagedProfileMember(owner.id, child, invitee.id)).rejects.toThrow("audit unavailable"); - await expect(cancelManagedProfileInvitation(owner.id, child, pending.invitation.id)).rejects.toThrow("audit unavailable"); + await expect(changeManagedProfileMember(owner.id, owner.id, invitee.id, "read_only")).rejects.toThrow("audit unavailable"); + await expect(removeManagedProfileMember(owner.id, owner.id, invitee.id)).rejects.toThrow("audit unavailable"); + await expect(cancelManagedProfileInvitation(owner.id, owner.id, pending.invitation.id)).rejects.toThrow( + "audit unavailable" + ); expect(await Membership.findOne({ where: { memberProfileId: invitee.id, revokedAt: null } })).toMatchObject({ role: "manager" }); expect((await Invitation.findByPk(pending.invitation.id))?.cancelledAt).toBeNull(); }); - for (const action of ["downgrade", "revoke"] as const) { - it(`rechecks live manager authority after a concurrent ${action}`, async () => { + for (const action of ["downgrade", "revoke", "deactivate"] as const) { + it(`rechecks authority after a concurrent ${action} holds the owner lock`, async () => { await accept((await invite()).invitation.id); const transaction = await sequelize.transaction(); await ManagedProfileManager.findByPk(owner.id, { lock: Transaction.LOCK.UPDATE, transaction }); @@ -528,12 +570,15 @@ describe("managed-profile membership transactions", () => { result => result, error => error ); + await waiting; try { - await waiting; - await Membership.update( - action === "downgrade" ? { role: "read_only" } : { revokedAt: new Date(), revokedByProfileId: owner.id }, - { transaction, where: { managedProfileId: child, memberProfileId: invitee.id, revokedAt: null } } - ); + if (action === "deactivate") + await ManagedProfileManager.update({ isActive: false }, { transaction, where: { profileId: owner.id } }); + else + await Membership.update( + action === "downgrade" ? { role: "read_only" } : { revokedAt: new Date(), revokedByProfileId: owner.id }, + { transaction, where: { ownerProfileId: owner.id, memberProfileId: invitee.id, revokedAt: null } } + ); await transaction.commit(); } catch (error) { await transaction.rollback(); @@ -546,81 +591,39 @@ describe("managed-profile membership transactions", () => { }); } - it("does not leak another child's members, invitations, or events", async () => { - for (const read of [ - () => listManagedProfileMembers(invitee.id, child, 50, 0), - () => listManagedProfileInvitations(invitee.id, child, 50, 0), - () => listManagedProfileMemberEvents(invitee.id, child, 50) - ]) - await expect(read()).rejects.toMatchObject({ code: "MANAGED_PROFILE_ACCESS_DENIED" }); - const foreign = await createTestUser(); - await expect(listManagedProfileMembers(owner.id, foreign.id, 50, 0)).rejects.toMatchObject({ - code: "MANAGED_PROFILE_ACCESS_DENIED" - }); - }); - - it("paginates tied event timestamps without missing or repeating rows and rejects foreign cursors", async () => { - const timestamp = new Date(); + it("paginates tied event timestamps without missing rows and rejects foreign cursors", async () => { await MembershipEvent.bulkCreate( - Array.from({ length: 5 }, () => ({ action: "invited" as const, managedProfileId: child, createdAt: timestamp })) + Array.from({ length: 5 }, () => ({ action: "invited" as const, ownerProfileId: owner.id, createdAt: new Date(0) })) ); const ids: string[] = []; let cursor: string | undefined; do { - const page = await listManagedProfileMemberEvents(owner.id, child, 2, cursor); + const page = await listManagedProfileMemberEvents(owner.id, owner.id, 2, cursor); ids.push(...page.events.map(event => event.id)); cursor = page.pagination.nextCursor ?? undefined; } while (cursor); expect(new Set(ids).size).toBe(6); expect(ids).toHaveLength(6); - await expect(listManagedProfileMemberEvents(owner.id, child, 2, crypto.randomUUID())).rejects.toMatchObject({ + await expect(listManagedProfileMemberEvents(owner.id, owner.id, 2, crypto.randomUUID())).rejects.toMatchObject({ code: "INVALID_PAGINATION" }); - }); - - for (const deactivate of ["owner", "child"] as const) { - it(`rechecks ${deactivate} state after waiting on the lifecycle owner lock`, async () => { - const { invitation } = await invite(); - const transaction = await sequelize.transaction(); - await ManagedProfileManager.findByPk(owner.id, { lock: Transaction.LOCK.UPDATE, transaction }); - let reached!: () => void; - const waiting = new Promise(resolve => { - reached = resolve; - }); - const original = ManagedProfileManager.findByPk.bind(ManagedProfileManager); - spyOn(ManagedProfileManager, "findByPk").mockImplementation((...args) => { - reached(); - return original(...args); - }); - const accepting = accept(invitation.id).then( - result => result, - error => error - ); - try { - await waiting; - if (deactivate === "owner") - await ManagedProfileManager.update({ isActive: false }, { transaction, where: { profileId: owner.id } }); - else - await ManagedProfile.update( - { status: "deleted", deletedAt: new Date() }, - { transaction, where: { profileId: child } } - ); - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - expect(await accepting).toMatchObject({ code: "MANAGED_PROFILE_ACCESS_DENIED" }); - expect(await eventCount("invitation_accepted")).toBe(0); + await expect(listManagedProfileMembers(owner.id, crypto.randomUUID(), 50, 0)).rejects.toMatchObject({ + code: "MANAGED_PROFILE_ACCESS_DENIED" }); - } + }); - it("denies all team reads and acceptance after owner lifecycle deletion", async () => { + it("keeps organization offers and team access valid after every child is deleted", async () => { const { invitation } = await invite(); - await deleteManagedProfile(owner.id, child); - await expect(accept(invitation.id)).rejects.toMatchObject({ code: "MANAGED_PROFILE_ACCESS_DENIED" }); - await expect(listManagedProfileMembers(owner.id, child, 50, 0)).rejects.toMatchObject({ - code: "MANAGED_PROFILE_ACCESS_DENIED" + const child = await provisionManagedProfile({ + contactEmail: "child@example.com", + creationSource: "manager", + customerType: "business", + externalSubjectId: "company", + managerProfileId: owner.id }); + await deleteManagedProfile(owner.id, child.profileId); + expect(await accept(invitation.id)).toMatchObject({ ownerProfileId: owner.id }); + expect((await listManagedProfileMembers(invitee.id, owner.id, 50, 0)).members).toHaveLength(2); + expect(await Membership.count()).toBe(2); }); }); diff --git a/apps/api/src/api/services/managed-profile-membership.service.ts b/apps/api/src/api/services/managed-profile-membership.service.ts index 86bad7365..c887dfbe6 100644 --- a/apps/api/src/api/services/managed-profile-membership.service.ts +++ b/apps/api/src/api/services/managed-profile-membership.service.ts @@ -1,8 +1,6 @@ -import { Op, Transaction } from "sequelize"; +import { Op, Transaction, UniqueConstraintError } from "sequelize"; import { z } from "zod"; import sequelize from "../../config/database"; -import CustomerEntity from "../../models/customerEntity.model"; -import ManagedProfile from "../../models/managedProfile.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; import Membership, { type ManagedProfileMembershipRole } from "../../models/managedProfileMembership.model"; import MembershipEvent from "../../models/managedProfileMembershipEvent.model"; @@ -41,45 +39,40 @@ export function normalizeMembershipEmail(email: unknown): string { return result.data; } -// Match provisioning/deletion: owner configuration, owner profile, child profile/aggregate, -// then invitation/membership rows. The initial relationship lookup is only a lock locator. -async function lockChild(profileId: string, transaction: Transaction): Promise { - const locator = await ManagedProfile.findOne({ transaction, where: { profileId } }); - if (!locator) throw accessDenied(); +export interface ManagedProfileOrganization { + ownerProfileId: string; + ownerEmail: string | null; + membership: { role: ManagedProfileMembershipRole; isOwner: boolean }; +} + +function organizationMembershipConflict(): ManagedProfileMembershipError { + return new ManagedProfileMembershipError( + "ORGANIZATION_MEMBERSHIP_CONFLICT", + 409, + "A profile may belong to only one organization" + ); +} + +// Match child operations: configuration before human profiles, then membership rows. +async function lockOrganization(ownerProfileId: string, transaction: Transaction): Promise { const options = { lock: Transaction.LOCK.UPDATE, transaction }; - const owner = await ManagedProfileManager.findByPk(locator.managerProfileId, options); - // Human profiles are read-only here. SHARE protects kind changes while allowing - // reciprocal owners/invitees and the KEY SHARE locks taken by audit/profile FKs. - const ownerProfile = await User.findByPk(locator.managerProfileId, { lock: Transaction.LOCK.SHARE, transaction }); - const child = await User.findByPk(profileId, options); - const relationship = await ManagedProfile.findOne({ ...options, where: { profileId } }); - if ( - !owner?.isActive || - ownerProfile?.kind !== "authenticated" || - child?.kind !== "managed" || - !child.activeCustomerEntityId || - relationship?.status !== "active" || - relationship.managerProfileId !== locator.managerProfileId - ) - throw accessDenied(); - const entities = await CustomerEntity.findAll({ ...options, where: { profileId } }); - if (entities.length !== 1 || entities[0].id !== child.activeCustomerEntityId || entities[0].status !== "active") { - throw accessDenied(); - } + const owner = await ManagedProfileManager.findByPk(ownerProfileId, options); + const ownerProfile = await User.findByPk(ownerProfileId, { lock: Transaction.LOCK.SHARE, transaction }); + if (!owner?.isActive || ownerProfile?.kind !== "authenticated") throw accessDenied(); const ownerMembership = await Membership.findOne({ ...options, - where: { managedProfileId: profileId, memberProfileId: ownerProfile.id, revokedAt: null } + where: { memberProfileId: ownerProfileId, ownerProfileId, revokedAt: null } }); if (ownerMembership?.role !== "manager") throw accessDenied(); - return relationship; + return ownerProfile; } -async function requireMember(actorProfileId: string, profileId: string, manage: boolean, transaction: Transaction) { +async function requireMember(actorProfileId: string, ownerProfileId: string, manage: boolean, transaction: Transaction) { const actor = await User.findByPk(actorProfileId, { lock: Transaction.LOCK.SHARE, transaction }); const member = await Membership.findOne({ lock: Transaction.LOCK.UPDATE, transaction, - where: { managedProfileId: profileId, memberProfileId: actorProfileId, revokedAt: null } + where: { memberProfileId: actorProfileId, ownerProfileId, revokedAt: null } }); if (actor?.kind !== "authenticated" || !member || !["manager", "read_only"].includes(member.role)) throw accessDenied(); if (manage && member.role !== "manager") { @@ -89,6 +82,26 @@ async function requireMember(actorProfileId: string, profileId: string, manage: "An active manager membership is required" ); } + return member; +} + +export async function getManagedProfileOrganization(actorProfileId: string): Promise { + try { + return await sequelize.transaction(async transaction => { + const locator = await Membership.findOne({ transaction, where: { memberProfileId: actorProfileId, revokedAt: null } }); + if (!locator) return null; + const owner = await lockOrganization(locator.ownerProfileId, transaction); + const member = await requireMember(actorProfileId, owner.id, false, transaction); + return { + membership: { isOwner: actorProfileId === owner.id, role: member.role }, + ownerEmail: owner.email, + ownerProfileId: owner.id + }; + }); + } catch (error) { + if (error instanceof ManagedProfileMembershipError && error.code === "MANAGED_PROFILE_ACCESS_DENIED") return null; + throw error; + } } function invitationStatus(invitation: Invitation) { @@ -111,7 +124,7 @@ function invitationResult(invitation: Invitation) { expiresAt: invitation.expiresAt, id: invitation.id, invitedByProfileId: invitation.invitedByProfileId, - managedProfileId: invitation.managedProfileId, + ownerProfileId: invitation.ownerProfileId, role: invitation.role, status: invitationStatus(invitation) }; @@ -135,7 +148,7 @@ async function expireInvitation(invitation: Invitation, transaction: Transaction { action: "invitation_expired", invitationId: invitation.id, - managedProfileId: invitation.managedProfileId, + ownerProfileId: invitation.ownerProfileId, role: invitation.role }, { transaction } @@ -150,10 +163,10 @@ function terminalInvitation(invitation: Invitation) { ); } -export async function listManagedProfileMembers(actorProfileId: string, profileId: string, limit: number, offset: number) { +export async function listManagedProfileMembers(actorProfileId: string, ownerProfileId: string, limit: number, offset: number) { return sequelize.transaction(async transaction => { - const child = await lockChild(profileId, transaction); - await requireMember(actorProfileId, profileId, false, transaction); + await lockOrganization(ownerProfileId, transaction); + await requireMember(actorProfileId, ownerProfileId, false, transaction); const { rows, count } = await Membership.findAndCountAll({ limit, offset, @@ -162,7 +175,7 @@ export async function listManagedProfileMembers(actorProfileId: string, profileI ["id", "ASC"] ], transaction, - where: { managedProfileId: profileId, revokedAt: null } + where: { ownerProfileId, revokedAt: null } }); const profiles = await User.findAll({ attributes: ["id", "email"], @@ -172,7 +185,7 @@ export async function listManagedProfileMembers(actorProfileId: string, profileI const emails = new Map(profiles.map(profile => [profile.id, profile.email])); return { members: rows.map(row => ({ - ...memberResult(row, child.managerProfileId), + ...memberResult(row, ownerProfileId), email: emails.get(row.memberProfileId) ?? null })), pagination: { limit, offset, total: count } @@ -182,15 +195,15 @@ export async function listManagedProfileMembers(actorProfileId: string, profileI export async function changeManagedProfileMember( actorProfileId: string, - profileId: string, + ownerProfileId: string, memberProfileId: string, role: unknown ) { const nextRole = requireMembershipRole(role); return sequelize.transaction(async transaction => { - const child = await lockChild(profileId, transaction); - await requireMember(actorProfileId, profileId, true, transaction); - if (memberProfileId === child.managerProfileId) { + await lockOrganization(ownerProfileId, transaction); + await requireMember(actorProfileId, ownerProfileId, true, transaction); + if (memberProfileId === ownerProfileId) { throw new ManagedProfileMembershipError( "MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED", 409, @@ -200,26 +213,26 @@ export async function changeManagedProfileMember( const member = await Membership.findOne({ lock: Transaction.LOCK.UPDATE, transaction, - where: { managedProfileId: profileId, memberProfileId, revokedAt: null } + where: { memberProfileId, ownerProfileId, revokedAt: null } }); if (!member) throw new ManagedProfileMembershipError("MEMBER_NOT_FOUND", 404, "Member was not found"); if (member.role !== nextRole) { const previousRole = member.role; await member.update({ role: nextRole }, { transaction }); await MembershipEvent.create( - { action: "role_changed", actorProfileId, managedProfileId: profileId, memberProfileId, previousRole, role: nextRole }, + { action: "role_changed", actorProfileId, memberProfileId, ownerProfileId, previousRole, role: nextRole }, { transaction } ); } - return { member: memberResult(member, child.managerProfileId) }; + return { member: memberResult(member, ownerProfileId) }; }); } -export async function removeManagedProfileMember(actorProfileId: string, profileId: string, memberProfileId: string) { +export async function removeManagedProfileMember(actorProfileId: string, ownerProfileId: string, memberProfileId: string) { await sequelize.transaction(async transaction => { - const child = await lockChild(profileId, transaction); - await requireMember(actorProfileId, profileId, true, transaction); - if (memberProfileId === child.managerProfileId) { + await lockOrganization(ownerProfileId, transaction); + await requireMember(actorProfileId, ownerProfileId, true, transaction); + if (memberProfileId === ownerProfileId) { throw new ManagedProfileMembershipError( "MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED", 409, @@ -229,14 +242,14 @@ export async function removeManagedProfileMember(actorProfileId: string, profile const member = await Membership.findOne({ lock: Transaction.LOCK.UPDATE, transaction, - where: { managedProfileId: profileId, memberProfileId, revokedAt: null } + where: { memberProfileId, ownerProfileId, revokedAt: null } }); if (!member) { const removed = await Membership.findOne({ transaction, where: { - managedProfileId: profileId, memberProfileId, + ownerProfileId, revokedAt: { [Op.ne]: null }, revokedByProfileId: actorProfileId } @@ -246,7 +259,7 @@ export async function removeManagedProfileMember(actorProfileId: string, profile } await member.update({ revokedAt: new Date(), revokedByProfileId: actorProfileId }, { transaction }); await MembershipEvent.create( - { action: "member_removed", actorProfileId, managedProfileId: profileId, memberProfileId, role: member.role }, + { action: "member_removed", actorProfileId, memberProfileId, ownerProfileId, role: member.role }, { transaction } ); }); @@ -254,19 +267,19 @@ export async function removeManagedProfileMember(actorProfileId: string, profile export async function createManagedProfileInvitation( actorProfileId: string, - profileId: string, + ownerProfileId: string, input: { email: unknown; role: unknown } ) { const email = normalizeMembershipEmail(input.email); const role = requireMembershipRole(input.role); const result = await sequelize.transaction(async transaction => { - await lockChild(profileId, transaction); - await requireMember(actorProfileId, profileId, true, transaction); + await lockOrganization(ownerProfileId, transaction); + await requireMember(actorProfileId, ownerProfileId, true, transaction); const pending = await Invitation.findOne({ lock: Transaction.LOCK.UPDATE, logging: false, transaction, - where: { acceptedAt: null, cancelledAt: null, email, expiredAt: null, managedProfileId: profileId } + where: { acceptedAt: null, cancelledAt: null, email, expiredAt: null, ownerProfileId } }); if (pending) { await expireInvitation(pending, transaction); @@ -280,7 +293,7 @@ export async function createManagedProfileInvitation( return { created: false, invitation: invitationResult(pending) }; } } - // Only disclose membership already visible in this child's roster, never profile existence. + // Only disclose membership already visible in this organization's roster, never profile existence. const existingProfile = await User.findOne({ logging: false, transaction, @@ -293,7 +306,7 @@ export async function createManagedProfileInvitation( existingProfile && (await Membership.findOne({ transaction, - where: { managedProfileId: profileId, memberProfileId: existingProfile.id, revokedAt: null } + where: { memberProfileId: existingProfile.id, ownerProfileId, revokedAt: null } })) ) { // Local email is only a hint: it may belong to someone else since the member @@ -314,13 +327,13 @@ export async function createManagedProfileInvitation( email, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), invitedByProfileId: actorProfileId, - managedProfileId: profileId, + ownerProfileId, role }, { logging: false, transaction } ); await MembershipEvent.create( - { action: "invited", actorProfileId, invitationId: invitation.id, managedProfileId: profileId, role }, + { action: "invited", actorProfileId, invitationId: invitation.id, ownerProfileId, role }, { transaction } ); await enqueueManagedProfileInvitation({ invitationId: invitation.id, recipientEmail: email }, transaction); @@ -330,10 +343,15 @@ export async function createManagedProfileInvitation( return result; } -export async function listManagedProfileInvitations(actorProfileId: string, profileId: string, limit: number, offset: number) { +export async function listManagedProfileInvitations( + actorProfileId: string, + ownerProfileId: string, + limit: number, + offset: number +) { return sequelize.transaction(async transaction => { - await lockChild(profileId, transaction); - await requireMember(actorProfileId, profileId, false, transaction); + await lockOrganization(ownerProfileId, transaction); + await requireMember(actorProfileId, ownerProfileId, false, transaction); const elapsed = await Invitation.findAll({ lock: Transaction.LOCK.UPDATE, logging: false, @@ -343,7 +361,7 @@ export async function listManagedProfileInvitations(actorProfileId: string, prof cancelledAt: null, expiredAt: null, expiresAt: { [Op.lte]: new Date() }, - managedProfileId: profileId + ownerProfileId } }); for (const invitation of elapsed) await expireInvitation(invitation, transaction); @@ -356,28 +374,28 @@ export async function listManagedProfileInvitations(actorProfileId: string, prof ["id", "DESC"] ], transaction, - where: { managedProfileId: profileId } + where: { ownerProfileId } }); return { invitations: rows.map(invitationResult), pagination: { limit, offset, total: count } }; }); } -export async function cancelManagedProfileInvitation(actorProfileId: string, profileId: string, invitationId: string) { +export async function cancelManagedProfileInvitation(actorProfileId: string, ownerProfileId: string, invitationId: string) { const result = await sequelize.transaction(async transaction => { - await lockChild(profileId, transaction); - await requireMember(actorProfileId, profileId, true, transaction); + await lockOrganization(ownerProfileId, transaction); + await requireMember(actorProfileId, ownerProfileId, true, transaction); const invitation = await Invitation.findOne({ lock: Transaction.LOCK.UPDATE, logging: false, transaction, - where: { id: invitationId, managedProfileId: profileId } + where: { id: invitationId, ownerProfileId } }); if (!invitation) throw new ManagedProfileMembershipError("INVITATION_NOT_FOUND", 404, "Invitation was not found"); await expireInvitation(invitation, transaction); if (invitationStatus(invitation) !== "pending") return terminalInvitation(invitation); await invitation.update({ cancelledAt: new Date(), cancelledByProfileId: actorProfileId }, { logging: false, transaction }); await MembershipEvent.create( - { action: "invitation_cancelled", actorProfileId, invitationId, managedProfileId: profileId, role: invitation.role }, + { action: "invitation_cancelled", actorProfileId, invitationId, ownerProfileId, role: invitation.role }, { transaction } ); }); @@ -402,82 +420,109 @@ export async function readOrAcceptManagedProfileInvitation( } catch { throw accessDenied(); } - const result = await sequelize.transaction(async transaction => { - const locator = await Invitation.findByPk(invitationId, { logging: false, transaction }); - if (!locator || locator.email !== email) throw accessDenied(); - const child = await lockChild(locator.managedProfileId, transaction); - const invitation = await Invitation.findByPk(invitationId, { lock: Transaction.LOCK.UPDATE, logging: false, transaction }); - const actor = await User.findByPk(principal.profileId, { lock: Transaction.LOCK.SHARE, transaction }); - if (!invitation || invitation.email !== email || actor?.kind !== "authenticated") throw accessDenied(); - await expireInvitation(invitation, transaction); - if (!accept) { - const inviter = await User.findByPk(invitation.invitedByProfileId, { attributes: ["id", "email"], transaction }); - return { - invitation: invitationResult(invitation), - inviter: { email: inviter?.email ?? null, profileId: invitation.invitedByProfileId }, - managedProfile: { externalSubjectId: child.externalSubjectId, profileId: child.profileId } - }; - } - const membership = await Membership.findOne({ - lock: Transaction.LOCK.UPDATE, - transaction, - where: { managedProfileId: child.profileId, memberProfileId: actor.id, revokedAt: null } + const result = await sequelize + .transaction(async transaction => { + const locator = await Invitation.findByPk(invitationId, { logging: false, transaction }); + if (!locator || locator.email !== email) throw accessDenied(); + // Lock existing configurations in stable order before human rows. The invitee's + // NO KEY UPDATE lock also serializes creation of a previously absent configuration, + // without blocking the KEY SHARE locks taken by audit foreign keys. + for (const id of [...new Set([locator.ownerProfileId, principal.profileId])].sort()) { + await ManagedProfileManager.findByPk(id, { lock: Transaction.LOCK.UPDATE, transaction }); + } + const owner = await lockOrganization(locator.ownerProfileId, transaction); + const actor = await User.findByPk(principal.profileId, { lock: Transaction.LOCK.NO_KEY_UPDATE, transaction }); + const invitation = await Invitation.findByPk(invitationId, { + lock: Transaction.LOCK.UPDATE, + logging: false, + transaction + }); + if (!invitation || invitation.email !== email || actor?.kind !== "authenticated") throw accessDenied(); + await expireInvitation(invitation, transaction); + if (!accept) { + const inviter = await User.findByPk(invitation.invitedByProfileId, { attributes: ["id", "email"], transaction }); + return { + invitation: invitationResult(invitation), + inviter: { email: inviter?.email ?? null, profileId: invitation.invitedByProfileId }, + organization: { ownerEmail: owner.email, ownerProfileId: owner.id } + }; + } + const membership = await Membership.findOne({ + lock: Transaction.LOCK.UPDATE, + transaction, + where: { memberProfileId: actor.id, revokedAt: null } + }); + if ( + invitation.acceptedAt && + invitation.acceptedByProfileId === actor.id && + membership?.ownerProfileId === owner.id && + membership.role === invitation.role + ) { + return { member: memberResult(membership, owner.id), ownerProfileId: owner.id }; + } + // Return conflicts from the callback so observed expiry and its event commit before rejection. + if (invitationStatus(invitation) !== "pending") return terminalInvitation(invitation); + if ( + (membership && membership.ownerProfileId !== owner.id) || + (actor.id !== owner.id && (await ManagedProfileManager.findByPk(actor.id, { transaction }))) + ) + return organizationMembershipConflict(); + if (membership) + return new ManagedProfileMembershipError("MEMBERSHIP_ALREADY_EXISTS", 409, "An active membership already exists"); + const role = requireMembershipRole(invitation.role); + const member = await Membership.create( + { createdByProfileId: invitation.invitedByProfileId, memberProfileId: actor.id, ownerProfileId: owner.id, role }, + { transaction } + ); + await invitation.update({ acceptedAt: new Date(), acceptedByProfileId: actor.id }, { logging: false, transaction }); + await MembershipEvent.bulkCreate( + [ + { + action: "member_added", + actorProfileId: actor.id, + invitationId, + memberProfileId: actor.id, + ownerProfileId: owner.id, + role + }, + { + action: "invitation_accepted", + actorProfileId: actor.id, + invitationId, + memberProfileId: actor.id, + ownerProfileId: owner.id, + role + } + ], + { transaction } + ); + return { member: memberResult(member, owner.id), ownerProfileId: owner.id }; + }) + .catch(error => { + if ( + error instanceof UniqueConstraintError && + "constraint" in error.original && + error.original.constraint === "uq_managed_profile_memberships_active" + ) { + throw organizationMembershipConflict(); + } + throw error; }); - if (invitation.acceptedAt && invitation.acceptedByProfileId === actor.id && membership?.role === invitation.role) { - return { managedProfileId: child.profileId, member: memberResult(membership, child.managerProfileId) }; - } - // Return conflicts from the callback so observed expiry and its event commit before rejection. - if (invitationStatus(invitation) !== "pending") return terminalInvitation(invitation); - if (membership) - return new ManagedProfileMembershipError("MEMBERSHIP_ALREADY_EXISTS", 409, "An active membership already exists"); - const role = requireMembershipRole(invitation.role); - const member = await Membership.create( - { createdByProfileId: invitation.invitedByProfileId, managedProfileId: child.profileId, memberProfileId: actor.id, role }, - { transaction } - ); - await invitation.update({ acceptedAt: new Date(), acceptedByProfileId: actor.id }, { logging: false, transaction }); - await MembershipEvent.bulkCreate( - [ - { - action: "member_added", - actorProfileId: actor.id, - invitationId, - managedProfileId: child.profileId, - memberProfileId: actor.id, - role - }, - { - action: "invitation_accepted", - actorProfileId: actor.id, - invitationId, - managedProfileId: child.profileId, - memberProfileId: actor.id, - role - } - ], - { transaction } - ); - return { managedProfileId: child.profileId, member: memberResult(member, child.managerProfileId) }; - }); if (result instanceof ManagedProfileMembershipError) throw result; return result; } export async function listManagedProfileMemberEvents( actorProfileId: string, - profileId: string, + ownerProfileId: string, limit: number, cursor?: string ) { return sequelize.transaction(async transaction => { - await lockChild(profileId, transaction); - await requireMember(actorProfileId, profileId, false, transaction); - if (cursor && !(await MembershipEvent.findOne({ transaction, where: { id: cursor, managedProfileId: profileId } }))) { - throw new ManagedProfileMembershipError( - "INVALID_PAGINATION", - 400, - "Cursor must identify an event in this managed profile" - ); + await lockOrganization(ownerProfileId, transaction); + await requireMember(actorProfileId, ownerProfileId, false, transaction); + if (cursor && !(await MembershipEvent.findOne({ transaction, where: { id: cursor, ownerProfileId } }))) { + throw new ManagedProfileMembershipError("INVALID_PAGINATION", 400, "Cursor must identify an event in this organization"); } const events = await MembershipEvent.findAll({ limit: limit + 1, @@ -487,7 +532,7 @@ export async function listManagedProfileMemberEvents( ], transaction, where: { - managedProfileId: profileId, + ownerProfileId, ...(cursor ? { // Compare in PostgreSQL, preserving timestamp precision and a stable UUID tie-breaker. diff --git a/apps/api/src/api/services/managed-profile-provisioning.service.test.ts b/apps/api/src/api/services/managed-profile-provisioning.service.test.ts index 45aaccaa4..644549295 100644 --- a/apps/api/src/api/services/managed-profile-provisioning.service.test.ts +++ b/apps/api/src/api/services/managed-profile-provisioning.service.test.ts @@ -1,12 +1,12 @@ import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; import CustomerEntity from "../../models/customerEntity.model"; import ManagedProfile from "../../models/managedProfile.model"; -import ManagedProfileManager from "../../models/managedProfileManager.model"; import ManagedProfileMembership from "../../models/managedProfileMembership.model"; import ManagedProfileMembershipEvent from "../../models/managedProfileMembershipEvent.model"; import User from "../../models/user.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; import { createTestUser } from "../../test-utils/factories"; +import { configureManagedProfileManager } from "./managed-profile-manager.service"; import { ManagedProfileProvisioningError, provisionManagedProfile @@ -14,13 +14,13 @@ import { async function createManager(isActive = true): Promise { const profile = await createTestUser(); - await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive, profileId: profile.id }); + await configureManagedProfileManager({ allowedCorridors: ["BR"], allowedCustomerTypes: null, isActive, profileId: profile.id }); return profile; } async function createNarrowedManager(allowedCustomerTypes: Array<"business" | "individual">): Promise { const profile = await createTestUser(); - await ManagedProfileManager.create({ + await configureManagedProfileManager({ allowedCorridors: ["BR"], allowedCustomerTypes, isActive: true, @@ -33,8 +33,10 @@ describe("managed profile provisioning", () => { beforeAll(setupTestDatabase); beforeEach(resetTestDatabase); - it("atomically creates the headless profile, customer entity, relationship, and owner membership", async () => { + it("creates the child aggregate without adding organization memberships or events", async () => { const manager = await createManager(); + const membershipCount = await ManagedProfileMembership.count(); + const eventCount = await ManagedProfileMembershipEvent.count(); const result = await provisionManagedProfile({ contactEmail: " Child@Example.COM ", @@ -65,18 +67,14 @@ describe("managed profile provisioning", () => { profileId: result.profileId, status: "active" }); - expect(await ManagedProfileMembership.findOne({ where: { managedProfileId: result.profileId } })).toMatchObject({ - createdByProfileId: manager.id, + expect(await ManagedProfileMembership.findOne({ where: { ownerProfileId: manager.id } })).toMatchObject({ + createdByProfileId: null, memberProfileId: manager.id, revokedAt: null, role: "manager" }); - expect(await ManagedProfileMembershipEvent.findOne({ where: { managedProfileId: result.profileId } })).toMatchObject({ - action: "member_added", - actorProfileId: manager.id, - memberProfileId: manager.id, - role: "manager" - }); + expect(await ManagedProfileMembership.count()).toBe(membershipCount); + expect(await ManagedProfileMembershipEvent.count()).toBe(eventCount); }); it("returns the existing profile for an idempotent retry", async () => { diff --git a/apps/api/src/api/services/managed-profile-provisioning.service.ts b/apps/api/src/api/services/managed-profile-provisioning.service.ts index 1b6b13309..c919506fe 100644 --- a/apps/api/src/api/services/managed-profile-provisioning.service.ts +++ b/apps/api/src/api/services/managed-profile-provisioning.service.ts @@ -5,8 +5,6 @@ import type { CustomerEntityType } from "../../models/customerEntity.model"; import CustomerEntity from "../../models/customerEntity.model"; import ManagedProfile, { type ManagedProfileCreationSource } from "../../models/managedProfile.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; -import ManagedProfileMembership from "../../models/managedProfileMembership.model"; -import ManagedProfileMembershipEvent from "../../models/managedProfileMembershipEvent.model"; import User from "../../models/user.model"; export class ManagedProfileProvisioningError extends Error { @@ -145,25 +143,6 @@ export async function provisionManagedProfile(input: ProvisionManagedProfileInpu }, { transaction } ); - await ManagedProfileMembership.create( - { - createdByProfileId: input.managerProfileId, - managedProfileId: profile.id, - memberProfileId: input.managerProfileId, - role: "manager" - }, - { transaction } - ); - await ManagedProfileMembershipEvent.create( - { - action: "member_added", - actorProfileId: input.managerProfileId, - managedProfileId: profile.id, - memberProfileId: input.managerProfileId, - role: "manager" - }, - { transaction } - ); return { contactEmail, diff --git a/apps/api/src/database/069-create-managed-profile-memberships-rollback.test.ts b/apps/api/src/database/069-create-managed-profile-memberships-rollback.test.ts index b37f942af..5ac223f8c 100644 --- a/apps/api/src/database/069-create-managed-profile-memberships-rollback.test.ts +++ b/apps/api/src/database/069-create-managed-profile-memberships-rollback.test.ts @@ -28,7 +28,7 @@ describe("069 managed-profile memberships rollback", () => { managed_profile_membership_events, managed_profile_membership_invitations, managed_profile_memberships, - managed_profiles + managed_profile_managers IN ACCESS EXCLUSIVE MODE;`, { transaction } ); diff --git a/apps/api/src/database/managed-profile-memberships-schema.test.ts b/apps/api/src/database/managed-profile-memberships-schema.test.ts index 075bb875f..d5d67518e 100644 --- a/apps/api/src/database/managed-profile-memberships-schema.test.ts +++ b/apps/api/src/database/managed-profile-memberships-schema.test.ts @@ -13,7 +13,13 @@ import { down, up } from "./migrations/069-create-managed-profile-memberships"; async function createManager(): Promise { const profile = await createTestUser(); - await ManagedProfileManager.create({ allowedCorridors: ["BR"], profileId: profile.id }); + await sequelize.transaction(async transaction => { + await ManagedProfileManager.create({ allowedCorridors: ["BR"], profileId: profile.id }, { transaction }); + await ManagedProfileMembership.create( + { ownerProfileId: profile.id, memberProfileId: profile.id, role: "manager" }, + { transaction } + ); + }); return profile; } @@ -35,10 +41,6 @@ async function createManagedProfile( }, { transaction } ); - await ManagedProfileMembership.create( - { managedProfileId: profile.id, memberProfileId: managerProfileId, role: "manager" }, - { transaction } - ); return profile; }); } @@ -47,7 +49,7 @@ describe("managed profile membership schema", () => { beforeAll(setupTestDatabase); beforeEach(resetTestDatabase); - it("backfills an owner manager membership for every retained relationship", async () => { + it("backfills once per configuration including inactive zero-child owners, not once per child", async () => { const queryInterface = sequelize.getQueryInterface(); await down(queryInterface); let defaultPrivilegesGranted = false; @@ -61,16 +63,15 @@ describe("managed profile membership schema", () => { $$;`); await sequelize.query("ALTER DEFAULT PRIVILEGES GRANT ALL PRIVILEGES ON TABLES TO anon, authenticated;"); defaultPrivilegesGranted = true; - const manager = await createManager(); - const profileIds = await sequelize.transaction(async transaction => { - const active = await User.create( - { email: null, id: crypto.randomUUID(), kind: "managed" }, - { transaction } - ); - const deleted = await User.create( - { email: null, id: crypto.randomUUID(), kind: "managed" }, - { transaction } - ); + const manager = await createTestUser(); + const emptyOwner = await createTestUser(); + await ManagedProfileManager.bulkCreate([ + { allowedCorridors: ["BR"], profileId: manager.id }, + { allowedCorridors: ["BR"], profileId: emptyOwner.id, isActive: false } + ]); + await sequelize.transaction(async transaction => { + const active = await User.create({ email: null, id: crypto.randomUUID(), kind: "managed" }, { transaction }); + const deleted = await User.create({ email: null, id: crypto.randomUUID(), kind: "managed" }, { transaction }); await ManagedProfile.bulkCreate( [ { @@ -90,15 +91,14 @@ describe("managed profile membership schema", () => { ], { transaction } ); - return [active.id, deleted.id]; }); await up(queryInterface); - const memberships = await ManagedProfileMembership.findAll({ order: [["managedProfileId", "ASC"]] }); + const memberships = await ManagedProfileMembership.findAll({ order: [["ownerProfileId", "ASC"]] }); expect(memberships).toHaveLength(2); - expect(memberships.map(membership => membership.managedProfileId).sort()).toEqual(profileIds.sort()); - expect(memberships.every(membership => membership.memberProfileId === manager.id)).toBe(true); + expect(memberships.map(membership => membership.ownerProfileId).sort()).toEqual([manager.id, emptyOwner.id].sort()); + expect(memberships.every(membership => membership.memberProfileId === membership.ownerProfileId)).toBe(true); expect(memberships.every(membership => membership.role === "manager" && membership.revokedAt === null)).toBe(true); expect(memberships.every(membership => membership.createdByProfileId === null)).toBe(true); expect(await ManagedProfileMembershipEvent.count()).toBe(0); @@ -122,58 +122,44 @@ describe("managed profile membership schema", () => { } }); - it("requires active children to have an active owner manager membership", async () => { - const manager = await createManager(); - - await expect( - sequelize.transaction(async transaction => { - const profile = await User.create( - { email: null, id: crypto.randomUUID(), kind: "managed" }, - { transaction } - ); - await ManagedProfile.create( - { - creationSource: "manager", - externalSubjectId: "missing-owner-membership", - managerProfileId: manager.id, - profileId: profile.id - }, - { transaction } - ); - }) - ).rejects.toThrow("Active managed profiles require an active owner manager membership"); + it("requires every configuration to have an active owner membership even without children", async () => { + const owner = await createTestUser(); + for (const isActive of [true, false]) { + await expect(ManagedProfileManager.create({ allowedCorridors: ["BR"], profileId: owner.id, isActive })).rejects.toThrow( + "Organizations require an active owner manager membership" + ); + } }); - it("requires authenticated members and a retained managed-profile target", async () => { + it("requires authenticated members and an organization target", async () => { const manager = await createManager(); - const child = await createManagedProfile(manager.id, "membership-kind-child"); const otherChild = await createManagedProfile(manager.id, "membership-kind-member"); const authenticatedProfile = await createTestUser(); await expect( ManagedProfileMembership.create({ - managedProfileId: child.id, + ownerProfileId: manager.id, memberProfileId: otherChild.id, role: "read_only" }) ).rejects.toThrow("Managed profile members must be authenticated profiles"); await expect( ManagedProfileMembership.create({ - managedProfileId: authenticatedProfile.id, + ownerProfileId: authenticatedProfile.id, memberProfileId: authenticatedProfile.id, role: "read_only" }) ).rejects.toThrow(); }); - it("allows only one active membership and permits a distinct grant after revocation", async () => { + it("allows only one active membership globally and permits another organization after revocation", async () => { const owner = await createManager(); + const otherOwner = await createManager(); const member = await createTestUser(); const invalidRoleMember = await createTestUser(); - const child = await createManagedProfile(owner.id, "membership-history"); const first = await ManagedProfileMembership.create({ createdByProfileId: owner.id, - managedProfileId: child.id, + ownerProfileId: owner.id, memberProfileId: member.id, role: "read_only" }); @@ -181,14 +167,14 @@ describe("managed profile membership schema", () => { await expect( ManagedProfileMembership.create({ createdByProfileId: owner.id, - managedProfileId: child.id, + ownerProfileId: otherOwner.id, memberProfileId: member.id, role: "manager" }) ).rejects.toThrow(); await expect( ManagedProfileMembership.create({ - managedProfileId: child.id, + ownerProfileId: owner.id, memberProfileId: invalidRoleMember.id, role: "operator" as "manager" }) @@ -197,33 +183,32 @@ describe("managed profile membership schema", () => { await first.update({ revokedAt: new Date(), revokedByProfileId: owner.id }); const second = await ManagedProfileMembership.create({ createdByProfileId: owner.id, - managedProfileId: child.id, + ownerProfileId: otherOwner.id, memberProfileId: member.id, role: "manager" }); expect(second.id).not.toBe(first.id); - expect(await ManagedProfileMembership.count({ where: { managedProfileId: child.id, memberProfileId: member.id } })).toBe(2); + expect(await ManagedProfileMembership.count({ where: { memberProfileId: member.id } })).toBe(2); }); - it("protects active owner membership and immutable ownership at database level", async () => { + it("protects inactive owner membership and immutable ownership at database level", async () => { const owner = await createManager(); const otherManager = await createManager(); const child = await createManagedProfile(owner.id, "protected-owner"); const relationship = await ManagedProfile.findOne({ where: { profileId: child.id } }); const membership = await ManagedProfileMembership.findOne({ - where: { managedProfileId: child.id, memberProfileId: owner.id } + where: { ownerProfileId: owner.id, memberProfileId: owner.id } }); + await ManagedProfileManager.update({ isActive: false }, { where: { profileId: owner.id } }); await expect(membership?.update({ role: "read_only" })).rejects.toThrow( - "Active managed profile owner membership cannot be downgraded or removed" + "Organization owner membership cannot be downgraded or removed" ); await expect(membership?.update({ revokedAt: new Date(), revokedByProfileId: owner.id })).rejects.toThrow( - "Active managed profile owner membership cannot be downgraded or removed" - ); - await expect(membership?.destroy()).rejects.toThrow( - "Active managed profile owner membership cannot be downgraded or removed" + "Organization owner membership cannot be downgraded or removed" ); + await expect(membership?.destroy()).rejects.toThrow("Organization owner membership cannot be downgraded or removed"); await expect(relationship?.update({ managerProfileId: otherManager.id })).rejects.toThrow( "Managed profile owner cannot be changed after creation" ); @@ -232,13 +217,12 @@ describe("managed profile membership schema", () => { it("constrains invitation roles, normalized email, terminal state, and pending uniqueness", async () => { const owner = await createManager(); const acceptedBy = await createTestUser(); - const child = await createManagedProfile(owner.id, "invitation-constraints"); const expiresAt = new Date(Date.now() + 60_000); const invitation = await ManagedProfileMembershipInvitation.create({ email: "member@example.com", expiresAt, invitedByProfileId: owner.id, - managedProfileId: child.id, + ownerProfileId: owner.id, role: "manager" }); @@ -247,7 +231,7 @@ describe("managed profile membership schema", () => { email: "member@example.com", expiresAt, invitedByProfileId: owner.id, - managedProfileId: child.id, + ownerProfileId: owner.id, role: "read_only" }) ).rejects.toThrow(); @@ -256,7 +240,7 @@ describe("managed profile membership schema", () => { email: "other@example.com", expiresAt, invitedByProfileId: owner.id, - managedProfileId: child.id, + ownerProfileId: owner.id, role: "operator" as "manager" }) ).rejects.toThrow(); @@ -265,7 +249,7 @@ describe("managed profile membership schema", () => { email: " Member@Example.com ", expiresAt, invitedByProfileId: owner.id, - managedProfileId: child.id, + ownerProfileId: owner.id, role: "manager" }) ).rejects.toThrow(); @@ -277,7 +261,7 @@ describe("managed profile membership schema", () => { email: "member@example.com", expiresAt, invitedByProfileId: owner.id, - managedProfileId: child.id, + ownerProfileId: owner.id, role: "read_only" }) ).resolves.toBeInstanceOf(ManagedProfileMembershipInvitation); @@ -285,11 +269,10 @@ describe("managed profile membership schema", () => { it("makes membership events append-only", async () => { const owner = await createManager(); - const child = await createManagedProfile(owner.id, "append-only-events"); const event = await ManagedProfileMembershipEvent.create({ action: "member_added", actorProfileId: owner.id, - managedProfileId: child.id, + ownerProfileId: owner.id, memberProfileId: owner.id, role: "manager" }); @@ -299,7 +282,7 @@ describe("managed profile membership schema", () => { await expect( ManagedProfileMembershipEvent.create({ action: "membership_exported" as "member_added", - managedProfileId: child.id + ownerProfileId: owner.id }) ).rejects.toThrow(); }); diff --git a/apps/api/src/database/managed-profiles-schema.test.ts b/apps/api/src/database/managed-profiles-schema.test.ts index 08b4cc175..a98d3085b 100644 --- a/apps/api/src/database/managed-profiles-schema.test.ts +++ b/apps/api/src/database/managed-profiles-schema.test.ts @@ -8,9 +8,17 @@ import User from "../models/user.model"; import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; import { createTestUser } from "../test-utils/factories"; -async function createManager(): Promise { +async function createManager( + allowedCorridors: ManagedProfileManager["allowedCorridors"] = ["BR", "EU"] +): Promise { const profile = await createTestUser(); - await ManagedProfileManager.create({ allowedCorridors: ["BR", "EU"], profileId: profile.id }); + await sequelize.transaction(async transaction => { + await ManagedProfileManager.create({ allowedCorridors, allowedCustomerTypes: null, profileId: profile.id }, { transaction }); + await ManagedProfileMembership.create( + { ownerProfileId: profile.id, memberProfileId: profile.id, role: "manager" }, + { transaction } + ); + }); return profile; } @@ -34,10 +42,6 @@ async function createManagedProfile( }, { transaction } ); - await ManagedProfileMembership.create( - { managedProfileId: profile.id, memberProfileId: managerProfileId, role: "manager" }, - { transaction } - ); return profile; }); } @@ -176,16 +180,13 @@ describe("managed profile schema", () => { }); it("allows an empty corridor grant so every corridor can be revoked", async () => { - await expect( - ManagedProfileManager.create({ allowedCorridors: [], profileId: (await createTestUser()).id }) - ).resolves.toBeInstanceOf(ManagedProfileManager); + const owner = await createManager([]); + expect((await ManagedProfileManager.findByPk(owner.id))?.allowedCorridors).toEqual([]); }); it("allows null customer-type policy and rejects empty, unknown, or duplicate restrictions", async () => { - const unrestricted = await createTestUser(); - await expect( - ManagedProfileManager.create({ allowedCorridors: ["AR"], allowedCustomerTypes: null, profileId: unrestricted.id }) - ).resolves.toBeInstanceOf(ManagedProfileManager); + const unrestricted = await createManager(["AR"]); + expect((await ManagedProfileManager.findByPk(unrestricted.id))?.allowedCustomerTypes).toBeNull(); for (const allowedCustomerTypes of [[], ["unknown"], ["individual", "individual"]]) { await expect( diff --git a/apps/api/src/database/migrations/069-create-managed-profile-memberships.ts b/apps/api/src/database/migrations/069-create-managed-profile-memberships.ts index bf778eacd..78b6b8ba8 100644 --- a/apps/api/src/database/migrations/069-create-managed-profile-memberships.ts +++ b/apps/api/src/database/migrations/069-create-managed-profile-memberships.ts @@ -20,18 +20,18 @@ export async function up(queryInterface: QueryInterface): Promise { type: DataTypes.UUID }, id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, - managed_profile_id: { + member_profile_id: { allowNull: false, onDelete: "RESTRICT", onUpdate: "CASCADE", - references: { key: "profile_id", model: "managed_profiles" }, + references: { key: "id", model: "profiles" }, type: DataTypes.UUID }, - member_profile_id: { + owner_profile_id: { allowNull: false, onDelete: "RESTRICT", onUpdate: "CASCADE", - references: { key: "id", model: "profiles" }, + references: { key: "profile_id", model: "managed_profile_managers" }, type: DataTypes.UUID }, revoked_at: { allowNull: true, type: DataTypes.DATE }, @@ -55,11 +55,14 @@ export async function up(queryInterface: QueryInterface): Promise { CHECK ((revoked_at IS NULL) = (revoked_by_profile_id IS NULL)); CREATE UNIQUE INDEX uq_managed_profile_memberships_active - ON managed_profile_memberships (managed_profile_id, member_profile_id) + ON managed_profile_memberships (member_profile_id) WHERE revoked_at IS NULL; CREATE INDEX idx_managed_profile_memberships_member - ON managed_profile_memberships (member_profile_id, created_at);`, + ON managed_profile_memberships (member_profile_id, created_at); + + CREATE INDEX idx_managed_profile_memberships_owner_created + ON managed_profile_memberships (owner_profile_id, created_at, id);`, { transaction } ); @@ -94,11 +97,11 @@ export async function up(queryInterface: QueryInterface): Promise { references: { key: "id", model: "profiles" }, type: DataTypes.UUID }, - managed_profile_id: { + owner_profile_id: { allowNull: false, onDelete: "RESTRICT", onUpdate: "CASCADE", - references: { key: "profile_id", model: "managed_profiles" }, + references: { key: "profile_id", model: "managed_profile_managers" }, type: DataTypes.UUID }, role: { allowNull: false, type: DataTypes.STRING(16) }, @@ -120,11 +123,11 @@ export async function up(queryInterface: QueryInterface): Promise { ); CREATE UNIQUE INDEX uq_managed_profile_membership_invitations_pending - ON managed_profile_membership_invitations (managed_profile_id, email) + ON managed_profile_membership_invitations (owner_profile_id, email) WHERE accepted_at IS NULL AND cancelled_at IS NULL AND expired_at IS NULL; - CREATE INDEX idx_managed_profile_membership_invitations_child_created - ON managed_profile_membership_invitations (managed_profile_id, created_at);`, + CREATE INDEX idx_managed_profile_membership_invitations_owner_created + ON managed_profile_membership_invitations (owner_profile_id, created_at);`, { transaction } ); @@ -148,18 +151,18 @@ export async function up(queryInterface: QueryInterface): Promise { references: { key: "id", model: "managed_profile_membership_invitations" }, type: DataTypes.UUID }, - managed_profile_id: { - allowNull: false, + member_profile_id: { + allowNull: true, onDelete: "RESTRICT", onUpdate: "CASCADE", - references: { key: "profile_id", model: "managed_profiles" }, + references: { key: "id", model: "profiles" }, type: DataTypes.UUID }, - member_profile_id: { - allowNull: true, + owner_profile_id: { + allowNull: false, onDelete: "RESTRICT", onUpdate: "CASCADE", - references: { key: "id", model: "profiles" }, + references: { key: "profile_id", model: "managed_profile_managers" }, type: DataTypes.UUID }, previous_role: { allowNull: true, type: DataTypes.STRING(16) }, @@ -188,16 +191,17 @@ export async function up(queryInterface: QueryInterface): Promise { ADD CONSTRAINT chk_managed_profile_membership_events_subject_email CHECK (subject_email IS NULL OR (subject_email <> '' AND subject_email = lower(btrim(subject_email)))); - CREATE INDEX idx_managed_profile_membership_events_child_created - ON managed_profile_membership_events (managed_profile_id, created_at DESC, id DESC);`, + CREATE INDEX idx_managed_profile_membership_events_owner_created + ON managed_profile_membership_events (owner_profile_id, created_at DESC, id DESC);`, { transaction } ); - // Existing relationships predate event attribution, so they intentionally receive no event. + // One organization per existing configuration, including inactive owners and owners with no children. + // Backfill predates event attribution, so it intentionally receives no event. await queryInterface.sequelize.query( `INSERT INTO managed_profile_memberships ( id, - managed_profile_id, + owner_profile_id, member_profile_id, role, created_by_profile_id, @@ -206,13 +210,13 @@ export async function up(queryInterface: QueryInterface): Promise { ) SELECT uuid_generate_v4(), - mp.profile_id, - mp.manager_profile_id, + mp.profile_id, + mp.profile_id, 'manager', NULL, mp.created_at, mp.created_at - FROM managed_profiles mp;`, + FROM managed_profile_managers mp;`, { transaction } ); @@ -234,18 +238,11 @@ export async function up(queryInterface: QueryInterface): Promise { FOR EACH ROW EXECUTE FUNCTION enforce_managed_profile_manager_immutable(); CREATE FUNCTION enforce_managed_profile_owner_membership() RETURNS trigger AS $$ - DECLARE - relationship managed_profiles%ROWTYPE; BEGIN - SELECT * INTO relationship - FROM managed_profiles - WHERE profile_id = OLD.managed_profile_id; - - IF relationship.status = 'active' - AND relationship.manager_profile_id = OLD.member_profile_id + IF OLD.owner_profile_id = OLD.member_profile_id AND ( TG_OP = 'DELETE' - OR NEW.managed_profile_id IS DISTINCT FROM OLD.managed_profile_id + OR NEW.owner_profile_id IS DISTINCT FROM OLD.owner_profile_id OR NEW.member_profile_id IS DISTINCT FROM OLD.member_profile_id OR NEW.role <> 'manager' OR NEW.revoked_at IS NOT NULL @@ -254,7 +251,7 @@ export async function up(queryInterface: QueryInterface): Promise { RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'chk_managed_profiles_owner_membership', - MESSAGE = 'Active managed profile owner membership cannot be downgraded or removed'; + MESSAGE = 'Organization owner membership cannot be downgraded or removed'; END IF; IF TG_OP = 'DELETE' THEN @@ -273,27 +270,27 @@ export async function up(queryInterface: QueryInterface): Promise { await queryInterface.sequelize.query( `CREATE FUNCTION enforce_managed_profile_membership_invariants() RETURNS trigger AS $$ DECLARE - affected_managed_profile_ids uuid[] := ARRAY[]::uuid[]; + affected_owner_profile_ids uuid[] := ARRAY[]::uuid[]; affected_member_profile_ids uuid[] := ARRAY[]::uuid[]; BEGIN IF TG_TABLE_NAME = 'managed_profile_memberships' THEN IF TG_OP = 'INSERT' THEN - affected_managed_profile_ids := ARRAY[NEW.managed_profile_id]; + affected_owner_profile_ids := ARRAY[NEW.owner_profile_id]; affected_member_profile_ids := ARRAY[NEW.member_profile_id]; ELSIF TG_OP = 'DELETE' THEN - affected_managed_profile_ids := ARRAY[OLD.managed_profile_id]; + affected_owner_profile_ids := ARRAY[OLD.owner_profile_id]; affected_member_profile_ids := ARRAY[OLD.member_profile_id]; ELSE - affected_managed_profile_ids := ARRAY[OLD.managed_profile_id, NEW.managed_profile_id]; + affected_owner_profile_ids := ARRAY[OLD.owner_profile_id, NEW.owner_profile_id]; affected_member_profile_ids := ARRAY[OLD.member_profile_id, NEW.member_profile_id]; END IF; - ELSIF TG_TABLE_NAME = 'managed_profiles' THEN + ELSIF TG_TABLE_NAME = 'managed_profile_managers' THEN IF TG_OP = 'INSERT' THEN - affected_managed_profile_ids := ARRAY[NEW.profile_id]; + affected_owner_profile_ids := ARRAY[NEW.profile_id]; ELSIF TG_OP = 'DELETE' THEN - affected_managed_profile_ids := ARRAY[OLD.profile_id]; + affected_owner_profile_ids := ARRAY[OLD.profile_id]; ELSE - affected_managed_profile_ids := ARRAY[OLD.profile_id, NEW.profile_id]; + affected_owner_profile_ids := ARRAY[OLD.profile_id, NEW.profile_id]; END IF; ELSE IF TG_OP = 'INSERT' THEN @@ -320,14 +317,13 @@ export async function up(queryInterface: QueryInterface): Promise { IF EXISTS ( SELECT 1 - FROM managed_profiles relationship - WHERE relationship.profile_id = ANY(affected_managed_profile_ids) - AND relationship.status = 'active' + FROM managed_profile_managers organization + WHERE organization.profile_id = ANY(affected_owner_profile_ids) AND NOT EXISTS ( SELECT 1 FROM managed_profile_memberships membership - WHERE membership.managed_profile_id = relationship.profile_id - AND membership.member_profile_id = relationship.manager_profile_id + WHERE membership.owner_profile_id = organization.profile_id + AND membership.member_profile_id = organization.profile_id AND membership.role = 'manager' AND membership.revoked_at IS NULL ) @@ -335,7 +331,7 @@ export async function up(queryInterface: QueryInterface): Promise { RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'chk_managed_profiles_owner_membership', - MESSAGE = 'Active managed profiles require an active owner manager membership'; + MESSAGE = 'Organizations require an active owner manager membership'; END IF; RETURN NULL; @@ -347,8 +343,8 @@ export async function up(queryInterface: QueryInterface): Promise { DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION enforce_managed_profile_membership_invariants(); - CREATE CONSTRAINT TRIGGER trg_managed_profiles_membership_invariants - AFTER INSERT OR UPDATE OR DELETE ON managed_profiles + CREATE CONSTRAINT TRIGGER trg_managed_profile_managers_membership_invariants + AFTER INSERT OR UPDATE OR DELETE ON managed_profile_managers DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION enforce_managed_profile_membership_invariants(); @@ -409,7 +405,7 @@ export async function down(queryInterface: QueryInterface): Promise { managed_profile_membership_events, managed_profile_membership_invitations, managed_profile_memberships, - managed_profiles + managed_profile_managers IN ACCESS EXCLUSIVE MODE;`, { transaction } ); @@ -420,10 +416,10 @@ export async function down(queryInterface: QueryInterface): Promise { EXISTS ( SELECT 1 FROM managed_profile_memberships membership - LEFT JOIN managed_profiles relationship - ON relationship.profile_id = membership.managed_profile_id - WHERE relationship.profile_id IS NULL - OR membership.member_profile_id <> relationship.manager_profile_id + LEFT JOIN managed_profile_managers organization + ON organization.profile_id = membership.owner_profile_id + WHERE organization.profile_id IS NULL + OR membership.member_profile_id <> organization.profile_id OR membership.role <> 'manager' OR membership.created_by_profile_id IS NOT NULL OR membership.revoked_at IS NOT NULL @@ -431,12 +427,12 @@ export async function down(queryInterface: QueryInterface): Promise { ) AS "hasNonBackfillMemberships", EXISTS ( SELECT 1 - FROM managed_profiles relationship + FROM managed_profile_managers organization WHERE NOT EXISTS ( SELECT 1 FROM managed_profile_memberships membership - WHERE membership.managed_profile_id = relationship.profile_id - AND membership.member_profile_id = relationship.manager_profile_id + WHERE membership.owner_profile_id = organization.profile_id + AND membership.member_profile_id = organization.profile_id AND membership.role = 'manager' AND membership.created_by_profile_id IS NULL AND membership.revoked_at IS NULL @@ -457,9 +453,12 @@ export async function down(queryInterface: QueryInterface): Promise { throw new Error("Cannot revert managed-profile memberships after membership activity has been recorded"); } - await queryInterface.sequelize.query("DROP TRIGGER trg_managed_profiles_membership_invariants ON managed_profiles;", { - transaction - }); + await queryInterface.sequelize.query( + "DROP TRIGGER trg_managed_profile_managers_membership_invariants ON managed_profile_managers;", + { + transaction + } + ); await queryInterface.sequelize.query("DROP TRIGGER trg_profiles_membership_invariants ON profiles;", { transaction }); await queryInterface.sequelize.query("DROP TRIGGER trg_managed_profiles_manager_immutable ON managed_profiles;", { transaction diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 0b950b822..4c527d27a 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -77,34 +77,34 @@ User.hasOne(ManagedProfile, { as: "managedProfileRelationship", foreignKey: "pro ManagedProfile.belongsTo(User, { as: "profile", foreignKey: "profileId" }); ManagedProfileManager.hasMany(ManagedProfile, { as: "managedProfiles", foreignKey: "managerProfileId" }); ManagedProfile.belongsTo(ManagedProfileManager, { as: "manager", foreignKey: "managerProfileId" }); -ManagedProfile.hasMany(ManagedProfileMembership, { +ManagedProfileManager.hasMany(ManagedProfileMembership, { as: "memberships", - foreignKey: "managedProfileId", + foreignKey: "ownerProfileId", sourceKey: "profileId" }); -ManagedProfileMembership.belongsTo(ManagedProfile, { - as: "managedProfile", - foreignKey: "managedProfileId", +ManagedProfileMembership.belongsTo(ManagedProfileManager, { + as: "organization", + foreignKey: "ownerProfileId", targetKey: "profileId" }); -ManagedProfile.hasMany(ManagedProfileMembershipInvitation, { +ManagedProfileManager.hasMany(ManagedProfileMembershipInvitation, { as: "membershipInvitations", - foreignKey: "managedProfileId", + foreignKey: "ownerProfileId", sourceKey: "profileId" }); -ManagedProfileMembershipInvitation.belongsTo(ManagedProfile, { - as: "managedProfile", - foreignKey: "managedProfileId", +ManagedProfileMembershipInvitation.belongsTo(ManagedProfileManager, { + as: "organization", + foreignKey: "ownerProfileId", targetKey: "profileId" }); -ManagedProfile.hasMany(ManagedProfileMembershipEvent, { +ManagedProfileManager.hasMany(ManagedProfileMembershipEvent, { as: "membershipEvents", - foreignKey: "managedProfileId", + foreignKey: "ownerProfileId", sourceKey: "profileId" }); -ManagedProfileMembershipEvent.belongsTo(ManagedProfile, { - as: "managedProfile", - foreignKey: "managedProfileId", +ManagedProfileMembershipEvent.belongsTo(ManagedProfileManager, { + as: "organization", + foreignKey: "ownerProfileId", targetKey: "profileId" }); User.hasMany(ManagedProfileMembership, { as: "managedProfileMemberships", foreignKey: "memberProfileId" }); diff --git a/apps/api/src/models/managedProfileMembership.model.ts b/apps/api/src/models/managedProfileMembership.model.ts index 06456c5b6..ffd3cc738 100644 --- a/apps/api/src/models/managedProfileMembership.model.ts +++ b/apps/api/src/models/managedProfileMembership.model.ts @@ -5,7 +5,7 @@ export type ManagedProfileMembershipRole = "manager" | "read_only"; export interface ManagedProfileMembershipAttributes { id: string; - managedProfileId: string; + ownerProfileId: string; memberProfileId: string; role: ManagedProfileMembershipRole; createdByProfileId: string | null; @@ -25,7 +25,7 @@ class ManagedProfileMembership implements ManagedProfileMembershipAttributes { declare id: string; - declare managedProfileId: string; + declare ownerProfileId: string; declare memberProfileId: string; declare role: ManagedProfileMembershipRole; declare createdByProfileId: string | null; @@ -47,20 +47,20 @@ ManagedProfileMembership.init( type: DataTypes.UUID }, id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, - managedProfileId: { + memberProfileId: { allowNull: false, - field: "managed_profile_id", + field: "member_profile_id", onDelete: "RESTRICT", onUpdate: "CASCADE", - references: { key: "profile_id", model: "managed_profiles" }, + references: { key: "id", model: "profiles" }, type: DataTypes.UUID }, - memberProfileId: { + ownerProfileId: { allowNull: false, - field: "member_profile_id", + field: "owner_profile_id", onDelete: "RESTRICT", onUpdate: "CASCADE", - references: { key: "id", model: "profiles" }, + references: { key: "profile_id", model: "managed_profile_managers" }, type: DataTypes.UUID }, revokedAt: { allowNull: true, field: "revoked_at", type: DataTypes.DATE }, @@ -78,12 +78,13 @@ ManagedProfileMembership.init( { indexes: [ { - fields: ["managed_profile_id", "member_profile_id"], + fields: ["member_profile_id"], name: "uq_managed_profile_memberships_active", unique: true, where: { revoked_at: null } }, - { fields: ["member_profile_id", "created_at"], name: "idx_managed_profile_memberships_member" } + { fields: ["member_profile_id", "created_at"], name: "idx_managed_profile_memberships_member" }, + { fields: ["owner_profile_id", "created_at", "id"], name: "idx_managed_profile_memberships_owner_created" } ], modelName: "ManagedProfileMembership", sequelize, diff --git a/apps/api/src/models/managedProfileMembershipEvent.model.ts b/apps/api/src/models/managedProfileMembershipEvent.model.ts index 6c1cb24a2..cff32f93e 100644 --- a/apps/api/src/models/managedProfileMembershipEvent.model.ts +++ b/apps/api/src/models/managedProfileMembershipEvent.model.ts @@ -13,7 +13,7 @@ export type ManagedProfileMembershipAction = export interface ManagedProfileMembershipEventAttributes { id: string; - managedProfileId: string; + ownerProfileId: string; action: ManagedProfileMembershipAction; actorProfileId: string | null; memberProfileId: string | null; @@ -34,7 +34,7 @@ class ManagedProfileMembershipEvent implements ManagedProfileMembershipEventAttributes { declare id: string; - declare managedProfileId: string; + declare ownerProfileId: string; declare action: ManagedProfileMembershipAction; declare actorProfileId: string | null; declare memberProfileId: string | null; @@ -66,14 +66,6 @@ ManagedProfileMembershipEvent.init( references: { key: "id", model: "managed_profile_membership_invitations" }, type: DataTypes.UUID }, - managedProfileId: { - allowNull: false, - field: "managed_profile_id", - onDelete: "RESTRICT", - onUpdate: "CASCADE", - references: { key: "profile_id", model: "managed_profiles" }, - type: DataTypes.UUID - }, memberProfileId: { allowNull: true, field: "member_profile_id", @@ -82,6 +74,14 @@ ManagedProfileMembershipEvent.init( references: { key: "id", model: "profiles" }, type: DataTypes.UUID }, + ownerProfileId: { + allowNull: false, + field: "owner_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "profile_id", model: "managed_profile_managers" }, + type: DataTypes.UUID + }, previousRole: { allowNull: true, field: "previous_role", type: DataTypes.STRING(16) }, role: { allowNull: true, type: DataTypes.STRING(16) }, subjectEmail: { allowNull: true, field: "subject_email", type: DataTypes.STRING(255) } @@ -89,8 +89,8 @@ ManagedProfileMembershipEvent.init( { indexes: [ { - fields: ["managed_profile_id", { name: "created_at", order: "DESC" }, { name: "id", order: "DESC" }], - name: "idx_managed_profile_membership_events_child_created" + fields: ["owner_profile_id", { name: "created_at", order: "DESC" }, { name: "id", order: "DESC" }], + name: "idx_managed_profile_membership_events_owner_created" } ], modelName: "ManagedProfileMembershipEvent", diff --git a/apps/api/src/models/managedProfileMembershipInvitation.model.ts b/apps/api/src/models/managedProfileMembershipInvitation.model.ts index ec7dd715b..7a216d50b 100644 --- a/apps/api/src/models/managedProfileMembershipInvitation.model.ts +++ b/apps/api/src/models/managedProfileMembershipInvitation.model.ts @@ -4,7 +4,7 @@ import type { ManagedProfileMembershipRole } from "./managedProfileMembership.mo export interface ManagedProfileMembershipInvitationAttributes { id: string; - managedProfileId: string; + ownerProfileId: string; email: string; role: ManagedProfileMembershipRole; invitedByProfileId: string; @@ -28,7 +28,7 @@ class ManagedProfileMembershipInvitation implements ManagedProfileMembershipInvitationAttributes { declare id: string; - declare managedProfileId: string; + declare ownerProfileId: string; declare email: string; declare role: ManagedProfileMembershipRole; declare invitedByProfileId: string; @@ -75,12 +75,12 @@ ManagedProfileMembershipInvitation.init( references: { key: "id", model: "profiles" }, type: DataTypes.UUID }, - managedProfileId: { + ownerProfileId: { allowNull: false, - field: "managed_profile_id", + field: "owner_profile_id", onDelete: "RESTRICT", onUpdate: "CASCADE", - references: { key: "profile_id", model: "managed_profiles" }, + references: { key: "profile_id", model: "managed_profile_managers" }, type: DataTypes.UUID }, role: { allowNull: false, type: DataTypes.STRING(16) }, @@ -89,14 +89,14 @@ ManagedProfileMembershipInvitation.init( { indexes: [ { - fields: ["managed_profile_id", "email"], + fields: ["owner_profile_id", "email"], name: "uq_managed_profile_membership_invitations_pending", unique: true, where: { accepted_at: null, cancelled_at: null, expired_at: null } }, { - fields: ["managed_profile_id", "created_at"], - name: "idx_managed_profile_membership_invitations_child_created" + fields: ["owner_profile_id", "created_at"], + name: "idx_managed_profile_membership_invitations_owner_created" } ], modelName: "ManagedProfileMembershipInvitation", diff --git a/apps/api/src/tests/alfredpay-managed-customer.integration.test.ts b/apps/api/src/tests/alfredpay-managed-customer.integration.test.ts index 4d4191cf6..b376b5856 100644 --- a/apps/api/src/tests/alfredpay-managed-customer.integration.test.ts +++ b/apps/api/src/tests/alfredpay-managed-customer.integration.test.ts @@ -5,7 +5,6 @@ import sequelize from "../config/database"; import CustomerEntity from "../models/customerEntity.model"; import ManagedProfile from "../models/managedProfile.model"; import ManagedProfileManager from "../models/managedProfileManager.model"; -import ManagedProfileMembership from "../models/managedProfileMembership.model"; import ProviderCustomer from "../models/providerCustomer.model"; import User from "../models/user.model"; import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; @@ -15,6 +14,7 @@ import { SupabaseAuthService } from "../api/services/auth"; import { createAlfredpayCustomer } from "../api/services/alfredpay/alfredpay-customer.service"; import { createManagedProfileCredential } from "../api/services/apiCredential.service"; import { provisionManagedProfile } from "../api/services/managed-profile-provisioning.service"; +import { configureManagedProfileManager } from "../api/services/managed-profile-manager.service"; const BASE_PATH = "/v1/alfredpay"; const originalGetInstance = AlfredpayApiService.getInstance; @@ -47,7 +47,7 @@ describe("managed Alfredpay customer creation", () => { allowedCustomerTypes: Array<"business" | "individual"> | null = null ) { const manager = await createTestUser({ email: "manager@example.com" }); - await ManagedProfileManager.create({ allowedCorridors, allowedCustomerTypes, isActive: true, profileId: manager.id }); + await configureManagedProfileManager({ allowedCorridors, allowedCustomerTypes, isActive: true, profileId: manager.id }); return manager; } @@ -299,10 +299,6 @@ describe("managed Alfredpay customer creation", () => { }, { transaction } ); - await ManagedProfileMembership.create( - { managedProfileId: childId, memberProfileId: manager.id, role: "manager" }, - { transaction } - ); }); const credential = await createTestApiKey({ userId: manager.id }); const createCustomer = mock(async () => ({ customerId: "unexpected", createdAt: new Date().toISOString() })); @@ -364,7 +360,7 @@ describe("managed Alfredpay customer creation", () => { // A second manager picks the victim's email as its child's unverified contact email. const attackerManager = await createTestUser({ email: "attacker@example.com" }); - await ManagedProfileManager.create({ + await configureManagedProfileManager({ allowedCorridors: ["MX"], allowedCustomerTypes: null, isActive: true, diff --git a/apps/api/src/tests/managed-profile-quote-ramp-lifecycle.integration.test.ts b/apps/api/src/tests/managed-profile-quote-ramp-lifecycle.integration.test.ts index 008a601ca..2ad48d06c 100644 --- a/apps/api/src/tests/managed-profile-quote-ramp-lifecycle.integration.test.ts +++ b/apps/api/src/tests/managed-profile-quote-ramp-lifecycle.integration.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; import { privateKeyToAccount } from "viem/accounts"; +import { configureManagedProfileManager } from "../api/services/managed-profile-manager.service"; import ApiCredential from "../models/apiCredential.model"; import ManagedProfile from "../models/managedProfile.model"; import ManagedProfileManager from "../models/managedProfileManager.model"; @@ -63,7 +64,7 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { it("retains registered child records while enforcing current delegation and owner isolation", async () => { const manager = await createTestUser({ email: "managed-lifecycle-manager@example.com" }); - await ManagedProfileManager.create({ allowedCorridors: ["BR"], isActive: true, profileId: manager.id }); + await configureManagedProfileManager({ allowedCorridors: ["BR"], allowedCustomerTypes: null, isActive: true, profileId: manager.id }); const managerCredential = await createTestApiKey({ userId: manager.id }); const managerHeaders = { "Content-Type": "application/json", "X-API-Key": managerCredential.plaintextKey }; @@ -84,10 +85,10 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { const childId = await createChild("managed-lifecycle-child"); const siblingId = await createChild("managed-lifecycle-sibling"); const member = await createTestUser({ email: "managed-lifecycle-member@example.com" }); - await ManagedProfileMembership.bulkCreate([ - { createdByProfileId: manager.id, managedProfileId: childId, memberProfileId: member.id, role: "manager" }, - { createdByProfileId: manager.id, managedProfileId: siblingId, memberProfileId: member.id, role: "manager" } - ]); + const membership = await ManagedProfileMembership.create( + { createdByProfileId: manager.id, ownerProfileId: manager.id, memberProfileId: member.id, role: "manager" } + ); + const laterChildId = await createChild("managed-lifecycle-later"); const memberCredential = await createTestApiKey({ userId: member.id }); const pricingPartner = await createTestPartner({ fiatCurrency: FiatToken.BRL, @@ -139,6 +140,13 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { "X-Managed-Profile-Id": childId }; + for (const profileId of [childId, siblingId, laterChildId]) { + const detail = await jsonRequest(`/v1/managed-profiles/${profileId}`, { + headers: { ...delegatedHeaders, "X-Managed-Profile-Id": profileId }, method: "GET" + }); + expect(detail.status).toBe(200); + } + const delegatedOnboarding = await jsonRequest("/v1/onboarding/status", { headers: delegatedHeaders, method: "GET" @@ -344,6 +352,29 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { expect(startBeforeNarrowing.status).toBe(400); expect(JSON.stringify(startBeforeNarrowing.body)).toContain("No presigned transactions found"); + await membership.update({ role: "read_only" }); + for (const profileId of [childId, siblingId, laterChildId]) { + const denied = await jsonRequest("/v1/ramp/update", { + body: JSON.stringify({ rampId: delegatedRampId }), + headers: { ...delegatedHeaders, "X-Managed-Profile-Id": profileId }, method: "POST" + }); + expect(denied.status).toBe(403); + expect(denied.body).toMatchObject({ error: { code: "MANAGED_PROFILE_MANAGER_REQUIRED" } }); + } + await membership.update({ revokedAt: new Date(), revokedByProfileId: manager.id }); + for (const profileId of [childId, siblingId, laterChildId]) { + const denied = await jsonRequest("/v1/ramp/history", { + headers: { ...delegatedHeaders, "X-Managed-Profile-Id": profileId }, method: "GET" + }); + expect(denied.status).toBe(403); + } + expect((await jsonRequest("/v1/ramp/history", { + headers: { "X-API-Key": childCredential.secretKey }, method: "GET" + })).status).toBe(200); + await ManagedProfileMembership.create({ + createdByProfileId: manager.id, ownerProfileId: manager.id, memberProfileId: member.id, role: "manager" + }); + await ManagedProfileManager.update({ allowedCorridors: [] }, { where: { profileId: manager.id } }); const updateAfterNarrowing = await jsonRequest("/v1/ramp/update", { body: JSON.stringify({ rampId: delegatedRampId }), diff --git a/apps/api/src/tests/recipients.integration.test.ts b/apps/api/src/tests/recipients.integration.test.ts index 670f4c4a0..a485fb162 100644 --- a/apps/api/src/tests/recipients.integration.test.ts +++ b/apps/api/src/tests/recipients.integration.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { EvmToken, FiatToken, RampDirection } from "@vortexfi/shared"; import { provisionManagedProfile } from "../api/services/managed-profile-provisioning.service"; +import { configureManagedProfileManager } from "../api/services/managed-profile-manager.service"; import { findPartnerWithPricing } from "../api/services/partners/partner-pricing.service"; import { config } from "../config/vars"; import CustomerEntity from "../models/customerEntity.model"; @@ -69,7 +70,7 @@ async function createApprovedSender(email: string): Promise<{ user: User; token: async function createManagedSender(suffix = "") { const manager = await createAuthedUser(`manager${suffix}@example.com`); - await ManagedProfileManager.create({ allowedCorridors: ["MX"], isActive: true, profileId: manager.user.id }); + await configureManagedProfileManager({ allowedCorridors: ["MX"], allowedCustomerTypes: null, isActive: true, profileId: manager.user.id }); const child = await provisionManagedProfile({ contactEmail: `managed-sender${suffix}@example.com`, creationSource: "manager", diff --git a/apps/dashboard/e2e/managed-profiles.spec.ts b/apps/dashboard/e2e/managed-profiles.spec.ts index 02119a27f..0c92d8179 100644 --- a/apps/dashboard/e2e/managed-profiles.spec.ts +++ b/apps/dashboard/e2e/managed-profiles.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { E2E_MANAGED_PROFILE_ID, mockBackend } from "./support/mockBackend"; +import { E2E_MANAGED_PROFILE_ID, E2E_ORGANIZATION_OWNER_ID, mockBackend } from "./support/mockBackend"; import { E2E_USER_ID, seedSession } from "./support/session"; const CHILD_EMAIL = "managed-child-with-a-long-identifier@example.test"; @@ -197,12 +197,14 @@ test("read-only child membership keeps reads but removes mutations and transfer }); test("open recipient and payout controls close when the membership is downgraded", async ({ page }) => { - const child = { - ...CHILD, - membership: { isOwner: true, role: "manager" as "manager" | "read_only" } + const organization = { + membership: { isOwner: false, role: "manager" as "manager" | "read_only" }, + ownerEmail: "owner@example.test", + ownerProfileId: E2E_ORGANIZATION_OWNER_ID }; const backend = await mockBackend(page, { - managedProfiles: [child], + managedProfiles: [CHILD], + organization, pendingInvitations: [ { alias: "Downgrade recipient", @@ -240,18 +242,18 @@ test("open recipient and payout controls close when the membership is downgraded await page.goto("/recipients"); await page.getByRole("cell", { name: "Downgrade recipient" }).click(); await expect(page.getByRole("dialog")).toBeVisible(); - child.membership = { isOwner: false, role: "read_only" }; + organization.membership.role = "read_only"; await refreshMembership(); await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByText("Recipient management is read-only for this membership.")).toBeVisible(); - child.membership = { isOwner: true, role: "manager" }; + organization.membership.role = "manager"; await refreshMembership(); await page.goto("/overview"); await page.getByTestId("corridor-card-MX").getByRole("button", { name: "View pay-out accounts" }).click(); await page.getByRole("button", { name: "Add another account" }).click(); await expect(page.getByRole("heading", { name: "Add pay-out account" })).toBeVisible(); - child.membership = { isOwner: false, role: "read_only" }; + organization.membership.role = "read_only"; await refreshMembership(); await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByText("Read only", { exact: true })).toBeVisible(); @@ -333,24 +335,25 @@ test("long managed identifiers and the acting banner fit a mobile viewport", asy }); for (const mobile of [false, true]) { - for (const hasMemberships of [false, true]) { - test(`provisioning actor retains navigation with ${hasMemberships ? "active" : "no"} memberships (${mobile ? "mobile" : "desktop"})`, async ({ + for (const hasChildren of [false, true]) { + test(`provisioning actor retains navigation with ${hasChildren ? "active" : "no"} children (${mobile ? "mobile" : "desktop"})`, async ({ page }) => { if (mobile) await page.setViewportSize({ height: 844, width: 390 }); const backend = await mockBackend(page, { canProvisionManagedProfiles: true, - managedProfiles: hasMemberships ? [CHILD] : [] + managedProfiles: hasChildren ? [CHILD] : [] }); await seedSession(page); await page.goto("/managed-profiles"); await expect(page.getByRole("heading", { name: "Managed profiles" })).toBeVisible(); - if (!hasMemberships) { + if (!hasChildren) { await expect(page.getByText("No managed profiles", { exact: true })).toBeVisible(); await expect(page.getByText(/You can provision managed profiles through the API/)).toBeVisible(); } if (mobile) await page.getByRole("button", { name: "Toggle Sidebar" }).first().click(); await expect(page.getByRole("link", { exact: true, name: "Managed profiles" })).toBeVisible(); + await expect(page.getByRole("link", { exact: true, name: "Team" })).toBeVisible(); expect(backend.unmatchedRequests).toEqual([]); }); } diff --git a/apps/dashboard/e2e/onboarding-monerium-eu.spec.ts b/apps/dashboard/e2e/onboarding-monerium-eu.spec.ts index 40402d7fb..f4ff603a9 100644 --- a/apps/dashboard/e2e/onboarding-monerium-eu.spec.ts +++ b/apps/dashboard/e2e/onboarding-monerium-eu.spec.ts @@ -74,7 +74,20 @@ test("Monerium callback does not complete OAuth while impersonating", async ({ p }); test("Monerium callback does not complete OAuth while acting for a managed child", async ({ page }) => { - const backend = await mockBackend(page, { moneriumKyc: true }); + const backend = await mockBackend(page, { + managedProfiles: [ + { + contactEmail: "managed-child@example.test", + customerType: "individual", + externalSubjectId: "managed-child-e2e", + membership: { isOwner: false, role: "manager" }, + policy: { allowedCorridors: ["EU"], allowedCustomerTypes: null }, + profileId: E2E_MANAGED_PROFILE_ID, + status: "active" + } + ], + moneriumKyc: true + }); await seedSession(page); await page.addInitScript( ({ managerProfileId, targetProfileId }) => { @@ -83,7 +96,9 @@ test("Monerium callback does not complete OAuth while acting for a managed child JSON.stringify({ customerType: "individual", externalSubjectId: "managed-child-e2e", + isOwner: false, managerProfileId, + membershipRole: "manager", targetEmail: "managed-child@example.test", targetProfileId }) @@ -95,7 +110,9 @@ test("Monerium callback does not complete OAuth while acting for a managed child await page.goto("/monerium/callback?code=e2e-code&state=e2e-state"); await expect(page).toHaveURL(/\/overview$/); + await expect(page.getByText("Acting for managed-child@example.test")).toBeVisible(); expect(backend.apiRequests.filter(request => request.path === "/v1/monerium/oauth/complete")).toEqual([]); + expect(backend.unmatchedRequests).toEqual([]); }); test("in-review Monerium onboarding requiring reauthentication is disabled instead of actionable", async ({ page }) => { diff --git a/apps/dashboard/e2e/support/mockBackend.ts b/apps/dashboard/e2e/support/mockBackend.ts index cc9299b38..42ed6aad0 100644 --- a/apps/dashboard/e2e/support/mockBackend.ts +++ b/apps/dashboard/e2e/support/mockBackend.ts @@ -3,10 +3,11 @@ import type { InvitationPreview, MemberEvent, MemberInvitation, + Organization, TeamMember } from "../../src/services/api/managed-profile-memberships.service"; import { MOCK_WALLET_ADDRESS } from "./mockWallet"; -import { E2E_USER_ID } from "./session"; +import { E2E_USER_EMAIL, E2E_USER_ID } from "./session"; export const APP_ORIGIN = "http://127.0.0.1:5174"; export const E2E_RAMP_ID = "ramp-e2e-1"; @@ -14,6 +15,7 @@ export const E2E_QUOTE_ID = "quote-e2e-1"; export const E2E_FIAT_ACCOUNT_ID = "fiat-account-e2e-mx"; export const E2E_FIAT_ACCOUNT_ID_2 = "fiat-account-e2e-mx-2"; export const E2E_MANAGED_PROFILE_ID = "managed-profile-e2e-child-1"; +export const E2E_ORGANIZATION_OWNER_ID = "11111111-1111-4111-8111-111111111111"; export const MX_USDC_RATE = 18.5; const POLYGON_USDT = "0xc2132d05d31c914a87c6611c10748aeb04b58e8f"; @@ -259,6 +261,14 @@ export function buildSellUnsignedTxs(evmEphemeral: string) { } interface MockBackendOptions { + organizationInventories?: Record< + string, + { + managedProfiles: NonNullable; + team: NonNullable; + } + >; + organization?: Organization | null; team?: { members: TeamMember[]; invitations: MemberInvitation[]; events: MemberEvent[] }; memberInvitation?: { preview: InvitationPreview; @@ -421,12 +431,33 @@ function answerRpc(chainIdHex: string) { * changed default RPC URL fails the suite instead of silently reaching the network. */ export async function mockBackend(page: Page, options: MockBackendOptions = {}) { + // Child fixtures are organization inventory. A null membership keeps all of them + // inaccessible until acceptance; newly appended children inherit the same authority. + if (options.organization === undefined) { + const membership = options.managedProfiles?.[0]?.membership; + options.organization = + membership || options.canProvisionManagedProfiles + ? { + membership: membership ?? { isOwner: true, role: "manager" }, + ownerEmail: membership?.isOwner === false ? "owner@example.test" : E2E_USER_EMAIL, + ownerProfileId: membership?.isOwner === false ? E2E_ORGANIZATION_OWNER_ID : E2E_USER_ID + } + : null; + } const knownManagedProfileIds = new Set([ ...(options.managedProfiles ?? []).map(profile => profile.profileId), ...(options.previousManagedProfileIds ?? []) ]); const membershipRequests: Array<{ method: string; path: string; body: Record | null; search: string }> = []; const team = options.team ?? { events: [], invitations: [], members: [] }; + const inventories = options.organizationInventories ?? { + [options.organization?.ownerProfileId ?? + options.memberInvitation?.preview.organization.ownerProfileId ?? + E2E_ORGANIZATION_OWNER_ID]: { + managedProfiles: options.managedProfiles ?? [], + team + } + }; const apiRequests: Array<{ managedProfileId: string | undefined; method: string; path: string }> = []; const apiCredentialRequests: Array<{ body: Record | null; method: string; path: string }> = []; const limitsRequests: Array> = []; @@ -500,12 +531,17 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) apiRequests.push({ managedProfileId: request.headers()["x-managed-profile-id"], method, path }); const fulfillJson = (body: unknown, code = 200) => route.fulfill({ json: body as object, status: code }); - const eligibleProfiles = (options.managedProfiles ?? []).filter( - profile => profile.status === "active" && options.managedProfileOwnerActive !== false - ); + const organization = options.managedProfileOwnerActive === false ? null : options.organization; + const inventory = organization ? inventories[organization.ownerProfileId] : undefined; + const eligibleProfiles = organization + ? (inventory?.managedProfiles ?? []) + .filter(profile => profile.status === "active") + .map(profile => ({ ...profile, membership: { ...organization.membership } })) + : []; + for (const profile of eligibleProfiles) knownManagedProfileIds.add(profile.profileId); const managedProfileActor = { canProvisionManagedProfiles: options.canProvisionManagedProfiles === true, - hasMemberships: eligibleProfiles.length > 0, + hasMemberships: !!organization, profileId: E2E_USER_ID }; @@ -541,19 +577,35 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) return; } - const teamPath = path.match(/^\/v1\/managed-profiles\/([^/]+)\/(members|member-invitations|member-events)(?:\/([^/]+))?$/); - const inviteePath = path.match(/^\/v1\/managed-profile-member-invitations\/([^/]+)(\/accept)?$/); - if (teamPath || inviteePath) { + const teamPath = path.match(/^\/v1\/organization\/(members|member-invitations|member-events)(?:\/([^/]+))?$/); + const inviteePath = path.match(/^\/v1\/organization-member-invitations\/([^/]+)(\/accept)?$/); + if (teamPath || inviteePath || path === "/v1/organization") { const body = request.postData() ? (request.postDataJSON() as Record) : null; - membershipRequests.push({ body, method, path, search: url.search }); - if (!request.headers().authorization || request.headers().authorization.startsWith("Bearer vtx_imp_")) { + if (teamPath || inviteePath) membershipRequests.push({ body, method, path, search: url.search }); + if ( + !request.headers().authorization || + request.headers().authorization.startsWith("Bearer vtx_imp_") || + request.headers()["x-managed-profile-id"] || + request.headers()["x-api-key"] || + request.headers()["x-public-key"] + ) { await fulfillJson({ error: { code: "MANAGED_PROFILE_ACCESS_DENIED", message: "A Supabase session is required" } }, 403); return; } + if (path === "/v1/organization") { + await fulfillJson({ organization }); + return; + } if (inviteePath) { const fixture = options.memberInvitation; const failure = method === "POST" ? fixture?.acceptError : fixture?.previewError; - if (failure || !fixture || request.headers()["x-managed-profile-id"]) { + const email = String(verifyOtpRequests.at(-1)?.email ?? E2E_USER_EMAIL).toLowerCase(); + if ( + failure || + !fixture || + fixture.preview.invitation.id !== inviteePath[1] || + fixture.preview.invitation.email.toLowerCase() !== email + ) { await fulfillJson( { error: { code: failure?.code ?? "MANAGED_PROFILE_ACCESS_DENIED", message: "Invitation unavailable" } }, failure?.status ?? 403 @@ -564,6 +616,13 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) await fulfillJson(fixture.preview); } else { const invitation = fixture.preview.invitation; + if (organization && organization.ownerProfileId !== invitation.ownerProfileId) { + await fulfillJson( + { error: { code: "ORGANIZATION_MEMBERSHIP_CONFLICT", message: "Already in another organization" } }, + 409 + ); + return; + } if (invitation.status !== "pending") { await fulfillJson( { error: { code: `INVITATION_${invitation.status.toUpperCase()}`, message: "Invitation is no longer pending" } }, @@ -571,6 +630,8 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) ); return; } + const team = inventories[invitation.ownerProfileId]?.team; + if (!team) throw new Error("Missing invitation organization inventory"); invitation.status = "accepted"; invitation.acceptedAt = new Date().toISOString(); const member: TeamMember = { @@ -583,20 +644,48 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) updatedAt: invitation.acceptedAt }; team.members.push(member); - await fulfillJson({ managedProfileId: invitation.managedProfileId, member }); + options.organization = { + ...fixture.preview.organization, + membership: { isOwner: false, role: invitation.role } + }; + const { email: _email, ...acceptedMember } = member; + await fulfillJson({ member: acceptedMember, ownerProfileId: invitation.ownerProfileId }); } return; } if (teamPath) { - const [, profileId, resource, targetId] = teamPath; - const child = eligibleProfiles.find(profile => profile.profileId === profileId); - if (!child || (method !== "GET" && child.membership.role !== "manager")) { + const [, resource, targetId] = teamPath; + const expectedOwnerIds = url.searchParams.getAll("expectedOwnerProfileId"); + const expectedOwnerProfileId = expectedOwnerIds[0] ?? ""; + if ( + expectedOwnerIds.length !== 1 || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(expectedOwnerProfileId) + ) { + await fulfillJson( + { error: { code: "MANAGED_PROFILE_INVALID_INPUT", message: "Expected organization owner must be a UUID" } }, + 400 + ); + return; + } + const team = inventory?.team; + if (!organization || !team) { await fulfillJson( { error: { code: "MANAGED_PROFILE_ACCESS_DENIED", message: "Managed-profile access is denied" } }, 403 ); return; } + if (organization.ownerProfileId !== expectedOwnerProfileId) { + await fulfillJson({ error: { code: "ORGANIZATION_CONTEXT_CHANGED", message: "Your organization changed" } }, 409); + return; + } + if (method !== "GET" && organization.membership.role !== "manager") { + await fulfillJson( + { error: { code: "MANAGED_PROFILE_ACCESS_DENIED", message: "Organization access is read-only" } }, + 403 + ); + return; + } const limit = Number(url.searchParams.get("limit") ?? 50); const offset = Number(url.searchParams.get("offset") ?? 0); if (method === "GET") { @@ -635,13 +724,13 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) if (method === "PATCH") { action = "role_changed"; member.role = role; - if (targetId === E2E_USER_ID) child.membership.role = role; + if (targetId === E2E_USER_ID) organization.membership.role = role; await fulfillJson({ member }); } else { action = "member_removed"; role = member.role; team.members.splice(index, 1); - if (targetId === E2E_USER_ID) options.managedProfiles?.splice(options.managedProfiles.indexOf(child), 1); + if (targetId === E2E_USER_ID) options.organization = null; await route.fulfill({ status: 204 }); } } else if (method === "POST") { @@ -654,7 +743,7 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) expiresAt: new Date(Date.now() + 7 * 86400000).toISOString(), id: `team-invitation-${team.invitations.length + 1}`, invitedByProfileId: E2E_USER_ID, - managedProfileId: profileId, + ownerProfileId: organization.ownerProfileId, role, status: "pending" }; @@ -691,7 +780,7 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) const managedProfile = eligibleProfiles.find(profile => profile.profileId === profileId); if (!managedProfile) { const bootstrap = request.headers()["x-managed-profile-id"] === profileId; - const relationship = options.managedProfiles?.find(profile => profile.profileId === profileId); + const relationship = inventory?.managedProfiles.find(profile => profile.profileId === profileId); const code = bootstrap && knownManagedProfileIds.has(profileId ?? "") ? "MANAGED_PROFILE_MEMBERSHIP_INVALID" diff --git a/apps/dashboard/e2e/support/session.ts b/apps/dashboard/e2e/support/session.ts index aa7946e16..1f1e59a96 100644 --- a/apps/dashboard/e2e/support/session.ts +++ b/apps/dashboard/e2e/support/session.ts @@ -1,6 +1,6 @@ import type { Page } from "@playwright/test"; -export const E2E_USER_ID = "user-e2e-1"; +export const E2E_USER_ID = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; export const E2E_USER_EMAIL = "e2e@vortexfinance.co"; // displayNameFromEmail("e2e@vortexfinance.co") in src/stores/auth.store.ts. export const E2E_USER_NAME = "E2e"; diff --git a/apps/dashboard/e2e/team-memberships.spec.ts b/apps/dashboard/e2e/team-memberships.spec.ts index 1f119b118..fefd24e4c 100644 --- a/apps/dashboard/e2e/team-memberships.spec.ts +++ b/apps/dashboard/e2e/team-memberships.spec.ts @@ -3,15 +3,25 @@ import type { InvitationPreview, MemberEvent, MemberInvitation, + Organization, TeamMember } from "../src/services/api/managed-profile-memberships.service"; import type { ManagedProfile } from "../src/services/api/managed-profiles.service"; -import { E2E_MANAGED_PROFILE_ID, mockBackend } from "./support/mockBackend"; +import { APP_ORIGIN, E2E_MANAGED_PROFILE_ID, E2E_ORGANIZATION_OWNER_ID, mockBackend } from "./support/mockBackend"; import { E2E_USER_EMAIL, E2E_USER_ID, seedSession } from "./support/session"; const INVITATION_ID = "12345678-1234-1234-1234-123456789abc"; const INVITATION_PATH = `/member-invitations/${INVITATION_ID}`; const NOW = "2026-09-01T12:00:00.000Z"; +const OWNER_B = "22222222-2222-4222-8222-222222222222"; + +function organization(role: "manager" | "read_only" = "manager", isOwner = false): Organization { + return { + membership: { isOwner, role }, + ownerEmail: isOwner ? E2E_USER_EMAIL : "owner@example.test", + ownerProfileId: isOwner ? E2E_USER_ID : E2E_ORGANIZATION_OWNER_ID + }; +} function child(role: "manager" | "read_only" = "manager"): ManagedProfile { return { @@ -44,8 +54,8 @@ function invitation(index = 0): MemberInvitation { expiredAt: null, expiresAt: "2099-09-01T12:00:00.000Z", id: index === 0 ? INVITATION_ID : `invitation-${index}`, - invitedByProfileId: "owner", - managedProfileId: E2E_MANAGED_PROFILE_ID, + invitedByProfileId: E2E_ORGANIZATION_OWNER_ID, + ownerProfileId: E2E_ORGANIZATION_OWNER_ID, role: "read_only", status: "pending" }; @@ -53,8 +63,8 @@ function invitation(index = 0): MemberInvitation { function preview(): InvitationPreview { return { invitation: invitation(), - inviter: { email: "inviter@example.test", profileId: "owner" }, - managedProfile: { externalSubjectId: "Invited company", profileId: E2E_MANAGED_PROFILE_ID } + inviter: { email: "inviter@example.test", profileId: E2E_ORGANIZATION_OWNER_ID }, + organization: { ownerEmail: "owner@example.test", ownerProfileId: E2E_ORGANIZATION_OWNER_ID } }; } function event(index: number): MemberEvent { @@ -110,13 +120,17 @@ for (const mobile of [false, true]) { const teammate = member(1); if (mobile) teammate.email = `${"long-team-member-".repeat(10)}@example.test`; const backend = await mockBackend(page, { - managedProfiles: [child()], + organization: organization(), team: { events: [], invitations: [], members: [member(), teammate] } }); - await selectChild(page); + await seedSession(page); await page.goto("/team"); await expect(page.getByRole("heading", { exact: true, name: "Team" })).toBeVisible(); - if (!mobile) await expect(page.getByRole("link", { exact: true, name: "Team" })).toBeVisible(); + if (mobile) await page.getByRole("button", { name: "Toggle Sidebar" }).click(); + await expect(page.getByRole("link", { exact: true, name: "Team" })).toBeVisible(); + if (mobile) await page.keyboard.press("Escape"); + await expect(page.getByText(/all current and future managed profiles/)).toBeVisible(); + expect(await page.evaluate(() => localStorage.getItem("vortex_dashboard_managed_profile_selection"))).toBeNull(); const owner = page.getByRole("listitem").filter({ hasText: "owner@example.test" }); await expect(owner.getByText("Owner", { exact: true })).toBeVisible(); await expect(owner.getByRole("button")).toHaveCount(0); @@ -151,6 +165,14 @@ for (const mobile of [false, true]) { "PATCH", "DELETE" ]); + for (const request of backend.membershipRequests) { + expect(new URLSearchParams(request.search).getAll("expectedOwnerProfileId")).toEqual([E2E_ORGANIZATION_OWNER_ID]); + } + expect( + backend.apiRequests + .filter(request => request.path.startsWith("/v1/organization/")) + .every(request => request.managedProfileId === undefined) + ).toBe(true); await noOverflow(page); expect(backend.unmatchedRequests).toEqual([]); expect(backend.unexpectedExternalRequests).toEqual([]); @@ -158,16 +180,15 @@ for (const mobile of [false, true]) { test(`read-only Team paginates every list without mutation UI (${mobile ? "mobile" : "desktop"})`, async ({ page }) => { if (mobile) await page.setViewportSize({ height: 844, width: 390 }); - const profile = child("read_only"); const backend = await mockBackend(page, { - managedProfiles: [profile], + organization: organization("read_only"), team: { events: Array.from({ length: 21 }, (_, i) => event(i)), invitations: Array.from({ length: 21 }, (_, i) => invitation(i)), members: Array.from({ length: 21 }, (_, i) => member(i)) } }); - await selectChild(page, profile); + await seedSession(page); await page.goto("/team"); await expect(page.getByText("Team access is read-only for this membership.")).toBeVisible(); await expect(page.getByRole("button", { name: /Invite member|Change role|Remove member|Cancel invitation/ })).toHaveCount( @@ -193,11 +214,69 @@ for (const mobile of [false, true]) { expect(backend.unmatchedRequests).toEqual([]); }); - test(`invitation login return, explicit accept, and open child (${mobile ? "mobile" : "desktop"})`, async ({ page }) => { + for (const access of ["owner", "manager", "read_only"] as const) { + test(`empty organization ${access} reaches main Team before account type selection (${mobile ? "mobile" : "desktop"})`, async ({ + page + }) => { + if (mobile) await page.setViewportSize({ height: 844, width: 390 }); + const org = organization(access === "read_only" ? "read_only" : "manager", access === "owner"); + org.ownerEmail = null; + const backend = await mockBackend(page, { + canProvisionManagedProfiles: access === "owner", + organization: org, + selectionRequired: true + }); + await seedSession(page); + await page.goto("/overview"); + if (mobile) await page.getByRole("button", { name: "Toggle Sidebar" }).first().click(); + await page.getByRole("link", { exact: true, name: "Team" }).click(); + if (mobile) await page.keyboard.press("Escape"); + await expect(page.getByRole("heading", { exact: true, name: "Team" })).toBeVisible(); + await expect(page.getByText(`Organization: ${org.ownerProfileId}`, { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { exact: true, name: "Invite member" })).toHaveCount( + access === "read_only" ? 0 : 1 + ); + await page.goto("/managed-profiles"); + await expect(page.getByText("No managed profiles", { exact: true })).toBeVisible(); + await expect(page.getByText(/You can provision/)).toHaveCount(access === "owner" ? 1 : 0); + expect(await page.evaluate(() => localStorage.getItem("vortex_dashboard_managed_profile_selection"))).toBeNull(); + await noOverflow(page); + expect(backend.unmatchedRequests).toEqual([]); + }); + } + + test(`child navigation has no Team and child actions have no Manage team (${mobile ? "mobile" : "desktop"})`, async ({ + page + }) => { + if (mobile) await page.setViewportSize({ height: 844, width: 390 }); + const backend = await mockBackend(page, { managedProfiles: [child()] }); + await seedSession(page); + await page.goto("/managed-profiles"); + await page.getByRole("button", { name: "Actions for child@example.test" }).filter({ visible: true }).click(); + await expect(page.getByRole("menuitem", { name: "Manage team" })).toHaveCount(0); + await page.getByRole("menuitem", { name: "Act for this profile" }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Act for this profile" }).click(); + await expect(page.getByText("Acting for child@example.test")).toBeVisible(); + if (mobile) await page.getByRole("button", { name: "Toggle Sidebar" }).first().click(); + await expect(page.getByRole("link", { exact: true, name: "Team" })).toHaveCount(0); + if (mobile) await page.keyboard.press("Escape"); + await page.goto("/team"); + await expect(page).toHaveURL(/\/overview$/); + await expect(page.getByText("Acting for child@example.test")).toBeVisible(); + expect(backend.membershipRequests).toEqual([]); + expect(backend.unmatchedRequests).toEqual([]); + await noOverflow(page); + }); + + test(`invitation login return, explicit accept, siblings and future children (${mobile ? "mobile" : "desktop"})`, async ({ + page + }) => { if (mobile) await page.setViewportSize({ height: 844, width: 390 }); + const profiles = [child("read_only"), { ...child(), contactEmail: "sibling@example.test", profileId: "sibling" }]; const backend = await mockBackend(page, { - managedProfiles: [child("read_only")], - memberInvitation: { preview: preview() } + managedProfiles: profiles, + memberInvitation: { preview: preview() }, + organization: null }); await page.goto(INVITATION_PATH); await expect(page.getByRole("link", { name: "Sign in to review" })).toBeVisible(); @@ -209,16 +288,31 @@ for (const mobile of [false, true]) { await page.getByRole("button", { exact: true, name: "Continue" }).click(); await page.locator('input[autocomplete="one-time-code"]').fill("123456"); await expect(page).toHaveURL(INVITATION_PATH); - await expect(page.getByRole("heading", { name: "Join Invited company" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Join owner@example.test's organization" })).toBeVisible(); + await expect(page.getByText(/You can view all current and future managed profiles/)).toBeVisible(); expect(backend.membershipRequests.filter(request => request.method === "POST")).toEqual([]); + const before = await page.evaluate(async () => + ( + await fetch("http://localhost:3000/v1/managed-profiles", { + headers: { Authorization: "Bearer e2e-access-token" } + }) + ).json() + ); + expect(before.managedProfiles).toEqual([]); + expect(before.actor.hasMemberships).toBe(false); await page.getByRole("button", { exact: true, name: "Accept invitation" }).click(); await expect(page.getByRole("heading", { exact: true, name: "Invitation accepted" })).toBeVisible(); expect(backend.membershipRequests.filter(request => request.method === "POST")).toHaveLength(1); - await page.getByRole("button", { name: "Open profile" }).click(); - await expect(page).toHaveURL(/\/overview$/); - await expect(page.getByText("Acting for child@example.test")).toBeVisible(); - await expect(page.getByText("Read only", { exact: true })).toBeVisible(); - const inviteRequests = backend.apiRequests.filter(request => request.path.includes("/managed-profile-member-invitations/")); + await page.getByRole("link", { name: "View managed profiles" }).click(); + await expect(page).toHaveURL(/\/managed-profiles$/); + await expect(page.getByText("child@example.test", { exact: true }).filter({ visible: true })).toBeVisible(); + await expect(page.getByText("sibling@example.test", { exact: true }).filter({ visible: true })).toBeVisible(); + profiles.push({ ...child(), contactEmail: "future@example.test", profileId: "future" }); + await page.evaluate(() => window.dispatchEvent(new Event("visibilitychange"))); + await expect(page.getByText("future@example.test", { exact: true }).filter({ visible: true })).toBeVisible(); + await expect(page.getByText("Read only", { exact: true }).filter({ visible: true })).toHaveCount(3); + expect(await page.evaluate(() => localStorage.getItem("vortex_dashboard_managed_profile_selection"))).toBeNull(); + const inviteRequests = backend.apiRequests.filter(request => request.path.includes("/organization-member-invitations/")); expect(inviteRequests.every(request => request.managedProfileId === undefined)).toBe(true); await noOverflow(page); expect(backend.unmatchedRequests).toEqual([]); @@ -226,13 +320,14 @@ for (const mobile of [false, true]) { }); } -test("Team requires child selection and is unavailable during impersonation", async ({ page }) => { - const backend = await mockBackend(page, { managedProfiles: [child()] }); +test("Team requires organization membership and is unavailable during impersonation", async ({ page }) => { + const backend = await mockBackend(page); await seedSession(page); await page.goto("/team"); - await expect(page).toHaveURL(/\/managed-profiles$/); + await expect(page).toHaveURL(/\/overview$/); await expect(page.getByRole("link", { exact: true, name: "Team" })).toHaveCount(0); await selectChild(page, child(), true); + await page.addInitScript(() => localStorage.removeItem("vortex_dashboard_managed_profile_selection")); await page.goto("/team"); await expect(page.getByText(/Team access is unavailable during impersonation/)).toBeVisible(); await expect(page.getByRole("button", { name: "Invite member" })).toHaveCount(0); @@ -241,40 +336,64 @@ test("Team requires child selection and is unavailable during impersonation", as expect(backend.membershipRequests).toEqual([]); }); -test("live Team downgrade closes confirmation and removal clears selection", async ({ page }) => { - const profile = child(); - const profiles = [profile]; - const backend = await mockBackend(page, { - managedProfiles: profiles, +test("live organization downgrade closes confirmation and removal updates all child access", async ({ page }) => { + const org = organization(); + const options = { + managedProfiles: [child(), { ...child(), profileId: "sibling" }], + organization: org as Organization | null, team: { events: [], invitations: [], members: [member(), member(1)] } - }); - await selectChild(page, profile); + }; + const backend = await mockBackend(page, options); + await seedSession(page); await page.goto("/team"); await page.getByRole("button", { exact: true, name: "Invite member" }).click(); await expect(page.getByRole("dialog")).toBeVisible(); - profile.membership.role = "read_only"; + org.membership.role = "read_only"; await page.evaluate(() => window.dispatchEvent(new Event("visibilitychange"))); await expect(page.getByText("Team access is read-only for this membership.")).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); - profiles.splice(0, 1); + await page.getByRole("link", { exact: true, name: "Managed profiles" }).click(); + await expect(page.getByText("Read only", { exact: true }).filter({ visible: true })).toHaveCount(2); + await page.getByRole("link", { exact: true, name: "Team" }).click(); + options.organization = null; await page.evaluate(() => window.dispatchEvent(new Event("visibilitychange"))); await expect(page).toHaveURL(/\/overview$/); + await expect(page.getByRole("link", { exact: true, name: "Team" })).toHaveCount(0); + await expect(page.getByRole("link", { exact: true, name: "Managed profiles" })).toHaveCount(0); expect(await page.evaluate(() => localStorage.getItem("vortex_dashboard_managed_profile_selection"))).toBeNull(); expect(backend.membershipRequests.every(request => request.method === "GET")).toBe(true); }); -test("generic bootstrap denial does not clear child selection", async ({ page }) => { +test("child Team deep link is forbidden without silently clearing selection, even on bootstrap denial", async ({ page }) => { await mockBackend(page, { managedProfiles: [child()] }); await page.route(`**/v1/managed-profiles/${E2E_MANAGED_PROFILE_ID}`, route => route.fulfill({ json: { error: { code: "MANAGED_PROFILE_ACCESS_DENIED", message: "Denied" } }, status: 403 }) ); await selectChild(page); await page.goto("/team"); - await expect(page.getByRole("heading", { exact: true, name: "Team" })).toBeVisible(); + await expect(page).toHaveURL(/\/overview$/); + await expect(page.getByRole("link", { exact: true, name: "Team" })).toHaveCount(0); await expect(page.getByText("Acting for child@example.test")).toBeVisible(); expect(await page.evaluate(() => localStorage.getItem("vortex_dashboard_managed_profile_selection"))).not.toBeNull(); }); +test("live organization removal closes an open manager dialog and removes child navigation", async ({ page }) => { + const options = { managedProfiles: [child()], organization: organization() as Organization | null }; + const backend = await mockBackend(page, options); + await seedSession(page); + await page.goto("/team"); + await page.getByRole("button", { exact: true, name: "Invite member" }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + options.organization = null; + await page.evaluate(() => window.dispatchEvent(new Event("visibilitychange"))); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page).toHaveURL(/\/overview$/); + await expect(page.getByRole("link", { exact: true, name: "Team" })).toHaveCount(0); + await expect(page.getByRole("link", { exact: true, name: "Managed profiles" })).toHaveCount(0); + expect(backend.membershipRequests.every(request => request.method === "GET")).toBe(true); + expect(backend.unmatchedRequests).toEqual([]); +}); + test("a failed role change never grants optimistic access and can be retried", async ({ page }) => { const target = { ...member(1), role: "read_only" as const }; const backend = await mockBackend(page, { @@ -282,12 +401,12 @@ test("a failed role change never grants optimistic access and can be retried", a team: { events: [], invitations: [], members: [member(), target] } }); let fail = true; - await page.route(`**/v1/managed-profiles/${E2E_MANAGED_PROFILE_ID}/members/${target.memberProfileId}`, async route => { + await page.route(`**/v1/organization/members/${target.memberProfileId}?*`, async route => { if (fail) await route.fulfill({ json: { error: { code: "MEMBER_NOT_FOUND", message: "Member was not found" } }, status: 409 }); else await route.fallback(); }); - await selectChild(page); + await seedSession(page); await page.goto("/team"); const row = page.getByRole("listitem", { includeHidden: true }).filter({ hasText: target.email }); await row.getByRole("button", { name: "Change role" }).click(); @@ -315,6 +434,158 @@ for (const status of ["expired", "cancelled", "accepted"] as const) { }); } +for (const nowInB of [false, true]) { + test(`accepted A invitation is historical after ${nowInB ? "joining B" : "removal"}`, async ({ page }) => { + const accepted = preview(); + accepted.invitation.status = "accepted"; + const current = { ...organization(), ownerEmail: "owner-b@example.test", ownerProfileId: OWNER_B }; + const options = { memberInvitation: { preview: accepted }, organization: nowInB ? current : null }; + const backend = await mockBackend(page, options); + await seedSession(page); + await page.goto(INVITATION_PATH); + await expect(page.getByRole("heading", { name: "Invitation already accepted" })).toBeVisible(); + await expect(page.getByText(/Reopening this link does not grant or restore membership/)).toBeVisible(); + await expect(page.getByText(/Your (current )?organization access covers/)).toHaveCount(0); + await expect(page.getByRole("link", { exact: true, name: "View team" })).toHaveCount(0); + await expect(page.getByRole("link", { exact: true, name: "View managed profiles" })).toHaveCount(0); + if (nowInB) { + await expect( + page.getByText(/Your current organization is owner-b@example.test, not the organization from this invitation/) + ).toBeVisible(); + await page.getByRole("link", { name: "View your current team" }).click(); + await expect(page.getByText("Organization: owner-b@example.test")).toBeVisible(); + } else { + await expect(page.getByText("You do not currently have access to this organization.")).toBeVisible(); + await expect(page.getByRole("link", { name: /team/i })).toHaveCount(0); + } + expect(backend.membershipRequests.filter(request => request.method === "POST")).toEqual([]); + expect(options.organization).toEqual(nowInB ? current : null); + expect(backend.unmatchedRequests).toEqual([]); + }); +} + +test("moving A to B isolates every inventory and rejects a held A invitation without retargeting", async ({ + page, + browser +}) => { + const offerA = preview(); + offerA.invitation.role = "manager"; + offerA.organization.ownerEmail = "owner-a@example.test"; + const offerB = preview(); + offerB.invitation = { ...invitation(), id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", ownerProfileId: OWNER_B, role: "manager" }; + offerB.organization = { ownerEmail: "owner-b@example.test", ownerProfileId: OWNER_B }; + const inventoryA = { + managedProfiles: [{ ...child(), contactEmail: "child-a@example.test" }], + team: { + events: [{ ...event(0), actorProfileId: "actor-a" }], + invitations: [offerA.invitation, { ...invitation(1), email: "pending-a@example.test" }], + members: [{ ...member(), email: "member-a@example.test" }] + } + }; + const inventoryB = { + managedProfiles: [{ ...child(), contactEmail: "child-b@example.test", profileId: "child-b" }], + team: { + events: [{ ...event(1), actorProfileId: "actor-b" }], + invitations: [offerB.invitation, { ...invitation(2), email: "pending-b@example.test", ownerProfileId: OWNER_B }], + members: [{ ...member(), email: "member-b@example.test" }] + } + }; + const options = { + memberInvitation: { preview: offerA }, + organization: null as Organization | null, + organizationInventories: { [E2E_ORGANIZATION_OWNER_ID]: inventoryA, [OWNER_B]: inventoryB } + }; + const backend = await mockBackend(page, options); + await seedSession(page); + await page.goto(INVITATION_PATH); + await page.getByRole("button", { exact: true, name: "Accept invitation" }).click(); + await page.getByRole("link", { exact: true, name: "View team" }).click(); + await expect(page.getByText("member-a@example.test", { exact: true })).toBeVisible(); + await expect(page.getByText("pending-a@example.test", { exact: true })).toBeVisible(); + await expect(page.getByText(/Actor: actor-a/)).toBeVisible(); + await page.getByRole("link", { exact: true, name: "Managed profiles" }).click(); + await expect(page.getByRole("cell", { exact: true, name: "child-a@example.test" })).toBeVisible(); + await page.getByRole("link", { exact: true, name: "Team" }).click(); + await page.getByRole("button", { exact: true, name: "Invite member" }).click(); + await page.getByRole("dialog").getByLabel("Email").fill("stale-a-invite@example.test"); + // Simulate a background client that has not received a focus refresh while the + // same human changes membership from a different device. + await page.evaluate(() => { + Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" }); + window.dispatchEvent(new Event("visibilitychange")); + }); + + const otherContext = await browser.newContext({ baseURL: APP_ORIGIN }); + try { + const other = await otherContext.newPage(); + const otherBackend = await mockBackend(other, options); + await seedSession(other); + await other.goto("/managed-profiles"); + await other.getByRole("button", { name: "Actions for child-a@example.test" }).click(); + await other.getByRole("menuitem", { name: "Act for this profile" }).click(); + await other.getByRole("dialog").getByRole("button", { name: "Act for this profile" }).click(); + await expect(other.getByText("Acting for child-a@example.test")).toBeVisible(); + options.organization = null; + inventoryA.team.members = inventoryA.team.members.filter(member => member.memberProfileId !== E2E_USER_ID); + await other.evaluate(() => window.dispatchEvent(new Event("visibilitychange"))); + await expect(other.getByText("Acting for child-a@example.test")).toHaveCount(0); + expect(await other.evaluate(() => localStorage.getItem("vortex_dashboard_managed_profile_selection"))).toBeNull(); + options.memberInvitation = { preview: offerB }; + await other.goto(`/member-invitations/${offerB.invitation.id}`); + await other.getByRole("button", { exact: true, name: "Accept invitation" }).click(); + await other.getByRole("link", { exact: true, name: "View team" }).click(); + await expect(other.getByText("Organization: owner-b@example.test")).toBeVisible(); + await expect(other.getByText("member-b@example.test", { exact: true })).toBeVisible(); + await expect(other.getByText("pending-b@example.test", { exact: true })).toBeVisible(); + await expect(other.getByText(/Actor: actor-b/)).toBeVisible(); + await expect(other.getByText(/member-a@example.test|pending-a@example.test|Actor: actor-a/)).toHaveCount(0); + expect(await other.evaluate(() => localStorage.getItem("vortex_dashboard_managed_profile_selection"))).toBeNull(); + + await expect(page.getByRole("dialog")).toBeVisible(); + const rejected = page.waitForResponse( + response => + new URL(response.url()).pathname === "/v1/organization/member-invitations" && response.request().method() === "POST" + ); + await page.getByRole("dialog").getByRole("button", { name: "Send invitation" }).click(); + const response = await rejected; + expect(response.status()).toBe(409); + expect((await response.json()).error.code).toBe("ORGANIZATION_CONTEXT_CHANGED"); + expect(new URL(response.url()).searchParams.get("expectedOwnerProfileId")).toBe(E2E_ORGANIZATION_OWNER_ID); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByText(/Your organization changed. The action was not applied/)).toBeVisible(); + await expect(page.getByText("Organization: owner-b@example.test")).toBeVisible(); + await expect(page.getByText("member-b@example.test", { exact: true })).toBeVisible(); + await expect(page.getByText("pending-b@example.test", { exact: true })).toBeVisible(); + await expect(page.getByText(/Actor: actor-b/)).toBeVisible(); + await expect(page.getByText(/member-a@example.test|pending-a@example.test|Actor: actor-a/)).toHaveCount(0); + await page.getByRole("link", { exact: true, name: "Managed profiles" }).click(); + await expect(page.getByRole("cell", { exact: true, name: "child-b@example.test" })).toBeVisible(); + await expect(page.getByRole("cell", { exact: true, name: "child-a@example.test" })).toHaveCount(0); + expect( + backend.membershipRequests.filter( + request => request.method === "POST" && request.path === "/v1/organization/member-invitations" + ) + ).toHaveLength(1); + expect( + [...inventoryA.team.invitations, ...inventoryB.team.invitations].some( + invitation => invitation.email === "stale-a-invite@example.test" + ) + ).toBe(false); + expect(inventoryB.team.events).toHaveLength(1); + for (const result of [backend, otherBackend]) { + expect( + result.apiRequests + .filter(request => request.path.startsWith("/v1/organization/")) + .every(request => request.managedProfileId === undefined) + ).toBe(true); + expect(result.unmatchedRequests).toEqual([]); + expect(result.unexpectedExternalRequests).toEqual([]); + } + } finally { + await otherContext.close(); + } +}); + test("wrong account has no invitation details, can change login, and a preview failure retries", async ({ page }) => { const fixture = { preview: preview(), @@ -343,13 +614,17 @@ test("accept-time denial hides cached preview and never sends a selected-child h }); await selectChild(page); await page.goto(INVITATION_PATH); - await expect(page.getByRole("heading", { name: "Join Invited company" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Stop acting to review invitation" })).toBeVisible(); + expect(backend.membershipRequests).toEqual([]); + expect(await page.evaluate(() => localStorage.getItem("vortex_dashboard_managed_profile_selection"))).not.toBeNull(); + await page.getByRole("button", { name: "Stop acting to review invitation" }).click(); + await expect(page.getByRole("heading", { name: "Join owner@example.test's organization" })).toBeVisible(); await page.getByRole("button", { exact: true, name: "Accept invitation" }).click(); await expect(page.getByRole("heading", { name: "Invitation unavailable for this account" })).toBeVisible(); await expect(page.getByText(/Invited company|inviter@example.test|Role:|Expires/)).toHaveCount(0); expect( backend.apiRequests - .filter(request => request.path.includes("/managed-profile-member-invitations/")) + .filter(request => request.path.includes("/organization-member-invitations/")) .every(request => request.managedProfileId === undefined) ).toBe(true); }); @@ -365,6 +640,81 @@ test("expiry racing acceptance becomes a terminal state", async ({ page }) => { await expect(page.getByRole("button", { exact: true, name: "Accept invitation" })).toHaveCount(0); }); +for (const access of ["owner", "manager", "read_only"] as const) { + test(`second organization acceptance explains conflict for ${access} without changing access`, async ({ page }) => { + const current = organization(access === "read_only" ? "read_only" : "manager", access === "owner"); + current.ownerProfileId = OWNER_B; + const options = { memberInvitation: { preview: preview() }, organization: current }; + const backend = await mockBackend(page, options); + await seedSession(page); + await page.goto(INVITATION_PATH); + await page.getByRole("button", { exact: true, name: "Accept invitation" }).click(); + await expect(page.getByRole("heading", { name: "You already belong to another organization" })).toBeVisible(); + await expect(page.getByRole("alert")).toContainText("Your current access has not changed"); + expect(options.organization).toBe(current); + expect(options.memberInvitation.preview.invitation.status).toBe("pending"); + expect(await page.evaluate(() => localStorage.getItem("vortex_dashboard_managed_profile_selection"))).toBeNull(); + expect(backend.membershipRequests.filter(request => request.method === "POST")).toHaveLength(1); + expect(backend.unmatchedRequests).toEqual([]); + }); +} + +test("a different verified OTP email cannot preview or accept the organization invitation", async ({ page }) => { + const backend = await mockBackend(page, { memberInvitation: { preview: preview() } }); + await page.goto(INVITATION_PATH); + await page.getByRole("link", { name: "Sign in to review" }).click(); + await page.getByLabel("Email", { exact: true }).fill("wrong@example.test"); + await page.getByRole("button", { exact: true, name: "Continue" }).click(); + await page.locator('input[autocomplete="one-time-code"]').fill("123456"); + await expect(page.getByRole("heading", { name: "Invitation unavailable for this account" })).toBeVisible(); + await expect(page.getByText(/owner@example.test|inviter@example.test|Role:|Expires/)).toHaveCount(0); + expect(backend.membershipRequests.filter(request => request.method === "POST")).toEqual([]); + expect(backend.unmatchedRequests).toEqual([]); +}); + +test("mutation denial revalidates organization authority and closes a stale manager dialog", async ({ page }) => { + const org = organization(); + const backend = await mockBackend(page, { + managedProfiles: [child()], + organization: org, + team: { events: [], invitations: [], members: [member(), member(1)] } + }); + await seedSession(page); + await page.goto("/team"); + await page.getByRole("button", { exact: true, name: "Invite member" }).click(); + await page.getByRole("dialog").getByLabel("Email").fill("invitee@example.test"); + org.membership.role = "read_only"; + await page.getByRole("dialog").getByRole("button", { name: "Send invitation" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByText("Team access is read-only for this membership.")).toBeVisible(); + await expect(page.getByRole("button", { name: /Invite member|Change role|Remove member/ })).toHaveCount(0); + await page.getByRole("link", { exact: true, name: "Managed profiles" }).click(); + await expect(page.getByRole("cell", { exact: true, name: "Read only" })).toBeVisible(); + expect(backend.unmatchedRequests).toEqual([]); +}); + +test("organization refresh errors hide cached manager controls and allow a safe retry", async ({ page }) => { + const backend = await mockBackend(page, { organization: organization() }); + await seedSession(page); + await page.goto("/team"); + await page.getByRole("button", { exact: true, name: "Invite member" }).click(); + let denied = true; + await page.route("**/v1/organization", async route => { + if (denied) + await route.fulfill({ json: { error: { code: "ORGANIZATION_ACCESS_DENIED", message: "Denied" } }, status: 403 }); + else await route.fallback(); + }); + await page.evaluate(() => window.dispatchEvent(new Event("visibilitychange"))); + await expect(page.getByText("Could not confirm your organization access.")).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Invite member" })).toHaveCount(0); + denied = false; + await page.getByRole("button", { name: "Retry organization access" }).click(); + await expect(page.getByRole("button", { exact: true, name: "Invite member" })).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); + expect(backend.membershipRequests.every(request => request.method === "GET")).toBe(true); +}); + test("login ignores an unsafe return destination", async ({ page }) => { await mockBackend(page); await seedSession(page); @@ -379,7 +729,7 @@ for (const action of ["Change role", "Remove member"]) { managedProfiles: [child()], team: { events: [], invitations: [], members: [member(), self] } }); - await selectChild(page); + await seedSession(page); await page.goto("/team"); await page.getByRole("button", { exact: true, name: action }).click(); await page.getByRole("dialog").getByRole("button", { exact: true, name: action }).click(); @@ -402,17 +752,17 @@ test("role grants wait for the server and Team read failures can be retried", as }); const response = Promise.withResolvers(); let requested = false; - await page.route(`**/v1/managed-profiles/${E2E_MANAGED_PROFILE_ID}/members/${target.memberProfileId}`, async route => { + await page.route(`**/v1/organization/members/${target.memberProfileId}?*`, async route => { requested = true; await response.promise; await route.fallback(); }); let failRead = true; - await page.route(`**/v1/managed-profiles/${E2E_MANAGED_PROFILE_ID}/members?*`, async route => { + await page.route("**/v1/organization/members?*", async route => { if (failRead) await route.fulfill({ json: { error: { code: "INTERNAL_SERVER_ERROR", message: "Failed" } }, status: 500 }); else await route.fallback(); }); - await selectChild(page); + await seedSession(page); await page.goto("/team"); await expect(page.getByText("Could not load members.")).toBeVisible({ timeout: 15000 }); failRead = false; @@ -439,8 +789,9 @@ test("an unconfirmed acceptance retries preview instead of accepting twice", asy await page.getByRole("button", { name: "Try again" }).click(); await expect(page.getByRole("heading", { name: "Invitation already accepted" })).toBeVisible(); expect(backend.membershipRequests.filter(request => request.method === "POST")).toHaveLength(1); - await page.getByRole("button", { name: "Open profile" }).click(); - await expect(page.getByRole("alert")).toContainText("Your membership may have changed"); + await expect(page.getByText("You do not currently have access to this organization.")).toBeVisible(); + await page.getByRole("link", { name: "Return to your dashboard" }).click(); + await expect(page).toHaveURL(/\/overview$/); expect(await page.evaluate(() => localStorage.getItem("vortex_dashboard_managed_profile_selection"))).toBeNull(); }); @@ -450,8 +801,8 @@ for (const reason of ["owner disabled", "child deleted"] as const) { const options = { managedProfileOwnerActive: true, managedProfiles: [profile] }; const backend = await mockBackend(page, options); await selectChild(page, profile); - await page.goto("/team"); - await expect(page.getByText("Team access is read-only for this membership.")).toBeVisible(); + await page.goto("/overview"); + await expect(page.getByText("Acting for child@example.test")).toBeVisible(); if (reason === "owner disabled") options.managedProfileOwnerActive = false; else profile.status = "deleted"; const bootstrapResponse = page.waitForResponse( @@ -461,11 +812,16 @@ for (const reason of ["owner disabled", "child deleted"] as const) { const response = await bootstrapResponse; expect(response.request().headers()["x-managed-profile-id"]).toBe(profile.profileId); expect((await response.json()).error.code).toBe("MANAGED_PROFILE_MEMBERSHIP_INVALID"); - await expect(page).toHaveURL(/\/overview$/); + await expect(page).toHaveURL(reason === "owner disabled" ? /\/overview$/ : /\/managed-profiles$/); expect(await page.evaluate(() => localStorage.getItem("vortex_dashboard_managed_profile_selection"))).toBeNull(); await expect(page.getByText("Acting for child@example.test")).toHaveCount(0); - await expect(page.getByRole("link", { exact: true, name: "Managed profiles" })).toHaveCount(0); - await expect(page.getByRole("link", { exact: true, name: "Team" })).toHaveCount(0); + if (reason === "owner disabled") { + await expect(page.getByRole("link", { exact: true, name: "Managed profiles" })).toHaveCount(0); + await expect(page.getByRole("link", { exact: true, name: "Team" })).toHaveCount(0); + } else { + await expect(page.getByRole("link", { exact: true, name: "Team" })).toBeVisible(); + await expect(page.getByText("No managed profiles", { exact: true })).toBeVisible(); + } expect(backend.membershipRequests.every(request => request.method === "GET")).toBe(true); expect(backend.unmatchedRequests).toEqual([]); }); diff --git a/apps/dashboard/src/components/layout/AppSidebar.tsx b/apps/dashboard/src/components/layout/AppSidebar.tsx index 92a3505ee..30b83c78e 100644 --- a/apps/dashboard/src/components/layout/AppSidebar.tsx +++ b/apps/dashboard/src/components/layout/AppSidebar.tsx @@ -26,6 +26,7 @@ import { } from "@/components/ui/sidebar"; import { useOnboardingStatusQuery } from "@/hooks/useApprovedCorridors"; import { useManagedProfiles } from "@/hooks/useManagedProfiles"; +import { useOrganization } from "@/hooks/useOrganization"; import { useImpersonationSession } from "@/stores/impersonation.store"; import { useManagedProfileSelection } from "@/stores/managed-profile.store"; import { VortexLogo } from "./VortexLogo"; @@ -43,10 +44,8 @@ const NAV_ITEMS = [ const ADMIN_NAV_ITEM = { icon: UserCog, label: "Admin", to: "/admin" } as const; const MANAGED_PROFILES_NAV_ITEM = { icon: UsersRound, label: "Managed profiles", to: "/managed-profiles" } as const; -const CHILD_NAV_ITEMS = [ - ...NAV_ITEMS.filter(item => item.to !== "/transfer" && item.to !== "/settings"), - { icon: UsersRound, label: "Team", to: "/team" } as const -]; +const TEAM_NAV_ITEM = { icon: UsersRound, label: "Team", to: "/team" } as const; +const CHILD_NAV_ITEMS = NAV_ITEMS.filter(item => item.to !== "/transfer" && item.to !== "/settings"); export function AppSidebar() { const pathname = useRouterState({ select: state => state.location.pathname }); @@ -54,6 +53,7 @@ export function AppSidebar() { const managedProfile = useManagedProfileSelection(); const managedProfiles = useManagedProfiles({ limit: 1, offset: 0 }, !managedProfile); const isImpersonating = useImpersonationSession() !== null; + const organization = useOrganization(); const isAdmin = onboardingStatus?.roles.includes("vortex_admin") ?? false; // An operator acting as a customer must see exactly the customer's navigation. const isActingForChild = !!managedProfile; @@ -63,6 +63,7 @@ export function AppSidebar() { : [ ...NAV_ITEMS, ...(hasManagedProfileAccess ? [MANAGED_PROFILES_NAV_ITEM] : []), + ...(!isImpersonating && !organization.isError && organization.data?.organization ? [TEAM_NAV_ITEM] : []), ...(isAdmin && !isImpersonating ? [ADMIN_NAV_ITEM] : []) ]; @@ -100,6 +101,14 @@ export function AppSidebar() {
)} + {organization.isError && !isActingForChild && !isImpersonating && ( + + organization.refetch()} type="button"> + + Retry team access + + + )} diff --git a/apps/dashboard/src/components/managed-profiles/ManagedProfilesList.tsx b/apps/dashboard/src/components/managed-profiles/ManagedProfilesList.tsx index 022d2a61e..d0172c17d 100644 --- a/apps/dashboard/src/components/managed-profiles/ManagedProfilesList.tsx +++ b/apps/dashboard/src/components/managed-profiles/ManagedProfilesList.tsx @@ -57,7 +57,9 @@ export function ManagedProfilesList({ profiles }: { profiles: ManagedProfile[] }

No managed profiles

-

Active profiles will appear here when they are assigned to you.

+

+ Your organization's active profiles will appear here when they are created. +

); } diff --git a/apps/dashboard/src/components/managed-profiles/Team.tsx b/apps/dashboard/src/components/managed-profiles/Team.tsx index bb6698642..4f847579d 100644 --- a/apps/dashboard/src/components/managed-profiles/Team.tsx +++ b/apps/dashboard/src/components/managed-profiles/Team.tsx @@ -14,14 +14,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Skeleton } from "@/components/ui/skeleton"; import { CHILD_CREDENTIAL_WARNING } from "@/domain/api-credentials"; import { MANAGED_PROFILES_QUERY_KEY } from "@/hooks/useManagedProfiles"; +import { ORGANIZATION_QUERY_KEY } from "@/hooks/useOrganization"; import { isApiError } from "@/services/api/api-client"; import { type MemberInvitation, - ManagedProfileMembershipsService as service, + type Organization, + OrganizationService as service, shouldRetryMembershipQuery, type TeamMember } from "@/services/api/managed-profile-memberships.service"; -import type { ManagedProfileSelection } from "@/services/auth"; const inviteSchema = z.object({ email: z.string().trim().toLowerCase().max(254).email("Enter a valid email"), @@ -43,43 +44,57 @@ const EVENT_LABEL = { role_changed: "Role changed" }; -export function Team({ selection }: { selection: ManagedProfileSelection }) { +export function Team({ + actorId, + organization, + authorityPending +}: { + actorId: string; + organization: Organization; + authorityPending: boolean; +}) { const client = useQueryClient(); const [memberOffset, setMemberOffset] = useState(0); const [invitationOffset, setInvitationOffset] = useState(0); const [cursors, setCursors] = useState>([undefined]); const [action, setAction] = useState(null); - const profileId = selection.targetProfileId; - const queryKey = ["managed-profile-team", selection.managerProfileId, profileId]; + const queryKey = ["organization-team", actorId, organization.ownerProfileId]; const members = useQuery({ - queryFn: ({ signal }) => service.members(profileId, memberOffset, signal), + queryFn: ({ signal }) => service.members(organization.ownerProfileId, memberOffset, signal), queryKey: [...queryKey, "members", memberOffset], refetchOnWindowFocus: "always", retry: shouldRetryMembershipQuery }); const invitations = useQuery({ - queryFn: ({ signal }) => service.invitations(profileId, invitationOffset, signal), + queryFn: ({ signal }) => service.invitations(organization.ownerProfileId, invitationOffset, signal), queryKey: [...queryKey, "invitations", invitationOffset], refetchOnWindowFocus: "always", retry: shouldRetryMembershipQuery }); const cursor = cursors.at(-1); const events = useQuery({ - queryFn: ({ signal }) => service.events(profileId, cursor, signal), + queryFn: ({ signal }) => service.events(organization.ownerProfileId, cursor, signal), queryKey: [...queryKey, "events", cursor], refetchOnWindowFocus: "always", retry: shouldRetryMembershipQuery }); - const denied = [members.error, invitations.error, events.error].some(error => isApiError(error) && error.status === 403); - const canManage = selection.membershipRole === "manager" && !denied; + const failed = members.isError || invitations.isError || events.isError; + const canManage = organization.membership.role === "manager" && !failed; + + useEffect(() => { + if (members.error || invitations.error || events.error) { + void client.invalidateQueries({ queryKey: [ORGANIZATION_QUERY_KEY, actorId] }); + } + }, [client, actorId, members.error, invitations.error, events.error]); useEffect(() => { - if (denied) void client.invalidateQueries({ queryKey: ["managed-profile-bootstrap"] }); - }, [client, denied]); + if (!canManage) setAction(null); + }, [canManage]); async function refreshTeam() { await Promise.all([ client.invalidateQueries({ queryKey }), + client.invalidateQueries({ queryKey: [ORGANIZATION_QUERY_KEY, actorId] }), client.invalidateQueries({ queryKey: [MANAGED_PROFILES_QUERY_KEY] }), client.invalidateQueries({ queryKey: ["managed-profile-bootstrap"] }) ]); @@ -88,13 +103,22 @@ export function Team({ selection }: { selection: ManagedProfileSelection }) { return (
-
+

Team

-

Members, invitations, and access history for this profile.

+

+ Organization: {organization.ownerEmail ?? organization.ownerProfileId} +

+

+ Team access covers all current and future managed profiles. Personal account resources stay private. +

- {canManage && } + {canManage && ( + + )}
- {selection.membershipRole === "read_only" && ( + {organization.membership.role === "read_only" && (

Team access is read-only for this membership.

)} @@ -121,7 +145,7 @@ export function Team({ selection }: { selection: ManagedProfileSelection }) {
{ROLE_LABEL[member.role]} {member.isOwner && Owner} - {member.memberProfileId === selection.managerProfileId && You} + {member.memberProfileId === actorId && You}
{member.isOwner && (

Owner access cannot be changed or removed.

@@ -129,10 +153,20 @@ export function Team({ selection }: { selection: ManagedProfileSelection }) {
{canManage && !member.isOwner && (
- -
@@ -193,7 +227,12 @@ export function Team({ selection }: { selection: ManagedProfileSelection }) {

Expires {new Date(invitation.expiresAt).toLocaleString()}

{canManage && invitation.status === "pending" && ( - )} @@ -284,7 +323,13 @@ export function Team({ selection }: { selection: ManagedProfileSelection }) {
{canManage && action && ( - setAction(null)} profileId={profileId} refreshTeam={refreshTeam} /> + setAction(null)} + refreshTeam={refreshTeam} + /> )} ); @@ -292,13 +337,15 @@ export function Team({ selection }: { selection: ManagedProfileSelection }) { function TeamActionDialog({ action, + authorityPending, + expectedOwnerProfileId, onClose, - profileId, refreshTeam }: { action: Action; + authorityPending: boolean; + expectedOwnerProfileId: string; onClose: () => void; - profileId: string; refreshTeam: () => Promise; }) { const form = useForm({ @@ -311,21 +358,28 @@ function TeamActionDialog({ ]; const mutation = useMutation({ mutationFn: async (values: InviteValues) => { + if (authorityPending) throw new Error("Organization access is being refreshed. Try again."); switch (action.type) { case "invite": - await service.invite(profileId, values); + await service.invite(expectedOwnerProfileId, values); break; case "cancel": - await service.cancel(profileId, action.invitation.id); + await service.cancel(expectedOwnerProfileId, action.invitation.id); break; case "change": - if (!action.member.isOwner) await service.changeRole(profileId, action.member.memberProfileId, nextRole); + if (!action.member.isOwner) await service.changeRole(expectedOwnerProfileId, action.member.memberProfileId, nextRole); break; case "remove": - if (!action.member.isOwner) await service.remove(profileId, action.member.memberProfileId); + if (!action.member.isOwner) await service.remove(expectedOwnerProfileId, action.member.memberProfileId); break; } }, + onError: error => { + if (isApiError(error) && error.data.code === "ORGANIZATION_CONTEXT_CHANGED") { + toast.error("Your organization changed. The action was not applied. Review your current team before trying again."); + onClose(); + } + }, onSettled: refreshTeam, onSuccess: () => { toast.success("Team updated"); @@ -339,12 +393,12 @@ function TeamActionDialog({ {title} {action.type === "invite" - ? "Send an email invitation for this profile. Access is granted only after the recipient explicitly accepts." + ? "Invite a member to all current and future managed profiles in this organization. Access is granted only after the recipient explicitly accepts with their verified email. Each person can belong to only one organization." : action.type === "cancel" ? `Cancel the pending invitation for ${action.invitation.email}? It will no longer be usable.` : action.type === "change" - ? `Change ${action.member.email ?? action.member.memberProfileId} to ${ROLE_LABEL[nextRole]}?` - : `Remove access for ${action.member.email ?? action.member.memberProfileId}?`} + ? `Change ${action.member.email ?? action.member.memberProfileId} to ${ROLE_LABEL[nextRole]} across all current and future managed profiles?` + : `Remove ${action.member.email ?? action.member.memberProfileId} from this organization and all its managed profiles?`} {(action.type === "remove" || (action.type === "change" && nextRole === "read_only")) && ( @@ -353,7 +407,7 @@ function TeamActionDialog({ {(action.type === "invite" || (action.type === "change" && nextRole === "manager")) && (

Managers can administer non-owner team access and child credentials. Read-only members can view data but cannot make - changes. + changes. Only the owner can provision or delete profiles. Personal account resources are not shared.

)} {mutation.isError && ( @@ -406,7 +460,7 @@ function TeamActionDialog({ Back + + ); + if (query.isPending) return ; + const organization = query.data.organization; + if (!organization || !actorId) return ; + // A live authority change discards any open mutation dialog. return ( ); } diff --git a/apps/dashboard/src/routes/member-invitations.$invitationId.tsx b/apps/dashboard/src/routes/member-invitations.$invitationId.tsx index 2464dfe37..3314f3800 100644 --- a/apps/dashboard/src/routes/member-invitations.$invitationId.tsx +++ b/apps/dashboard/src/routes/member-invitations.$invitationId.tsx @@ -1,22 +1,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; -import { useState } from "react"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { toast } from "sonner"; import { VortexLogo } from "@/components/layout/VortexLogo"; -import { toManagedProfileSelection } from "@/components/managed-profiles/managed-profile-ui"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { MANAGED_PROFILES_QUERY_KEY } from "@/hooks/useManagedProfiles"; +import { ORGANIZATION_QUERY_KEY, useOrganization } from "@/hooks/useOrganization"; import { isApiError } from "@/services/api/api-client"; -import { - ManagedProfileMembershipsService as service, - shouldRetryMembershipQuery -} from "@/services/api/managed-profile-memberships.service"; -import { ManagedProfilesService } from "@/services/api/managed-profiles.service"; -import { AuthService } from "@/services/auth"; +import { OrganizationService as service, shouldRetryMembershipQuery } from "@/services/api/managed-profile-memberships.service"; import { useAuthStore } from "@/stores/auth.store"; import { useImpersonationSession } from "@/stores/impersonation.store"; -import { selectManagedProfile } from "@/stores/managed-profile.store"; +import { clearManagedProfile, useManagedProfileSelection } from "@/stores/managed-profile.store"; export const Route = createFileRoute("/member-invitations/$invitationId")({ component: InvitationPage }); @@ -24,6 +19,7 @@ function InvitationPage() { const { invitationId } = Route.useParams(); const user = useAuthStore(state => state.user); const impersonation = useImpersonationSession(); + const selection = useManagedProfileSelection(); return (
@@ -33,7 +29,7 @@ function InvitationPage() { Team invitation - Access a managed profile with your own Vortex account. + Join an organization with your own Vortex account. {impersonation ? ( @@ -43,6 +39,25 @@ function InvitationPage() { Return to dashboard + ) : selection ? ( + <> +

+ You are acting for {selection.targetEmail || selection.externalSubjectId}. Stop acting to review this + invitation with your personal account. +

+ + ) : !user ? ( <>

@@ -70,10 +85,8 @@ function InvitationPage() { function InvitationDetails({ invitationId, userId }: { invitationId: string; userId: string }) { const client = useQueryClient(); - const navigate = useNavigate(); const logout = useAuthStore(state => state.logout); - const [opening, setOpening] = useState(false); - const [openError, setOpenError] = useState(null); + const organization = useOrganization(); const preview = useQuery({ gcTime: 0, queryFn: ({ signal }) => service.preview(invitationId, signal), @@ -83,16 +96,30 @@ function InvitationDetails({ invitationId, userId }: { invitationId: string; use }); const accept = useMutation({ mutationFn: () => service.accept(invitationId), - onSuccess: async () => { + onSettled: async () => { await Promise.all([ client.invalidateQueries({ queryKey: [MANAGED_PROFILES_QUERY_KEY] }), client.invalidateQueries({ queryKey: ["managed-profile-bootstrap"] }), - client.invalidateQueries({ queryKey: ["managed-profile-team"] }) + client.invalidateQueries({ queryKey: ["organization-team"] }), + client.invalidateQueries({ queryKey: [ORGANIZATION_QUERY_KEY] }) ]); } }); const error = accept.error ?? preview.error; const code = isApiError(error) ? error.data.code : undefined; + if (code === "ORGANIZATION_MEMBERSHIP_CONFLICT") + return ( + <> +

You already belong to another organization

+

+ Each person can belong to only one organization, including owners and read-only members. Your current access has not + changed. Resolve your existing membership with its owner before accepting this invitation. +

+ + + ); // A denial takes precedence over any cached preview, including a denial at accept time. if (isApiError(error) && (error.status === 403 || error.status === 401)) { return ( @@ -126,55 +153,55 @@ function InvitationDetails({ invitationId, userId }: { invitationId: string; use ); } - async function openProfile() { - const profileId = accept.data?.managedProfileId ?? preview.data?.managedProfile.profileId; - if (!profileId || opening) return; - setOpening(true); - setOpenError(null); - try { - // Preview roles are not selection authority; membership may have changed since acceptance. - const result = await ManagedProfilesService.get(profileId); - if (AuthService.getEffectiveBearerProfileId() !== userId || AuthService.getAcceptedImpersonationSessionSnapshot()) return; - if ( - result.actor.profileId !== userId || - result.managedProfile.profileId !== profileId || - result.managedProfile.status !== "active" - ) - throw new Error("Invalid membership"); - if (!selectManagedProfile(toManagedProfileSelection(result.managedProfile))) { - setOpenError("Finish or cancel the current transfer signing step before changing profiles."); - return; - } - await navigate({ to: "/overview" }); - } catch { - setOpenError("Could not open this profile. Your membership may have changed. Try again or contact a manager."); - } finally { - setOpening(false); - } - } - if (status === "success" || status === "accepted" || code === "MEMBERSHIP_ALREADY_EXISTS") { + const ownerProfileId = accept.data?.ownerProfileId ?? preview.data?.organization.ownerProfileId; + const currentOrganization = !organization.isError && !organization.isFetching ? organization.data?.organization : null; + const hasCurrentAccess = !!ownerProfileId && currentOrganization?.ownerProfileId === ownerProfileId; return ( <> -

- {status === "success" - ? "Invitation accepted" - : code === "MEMBERSHIP_ALREADY_EXISTS" - ? "You already have access" - : "Invitation already accepted"} -

-

Open the profile to view your current access. Opening it does not change your role.

- {openError && ( -

- {openError} -

+

{status === "success" ? "Invitation accepted" : "Invitation already accepted"}

+ {hasCurrentAccess ? ( + <> +

+ Your current organization access covers all current and future managed profiles. You remain in your personal + account until you explicitly choose to act for a profile. +

+ + + + ) : ( + <> +

+ Invitation status does not confirm current access. Reopening this link does not grant or restore membership. +

+ {organization.isFetching || organization.isPending ? ( +

Checking current organization access...

+ ) : currentOrganization ? ( + <> +

+ Your current organization is {currentOrganization.ownerEmail ?? currentOrganization.ownerProfileId}, not the + organization from this invitation. +

+ + + ) : organization.isError ? ( + + ) : ( +

You do not currently have access to this organization.

+ )} + + )} - - ); } @@ -200,15 +227,21 @@ function InvitationDetails({ invitationId, userId }: { invitationId: string; use return ( <> -

Join {preview.data.managedProfile.externalSubjectId}

+

+ Join {preview.data.organization.ownerEmail ?? preview.data.organization.ownerProfileId}'s organization +

Invited by {preview.data.inviter.email ?? preview.data.inviter.profileId}

Role: {preview.data.invitation.role === "manager" ? "Manager" : "Read only"}

{preview.data.invitation.role === "manager" - ? "You can manage this profile, including non-owner team access and child credentials." - : "You can view this profile's data, but cannot make changes."} + ? "You can manage all current and future managed profiles, including non-owner team access and child credentials. Only the owner can provision or delete profiles." + : "You can view all current and future managed profiles, but cannot make changes."} +

+

+ Personal account resources are not shared. Each person can belong to only one organization. Accepting confirms you want + to join using the verified email that received this invitation.

Expires {new Date(preview.data.invitation.expiresAt).toLocaleString()}