diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index 2f013b63d..292c665d5 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", + ownerProfileId: "manager-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/managedProfileMemberships.controller.ts b/apps/api/src/api/controllers/managedProfileMemberships.controller.ts new file mode 100644 index 000000000..104311336 --- /dev/null +++ b/apps/api/src/api/controllers/managedProfileMemberships.controller.ts @@ -0,0 +1,156 @@ +import type { Request, RequestHandler, Response } from "express"; +import logger from "../../config/logger"; +import { + cancelManagedProfileInvitation, + changeManagedProfileMember, + createManagedProfileInvitation, + getManagedProfileOrganization, + 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 || 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)) { + 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, await organization(req, actor), limit, offset)); +}); + +export const patchMember = handle(async (req, res, actor) => { + res.json( + await changeManagedProfileMember( + actor, + await organization(req, actor), + req.params.memberProfileId as string, + req.body?.role + ) + ); +}); + +export const deleteMember = handle(async (req, res, actor) => { + 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, await organization(req, actor), limit, offset)); +}); + +export const postInvitation = handle(async (req, res, actor) => { + const result = await createManagedProfileInvitation(actor, await organization(req, actor), { + 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, await organization(req, actor), 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, await organization(req, actor), 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 + ) + ); +}); + +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 f084c44d0..1899b1827 100644 --- a/apps/api/src/api/controllers/managedProfiles.controller.test.ts +++ b/apps/api/src/api/controllers/managedProfiles.controller.test.ts @@ -1,14 +1,28 @@ -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 { configureManagedProfileManager } from "../services/managed-profile-manager.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,23 +38,296 @@ describe("managed profile lifecycle routes", () => { }); afterAll(() => server?.close()); + afterEach(() => { + mock.restore(); + config.dashboardPublicUrl = originalDashboardPublicUrl; + }); beforeEach(resetTestDatabase); 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 }; } - 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, 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 }); + 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, 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}` } + : { "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: true }, + 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: true }, + 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, 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 })); + 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, 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; + 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, 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" }); + 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("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); - 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 +349,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 +382,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: true, profileId: manager.id }, managedProfiles: [], pagination: { limit: 50, offset: 0, total: 0 } }); @@ -151,6 +445,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, + ownerProfileId: owner.manager.id, + memberProfileId: managerMember.manager.id, + role: "manager" + }, + { + createdByProfileId: owner.manager.id, + ownerProfileId: owner.manager.id, + 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: { 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`, { + 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/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, statusCode: 200, json: mock((body: unknown) => { res.body = body; @@ -35,12 +45,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 +66,21 @@ 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, 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; } 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 +88,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 +100,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 +112,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 +132,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,27 +146,221 @@ 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 }); expect(Object.isFrozen(req.managedProfileContext)).toBe(true); + expect(ManagedProfileMembership.findOne).toHaveBeenCalledWith({ + where: { ownerProfileId: OWNER_ID, memberProfileId: MANAGER_ID, revokedAt: null } + }); 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("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(); + + 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: { + ownerProfileId: OWNER_ID, + memberProfileId: MANAGER_ID, + createdAt: { [Op.lte]: expect.any(Date) }, + [Op.or]: [{ revokedAt: null }, { revokedAt: { [Op.gt]: new Date("2026-01-01") } }] + } + }); + }); + + 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 +385,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 +402,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 +430,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 +473,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 +499,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 +513,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 +525,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 +541,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 +565,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..9a8378c3f 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 [relationship, subject] = await Promise.all([ 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; + } + 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); + 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/middlewares/supabaseAuth.ts b/apps/api/src/api/middlewares/supabaseAuth.ts index 3db7269d3..831956b51 100644 --- a/apps/api/src/api/middlewares/supabaseAuth.ts +++ b/apps/api/src/api/middlewares/supabaseAuth.ts @@ -10,6 +10,7 @@ declare global { interface Request { userId?: string; userEmail?: string; + emailConfirmedAt?: string; /** Set only when the caller presented an impersonation token; `userId` is the target. */ impersonation?: ImpersonationContext; } @@ -40,6 +41,7 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio req.userId = result.userId; req.userEmail = result.userEmail; + req.emailConfirmedAt = result.emailConfirmedAt; req.impersonation = result.impersonation; next(); } catch (error) { @@ -71,6 +73,7 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti } req.userId = result.userId; req.userEmail = result.userEmail; + req.emailConfirmedAt = result.emailConfirmedAt; req.impersonation = result.impersonation; next(); } catch (error) { 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/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/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/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/index.ts b/apps/api/src/api/routes/v1/index.ts index 351c046fb..538f092d0 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -22,6 +22,7 @@ import emailRoutes from "./email.route"; import fiatRoutes from "./fiat.route"; import limitsRoutes from "./limits.route"; import maintenanceRoutes from "./maintenance.route"; +import managedProfileMembershipRoutes, { managedProfileInviteeRoutes } from "./managed-profile-memberships.route"; import managedProfilesRoutes from "./managed-profiles.route"; import metricsRoutes from "./metrics.route"; import moneriumRoutes from "./monerium.route"; @@ -225,6 +226,8 @@ router.use("/onboarding", onboardingRoutes); /** One-record API credential management for authenticated Supabase users. */ router.use("/api-credentials", apiCredentialsRoutes); +router.use("/organization", managedProfileMembershipRoutes); +router.use("/organization-member-invitations", managedProfileInviteeRoutes); router.use("/managed-profiles", managedProfilesRoutes); /** 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-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 new file mode 100644 index 000000000..fd753265d --- /dev/null +++ b/apps/api/src/api/routes/v1/managed-profile-memberships.route.test.ts @@ -0,0 +1,474 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { inspect } from "node:util"; +import express from "express"; +import { config } from "../../../config/vars"; +import logger from "../../../config/logger"; +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"; +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"; + +describe("managed-profile membership HTTP API", () => { + 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/organization", membershipRoutes); + app.get("/v1/managed-profiles", (_req, res) => res.json({ lifecycle: true })); + 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"); + 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 configureManagedProfileManager({ + allowedCorridors: ["BR"], + allowedCustomerTypes: null, + 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, expectedOwnerProfileId = owner.id) => + `organization/${suffix}${suffix.includes("?") ? "&" : "?"}expectedOwnerProfileId=${expectedOwnerProfileId}`; + const invitePath = (id: string) => `organization-member-invitations/${id}`; + + 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(); + 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()).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( + 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 = [ + ["organization", "GET"], + [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("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(), + targetProfileId: owner.id, + targetEmail: owner.email!, + sessionId: crypto.randomUUID(), + expiresAt: new Date(Date.now() + 10000) + }); + const id = crypto.randomUUID(); + for (const [path, method] of [ + ["organization", "GET"], + [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 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" + }); + 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"] + ]) { + 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, owner.id, { + 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({ 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); + } + 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("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", + "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( + team(`members/${owner.id.toUpperCase()}`, owner.id.toUpperCase()), + method, + method === "PATCH" ? { role: "read_only" } : undefined, + "owner" + ); + 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("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")); + 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..8102aecd9 --- /dev/null +++ b/apps/api/src/api/routes/v1/managed-profile-memberships.route.ts @@ -0,0 +1,52 @@ +import { type RequestHandler, Router } from "express"; +import rateLimit from "express-rate-limit"; +import { + acceptInvitation, + deleteInvitation, + deleteMember, + patchMember, + postInvitation, + previewInvitation, + readInvitations, + readMemberEvents, + readMembers, + readOrganization +} 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 authenticatedLimiter = rateLimit({ + keyGenerator: req => req.userId as string, + legacyHeaders: false, + max: 120, + standardHeaders: true, + windowMs: 60 * 1000 +}); +const session = [rejectApiCredentials, requireAuth, rejectImpersonation, rejectManagedProfileSelection, authenticatedLimiter]; + +const router = Router(); +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, previewInvitation); +managedProfileInviteeRoutes.post("/:invitationId/accept", ...session, acceptInvitation); + +export default router; 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..8dfd2c96c 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 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 { configureManagedProfileManager } from "../../services/managed-profile-manager.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 configureManagedProfileManager({ allowedCorridors: ["BR"], allowedCustomerTypes: null, 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 configureManagedProfileManager({ allowedCorridors: ["BR"], allowedCustomerTypes: null, 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..d50c3850b 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: { ownerProfileId: "owner", 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 = {}; @@ -162,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"], @@ -181,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 1fb87470b..3de4093f1 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: { memberProfileId: actorProfileId, ownerProfileId: relationship.managerProfileId, 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/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/avenia/avenia-kyc-import.service.test.ts b/apps/api/src/api/services/avenia/avenia-kyc-import.service.test.ts index 82ff26f92..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 @@ -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 = { + ownerProfileId: "manager-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([ + { ownerProfileId: "another-owner" }, + { 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..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 @@ -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.ownerProfileId !== relationship.managerProfileId || + 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..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 @@ -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 = { + ownerProfileId: "manager-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([ + { ownerProfileId: "another-owner" }, + { 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..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 @@ -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.ownerProfileId !== relationship.managerProfileId || + 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/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..d565cfd6f --- /dev/null +++ b/apps/api/src/api/services/email/managed-profile-membership-invitation.test.ts @@ -0,0 +1,123 @@ +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).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); + } + 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..bdf99c4a0 --- /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 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 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 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 7a9cec707..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 @@ -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"; @@ -10,15 +11,16 @@ import { getOrCreateCustomerEntityForProfile } from "./customer-entity.service"; import { createCredential, createManagedProfileCredential, revokeManagedProfileCredential } from "./apiCredential.service"; import { deleteManagedProfile, + getManagedProfileActor, getManagedProfile, - listManagedProfiles, - ManagedProfileLifecycleError + 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; } @@ -48,6 +50,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 +66,68 @@ describe("managed profile lifecycle", () => { }); }); + 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(); + const visible = await provisionManagedProfile({ + contactEmail: "visible@example.com", + creationSource: "manager", + customerType: "individual", + externalSubjectId: "visible-child", + managerProfileId: owner.id + }); + const foreign = await provisionManagedProfile({ + contactEmail: "hidden@example.com", + creationSource: "manager", + customerType: "individual", + externalSubjectId: "hidden-child", + managerProfileId: otherOwner.id + }); + await ManagedProfileMembership.create({ + ownerProfileId: owner.id, + 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 } + }); + 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 () => { const manager = await createManager(); const child = await provisionManagedProfile({ @@ -106,7 +172,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 +191,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 +204,85 @@ 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 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 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: status === "all" ? 2 : 1 + }); + const firstPage = await listManagedProfiles(owner.id, { limit: 1, offset: 0, status }); + expect(firstPage.managedProfiles.map(row => row.profileId)).toEqual([status === "deleted" ? owned.profileId : active.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: true }, managedProfiles: [], total: 0 + }); + 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: 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" + }); + }); + it("prevents a managed child from creating a second customer-entity type", async () => { const manager = await createManager(); const child = await provisionManagedProfile({ @@ -179,8 +326,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 +373,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..68cefff74 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,7 +7,9 @@ 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 { getManagedProfileOrganization } from "./managed-profile-membership.service"; import { provisionManagedProfile } from "./managed-profile-provisioning.service"; export class ManagedProfileLifecycleError extends Error { @@ -16,6 +18,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 +41,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 +98,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 +132,74 @@ export async function createManagedProfile(input: { return { created: provisioned.created, managedProfile: await toResult(relationship) }; } +function eligibleMembershipIncludes(actorProfileId: string): Includeable[] { + return [ + { + as: "manager", + include: [ + { + as: "memberships", + model: ManagedProfileMembership, + required: true, + where: { memberProfileId: actorProfileId, revokedAt: null, role: ["manager", "read_only"] } + } + ], + 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, organization] = await Promise.all([ + ManagedProfileManager.findByPk(actorProfileId), + getManagedProfileOrganization(actorProfileId) + ]); + return { + canProvisionManagedProfiles: actorManager?.isActive === true, + hasMemberships: organization !== null, + 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 +218,7 @@ export async function listManagedProfiles( } return { + actor, limit: options.limit, managedProfiles: rows.map(relationship => { const profileEntities = entitiesByProfileId.get(relationship.profileId) ?? []; @@ -141,27 +228,84 @@ export async function listManagedProfiles( "The managed profile does not have exactly one customer entity" ); } - return toResultWithCustomerType(relationship, profileEntities[0].type); + 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"); + } + 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 [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 || + !["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 is not prior access: the child must have existed during a stored organization membership interval. + if ( + bootstrap && + 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", + "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 +317,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: { memberProfileId: managerProfileId, ownerProfileId: relationship.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/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 new file mode 100644 index 000000000..718d4a98e --- /dev/null +++ b/apps/api/src/api/services/managed-profile-membership.service.test.ts @@ -0,0 +1,629 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { Transaction, UniqueConstraintError } from "sequelize"; +import sequelize from "../../config/database"; +import { config } from "../../config/vars"; +import EmailNotification from "../../models/emailNotification.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 { configureManagedProfileManager } from "./managed-profile-manager.service"; +import { + cancelManagedProfileInvitation, + changeManagedProfileMember, + createManagedProfileInvitation, + getManagedProfileOrganization, + listManagedProfileInvitations, + listManagedProfileMemberEvents, + listManagedProfileMembers, + readOrAcceptManagedProfileInvitation, + removeManagedProfileMember +} from "./managed-profile-membership.service"; +import { provisionManagedProfile } from "./managed-profile-provisioning.service"; + +describe("organization membership transactions", () => { + const originalDashboardUrl = config.dashboardPublicUrl; + let owner: User; + let invitee: User; + 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, owner.id, { email: recipient, role }); + const accept = (id: string) => readOrAcceptManagedProfileInvitation(principal(), id, true); + const eventCount = (action: string) => MembershipEvent.count({ where: { ownerProfileId: owner.id, action } }); + + beforeAll(setupTestDatabase); + beforeEach(async () => { + config.dashboardPublicUrl = "https://dashboard.example.com"; + await resetTestDatabase(); + owner = await createTestUser(); + invitee = await createTestUser({ email: "stale@example.com" }); + await configure(owner.id); + }); + afterEach(() => { + mock.restore(); + config.dashboardPublicUrl = originalDashboardUrl; + }); + + 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, 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); + expect(unknown.created).toBe(true); + expect(Object.keys(existing.invitation)).toEqual(Object.keys(unknown.invitation)); + expect(await EmailNotification.count()).toBe(2); + }); + + it("lets the current mailbox holder accept despite another member's stale local email", async () => { + const staleMember = await createTestUser({ email }); + 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); + 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); + }); + + 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: confirmation === "changed" ? "changed@example.com" : " STALE@EXAMPLE.COM ", + email_confirmed_at: + confirmation === "unverified" + ? undefined + : confirmation === "invalid_timestamp" + ? "invalid" + : "2026-09-01T00:00:00Z" + } as never); + 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); + }); + } + + for (const accepting of [false, true]) { + 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, invitee.id, { email: owner.email!, role: "manager" }); + 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 + ) + ]); + 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("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" + }); + } + }); + + 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 locked = new Promise(resolve => { + reached = resolve; + }); + const proceed = new Promise(resolve => { + release = resolve; + }); + 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 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 + ); + 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, owner.id, first.invitation.id); + expect((await invite("read_only")).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, owner.id, { email, role })).rejects.toMatchObject({ + code: "INVALID_MEMBERSHIP_ROLE" + }); + 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, owner.id, { email: recipient, role: "manager" }) + ).rejects.toMatchObject({ code: "INVALID_INVITATION_EMAIL" }); + } + expect(await Invitation.count()).toBe(0); + }); + + 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", 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); + expect(await accept(invitation.id)).toMatchObject({ + ownerProfileId: owner.id, + member: { memberProfileId: invitee.id, role: "manager", isOwner: false } + }); + 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 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: 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" }); + }); + + 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); + expect(await eventCount("invitation_accepted")).toBe(1); + }); + + 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, owner.id, invitee.id, "read_only"); + await expect(accept(invitation.id)).rejects.toMatchObject({ code: "INVITATION_ACCEPTED" }); + await removeManagedProfileMember(owner.id, owner.id, invitee.id); + await expect(accept(invitation.id)).rejects.toMatchObject({ code: "INVITATION_ACCEPTED" }); + 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); + }); + + it("never overwrites an existing same-organization membership", async () => { + const { invitation } = await invite(); + 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 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, owner.id, 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, 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); + expect(await eventCount("invitation_expired")).toBe(1); + }); + } + + 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({ ownerProfileId: owner.id, 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); + }); + + it("serializes cancellation versus acceptance with exactly one terminal event", async () => { + const { invitation } = await invite(); + const results = await Promise.allSettled([ + 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); + 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 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"); + expect(await Membership.count({ where: { memberProfileId: invitee.id } })).toBe(0); + expect((await Invitation.findByPk(invitation.id))?.acceptedAt).toBeNull(); + }); + + it("maps a database active-membership unique race to a typed organization conflict", async () => { + const { invitation } = await invite(); + 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, 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, 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 concurrently and keeps role/removal histories idempotent", async () => { + const attempts = await Promise.allSettled([ + changeManagedProfileMember(owner.id, owner.id, owner.id, "read_only"), + removeManagedProfileMember(owner.id, owner.id, 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); + 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); + 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, owner.id, crypto.randomUUID())).rejects.toMatchObject({ + code: "MEMBER_NOT_FOUND" + }); + }); + + 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, 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", "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 }); + 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 + ); + await waiting; + try { + 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(); + throw error; + } + expect(await creating).toMatchObject({ + code: action === "downgrade" ? "MANAGED_PROFILE_MANAGER_REQUIRED" : "MANAGED_PROFILE_ACCESS_DENIED" + }); + expect(await Invitation.count()).toBe(1); + }); + } + + it("paginates tied event timestamps without missing rows and rejects foreign cursors", async () => { + await MembershipEvent.bulkCreate( + 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, 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, owner.id, 2, crypto.randomUUID())).rejects.toMatchObject({ + code: "INVALID_PAGINATION" + }); + await expect(listManagedProfileMembers(owner.id, crypto.randomUUID(), 50, 0)).rejects.toMatchObject({ + code: "MANAGED_PROFILE_ACCESS_DENIED" + }); + }); + + it("keeps organization offers and team access valid after every child is deleted", async () => { + const { invitation } = await invite(); + 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 new file mode 100644 index 000000000..c887dfbe6 --- /dev/null +++ b/apps/api/src/api/services/managed-profile-membership.service.ts @@ -0,0 +1,561 @@ +import { Op, Transaction, UniqueConstraintError } from "sequelize"; +import { z } from "zod"; +import sequelize from "../../config/database"; +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; +} + +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(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: { memberProfileId: ownerProfileId, ownerProfileId, revokedAt: null } + }); + if (ownerMembership?.role !== "manager") throw accessDenied(); + return ownerProfile; +} + +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: { memberProfileId: actorProfileId, ownerProfileId, 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" + ); + } + 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) { + 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, + ownerProfileId: invitation.ownerProfileId, + 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, + ownerProfileId: invitation.ownerProfileId, + 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, ownerProfileId: string, limit: number, offset: number) { + return sequelize.transaction(async transaction => { + await lockOrganization(ownerProfileId, transaction); + await requireMember(actorProfileId, ownerProfileId, false, transaction); + const { rows, count } = await Membership.findAndCountAll({ + limit, + offset, + order: [ + ["createdAt", "ASC"], + ["id", "ASC"] + ], + transaction, + where: { ownerProfileId, 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, ownerProfileId), + email: emails.get(row.memberProfileId) ?? null + })), + pagination: { limit, offset, total: count } + }; + }); +} + +export async function changeManagedProfileMember( + actorProfileId: string, + ownerProfileId: string, + memberProfileId: string, + role: unknown +) { + const nextRole = requireMembershipRole(role); + return sequelize.transaction(async transaction => { + await lockOrganization(ownerProfileId, transaction); + await requireMember(actorProfileId, ownerProfileId, true, transaction); + if (memberProfileId === ownerProfileId) { + 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: { 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, memberProfileId, ownerProfileId, previousRole, role: nextRole }, + { transaction } + ); + } + return { member: memberResult(member, ownerProfileId) }; + }); +} + +export async function removeManagedProfileMember(actorProfileId: string, ownerProfileId: string, memberProfileId: string) { + await sequelize.transaction(async transaction => { + await lockOrganization(ownerProfileId, transaction); + await requireMember(actorProfileId, ownerProfileId, true, transaction); + if (memberProfileId === ownerProfileId) { + 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: { memberProfileId, ownerProfileId, revokedAt: null } + }); + if (!member) { + const removed = await Membership.findOne({ + transaction, + where: { + memberProfileId, + ownerProfileId, + 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, memberProfileId, ownerProfileId, role: member.role }, + { transaction } + ); + }); +} + +export async function createManagedProfileInvitation( + actorProfileId: 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 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, ownerProfileId } + }); + 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 organization'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: { memberProfileId: existingProfile.id, ownerProfileId, 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, + ownerProfileId, + role + }, + { logging: false, transaction } + ); + await MembershipEvent.create( + { action: "invited", actorProfileId, invitationId: invitation.id, ownerProfileId, 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, + ownerProfileId: string, + limit: number, + offset: number +) { + return sequelize.transaction(async transaction => { + await lockOrganization(ownerProfileId, transaction); + await requireMember(actorProfileId, ownerProfileId, 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() }, + ownerProfileId + } + }); + 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: { ownerProfileId } + }); + return { invitations: rows.map(invitationResult), pagination: { limit, offset, total: count } }; + }); +} + +export async function cancelManagedProfileInvitation(actorProfileId: string, ownerProfileId: string, invitationId: string) { + const result = await sequelize.transaction(async 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, 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, ownerProfileId, 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(); + // 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 (result instanceof ManagedProfileMembershipError) throw result; + return result; +} + +export async function listManagedProfileMemberEvents( + actorProfileId: string, + ownerProfileId: string, + limit: number, + cursor?: string +) { + return sequelize.transaction(async transaction => { + 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, + order: [ + ["createdAt", "DESC"], + ["id", "DESC"] + ], + transaction, + where: { + ownerProfileId, + ...(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/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..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,10 +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 @@ -12,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, @@ -31,8 +33,10 @@ describe("managed profile provisioning", () => { beforeAll(setupTestDatabase); beforeEach(resetTestDatabase); - it("atomically creates the headless profile, customer entity, and relationship", 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 ", @@ -63,6 +67,14 @@ describe("managed profile provisioning", () => { profileId: result.profileId, status: "active" }); + expect(await ManagedProfileMembership.findOne({ where: { ownerProfileId: manager.id } })).toMatchObject({ + createdByProfileId: null, + memberProfileId: manager.id, + revokedAt: null, + role: "manager" + }); + expect(await ManagedProfileMembership.count()).toBe(membershipCount); + expect(await ManagedProfileMembershipEvent.count()).toBe(eventCount); }); it("returns the existing profile for an idempotent retry", async () => { @@ -82,6 +94,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/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/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/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..5ac223f8c --- /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_profile_managers + IN ACCESS EXCLUSIVE MODE;`, + { transaction } + ); + expect(dropTable).not.toHaveBeenCalled(); + }); +}); 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/managed-profile-memberships-schema.test.ts b/apps/api/src/database/managed-profile-memberships-schema.test.ts new file mode 100644 index 000000000..d5d67518e --- /dev/null +++ b/apps/api/src/database/managed-profile-memberships-schema.test.ts @@ -0,0 +1,325 @@ +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 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; +} + +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 } + ); + return profile; + }); +} + +describe("managed profile membership schema", () => { + beforeAll(setupTestDatabase); + beforeEach(resetTestDatabase); + + 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; + + 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 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( + [ + { + 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 } + ); + }); + + await up(queryInterface); + + const memberships = await ManagedProfileMembership.findAll({ order: [["ownerProfileId", "ASC"]] }); + expect(memberships).toHaveLength(2); + 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); + + 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 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 an organization target", async () => { + const manager = await createManager(); + const otherChild = await createManagedProfile(manager.id, "membership-kind-member"); + const authenticatedProfile = await createTestUser(); + + await expect( + ManagedProfileMembership.create({ + ownerProfileId: manager.id, + memberProfileId: otherChild.id, + role: "read_only" + }) + ).rejects.toThrow("Managed profile members must be authenticated profiles"); + await expect( + ManagedProfileMembership.create({ + ownerProfileId: authenticatedProfile.id, + memberProfileId: authenticatedProfile.id, + role: "read_only" + }) + ).rejects.toThrow(); + }); + + 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 first = await ManagedProfileMembership.create({ + createdByProfileId: owner.id, + ownerProfileId: owner.id, + memberProfileId: member.id, + role: "read_only" + }); + + await expect( + ManagedProfileMembership.create({ + createdByProfileId: owner.id, + ownerProfileId: otherOwner.id, + memberProfileId: member.id, + role: "manager" + }) + ).rejects.toThrow(); + await expect( + ManagedProfileMembership.create({ + ownerProfileId: owner.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, + ownerProfileId: otherOwner.id, + memberProfileId: member.id, + role: "manager" + }); + + expect(second.id).not.toBe(first.id); + expect(await ManagedProfileMembership.count({ where: { memberProfileId: member.id } })).toBe(2); + }); + + 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: { ownerProfileId: owner.id, memberProfileId: owner.id } + }); + await ManagedProfileManager.update({ isActive: false }, { where: { profileId: owner.id } }); + + await expect(membership?.update({ role: "read_only" })).rejects.toThrow( + "Organization owner membership cannot be downgraded or removed" + ); + await expect(membership?.update({ revokedAt: new Date(), revokedByProfileId: owner.id })).rejects.toThrow( + "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" + ); + }); + + it("constrains invitation roles, normalized email, terminal state, and pending uniqueness", async () => { + const owner = await createManager(); + const acceptedBy = await createTestUser(); + const expiresAt = new Date(Date.now() + 60_000); + const invitation = await ManagedProfileMembershipInvitation.create({ + email: "member@example.com", + expiresAt, + invitedByProfileId: owner.id, + ownerProfileId: owner.id, + role: "manager" + }); + + await expect( + ManagedProfileMembershipInvitation.create({ + email: "member@example.com", + expiresAt, + invitedByProfileId: owner.id, + ownerProfileId: owner.id, + role: "read_only" + }) + ).rejects.toThrow(); + await expect( + ManagedProfileMembershipInvitation.create({ + email: "other@example.com", + expiresAt, + invitedByProfileId: owner.id, + ownerProfileId: owner.id, + role: "operator" as "manager" + }) + ).rejects.toThrow(); + await expect( + ManagedProfileMembershipInvitation.create({ + email: " Member@Example.com ", + expiresAt, + invitedByProfileId: owner.id, + ownerProfileId: owner.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, + ownerProfileId: owner.id, + role: "read_only" + }) + ).resolves.toBeInstanceOf(ManagedProfileMembershipInvitation); + }); + + it("makes membership events append-only", async () => { + const owner = await createManager(); + const event = await ManagedProfileMembershipEvent.create({ + action: "member_added", + actorProfileId: owner.id, + ownerProfileId: owner.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", + ownerProfileId: owner.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..a98d3085b 100644 --- a/apps/api/src/database/managed-profiles-schema.test.ts +++ b/apps/api/src/database/managed-profiles-schema.test.ts @@ -3,13 +3,22 @@ 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"; -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; } @@ -171,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 new file mode 100644 index 000000000..78b6b8ba8 --- /dev/null +++ b/apps/api/src/database/migrations/069-create-managed-profile-memberships.ts @@ -0,0 +1,478 @@ +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 }, + member_profile_id: { + allowNull: false, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + owner_profile_id: { + allowNull: false, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "profile_id", model: "managed_profile_managers" }, + 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 (member_profile_id) + WHERE revoked_at IS NULL; + + CREATE INDEX idx_managed_profile_memberships_member + 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 } + ); + + 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 + }, + owner_profile_id: { + allowNull: false, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "profile_id", model: "managed_profile_managers" }, + 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 (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_owner_created + ON managed_profile_membership_invitations (owner_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 + }, + member_profile_id: { + allowNull: true, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }, + owner_profile_id: { + allowNull: false, + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "profile_id", model: "managed_profile_managers" }, + 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_owner_created + ON managed_profile_membership_events (owner_profile_id, created_at DESC, id DESC);`, + { transaction } + ); + + // 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, + owner_profile_id, + member_profile_id, + role, + created_by_profile_id, + created_at, + updated_at + ) + SELECT + uuid_generate_v4(), + mp.profile_id, + mp.profile_id, + 'manager', + NULL, + mp.created_at, + mp.created_at + FROM managed_profile_managers 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 $$ + BEGIN + IF OLD.owner_profile_id = OLD.member_profile_id + AND ( + TG_OP = 'DELETE' + 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 + ) + THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + CONSTRAINT = 'chk_managed_profiles_owner_membership', + MESSAGE = 'Organization 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_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_owner_profile_ids := ARRAY[NEW.owner_profile_id]; + affected_member_profile_ids := ARRAY[NEW.member_profile_id]; + ELSIF TG_OP = 'DELETE' THEN + affected_owner_profile_ids := ARRAY[OLD.owner_profile_id]; + affected_member_profile_ids := ARRAY[OLD.member_profile_id]; + ELSE + 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_profile_managers' THEN + IF TG_OP = 'INSERT' THEN + affected_owner_profile_ids := ARRAY[NEW.profile_id]; + ELSIF TG_OP = 'DELETE' THEN + affected_owner_profile_ids := ARRAY[OLD.profile_id]; + ELSE + affected_owner_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_profile_managers organization + WHERE organization.profile_id = ANY(affected_owner_profile_ids) + AND NOT EXISTS ( + SELECT 1 + FROM managed_profile_memberships membership + 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 + ) + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + CONSTRAINT = 'chk_managed_profiles_owner_membership', + MESSAGE = 'Organizations 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_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(); + + 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_profile_managers + 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_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 + OR membership.revoked_by_profile_id IS NOT NULL + ) AS "hasNonBackfillMemberships", + EXISTS ( + SELECT 1 + FROM managed_profile_managers organization + WHERE NOT EXISTS ( + SELECT 1 + FROM managed_profile_memberships membership + 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 + 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_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 + }); + 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/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/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 6c97bf074..4c527d27a 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" }); +ManagedProfileManager.hasMany(ManagedProfileMembership, { + as: "memberships", + foreignKey: "ownerProfileId", + sourceKey: "profileId" +}); +ManagedProfileMembership.belongsTo(ManagedProfileManager, { + as: "organization", + foreignKey: "ownerProfileId", + targetKey: "profileId" +}); +ManagedProfileManager.hasMany(ManagedProfileMembershipInvitation, { + as: "membershipInvitations", + foreignKey: "ownerProfileId", + sourceKey: "profileId" +}); +ManagedProfileMembershipInvitation.belongsTo(ManagedProfileManager, { + as: "organization", + foreignKey: "ownerProfileId", + targetKey: "profileId" +}); +ManagedProfileManager.hasMany(ManagedProfileMembershipEvent, { + as: "membershipEvents", + foreignKey: "ownerProfileId", + sourceKey: "profileId" +}); +ManagedProfileMembershipEvent.belongsTo(ManagedProfileManager, { + as: "organization", + foreignKey: "ownerProfileId", + 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..ffd3cc738 --- /dev/null +++ b/apps/api/src/models/managedProfileMembership.model.ts @@ -0,0 +1,96 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export type ManagedProfileMembershipRole = "manager" | "read_only"; + +export interface ManagedProfileMembershipAttributes { + id: string; + ownerProfileId: 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 ownerProfileId: 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 }, + memberProfileId: { + allowNull: false, + field: "member_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + 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 + }, + 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: ["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: ["owner_profile_id", "created_at", "id"], name: "idx_managed_profile_memberships_owner_created" } + ], + 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..cff32f93e --- /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; + ownerProfileId: 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 ownerProfileId: 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 + }, + memberProfileId: { + allowNull: true, + field: "member_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + 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) } + }, + { + indexes: [ + { + fields: ["owner_profile_id", { name: "created_at", order: "DESC" }, { name: "id", order: "DESC" }], + name: "idx_managed_profile_membership_events_owner_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..7a216d50b --- /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; + ownerProfileId: 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 ownerProfileId: 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 + }, + ownerProfileId: { + allowNull: false, + field: "owner_profile_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + references: { key: "profile_id", model: "managed_profile_managers" }, + type: DataTypes.UUID + }, + role: { allowNull: false, type: DataTypes.STRING(16) }, + updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE } + }, + { + indexes: [ + { + 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: ["owner_profile_id", "created_at"], + name: "idx_managed_profile_membership_invitations_owner_created" + } + ], + modelName: "ManagedProfileMembershipInvitation", + sequelize, + tableName: "managed_profile_membership_invitations", + timestamps: true + } +); + +export default ManagedProfileMembershipInvitation; 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..b376b5856 100644 --- a/apps/api/src/tests/alfredpay-managed-customer.integration.test.ts +++ b/apps/api/src/tests/alfredpay-managed-customer.integration.test.ts @@ -14,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; @@ -46,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; } @@ -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( @@ -346,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 7f3970e42..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,19 +1,23 @@ 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"; +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 +29,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(); }); @@ -58,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 }; @@ -78,6 +84,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" }); + 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, markupCurrency: FiatToken.BRL, @@ -122,7 +134,18 @@ 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 + }; + + 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, @@ -173,7 +196,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 +228,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 +261,7 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { const delegatedRegistration = await registerQuote( delegatedQuoteId, - managerCredential.plaintextKey, + memberCredential.plaintextKey, EPHEMERALS[0], childId ); @@ -266,16 +304,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); @@ -311,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 }), @@ -318,17 +382,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 +417,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 +437,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/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/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..0c92d8179 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_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"; 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,60 @@ 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(); + 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" + ) + ).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 +125,169 @@ 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 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], + organization, + 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(); + 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(); + + 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(); + organization.membership.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 +333,92 @@ 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 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: hasChildren ? [CHILD] : [] + }); + await seedSession(page); + await page.goto("/managed-profiles"); + await expect(page.getByRole("heading", { name: "Managed profiles" })).toBeVisible(); + 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([]); + }); + } +} + +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/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 21280da49..42ed6aad0 100644 --- a/apps/dashboard/e2e/support/mockBackend.ts +++ b/apps/dashboard/e2e/support/mockBackend.ts @@ -1,6 +1,13 @@ import type { Page } from "@playwright/test"; +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"; @@ -8,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"; @@ -253,6 +261,20 @@ 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; + previewError?: { code: string; status: number }; + acceptError?: { code: string; status: number }; + }; apiCredentials?: Array>; approvedCorridors?: Array<"AR" | "BR" | "CO" | "MX" | "US">; limits?: Array>; @@ -276,12 +298,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 +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> = []; @@ -470,6 +531,19 @@ 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 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: !!organization, + 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 +577,233 @@ 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\/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; + 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; + 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 + ); + return; + } + if (method === "GET") { + 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" } }, + 409 + ); + 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 = { + 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); + 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 [, 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") { + 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) 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.organization = null; + 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, + ownerProfileId: organization.ownerProfileId, + 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 = inventory?.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 +901,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 +937,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 +1465,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/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 new file mode 100644 index 000000000..209fc34c9 --- /dev/null +++ b/apps/dashboard/e2e/team-memberships.spec.ts @@ -0,0 +1,909 @@ +import { expect, type Page, test } from "@playwright/test"; +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 { 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 { + contactEmail: "child@example.test", + customerType: "business", + externalSubjectId: "Invited company", + membership: { isOwner: false, role }, + policy: { allowedCorridors: ["MX"], allowedCustomerTypes: null }, + profileId: E2E_MANAGED_PROFILE_ID, + status: "active" + }; +} +function member(index = 0): TeamMember { + return { + createdAt: NOW, + email: index === 0 ? "owner@example.test" : `member-${index}@example.test`, + id: `membership-${index}`, + isOwner: index === 0, + memberProfileId: `member-profile-${index}`, + role: "manager", + updatedAt: NOW + }; +} +function invitation(index = 0): MemberInvitation { + return { + acceptedAt: null, + cancelledAt: null, + createdAt: NOW, + email: index === 0 ? E2E_USER_EMAIL : `invitee-${index}@example.test`, + expiredAt: null, + expiresAt: "2099-09-01T12:00:00.000Z", + id: index === 0 ? INVITATION_ID : `invitation-${index}`, + invitedByProfileId: E2E_ORGANIZATION_OWNER_ID, + ownerProfileId: E2E_ORGANIZATION_OWNER_ID, + role: "read_only", + status: "pending" + }; +} +function preview(): InvitationPreview { + return { + invitation: invitation(), + 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 { + return { + action: "member_added", + actorProfileId: `actor-${index}`, + createdAt: NOW, + id: `event-${index}`, + invitationId: null, + memberProfileId: `member-profile-${index}`, + previousRole: null, + role: "manager" + }; +} +async function selectChild(page: Page, profile = child(), impersonation = false) { + await seedSession(page); + await page.addInitScript( + ({ profile, userId, impersonation }) => { + if (impersonation) + localStorage.setItem( + "vortex_dashboard_impersonation_session", + JSON.stringify({ + expiresAt: new Date(Date.now() + 600000).toISOString(), + sessionId: "imp", + targetEmail: "target@example.test", + targetProfileId: userId, + token: "vtx_imp_test" + }) + ); + localStorage.setItem( + "vortex_dashboard_managed_profile_selection", + JSON.stringify({ + customerType: profile.customerType, + externalSubjectId: profile.externalSubjectId, + isOwner: profile.membership.isOwner, + managerProfileId: userId, + membershipRole: profile.membership.role, + targetEmail: profile.contactEmail, + targetProfileId: profile.profileId + }) + ); + }, + { impersonation, profile, userId: E2E_USER_ID } + ); +} +async function noOverflow(page: Page) { + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); +} + +for (const mobile of [false, true]) { + test(`Team manager confirmations, owner protection, and refresh (${mobile ? "mobile" : "desktop"})`, async ({ page }) => { + if (mobile) await page.setViewportSize({ height: 844, width: 390 }); + const teammate = member(1); + if (mobile) teammate.email = `${"long-team-member-".repeat(10)}@example.test`; + const backend = await mockBackend(page, { + organization: organization(), + team: { events: [], invitations: [], members: [member(), teammate] } + }); + await seedSession(page); + await page.goto("/team"); + await expect(page.getByRole("heading", { 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); + await page.getByRole("button", { exact: true, name: "Invite member" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByLabel("Email").fill("New.Member@example.test"); + await expect(dialog.getByRole("combobox")).toHaveText("Read only"); + expect(backend.membershipRequests.filter(request => request.method !== "GET")).toEqual([]); + await dialog.getByRole("button", { name: "Send invitation" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.getByText("new.member@example.test", { exact: true })).toBeVisible(); + await expect(page.getByText("Invitation sent", { exact: true })).toBeVisible(); + await page.getByRole("button", { exact: true, name: "Cancel invitation" }).click(); + await dialog.getByRole("button", { exact: true, name: "Back" }).click(); + expect(backend.membershipRequests.filter(request => request.method === "DELETE")).toEqual([]); + await page.getByRole("button", { exact: true, name: "Cancel invitation" }).click(); + await dialog.getByRole("button", { exact: true, name: "Cancel invitation" }).click(); + await expect(page.getByText("cancelled", { exact: true })).toBeVisible(); + const row = page.getByRole("listitem").filter({ hasText: teammate.email ?? teammate.memberProfileId }); + await row.getByRole("button", { name: "Change role" }).click(); + await expect(dialog.getByText(/provider, fiat-account, quote, and ramp operations/)).toBeVisible(); + await expect(dialog.getByText(/remain valid after a human member is removed or downgraded/)).toBeVisible(); + await dialog.getByRole("button", { name: "Change role" }).click(); + await expect(row.getByText("Read only", { exact: true })).toBeVisible(); + await row.getByRole("button", { name: "Remove member" }).click(); + await dialog.getByRole("button", { name: "Remove member" }).click(); + await expect(row).toHaveCount(0); + await expect(page.getByText("Member removed", { exact: true })).toBeVisible(); + expect(backend.membershipRequests.filter(request => request.method !== "GET").map(request => request.method)).toEqual([ + "POST", + "DELETE", + "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([]); + }); + + test(`Team hides only the viewer's accepted invitation (${mobile ? "mobile" : "desktop"})`, async ({ page }) => { + if (mobile) await page.setViewportSize({ height: 844, width: 390 }); + const backend = await mockBackend(page, { + organization: organization(), + team: { + events: [{ ...event(0), action: "invitation_accepted", memberProfileId: E2E_USER_ID }], + invitations: [ + { ...invitation(), acceptedAt: NOW, status: "accepted" }, + { ...invitation(1), acceptedAt: NOW, status: "accepted" }, + { ...invitation(2), email: E2E_USER_EMAIL } + ], + members: [member(), { ...member(1), email: E2E_USER_EMAIL, memberProfileId: E2E_USER_ID }] + } + }); + await seedSession(page); + await page.goto("/team"); + const invitations = page.getByRole("list").filter({ has: page.getByText("Expires", { exact: false }) }); + await expect(invitations.getByRole("listitem")).toHaveCount(2); + const own = invitations.getByRole("listitem").filter({ hasText: E2E_USER_EMAIL }); + await expect(own.getByText("pending", { exact: true })).toBeVisible(); + await expect(own.getByText("accepted", { exact: true })).toHaveCount(0); + const other = invitations.getByRole("listitem").filter({ hasText: "invitee-1@example.test" }); + await expect(other.getByText("accepted", { exact: true })).toBeVisible(); + await expect(page.getByText("You", { exact: true })).toBeVisible(); + await expect(page.getByText("Invitation accepted", { exact: true })).toBeVisible(); + expect(backend.membershipRequests.every(request => request.method === "GET")).toBe(true); + expect(backend.unmatchedRequests).toEqual([]); + await noOverflow(page); + }); + + test(`Team keeps pagination reachable when accepted invitations are hidden (${mobile ? "mobile" : "desktop"})`, async ({ + page + }) => { + if (mobile) await page.setViewportSize({ height: 844, width: 390 }); + const backend = await mockBackend(page, { + organization: organization("read_only"), + team: { + events: [], + invitations: Array.from({ length: 21 }, (_, index) => + index === 0 || index === 20 + ? { ...invitation(index), acceptedAt: NOW, email: E2E_USER_EMAIL, status: "accepted" as const } + : invitation(index) + ), + members: [] + } + }); + await seedSession(page); + await page.goto("/team"); + const invitations = page.getByRole("list").filter({ has: page.getByText("Expires", { exact: false }) }); + await expect(invitations.getByRole("listitem")).toHaveCount(19); + await page.getByRole("button", { name: "Next invitations" }).click(); + await expect(page.getByText("No invitations on this page.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Next invitations" })).toBeDisabled(); + await page.getByRole("button", { name: "Previous invitations" }).click(); + await expect(invitations.getByRole("listitem")).toHaveCount(19); + expect(backend.membershipRequests.some(request => request.search.includes("offset=20"))).toBe(true); + expect(backend.membershipRequests.every(request => request.method === "GET")).toBe(true); + expect(backend.unmatchedRequests).toEqual([]); + await noOverflow(page); + }); + + 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 backend = await mockBackend(page, { + 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 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( + 0 + ); + await page.getByRole("button", { name: "Next members" }).click(); + await expect(page.getByText("member-20@example.test", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Next members" })).toBeDisabled(); + await page.getByRole("button", { name: "Previous members" }).click(); + await expect(page.getByText("owner@example.test", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Next invitations" }).click(); + await expect(page.getByText("invitee-20@example.test", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Previous invitations" }).click(); + await page.getByRole("button", { name: "Older events" }).click(); + await expect(page.getByText(/Actor: actor-20/)).toBeVisible(); + await expect(page.getByRole("button", { name: "Older events" })).toBeDisabled(); + await page.getByRole("button", { name: "Newer events" }).click(); + await expect(page.getByText(/Actor: actor-0 /)).toBeVisible(); + expect(backend.membershipRequests.some(request => request.search.includes("offset=20"))).toBe(true); + expect(backend.membershipRequests.some(request => request.search.includes("cursor=event-19"))).toBe(true); + expect(backend.membershipRequests.every(request => request.method === "GET")).toBe(true); + await noOverflow(page); + expect(backend.unmatchedRequests).toEqual([]); + }); + + 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: profiles, + memberInvitation: { preview: preview() }, + organization: null + }); + await page.goto(INVITATION_PATH); + await expect(page.getByRole("link", { name: "Sign in to review" })).toBeVisible(); + await expect(page.getByText(/Invited company|inviter@example.test|Role:|Expires/)).toHaveCount(0); + expect(backend.membershipRequests).toEqual([]); + await page.getByRole("link", { name: "Sign in to review" }).click(); + await expect(page).toHaveURL(/\/login\?returnTo=/); + await page.getByLabel("Email", { exact: true }).fill(E2E_USER_EMAIL); + 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 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("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([]); + expect(backend.unexpectedExternalRequests).toEqual([]); + }); +} + +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(/\/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); + await page.goto(INVITATION_PATH); + await expect(page.getByText(/Invitations are unavailable during impersonation/)).toBeVisible(); + expect(backend.membershipRequests).toEqual([]); +}); + +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)] } + }; + 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(); + 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); + 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("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).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, { + managedProfiles: [child()], + team: { events: [], invitations: [], members: [member(), target] } + }); + let fail = true; + 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 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(); + await page.getByRole("dialog").getByRole("button", { name: "Change role" }).click(); + await expect(page.getByRole("alert")).toHaveText("Member was not found"); + await expect(row.getByText("Read only", { exact: true })).toBeVisible(); + fail = false; + await page.getByRole("dialog").getByRole("button", { name: "Change role" }).click(); + await expect(row.getByText("Manager", { exact: true })).toBeVisible(); + expect(backend.unmatchedRequests).toEqual([]); +}); + +for (const status of ["expired", "cancelled", "accepted"] as const) { + test(`invitation ${status} is terminal without automatic acceptance`, async ({ page }) => { + const data = preview(); + data.invitation.status = status; + const backend = await mockBackend(page, { memberInvitation: { preview: data } }); + await seedSession(page); + await page.goto(INVITATION_PATH); + await expect( + page.getByRole("heading", { name: status === "accepted" ? "Invitation already accepted" : `Invitation ${status}` }) + ).toBeVisible(); + await expect(page.getByRole("button", { exact: true, name: "Accept invitation" })).toHaveCount(0); + expect(backend.membershipRequests.filter(request => request.method === "POST")).toEqual([]); + }); +} + +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(), + previewError: { code: "MANAGED_PROFILE_ACCESS_DENIED", status: 403 } as { code: string; status: number } | undefined + }; + const backend = await mockBackend(page, { memberInvitation: fixture }); + await seedSession(page); + await page.goto(INVITATION_PATH); + 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); + await page.getByRole("button", { name: "Sign in with another account" }).click(); + await expect(page.getByRole("link", { name: "Sign in to review" })).toBeVisible(); + expect(backend.membershipRequests.filter(request => request.method === "POST")).toEqual([]); + fixture.previewError = { code: "INTERNAL_SERVER_ERROR", status: 500 }; + await page.reload(); + await expect(page.getByRole("heading", { name: "Could not load invitation" })).toBeVisible({ timeout: 15000 }); + fixture.previewError = undefined; + await page.getByRole("button", { name: "Try again" }).click(); + await expect(page.getByRole("button", { exact: true, name: "Accept invitation" })).toBeVisible(); +}); + +test("accept-time denial hides cached preview and never sends a selected-child header", async ({ page }) => { + const backend = await mockBackend(page, { + managedProfiles: [child()], + memberInvitation: { acceptError: { code: "MANAGED_PROFILE_ACCESS_DENIED", status: 403 }, preview: preview() } + }); + await selectChild(page); + await page.goto(INVITATION_PATH); + 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("/organization-member-invitations/")) + .every(request => request.managedProfileId === undefined) + ).toBe(true); +}); + +test("expiry racing acceptance becomes a terminal state", async ({ page }) => { + await mockBackend(page, { + memberInvitation: { acceptError: { code: "INVITATION_EXPIRED", status: 409 }, preview: preview() } + }); + await seedSession(page); + await page.goto(INVITATION_PATH); + await page.getByRole("button", { exact: true, name: "Accept invitation" }).click(); + await expect(page.getByRole("heading", { name: "Invitation expired" })).toBeVisible(); + 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); + await page.goto("/login?returnTo=https%3A%2F%2Fevil.test"); + await expect(page).toHaveURL(/\/overview$/); +}); + +for (const action of ["Change role", "Remove member"]) { + test(`confirming your own ${action.toLowerCase()} refreshes live membership`, async ({ page }) => { + const self = { ...member(1), email: E2E_USER_EMAIL, memberProfileId: E2E_USER_ID }; + const backend = await mockBackend(page, { + managedProfiles: [child()], + team: { events: [], invitations: [], members: [member(), self] } + }); + 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(); + if (action === "Change role") { + 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); + } else { + await expect(page).toHaveURL(/\/overview$/); + expect(await page.evaluate(() => localStorage.getItem("vortex_dashboard_managed_profile_selection"))).toBeNull(); + } + expect(backend.unmatchedRequests).toEqual([]); + }); +} + +test("role grants wait for the server and Team read failures can be retried", async ({ page }) => { + const target = { ...member(1), role: "read_only" as const }; + const backend = await mockBackend(page, { + managedProfiles: [child()], + team: { events: [], invitations: [], members: [member(), target] } + }); + const response = Promise.withResolvers(); + let requested = false; + 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/organization/members?*", async route => { + if (failRead) await route.fulfill({ json: { error: { code: "INTERNAL_SERVER_ERROR", message: "Failed" } }, status: 500 }); + else await route.fallback(); + }); + await seedSession(page); + await page.goto("/team"); + await expect(page.getByText("Could not load members.")).toBeVisible({ timeout: 15000 }); + failRead = false; + await page.getByRole("button", { name: "Retry members" }).click(); + const row = page.getByRole("listitem", { includeHidden: true }).filter({ hasText: target.email }); + await row.getByRole("button", { name: "Change role" }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Change role" }).click(); + await expect.poll(() => requested).toBe(true); + await expect(page.getByRole("button", { exact: true, name: "Updating..." })).toBeDisabled(); + await expect(row.getByText("Read only", { exact: true })).toBeVisible(); + response.resolve(); + await expect(row.getByText("Manager", { exact: true })).toBeVisible(); + expect(backend.unmatchedRequests).toEqual([]); +}); + +test("an unconfirmed acceptance retries preview instead of accepting twice", async ({ page }) => { + const fixture = { acceptError: { code: "INTERNAL_SERVER_ERROR", status: 500 }, preview: preview() }; + const backend = await mockBackend(page, { memberInvitation: fixture }); + await seedSession(page); + await page.goto(INVITATION_PATH); + await page.getByRole("button", { exact: true, name: "Accept invitation" }).click(); + await expect(page.getByRole("heading", { name: "Could not load invitation" })).toBeVisible(); + fixture.preview.invitation.status = "accepted"; + 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 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(); +}); + +for (const reason of ["owner disabled", "child deleted"] as const) { + test(`read-only bootstrap clears selection when ${reason}`, async ({ page }) => { + const profile = child("read_only"); + const options = { managedProfileOwnerActive: true, managedProfiles: [profile] }; + const backend = await mockBackend(page, options); + await selectChild(page, profile); + 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( + response => new URL(response.url()).pathname === `/v1/managed-profiles/${profile.profileId}` && response.status() === 403 + ); + await page.evaluate(() => window.dispatchEvent(new Event("visibilitychange"))); + 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(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); + 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([]); + }); +} + +test("child credential scope and survival warning fits the mobile creation dialog", async ({ page }) => { + await page.setViewportSize({ height: 844, width: 390 }); + const backend = await mockBackend(page, { managedProfiles: [child()] }); + await selectChild(page); + await page.goto("/api-keys"); + await expect( + page.getByText(/shared company principal for supported provider, fiat-account, quote, and ramp operations/) + ).toBeVisible(); + await page.getByRole("button", { exact: true, name: "Create credential" }).click(); + const dialog = page.getByRole("dialog"); + await expect( + dialog.getByText(/shared company principal for supported provider, fiat-account, quote, and ramp operations/) + ).toBeVisible(); + await expect(dialog.getByText(/remain valid after a human member is removed or downgraded/)).toBeVisible(); + await expect(dialog.getByRole("button", { exact: true, name: "Create credential" })).toBeInViewport(); + await noOverflow(page); + expect(backend.apiCredentialRequests).toEqual([]); + expect(backend.unmatchedRequests).toEqual([]); +}); 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/auth/login-return.test.ts b/apps/dashboard/src/components/auth/login-return.test.ts new file mode 100644 index 000000000..4f6a33875 --- /dev/null +++ b/apps/dashboard/src/components/auth/login-return.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { safeLoginReturnTo } from "./login-return"; + +describe("membership invitation login return", () => { + const path = "/member-invitations/12345678-1234-1234-1234-123456789abc"; + it("preserves only a local invitation UUID path", () => { + assert.equal(safeLoginReturnTo(path), path); + const uppercaseUuid = path.replace("789abc", "789ABC"); + assert.equal(safeLoginReturnTo(uppercaseUuid), uppercaseUuid); + }); + it("rejects external, encoded, recursive, malformed, and extra URL components", () => { + for (const value of [ + undefined, + null, + {}, + [path], + "https://evil.test", + "//evil.test", + "/\\evil.test", + "/login", + "/overview", + `${path}?next=https://evil.test`, + `${path}#secret`, + `${path}/../login`, + "/member-invitations/%2f%2fevil.test", + `${path}\n`, + "/member-invitations/not-a-uuid" + ]) { + assert.equal(safeLoginReturnTo(value), undefined, String(value)); + } + }); +}); diff --git a/apps/dashboard/src/components/auth/login-return.ts b/apps/dashboard/src/components/auth/login-return.ts new file mode 100644 index 000000000..3ffbe14ce --- /dev/null +++ b/apps/dashboard/src/components/auth/login-return.ts @@ -0,0 +1,9 @@ +// Only membership invitation links need a login return destination. An allowlist +// avoids external redirects, encoded separators, and recursive login redirects. +export function safeLoginReturnTo(value: unknown): string | undefined { + return typeof value === "string" && + value === value.trim() && + /^\/member-invitations\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) + ? value + : undefined; +} diff --git a/apps/dashboard/src/components/layout/AppSidebar.tsx b/apps/dashboard/src/components/layout/AppSidebar.tsx index 95964ab2d..30b83c78e 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,8 @@ import { SidebarRail } from "@/components/ui/sidebar"; import { useOnboardingStatusQuery } from "@/hooks/useApprovedCorridors"; -import { isManagedProfilesAccessDenied, useManagedProfiles } from "@/hooks/useManagedProfiles"; +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"; @@ -42,7 +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 !== "/api-keys" && item.to !== "/settings"); +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 }); @@ -50,16 +53,17 @@ 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; - 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] : []), + ...(!isImpersonating && !organization.isError && organization.data?.organization ? [TEAM_NAV_ITEM] : []), ...(isAdmin && !isImpersonating ? [ADMIN_NAV_ITEM] : []) ]; @@ -84,7 +88,7 @@ export function AppSidebar() { ))} - {managerCheckFailed && !isActingForChild && ( + {managedProfiles.isError && !isActingForChild && ( )} + {organization.isError && !isActingForChild && !isImpersonating && ( + + organization.refetch()} type="button"> + + Retry team access + + + )} diff --git a/apps/dashboard/src/components/layout/ManagedProfileBanner.tsx b/apps/dashboard/src/components/layout/ManagedProfileBanner.tsx index c5f1e9670..bea3dde93 100644 --- a/apps/dashboard/src/components/layout/ManagedProfileBanner.tsx +++ b/apps/dashboard/src/components/layout/ManagedProfileBanner.tsx @@ -1,5 +1,6 @@ import { useNavigate } from "@tanstack/react-router"; import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { clearManagedProfile, useManagedProfileSelection } from "@/stores/managed-profile.store"; @@ -27,9 +28,13 @@ export function ManagedProfileBanner() { return (
- - Acting for {label} - +
+ + Acting for {label} + + {selection.membershipRole === "manager" ? "Manager" : "Read only"} + {selection.isOwner && Owner} +
diff --git a/apps/dashboard/src/components/managed-profiles/Team.tsx b/apps/dashboard/src/components/managed-profiles/Team.tsx new file mode 100644 index 000000000..4f796483d --- /dev/null +++ b/apps/dashboard/src/components/managed-profiles/Team.tsx @@ -0,0 +1,480 @@ +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +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, + type Organization, + OrganizationService as service, + shouldRetryMembershipQuery, + type TeamMember +} from "@/services/api/managed-profile-memberships.service"; +import { useAuthStore } from "@/stores/auth.store"; + +const inviteSchema = z.object({ + email: z.string().trim().toLowerCase().max(254).email("Enter a valid email"), + role: z.enum(["manager", "read_only"]) +}); +type InviteValues = z.infer; +type Action = + | { type: "invite" } + | { type: "cancel"; invitation: MemberInvitation } + | { type: "change" | "remove"; member: TeamMember }; +const ROLE_LABEL = { manager: "Manager", read_only: "Read only" }; +const EVENT_LABEL = { + invitation_accepted: "Invitation accepted", + invitation_cancelled: "Invitation cancelled", + invitation_expired: "Invitation expired", + invited: "Invitation sent", + member_added: "Member added", + member_removed: "Member removed", + role_changed: "Role changed" +}; + +export function Team({ + actorId, + organization, + authorityPending +}: { + actorId: string; + organization: Organization; + authorityPending: boolean; +}) { + const client = useQueryClient(); + const actorEmail = useAuthStore(state => state.user?.email) + ?.trim() + .toLowerCase(); + const [memberOffset, setMemberOffset] = useState(0); + const [invitationOffset, setInvitationOffset] = useState(0); + const [cursors, setCursors] = useState>([undefined]); + const [action, setAction] = useState(null); + const queryKey = ["organization-team", actorId, organization.ownerProfileId]; + const members = useQuery({ + queryFn: ({ signal }) => service.members(organization.ownerProfileId, memberOffset, signal), + queryKey: [...queryKey, "members", memberOffset], + refetchOnWindowFocus: "always", + retry: shouldRetryMembershipQuery + }); + const invitations = useQuery({ + queryFn: ({ signal }) => service.invitations(organization.ownerProfileId, invitationOffset, signal), + queryKey: [...queryKey, "invitations", invitationOffset], + refetchOnWindowFocus: "always", + retry: shouldRetryMembershipQuery + }); + const visibleInvitations = (invitations.data?.invitations ?? []).filter( + invitation => !actorEmail || invitation.status !== "accepted" || invitation.email.trim().toLowerCase() !== actorEmail + ); + const cursor = cursors.at(-1); + const events = useQuery({ + queryFn: ({ signal }) => service.events(organization.ownerProfileId, cursor, signal), + queryKey: [...queryKey, "events", cursor], + refetchOnWindowFocus: "always", + retry: shouldRetryMembershipQuery + }); + 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 (!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"] }) + ]); + } + + return ( +
+
+
+

Team

+

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

+

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

+
+ {canManage && ( + + )} +
+ {organization.membership.role === "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 === actorId && 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.

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

    {invitation.email}

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

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

    +
    + {canManage && invitation.status === "pending" && ( + + )} +
  • + ))} +
+ {visibleInvitations.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)} + refreshTeam={refreshTeam} + /> + )} +
+ ); +} + +function TeamActionDialog({ + action, + authorityPending, + expectedOwnerProfileId, + onClose, + refreshTeam +}: { + action: Action; + authorityPending: boolean; + expectedOwnerProfileId: string; + onClose: () => void; + 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) => { + if (authorityPending) throw new Error("Organization access is being refreshed. Try again."); + switch (action.type) { + case "invite": + await service.invite(expectedOwnerProfileId, values); + break; + case "cancel": + await service.cancel(expectedOwnerProfileId, action.invitation.id); + break; + case "change": + if (!action.member.isOwner) await service.changeRole(expectedOwnerProfileId, action.member.memberProfileId, nextRole); + break; + case "remove": + 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"); + onClose(); + } + }); + return ( + !open && !mutation.isPending && onClose()} open> + + + {title} + + {action.type === "invite" + ? "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]} 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")) && ( +

{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. Only the owner can provision or delete profiles. Personal account resources are not shared. +

+ )} + {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/components/managed-profiles/managed-profile-ui.test.ts b/apps/dashboard/src/components/managed-profiles/managed-profile-ui.test.ts index 86440d7ad..41d56e135 100644 --- a/apps/dashboard/src/components/managed-profiles/managed-profile-ui.test.ts +++ b/apps/dashboard/src/components/managed-profiles/managed-profile-ui.test.ts @@ -1,19 +1,35 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { isChildModePathForbidden, toManagedProfileSelection } from "./managed-profile-ui"; +import { canAccessManagedProfiles, isChildModePathForbidden, toManagedProfileSelection } from "./managed-profile-ui"; describe("managed profile UI", () => { + it("uses explicit actor navigation flags, not actor existence or paginated rows", () => { + assert.equal(canAccessManagedProfiles(undefined), false); + for (const canProvisionManagedProfiles of [false, true]) { + for (const hasMemberships of [false, true]) { + assert.equal( + canAccessManagedProfiles({ canProvisionManagedProfiles, hasMemberships, profileId: "actor" }), + canProvisionManagedProfiles || hasMemberships + ); + } + } + }); it("builds the persisted child selection from a profile", () => { assert.deepEqual( toManagedProfileSelection({ contactEmail: "child@example.com", customerType: "business", externalSubjectId: "customer-42", - profileId: "profile-42" + membership: { isOwner: true, role: "manager" }, + policy: { allowedCorridors: ["BR"], allowedCustomerTypes: null }, + profileId: "profile-42", + status: "active" }), { customerType: "business", externalSubjectId: "customer-42", + isOwner: true, + membershipRole: "manager", targetEmail: "child@example.com", targetProfileId: "profile-42" } @@ -26,7 +42,10 @@ describe("managed profile UI", () => { contactEmail: null, customerType: "individual", externalSubjectId: "customer-7", - profileId: "profile-7" + membership: { isOwner: false, role: "read_only" }, + policy: { allowedCorridors: ["MX"], allowedCustomerTypes: ["individual"] }, + profileId: "profile-7", + status: "active" }).targetEmail, "customer-7" ); @@ -36,6 +55,10 @@ describe("managed profile UI", () => { assert.equal(isChildModePathForbidden("/settings"), true); assert.equal(isChildModePathForbidden("/admin/account-id"), true); assert.equal(isChildModePathForbidden("/managed-profiles"), true); + assert.equal(isChildModePathForbidden("/transfer"), true); + assert.equal(isChildModePathForbidden("/team"), true); + assert.equal(isChildModePathForbidden("/team/members"), true); + assert.equal(isChildModePathForbidden("/api-keys"), false); assert.equal(isChildModePathForbidden("/administration-guide"), false); assert.equal(isChildModePathForbidden("/transactions"), false); }); diff --git a/apps/dashboard/src/components/managed-profiles/managed-profile-ui.ts b/apps/dashboard/src/components/managed-profiles/managed-profile-ui.ts index 9f5d480a6..b78dc4b00 100644 --- a/apps/dashboard/src/components/managed-profiles/managed-profile-ui.ts +++ b/apps/dashboard/src/components/managed-profiles/managed-profile-ui.ts @@ -1,7 +1,11 @@ -import type { ManagedProfile } from "@/services/api/managed-profiles.service"; +import type { ManagedProfile, ManagedProfileActor } from "@/services/api/managed-profiles.service"; import type { ManagedProfileSelection } from "@/services/auth"; -export const CHILD_FORBIDDEN_PATHS = ["/api-keys", "/settings", "/admin", "/managed-profiles"] as const; +export const CHILD_FORBIDDEN_PATHS = ["/settings", "/admin", "/managed-profiles", "/transfer", "/team"] as const; + +export function canAccessManagedProfiles(actor: ManagedProfileActor | undefined): boolean { + return actor?.canProvisionManagedProfiles === true || actor?.hasMemberships === true; +} export function isChildModePathForbidden(pathname: string): boolean { return CHILD_FORBIDDEN_PATHS.some(path => pathname === path || pathname.startsWith(`${path}/`)); @@ -11,6 +15,8 @@ export function toManagedProfileSelection(profile: ManagedProfile): Omit void; verificationReadOnly?: boolean; @@ -39,7 +40,13 @@ const BAR_TONE: Record = { started: "bg-primary" }; -export function CorridorCard({ account, corridor, onStart, verificationReadOnly = false }: CorridorCardProps) { +export function CorridorCard({ + account, + canMutatePayoutAccounts = true, + corridor, + onStart, + verificationReadOnly = false +}: CorridorCardProps) { const kind = onboardingKindFor(corridor, account.type); const available = isOnboardingAvailable(corridor, kind); const onboarding = account.onboardings[corridor.id]; @@ -108,6 +115,7 @@ export function CorridorCard({ account, corridor, onStart, verificationReadOnly {managesPayoutAccounts ? ( void; } -export function PayoutAccountsSection({ accounts, corridorId, error, isLoading, refetch }: PayoutAccountsSectionProps) { +export function PayoutAccountsSection({ + accounts, + canMutate = true, + corridorId, + error, + isLoading, + refetch +}: PayoutAccountsSectionProps) { const [open, setOpen] = useState(false); const [view, setView] = useState("list"); + useEffect(() => { + if (!canMutate) setOpen(false); + }, [canMutate]); + function show(nextView: FiatAccountDialogView) { setView(nextView); setOpen(true); @@ -49,12 +61,15 @@ export function PayoutAccountsSection({ accounts, corridorId, error, isLoading, {savedAccounts.length === 0 ? ( <>

- Add a pay-out account to enable reception of money through pay-outs. Pay-ins and third-party payments work without - one. + {canMutate + ? "Add a pay-out account to enable reception of money through pay-outs. Pay-ins and third-party payments work without one." + : "No pay-out accounts are registered. This membership has read-only access."}

- + {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 ( + + ); + 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/_app/transactions.tsx b/apps/dashboard/src/routes/_app/transactions.tsx index 7ac7433ae..94a1f92a9 100644 --- a/apps/dashboard/src/routes/_app/transactions.tsx +++ b/apps/dashboard/src/routes/_app/transactions.tsx @@ -11,6 +11,7 @@ import { useRecipients } from "@/hooks/useRecipients"; import { useTransactions } from "@/hooks/useTransactions"; import { popIn } from "@/lib/motion"; import { transferActor } from "@/machines/transferActor"; +import { useManagedProfileSelection } from "@/stores/managed-profile.store"; export const Route = createFileRoute("/_app/transactions")({ component: TransactionsPage @@ -18,9 +19,11 @@ export const Route = createFileRoute("/_app/transactions")({ function TransactionsPage() { const account = useActiveAccount(); + const managedProfile = useManagedProfileSelection(); const { transactions } = useTransactions(account); const { recipients } = useRecipients(account); const resumableRamp = useSelector(transferActor, snapshot => + !managedProfile && snapshot.matches("AwaitingPayment") && snapshot.context.meta?.ownerProfileId === snapshot.context.activeOwnerProfileId && snapshot.context.meta.accountId === account?.id @@ -83,7 +86,7 @@ function TransactionsPage() { : "Start a pay-in or approve a pay-out account to create your first transaction."}

- {hasApprovedRecipient ? ( + {managedProfile ? null : hasApprovedRecipient ? ( + + ) : selection ? ( + <> +

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

+ + + ) : !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 logout = useAuthStore(state => state.logout); + const organization = useOrganization(); + 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), + onSettled: async () => { + await Promise.all([ + client.invalidateQueries({ queryKey: [MANAGED_PROFILES_QUERY_KEY] }), + client.invalidateQueries({ queryKey: ["managed-profile-bootstrap"] }), + 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 ( + <> +

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.

+ + + ); + } + + 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" : "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.

+ )} + + + )} + + ); + } + 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.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 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()}

+ + + ); +} diff --git a/apps/dashboard/src/services/api/api-client.test.ts b/apps/dashboard/src/services/api/api-client.test.ts index 74212794e..c3dbaacf6 100644 --- a/apps/dashboard/src/services/api/api-client.test.ts +++ b/apps/dashboard/src/services/api/api-client.test.ts @@ -1,13 +1,14 @@ import assert from "node:assert/strict"; import { after, beforeEach, describe, it } from "node:test"; import { AuthService } from "@/services/auth"; -import { apiClient, isApiError, setManagedProfileAccessDeniedHandler } from "./api-client"; +import { apiClient, isApiError } from "./api-client"; const originalFetch = globalThis.fetch; const originalGetAcceptedImpersonationSessionSnapshot = AuthService.getAcceptedImpersonationSessionSnapshot; const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); const values = new Map(); +const MANAGER_MEMBERSHIP = { isOwner: true, membershipRole: "manager" as const }; Object.defineProperty(globalThis, "localStorage", { configurable: true, @@ -28,13 +29,11 @@ beforeEach(() => { values.clear(); AuthService.initializeAcceptedIdentitySnapshots(); AuthService.getAcceptedImpersonationSessionSnapshot = originalGetAcceptedImpersonationSessionSnapshot; - setManagedProfileAccessDeniedHandler(undefined); }); after(() => { globalThis.fetch = originalFetch; AuthService.getAcceptedImpersonationSessionSnapshot = originalGetAcceptedImpersonationSessionSnapshot; - setManagedProfileAccessDeniedHandler(undefined); if (originalLocalStorage) { Object.defineProperty(globalThis, "localStorage", originalLocalStorage); } else { @@ -78,6 +77,7 @@ describe("apiFetch while impersonating", () => { it("does not let caller headers override trusted identity headers", async () => { AuthService.storeManagedProfileSelection({ + ...MANAGER_MEMBERSHIP, customerType: "business", externalSubjectId: "merchant-42", managerProfileId: "customer-1", @@ -195,6 +195,7 @@ describe("apiFetch without impersonation", () => { it("adds a valid managed profile only when the request explicitly opts in", async () => { AuthService.storeManagedProfileSelection({ + ...MANAGER_MEMBERSHIP, customerType: "business", externalSubjectId: "merchant-42", managerProfileId: "user-1", @@ -216,6 +217,7 @@ describe("apiFetch without impersonation", () => { it("preserves the captured managed profile header on a 401 refresh retry", async () => { AuthService.storeManagedProfileSelection({ + ...MANAGER_MEMBERSHIP, customerType: "business", externalSubjectId: "merchant-42", managerProfileId: "user-1", @@ -226,6 +228,7 @@ describe("apiFetch without impersonation", () => { globalThis.fetch = (async (input, init) => { if (String(input).includes("/auth/refresh")) { AuthService.storeManagedProfileSelection({ + ...MANAGER_MEMBERSHIP, customerType: "business", externalSubjectId: "merchant-43", managerProfileId: "user-1", @@ -251,6 +254,7 @@ describe("apiFetch without impersonation", () => { it("uses the tab-accepted selection instead of an unaccepted storage change", async () => { AuthService.storeManagedProfileSelection({ + ...MANAGER_MEMBERSHIP, customerType: "business", externalSubjectId: "merchant-42", managerProfileId: "user-1", @@ -260,6 +264,7 @@ describe("apiFetch without impersonation", () => { values.set( AuthService.MANAGED_PROFILE_STORAGE_KEY, JSON.stringify({ + ...MANAGER_MEMBERSHIP, customerType: "business", externalSubjectId: "merchant-43", managerProfileId: "user-1", @@ -278,8 +283,9 @@ describe("apiFetch without impersonation", () => { assert.equal(managedProfileId, "child-1"); }); - it("clears only the stale selection on managed access denial and never retries without the header", async () => { + it("keeps child mode when a delegated feature request is denied", async () => { AuthService.storeManagedProfileSelection({ + ...MANAGER_MEMBERSHIP, customerType: "business", externalSubjectId: "merchant-42", managerProfileId: "user-1", @@ -299,34 +305,26 @@ describe("apiFetch without impersonation", () => { await assert.rejects(() => apiClient.get("/delegated", { managedProfile: true }), error => isApiError(error) && error.status === 403); assert.equal(calls, 1); - assert.equal(AuthService.getManagedProfileSelection(), null); + assert.equal(AuthService.getManagedProfileSelection()?.targetProfileId, "child-1"); }); - it("runs access-denied handling while the stale child selection is still active", async () => { + it("does not clear child mode from membership errors outside bootstrap", async () => { AuthService.storeManagedProfileSelection({ + ...MANAGER_MEMBERSHIP, customerType: "business", externalSubjectId: "merchant-42", managerProfileId: "user-1", targetEmail: "child@example.com", targetProfileId: "child-1" }); - const selectionSnapshot = AuthService.getAcceptedManagedProfileSelectionSnapshot(); - let handled = false; - setManagedProfileAccessDeniedHandler(snapshot => { - assert.equal(snapshot, selectionSnapshot); - assert.equal(AuthService.getManagedProfileSelection()?.targetProfileId, "child-1"); - handled = AuthService.clearManagedProfileSelection(snapshot); - return handled; - }); globalThis.fetch = (async () => - new Response(JSON.stringify({ error: { code: "MANAGED_PROFILE_ACCESS_DENIED", message: "denied" } }), { + new Response(JSON.stringify({ error: { code: "MANAGED_PROFILE_MEMBERSHIP_INVALID", message: "denied" } }), { headers: { "Content-Type": "application/json" }, status: 403 })) as typeof fetch; await assert.rejects(() => apiClient.get("/delegated", { managedProfile: true }), error => isApiError(error)); - assert.equal(handled, true); - assert.equal(AuthService.getManagedProfileSelection(), null); + assert.equal(AuthService.getManagedProfileSelection()?.targetProfileId, "child-1"); }); }); diff --git a/apps/dashboard/src/services/api/api-client.ts b/apps/dashboard/src/services/api/api-client.ts index 8276c9339..3ecf6982e 100644 --- a/apps/dashboard/src/services/api/api-client.ts +++ b/apps/dashboard/src/services/api/api-client.ts @@ -5,14 +5,6 @@ function refreshTokenOnce(): Promise { return AuthService.refreshAccessToken().catch(() => null); } -let managedProfileAccessDeniedHandler: ((selectionSnapshot: string) => boolean | Promise) | undefined; - -export function setManagedProfileAccessDeniedHandler( - handler: ((selectionSnapshot: string) => boolean | Promise) | undefined -): void { - managedProfileAccessDeniedHandler = handler; -} - export class ApiError extends Error { status: number; data: { @@ -131,11 +123,6 @@ async function apiFetch( errorData.message ?? response.statusText; const code = errorData.code ?? (typeof errorData.error === "object" ? errorData.error.code : undefined); - if (managedProfileId && response.status === 403 && code === "MANAGED_PROFILE_ACCESS_DENIED" && selectionSnapshot) { - if (!(await managedProfileAccessDeniedHandler?.(selectionSnapshot))) { - AuthService.clearManagedProfileSelection(selectionSnapshot); - } - } throw new ApiError( response.status, { @@ -158,7 +145,7 @@ export const apiClient = { get: (url: string, config?: RequestConfig) => apiFetch("GET", url, { managedProfile: config?.managedProfile, params: config?.params, signal: config?.signal }), patch: (url: string, data?: unknown, config?: RequestConfig) => - apiFetch("PATCH", url, { data, managedProfile: config?.managedProfile }), + apiFetch("PATCH", url, { data, managedProfile: config?.managedProfile, params: config?.params }), post: (url: string, data?: unknown, config?: RequestConfig) => apiFetch("POST", url, { data, diff --git a/apps/dashboard/src/services/api/api-credentials.service.ts b/apps/dashboard/src/services/api/api-credentials.service.ts index f8cee6ce2..349a4eba3 100644 --- a/apps/dashboard/src/services/api/api-credentials.service.ts +++ b/apps/dashboard/src/services/api/api-credentials.service.ts @@ -30,7 +30,20 @@ interface ListApiCredentialsResponse { } export const ApiCredentialsService = { - create: (request: CreateApiCredentialRequest) => apiClient.post("/api-credentials", request), - list: (signal?: AbortSignal) => apiClient.get("/api-credentials", { signal }), - revoke: (credentialId: string) => apiClient.delete(`/api-credentials/${credentialId}`) + create: (request: CreateApiCredentialRequest, managedProfileId?: string) => + apiClient.post( + managedProfileId ? `/managed-profiles/${managedProfileId}/api-credentials` : "/api-credentials", + request + ), + list: (signal?: AbortSignal, managedProfileId?: string) => + apiClient.get( + managedProfileId ? `/managed-profiles/${managedProfileId}/api-credentials` : "/api-credentials", + { signal } + ), + revoke: (credentialId: string, managedProfileId?: string) => + apiClient.delete( + managedProfileId + ? `/managed-profiles/${managedProfileId}/api-credentials/${credentialId}` + : `/api-credentials/${credentialId}` + ) }; diff --git a/apps/dashboard/src/services/api/avenia.service.test.ts b/apps/dashboard/src/services/api/avenia.service.test.ts index 2bd17eaa3..3059f7dd2 100644 --- a/apps/dashboard/src/services/api/avenia.service.test.ts +++ b/apps/dashboard/src/services/api/avenia.service.test.ts @@ -34,7 +34,9 @@ beforeEach(() => { AuthService.storeManagedProfileSelection({ customerType: "business", externalSubjectId: "business-1", + isOwner: true, managerProfileId: "manager-1", + membershipRole: "manager", targetEmail: "child@example.com", targetProfileId: "child-1" }); diff --git a/apps/dashboard/src/services/api/managed-profile-memberships.service.test.ts b/apps/dashboard/src/services/api/managed-profile-memberships.service.test.ts new file mode 100644 index 000000000..71f394fed --- /dev/null +++ b/apps/dashboard/src/services/api/managed-profile-memberships.service.test.ts @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import { after, beforeEach, describe, it } from "node:test"; +import { AuthService } from "@/services/auth"; +import { ApiError } from "./api-client"; +import { + OrganizationService as service, + shouldRetryMembershipQuery +} from "./managed-profile-memberships.service"; +import { ManagedProfilesService } from "./managed-profiles.service"; + +const originalFetch = globalThis.fetch; +const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); +const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); +const values = new Map(); +const OWNER_ID = "11111111-1111-4111-8111-111111111111"; +Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value) + } +}); +Object.defineProperty(globalThis, "window", { configurable: true, value: { location: { origin: "http://localhost" } } }); + +const requests: Array<{ url: URL; method: string; headers: Headers; body: unknown }> = []; +beforeEach(() => { + values.clear(); + requests.length = 0; + AuthService.initializeAcceptedIdentitySnapshots(); + AuthService.storeTokens({ + accessToken: "human-token", + refreshToken: "refresh", + userId: "human", + userEmail: "human@example.test" + }); + AuthService.storeManagedProfileSelection({ + customerType: "business", + externalSubjectId: "child", + isOwner: false, + managerProfileId: "human", + membershipRole: "manager", + targetEmail: "child@example.test", + targetProfileId: "child" + }); + globalThis.fetch = (async (input, init) => { + requests.push({ + url: new URL(String(input)), + method: init?.method ?? "GET", + headers: new Headers(init?.headers), + body: init?.body ? JSON.parse(String(init.body)) : undefined + }); + return init?.method === "DELETE" ? new Response(null, { status: 204 }) : Response.json({}); + }) as typeof fetch; +}); +after(() => { + globalThis.fetch = originalFetch; + if (originalLocalStorage) Object.defineProperty(globalThis, "localStorage", originalLocalStorage); + else Reflect.deleteProperty(globalThis, "localStorage"); + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow); + else Reflect.deleteProperty(globalThis, "window"); +}); + +describe("membership wire contract", () => { + it("opts only bootstrap detail reads into child selection, not invitation open reads", async () => { + await ManagedProfilesService.get("child", { bootstrap: true }); + await ManagedProfilesService.get("child"); + const [bootstrap, openProfile] = requests; + assert.ok(bootstrap && openProfile); + assert.equal(bootstrap.headers.get("x-managed-profile-id"), "child"); + assert.equal(openProfile.headers.get("x-managed-profile-id"), null); + assert.equal(bootstrap.headers.get("authorization"), "Bearer human-token"); + assert.equal(openProfile.headers.get("authorization"), "Bearer human-token"); + }); + it("uses human bearer only, exact DTO bodies, and both pagination schemes", async () => { + await service.members(OWNER_ID, 20); + await service.invitations(OWNER_ID, 40); + await service.events(OWNER_ID, "event-cursor"); + await service.invite(OWNER_ID, { email: "invitee@example.test", role: "read_only" }); + await service.changeRole(OWNER_ID, "member-profile", "manager"); + await service.remove(OWNER_ID, "member-profile"); + await service.cancel(OWNER_ID, "invite"); + await service.preview("invite"); + await service.accept("invite"); + await service.get(); + assert.deepEqual(requests.map(request => `${request.method} ${request.url.pathname}`), [ + "GET /v1/organization/members", + "GET /v1/organization/member-invitations", + "GET /v1/organization/member-events", + "POST /v1/organization/member-invitations", + "PATCH /v1/organization/members/member-profile", + "DELETE /v1/organization/members/member-profile", + "DELETE /v1/organization/member-invitations/invite", + "GET /v1/organization-member-invitations/invite", + "POST /v1/organization-member-invitations/invite/accept", + "GET /v1/organization" + ]); + const [members, invitations, events, invite, change] = requests; + assert.ok(members && invitations && events && invite && change); + assert.deepEqual(Object.fromEntries(members.url.searchParams), { expectedOwnerProfileId: OWNER_ID, limit: "20", offset: "20" }); + assert.deepEqual(Object.fromEntries(invitations.url.searchParams), { expectedOwnerProfileId: OWNER_ID, limit: "20", offset: "40" }); + assert.deepEqual(Object.fromEntries(events.url.searchParams), { cursor: "event-cursor", expectedOwnerProfileId: OWNER_ID, limit: "20" }); + assert.deepEqual(invite.body, { email: "invitee@example.test", role: "read_only" }); + assert.deepEqual(change.body, { role: "manager" }); + for (const request of requests) { + assert.deepEqual(request.url.searchParams.getAll("expectedOwnerProfileId"), + request.url.pathname.startsWith("/v1/organization/") ? [OWNER_ID] : []); + assert.equal(request.headers.get("authorization"), "Bearer human-token"); + assert.equal(request.headers.get("x-managed-profile-id"), null); + assert.equal(request.headers.get("x-api-key"), null); + assert.equal(request.headers.get("x-public-key"), null); + } + }); + + it("blocks every endpoint during impersonation without issuing a request", async () => { + AuthService.storeImpersonationSession({ + expiresAt: new Date(Date.now() + 60000).toISOString(), + sessionId: "imp", + targetEmail: "other@example.test", + targetProfileId: "other", + token: "vtx_imp_token" + }); + for (const call of [ + () => service.get(), + () => service.members(OWNER_ID), + () => service.invitations(OWNER_ID), + () => service.events(OWNER_ID), + () => service.invite(OWNER_ID, { email: "a@example.test", role: "manager" }), + () => service.changeRole(OWNER_ID, "member", "read_only"), + () => service.remove(OWNER_ID, "member"), + () => service.cancel(OWNER_ID, "invite"), + () => service.preview("invite"), + () => service.accept("invite") + ]) await assert.rejects(call, (error: unknown) => error instanceof ApiError && error.status === 403); + assert.equal(requests.length, 0); + }); + + it("does not retry denials and terminal invitation conflicts", () => { + for (const status of [400, 401, 403, 404, 409, 429]) { + assert.equal(shouldRetryMembershipQuery(0, new ApiError(status, {}, "denied")), false); + } + assert.equal(shouldRetryMembershipQuery(0, new ApiError(500, {}, "failed")), true); + assert.equal(shouldRetryMembershipQuery(2, new Error("offline")), false); + }); + + it("does not fall back to credentials without a login session", async () => { + values.clear(); + for (const call of [ + () => service.get(), + () => service.members(OWNER_ID), + () => service.invitations(OWNER_ID), + () => service.events(OWNER_ID), + () => service.invite(OWNER_ID, { email: "a@example.test", role: "manager" }), + () => service.changeRole(OWNER_ID, "member", "read_only"), + () => service.remove(OWNER_ID, "member"), + () => service.cancel(OWNER_ID, "invite") + ]) await assert.rejects(call, ApiError); + await assert.rejects(() => service.preview("invite"), ApiError); + await assert.rejects(() => service.accept("invite"), ApiError); + assert.equal(requests.length, 0); + }); + + it("preserves owner preconditions as one encoded query value on every scoped request", async () => { + const owner = `${OWNER_ID}&expectedOwnerProfileId=another`; + await service.members(owner); + await service.invitations(owner); + await service.events(owner); + await service.invite(owner, { email: "a@example.test", role: "manager" }); + await service.changeRole(owner, "member", "read_only"); + await service.remove(owner, "member"); + await service.cancel(owner, "invite"); + assert.equal(requests.length, 7); + for (const request of requests) assert.deepEqual(request.url.searchParams.getAll("expectedOwnerProfileId"), [owner]); + }); + + it("surfaces context changes without retrying or retargeting any scoped request", async () => { + const recordRequest = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + await recordRequest(input, init); + return Response.json({ error: { code: "ORGANIZATION_CONTEXT_CHANGED", message: "Organization changed" } }, { status: 409 }); + }) as typeof fetch; + for (const call of [ + () => service.members(OWNER_ID), + () => service.invitations(OWNER_ID), + () => service.events(OWNER_ID), + () => service.invite(OWNER_ID, { email: "a@example.test", role: "manager" }), + () => service.changeRole(OWNER_ID, "member", "read_only"), + () => service.remove(OWNER_ID, "member"), + () => service.cancel(OWNER_ID, "invite") + ]) await assert.rejects(call, (error: unknown) => { + assert.ok(error instanceof ApiError); + assert.equal(error.data.code, "ORGANIZATION_CONTEXT_CHANGED"); + assert.equal(shouldRetryMembershipQuery(0, error), false); + return true; + }); + assert.equal(requests.length, 7); + for (const request of requests) assert.equal(request.url.searchParams.get("expectedOwnerProfileId"), OWNER_ID); + }); +}); diff --git a/apps/dashboard/src/services/api/managed-profile-memberships.service.ts b/apps/dashboard/src/services/api/managed-profile-memberships.service.ts new file mode 100644 index 000000000..8d3605192 --- /dev/null +++ b/apps/dashboard/src/services/api/managed-profile-memberships.service.ts @@ -0,0 +1,141 @@ +import { AuthService } from "../auth"; +import { ApiError, apiClient, isApiError } from "./api-client"; +import type { ManagedProfileMembershipRole } from "./managed-profiles.service"; + +export interface Organization { + ownerProfileId: string; + ownerEmail: string | null; + membership: { role: ManagedProfileMembershipRole; isOwner: boolean }; +} + +export interface TeamMember { + createdAt: string; + id: string; + isOwner: boolean; + memberProfileId: string; + role: ManagedProfileMembershipRole; + updatedAt: string; + email: string | null; +} + +export interface MemberInvitation { + acceptedAt: string | null; + cancelledAt: string | null; + createdAt: string; + email: string; + expiredAt: string | null; + expiresAt: string; + id: string; + invitedByProfileId: string; + ownerProfileId: string; + role: ManagedProfileMembershipRole; + status: "pending" | "accepted" | "cancelled" | "expired"; +} + +export interface MemberEvent { + action: + | "invited" + | "invitation_cancelled" + | "invitation_expired" + | "invitation_accepted" + | "member_added" + | "role_changed" + | "member_removed"; + actorProfileId: string | null; + createdAt: string; + id: string; + invitationId: string | null; + memberProfileId: string | null; + previousRole: ManagedProfileMembershipRole | null; + role: ManagedProfileMembershipRole | null; +} + +export interface InvitationPreview { + invitation: MemberInvitation; + inviter: { email: string | null; profileId: string }; + organization: Pick; +} + +export interface OffsetPagination { + limit: number; + offset: number; + total: number; +} + +// Membership APIs are human-session-only, including reads. Never use a child secret +// or silently fall back to the operator's bearer while impersonating. +function requireSession(): void { + if (!AuthService.getTokens() || AuthService.getAcceptedImpersonationSessionSnapshot()) { + throw new ApiError(403, { code: "MANAGED_PROFILE_ACCESS_DENIED" }, "A personal login session is required."); + } +} + +export function shouldRetryMembershipQuery(failureCount: number, error: unknown): boolean { + return !(isApiError(error) && error.status >= 400 && error.status < 500) && failureCount < 2; +} + +export const OrganizationService = { + async accept(invitationId: string) { + requireSession(); + return apiClient.post<{ ownerProfileId: string; member: Omit }>( + `/organization-member-invitations/${encodeURIComponent(invitationId)}/accept` + ); + }, + async cancel(expectedOwnerProfileId: string, invitationId: string) { + requireSession(); + return apiClient.delete(`/organization/member-invitations/${encodeURIComponent(invitationId)}`, { + params: { expectedOwnerProfileId } + }); + }, + async changeRole(expectedOwnerProfileId: string, memberProfileId: string, role: ManagedProfileMembershipRole) { + requireSession(); + return apiClient.patch<{ member: Omit }>( + `/organization/members/${encodeURIComponent(memberProfileId)}`, + { role }, + { params: { expectedOwnerProfileId } } + ); + }, + async events(expectedOwnerProfileId: string, cursor?: string, signal?: AbortSignal) { + requireSession(); + return apiClient.get<{ events: MemberEvent[]; pagination: { limit: number; nextCursor: string | null } }>( + "/organization/member-events", + { params: { cursor, expectedOwnerProfileId, limit: 20 }, signal } + ); + }, + async get(signal?: AbortSignal) { + requireSession(); + return apiClient.get<{ organization: Organization | null }>("/organization", { signal }); + }, + async invitations(expectedOwnerProfileId: string, offset = 0, signal?: AbortSignal) { + requireSession(); + return apiClient.get<{ invitations: MemberInvitation[]; pagination: OffsetPagination }>( + "/organization/member-invitations", + { params: { expectedOwnerProfileId, limit: 20, offset }, signal } + ); + }, + async invite(expectedOwnerProfileId: string, input: { email: string; role: ManagedProfileMembershipRole }) { + requireSession(); + return apiClient.post<{ invitation: MemberInvitation }>("/organization/member-invitations", input, { + params: { expectedOwnerProfileId } + }); + }, + async members(expectedOwnerProfileId: string, offset = 0, signal?: AbortSignal) { + requireSession(); + return apiClient.get<{ members: TeamMember[]; pagination: OffsetPagination }>("/organization/members", { + params: { expectedOwnerProfileId, limit: 20, offset }, + signal + }); + }, + async preview(invitationId: string, signal?: AbortSignal) { + requireSession(); + return apiClient.get(`/organization-member-invitations/${encodeURIComponent(invitationId)}`, { + signal + }); + }, + async remove(expectedOwnerProfileId: string, memberProfileId: string) { + requireSession(); + return apiClient.delete(`/organization/members/${encodeURIComponent(memberProfileId)}`, { + params: { expectedOwnerProfileId } + }); + } +}; diff --git a/apps/dashboard/src/services/api/managed-profiles.service.ts b/apps/dashboard/src/services/api/managed-profiles.service.ts index 9338107dc..2f10f9932 100644 --- a/apps/dashboard/src/services/api/managed-profiles.service.ts +++ b/apps/dashboard/src/services/api/managed-profiles.service.ts @@ -2,22 +2,36 @@ import type { CorridorId } from "@/domain/types"; import { apiClient } from "./api-client"; export type ManagedProfileCustomerType = "business" | "individual"; +export type ManagedProfileMembershipRole = "manager" | "read_only"; -export interface ManagedProfileManager { +export interface ManagedProfileActor { + canProvisionManagedProfiles: boolean; + hasMemberships: boolean; + profileId: string; +} + +export interface ManagedProfilePolicy { allowedCorridors: CorridorId[]; allowedCustomerTypes: ManagedProfileCustomerType[] | null; - profileId: string; +} + +export interface ManagedProfileMembership { + isOwner: boolean; + role: ManagedProfileMembershipRole; } export interface ManagedProfile { contactEmail: string | null; customerType: ManagedProfileCustomerType; externalSubjectId: string; + membership: ManagedProfileMembership; + policy: ManagedProfilePolicy; profileId: string; + status: "active" | "deleted"; } export interface ManagedProfilesResponse { - manager: ManagedProfileManager; + actor: ManagedProfileActor; managedProfiles: ManagedProfile[]; pagination: { limit: number; @@ -26,12 +40,24 @@ export interface ManagedProfilesResponse { }; } +export interface ManagedProfileResponse { + actor: ManagedProfileActor; + managedProfile: ManagedProfile; +} + export interface ListManagedProfilesParams extends Record { limit?: number; offset?: number; } export const ManagedProfilesService = { + get(profileId: string, options: { bootstrap?: boolean; signal?: AbortSignal } = {}): Promise { + // The API reserves membership-invalid errors for selected-child bootstrap intent. + return apiClient.get(`/managed-profiles/${profileId}`, { + managedProfile: options.bootstrap === true, + signal: options.signal + }); + }, list(params: ListManagedProfilesParams = {}, signal?: AbortSignal): Promise { return apiClient.get("/managed-profiles", { params, signal }); } diff --git a/apps/dashboard/src/services/auth.test.ts b/apps/dashboard/src/services/auth.test.ts index 74f56bbda..d5b02937a 100644 --- a/apps/dashboard/src/services/auth.test.ts +++ b/apps/dashboard/src/services/auth.test.ts @@ -360,7 +360,9 @@ describe("AuthService managed profile selection", () => { AuthService.storeManagedProfileSelection({ customerType: "business", externalSubjectId: "merchant-42", + isOwner: true, managerProfileId: "user-1", + membershipRole: "manager", targetEmail: "child@example.com", targetProfileId: "child-1", }); @@ -382,7 +384,9 @@ describe("AuthService managed profile selection", () => { AuthService.storeManagedProfileSelection({ customerType: "individual", externalSubjectId: "first", + isOwner: false, managerProfileId: "user-1", + membershipRole: "read_only", targetEmail: "first@example.com", targetProfileId: "child-1", }); @@ -390,7 +394,9 @@ describe("AuthService managed profile selection", () => { AuthService.storeManagedProfileSelection({ customerType: "individual", externalSubjectId: "second", + isOwner: true, managerProfileId: "user-1", + membershipRole: "manager", targetEmail: "second@example.com", targetProfileId: "child-2", }); @@ -403,11 +409,30 @@ describe("AuthService managed profile selection", () => { AuthService.storeManagedProfileSelection({ customerType: "business", externalSubjectId: "merchant-42", + isOwner: true, managerProfileId: "user-1", + membershipRole: "manager", targetEmail: "child@example.com", targetProfileId: "child-1" }); assert.equal(AuthService.getEffectiveProfileId(), "child-1"); }); + + it("clears legacy selections that do not carry membership authority", () => { + localStorage.setItem( + AuthService.MANAGED_PROFILE_STORAGE_KEY, + JSON.stringify({ + customerType: "business", + externalSubjectId: "merchant-42", + managerProfileId: "user-1", + targetEmail: "child@example.com", + targetProfileId: "child-1" + }) + ); + AuthService.initializeAcceptedIdentitySnapshots(); + + assert.equal(AuthService.getManagedProfileSelection(), null); + assert.equal(localStorage.getItem(AuthService.MANAGED_PROFILE_STORAGE_KEY), null); + }); }); diff --git a/apps/dashboard/src/services/auth.ts b/apps/dashboard/src/services/auth.ts index ba14caea2..b48272bb3 100644 --- a/apps/dashboard/src/services/auth.ts +++ b/apps/dashboard/src/services/auth.ts @@ -17,7 +17,9 @@ export interface ImpersonationSession { } export interface ManagedProfileSelection { + isOwner: boolean; managerProfileId: string; + membershipRole: "manager" | "read_only"; targetProfileId: string; targetEmail: string; externalSubjectId: string; @@ -253,7 +255,12 @@ export class AuthService { } static getManagedProfileSelectionSnapshot(): string | null { - return localStorage.getItem(this.MANAGED_PROFILE_STORAGE_KEY); + const snapshot = localStorage.getItem(this.MANAGED_PROFILE_STORAGE_KEY); + if (snapshot !== null && this.parseManagedProfileSelectionSnapshot(snapshot) === null) { + localStorage.removeItem(this.MANAGED_PROFILE_STORAGE_KEY); + return null; + } + return snapshot; } static parseManagedProfileSelectionSnapshot(snapshot: string | null): ManagedProfileSelection | null { @@ -262,6 +269,8 @@ export class AuthService { const parsed = JSON.parse(snapshot) as Partial; if ( typeof parsed.managerProfileId !== "string" || + (parsed.membershipRole !== "manager" && parsed.membershipRole !== "read_only") || + typeof parsed.isOwner !== "boolean" || typeof parsed.targetProfileId !== "string" || typeof parsed.targetEmail !== "string" || typeof parsed.externalSubjectId !== "string" || diff --git a/apps/dashboard/src/stores/managed-profile.store.test.ts b/apps/dashboard/src/stores/managed-profile.store.test.ts index 3c3b739c6..b76fb98cd 100644 --- a/apps/dashboard/src/stores/managed-profile.store.test.ts +++ b/apps/dashboard/src/stores/managed-profile.store.test.ts @@ -14,11 +14,17 @@ Object.defineProperty(globalThis, "localStorage", { let accountStateClears = 0; let identityChangeAllowed = true; let activatedOwner: string | null = null; +const MANAGER_MEMBERSHIP = { isOwner: true, membershipRole: "manager" as const }; const { AuthService } = await import("@/services/auth"); const { enterImpersonation } = await import("./impersonation.store"); -const { applyStoredManagedProfileForTests, clearManagedProfile, clearManagedProfileSelection, selectManagedProfile } = - await import("./managed-profile.store"); +const { + applyStoredManagedProfileForTests, + clearManagedProfile, + clearManagedProfileSelection, + refreshManagedProfileSelection, + selectManagedProfile +} = await import("./managed-profile.store"); function configureIdentityEffects(): void { AuthService.configureIdentityTransitionEffects({ activateTransferOwner: (ownerProfileId: string) => { @@ -56,6 +62,7 @@ describe("managed profile transitions", () => { it("atomically binds selection to the current bearer and activates its transfer owner", () => { assert.equal( selectManagedProfile({ + ...MANAGER_MEMBERSHIP, customerType: "business", externalSubjectId: "merchant-42", targetEmail: "child@example.com", @@ -82,6 +89,7 @@ describe("managed profile transitions", () => { ); assert.equal( selectManagedProfile({ + ...MANAGER_MEMBERSHIP, customerType: "individual", externalSubjectId: "customer-42", targetEmail: "child@example.com", @@ -99,6 +107,7 @@ describe("managed profile transitions", () => { assert.equal( selectManagedProfile({ + ...MANAGER_MEMBERSHIP, customerType: "business", externalSubjectId: "merchant-42", targetEmail: "child@example.com", @@ -112,6 +121,7 @@ describe("managed profile transitions", () => { it("adopts a cross-tab selection once and switches the transfer owner", () => { const selection = { + ...MANAGER_MEMBERSHIP, customerType: "individual", externalSubjectId: "person-42", managerProfileId: "manager-1", @@ -129,6 +139,7 @@ describe("managed profile transitions", () => { it("restores the accepted selection when a cross-tab change is blocked", () => { selectManagedProfile({ + ...MANAGER_MEMBERSHIP, customerType: "business", externalSubjectId: "merchant-1", targetEmail: "first@example.com", @@ -137,6 +148,7 @@ describe("managed profile transitions", () => { accountStateClears = 0; identityChangeAllowed = false; const rejected = { + ...MANAGER_MEMBERSHIP, customerType: "business", externalSubjectId: "merchant-2", managerProfileId: "manager-1", @@ -158,6 +170,7 @@ describe("managed profile transitions", () => { it("returns to the bearer identity when child mode stops", () => { selectManagedProfile({ + ...MANAGER_MEMBERSHIP, customerType: "business", externalSubjectId: "merchant-42", targetEmail: "child@example.com", @@ -174,6 +187,7 @@ describe("managed profile transitions", () => { it("compare-and-clears the expected denied selection", () => { selectManagedProfile({ + ...MANAGER_MEMBERSHIP, customerType: "business", externalSubjectId: "merchant-42", targetEmail: "child@example.com", @@ -191,6 +205,7 @@ describe("managed profile transitions", () => { it("keeps the accepted child identity when clearing a denied selection is blocked", () => { selectManagedProfile({ + ...MANAGER_MEMBERSHIP, customerType: "business", externalSubjectId: "merchant-42", targetEmail: "child@example.com", @@ -208,4 +223,68 @@ describe("managed profile transitions", () => { assert.equal(accountStateClears, 0); assert.equal(activatedOwner, null); }); + + it("refreshes metadata for the same child without resetting account identity", () => { + selectManagedProfile({ + ...MANAGER_MEMBERSHIP, + customerType: "business", + externalSubjectId: "merchant-42", + targetEmail: "old@example.com", + targetProfileId: "child-1" + }); + const snapshot = AuthService.getAcceptedManagedProfileSelectionSnapshot(); + accountStateClears = 0; + activatedOwner = null; + + assert.equal( + refreshManagedProfileSelection( + { + customerType: "business", + externalSubjectId: "merchant-42", + isOwner: false, + membershipRole: "read_only", + targetEmail: "new@example.com", + targetProfileId: "child-1" + }, + snapshot ?? "" + ), + true + ); + + assert.equal(AuthService.getManagedProfileSelection()?.targetEmail, "new@example.com"); + assert.equal(AuthService.getManagedProfileSelection()?.membershipRole, "read_only"); + assert.equal(accountStateClears, 0); + assert.equal(activatedOwner, null); + }); + + it("accepts a same-child role downgrade while identity switching is blocked", () => { + selectManagedProfile({ + ...MANAGER_MEMBERSHIP, + customerType: "business", + externalSubjectId: "merchant-42", + targetEmail: "child@example.com", + targetProfileId: "child-1" + }); + accountStateClears = 0; + activatedOwner = null; + identityChangeAllowed = false; + localStorage.setItem( + AuthService.MANAGED_PROFILE_STORAGE_KEY, + JSON.stringify({ + customerType: "business", + externalSubjectId: "merchant-42", + isOwner: false, + managerProfileId: "manager-1", + membershipRole: "read_only", + targetEmail: "child@example.com", + targetProfileId: "child-1" + }) + ); + + applyStoredManagedProfileForTests(); + + assert.equal(AuthService.getManagedProfileSelection()?.membershipRole, "read_only"); + assert.equal(accountStateClears, 0); + assert.equal(activatedOwner, null); + }); }); diff --git a/apps/dashboard/src/stores/managed-profile.store.ts b/apps/dashboard/src/stores/managed-profile.store.ts index f0777fee6..dfcc34373 100644 --- a/apps/dashboard/src/stores/managed-profile.store.ts +++ b/apps/dashboard/src/stores/managed-profile.store.ts @@ -7,6 +7,19 @@ const reactListeners = new Set<() => void>(); function applyStoredSelection(): void { const nextSnapshot = AuthService.getManagedProfileSelectionSnapshot(); if (nextSnapshot === currentSnapshot) return; + const currentSelection = AuthService.parseManagedProfileSelectionSnapshot(currentSnapshot); + const nextSelection = AuthService.parseManagedProfileSelectionSnapshot(nextSnapshot); + const sameIdentity = + currentSelection !== null && + nextSelection !== null && + currentSelection.managerProfileId === nextSelection.managerProfileId && + currentSelection.targetProfileId === nextSelection.targetProfileId; + if (sameIdentity) { + currentSnapshot = nextSnapshot; + AuthService.acceptManagedProfileSelectionSnapshot(nextSnapshot); + for (const listener of reactListeners) listener(); + return; + } if (!AuthService.canChangeEffectiveIdentity()) { AuthService.restoreAcceptedManagedProfileSelection(); return; @@ -43,6 +56,17 @@ export function selectManagedProfile(selection: Omit, + expectedSnapshot: string +): boolean { + if (AuthService.getManagedProfileSelectionSnapshot() !== expectedSnapshot) return false; + const current = AuthService.parseManagedProfileSelectionSnapshot(expectedSnapshot); + if (!current || current.targetProfileId !== selection.targetProfileId) return false; + AuthService.storeManagedProfileSelection({ ...selection, managerProfileId: current.managerProfileId }); + return true; +} + export function clearManagedProfile(): boolean { return clearManagedProfileSelection(); } diff --git a/docs/README.md b/docs/README.md index f40111798..155a1226c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,28 +7,30 @@ 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) | Partially superseded by ADR 0006; retained capability and immutable-owner decisions | +| [`adr-0006-organization-wide-teams.md`](adr-0006-organization-wide-teams.md) | Accepted one-account-one-org approximation, organization-wide roles, invitations, and single-affiliation 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..9e4284d47 100644 --- a/docs/adr-0003-managed-headless-profiles.md +++ b/docs/adr-0003-managed-headless-profiles.md @@ -1,7 +1,11 @@ # 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; +[`ADR 0006`](adr-0006-organization-wide-teams.md) supersedes its child-scoped membership +decisions with organization-wide teams under the one-account-one-org approximation. ## Context @@ -14,7 +18,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 +37,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 capability matrix that this ADR originally + excluded; ADR 0006 changes membership scope, not those capability restrictions. ## 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..662a62f07 --- /dev/null +++ b/docs/adr-0005-managed-profile-memberships.md @@ -0,0 +1,77 @@ +# ADR 0005: Managed-Profile Memberships + +Status: partially superseded by [ADR 0006](adr-0006-organization-wide-teams.md) for +membership/invitation scope, Team placement, and owner-membership provisioning. The +child-scoped decisions below are historical, not the current contract. 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/adr-0006-organization-wide-teams.md b/docs/adr-0006-organization-wide-teams.md new file mode 100644 index 000000000..45a8530b9 --- /dev/null +++ b/docs/adr-0006-organization-wide-teams.md @@ -0,0 +1,86 @@ +# ADR 0006: Organization-Wide Teams + +Status: accepted. Supersedes ADR 0005's child-scoped membership, invitation, Team, +and owner-membership provisioning decisions. Preserves its immutable child ownership, +capability restrictions, credential independence, verified acceptance, audit/outbox, and +RLS invariants, as well as ADR 0003's headless identity and lifecycle decisions. + +## Context + +A team operates all customers of one owning manager account, not a separately assigned +roster for each customer. Child-scoped grants obscure that scope and prevent team setup +before the first customer exists. The prior per-profile feature has never been deployed; +its data is disposable and its API is unshipped. + +## Decision + +- Exactly one owning manager account/configuration defines exactly one organization. + This is a **one-account-one-org approximation**, not a general organization entity model. + Every person, including owners, invited managers, and read-only members, has at most one + active organization affiliation. Owners, including owners of disabled configurations, + cannot join another organization. +- Rewrite unshipped migration 069 directly. Keep the existing membership, invitation, and + event table names and internal class filenames; replace their `managed_profile_id` scope + with `owner_profile_id` referencing `managed_profile_managers.profile_id`. Enforce one + active membership globally by `member_profile_id`, not by owner/member pair alone. +- Backfill one protected owner self-membership with role `manager` per manager configuration, + including disabled configurations and configurations with no children. New manager + configuration creation writes this membership and its event once. Child provisioning + creates no membership grants or membership events. + Config-created `createdByProfileId` and event `actorProfileId` are null/system-attributed: + `ADMIN_SECRET` identifies no human actor; the owner remains the member subject. +- A live organization `manager` or `read_only` role applies to all present and future children + of that owner. It does not share any human member's personal resources. Child DTOs retain + effective `membership: { role, isOwner }`; global `profile_roles` remain separate. +- Managers administer non-owner team members and supported child resources/credentials. + Only the owner provisions/deletes children, reads retained deleted children, and controls + owner policy through existing configuration administration. Membership grants no policy, + pricing, global-role, or lifecycle authority. Read-only grants no writes, even with a + personal secret credential. Existing read/manage/credential_manage/ramp restrictions remain. +- Organization discovery, team, and invitee APIs require a human Supabase bearer session; + reject any child selector, API/public-key header (even with a bearer), and impersonation. + Replace the old per-child team paths without aliases. Team belongs in the main nonacting + dashboard and works before the organization has children. +- Require UUID query `expectedOwnerProfileId` on the seven scoped Team operations, including + item PATCH/DELETE; exempt discovery and invitee locator routes. This binds displayed-org + intent, not authority or multi-org selection. The server derives the current org and keeps + live service authorization; missing/malformed input is `400 MANAGED_PROFILE_INVALID_INPUT`, + while a different expected/current owner is `409 ORGANIZATION_CONTEXT_CHANGED`. A stale + dialog opened in A cannot silently operate in B after the actor's affiliation changes. +- Active owner configuration is required for organization/team operations and child access. + Deactivation retains memberships but denies operations; it does not free an affiliation. + `hasMemberships` means live organization membership even with zero children; + `canProvisionManagedProfiles` remains owner-only. Neither depends on list pagination. +- Invitations are durable organization offers, not the inviter's continuing personal grant. + Removing or downgrading the inviter does not cancel a pending offer. Exact current verified + email, explicit acceptance, seven-day expiry, transactional events/outbox, idempotency, + owner protection, and RLS remain required. Second-org acceptance returns + `409 ORGANIZATION_MEMBERSHIP_CONFLICT`; concurrent accepts cannot bypass global uniqueness. +- Removing or downgrading a member changes delegated access to every child. It does not + revoke child-owned shared API credentials. Explicit credential revocation remains necessary + when a departing member possessed a secret. Keep the email discriminator + `managed_profile_membership_invitation` and its existing internal producer/template names. + +## Consequences + +This is an intentional breaking replacement of an unshipped API, not a migration of shipped +per-child grants. No compatibility aliases, dual schema, or forward migration are required. +Local disposable databases must be recreated if they applied the old 069. The shared/SDK +typed surface is not expected to change; OpenAPI and its generated declarations do change. + +Child ownership, compliance identity, provider records, credentials, quotes, ramps, recipients, +and financial history remain child-owned. Team roles express organization-wide authorization, +never ownership of a human's personal account or a transfer of a child's controlling owner. + +Multi-organization management, organization kinds, owner transfer, and an organization switcher +are **not supported**. Adding any of them requires explicitly revisiting this architectural +model in a later ADR. They cannot be introduced by silently reinterpreting membership rows. + +## Specifications + +- [Identity architecture](architecture-identity-model.md) +- [Dashboard product behavior](product-dashboard.md) +- [Organization membership security](security-spec/01-auth/managed-profile-memberships.md) +- [Public organization and managed-profile API](api/pages/14-managed-profiles.md) +- [Email architecture](architecture-email-notifications.md) +- [Testing strategy](operations-testing.md) diff --git a/docs/api/README.md b/docs/api/README.md index 1a7cabc2b..2a89585b1 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -18,6 +18,7 @@ Run the docs check before publishing: ```bash bun run docs:api:check +bun test ./docs/api/scripts/check-openapi.test.ts ``` Generate TypeScript declarations from the OpenAPI file when endpoint schemas change: @@ -113,6 +114,18 @@ The likely long-term path is schema first: move API request and response contrac The endpoint reference should stay SDK-led and partner-facing. Preserve currently documented Apidog endpoints unless we intentionally decide to remove one. Do not add internal routes just because they exist in the API server. +The explicitly approved organization surface includes `GET /v1/organization`, its team +routes, and `/v1/organization-member-invitations/*`, grouped under Managed Profiles. These ten +operations are human Supabase bearer-only and reject all child selectors, API/public keys, +and impersonation. They intentionally replace the unshipped per-child team API with no aliases; +this is an API surface break, not an expected shared/SDK snapshot change. See +[ADR 0006](../adr-0006-organization-wide-teams.md) and the manifest scope. Do not run the Apidog +export while authoring this source change: it downloads and overwrites the local OpenAPI. + +All seven scoped Team operations require UUID query `expectedOwnerProfileId` to bind the +displayed org without granting authority; missing/malformed input is `400`, and changed org +context is typed `409 ORGANIZATION_CONTEXT_CHANGED`. Discovery and invitee routes are exempt. + The docs must strongly state that Vortex does not receive, store, or reconstruct ephemeral account secret keys. The SDK or direct API client is responsible for keeping those secrets available until the ramp and any recovery window are complete. Do not add `subsidize`, `moonbeam`, or `pendulum` route files to the public docs just because they exist on disk. Also keep auth, SIWE, metrics, prices, maintenance, admin, and other first-party/internal routes out of the partner docs unless their inclusion is explicitly approved. diff --git a/docs/api/apidog/page-manifest.json b/docs/api/apidog/page-manifest.json index 47f48b95f..7f0598f94 100644 --- a/docs/api/apidog/page-manifest.json +++ b/docs/api/apidog/page-manifest.json @@ -46,6 +46,14 @@ "/v1/managed-profiles/{profileId}", "/v1/managed-profiles/{profileId}/api-credentials", "/v1/managed-profiles/{profileId}/api-credentials/{credentialId}", + "/v1/organization", + "/v1/organization/members", + "/v1/organization/members/{memberProfileId}", + "/v1/organization/member-invitations", + "/v1/organization/member-invitations/{invitationId}", + "/v1/organization/member-events", + "/v1/organization-member-invitations/{invitationId}", + "/v1/organization-member-invitations/{invitationId}/accept", "/v1/onboarding/active-entity", "/v1/onboarding/requirements", "/v1/onboarding/status", @@ -74,11 +82,13 @@ "Vortex Widget", "Ramp", "Account Management", + "Managed Profiles", "Authentication", "Webhooks", "Public Key", "Reference Data" ], + "scope": "Approved public contract includes seven managed-child lifecycle operations and ten human-bearer-only organization/team/invitee operations, grouped under Managed Profiles. All seven scoped Team operations require UUID query expectedOwnerProfileId as a displayed-org precondition, not authority; discovery and invitee locator routes are exempt. Missing/malformed input is 400 MANAGED_PROFILE_INVALID_INPUT; changed context is 409 ORGANIZATION_CONTEXT_CHANGED. Org-wide Team intentionally replaces the unshipped per-child membership API without aliases. One owning account/config defines one org; personal resources are not shared. No multi-organization management, organization kinds, owner transfer or organization switcher is supported.", "source": "docs/api/openapi/vortex.openapi.json" }, "markdownSync": { @@ -138,7 +148,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 keys or Supabase sessions: organization-wide team roles, human-only invitations, and secret-only managed provider and ramp mutations.", "metaTitle": "Authentication And API Keys — Vortex API" }, "slug": "authentication-and-partner-keys", @@ -322,9 +332,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 org-wide managers or read-only members, accept 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..a454b218e 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; @@ -851,17 +923,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 effective membership (role, isOwner) and immutable-owner policy; there is no singular manager response. Organization roles cover all present and future children. Default status=active lists eligible children of the actor's one organization and returns 200 with an empty list even when both actor flags are false. Child eligibility excludes revoked/invalid roles, inactive owners, deleted children and invalid child entity layouts. hasMemberships means live organization membership even with zero children, independent of the requested status filter and page; it requires active owner configuration, not an eligible child count. * - * **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: invited members cannot use these retained filters. Retained rows still require eligible membership, owner and entity layout but do not determine 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 +953,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 effective organization membership and immutable-owner policy. Actor flags use the same live organization membership and owner-only provisioning rules as the list endpoint, not this child's status or the eligible child count. Active child reads require a live manager or read_only membership in the child's organization, 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 for this actor and the child's immutable owner, with at least one membership interval overlapping the child lifetime: membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt) on the same membership row. Only then may ineligibility return 403 MANAGED_PROFILE_MEMBERSHIP_INVALID. A child created after revocation or wholly within a membership gap remains masked 404 even with historic org membership. 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 with no overlapping membership 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 +985,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 +1016,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; @@ -998,6 +1076,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; @@ -1008,6 +1088,174 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/organization": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Discover the current organization + * @description Discovers the human actor's one live organization, including before any child exists. Returns organization with ownerProfileId, nullable ownerEmail and membership { role, isOwner }, or null without a live membership. Active owner configuration is required: deactivation retains affiliation but returns null and denies organization/team operations. Supabase bearer-only. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. Team belongs in the main nonacting dashboard, not child mode. Organization roles cover all present and future children; personal user resources are not shared. Only the owner provisions/deletes children, reads retained deleted children and controls owner policy. Managers administer non-owner team and supported child resources/credentials; read_only grants no writes even through personal secrets. No multi-organization management, organization kinds, owner transfer or organization switcher is supported; future support requires explicitly revisiting the model in a later ADR. + */ + get: operations["getOrganization"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/organization-member-invitations/{invitationId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Preview an organization membership invitation + * @description Supabase bearer-only. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. The invitation UUID is only a locator. Preview requires active owner configuration, 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 organization, inviter, role, or status details. Authorized preview returns invitation, inviter { email, profileId }, and organization { ownerProfileId, ownerEmail }, including terminal status. Both emails are nullable. Observed seven-day expiry is persisted. Preview and OTP verification grant no membership. + */ + get: operations["previewOrganizationMemberInvitation"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/organization-member-invitations/{invitationId}/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Accept an organization membership invitation + * @description Explicit acceptance by a human Supabase bearer principal with the exact current verified invitation email (email_confirmed_at required), with no request body or request email. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. Transactionally rechecks invitation, seven-day expiry, active owner configuration and actor affiliation; creates one globally active organization membership and invitation_accepted/member_added events. Returns { ownerProfileId, member }. All present and future children inherit the role; personal user resources are not shared. Pending offers survive inviter removal or downgrade. Everyone is limited to one active org affiliation; owners, including disabled owners, cannot join another org. Second-org acceptance returns 409 ORGANIZATION_MEMBERSHIP_CONFLICT. 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. OTP alone grants no organization access. + */ + post: operations["acceptOrganizationMemberInvitation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/organization/member-events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read organization access history + * @description Supabase bearer-only with a live manager or read_only organization membership and active owner configuration, even with zero children. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. 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 organization. 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["listOrganizationMemberEvents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/organization/member-invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List organization membership invitations + * @description Supabase bearer-only with a live manager or read_only organization membership and active owner configuration, even with zero children. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. Returns pending and terminal organization invitations, ordered by createdAt then id descending. Observed seven-day expiry is persisted with one event. No status filter is supported. + */ + get: operations["listOrganizationMemberInvitations"]; + put?: never; + /** + * Invite an organization member + * @description Supabase bearer-only with a live manager organization membership and active owner configuration, even with zero children. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. Email is trimmed/lowercased and role is manager or read_only. Creates one pending invitation per organization/email, expiring after seven days, and queues its email transactionally. The offer remains valid after inviter removal or downgrade. 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 organization's roster. Returns invitation metadata with ownerProfileId, never a secret acceptance token or URL. + */ + post: operations["createOrganizationMemberInvitation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/organization/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 organization membership and active owner configuration, even with zero children. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. Cancels a pending invitation for this organization. Expiry is observed before cancellation. Terminal invitations, including repeated cancellation, return 409 rather than 204. Cancellation never removes an accepted membership. + */ + delete: operations["cancelOrganizationMemberInvitation"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/organization/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List active organization members + * @description Supabase bearer-only with a live manager or read_only organization membership and active owner configuration, even with zero children. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. Lists active members including immutable owner metadata and nullable profile email, ordered by createdAt then id ascending. Membership grants its role over all present and future children, never transfers ownership, and never shares personal user resources. + */ + get: operations["listOrganizationMembers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/organization/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 organization membership and active owner configuration, even with zero children. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. 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. Removal ends delegated access to all organization children, but pending invitations sent by the removed inviter remain durable offers. Child-owned shared credentials are independent principals and are NOT revoked by member removal. Revoke exposed child credentials separately. + */ + delete: operations["removeOrganizationMember"]; + options?: never; + head?: never; + /** + * Change a non-owner member role + * @description Supabase bearer-only with a live manager organization membership and active owner configuration, even with zero children. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. 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 changed role applies to all present and future children. Downgrade does not revoke child-owned shared credentials or cancel pending invitations sent by that member. The response member does not contain email. + */ + patch: operations["changeOrganizationMemberRole"]; + trace?: never; + }; "/v1/public-key": { parameters: { query?: never; @@ -1067,6 +1315,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 +1380,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 +1399,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 +1424,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +1526,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 +1557,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 +1579,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 +1591,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +1618,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 +1640,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 +1652,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +1681,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 +1705,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 +1729,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 +1762,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 +2278,11 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + AcceptManagedProfileInvitationResponse: { + member: components["schemas"]["ManagedProfileMember"]; + /** Format: uuid */ + ownerProfileId: string; + }; AccountMeta: { /** @description The account address. */ address: string; @@ -2213,6 +2488,9 @@ export interface components { BrValidatePixKeyResponse: { valid: boolean; }; + ChangeManagedProfileMemberRequest: { + role: components["schemas"]["ManagedProfileMembershipRole"]; + }; CleanupPhase: { /** @enum {string} */ string?: "moonbeamCleanup" | "pendulumCleanup" | "stellarCleanup"; @@ -2257,6 +2535,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 +2977,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 +3039,117 @@ 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 for a live organization membership with active owner configuration, even with zero children. Requires an unrevoked manager or read_only organization membership, including the protected owner self-membership. Independent of child eligibility, pagination, status filter and detail target. Owner deactivation retains membership but makes this flag false. */ + 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 */ + ownerProfileId: 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 + * @description Human actor when known; null for the config-created owner self-membership event, which is system-attributed. ADMIN_SECRET does not identify the owning account as actor; the owner remains memberProfileId. The corresponding internal membership createdByProfileId is also null. + */ + 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"]; }; + /** + * @description Organization-wide role inherited by all present and future children of one immutable owner. manager permits supported child management and non-owner team administration, not owner-only provisioning/deletion, retained reads or policy control. read_only grants no writes, even through personal secrets. Personal human resources are not shared. The schema name is retained with the existing internal membership names; this is not a per-child grant. + * @enum {string} + */ + ManagedProfileMembershipRole: "manager" | "read_only"; ManagedProfilePagination: { limit: number; offset: number; total: number; }; + /** @description The immutable controlling owner's current policy, returned with each child so authorization and display use that owner's live rules. Not a copied child grant or the acting member's personal policy. The actor has at most one active organization affiliation. 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 an actor membership for the child's owner overlapping the child's lifetime: membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). It is not bearer-only. Historic org membership alone cannot reveal a child created after revocation or wholly within a membership gap; these probes remain masked 404. */ code: string; message: string; status: number; @@ -2916,6 +3305,34 @@ export interface components { }; /** @enum {string} */ OnChainToken: "USDC" | "USDT" | "ETH" | "USDC.E"; + /** @description One owning manager account/config defines exactly one organization: the current one-account-one-org approximation. Every person has at most one active org affiliation; owners, including disabled owners, cannot join another org. Roles cover all present and future children, not personal user resources. Multi-organization management, organization kinds, owner transfer and an organization switcher are unsupported and require explicitly revisiting the architectural model through a later ADR, not reinterpreting membership. */ + Organization: { + membership: components["schemas"]["OrganizationMembership"]; + ownerEmail: string | null; + /** Format: uuid */ + ownerProfileId: string; + }; + OrganizationContextChangedErrorResponse: { + error: { + /** @constant */ + code: "ORGANIZATION_CONTEXT_CHANGED"; + message: string; + /** @constant */ + status: 409; + }; + }; + OrganizationIdentity: { + ownerEmail: string | null; + /** Format: uuid */ + ownerProfileId: string; + }; + OrganizationMembership: { + isOwner: boolean; + role: components["schemas"]["ManagedProfileMembershipRole"]; + }; + OrganizationResponse: { + organization: components["schemas"]["Organization"] | null; + }; PayloadTooLargeErrorResponse: { /** @constant */ code: 413; @@ -2977,6 +3394,15 @@ export interface components { } & { [key: string]: unknown; }; + PreviewManagedProfileInvitationResponse: { + invitation: components["schemas"]["ManagedProfileInvitation"]; + inviter: { + email: string | null; + /** Format: uuid */ + profileId: string; + }; + organization: components["schemas"]["OrganizationIdentity"]; + }; QuoteResponse: { anchorFeeFiat: string; anchorFeeUSD: string; @@ -3359,6 +3785,83 @@ export interface components { "application/json": components["schemas"]["ManagedSelectorErrorResponse"]; }; }; + /** @description Authentication service unavailable (transient Supabase verification failure). */ + MembershipAuthUnavailable: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "Authentication service unavailable" + * } + */ + "application/json": components["schemas"]["FlatErrorResponse"]; + }; + }; + /** @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. All organization/team/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/public-key headers (even with a bearer), inaccessible organization/membership, inactive owner configuration, 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"]; + }; + }; + /** @description ORGANIZATION_CONTEXT_CHANGED: expectedOwnerProfileId does not match the actor's current organization. This displayed-org precondition never selects or authorizes another organization. Refresh context and require a new user decision rather than resubmitting the stale operation against a different org. */ + OrganizationContextChanged: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrganizationContextChangedErrorResponse"]; + }; + }; RecordNotFound: { headers: { [name: string]: unknown; @@ -3372,8 +3875,18 @@ export interface components { }; }; 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`. */ + /** + * @description Required displayed-organization precondition on all seven scoped Team operations. Capture ownerProfileId from the organization being displayed and keep it with the request/dialog. The current organization is derived server-side and live service authorization still applies; this is not authority or a multi-org selector. Missing or malformed expectedOwnerProfileId returns 400 MANAGED_PROFILE_INVALID_INPUT; an expected owner different from the actor's current organization returns 409 ORGANIZATION_CONTEXT_CHANGED. Refresh organization context and require a new user decision, never retry an old dialog automatically against the new org. Discovery GET /v1/organization and invitee locator routes are exempt. + * @example 00000000-0000-0000-0000-000000000001 + */ + ExpectedOwnerProfileId: string; + /** @description Selects one child through the actor's live manager or read_only organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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; }; requestBodies: never; headers: never; @@ -3642,7 +4155,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +4191,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 +4211,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +4238,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 +4285,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +4312,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 +4347,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +4378,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 +4419,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +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. */ 404: { @@ -3962,7 +4480,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +4507,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 +4535,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +4563,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 +4605,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +4637,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 +4661,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +4691,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 +4715,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +4747,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 +4778,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +4806,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 +4850,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +4882,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 +4911,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +4952,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 +5021,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +5053,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 +5070,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +5101,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 +5181,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +5209,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 +5235,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +5267,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 +5302,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +5334,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 +5371,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +5398,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 +5424,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +5455,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 +5483,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +5510,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 +5538,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +5566,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 +5594,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +5622,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 +5650,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +5678,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 +5707,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +5735,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 +5761,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +5793,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 +5819,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +5851,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 +5877,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +5909,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 +5935,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +5967,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 +5993,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +6025,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 +6051,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +6083,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 +6109,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +6141,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 +6176,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +6208,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 +6234,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +6266,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 +6292,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +6324,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 +6350,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +6389,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; @@ -5883,7 +6414,7 @@ export interface operations { limit?: number; /** @description Number of records to skip. */ offset?: number; - /** @description Lifecycle records to include. */ + /** @description active lists eligible active children of the actor's one organization. Both deleted and all require the actor's own active manager configuration and return only that owner's children; invited members cannot use these retained filters. */ status?: "active" | "deleted" | "all"; }; header?: never; @@ -5892,7 +6423,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 +6450,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 +6459,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 +6528,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 +6560,10 @@ export interface operations { getManagedProfile: { parameters: { query?: never; - header?: never; + header?: { + /** @description Selects one child through the actor's live manager or read_only organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +6572,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 +6599,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 an actor membership for the child's owner overlapping the child's lifetime 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 +6608,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 owner-matching membership interval overlapping the child's lifetime receive the same masked 404 for existing and unknown children, even with a matching selector. Historic org membership is insufficient for a child created after revocation or wholly within a membership gap. */ 404: { headers: { [name: string]: unknown; @@ -6083,15 +6617,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 +6665,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 +6674,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 +6697,10 @@ export interface operations { listManagedProfileApiCredentials: { parameters: { query?: never; - header?: never; + header?: { + /** @description Selects one child through the actor's live manager or read_only organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +6718,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 +6736,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 +6745,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 +6768,10 @@ export interface operations { createManagedProfileApiCredential: { parameters: { query?: never; - header?: never; + header?: { + /** @description Selects one child through the actor's live manager or read_only organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +6793,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 +6811,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 +6820,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 +6852,10 @@ export interface operations { revokeManagedProfileApiCredential: { parameters: { query?: never; - header?: never; + header?: { + /** @description Selects one child through the actor's live manager or read_only organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +6873,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 +6891,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 +6900,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; @@ -6490,7 +7024,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +7052,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: { @@ -6530,11 +7065,449 @@ export interface operations { }; }; }; + getOrganization: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The actor's live organization or null; independent of child count. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrganizationResponse"]; + }; + }; + 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"]; + }; + }; + previewOrganizationMemberInvitation: { + 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"]; + }; + }; + acceptOrganizationMemberInvitation: { + 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 ORGANIZATION_MEMBERSHIP_CONFLICT for second-org acceptance, including owners with disabled configuration; INVITATION_ACCEPTED, INVITATION_CANCELLED, INVITATION_EXPIRED, or MEMBERSHIP_ALREADY_EXISTS for existing/terminal access. 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"]; + }; + }; + listOrganizationMemberEvents: { + parameters: { + query: { + /** + * @description Required displayed-organization precondition on all seven scoped Team operations. Capture ownerProfileId from the organization being displayed and keep it with the request/dialog. The current organization is derived server-side and live service authorization still applies; this is not authority or a multi-org selector. Missing or malformed expectedOwnerProfileId returns 400 MANAGED_PROFILE_INVALID_INPUT; an expected owner different from the actor's current organization returns 409 ORGANIZATION_CONTEXT_CHANGED. Refresh organization context and require a new user decision, never retry an old dialog automatically against the new org. Discovery GET /v1/organization and invitee locator routes are exempt. + * @example 00000000-0000-0000-0000-000000000001 + */ + expectedOwnerProfileId: components["parameters"]["ExpectedOwnerProfileId"]; + limit?: components["parameters"]["MembershipLimit"]; + cursor?: string; + }; + header?: never; + path?: never; + 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 missing or malformed expectedOwnerProfileId; INVALID_PAGINATION for invalid limit/offset or event cursor, including a cursor outside the organization; 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"]; + 409: components["responses"]["OrganizationContextChanged"]; + 413: components["responses"]["MembershipPayloadTooLarge"]; + 429: components["responses"]["MembershipRateLimited"]; + 500: components["responses"]["MembershipInternalError"]; + 503: components["responses"]["MembershipAuthUnavailable"]; + }; + }; + listOrganizationMemberInvitations: { + parameters: { + query: { + /** + * @description Required displayed-organization precondition on all seven scoped Team operations. Capture ownerProfileId from the organization being displayed and keep it with the request/dialog. The current organization is derived server-side and live service authorization still applies; this is not authority or a multi-org selector. Missing or malformed expectedOwnerProfileId returns 400 MANAGED_PROFILE_INVALID_INPUT; an expected owner different from the actor's current organization returns 409 ORGANIZATION_CONTEXT_CHANGED. Refresh organization context and require a new user decision, never retry an old dialog automatically against the new org. Discovery GET /v1/organization and invitee locator routes are exempt. + * @example 00000000-0000-0000-0000-000000000001 + */ + expectedOwnerProfileId: components["parameters"]["ExpectedOwnerProfileId"]; + limit?: components["parameters"]["MembershipLimit"]; + offset?: components["parameters"]["MembershipOffset"]; + }; + header?: never; + path?: never; + 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 missing or malformed expectedOwnerProfileId; INVALID_PAGINATION for invalid limit/offset; 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"]; + 409: components["responses"]["OrganizationContextChanged"]; + 413: components["responses"]["MembershipPayloadTooLarge"]; + 429: components["responses"]["MembershipRateLimited"]; + 500: components["responses"]["MembershipInternalError"]; + 503: components["responses"]["MembershipAuthUnavailable"]; + }; + }; + createOrganizationMemberInvitation: { + parameters: { + query: { + /** + * @description Required displayed-organization precondition on all seven scoped Team operations. Capture ownerProfileId from the organization being displayed and keep it with the request/dialog. The current organization is derived server-side and live service authorization still applies; this is not authority or a multi-org selector. Missing or malformed expectedOwnerProfileId returns 400 MANAGED_PROFILE_INVALID_INPUT; an expected owner different from the actor's current organization returns 409 ORGANIZATION_CONTEXT_CHANGED. Refresh organization context and require a new user decision, never retry an old dialog automatically against the new org. Discovery GET /v1/organization and invitee locator routes are exempt. + * @example 00000000-0000-0000-0000-000000000001 + */ + expectedOwnerProfileId: components["parameters"]["ExpectedOwnerProfileId"]; + }; + header?: never; + path?: never; + 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 missing or malformed expectedOwnerProfileId; INVALID_MEMBERSHIP_ROLE or INVALID_INVITATION_EMAIL for invalid input; 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 ORGANIZATION_CONTEXT_CHANGED when expectedOwnerProfileId differs from the actor's current org; INVITATION_ROLE_CONFLICT or MEMBERSHIP_ALREADY_EXISTS for invitation conflicts. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileErrorResponse"] | components["schemas"]["OrganizationContextChangedErrorResponse"]; + }; + }; + 413: components["responses"]["MembershipPayloadTooLarge"]; + 429: components["responses"]["MembershipRateLimited"]; + 500: components["responses"]["MembershipInternalError"]; + 503: components["responses"]["MembershipAuthUnavailable"]; + }; + }; + cancelOrganizationMemberInvitation: { + parameters: { + query: { + /** + * @description Required displayed-organization precondition on all seven scoped Team operations. Capture ownerProfileId from the organization being displayed and keep it with the request/dialog. The current organization is derived server-side and live service authorization still applies; this is not authority or a multi-org selector. Missing or malformed expectedOwnerProfileId returns 400 MANAGED_PROFILE_INVALID_INPUT; an expected owner different from the actor's current organization returns 409 ORGANIZATION_CONTEXT_CHANGED. Refresh organization context and require a new user decision, never retry an old dialog automatically against the new org. Discovery GET /v1/organization and invitee locator routes are exempt. + * @example 00000000-0000-0000-0000-000000000001 + */ + expectedOwnerProfileId: components["parameters"]["ExpectedOwnerProfileId"]; + }; + header?: never; + path: { + 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 or missing/malformed expectedOwnerProfileId. 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 organization. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileErrorResponse"]; + }; + }; + /** @description ORGANIZATION_CONTEXT_CHANGED when expectedOwnerProfileId differs from the actor's current org; INVITATION_ACCEPTED, INVITATION_CANCELLED, or INVITATION_EXPIRED for terminal invitations. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileErrorResponse"] | components["schemas"]["OrganizationContextChangedErrorResponse"]; + }; + }; + 413: components["responses"]["MembershipPayloadTooLarge"]; + 429: components["responses"]["MembershipRateLimited"]; + 500: components["responses"]["MembershipInternalError"]; + 503: components["responses"]["MembershipAuthUnavailable"]; + }; + }; + listOrganizationMembers: { + parameters: { + query: { + /** + * @description Required displayed-organization precondition on all seven scoped Team operations. Capture ownerProfileId from the organization being displayed and keep it with the request/dialog. The current organization is derived server-side and live service authorization still applies; this is not authority or a multi-org selector. Missing or malformed expectedOwnerProfileId returns 400 MANAGED_PROFILE_INVALID_INPUT; an expected owner different from the actor's current organization returns 409 ORGANIZATION_CONTEXT_CHANGED. Refresh organization context and require a new user decision, never retry an old dialog automatically against the new org. Discovery GET /v1/organization and invitee locator routes are exempt. + * @example 00000000-0000-0000-0000-000000000001 + */ + expectedOwnerProfileId: components["parameters"]["ExpectedOwnerProfileId"]; + limit?: components["parameters"]["MembershipLimit"]; + offset?: components["parameters"]["MembershipOffset"]; + }; + header?: never; + path?: never; + 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 missing or malformed expectedOwnerProfileId; INVALID_PAGINATION for invalid limit/offset; 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"]; + 409: components["responses"]["OrganizationContextChanged"]; + 413: components["responses"]["MembershipPayloadTooLarge"]; + 429: components["responses"]["MembershipRateLimited"]; + 500: components["responses"]["MembershipInternalError"]; + 503: components["responses"]["MembershipAuthUnavailable"]; + }; + }; + removeOrganizationMember: { + parameters: { + query: { + /** + * @description Required displayed-organization precondition on all seven scoped Team operations. Capture ownerProfileId from the organization being displayed and keep it with the request/dialog. The current organization is derived server-side and live service authorization still applies; this is not authority or a multi-org selector. Missing or malformed expectedOwnerProfileId returns 400 MANAGED_PROFILE_INVALID_INPUT; an expected owner different from the actor's current organization returns 409 ORGANIZATION_CONTEXT_CHANGED. Refresh organization context and require a new user decision, never retry an old dialog automatically against the new org. Discovery GET /v1/organization and invitee locator routes are exempt. + * @example 00000000-0000-0000-0000-000000000001 + */ + expectedOwnerProfileId: components["parameters"]["ExpectedOwnerProfileId"]; + }; + header?: never; + path: { + /** @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 or missing/malformed expectedOwnerProfileId. 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 ORGANIZATION_CONTEXT_CHANGED when expectedOwnerProfileId differs from the actor's current org; MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED when removing the protected owner. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileErrorResponse"] | components["schemas"]["OrganizationContextChangedErrorResponse"]; + }; + }; + 413: components["responses"]["MembershipPayloadTooLarge"]; + 429: components["responses"]["MembershipRateLimited"]; + 500: components["responses"]["MembershipInternalError"]; + 503: components["responses"]["MembershipAuthUnavailable"]; + }; + }; + changeOrganizationMemberRole: { + parameters: { + query: { + /** + * @description Required displayed-organization precondition on all seven scoped Team operations. Capture ownerProfileId from the organization being displayed and keep it with the request/dialog. The current organization is derived server-side and live service authorization still applies; this is not authority or a multi-org selector. Missing or malformed expectedOwnerProfileId returns 400 MANAGED_PROFILE_INVALID_INPUT; an expected owner different from the actor's current organization returns 409 ORGANIZATION_CONTEXT_CHANGED. Refresh organization context and require a new user decision, never retry an old dialog automatically against the new org. Discovery GET /v1/organization and invitee locator routes are exempt. + * @example 00000000-0000-0000-0000-000000000001 + */ + expectedOwnerProfileId: components["parameters"]["ExpectedOwnerProfileId"]; + }; + header?: never; + path: { + /** @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 or missing/malformed expectedOwnerProfileId; 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 ORGANIZATION_CONTEXT_CHANGED when expectedOwnerProfileId differs from the actor's current org; MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED when changing the protected owner. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedProfileErrorResponse"] | components["schemas"]["OrganizationContextChangedErrorResponse"]; + }; + }; + 413: components["responses"]["MembershipPayloadTooLarge"]; + 429: components["responses"]["MembershipRateLimited"]; + 500: components["responses"]["MembershipInternalError"]; + 503: components["responses"]["MembershipAuthUnavailable"]; + }; + }; createQuote: { 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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +7632,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 +7671,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +7758,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 +7797,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +7832,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 +7847,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +7885,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 +7907,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +8081,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 +8110,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +8230,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 +8259,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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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 +8392,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..743f46273 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -1,8 +1,16 @@ { "components": { "parameters": { + "ExpectedOwnerProfileId": { + "description": "Required displayed-organization precondition on all seven scoped Team operations. Capture ownerProfileId from the organization being displayed and keep it with the request/dialog. The current organization is derived server-side and live service authorization still applies; this is not authority or a multi-org selector. Missing or malformed expectedOwnerProfileId returns 400 MANAGED_PROFILE_INVALID_INPUT; an expected owner different from the actor's current organization returns 409 ORGANIZATION_CONTEXT_CHANGED. Refresh organization context and require a new user decision, never retry an old dialog automatically against the new org. Discovery GET /v1/organization and invitee locator routes are exempt.", + "example": "00000000-0000-0000-0000-000000000001", + "in": "query", + "name": "expectedOwnerProfileId", + "required": true, + "schema": { "format": "uuid", "type": "string" } + }, "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 organization 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: an actor membership for the child's owner must overlap that child's lifetime before ineligibility returns MANAGED_PROFILE_MEMBERSHIP_INVALID. The same row must satisfy membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). A child created after revocation or wholly within a membership gap receives masked 404 even with historic org membership; never-member existing/unknown probes are also masked identically. Bearer and member-secret callers use the same checks. 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, @@ -10,6 +18,46 @@ "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" + } } }, "responses": { @@ -62,6 +110,95 @@ }, "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. All organization/team/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/public-key headers (even with a bearer), inaccessible organization/membership, inactive owner configuration, 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." + }, + "OrganizationContextChanged": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/OrganizationContextChangedErrorResponse" } + } + }, + "description": "ORGANIZATION_CONTEXT_CHANGED: expectedOwnerProfileId does not match the actor's current organization. This displayed-org precondition never selects or authorizes another organization. Refresh context and require a new user decision rather than resubmitting the stale operation against a different org." + }, "RecordNotFound": { "content": { "application/json": { @@ -83,6 +220,19 @@ } }, "schemas": { + "AcceptManagedProfileInvitationResponse": { + "properties": { + "member": { + "$ref": "#/components/schemas/ManagedProfileMember" + }, + "ownerProfileId": { + "format": "uuid", + "type": "string" + } + }, + "required": ["ownerProfileId", "member"], + "type": "object" + }, "AccountMeta": { "properties": { "address": { @@ -778,6 +928,15 @@ "required": ["valid"], "type": "object" }, + "ChangeManagedProfileMemberRequest": { + "properties": { + "role": { + "$ref": "#/components/schemas/ManagedProfileMembershipRole" + } + }, + "required": ["role"], + "type": "object" + }, "CleanupPhase": { "properties": { "string": { @@ -876,6 +1035,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 +2362,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 +2544,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 for a live organization membership with active owner configuration, even with zero children. Requires an unrevoked manager or read_only organization membership, including the protected owner self-membership. Independent of child eligibility, pagination, status filter and detail target. Owner deactivation retains membership but makes this flag false.", + "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,55 +2625,287 @@ "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" + "acceptedAt": { + "format": "date-time", + "type": ["string", "null"] }, - "collection": { - "enum": ["direct-upload", "hosted"], + "cancelledAt": { + "format": "date-time", + "type": ["string", "null"] + }, + "createdAt": { + "format": "date-time", "type": "string" }, - "description": { + "email": { + "format": "email", "type": "string" }, - "required": { + "expiredAt": { + "format": "date-time", + "type": ["string", "null"] + }, + "expiresAt": { + "format": "date-time", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "invitedByProfileId": { + "format": "uuid", + "type": "string" + }, + "ownerProfileId": { + "format": "uuid", + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/ManagedProfileMembershipRole" + }, + "status": { + "enum": ["pending", "accepted", "cancelled", "expired"], + "type": "string" + } + }, + "required": [ + "id", + "ownerProfileId", + "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": { + "description": "Human actor when known; null for the config-created owner self-membership event, which is system-attributed. ADMIN_SECRET does not identify the owning account as actor; the owner remains memberProfileId. The corresponding internal membership createdByProfileId is also null.", + "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": { + "description": "Organization-wide role inherited by all present and future children of one immutable owner. manager permits supported child management and non-owner team administration, not owner-only provisioning/deletion, retained reads or policy control. read_only grants no writes, even through personal secrets. Personal human resources are not shared. The schema name is retained with the existing internal membership names; this is not a per-child grant.", + "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 immutable controlling owner's current policy, returned with each child so authorization and display use that owner's live rules. Not a copied child grant or the acting member's personal policy. The actor has at most one active organization affiliation. 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 an actor membership for the child's owner overlapping the child's lifetime: membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt). It is not bearer-only. Historic org membership alone cannot reveal a child created after revocation or wholly within a membership gap; these probes remain masked 404.", + "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" + }, + "required": { "type": "boolean" }, "requiredWhen": { @@ -2539,10 +3010,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 +3030,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" } }, @@ -2829,6 +3308,56 @@ "enum": ["USDC", "USDT", "ETH", "USDC.E"], "type": "string" }, + "Organization": { + "description": "One owning manager account/config defines exactly one organization: the current one-account-one-org approximation. Every person has at most one active org affiliation; owners, including disabled owners, cannot join another org. Roles cover all present and future children, not personal user resources. Multi-organization management, organization kinds, owner transfer and an organization switcher are unsupported and require explicitly revisiting the architectural model through a later ADR, not reinterpreting membership.", + "properties": { + "membership": { "$ref": "#/components/schemas/OrganizationMembership" }, + "ownerEmail": { "type": ["string", "null"] }, + "ownerProfileId": { "format": "uuid", "type": "string" } + }, + "required": ["ownerProfileId", "ownerEmail", "membership"], + "type": "object" + }, + "OrganizationContextChangedErrorResponse": { + "properties": { + "error": { + "properties": { + "code": { "const": "ORGANIZATION_CONTEXT_CHANGED", "type": "string" }, + "message": { "type": "string" }, + "status": { "const": 409, "type": "integer" } + }, + "required": ["code", "message", "status"], + "type": "object" + } + }, + "required": ["error"], + "type": "object" + }, + "OrganizationIdentity": { + "properties": { + "ownerEmail": { "type": ["string", "null"] }, + "ownerProfileId": { "format": "uuid", "type": "string" } + }, + "required": ["ownerProfileId", "ownerEmail"], + "type": "object" + }, + "OrganizationMembership": { + "properties": { + "isOwner": { "type": "boolean" }, + "role": { "$ref": "#/components/schemas/ManagedProfileMembershipRole" } + }, + "required": ["role", "isOwner"], + "type": "object" + }, + "OrganizationResponse": { + "properties": { + "organization": { + "oneOf": [{ "$ref": "#/components/schemas/Organization" }, { "type": "null" }] + } + }, + "required": ["organization"], + "type": "object" + }, "PayloadTooLargeErrorResponse": { "additionalProperties": false, "properties": { @@ -2914,17 +3443,42 @@ }, "type": "object" }, - "QuoteResponse": { + "PreviewManagedProfileInvitationResponse": { "properties": { - "anchorFeeFiat": { - "type": "string" - }, - "anchorFeeUSD": { - "type": "string" + "invitation": { + "$ref": "#/components/schemas/ManagedProfileInvitation" }, - "expiresAt": { - "description": "The timestamp when this quote expires.", - "format": "date-time", + "inviter": { + "properties": { + "email": { + "type": ["string", "null"] + }, + "profileId": { + "format": "uuid", + "type": "string" + } + }, + "required": ["profileId", "email"], + "type": "object" + }, + "organization": { + "$ref": "#/components/schemas/OrganizationIdentity" + } + }, + "required": ["invitation", "inviter", "organization"], + "type": "object" + }, + "QuoteResponse": { + "properties": { + "anchorFeeFiat": { + "type": "string" + }, + "anchorFeeUSD": { + "type": "string" + }, + "expiresAt": { + "description": "The timestamp when this quote expires.", + "format": "date-time", "type": "string" }, "feeCurrency": { @@ -4178,7 +4732,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 +4776,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 +4800,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 +4850,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 +4907,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 +4957,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 +5003,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 +5054,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 +5100,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 +5151,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 +5186,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 +5246,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 +5281,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 +5329,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 +5367,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 +5425,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 +5443,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 +5499,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 +5517,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 +5575,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 +5596,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 +5644,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 +5689,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 +5747,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 +5768,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 +5859,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 +5938,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 +5989,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 +6011,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 +6062,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 +6108,8 @@ } ], "summary": "Submit KYC level 1 data", - "tags": ["Account Management"] + "tags": ["Account Management"], + "x-managed-profile-capability": "credential_manage" } }, "/v1/brl/validatePixKey": { @@ -5594,7 +6170,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 +6220,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 +6253,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 +6303,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 +6345,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 +6395,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 +6437,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 +6486,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 +6519,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 +6565,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 +6598,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 +6645,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 +6678,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 +6730,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 +6763,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 +6810,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 +6843,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 +6890,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 +6923,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 +6978,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 +7011,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 +7060,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 +7093,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 +7142,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 +7175,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 +7232,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 +7265,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 +7315,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 +7347,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 +7397,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 +7429,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 +7479,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 +7511,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 +7561,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 +7603,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 +7653,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 +7685,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 +7735,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 +7767,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 +7817,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 +7849,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 +7912,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 +7927,13 @@ } ], "summary": "Get user ramp limits", - "tags": ["Account Management"] + "tags": ["Account Management"], + "x-managed-profile-capability": "read" } }, "/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 effective membership (role, isOwner) and immutable-owner policy; there is no singular manager response. Organization roles cover all present and future children. Default status=active lists eligible children of the actor's one organization and returns 200 with an empty list even when both actor flags are false. Child eligibility excludes revoked/invalid roles, inactive owners, deleted children and invalid child entity layouts. hasMemberships means live organization membership even with zero children, independent of the requested status filter and page; it requires active owner configuration, not an eligible child count.\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: invited members cannot use these retained filters. Retained rows still require eligible membership, owner and entity layout but do not determine 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 +7960,7 @@ } }, { - "description": "Lifecycle records to include.", + "description": "active lists eligible active children of the actor's one organization. Both deleted and all require the actor's own active manager configuration and return only that owner's children; invited members cannot use these retained filters.", "in": "query", "name": "status", "required": false, @@ -7367,12 +7975,62 @@ "200": { "content": { "application/json": { + "examples": { + "memberBeforeProvisioning": { + "summary": "Invited member of an active organization with no children", + "value": { + "actor": { + "canProvisionManagedProfiles": false, + "hasMemberships": true, + "profileId": "00000000-0000-0000-0000-000000000003" + }, + "managedProfiles": [], + "pagination": { "limit": 50, "offset": 0, "total": 0 } + } + }, + "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": true, + "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 +8060,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 +8070,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 +8095,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 +8157,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 +8194,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 +8240,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 +8250,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 +8275,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 effective organization membership and immutable-owner policy. Actor flags use the same live organization membership and owner-only provisioning rules as the list endpoint, not this child's status or the eligible child count. Active child reads require a live manager or read_only membership in the child's organization, 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 for this actor and the child's immutable owner, with at least one membership interval overlapping the child lifetime: membership.createdAt <= (child.deletedAt ?? now) and (membership.revokedAt IS NULL or membership.revokedAt > child.createdAt) on the same membership row. Only then may ineligibility return 403 MANAGED_PROFILE_MEMBERSHIP_INVALID. A child created after revocation or wholly within a membership gap remains masked 404 even with historic org membership. 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 with no overlapping membership 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 +8297,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 +8311,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 +8331,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 an actor membership for the child's owner overlapping the child's lifetime 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 +8341,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 owner-matching membership interval overlapping the child's lifetime receive the same masked 404 for existing and unknown children, even with a matching selector. Historic org membership is insufficient for a child created after revocation or wholly within a membership gap." }, "500": { "content": { @@ -7712,14 +8363,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 +8405,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 +8425,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 +8435,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 +8457,16 @@ } ], "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", @@ -7848,7 +8507,7 @@ } } }, - "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." }, "401": { "content": { @@ -7868,7 +8527,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": { @@ -7878,7 +8537,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." }, "409": { "content": { @@ -7910,14 +8569,18 @@ } ], "summary": "Create a managed profile API credential", - "tags": ["Managed Profiles"] + "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 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.", + "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", @@ -7929,49 +8592,636 @@ } }, { - "description": "Child credential ID.", - "in": "path", - "name": "credentialId", - "required": true, - "schema": { - "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/onboarding/active-entity": { + "put": { + "description": "Selects the authenticated profile's immutable active customer-entity type. Managed-child delegation is not supported.", + "operationId": "selectActiveCustomerEntity", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelectActiveCustomerEntityRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelectActiveCustomerEntityResponse" + } + } + }, + "description": "Active customer entity selected." + }, + "400": { + "description": "Invalid customer-entity type." + }, + "401": { + "description": "Supabase Bearer authentication required." + }, + "404": { + "description": "No active owned entity of the requested type exists." + }, + "409": { + "description": "The selection conflicts with an existing selection or is ambiguous." + }, + "500": { + "description": "Selection could not be completed." + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Select active customer entity", + "tags": ["KYC and KYB"] + } + }, + "/v1/onboarding/requirements": { + "get": { + "description": "Returns versioned document and ordered action metadata for an existing supported onboarding flow. GET operations, status polling, and readiness checks are intentionally omitted and remain documented in the integration guides and OpenAPI. Request fields and bodies are defined only by the referenced OpenAPI schemas and are not duplicated at the top level. This endpoint does not return profile state or customer PII. Monerium is outside this discovery proposal.", + "operationId": "getOnboardingRequirements", + "parameters": [ + { + "in": "query", + "name": "country", + "required": true, + "schema": { + "enum": ["AR", "BR", "CO", "MX", "US"], + "type": "string" + } + }, + { + "in": "query", + "name": "customerType", + "required": true, + "schema": { + "enum": ["individual", "business"], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingRequirementsResponse" + } + } + }, + "description": "Flow metadata and ordered non-GET action sequence." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingRequirementsErrorResponse" + } + } + }, + "description": "Missing or invalid query." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingRequirementsErrorResponse" + } + } + }, + "description": "No published flow exists for the country and customer type." + } + }, + "security": [], + "summary": "Discover KYC or KYB requirements", + "tags": ["KYC and KYB", "Reference Data"] + } + }, + "/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.\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": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingStatusResponse" + } + } + }, + "description": "Aggregated onboarding state returned." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } + }, + "description": "The managed-profile selector is invalid." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$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": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingStatusErrorResponse" + } + } + }, + "description": "Onboarding aggregation failed." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Get aggregate onboarding status", + "tags": ["KYC and KYB", "Account Management"], + "x-managed-profile-capability": "read" + } + }, + "/v1/organization": { + "get": { + "description": "Discovers the human actor's one live organization, including before any child exists. Returns organization with ownerProfileId, nullable ownerEmail and membership { role, isOwner }, or null without a live membership. Active owner configuration is required: deactivation retains affiliation but returns null and denies organization/team operations. Supabase bearer-only. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. Team belongs in the main nonacting dashboard, not child mode. Organization roles cover all present and future children; personal user resources are not shared. Only the owner provisions/deletes children, reads retained deleted children and controls owner policy. Managers administer non-owner team and supported child resources/credentials; read_only grants no writes even through personal secrets. No multi-organization management, organization kinds, owner transfer or organization switcher is supported; future support requires explicitly revisiting the model in a later ADR.", + "operationId": "getOrganization", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "noLiveOrganization": { "value": { "organization": null } }, + "ownerBeforeProvisioning": { + "value": { + "organization": { + "membership": { "isOwner": true, "role": "manager" }, + "ownerEmail": "owner@example.com", + "ownerProfileId": "00000000-0000-0000-0000-000000000001" + } + } + } + }, + "schema": { "$ref": "#/components/schemas/OrganizationResponse" } + } + }, + "description": "The actor's live organization or null; independent of child count." + }, + "400": { "$ref": "#/components/responses/MembershipBadRequest" }, + "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": "Discover the current organization", + "tags": ["Managed Profiles"] + } + }, + "/v1/organization-member-invitations/{invitationId}": { + "get": { + "description": "Supabase bearer-only. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. The invitation UUID is only a locator. Preview requires active owner configuration, 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 organization, inviter, role, or status details. Authorized preview returns invitation, inviter { email, profileId }, and organization { ownerProfileId, ownerEmail }, including terminal status. Both emails are nullable. Observed seven-day expiry is persisted. Preview and OTP verification grant no membership.", + "operationId": "previewOrganizationMemberInvitation", + "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 an organization membership invitation", + "tags": ["Managed Profiles"] + } + }, + "/v1/organization-member-invitations/{invitationId}/accept": { + "post": { + "description": "Explicit acceptance by a human Supabase bearer principal with the exact current verified invitation email (email_confirmed_at required), with no request body or request email. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. Transactionally rechecks invitation, seven-day expiry, active owner configuration and actor affiliation; creates one globally active organization membership and invitation_accepted/member_added events. Returns { ownerProfileId, member }. All present and future children inherit the role; personal user resources are not shared. Pending offers survive inviter removal or downgrade. Everyone is limited to one active org affiliation; owners, including disabled owners, cannot join another org. Second-org acceptance returns 409 ORGANIZATION_MEMBERSHIP_CONFLICT. 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. OTP alone grants no organization access.", + "operationId": "acceptOrganizationMemberInvitation", + "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": "ORGANIZATION_MEMBERSHIP_CONFLICT for second-org acceptance, including owners with disabled configuration; INVITATION_ACCEPTED, INVITATION_CANCELLED, INVITATION_EXPIRED, or MEMBERSHIP_ALREADY_EXISTS for existing/terminal access. 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 an organization membership invitation", + "tags": ["Managed Profiles"] + } + }, + "/v1/organization/member-events": { + "get": { + "description": "Supabase bearer-only with a live manager or read_only organization membership and active owner configuration, even with zero children. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. 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 organization. Event payloads omit emails, secrets, and invitation URLs. Offset, if supplied, is validated as a non-negative integer but ignored; use cursor pagination.", + "operationId": "listOrganizationMemberEvents", + "parameters": [ + { "$ref": "#/components/parameters/ExpectedOwnerProfileId" }, + { + "$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 missing or malformed expectedOwnerProfileId; INVALID_PAGINATION for invalid limit/offset or event cursor, including a cursor outside the organization; 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": { "$ref": "#/components/responses/OrganizationContextChanged" }, + "413": { + "$ref": "#/components/responses/MembershipPayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/MembershipRateLimited" + }, + "500": { + "$ref": "#/components/responses/MembershipInternalError" + }, + "503": { + "$ref": "#/components/responses/MembershipAuthUnavailable" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Read organization access history", + "tags": ["Managed Profiles"] + } + }, + "/v1/organization/member-invitations": { + "get": { + "description": "Supabase bearer-only with a live manager or read_only organization membership and active owner configuration, even with zero children. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. Returns pending and terminal organization invitations, ordered by createdAt then id descending. Observed seven-day expiry is persisted with one event. No status filter is supported.", + "operationId": "listOrganizationMemberInvitations", + "parameters": [ + { "$ref": "#/components/parameters/ExpectedOwnerProfileId" }, + { + "$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 missing or malformed expectedOwnerProfileId; INVALID_PAGINATION for invalid limit/offset; 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": { "$ref": "#/components/responses/OrganizationContextChanged" }, + "413": { + "$ref": "#/components/responses/MembershipPayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/MembershipRateLimited" + }, + "500": { + "$ref": "#/components/responses/MembershipInternalError" + }, + "503": { + "$ref": "#/components/responses/MembershipAuthUnavailable" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "List organization membership invitations", + "tags": ["Managed Profiles"] + }, + "post": { + "description": "Supabase bearer-only with a live manager organization membership and active owner configuration, even with zero children. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. Email is trimmed/lowercased and role is manager or read_only. Creates one pending invitation per organization/email, expiring after seven days, and queues its email transactionally. The offer remains valid after inviter removal or downgrade. 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 organization's roster. Returns invitation metadata with ownerProfileId, never a secret acceptance token or URL.", + "operationId": "createOrganizationMemberInvitation", + "parameters": [{ "$ref": "#/components/parameters/ExpectedOwnerProfileId" }], + "requestBody": { + "content": { + "application/json": { + "example": { + "email": "operator@example.com", + "role": "manager" + }, + "schema": { + "$ref": "#/components/schemas/CreateManagedProfileInvitationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileInvitationResponse" + } + } + }, + "description": "Identical pending invitation; no duplicate email." + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileInvitationResponse" + } + } + }, + "description": "Invitation created and email queued." + }, + "400": { + "$ref": "#/components/responses/MembershipBadRequest", + "description": "MANAGED_PROFILE_INVALID_INPUT for missing or malformed expectedOwnerProfileId; INVALID_MEMBERSHIP_ROLE or INVALID_INVITATION_EMAIL for invalid input; 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": { + "anyOf": [ + { "$ref": "#/components/schemas/ManagedProfileErrorResponse" }, + { "$ref": "#/components/schemas/OrganizationContextChangedErrorResponse" } + ] + } + } + }, + "description": "ORGANIZATION_CONTEXT_CHANGED when expectedOwnerProfileId differs from the actor's current org; INVITATION_ROLE_CONFLICT or MEMBERSHIP_ALREADY_EXISTS for invitation conflicts." + }, + "413": { + "$ref": "#/components/responses/MembershipPayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/MembershipRateLimited" + }, + "500": { + "$ref": "#/components/responses/MembershipInternalError" + }, + "503": { + "$ref": "#/components/responses/MembershipAuthUnavailable" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Invite an organization member", + "tags": ["Managed Profiles"] + } + }, + "/v1/organization/member-invitations/{invitationId}": { + "delete": { + "description": "Supabase bearer-only with a live manager organization membership and active owner configuration, even with zero children. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. Cancels a pending invitation for this organization. Expiry is observed before cancellation. Terminal invitations, including repeated cancellation, return 409 rather than 204. Cancellation never removes an accepted membership.", + "operationId": "cancelOrganizationMemberInvitation", + "parameters": [ + { "$ref": "#/components/parameters/ExpectedOwnerProfileId" }, + { + "$ref": "#/components/parameters/MembershipInvitationId" } ], "responses": { "204": { - "description": "Credential is revoked; both values are unusable. Repeated revocation also returns this status." + "description": "Invitation cancelled; empty body." }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ManagedProfileErrorResponse" - } - } - }, - "description": "`MANAGED_PROFILE_INVALID_INPUT`: profileId or credentialId is not a UUID." + "$ref": "#/components/responses/MembershipBadRequest", + "description": "MANAGED_PROFILE_INVALID_INPUT for non-UUID path IDs or missing/malformed expectedOwnerProfileId. 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": { - "content": { - "application/json": { - "schema": { - "$ref": "#/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." + "$ref": "#/components/responses/MembershipForbidden" }, "404": { "content": { @@ -7981,70 +9231,89 @@ } } }, - "description": "`CREDENTIAL_NOT_FOUND`: the active child or credential does not exist, or is not owned by this manager." + "description": "INVITATION_NOT_FOUND: no invitation with this ID in the authorized organization." }, - "500": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ManagedProfileErrorResponse" + "anyOf": [ + { "$ref": "#/components/schemas/ManagedProfileErrorResponse" }, + { "$ref": "#/components/schemas/OrganizationContextChangedErrorResponse" } + ] } } }, - "description": "Internal server error." + "description": "ORGANIZATION_CONTEXT_CHANGED when expectedOwnerProfileId differs from the actor's current org; INVITATION_ACCEPTED, INVITATION_CANCELLED, or INVITATION_EXPIRED for terminal invitations." + }, + "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": "Cancel a pending membership invitation", "tags": ["Managed Profiles"] } }, - "/v1/onboarding/active-entity": { - "put": { - "description": "Selects the authenticated profile's immutable active customer-entity type. Managed-child delegation is not supported.", - "operationId": "selectActiveCustomerEntity", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SelectActiveCustomerEntityRequest" - } - } + "/v1/organization/members": { + "get": { + "description": "Supabase bearer-only with a live manager or read_only organization membership and active owner configuration, even with zero children. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. Lists active members including immutable owner metadata and nullable profile email, ordered by createdAt then id ascending. Membership grants its role over all present and future children, never transfers ownership, and never shares personal user resources.", + "operationId": "listOrganizationMembers", + "parameters": [ + { "$ref": "#/components/parameters/ExpectedOwnerProfileId" }, + { + "$ref": "#/components/parameters/MembershipLimit" }, - "required": true - }, + { + "$ref": "#/components/parameters/MembershipOffset" + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SelectActiveCustomerEntityResponse" + "$ref": "#/components/schemas/ListManagedProfileMembersResponse" } } }, - "description": "Active customer entity selected." + "description": "Active members and offset pagination." }, "400": { - "description": "Invalid customer-entity type." + "$ref": "#/components/responses/MembershipBadRequest", + "description": "MANAGED_PROFILE_INVALID_INPUT for missing or malformed expectedOwnerProfileId; INVALID_PAGINATION for invalid limit/offset; MANAGED_PROFILE_UNSUPPORTED for any managed selector. Malformed JSON is rejected before route authentication." }, "401": { - "description": "Supabase Bearer authentication required." + "$ref": "#/components/responses/MembershipUnauthorized" }, - "404": { - "description": "No active owned entity of the requested type exists." + "403": { + "$ref": "#/components/responses/MembershipForbidden" }, - "409": { - "description": "The selection conflicts with an existing selection or is ambiguous." + "409": { "$ref": "#/components/responses/OrganizationContextChanged" }, + "413": { + "$ref": "#/components/responses/MembershipPayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/MembershipRateLimited" }, "500": { - "description": "Selection could not be completed." + "$ref": "#/components/responses/MembershipInternalError" + }, + "503": { + "$ref": "#/components/responses/MembershipAuthUnavailable" } }, "security": [ @@ -8052,129 +9321,164 @@ "BearerAuth": [] } ], - "summary": "Select active customer entity", - "tags": ["KYC and KYB"] + "summary": "List active organization members", + "tags": ["Managed Profiles"] } }, - "/v1/onboarding/requirements": { - "get": { - "description": "Returns versioned document and ordered action metadata for an existing supported onboarding flow. GET operations, status polling, and readiness checks are intentionally omitted and remain documented in the integration guides and OpenAPI. Request fields and bodies are defined only by the referenced OpenAPI schemas and are not duplicated at the top level. This endpoint does not return profile state or customer PII. Monerium is outside this discovery proposal.", - "operationId": "getOnboardingRequirements", + "/v1/organization/members/{memberProfileId}": { + "delete": { + "description": "Supabase bearer-only with a live manager organization membership and active owner configuration, even with zero children. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. 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. Removal ends delegated access to all organization children, but pending invitations sent by the removed inviter remain durable offers. Child-owned shared credentials are independent principals and are NOT revoked by member removal. Revoke exposed child credentials separately.", + "operationId": "removeOrganizationMember", "parameters": [ + { "$ref": "#/components/parameters/ExpectedOwnerProfileId" }, { - "in": "query", - "name": "country", - "required": true, - "schema": { - "enum": ["AR", "BR", "CO", "MX", "US"], - "type": "string" - } - }, - { - "in": "query", - "name": "customerType", - "required": true, - "schema": { - "enum": ["individual", "business"], - "type": "string" - } + "$ref": "#/components/parameters/MembershipMemberProfileId" } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OnboardingRequirementsResponse" - } - } - }, - "description": "Flow metadata and ordered non-GET action sequence." + "204": { + "description": "Member revoked; empty body." }, "400": { + "$ref": "#/components/responses/MembershipBadRequest", + "description": "MANAGED_PROFILE_INVALID_INPUT for non-UUID path IDs or missing/malformed expectedOwnerProfileId. Malformed JSON is rejected before route authentication." + }, + "401": { + "$ref": "#/components/responses/MembershipUnauthorized" + }, + "403": { + "$ref": "#/components/responses/MembershipForbidden" + }, + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OnboardingRequirementsErrorResponse" + "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } }, - "description": "Missing or invalid query." + "description": "MEMBER_NOT_FOUND: no active target or same-actor revocation retry." }, - "404": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OnboardingRequirementsErrorResponse" + "anyOf": [ + { "$ref": "#/components/schemas/ManagedProfileErrorResponse" }, + { "$ref": "#/components/schemas/OrganizationContextChangedErrorResponse" } + ] } } }, - "description": "No published flow exists for the country and customer type." + "description": "ORGANIZATION_CONTEXT_CHANGED when expectedOwnerProfileId differs from the actor's current org; MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED when removing the protected owner." + }, + "413": { + "$ref": "#/components/responses/MembershipPayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/MembershipRateLimited" + }, + "500": { + "$ref": "#/components/responses/MembershipInternalError" + }, + "503": { + "$ref": "#/components/responses/MembershipAuthUnavailable" } }, - "security": [], - "summary": "Discover KYC or KYB requirements", - "tags": ["KYC and KYB", "Reference Data"] - } - }, - "/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.", - "operationId": "getOnboardingStatus", + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Revoke a non-owner member", + "tags": ["Managed Profiles"] + }, + "patch": { + "description": "Supabase bearer-only with a live manager organization membership and active owner configuration, even with zero children. Rejects any child selector, API/public-key headers (even with a bearer), and impersonation. 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 changed role applies to all present and future children. Downgrade does not revoke child-owned shared credentials or cancel pending invitations sent by that member. The response member does not contain email.", + "operationId": "changeOrganizationMemberRole", "parameters": [ + { "$ref": "#/components/parameters/ExpectedOwnerProfileId" }, { - "$ref": "#/components/parameters/ManagedProfileId" + "$ref": "#/components/parameters/MembershipMemberProfileId" } ], + "requestBody": { + "content": { + "application/json": { + "example": { + "role": "read_only" + }, + "schema": { + "$ref": "#/components/schemas/ChangeManagedProfileMemberRequest" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OnboardingStatusResponse" + "$ref": "#/components/schemas/ManagedProfileMemberResponse" } } }, - "description": "Aggregated onboarding state returned." + "description": "Current member role." }, "400": { + "$ref": "#/components/responses/MembershipBadRequest", + "description": "MANAGED_PROFILE_INVALID_INPUT for non-UUID path IDs or missing/malformed expectedOwnerProfileId; 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": { "application/json": { "schema": { - "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } }, - "description": "The managed-profile selector is invalid." + "description": "MEMBER_NOT_FOUND: no active target member." }, - "401": { - "$ref": "#/components/responses/ManagedSelectorUnauthorized", - "description": "Authentication required." - }, - "403": { - "$ref": "#/components/responses/ManagedSelectorForbidden" - }, - "500": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OnboardingStatusErrorResponse" + "anyOf": [ + { "$ref": "#/components/schemas/ManagedProfileErrorResponse" }, + { "$ref": "#/components/schemas/OrganizationContextChangedErrorResponse" } + ] } } }, - "description": "Onboarding aggregation failed." + "description": "ORGANIZATION_CONTEXT_CHANGED when expectedOwnerProfileId differs from the actor's current org; MANAGED_PROFILE_OWNER_MEMBERSHIP_REQUIRED when changing the protected owner." + }, + "413": { + "$ref": "#/components/responses/MembershipPayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/MembershipRateLimited" + }, + "500": { + "$ref": "#/components/responses/MembershipInternalError" + }, + "503": { + "$ref": "#/components/responses/MembershipAuthUnavailable" } }, "security": [ - { - "SecretApiKey": [] - }, { "BearerAuth": [] } ], - "summary": "Get aggregate onboarding status", - "tags": ["KYC and KYB", "Account Management"] + "summary": "Change a non-owner member role", + "tags": ["Managed Profiles"] } }, "/v1/public-key": { @@ -8213,7 +9517,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 +9744,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 +9802,8 @@ } ], "summary": "Create a new quote", - "tags": ["Quotes"] + "tags": ["Quotes"], + "x-managed-profile-capability": "read" } }, "/v1/quotes/{id}": { @@ -8537,7 +9842,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 +10032,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 +10083,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 +10136,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 +10148,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 +10356,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 +10369,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 +10433,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 +10452,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 +10511,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 +10524,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 +10592,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 +10605,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 +10912,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 +10938,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 +11177,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 +11205,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 +11457,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 +11485,8 @@ } ], "summary": "Update ramp process", - "tags": ["Ramp"] + "tags": ["Ramp"], + "x-managed-profile-capability": "ramp" } }, "/v1/session/create": { @@ -10665,22 +11981,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 +12060,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..b68011b09 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,81 @@ 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. Exactly one owning manager account/config defines one organization: the current **one-account-one-org approximation**. Each person has at most one active organization affiliation; owners, including disabled owners, cannot join another org. Organization `manager` and `read_only` roles apply to all present and future children of that immutable owner, never to human personal resources. An active member can select an eligible child on supported operations 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 an actor membership for the child's immutable owner overlaps the child lifetime and the child is now ineligible. On the same row, require `membership.createdAt <= (child.deletedAt ?? now)` and (`membership.revokedAt IS NULL` or `membership.revokedAt > child.createdAt`); now applies to an undeleted child. A child created after revocation or wholly within a membership gap is masked `404` even with historic org membership. Revocation, deleted child, disabled owner or invalid entity layout invalidate evidenced bootstrap; 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` means **live organization membership even with zero children**, with an unrevoked allowed role and active owner configuration, independent of page/status/detail target. Child eligibility separately requires an active relationship, a managed child, and exactly one active owned entity selected by that child. Deleted or invalid children do not remove org affiliation. + +The default active list returns `200` even without owner configuration or live membership: `managedProfiles: []`, `pagination.total: 0`, and both flags false. An enabled owner with no children has both flags true; an invited member of an empty active org has `canProvisionManagedProfiles: false` and `hasMemberships: true`. 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**. Invited members cannot use either retained filter. 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 effective `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 retains memberships but denies organization/team and child operations; it does not permit joining another org. + +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 ten organization/team/invitee operations require a human Supabase bearer session and reject `X-API-Key`, `X-Public-Key` (even with a valid bearer), impersonation, and any child selector. `GET /v1/organization` returns `{ organization: { ownerProfileId, ownerEmail, membership: { role, isOwner } } | null }`; `ownerEmail` is nullable, and disabled owner configuration yields null. Team uses `/v1/organization/members`, `/member-invitations`, and `/member-events`, works for empty orgs in the main nonacting dashboard, and has no old per-child aliases. Invitees use `/v1/organization-member-invitations/:invitationId` and `/:invitationId/accept`. Preview returns `{ invitation, inviter: { email, profileId }, organization: { ownerProfileId, ownerEmail } }`; acceptance returns `{ ownerProfileId, member }`. Invitation `ownerProfileId` replaces `managedProfileId`; other body/member/event shapes and pagination are unchanged. See [Managed Profiles](https://api-docs.vortexfinance.co/managed-profiles) for the complete route table and examples. + +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`. + +All seven scoped Team operations require UUID query `expectedOwnerProfileId`, including item PATCH/DELETE. For example, `POST /v1/organization/member-invitations?expectedOwnerProfileId=00000000-0000-0000-0000-000000000001` binds the dialog to that displayed org. Missing/malformed input is `400 MANAGED_PROFILE_INVALID_INPUT`; mismatch with the actor's server-derived current org is `409 ORGANIZATION_CONTEXT_CHANGED` in `{ error: { code, message, status } }`. This is a precondition, not authority or a multi-org selector; live service authorization still applies. Discovery and invitee locator routes are exempt. After context conflict, refresh discovery and require a new user decision rather than replaying A's stale dialog in B after an affiliation change. + +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. + +Pending invitations are durable organization offers even if their inviter is later removed or downgraded. Acceptance requires active owner configuration and rejects a second org with `409 ORGANIZATION_MEMBERSHIP_CONFLICT`, including for disabled owners. Removal/downgrade changes all child delegated access without revoking child-owned shared credentials. No multi-organization management, organization kinds, owner transfer, or organization switcher is supported; adding any requires explicitly revisiting the architectural model in a later ADR, not reinterpreting membership. The old per-child API was unshipped and is intentionally replaced without compatibility aliases. 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 +191,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..4221f20bf 100644 --- a/docs/api/pages/12-ai-agent-integration.md +++ b/docs/api/pages/12-ai-agent-integration.md @@ -238,4 +238,14 @@ 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 organization membership for the child's immutable owner. One owning account/config defines exactly one org (the current one-account-one-org approximation); everyone has at most one active affiliation, and owners including disabled owners cannot join another org. All present/future children inherit the role; personal resources are not shared. List/read lifecycle responses identify `actor` and decorate each child with effective `membership: { role, isOwner }` and its owner's `policy`; creation remains undecorated. `read_only` includes quote creation but never mutations, even through a personal 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. All ten organization/team/invitee routes are human Supabase bearer-only and reject any child selector, API/public-key headers (even with a bearer), and impersonation. Follow the capability matrix 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 page length, for access discovery: `hasMemberships` means live organization membership even with zero children, with active owner configuration, independently of pagination/status/child eligibility. Provisioning stays owner-only. 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 an actor membership for the child's immutable owner overlapping its lifetime: `membership.createdAt <= (child.deletedAt ?? now)` and (`membership.revokedAt IS NULL` or `membership.revokedAt > child.createdAt`) on one row. Children created after revocation or wholly within a membership gap remain masked `404` despite historic org membership; never-member existing/unknown probes are also masked identically. 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`. + +Discover the org with `GET /v1/organization` (nullable org with `ownerProfileId`, nullable `ownerEmail`, and `membership: { role, isOwner }`). Team is main nonacting UI, available with zero children; its routes are `/v1/organization/members`, `/member-invitations`, and `/member-events`. Invitee preview/acceptance uses `/v1/organization-member-invitations/:invitationId` and `/:invitationId/accept`. Preview returns `{ invitation, inviter: { email, profileId }, organization: { ownerProfileId, ownerEmail } }`; acceptance returns `{ ownerProfileId, member }`. Invitation `ownerProfileId` replaces `managedProfileId`; other shapes/pagination stay unchanged. Old child-scoped Team paths are removed without aliases, an intentional break to the unshipped feature, not a shared/SDK change. + +Current verified email and explicit acceptance remain required; offers expire after seven days and survive inviter removal/downgrade. Second-org acceptance is `409 ORGANIZATION_MEMBERSHIP_CONFLICT`. Deactivation retains memberships but denies org/team/child operations and makes discovery null. Removal/downgrade affects every child without revoking child-owned shared secrets. Multi-organization management, organization kinds, owner transfer and an org switcher are unsupported; adding them requires explicitly revisiting the architectural model via a later ADR, not reinterpreting membership. + +Send required UUID query `expectedOwnerProfileId` on all seven scoped Team operations, including item PATCH/DELETE, captured from the org being displayed. Example: `GET /v1/organization/members?expectedOwnerProfileId=00000000-0000-0000-0000-000000000001`. Discovery and invitee locator routes are exempt. Missing/malformed input is `400 MANAGED_PROFILE_INVALID_INPUT`; a different expected/server-derived current owner is `409 ORGANIZATION_CONTEXT_CHANGED`. This displayed-org precondition neither grants authority nor selects another org; live service authorization remains mandatory. Preserve the owner with the dialog, refresh after conflict, and require a new decision rather than silently issuing B's invitation from A's stale dialog after removal and cross-tab acceptance. + --- diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 0b236ccb9..ec3235be7 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -13,7 +13,15 @@ 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. + +### One Organization Per Owning Account + +Exactly one owning manager account/config defines exactly one organization. This is the current **one-account-one-org approximation**, not a general organization entity model. Every person, including owners, invited managers, and read-only members, is limited to one active organization affiliation. Owners, including owners with disabled configuration, cannot join another org. + +An organization's role applies to **all present and future children** of its owner. Personal user resources are not shared. A Manager administers non-owner team members and supported child resources/credentials; only the Owner provisions/deletes children, reads retained deleted children, and controls owner policy through existing administration. Read-only grants no writes, even using personal secret credentials. The existing provider/ramp secret requirements still apply. + +Active owner configuration is required for organization/team and child operations. Deactivation retains memberships but denies operations; it does not free an affiliation to join another org. Multi-organization management, organization kinds, owner transfer, and an organization switcher are not supported. Future support requires explicitly revisiting the architectural model through a later ADR, not reinterpreting membership. ## Create A Managed Child @@ -53,18 +61,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 children of the actor's one organization (`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; invited members cannot use retained filters. `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 effective `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 and stays owner-only. `hasMemberships` means live organization membership with active owner configuration, **even with zero children**, independent of page, status filter and detail target. Child eligibility separately excludes deleted children and invalid entity layouts; a returned child must select its sole active owned customer entity. Neither an empty org nor an empty page removes live organization membership. + +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`, `true`; an invited member of an empty active org receives `false`, `true`. 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: an actor membership for the child's immutable owner must overlap that child's lifetime. The same row must satisfy `membership.createdAt <= (child.deletedAt ?? now)` and (`membership.revokedAt IS NULL` or `membership.revokedAt > child.createdAt`); use now if the child is not deleted. Only then may ineligibility return `403 MANAGED_PROFILE_MEMBERSHIP_INVALID`. A child created after revocation or wholly within a membership gap remains masked `404`, even with historic org membership. Revocation, deleted children, disabled owners and invalid entity layouts invalidate evidenced bootstrap. Deleted children invalidate bootstrap even for their owner. Both bearer and member-secret callers use this rule; callers with no overlapping owner-matching membership 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 +136,150 @@ 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. -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. +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. + +## Discover Your Organization And Team + +Members are authenticated human profiles; children remain 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 personal secret key. The immutable owner has one protected `manager` self-membership from configuration creation, including before any children exist. Provisioning a child creates no new grants. + +All ten endpoints below require `Authorization: Bearer `. Do not attach API/public-key headers, even alongside a bearer. All reject API credentials, direct child credentials, impersonation, and **any** `X-Managed-Profile-Id`, including a matching, empty, or malformed selector. There are no child-path Team aliases. Team lives in the main nonacting dashboard and works for empty organizations. + +| Endpoint | Authority | Success | +|---|---|---| +| `GET /v1/organization` | Human actor | `200 { organization: { ownerProfileId, ownerEmail, membership: { role, isOwner } } \| null }` | +| `GET /v1/organization/members` | Either role | `200 { members, pagination }` | +| `PATCH /v1/organization/members/{memberProfileId}` | Manager member | `200 { member }` | +| `DELETE /v1/organization/members/{memberProfileId}` | Manager member | `204`, no body | +| `GET /v1/organization/member-invitations` | Either role | `200 { invitations, pagination }` | +| `POST /v1/organization/member-invitations` | Manager member | `201 { invitation }`, or `200` for identical pending retry | +| `DELETE /v1/organization/member-invitations/{invitationId}` | Manager member | `204`, no body | +| `GET /v1/organization/member-events` | Either role | `200 { events, pagination }` | +| `GET /v1/organization-member-invitations/{invitationId}` | Exact verified-email invitee | `200 { invitation, inviter, organization }` | +| `POST /v1/organization-member-invitations/{invitationId}/accept` | Exact verified-email invitee | `200 { ownerProfileId, member }` | + +Discovery returns `organization: null` without a live membership, including when the owner configuration is disabled. A live empty org returns the same organization projection as one with children. `ownerEmail` is nullable. Team derives its organization from the actor, not a caller-supplied owner ID. + +**Required Team precondition:** all seven scoped Team operations, including item PATCH/DELETE, require UUID query `expectedOwnerProfileId`. Capture `ownerProfileId` from the organization being displayed and keep it with each request/dialog. Discovery `GET /v1/organization` and both invitee locator routes are exempt. The parameter binds displayed-org intent, not authority or a multi-org selector: the server still derives the current org and performs live service authorization. Missing/malformed input returns `400 MANAGED_PROFILE_INVALID_INPUT`; expected/current owner mismatch returns `409 ORGANIZATION_CONTEXT_CHANGED` with `{ "error": { "code": "ORGANIZATION_CONTEXT_CHANGED", "message": "...", "status": 409 } }`. + +For example, a dialog opened in A must still send A's owner ID if the actor is removed from A and accepts B elsewhere. On conflict, refresh discovery and ask for a new decision; never automatically retry the old invitation against B. All seven paths keep their existing bodies and pagination: + +```http +GET /v1/organization/members?expectedOwnerProfileId=00000000-0000-0000-0000-000000000001&limit=50 +PATCH /v1/organization/members/00000000-0000-0000-0000-000000000003?expectedOwnerProfileId=00000000-0000-0000-0000-000000000001 +DELETE /v1/organization/members/00000000-0000-0000-0000-000000000003?expectedOwnerProfileId=00000000-0000-0000-0000-000000000001 +GET /v1/organization/member-invitations?expectedOwnerProfileId=00000000-0000-0000-0000-000000000001&limit=50 +POST /v1/organization/member-invitations?expectedOwnerProfileId=00000000-0000-0000-0000-000000000001 +DELETE /v1/organization/member-invitations/00000000-0000-0000-0000-000000000004?expectedOwnerProfileId=00000000-0000-0000-0000-000000000001 +GET /v1/organization/member-events?expectedOwnerProfileId=00000000-0000-0000-0000-000000000001&limit=50 +``` + +### 1. Create An Invitation + +```http +POST /v1/organization/member-invitations?expectedOwnerProfileId=00000000-0000-0000-0000-000000000001 +Authorization: Bearer +Content-Type: application/json + +{ "email": "operator@example.com", "role": "manager" } +``` + +```json +{ + "invitation": { + "id": "00000000-0000-0000-0000-000000000004", + "ownerProfileId": "00000000-0000-0000-0000-000000000001", + "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. It is a durable organization offer: later inviter removal or downgrade does not cancel it. Creation does not reveal whether an unrelated profile exists. Only one pending invitation per organization/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/organization-member-invitations/00000000-0000-0000-0000-000000000004 +Authorization: Bearer +``` + +Authorized preview requires active owner configuration and returns the invitation above, `inviter: { email, profileId }` (nullable inviter email), and `organization: { ownerProfileId, ownerEmail }` (nullable owner email). It can return `pending`, `accepted`, `cancelled`, or `expired`. An unknown UUID or mismatched/unverified email returns generic `403 MANAGED_PROFILE_ACCESS_DENIED` without organization, inviter, role, or status details. + +After showing the organization and role to the invitee, explicitly accept with **no request body and no managed selector**: + +```http +POST /v1/organization-member-invitations/00000000-0000-0000-0000-000000000004/accept +Authorization: Bearer +``` + +```json +{ + "ownerProfileId": "00000000-0000-0000-0000-000000000001", + "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. + +Second-org acceptance returns `409 ORGANIZATION_MEMBERSHIP_CONFLICT`. This includes owners with disabled configurations and members whose existing org is disabled: deactivation does not release affiliation. Concurrent accepts cannot grant two active org memberships. Acceptance of a pending offer does not depend on the inviter still being a member, but does require active owner configuration. Success grants the role for all current and future children and leads to the main org/Team surface, not child selection. + +### 3. Change Access And Audit + +Use the **member profile UUID**, not `member.id`, in member mutation paths: + +```http +PATCH /v1/organization/members/00000000-0000-0000-0000-000000000003?expectedOwnerProfileId=00000000-0000-0000-0000-000000000001 +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`. + +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 `?expectedOwnerProfileId=&limit=50&cursor=`, newest first by creation time/UUID, returning `pagination: { limit, nextCursor }`. The cursor must belong to this organization; `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`. Config-created owner self-membership has system/null creator attribution, and its `member_added` event has `actorProfileId: null` with the owner as member subject; admin-secret authentication does not identify a human owner action. 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 and the required scoped-Team query `expectedOwnerProfileId` must be UUIDs; missing precondition is also invalid. | +| `400 INVALID_PAGINATION` | Invalid page size/offset or event cursor, including a cursor outside the organization. | +| `400 MANAGED_PROFILE_UNSUPPORTED` | Any child selector on an organization/team/invitee route. | +| `400 INVALID_MEMBERSHIP_ROLE` / `INVALID_INVITATION_EMAIL` | Invalid role or creation email. | +| `403 MANAGED_PROFILE_ACCESS_DENIED` | Missing org authority, inactive owner, forbidden API/public-key headers, or unauthorized invitee. | +| `403 IMPERSONATION_NOT_ALLOWED` | All membership operations reject impersonation. | +| `404 MEMBER_NOT_FOUND` / `INVITATION_NOT_FOUND` | The authorized organization'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 membership in this organization already exists. | +| `409 ORGANIZATION_MEMBERSHIP_CONFLICT` | The invitee is affiliated with another org, including as a disabled owner. | +| `409 ORGANIZATION_CONTEXT_CHANGED` | Scoped Team request's expected owner differs from the actor's current org. Refresh context and require a new decision; do not replay stale intent in the new org. | +| `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 +332,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 an actor membership for the child's owner overlapping its lifetime 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.test.ts b/docs/api/scripts/check-openapi.test.ts new file mode 100644 index 000000000..509499541 --- /dev/null +++ b/docs/api/scripts/check-openapi.test.ts @@ -0,0 +1,111 @@ +import { expect, mock, test } from "bun:test"; +import * as fs from "node:fs"; + +type Document = Record; +const preconditionRef = "#/components/parameters/ExpectedOwnerProfileId"; +const scopedOperations = [ + ["/v1/organization/members", "get"], + ["/v1/organization/members/{memberProfileId}", "patch"], + ["/v1/organization/members/{memberProfileId}", "delete"], + ["/v1/organization/member-invitations", "get"], + ["/v1/organization/member-invitations", "post"], + ["/v1/organization/member-invitations/{invitationId}", "delete"], + ["/v1/organization/member-events", "get"] +] as const; +const cases: { name: string; error: string; mutate: (doc: Document) => void }[] = [ + ...scopedOperations.map(([path, method]) => ({ + name: `missing precondition: ${method} ${path}`, + error: "must require the UUID query precondition", + mutate: (doc: Document) => { + doc.paths[path][method].parameters = doc.paths[path][method].parameters.filter( + (p: Document) => p.$ref !== preconditionRef + ); + } + })), + ...([ + ["/v1/organization", "get"], + ["/v1/organization-member-invitations/{invitationId}", "get"], + ["/v1/organization-member-invitations/{invitationId}/accept", "post"] + ] as const).map(([path, method]) => ({ + name: `exempt route: ${method} ${path}`, + error: "must remain exempt", + mutate: (doc: Document) => { + doc.paths[path][method].parameters.push({ $ref: preconditionRef }); + } + })), + ...["optional", "header", "non-uuid", "non-string"].map(kind => ({ + name: `invalid precondition: ${kind}`, + error: "must require the UUID query precondition", + mutate: (doc: Document) => { + const parameter = doc.components.parameters.ExpectedOwnerProfileId; + if (kind === "optional") parameter.required = false; + if (kind === "header") parameter.in = "header"; + if (kind === "non-uuid") delete parameter.schema.format; + if (kind === "non-string") parameter.schema.type = "integer"; + } + })), + { + name: "item mutation loses typed context conflict", + error: "must require the UUID query precondition", + mutate: doc => { + doc.paths["/v1/organization/members/{memberProfileId}"].patch.responses[409].content["application/json"].schema = { + $ref: "#/components/schemas/ManagedProfileErrorResponse" + }; + } + }, + { + name: "invalid context conflict code", + error: "must expose typed code, message and status 409", + mutate: doc => { + doc.components.schemas.OrganizationContextChangedErrorResponse.properties.error.properties.code.const = "OTHER"; + } + }, + { + name: "bootstrap must document lifetime overlap", + error: "Managed lifecycle must document: membership.createdAt", + mutate: doc => { + const operation = doc.paths["/v1/managed-profiles/{profileId}"].get; + operation.description = operation.description.replace( + "membership.createdAt <= (child.deletedAt ?? now)", + "historic org membership" + ); + } + }, + { + name: "policy must not imply multi-org affiliation", + error: "ManagedProfilePolicy must describe", + mutate: doc => { + doc.components.schemas.ManagedProfilePolicy.description = "An actor may have children with different owners"; + } + } +]; + +const fixture = process.env.OPENAPI_CHECK_NEGATIVE; +if (fixture) { + const scenario = cases.find(item => item.name === fixture); + if (!scenario) throw new Error(`Unknown checker fixture: ${fixture}`); + const originalRead = fs.readFileSync; + // Preload in a child process: corrupt only its in-memory OpenAPI, never the shared worktree. + mock.module("node:fs", () => ({ + ...fs, + readFileSync(path: string, options: any) { + const value = originalRead(path, options); + if (path !== "docs/api/openapi/vortex.openapi.json") return value; + const doc = JSON.parse(String(value)); + scenario.mutate(doc); + return JSON.stringify(doc); + } + })); +} else { + for (const scenario of cases) { + test(scenario.name, () => { + const result = Bun.spawnSync(["bun", "--preload", import.meta.path, "docs/api/scripts/check-openapi.ts"], { + env: { ...process.env, OPENAPI_CHECK_NEGATIVE: scenario.name }, + stderr: "pipe", + stdout: "pipe" + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toString() + result.stdout.toString()).toContain(scenario.error); + }); + } +} diff --git a/docs/api/scripts/check-openapi.ts b/docs/api/scripts/check-openapi.ts index 402517208..c198061d2 100644 --- a/docs/api/scripts/check-openapi.ts +++ b/docs/api/scripts/check-openapi.ts @@ -6,7 +6,67 @@ 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/organization", "get", "OrganizationResponse", ["200", "400", "401", "403", "429", "500", "503"]], + [ + "/v1/organization/members", + "get", + "ListManagedProfileMembersResponse", + ["200", "400", "401", "403", "409", "429", "500", "503"] + ], + [ + "/v1/organization/members/{memberProfileId}", + "patch", + "ManagedProfileMemberResponse", + ["200", "400", "401", "403", "404", "409", "429", "500", "503"] + ], + [ + "/v1/organization/members/{memberProfileId}", + "delete", + null, + ["204", "400", "401", "403", "404", "409", "429", "500", "503"] + ], + [ + "/v1/organization/member-invitations", + "get", + "ListManagedProfileInvitationsResponse", + ["200", "400", "401", "403", "409", "429", "500", "503"] + ], + [ + "/v1/organization/member-invitations", + "post", + "ManagedProfileInvitationResponse", + ["200", "201", "400", "401", "403", "409", "429", "500", "503"] + ], + [ + "/v1/organization/member-invitations/{invitationId}", + "delete", + null, + ["204", "400", "401", "403", "404", "409", "429", "500", "503"] + ], + [ + "/v1/organization/member-events", + "get", + "ListManagedProfileMemberEventsResponse", + ["200", "400", "401", "403", "409", "429", "500", "503"] + ], + [ + "/v1/organization-member-invitations/{invitationId}", + "get", + "PreviewManagedProfileInvitationResponse", + ["200", "400", "401", "403", "429", "500", "503"] + ], + [ + "/v1/organization-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 +135,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 +188,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 +486,8 @@ 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") || path.startsWith("/v1/organization")) && + !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 +514,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 ten organization/team/invitation operations."); } function operationAt(path: string, method: string): JsonObject { @@ -515,7 +577,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 +592,351 @@ 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("live organization membership") || + !String((actorProperties.hasMemberships as JsonObject).description).includes("even with zero children") || + !String((actorProperties.hasMemberships as JsonObject).description).includes("active owner configuration") +) { + throw new Error("ManagedProfileActor must expose owner-only provisioning and live organization membership flags."); +} +assertRequiredFields("OrganizationResponse", ["organization"]); +assertRequiredFields("Organization", ["ownerProfileId", "ownerEmail", "membership"]); +assertRequiredFields("OrganizationIdentity", ["ownerProfileId", "ownerEmail"]); +assertRequiredFields("OrganizationMembership", ["role", "isOwner"]); +const organizationProperties = (schemas.Organization as JsonObject).properties as JsonObject; +const organizationIdentityProperties = (schemas.OrganizationIdentity as JsonObject).properties as JsonObject; +const organizationMembershipProperties = (schemas.OrganizationMembership as JsonObject).properties as JsonObject; +const organizationResponse = ((schemas.OrganizationResponse as JsonObject).properties as JsonObject).organization as JsonObject; +const previewProperties = (schemas.PreviewManagedProfileInvitationResponse as JsonObject).properties as JsonObject; +const inviter = previewProperties.inviter as JsonObject; +const inviterProperties = inviter.properties as JsonObject; +const acceptProperties = (schemas.AcceptManagedProfileInvitationResponse as JsonObject).properties as JsonObject; +if ( + JSON.stringify(organizationResponse.oneOf) !== + JSON.stringify([{ $ref: "#/components/schemas/Organization" }, { type: "null" }]) || + !transitivelyReferences(organizationProperties.membership, "#/components/schemas/OrganizationMembership") || + !transitivelyReferences(organizationMembershipProperties.role, "#/components/schemas/ManagedProfileMembershipRole") || + (organizationMembershipProperties.isOwner as JsonObject).type !== "boolean" || + !transitivelyReferences(previewProperties.organization, "#/components/schemas/OrganizationIdentity") || + JSON.stringify(Object.keys(inviterProperties).sort()) !== JSON.stringify(["email", "profileId"]) || + JSON.stringify([...(inviter.required as string[])].sort()) !== JSON.stringify(["email", "profileId"]) || + JSON.stringify((inviterProperties.email as JsonObject).type) !== JSON.stringify(["string", "null"]) || + (inviterProperties.profileId as JsonObject).format !== "uuid" || + (acceptProperties.ownerProfileId as JsonObject).format !== "uuid" || + !transitivelyReferences(acceptProperties.member, "#/components/schemas/ManagedProfileMember") || + [organizationProperties, organizationIdentityProperties].some( + properties => + (properties.ownerProfileId as JsonObject).type !== "string" || + (properties.ownerProfileId as JsonObject).format !== "uuid" || + JSON.stringify((properties.ownerEmail as JsonObject).type) !== JSON.stringify(["string", "null"]) + ) +) { + throw new Error( + "Organization discovery must be nullable; discovery and invitee responses must expose the exact owner, inviter and member shapes." + ); +} +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", + "ownerProfileId", + "invitedByProfileId", + "email", + "role", + "status", + "createdAt", + "expiresAt", + "acceptedAt", + "cancelledAt", + "expiredAt" +]); +assertRequiredFields("ManagedProfileInvitationResponse", ["invitation"]); +assertRequiredFields("ListManagedProfileInvitationsResponse", ["invitations", "pagination"]); +assertRequiredFields("PreviewManagedProfileInvitationResponse", ["invitation", "inviter", "organization"]); +assertRequiredFields("AcceptManagedProfileInvitationResponse", ["ownerProfileId", "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", + "all present and future children", + "live organization membership even with zero children" + ] + ], + [ + detailManagedProfile.description, + [ + "Bootstrap is explicitly GET detail with an exactly matching X-Managed-Profile-Id", + "Stored membership history (active or revoked) is required", + "for this actor and the child's immutable owner", + "membership.createdAt <= (child.deletedAt ?? now)", + "membership.revokedAt IS NULL or membership.revokedAt > child.createdAt", + "on the same membership row", + "wholly within a membership gap remains masked 404", + "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, true, 0], + ["memberBeforeProvisioning", false, true, 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.`); + } +} + +const contextError = schemas.OrganizationContextChangedErrorResponse as JsonObject; +assertRequiredFields("OrganizationContextChangedErrorResponse", ["error"]); +const contextErrorBody = (contextError.properties as JsonObject).error as JsonObject; +const contextErrorProperties = contextErrorBody.properties as JsonObject; +if ( + JSON.stringify(Object.keys(contextErrorProperties).sort()) !== JSON.stringify(["code", "message", "status"]) || + JSON.stringify([...(contextErrorBody.required as string[])].sort()) !== JSON.stringify(["code", "message", "status"]) || + (contextErrorProperties.code as JsonObject).const !== "ORGANIZATION_CONTEXT_CHANGED" || + (contextErrorProperties.status as JsonObject).const !== 409 || + (contextErrorProperties.message as JsonObject).type !== "string" +) { + throw new Error("Organization context conflict must expose typed code, message and status 409."); +} +const expectedOwnerParameter = ((openapi.components as JsonObject).parameters as JsonObject) + .ExpectedOwnerProfileId as JsonObject; +for (const statement of ["derived server-side", "live service authorization", "not authority or a multi-org selector"]) { + if (!String(expectedOwnerParameter?.description).includes(statement)) { + throw new Error(`Organization precondition must document: ${statement}`); + } +} +if ( + !String(managerPolicySchema.description).includes("immutable controlling owner's current policy") || + String(managerPolicySchema.description).includes("different owners") +) { + throw new Error("ManagedProfilePolicy must describe the current immutable controlling owner's policy, not multiple owners."); +} + +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/organization-member-invitations/"); + const pathItem = (openapi.paths as JsonObject)[path] as JsonObject; + const parameters = [...((pathItem.parameters ?? []) as JsonObject[]), ...((operation.parameters ?? []) as JsonObject[])]; + const resolvedParameters = parameters.map( + p => (typeof p.$ref === "string" ? valueAtPointer(openapi, p.$ref) : p) as JsonObject + ); + if ( + resolvedParameters.some( + p => p?.name === "X-Managed-Profile-Id" || p?.name === "profileId" || p?.name === "ownerProfileId" + ) || + !String(operation.description).includes( + "Rejects any child selector, API/public-key headers (even with a bearer), and impersonation" + ) || + ((invitee || method === "get" || method === "delete") && operation.requestBody) + ) { + throw new Error(`${method.toUpperCase()} ${path} must not advertise request-email acceptance or secret-key selection.`); + } + const scopedTeam = path.startsWith("/v1/organization/"); + const expectedOwners = resolvedParameters.filter(p => p?.name === "expectedOwnerProfileId"); + if (scopedTeam) { + const precondition = expectedOwners[0]; + const schema = precondition?.schema as JsonObject | undefined; + if ( + expectedOwners.length !== 1 || + precondition.in !== "query" || + precondition.required !== true || + schema?.type !== "string" || + schema.format !== "uuid" || + !transitivelyReferences(responses["409"], "#/components/schemas/OrganizationContextChangedErrorResponse") || + !JSON.stringify(responses["400"]).includes("MANAGED_PROFILE_INVALID_INPUT") || + !JSON.stringify(responses["400"]).includes("expectedOwnerProfileId") + ) { + throw new Error( + `${method.toUpperCase()} ${path} must require the UUID query precondition expectedOwnerProfileId with documented 400 and typed context 409.` + ); + } + } else if ( + expectedOwners.length > 0 || + transitivelyReferences(responses, "#/components/schemas/OrganizationContextChangedErrorResponse") + ) { + throw new Error( + `${method.toUpperCase()} ${path} must remain exempt from the expectedOwnerProfileId precondition and context conflict.` + ); + } + 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/organization/member-invitations", "post"); +if ( + !transitivelyReferences(invitationCreate.requestBody, "#/components/schemas/CreateManagedProfileInvitationRequest") || + !transitivelyReferences( + (invitationCreate.responses as JsonObject)["201"], + "#/components/schemas/ManagedProfileInvitationResponse" + ) || + !transitivelyReferences( + operationAt("/v1/organization/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 invitationAccept = operationAt("/v1/organization-member-invitations/{invitationId}/accept", "post"); +if ( + !JSON.stringify((invitationAccept.responses as JsonObject)["409"]).includes("ORGANIZATION_MEMBERSHIP_CONFLICT") || + !String(invitationAccept.description).includes("including disabled owners") || + !String(invitationAccept.description).includes("inviter removal or downgrade") || + (invitationProperties.ownerProfileId as JsonObject).format !== "uuid" || + "MembershipSelection" in ((openapi.components as JsonObject).parameters as JsonObject) || + "MembershipProfileId" in ((openapi.components as JsonObject).parameters as JsonObject) +) { + throw new Error( + "Organization invitations must preserve durable offers, reject second-org acceptance, and remove child selection." + ); +} +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 +983,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}`); } diff --git a/docs/architecture-email-notifications.md b/docs/architecture-email-notifications.md index 33c01cacb..390a13972 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,47 @@ 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. +**Organization membership invitation** — invitation creation inserts the owner-scoped 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. + +The existing `managed_profile_membership_invitation` discriminator and internal +producer/template filenames are intentionally unchanged. The invitation is a durable +organization offer, including for an empty org; inviter removal or downgrade does not cancel +it. Active owner config, exact current verified email, and explicit acceptance are still +required. The API preview identifies the organization by `ownerProfileId` and nullable +`ownerEmail`, not a child. See [ADR 0006](adr-0006-organization-wide-teams.md). + +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, owner/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`. -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 +154,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 +211,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 +221,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 +230,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 +275,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 +322,36 @@ 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 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` | 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` | 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`. -> **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 +394,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 +424,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 +447,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 +469,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 +486,60 @@ 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` | 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. -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 +561,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..5972f57a5 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 architectural contract, including the approved rewrite of unshipped +migration 069 under [ADR 0006](adr-0006-organization-wide-teams.md), 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,10 @@ erDiagram profiles ||--o| managed_profile_managers : enables managed_profile_managers ||--o{ managed_profiles : controls profiles ||--o| managed_profiles : identifies + managed_profile_managers ||--|{ managed_profile_memberships : authorizes + profiles ||--o{ managed_profile_memberships : membership_history + managed_profile_managers ||--o{ managed_profile_membership_invitations : offers + managed_profile_managers ||--o{ managed_profile_membership_events : audits customer_entities ||--o{ recipient_invitations : sends customer_entities ||--o{ sender_recipients : participates sender_recipients ||--o{ recipient_payout_references : uses @@ -114,15 +118,100 @@ 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. + +### Organization affiliation and inherited child access + +Exactly one owning manager account/configuration defines exactly one organization. This +is the current **one-account-one-org approximation**, not a generic organization entity +model. Every person, including owners, invited managers, and read-only members, is limited +to one active organization affiliation. An owner, including one with a disabled manager +configuration, cannot join another organization. Personal user resources are not shared. + +The unshipped per-child feature and its data are disposable. Migration 069 is rewritten +directly, without a forward migration or compatibility path. It retains the existing +membership/invitation/event table names and internal class filenames, replacing the +`managed_profile_id` property with `owner_profile_id`, a foreign key to +`managed_profile_managers.profile_id`. Membership roles remain `manager` and `read_only`. +The active unique constraint is global on `member_profile_id`; the ER's many memberships +represent retained history, not concurrent affiliations. Invitations are unique while +pending by `(owner_profile_id, email)`, and events are append-only in the same owner scope. + +Backfill exactly one protected owner-manager self-membership per manager configuration, +including disabled owners and owners with no children. New configuration creation adds +this membership and its `member_added` event once; child provisioning adds no grants or +membership events. Config-created self-membership has `createdByProfileId: null` and its +event has `actorProfileId: null` for system attribution, with the owner as member subject; +`ADMIN_SECRET` does not identify the owner as an acting human. Owner membership cannot be removed or downgraded. Active owner +configuration is required for organization/team operations and delegated child access; +deactivation retains memberships and denies operations rather than releasing affiliation. + +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. 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. `hasMemberships` means live organization membership with an +active owner configuration, even with zero children, independent of page/status/detail target. +Both the enabled owner and invited members retain this flag in an empty organization. Each +returned child still requires an active relationship and valid entity layout. Organization +roles apply to all present and future children of that owner, not a per-child assignment. +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`. Prior access +requires an actor membership for that immutable owner overlapping the child's lifetime: +`membership.createdAt <= (child.deletedAt ?? now)` and (`membership.revokedAt IS NULL` or +`membership.revokedAt > child.createdAt`) on the same row. Only this evidence permits +`MANAGED_PROFILE_MEMBERSHIP_INVALID` after membership, owner, child or entity eligibility is lost. +Children created after revocation or wholly within a membership gap remain masked `404` even +with historic org membership; 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 durable organization offers and expire after seven days; inviter +removal or downgrade does not invalidate a pending offer. The exact current verified Supabase +email must explicitly accept; OTP alone never grants membership. Accepting a second organization +returns `409 ORGANIZATION_MEMBERSHIP_CONFLICT`, including for disabled owners. Invitation, +membership, and event rows are not directly available through PostgREST. Team lives in the main +nonacting dashboard and works without children. `GET /v1/organization` returns the actor's live +organization or null; `/v1/organization/*` exposes its roster, invitations, and history. +Invitee preview/acceptance uses `/v1/organization-member-invitations/:invitationId`. +All organization, team, and invitee routes require a human Supabase bearer and reject any child +selector, API/public key, and impersonation. No old per-child team aliases remain. + +All seven scoped Team operations require UUID query `expectedOwnerProfileId`, including item +PATCH/DELETE. It binds the displayed org, not authority or a multi-org selector: current org +remains server-derived with live service authorization. Missing/malformed input is +`400 MANAGED_PROFILE_INVALID_INPUT`; a different expected/current owner is +`409 ORGANIZATION_CONTEXT_CHANGED`. Discovery and invitee locator routes remain exempt. +This prevents a stale A dialog from issuing a B invitation after removal from A and acceptance +of B elsewhere; clients must refresh context and require a new decision, not replay the dialog. + +Removal or downgrade affects delegated access to all children but does not revoke child-owned +shared credentials. No multi-organization management, organization kinds, owner transfer, or +organization switcher is supported. Any such capability requires explicitly revisiting the +architectural model through a later ADR, not reinterpreting membership. 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), superseded in scope by +[`ADR 0006`](adr-0006-organization-wide-teams.md). ### Recipients @@ -150,9 +239,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 +261,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 @@ -184,7 +278,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. @@ -200,6 +294,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..014186038 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,60 @@ 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-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 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 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. + +The [organization-wide Team contract](adr-0006-organization-wide-teams.md) adds these required +regression gates to the membership, migration, route, and dashboard suites. These are acceptance +criteria, not a claim that a local or deployed suite has already passed: + +- Migration 069 is rewritten directly for disposable unshipped data: `owner_profile_id` references + manager config, one active membership is globally unique on `member_profile_id`, and each config + has one protected owner self-membership, including disabled owners and empty orgs. New config + creation emits one membership event; child provisioning emits no grants or membership events. + Config-created `createdByProfileId` and event `actorProfileId` are null/system attribution, + with the owner as member subject, rather than an invented human actor for `ADMIN_SECRET`. +- Existing and newly provisioned siblings inherit the org role without grants. Foreign children + and human personal resources remain inaccessible. Read-only cannot write using a personal secret. +- Owner/manager/read-only and concurrent second-org accepts enforce single affiliation; + `ORGANIZATION_MEMBERSHIP_CONFLICT` is `409`. Disabled owners cannot join another org, and + accepting an invitation races safely with enabling a separate owning manager configuration. +- Deactivation retains memberships while denying org/team/child operations. Removal/downgrade + changes all child delegated access without revoking child-owned shared credentials. Pending + offers survive inviter removal/downgrade; verified email, explicit acceptance, seven-day expiry, + transactional audit/outbox, replay, owner protection, and RLS tests remain required. +- All ten organization/team/invitee operations reject any child selector, API/public-key headers + (including with a bearer), and impersonation. Old per-child team/invitee routes have no aliases. + Pin exact organization discovery, preview/acceptance, member/event projections, and pagination. +- Keep the existing runtime regressions for owner-matching membership/child-lifetime overlap: + `membership.createdAt <= (child.deletedAt ?? now)` and (`membership.revokedAt IS NULL` or + `membership.revokedAt > child.createdAt`) on one row. Children created after revocation or + wholly in a membership gap stay masked `404`; historic org membership alone is insufficient. +- Team route/client regressions bind all seven operations to required UUID query + `expectedOwnerProfileId`: missing/malformed `400 MANAGED_PROFILE_INVALID_INPUT`, different + expected/current owner `409 ORGANIZATION_CONTEXT_CHANGED`, and discovery/invitee exemptions. + A stale invitation dialog for A must never create an invite in B after cross-tab affiliation change. + `bun test ./docs/api/scripts/check-openapi.test.ts` separately pins the documented parameter, + exemptions, typed conflict, bootstrap prose, and owner-policy description without duplicating + runtime authorization tests or mutating the shared worktree. +- Main nonacting Team works on desktop/mobile before any children exist; both owners and invited + members get live org discovery and `hasMemberships: true` for empty orgs. `canProvisionManagedProfiles` + stays owner-only. Child-mode Team is absent, role changes refresh every child's gates, shared-key + warnings are visible, and invitations lead to the org rather than selecting a child. +- Run `bun docs:api:types`, then `bun docs:api:check` for OpenAPI/schema/auth inventory and generated + freshness; `bun wire-contract:check` should show no shared/SDK snapshot change. This deliberately + breaks the unshipped per-child API, so do not add compatibility paths or run the Apidog export. + +Recreate a disposable local test database if it applied the old 069; do not test the rewritten +migration against that stale schema or add a production forward-compatibility migration for it. Notes: @@ -269,7 +315,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..1a9aee09c 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,39 +133,62 @@ 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 category — recipient-approval alerts — was dropped for now: no such notification type exists in the backend yet.) -### Managed profiles (implemented) +### Organization Team and managed profiles + +The approved model is a **one-account-one-org approximation**: one owning manager +account/config defines exactly one organization, and every person has at most one active +org affiliation. Owners, including disabled owners, cannot join another org. All present +and future children inherit the organization role. Personal user resources are not shared. +There is no multi-organization management, organization kind, owner transfer, or organization +switcher. Such capabilities require explicitly revisiting the architectural model in a later +ADR, not reinterpreting membership. See [ADR 0006](adr-0006-organization-wide-teams.md). 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. + +- 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` means live organization membership even with zero children, + independently of page/status; owner deactivation makes it false but retains affiliation. 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 all eligible children of my organization. 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 the displayed wallet session, and redirects to `/overview`. It MUST NOT clear ramp ephemerals, @@ -171,43 +197,104 @@ 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 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 an actor membership for the child's owner + overlapping the child lifetime 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. + +Bootstrap evidence requires the same membership row to satisfy +`membership.createdAt <= (child.deletedAt ?? now)` and (`membership.revokedAt IS NULL` or +`membership.revokedAt > child.createdAt`), with matching actor and immutable owner. A child +created after revocation or wholly within a membership gap stays masked `404`, despite historic +org membership; this is not permission to clear selection or disclose that child. + +**Child-mode navigation.** Onboarding status, Recipients, Get a quote, Transactions, child API keys, +and Limits remain available where their API routes support managed-child authorization. Team +is not a child-mode surface; it belongs in the main nonacting dashboard. 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. +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. -**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.** In the main nonacting dashboard, `GET /v1/organization` discovers +`{ organization: { ownerProfileId, ownerEmail, membership: { role, isOwner } } | null }`. +`ownerEmail` is nullable. Team is available to both roles even before any child exists. +Both roles can inspect the organization 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. +The invitation list hides accepted invitations addressed to the viewer's current email; +other members still see those records. This is presentation-only: the API, pagination, +membership roster, and access history remain unchanged. + +Active owner configuration is required for Team and org operations. Deactivation retains +memberships but denies operations and returns no live organization on discovery. Only the +owner can provision/delete children, inspect retained deleted children, or control owner policy +through existing administration. An invited Manager cannot do these. Read-only permits no +writes, including delegated calls using personal secrets. Removing/downgrading a member changes +access to all children but does not revoke child-owned shared API credentials; the UI must warn +that exposed shared keys need separate revocation. + +Team requests use `/v1/organization/members`, `/member-invitations`, and `/member-events` with +the existing member/invitation/event pagination and projections. All organization, team, and +invitee routes require a human Supabase bearer and reject any child selector, API/public-key +headers, and impersonation. No old per-child Team paths or aliases remain. + +All seven scoped Team requests, including item PATCH/DELETE, carry required UUID query +`expectedOwnerProfileId` captured from the displayed organization. Missing/malformed input is +`400 MANAGED_PROFILE_INVALID_INPUT`; expected/current org mismatch is +`409 ORGANIZATION_CONTEXT_CHANGED`. Discovery and invitee locator routes are exempt. The server +still derives the current org and enforces live service authorization; the precondition is not +authority or an org switcher. A dialog opened in A must retain A's owner ID even if another tab +accepts B after removal from A. On context conflict, refresh the org, discard stale intent, and +require a new user decision instead of silently submitting the old invitation into B. + +An invitation link opens `/member-invitations/:invitationId`. Before authentication the page shows +no organization, 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, second-org conflict, and success states remain distinct. Preview and +acceptance use `/v1/organization-member-invitations/:invitationId` and `/:invitationId/accept`. +Preview identifies `{ ownerProfileId, ownerEmail }`, not a child. Acceptance returns +`{ ownerProfileId, member }`, refreshes organization and managed-profile queries, and leads to +the main organization/Team surface, including when no children exist. OTP alone grants no access. +Invitations expire after seven days and remain durable org offers even after the inviter is +removed. A person already affiliated elsewhere, including a disabled owner, receives +`409 ORGANIZATION_MEMBERSHIP_CONFLICT` rather than an organization switcher. + +**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 +368,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 +384,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 +392,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 +402,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 +437,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 +471,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..6696eecea 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 organization +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 all organization/team/invitee operations, including reads. 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,29 @@ 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 use organization/team/invitee APIs** - organization + discovery, team reads and mutations, invitation preview and acceptance all reject impersonation. + Supported child inspection still uses only the target's already-live organization membership + for the child's immutable owner, inherited across that org's children. It cannot manufacture + or upgrade child access. Team remains a main nonacting human-account surface. ## 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 +279,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 +308,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..6e8afa7d8 100644 --- a/docs/security-spec/01-auth/api-keys.md +++ b/docs/security-spec/01-auth/api-keys.md @@ -32,9 +32,13 @@ 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 | +| 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 | @@ -48,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 @@ -56,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: @@ -96,9 +100,29 @@ 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 /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, effective organization role and immutable owner policy. Default active lists return `200` even when empty; `hasMemberships` means live organization membership with active owner configuration even with zero children, independent of child eligibility, page or status. 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 for the child's owner 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 [Organization 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**: one owning account/config defines one organization, with a protected owner `manager` self-membership per config, including disabled owners and owners with no children. New config creation adds membership and its append-only `member_added` event once; child provisioning adds no grants/events. Rewritten unshipped migration 069 retains table/class filenames but uses `owner_profile_id` referencing manager config and global active membership uniqueness on `member_profile_id`. Every person has at most one active affiliation; owners including disabled owners cannot join another org. Roles apply to all present/future children, never human personal resources. Deactivation retains memberships and denies org/team/child operations. Membership and invitation changes write append-only events atomically; pending org offers survive inviter removal/downgrade, and second-org acceptance returns `409 ORGANIZATION_MEMBERSHIP_CONFLICT`. All organization/team/invitee routes require human Supabase bearer authentication and reject any child selector, API/public-key headers (even with a bearer), and impersonation. Member removal/downgrade changes every child's delegated access without revoking shared child-owned credentials. See [ADR 0006](../../adr-0006-organization-wide-teams.md) for the one-account-one-org approximation and the requirement for a later ADR before multi-organization management, organization kinds, owner transfer, or a switcher. + +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. + +Lifecycle membership-history evidence is specific to the actor and child's immutable owner +and requires lifetime overlap on one row: `membership.createdAt <= (child.deletedAt ?? now)` +and (`membership.revokedAt IS NULL` or `membership.revokedAt > child.createdAt`). Historic org +membership alone cannot reveal a child created after revocation or wholly within a membership +gap; these bootstrap probes are masked `404`, not membership-invalid. + +All seven scoped Team operations require UUID query `expectedOwnerProfileId` to bind the +displayed organization: missing/malformed input is `400 MANAGED_PROFILE_INVALID_INPUT`, and a +different expected/current org is `409 ORGANIZATION_CONTEXT_CHANGED`. Discovery and invitee +locator routes are exempt. Current org remains server-derived with live service authorization; +the precondition is neither authority nor a multi-org selector, and API keys remain forbidden. +Config-created owner self-membership/event attribution is system/null (`createdByProfileId` +and `actorProfileId`), not a fabricated owner action from `ADMIN_SECRET`; the owner is subject. ## Threat Vectors & Mitigations @@ -107,14 +131,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 new file mode 100644 index 000000000..75f29c80c --- /dev/null +++ b/docs/security-spec/01-auth/managed-profile-memberships.md @@ -0,0 +1,269 @@ +# Organization Memberships + +## What This Does + +Organization memberships grant an authenticated profile inherited access to all present +and future headless children of one owning manager configuration, without changing child +or personal resource ownership. The immutable owner in +`managed_profiles.manager_profile_id` remains the policy, pricing, namespace, provisioning, +and deletion principal. Exactly one owning account/config defines exactly one organization: +the current **one-account-one-org approximation** under [ADR 0006](../../adr-0006-organization-wide-teams.md). +Every person has at most one active org affiliation; owners, including disabled owners, +cannot join another org. Personal resources are not shared. Multi-organization management, +organization kinds, owner transfer, and an organization switcher are unsupported. Adding +them requires explicitly revisiting the architectural model in a later ADR, not reinterpreting +membership. Existing internal membership table/class filenames remain unchanged. + +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 + +`GET /v1/organization` returns `{ organization: { ownerProfileId, ownerEmail, membership: +{ role, isOwner } } | null }`, with nullable `ownerEmail`. Null means no live organization +membership, including a disabled owner configuration. Team routes derive that same owner +from the human actor, never a path/body owner selector, and are nested under `/v1/organization`: + +| 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 | + +All seven scoped Team operations above require the UUID query parameter +`expectedOwnerProfileId`, including item PATCH/DELETE. Missing or malformed input returns +`400 MANAGED_PROFILE_INVALID_INPUT`; an expected owner different from the actor's current +organization returns `409 ORGANIZATION_CONTEXT_CHANGED` in the structured service error body. +The value binds the displayed org, not authority: current org is still derived server-side and +live service authorization applies. Discovery and both invitee locator routes are exempt. + +Invitee routes are bearer-authenticated and do not accept managed selection: + +| Route | Purpose | +| ------------------------------------------------------------------ | ------------------------------------------------------------- | +| `GET /v1/organization-member-invitations/:invitationId` | Preview status only after exact verified-email authentication | +| `POST /v1/organization-member-invitations/:invitationId/accept` | Explicitly accept and create/reactivate membership | + +Preview returns `{ invitation, inviter: { email, profileId }, organization: +{ ownerProfileId, ownerEmail } }`. Acceptance returns `{ ownerProfileId, member }` with the +existing member projection. Invitation `ownerProfileId` replaces `managedProfileId`. +Member/event/body projections and pagination are unchanged. All ten organization/team/invitee +operations require human Supabase bearer authentication and reject any child selector, +API/public-key header (even alongside a bearer), and impersonation. Old per-child team paths +and invitee paths are removed without aliases. Active owner configuration is required for +team and invitee operations even if no children exist. + +### 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` means +live organization membership with active owner configuration, even with zero children, +independently of page, status filter or detail target. Both enabled owners and invited members +can have `hasMemberships: true` in an empty org. Child eligibility separately requires an +active child relationship, managed subject, and exactly one active owned customer entity +selected by the child; invalid/deleted children do not remove the org affiliation. + +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; they do not determine `hasMemberships`. + +`GET /v1/managed-profiles/:profileId` with an exactly matching `X-Managed-Profile-Id` is explicit +bootstrap. Prior access requires an actor membership for the child's immutable owner whose +interval overlaps the child lifetime: `membership.createdAt <= (child.deletedAt ?? now)` and +(`membership.revokedAt IS NULL` or `membership.revokedAt > child.createdAt`) on the same row. +Historic org membership alone is insufficient. A child created after revocation or wholly +within a membership gap receives masked `404`, even with a matching selector. 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 organization-scoped** — every delegated request resolves the + selected managed child, its active immutable owner configuration, and the actor's active + membership for that owner. The role covers all present and future children of that owner, + not a per-child grant. 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, including when the owner is disabled or has no children. Migration 069 + backfills one self-membership per manager config; new config creation adds it and its + event once. Child provisioning MUST NOT add grants or membership events. +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. 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. 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 + capabilities without a human membership. Member removal or downgrade affects delegated + access to every child but does not revoke these credentials; child deletion does. +10. **All organization operations MUST reject non-human principals and selectors** — discovery, + team reads/mutations, and invitee preview/acceptance require a current human Supabase bearer + session and reject API/public keys, direct child credentials, any child selector, and + admin impersonation. A matching or malformed selector is not an exception. +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 `(owner_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 organization, + 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, active owner, 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 consistent owner-first locking** - organization configuration and + owner authorization (including protected self-membership) precede target mutation. + Acceptance locks existing owner/invitee configurations in stable order before the + invitee profile and invitation/membership rows. Child resource mutations retain + owner-first, child-aggregate, membership ordering. Role change, removal, cancellation, + expiry, and acceptance recheck live authority under locks. Cross-org accept and manager + configuration creation races MUST serialize on the person's affiliation and preserve + global active membership uniqueness. +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. Config-created owner + self-membership uses `createdByProfileId: null`, and its event uses `actorProfileId: null` + (system attribution); the owner remains the member subject. `ADMIN_SECRET` identifies no + human operator and MUST NOT falsely attribute this action to the owning account. +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** — detail bootstrap MUST require an exactly matching + selector and at least one actor membership for the child's immutable owner satisfying + `membership.createdAt <= (child.deletedAt ?? now)` and (`membership.revokedAt IS NULL` or + `membership.revokedAt > child.createdAt`) on the same row before returning + `403 MANAGED_PROFILE_MEMBERSHIP_INVALID` for ineligibility. Use now for an undeleted child. + A child created after revocation or wholly within a membership gap MUST receive masked + `404` even if the actor has historic org membership. 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, + 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. +21. **One active affiliation MUST be enforced globally** — rewritten unshipped migration 069 + uses `owner_profile_id` referencing `managed_profile_managers.profile_id` in the existing + membership/invitation/event tables, with active membership uniqueness on `member_profile_id`. + Owners (including disabled owners) cannot accept another org. Invited managers and + read-only members cannot hold a second active org membership or become a separate owner. + Second-org acceptance returns `409 ORGANIZATION_MEMBERSHIP_CONFLICT`; no event or grant + may commit on conflict. No compatibility schema or forward migration is introduced. +22. **Deactivation MUST retain affiliation and deny operations** — organization/team and child + operations require active owner config. Disabling it does not revoke membership, permit + joining another org, or remove owner protection. Discovery returns null, not a live org. +23. **Pending invitations MUST remain durable org offers** — subsequent removal or downgrade + of the inviter does not cancel an offer. Acceptance rechecks organization availability, + verified invitee and global affiliation, not the inviter's continued membership. +24. **Team requests MUST bind the displayed organization** - all seven scoped Team operations + require query `expectedOwnerProfileId` as a UUID; absent/malformed values return + `400 MANAGED_PROFILE_INVALID_INPUT`. Derive the actor's current organization server-side; + mismatch returns `409 ORGANIZATION_CONTEXT_CHANGED`, never an operation against a newly + joined org. Live service authorization still gates access. The precondition does not grant + authority, select an org, or introduce multi-org management. Discovery and invitee locator + routes are exempt. A client MUST retain the displayed owner with each request/dialog and + require a new user decision after context refresh, not silently replay stale intent. + +## Threat Vectors & Mitigations + +| Threat | Attack scenario | Mitigation | +| -------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Cross-org access | A member changes `X-Managed-Profile-Id` to a foreign child | Resolve membership for the child's immutable owner; siblings inherit access, foreign orgs do not | +| 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 invitations to different orgs concurrently | Person-level affiliation serialization plus global active uniqueness permits only one org | +| Stale dashboard authority | Cached manager role remains after downgrade | Server checks live membership; bootstrap refresh removes controls without treating cache as authority | +| Historic affiliation leaks a child | Actor probes a child created after revocation or wholly inside a membership gap | Require owner-matching membership interval overlap with child lifetime; otherwise masked `404` | +| Stale org dialog mutates a new org | Actor opens an invite in A, is removed, accepts B elsewhere, then submits the old dialog | Required expected-owner query precondition fails `409 ORGANIZATION_CONTEXT_CHANGED`; live org is never selected by the parameter | +| 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 one protected owner-manager membership per config, including disabled + owners and empty orgs; new config creation emits one event and child creation adds no grants. +- [ ] 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 ten organization/team/invitee operations reject impersonation, any child selector, + and API/public keys, including key-plus-bearer combinations; old paths have no aliases. +- [ ] All seven scoped Team operations require the expected-owner UUID query precondition, + reject malformed/missing input with `400`, and mismatched context with typed `409`; + discovery/invitee routes remain exempt and stale A dialogs cannot mutate B. +- [ ] 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. +- [ ] Actor flags remain independent of pagination/status; empty default lists return `200`; + live org membership counts even with zero eligible children and disabled owners do not. +- [ ] Second-org acceptance and owner enablement races preserve global uniqueness; disabled + owners cannot join another org, and deactivation retains memberships while denying access. +- [ ] Pending offers survive inviter removal/downgrade; acceptance still checks expiry and owner. +- [ ] Removal/downgrade changes access to all present/future children without revoking shared keys. +- [ ] 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 and owner-matching membership interval overlap; + deleted owner bootstrap invalidates, and children created after revocation or wholly in a + membership gap remain masked `404` like never-member existing/unknown probes. +- [ ] Desktop and mobile tests cover main nonacting Team for empty orgs, role badges, read-only controls, 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..c62faf4e4 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,15 @@ 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 organization membership for the child's immutable owner and the + owner relationship. The role applies to all present/future children, never the member's + personal recipient resources; see [Organization Memberships](../01-auth/managed-profile-memberships.md). + `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..41eb097a8 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. @@ -90,13 +91,15 @@ Under RISK-021, caller correctness is relied on when Avenia omits `accountInfo.t provider value is still compared and a mismatch is rejected. The route accepts only a profile-bound secret key or Supabase session. A direct profile acts for -itself. A controlling manager may select one directly managed child, but direct child credentials -are explicitly rejected. Authentication and managed authorization precede the route-local JSON +itself. An active organization `manager` member may select any eligible child of the immutable +owner using that member's own secret credential; selected-child bearers and direct child +credentials are explicitly rejected. Authentication and managed authorization precede the route-local JSON parser and strict validation of the required `Idempotency-Key` and `{ importToken, consentAttested: true }` body. The parser accepts `application/json` and has a 16 KiB raw-body limit, which accommodates JSON escaping of the 1 KiB token limit without exposing the global 20 MB buffer before authentication. No caller identity selector is accepted in the body or query. -The service locks and rereads the manager, exact relationship, subject, and active entity under the +The service locks and rereads the owner config, exact relationship, subject, active entity, and +exact organization membership (owner, actor, role `manager`, unrevoked) under the canonical KYC-case transaction before claim reuse or creation, then repeats that authorization before changing a prepared claim to submitted. The submission transaction locks the provider customer before the case, then checks authorization, binding, method, claim state, and both approval states. Revocation @@ -122,13 +125,21 @@ before claim/reconciliation replay; only a provider-confirmed retryable terminal the old claim so a new payload fingerprint can be prepared. The full identity payload is sent through sensitive-body mode; request logging and echoed provider error details are replaced with fixed non-sensitive text. Unmanaged service calls require the actor and subject to match and forbid -managed selectors. Managed submissions allow either the controlling manager or direct child as actor, -and lock the provider customer before the case, then revalidate the controlling active manager, exact relationship, +managed selectors. Managed submissions allow an active organization `manager` member using a +personal secret, or a direct child credential, as actor. They lock the provider customer before +the case, then revalidate the controlling active owner, exact relationship, and, for a delegated +actor, the exact active organization manager membership bound to that owner and actor, `BR` and individual policy, managed subject, and expected active entity during preparation and again immediately before `prepared -> submitted`; revocation fails only that matching prepared standard claim and prevents the Level 1 POST. Initial submission method selection performs the same transactional authorization check before changing a nullable method to `standard`. +Organization roles inherit across all present and future children, not per-child grants; +personal human resources remain private. Removal/downgrade blocks subsequent delegated claims +for every child without revoking shared child credentials. See +[Organization Memberships](../01-auth/managed-profile-memberships.md) for single affiliation, +owner deactivation, and the one-account-one-org architectural boundary. + Those five fields are runtime-validated before any of them is read (invariant 31). A signed body is still an untrusted shape: the payload is persisted and later rendered into a user's inbox, so a missing `status` or `updatedAt` is rejected `400` rather than queued. @@ -137,11 +148,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,57 +183,57 @@ 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. 37. **Concurrent Avenia KYB case creation within one API process MUST converge on one operation** — `getOrCreateAveniaKybCase` coalesces in-flight creation by provider-customer ID before calling Sequelize, so simultaneous submissions handled by the same process receive the same canonical case. The entry is removed after success or failure so later reads and retries still consult the database. This is intentionally process-local and does not provide a cross-replica database uniqueness guarantee. 38. **UBO creation MUST fail closed after an ambiguous provider outcome** — Before sending an Avenia UBO creation request, Vortex locks the provider customer, requires one canonical KYB case, and records a `prepared` submission using one-way identity and full-payload fingerprints; raw UBO identity payloads and document IDs are not persisted in this state. A parsed provider response records `confirmed` and its UBO ID. Transport errors, timeouts, rate limits, conflicts, and provider failures record `ambiguous`, and subsequent requests for that identity return `409` without another provider POST until an operator reconciles the outcome. Deterministic client rejections record `failed` and may be corrected and retried. 39. **Active-attempt reconciliation MUST NOT overwrite terminal KYB state** — Reconciliation locks and rereads both the provider customer and KYB case before applying `pending` or `in_review`. If either row has become approved or rejected since the provider attempt list was fetched, the stale active response is ignored and terminal status and lifecycle metadata remain intact. -40. **Individual token import MUST be profile-derived and manager-controlled** — The operation accepts only a profile-bound secret credential or Supabase session. A direct authenticated profile may import only for itself and MUST NOT carry an expected managed relationship or entity. Managed import requires the controlling manager, exact active direct relationship, `BR` corridor, individual permission under the null-means-all policy, canonical active entity, canonical BR individual support, and `X-Managed-Profile-Id`; a direct managed-child credential MUST be rejected. Authentication and authorization MUST run before the bounded route-local JSON parser and strict token/body validation. The service MUST lock and reread that managed binding in the canonical KYC-case transaction before claim reuse or creation and again before `prepared -> submitted`; second-check denial MUST fail only the exact still-prepared claim and MUST prevent the provider POST. CPF, tax ID, subaccount, Sumsub applicant, profile, entity, and provider-customer selectors MUST NOT be accepted from the body or query. +40. **Individual token import MUST be profile-derived and manager-controlled** - The operation accepts only a profile-bound secret credential or Supabase session. A direct authenticated profile may import only for itself and MUST NOT carry an expected managed relationship, membership or entity. Managed import requires the acting member's secret, exact active organization `manager` membership bound to that actor and the child's immutable owner, active owner configuration, exact active direct child relationship, `BR` corridor, individual permission under the null-means-all policy, canonical active entity, canonical BR individual support, and `X-Managed-Profile-Id`; selected-child bearers and direct managed-child credentials MUST be rejected. Authentication and authorization MUST run before the bounded route-local JSON parser and strict token/body validation. The service MUST lock and reread that managed binding and exact membership in the canonical KYC-case transaction before claim reuse or creation and again before `prepared -> submitted`; second-check denial MUST fail only the exact still-prepared claim and MUST prevent the provider POST. CPF, tax ID, subaccount, Sumsub applicant, profile, entity, and provider-customer selectors MUST NOT be accepted from the body or query. 41. **Individual Avenia KYC method selection MUST be immutable and serialized** — Migration 066 backfills every existing `kyc_cases` row with `provider = 'avenia'` and `type = 'kyc'` to `standard` without relying on a provider-customer join; cases created later remain nullable until claimed. The first standard document, liveness resource, normal submission, or runtime status read locks a nullable canonical case and selects `standard`; submission locks the provider customer before the case and revalidates current direct or delegated authorization before selecting it. Clients intending token import MUST import before a status read. A token-import claim selects `sumsub_share_token`. Selection cannot be cleared or changed, including after a pre-provider failure. Every normal mutation MUST reject a token-selected case, and token import MUST reject a standard-selected case. Runtime method selection MUST NOT read provider document or attempt history to infer or bind a legacy method. Migration 066 is forward-only after verification state exists: rollback takes an exclusive lock and fails while any method or submission JSON remains. 42. **Token import MUST use durable idempotency without unsafe replay** — Before the provider POST, Vortex persists a claim in the locked canonical case keyed by a hash of the caller's 1-to-128-visible-ASCII `Idempotency-Key`, with a SHA-256 token digest. The JSON CHECK rejects unknown claim keys, requires non-empty actor, subject, idempotency hash, and token digest strings, and requires a non-empty consent array whose objects contain only non-empty actor, subject, policy-version, and timestamp strings. The complete paginated attempt baseline and case `submitted_at` are persisted atomically with the `prepared` to `submitted` transition. Baseline JSON elements MUST be non-empty strings. A failed pre-provider baseline read marks the claim failed and requires a new idempotency key even though the token was not sent. A confirmed same-key/same-token retry returns the case's exact `provider_case_id` without another POST; a changed token returns `409`. A same-key/same-token retry of a submitted or ambiguous claim MAY reconcile through the known attempt or bounded provider history, but history reconciliation MUST use `submitted_at` and exclude baseline attempts and attempts bound to another KYC case's `provider_case_id`. A missing timestamp or non-unique result fails closed, and the token-import POST MUST NOT be repeated or the token resent. Deterministic provider client rejections are the only post-send results classified `failed`, because they create no attempt and transfer no data: provider `401` returns `412` and every other deterministic 4xx returns a fixed `400` without Avenia's status or detail. Both require a new idempotency key before another send, so the token is never replayed under the original key. Every other initial post-send provider, transport, timeout, rate-limit, conflict, malformed-response, 5xx, or local-confirmation failure is `ambiguous`, returns `502`, and MUST NOT automatically replay under any key. 43. **The imported token MUST remain secret** — It is accepted only as a non-empty opaque request-body string of at most 1 KiB, held only in request memory, and sent through the Avenia client's sensitive-body mode. It MUST NOT be returned, persisted, parsed, placed in a URL, or emitted to logs, provider error details, Sentry, analytics, traces, metrics labels, API client events, or support data. Only a SHA-256 digest may be retained for input-consistency checks. 44. **Imported KYC status MUST bind to the exact Avenia attempt and conditionally verify tax identity** — The accepted attempt ID is stored on the canonical case and exact-attempt polling MUST reject a mismatched provider response. `PENDING` maps to pending, `PROCESSING` to in review, and `EXPIRED` remains non-approved and locally pending for reconciliation while the provider value is retained in `status_external`. Only Avenia `COMPLETED + APPROVED` may approve the case and provider customer; token possession, import acceptance, Sumsub assertions, and client assertions never approve KYC. Every transaction that mutates both canonical rows MUST lock `provider_customers` before `kyc_cases` so confirmation and status persistence cannot deadlock each other. Before either individual status path persists imported approval, it MUST fetch Avenia subaccount info. If Avenia exposes a non-empty `accountInfo.taxId`, Vortex MUST normalize and hash it with the canonical tax-reference function and require equality with `provider_customers.tax_reference_hash`; mismatch MUST leave both rows unapproved. If the provider field is absent or empty, approval behavior is unchanged because provider confirmation that it is guaranteed for imported KYC is still a deployment blocker. Provider-read failure prevents that poll from approving. Existing terminal approval MUST NOT be downgraded. 45. **The Avenia webhook MUST remain notification-only for imported KYC** — A signed event may enqueue an idempotent notification but MUST NOT approve or otherwise mutate verification state. Exact-attempt polling remains the authoritative persistence path. 46. **Consent evidence MUST remain explicit and provisional** — The request requires literal `consentAttested: true`; Vortex appends an entry containing actor, subject, timestamp, and server-controlled policy `sumsub-share-v1` to `verification_submission.consentAttestations` without the raw token. Replacing a provider-`401` failed claim with a new idempotency key MUST preserve prior entries and append the new attestation. This policy is enabled provisionally and MUST NOT be represented as replacing legal basis, applicant disclosure, biometric/special-category consent, or data-transfer obligations while legal/provider confirmations remain unresolved. -47. **Standard individual KYC submission MUST be durable, request-bound, authorized at claim time, and privacy-safe** — Vortex MUST persist the submission JSON on the locked canonical case before the Avenia Level 1 POST and atomically persist its complete attempt baseline and case `submitted_at` when claiming it as `submitted`. Deterministic provider client rejections create no attempt, so they record `failed` with a fixed `400` and may be corrected and retried, mirroring UBO creation. Timeouts, rate limits, conflicts, 5xx, and local-confirmation outcomes become `ambiguous`; reconciliation uses that timestamp, excludes baseline and attempts bound to other cases, fails closed when it is absent, and never sends a second POST for an active claim. Avenia's own status and detail MUST NOT be forwarded to the caller. Prepared, submitted, ambiguous, and confirmed reuse MUST match actor, subject, and the SHA-256 digest of the canonical flat payload before reconciliation or replay; mismatches return a fixed `409` without a provider POST. Unmanaged calls MUST have `actor === subject` with no managed selectors. Managed calls MUST identify the controlling manager separately, allow only `actor === controlling manager` or `actor === subject`, and lock and revalidate the controlling active manager, exact active manager/subject/relationship ID, `BR` permission, null-means-all individual policy, managed subject, and expected active individual entity during preparation and immediately before `prepared -> submitted`. Second-check revocation or a concurrent canonical approval MUST prevent the provider POST. Confirmation MUST bind its exact attempt ID even when approval won concurrently, without downgrading terminal state or timestamps. A provider-confirmed retryable terminal may replace the prior confirmed JSON with a prepared claim carrying the new request fingerprint; confirmation clears prior case approval/rejection timestamps and failure reasons before returning it to pending. A standard provider state of `COMPLETED` without `APPROVED` or `REJECTED` MUST fail closed before either direct-status or onboarding persistence. The standard identity payload MUST use the Avenia client's sensitive-body mode, and raw payloads, digest inputs, request logs, provider error details, and thrown errors MUST NOT contain identity values echoed by Avenia. +47. **Standard individual KYC submission MUST be durable, request-bound, authorized at claim time, and privacy-safe** - Vortex MUST persist the submission JSON on the locked canonical case before the Avenia Level 1 POST and atomically persist its complete attempt baseline and case `submitted_at` when claiming it as `submitted`. Deterministic provider client rejections create no attempt, so they record `failed` with a fixed `400` and may be corrected and retried, mirroring UBO creation. Timeouts, rate limits, conflicts, 5xx, and local-confirmation outcomes become `ambiguous`; reconciliation uses that timestamp, excludes baseline and attempts bound to other cases, fails closed when it is absent, and never sends a second POST for an active claim. Avenia's own status and detail MUST NOT be forwarded to the caller. Prepared, submitted, ambiguous, and confirmed reuse MUST match actor, subject, and the SHA-256 digest of the canonical flat payload before reconciliation or replay; mismatches return a fixed `409` without a provider POST. Unmanaged calls MUST have `actor === subject` with no managed selectors or membership. Managed calls MUST identify the immutable controlling owner separately and allow either a delegated organization `manager` member's secret or a direct child credential. They MUST lock and revalidate active owner configuration, exact active owner/subject/relationship ID, `BR` permission, null-means-all individual policy, managed subject, and expected active individual entity during preparation and immediately before `prepared -> submitted`. Delegated actors additionally require the exact unrevoked membership ID bound to that owner and actor with role `manager`; direct child credentials do not depend on human membership. Second-check revocation/downgrade or a concurrent canonical approval MUST prevent the provider POST. Confirmation MUST bind its exact attempt ID even when approval won concurrently, without downgrading terminal state or timestamps. A provider-confirmed retryable terminal may replace the prior confirmed JSON with a prepared claim carrying the new request fingerprint; confirmation clears prior case approval/rejection timestamps and failure reasons before returning it to pending. A standard provider state of `COMPLETED` without `APPROVED` or `REJECTED` MUST fail closed before either direct-status or onboarding persistence. The standard identity payload MUST use the Avenia client's sensitive-body mode, and raw payloads, digest inputs, request logs, provider error details, and thrown errors MUST NOT contain identity values echoed by Avenia. ## 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 @@ -253,6 +264,9 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou - [x] BRL→EVM destination-token precision preserved. **PASS** — block flow simulation preserves Squid destination raw output and destination-token decimals. - [x] BRL Base output topology is token-specific. **PASS** — block catalog resolution maps USDC to the no-Squid flow, BRLA to the direct bypass, and USDT/ETH/AXLUSDC/EURC to the one-phase same-chain Squid flow; flow and transaction tests enforce Base construction and contiguous destination nonce ordering. - [x] Individual token import is auth-first, profile-derived, manager-only for children, and rejects direct child credentials. **PASS** — route middleware ordering and service ownership checks enforce the contract before strict body validation. +- [ ] Verify organization-manager token import and standard KYC recheck the exact actor/owner + membership at both claim boundaries, reject removal/downgrade, and preserve direct-child + standard KYC versus token-import restrictions. Runtime regression verification is pending. - [x] Standard and token KYC methods are immutable and durably serialized. **PASS** — migration 066 adds the method trigger and one submission JSON field; the canonical case row lock serializes every claim. - [x] Token import does not replay ambiguous outcomes. **PASS** — provider `401` alone becomes failed/retriable with a new key; all other post-send failures become ambiguous. A same-key/same-token retry may reconcile through provider reads, while no retry path repeats the token-import POST. - [x] Standard KYC submissions are durable, claim-time authorized, and use sensitive provider logging. **PASS** — active case claims reconcile without another Level 1 POST; direct calls are self-only; managed authorization is locked and revalidated before send, with revocation failing only the matching prepared JSON claim before any Level 1 POST; and the shared BRLA client suppresses the request body and echoed provider details. diff --git a/docs/security-spec/05-integrations/resend.md b/docs/security-spec/05-integrations/resend.md index 26f5710f5..6afb62cfd 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 organization membership invitations. These go through the Resend HTTPS API from `apps/api`. Organization invites retain the internal `managed_profile_membership_invitation` discriminator and producer/template filenames. Only the second class is application code and the subject of this spec. @@ -18,13 +18,14 @@ 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 - `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: @@ -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 @@ -58,51 +59,55 @@ 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 -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. 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. 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 `/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 -| 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, 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 @@ -120,3 +125,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..0893f0b08 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 @@ -13,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 @@ -44,13 +45,46 @@ 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. + +**Organization membership inventory:** `v1/managed-profile-memberships.route.ts` retains its +internal filename and supplies organization and invitee routers mounted by `v1/index.ts`. +Its ten operations are: + +| Operation | Authority | +|---|---| +| `GET /v1/organization` | Human actor; live org projection or null | +| `GET /v1/organization/members` | Either active org membership role | +| `PATCH /v1/organization/members/:memberProfileId` | Active manager member; non-owner target | +| `DELETE /v1/organization/members/:memberProfileId` | Active manager member; non-owner target | +| `GET /v1/organization/member-invitations` | Either active org membership role | +| `POST /v1/organization/member-invitations` | Active manager member | +| `DELETE /v1/organization/member-invitations/:invitationId` | Active manager member | +| `GET /v1/organization/member-events` | Either active org membership role | +| `GET /v1/organization-member-invitations/:invitationId` | Exact current verified-email invitee | +| `POST /v1/organization-member-invitations/:invitationId/accept` | Exact current verified-email invitee; explicit acceptance | + +All ten require human Supabase bearer authentication and reject API/public-key headers (even +alongside a bearer), impersonation, and any child selector. Active owner configuration is required +for org operations; discovery returns null without a live membership, including disabled owners. +Team works with zero children; old per-child team/invitee paths have no aliases. +All seven scoped Team operations require UUID query `expectedOwnerProfileId`, including item +PATCH/DELETE. Missing/malformed input returns `400 MANAGED_PROFILE_INVALID_INPUT`; a different +expected/server-derived current org returns `409 ORGANIZATION_CONTEXT_CHANGED` with structured +`{ error: { code, message, status } }`. This binds displayed-org intent, not authority or multi-org +selection; live service authorization still applies. Discovery and invitee routes are exempt. +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. Membership route tests must exercise all ten operations; +`docs:api:check` pins their exact public method/path, auth and response inventory. Full rules live +in [Organization 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. ## 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. @@ -71,12 +105,33 @@ 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 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** - 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, effective organization role and owner policy. Default active lists return an empty `200` for actors without live memberships; `hasMemberships` means live organization membership with active owner configuration even with zero children, independent of child eligibility or pagination. 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 for the child's owner 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 adds no membership grants/events: owner self-membership is created once per manager config. Any active organization `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 [Organization 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. + +Organization membership is keyed by the child's immutable owner config and inherited by all +present/future children, not a per-child grant. Every person has one active affiliation globally, +including owners of disabled configs. Deactivation retains memberships and denies operations; +removal/downgrade changes all child delegated access without revoking shared child credentials. +Pending invitations remain durable org offers after inviter removal. Second-org acceptance is +`409 ORGANIZATION_MEMBERSHIP_CONFLICT`. This one-account-one-org approximation excludes personal +resource sharing and requires a later ADR before any multi-org model or owner-transfer change. + +Stored history proves prior child access only when an actor membership for the child's immutable +owner overlaps the child's lifetime: `membership.createdAt <= (child.deletedAt ?? now)` and +(`membership.revokedAt IS NULL` or `membership.revokedAt > child.createdAt`) on the same row. +Children created after revocation or wholly within a membership gap remain masked `404` despite +historic org membership. Config-created owner self-membership and its event use null/system +creator and actor attribution: `ADMIN_SECRET` identifies no human owner action. ## Threat Vectors & Mitigations @@ -84,12 +139,12 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **⚠️ 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. | @@ -103,7 +158,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. 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..825dd1e52 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,12 @@ 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. Organization membership invitations are the narrow exception + (the existing `managed_profile_membership_invitation` discriminator is unchanged): + 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..f3713719a 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 | +| Organization Memberships | `01-auth/managed-profile-memberships.md` | One-account-one-org approximation, global single affiliation, inherited child 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,31 @@ 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 | +| **Organization** | Exactly one owning manager account/config; the current one-account-one-org approximation, not a generic organization entity | +| **Organization membership** | Revocable edge from an authenticated person to one owner config; at most one active affiliation globally, including owners of disabled configs | +| **Membership role** | Organization-wide `manager` or `read_only` permission inherited by all present/future children; separate from global `profile_roles` | +| **Membership invitation** | Durable seven-day organization offer, surviving inviter removal, granting 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..8f4cb9ce3 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -20,29 +20,35 @@ 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. | + +RISK-018's organization boundary under [ADR 0006](../adr-0006-organization-wide-teams.md) +denies **all** organization/team/invitee operations during impersonation, including discovery, +roster/history reads and invitation preview, not just mutations. Supported child inspection +still uses the target's live organization membership for the child's immutable owner. This +does not broaden any accepted mutation scope or share human personal resources. ## Review cadence