From 7f4228463c0942116a6e6fb61429d574fe2258cf Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 19:06:45 +0200 Subject: [PATCH 1/5] feat(api): add server-controlled partner attribution service Creates a profile-partner assignment from a server-resolved partner id with first-partner-wins semantics: an active, unexpired assignment is never replaced, and expired-but-active rows are deactivated so the active-assignment partial unique index cannot collide. --- .../partner-attribution.service.test.ts | 82 +++++++++++++++++++ .../partners/partner-attribution.service.ts | 79 ++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 apps/api/src/api/services/partners/partner-attribution.service.test.ts create mode 100644 apps/api/src/api/services/partners/partner-attribution.service.ts diff --git a/apps/api/src/api/services/partners/partner-attribution.service.test.ts b/apps/api/src/api/services/partners/partner-attribution.service.test.ts new file mode 100644 index 000000000..557b7e483 --- /dev/null +++ b/apps/api/src/api/services/partners/partner-attribution.service.test.ts @@ -0,0 +1,82 @@ +import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import ProfilePartnerAssignment from "../../../models/profilePartnerAssignment.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { createTestPartner, createTestUser } from "../../../test-utils/factories"; +import { claimPartnerAttribution } from "./partner-attribution.service"; + +describe("partner attribution", () => { + beforeAll(setupTestDatabase); + beforeEach(resetTestDatabase); + + it("creates an active assignment carrying the partner's id and name", async () => { + const user = await createTestUser(); + const partner = await createTestPartner(); + + expect(await claimPartnerAttribution(user.id, partner.id)).toBe("created"); + + const assignments = await ProfilePartnerAssignment.findAll({ where: { userId: user.id } }); + expect(assignments).toHaveLength(1); + expect(assignments[0]).toMatchObject({ + expiresAt: null, + isActive: true, + partnerId: partner.id, + partnerName: partner.name + }); + }); + + it("never replaces an existing active assignment (first partner wins)", async () => { + const user = await createTestUser(); + const firstPartner = await createTestPartner(); + const secondPartner = await createTestPartner(); + await ProfilePartnerAssignment.create({ + isActive: true, + partnerId: firstPartner.id, + partnerName: firstPartner.name, + userId: user.id + }); + + expect(await claimPartnerAttribution(user.id, secondPartner.id)).toBe("skipped_existing_assignment"); + + const assignments = await ProfilePartnerAssignment.findAll({ where: { isActive: true, userId: user.id } }); + expect(assignments).toHaveLength(1); + expect(assignments[0].partnerId).toBe(firstPartner.id); + }); + + it("is idempotent for repeated claims of the same partner", async () => { + const user = await createTestUser(); + const partner = await createTestPartner(); + + expect(await claimPartnerAttribution(user.id, partner.id)).toBe("created"); + expect(await claimPartnerAttribution(user.id, partner.id)).toBe("skipped_existing_assignment"); + expect(await ProfilePartnerAssignment.count({ where: { userId: user.id } })).toBe(1); + }); + + it("replaces an expired active row without tripping the active-assignment unique index", async () => { + const user = await createTestUser(); + const expiredPartner = await createTestPartner(); + const partner = await createTestPartner(); + await ProfilePartnerAssignment.create({ + expiresAt: new Date(Date.now() - 60_000), + isActive: true, + partnerId: expiredPartner.id, + partnerName: expiredPartner.name, + userId: user.id + }); + + expect(await claimPartnerAttribution(user.id, partner.id)).toBe("created"); + + const active = await ProfilePartnerAssignment.findAll({ where: { isActive: true, userId: user.id } }); + expect(active).toHaveLength(1); + expect(active[0].partnerId).toBe(partner.id); + }); + + it("skips inactive partners and missing profiles without creating rows", async () => { + const user = await createTestUser(); + const inactivePartner = await createTestPartner({ isActive: false }); + const partner = await createTestPartner(); + + expect(await claimPartnerAttribution(user.id, inactivePartner.id)).toBe("skipped_partner_inactive"); + expect(await claimPartnerAttribution(crypto.randomUUID(), partner.id)).toBe("skipped_profile_missing"); + expect(await ProfilePartnerAssignment.count()).toBe(0); + }); +}); diff --git a/apps/api/src/api/services/partners/partner-attribution.service.ts b/apps/api/src/api/services/partners/partner-attribution.service.ts new file mode 100644 index 000000000..70b83940b --- /dev/null +++ b/apps/api/src/api/services/partners/partner-attribution.service.ts @@ -0,0 +1,79 @@ +import { Transaction } from "sequelize"; +import sequelize from "../../../config/database"; +import logger from "../../../config/logger"; +import Partner from "../../../models/partner.model"; +import ProfilePartnerAssignment from "../../../models/profilePartnerAssignment.model"; +import User from "../../../models/user.model"; + +export type PartnerAttributionOutcome = + | "created" + | "skipped_existing_assignment" + | "skipped_partner_inactive" + | "skipped_profile_missing"; + +/** + * Persists partner pricing attribution for a profile onboarded through a partner-attributed + * API credential. The partner is always resolved server-side from `api_credentials.partner_id` + * — never from a client-chosen value — which keeps the server-side-only invariant of + * `docs/security-spec/03-ramp-engine/profile-partner-pricing.md` intact. + * + * First partner wins: a profile that already holds an active, unexpired assignment keeps it, + * so an attribution claim can never hijack a previously assigned profile. Skips are silent by + * design (logged, not thrown) — attribution must never fail the onboarding it rides on. + * + * Callers must pass a transaction that holds the profile row lock (a freshly created profile + * row is locked by its own insert), mirroring the admin and seeded-discount assignment paths. + */ +export async function assignPartnerAttribution( + userId: string, + partnerId: string, + transaction: Transaction +): Promise { + const partner = await Partner.findOne({ transaction, where: { id: partnerId, isActive: true } }); + if (!partner) { + logger.warn(`Partner attribution for profile ${userId}: partner ${partnerId} not found or inactive; skipped`); + return "skipped_partner_inactive"; + } + + const now = new Date(); + const activeAssignments = await ProfilePartnerAssignment.findAll({ + transaction, + where: { isActive: true, userId } + }); + if (activeAssignments.some(assignment => !assignment.expiresAt || assignment.expiresAt > now)) { + return "skipped_existing_assignment"; + } + + // Any remaining active rows are expired — deactivate them so the partial unique index on + // active assignments cannot collide (same replacement step the admin path performs). + if (activeAssignments.length > 0) { + await ProfilePartnerAssignment.update({ isActive: false }, { transaction, where: { isActive: true, userId } }); + } + await ProfilePartnerAssignment.create( + { + isActive: true, + partnerId: partner.id, + partnerName: partner.name, + userId + }, + { transaction } + ); + + return "created"; +} + +/** + * Standalone claim used by the widget flow: opens its own transaction and takes the same + * profile row lock the admin assignment path takes, so concurrent claims and admin writes + * for one profile serialize. + */ +export async function claimPartnerAttribution(userId: string, partnerId: string): Promise { + return sequelize.transaction(async transaction => { + const lockedUser = await User.findByPk(userId, { lock: Transaction.LOCK.UPDATE, transaction }); + if (!lockedUser) { + logger.warn(`Partner attribution claim: profile ${userId} not found; skipped`); + return "skipped_profile_missing"; + } + return assignPartnerAttribution(userId, partnerId, transaction); + }); +} From 0f04aa60d21a305d6da3f67cf040cbeb7ae7d243 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 19:06:45 +0200 Subject: [PATCH 2/5] feat(api): assign credential partner at managed-profile provisioning Children created through a partner-attributed secret credential get the credential's partner assignment inside the provisioning transaction. Never retroactive for existing children; Bearer-authenticated managers provision without attribution. --- .../controllers/managedProfiles.controller.ts | 3 ++ .../managed-profile-lifecycle.service.ts | 1 + ...naged-profile-provisioning.service.test.ts | 41 ++++++++++++++++++- .../managed-profile-provisioning.service.ts | 9 ++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/controllers/managedProfiles.controller.ts b/apps/api/src/api/controllers/managedProfiles.controller.ts index 9a2081fdc..3756ab8f6 100644 --- a/apps/api/src/api/controllers/managedProfiles.controller.ts +++ b/apps/api/src/api/controllers/managedProfiles.controller.ts @@ -86,6 +86,9 @@ export async function postManagedProfile(req: Request, res: Response): Promise { }); }); + it("fixes partner pricing attribution to a newly provisioned profile", async () => { + const manager = await createManager(); + const partner = await createTestPartner(); + + const result = await provisionManagedProfile({ + attributingPartnerId: partner.id, + contactEmail: "attributed@example.com", + creationSource: "manager", + customerType: "individual", + externalSubjectId: "customer-attributed", + managerProfileId: manager.id + }); + + expect(await ProfilePartnerAssignment.findAll({ where: { userId: result.profileId } })).toMatchObject([ + { isActive: true, partnerId: partner.id, partnerName: partner.name } + ]); + }); + + it("does not create or change attribution for unattributed or already-existing profiles", async () => { + const manager = await createManager(); + const partner = await createTestPartner(); + const input = { + contactEmail: "unattributed@example.com", + creationSource: "manager" as const, + customerType: "individual" as const, + externalSubjectId: "customer-unattributed", + managerProfileId: manager.id + }; + + const created = await provisionManagedProfile(input); + expect(await ProfilePartnerAssignment.count({ where: { userId: created.profileId } })).toBe(0); + + // A later retry with attribution must not retroactively assign the existing profile. + const retried = await provisionManagedProfile({ ...input, attributingPartnerId: partner.id }); + expect(retried.created).toBe(false); + expect(await ProfilePartnerAssignment.count({ where: { userId: created.profileId } })).toBe(0); + }); + it("returns the existing profile for an idempotent retry", async () => { const manager = await createManager(); const input = { diff --git a/apps/api/src/api/services/managed-profile-provisioning.service.ts b/apps/api/src/api/services/managed-profile-provisioning.service.ts index c919506fe..eefeaee46 100644 --- a/apps/api/src/api/services/managed-profile-provisioning.service.ts +++ b/apps/api/src/api/services/managed-profile-provisioning.service.ts @@ -6,6 +6,7 @@ import CustomerEntity from "../../models/customerEntity.model"; import ManagedProfile, { type ManagedProfileCreationSource } from "../../models/managedProfile.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; import User from "../../models/user.model"; +import { assignPartnerAttribution } from "./partners/partner-attribution.service"; export class ManagedProfileProvisioningError extends Error { constructor( @@ -22,6 +23,8 @@ export class ManagedProfileProvisioningError extends Error { } export interface ProvisionManagedProfileInput { + /** Partner attribution of the acting API credential; fixes partner pricing to the new profile. */ + attributingPartnerId?: string | null; contactEmail: string; creationSource: ManagedProfileCreationSource; customerType: CustomerEntityType; @@ -144,6 +147,12 @@ export async function provisionManagedProfile(input: ProvisionManagedProfileInpu { transaction } ); + // Attribution applies only at creation — an existing relationship returned above keeps + // whatever pricing assignment it was (or was not) provisioned with. + if (input.attributingPartnerId) { + await assignPartnerAttribution(profile.id, input.attributingPartnerId, transaction); + } + return { contactEmail, created: true, From 89ec0f8cfe6f529731e5c27e906c008597aa7b91 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 19:06:45 +0200 Subject: [PATCH 3/5] feat(api): add partner attribution claim endpoint POST /v1/partner-attribution/claim assigns the validated public key's partner to the authenticated profile so widget-onboarded users keep the partner's pricing. The client presents only the key; the partner is resolved exclusively from the credential. --- .../partnerAttribution.controller.test.ts | 104 ++++++++++++++++++ .../partnerAttribution.controller.ts | 52 +++++++++ apps/api/src/api/routes/v1/index.ts | 8 ++ .../routes/v1/partner-attribution.route.ts | 11 ++ 4 files changed, 175 insertions(+) create mode 100644 apps/api/src/api/controllers/partnerAttribution.controller.test.ts create mode 100644 apps/api/src/api/controllers/partnerAttribution.controller.ts create mode 100644 apps/api/src/api/routes/v1/partner-attribution.route.ts diff --git a/apps/api/src/api/controllers/partnerAttribution.controller.test.ts b/apps/api/src/api/controllers/partnerAttribution.controller.test.ts new file mode 100644 index 000000000..289d5e73f --- /dev/null +++ b/apps/api/src/api/controllers/partnerAttribution.controller.test.ts @@ -0,0 +1,104 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import express from "express"; +import ProfilePartnerAssignment from "../../models/profilePartnerAssignment.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestApiKey, createTestPartner, createTestUser } from "../../test-utils/factories"; +import { type FakeSupabaseAuth, installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; +import partnerAttributionRoutes from "../routes/v1/partner-attribution.route"; + +const BASE_PATH = "/v1/partner-attribution"; + +describe("partner attribution claim route", () => { + let server: ReturnType; + let baseUrl: string; + let auth: FakeSupabaseAuth; + + beforeAll(async () => { + await setupTestDatabase(); + auth = installFakeSupabaseAuth(); + const app = express(); + app.use(express.json()); + app.use(BASE_PATH, partnerAttributionRoutes); + 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}${BASE_PATH}/claim`; + }); + + afterAll(() => { + auth.restore(); + server?.close(); + }); + beforeEach(resetTestDatabase); + + function claim(token: string | null, publicKey?: string) { + return fetch(baseUrl, { + body: JSON.stringify({}), + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(publicKey ? { "x-public-key": publicKey } : {}) + }, + method: "POST" + }); + } + + it("requires a Supabase user token", async () => { + const partner = await createTestPartner(); + const { publicKey } = await createTestApiKey({ partnerName: partner.name }); + expect((await claim(null, publicKey)).status).toBe(401); + expect(await ProfilePartnerAssignment.count()).toBe(0); + }); + + it("requires a valid public key", async () => { + const user = await createTestUser(); + expect((await claim(testUserToken(user.id))).status).toBe(400); + expect((await claim(testUserToken(user.id), `pk_test_${"a".repeat(32)}`)).status).toBe(401); + const { plaintextKey } = await createTestApiKey(); + expect((await claim(testUserToken(user.id), plaintextKey)).status).toBe(400); + expect(await ProfilePartnerAssignment.count()).toBe(0); + }); + + it("assigns the credential's partner to the authenticated profile exactly once", async () => { + const user = await createTestUser(); + const partner = await createTestPartner(); + const { publicKey } = await createTestApiKey({ partnerName: partner.name }); + + const first = await claim(testUserToken(user.id), publicKey); + expect(first.status).toBe(200); + expect(await first.json()).toEqual({ outcome: "created" }); + expect(await ProfilePartnerAssignment.findAll({ where: { userId: user.id } })).toMatchObject([ + { isActive: true, partnerId: partner.id, partnerName: partner.name } + ]); + + const second = await claim(testUserToken(user.id), publicKey); + expect(second.status).toBe(200); + expect(await second.json()).toEqual({ outcome: "skipped_existing_assignment" }); + expect(await ProfilePartnerAssignment.count({ where: { userId: user.id } })).toBe(1); + }); + + it("keeps an existing assignment when a different partner's key is presented", async () => { + const user = await createTestUser(); + const firstPartner = await createTestPartner(); + const secondPartner = await createTestPartner(); + const { publicKey: firstKey } = await createTestApiKey({ partnerName: firstPartner.name }); + const { publicKey: secondKey } = await createTestApiKey({ partnerName: secondPartner.name }); + + expect(await (await claim(testUserToken(user.id), firstKey)).json()).toEqual({ outcome: "created" }); + expect(await (await claim(testUserToken(user.id), secondKey)).json()).toEqual({ outcome: "skipped_existing_assignment" }); + + const active = await ProfilePartnerAssignment.findAll({ where: { isActive: true, userId: user.id } }); + expect(active).toHaveLength(1); + expect(active[0].partnerId).toBe(firstPartner.id); + }); + + it("no-ops for keys without partner attribution", async () => { + const user = await createTestUser(); + const { publicKey } = await createTestApiKey(); + + const response = await claim(testUserToken(user.id), publicKey); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ outcome: "no_partner_attribution" }); + expect(await ProfilePartnerAssignment.count()).toBe(0); + }); +}); diff --git a/apps/api/src/api/controllers/partnerAttribution.controller.ts b/apps/api/src/api/controllers/partnerAttribution.controller.ts new file mode 100644 index 000000000..f70cde87e --- /dev/null +++ b/apps/api/src/api/controllers/partnerAttribution.controller.ts @@ -0,0 +1,52 @@ +import type { Request, Response } from "express"; +import httpStatus from "http-status"; +import logger from "../../config/logger"; +import { claimPartnerAttribution } from "../services/partners/partner-attribution.service"; + +/** + * Claims partner pricing attribution for the authenticated profile from a validated public + * API key. The partner is resolved server-side from the credential's `partner_id`; the + * client only presents the key, so it cannot choose an arbitrary partner. Idempotent: + * a profile with an active assignment keeps it. + */ +export async function postPartnerAttributionClaim(req: Request, res: Response): Promise { + const userId = req.userId; + if (!userId) { + res.status(httpStatus.UNAUTHORIZED).json({ + error: { code: "AUTHENTICATION_REQUIRED", message: "Authentication is required", status: httpStatus.UNAUTHORIZED } + }); + return; + } + + // validatePublicKey() is a no-op without a key; the claim is meaningless without one. + const credential = req.credential; + if (!credential) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "MISSING_PUBLIC_KEY", + message: "A public API key (x-public-key header or apiKey body field) is required", + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + if (!credential.partnerId) { + res.status(httpStatus.OK).json({ outcome: "no_partner_attribution" }); + return; + } + + try { + const outcome = await claimPartnerAttribution(userId, credential.partnerId); + res.status(httpStatus.OK).json({ outcome }); + } catch (error) { + logger.error("Error claiming partner attribution", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to claim partner attribution", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index 351c046fb..0b3ee3411 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -28,6 +28,7 @@ import moneriumRoutes from "./monerium.route"; import mykoboRoutes from "./mykobo.route"; import notificationsRoutes from "./notifications.route"; import onboardingRoutes from "./onboarding.route"; +import partnerAttributionRoutes from "./partner-attribution.route"; import paymentMethodsRoutes from "./payment-methods.route"; import priceRoutes from "./price.route"; import publicKeyRoutes from "./public-key.route"; @@ -227,6 +228,13 @@ router.use("/onboarding", onboardingRoutes); router.use("/api-credentials", apiCredentialsRoutes); router.use("/managed-profiles", managedProfilesRoutes); +/** + * Partner pricing attribution claim for widget-onboarded users. The partner is + * resolved server-side from the presented public API key's credential. + * POST /v1/partner-attribution/claim + */ +router.use("/partner-attribution", partnerAttributionRoutes); + /** * Admin routes for partner-managed API credentials. The partner is addressed by * its unique name; each credential is bound to one explicit profile subject. diff --git a/apps/api/src/api/routes/v1/partner-attribution.route.ts b/apps/api/src/api/routes/v1/partner-attribution.route.ts new file mode 100644 index 000000000..bddb68ccc --- /dev/null +++ b/apps/api/src/api/routes/v1/partner-attribution.route.ts @@ -0,0 +1,11 @@ +import { Router } from "express"; +import { postPartnerAttributionClaim } from "../../controllers/partnerAttribution.controller"; +import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; +import { validatePublicKey } from "../../middlewares/publicKeyAuth"; +import { requireAuth } from "../../middlewares/supabaseAuth"; + +const router = Router(); + +router.post("/claim", requireAuth, rejectImpersonation, validatePublicKey(), postPartnerAttributionClaim); + +export default router; From 951d9d82c005f99242ec200d95dd33035e20faa7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 19:06:52 +0200 Subject: [PATCH 4/5] feat(frontend): claim partner attribution after widget sign-in Fires the claim once per session when the widget was opened with a partner's public apiKey URL parameter and the user is authenticated. Failures are non-fatal and retried on the next auth change. --- .../hooks/usePartnerAttributionClaim.test.tsx | 47 +++++++++++++++++++ .../src/hooks/usePartnerAttributionClaim.ts | 27 +++++++++++ apps/frontend/src/pages/widget/index.tsx | 3 ++ .../api/partner-attribution.service.ts | 29 ++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 apps/frontend/src/hooks/usePartnerAttributionClaim.test.tsx create mode 100644 apps/frontend/src/hooks/usePartnerAttributionClaim.ts create mode 100644 apps/frontend/src/services/api/partner-attribution.service.ts diff --git a/apps/frontend/src/hooks/usePartnerAttributionClaim.test.tsx b/apps/frontend/src/hooks/usePartnerAttributionClaim.test.tsx new file mode 100644 index 000000000..72a2e8266 --- /dev/null +++ b/apps/frontend/src/hooks/usePartnerAttributionClaim.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { usePartnerStore } from "../stores/partnerStore"; +import { usePartnerAttributionClaim } from "./usePartnerAttributionClaim"; + +vi.mock("../services/api/partner-attribution.service", () => ({ + PartnerAttributionService: { claim: vi.fn().mockResolvedValue({ outcome: "created" }) } +})); + +import { PartnerAttributionService } from "../services/api/partner-attribution.service"; + +const API_KEY = `pk_test_${"a".repeat(32)}`; + +function fakeRampActor(isAuthenticated: boolean) { + const snapshot = { context: { isAuthenticated } }; + return { + getSnapshot: () => snapshot, + subscribe: () => ({ unsubscribe: () => undefined }) + } as unknown as Parameters[0]; +} + +describe("usePartnerAttributionClaim", () => { + beforeEach(() => { + vi.mocked(PartnerAttributionService.claim).mockClear(); + usePartnerStore.setState({ apiKey: undefined, partnerId: undefined }); + }); + + it("does not claim while unauthenticated or without an apiKey", () => { + usePartnerStore.setState({ apiKey: API_KEY }); + renderHook(() => usePartnerAttributionClaim(fakeRampActor(false))); + + usePartnerStore.setState({ apiKey: null }); + renderHook(() => usePartnerAttributionClaim(fakeRampActor(true))); + + expect(PartnerAttributionService.claim).not.toHaveBeenCalled(); + }); + + it("claims exactly once per apiKey after sign-in", () => { + usePartnerStore.setState({ apiKey: API_KEY }); + const { rerender } = renderHook(() => usePartnerAttributionClaim(fakeRampActor(true))); + rerender(); + + expect(PartnerAttributionService.claim).toHaveBeenCalledTimes(1); + expect(PartnerAttributionService.claim).toHaveBeenCalledWith(API_KEY); + }); +}); diff --git a/apps/frontend/src/hooks/usePartnerAttributionClaim.ts b/apps/frontend/src/hooks/usePartnerAttributionClaim.ts new file mode 100644 index 000000000..a81d192a8 --- /dev/null +++ b/apps/frontend/src/hooks/usePartnerAttributionClaim.ts @@ -0,0 +1,27 @@ +import { useSelector } from "@xstate/react"; +import { useEffect, useRef } from "react"; +import type { ActorRefFrom } from "xstate"; +import type { rampMachine } from "../machines/ramp.machine"; +import { PartnerAttributionService } from "../services/api/partner-attribution.service"; +import { useApiKey } from "../stores/partnerStore"; + +/** + * Claims partner pricing attribution for the signed-in user when the widget was opened + * with a partner's public API key (`apiKey` URL parameter). The backend resolves the + * partner from the key's credential and never replaces an existing assignment, so this is + * safe to fire once per session; failures are non-fatal and retried on the next auth change. + */ +export function usePartnerAttributionClaim(actorRef: ActorRefFrom) { + const isAuthenticated = useSelector(actorRef, state => state.context.isAuthenticated); + const apiKey = useApiKey(); + const claimedKeyRef = useRef(null); + + useEffect(() => { + if (!isAuthenticated || !apiKey || claimedKeyRef.current === apiKey) return; + claimedKeyRef.current = apiKey; + PartnerAttributionService.claim(apiKey).catch(error => { + claimedKeyRef.current = null; + console.warn("Partner attribution claim failed:", error); + }); + }, [isAuthenticated, apiKey]); +} diff --git a/apps/frontend/src/pages/widget/index.tsx b/apps/frontend/src/pages/widget/index.tsx index af62ae9f1..e6be61417 100644 --- a/apps/frontend/src/pages/widget/index.tsx +++ b/apps/frontend/src/pages/widget/index.tsx @@ -31,6 +31,7 @@ import { } from "../../contexts/rampState"; import { cn } from "../../helpers/cn"; import { useAuthTokens } from "../../hooks/useAuthTokens"; +import { usePartnerAttributionClaim } from "../../hooks/usePartnerAttributionClaim"; import { isInCompoundState } from "../../machines/types"; import { FiatAccountRegistration } from "../alfredpay/FiatAccountRegistration"; @@ -79,6 +80,8 @@ const WidgetContent = () => { // Enable session persistence and auto-refresh useAuthTokens(rampActor); + // Fix partner pricing to users who signed in through a partner's apiKey link + usePartnerAttributionClaim(rampActor); const { rampState, diff --git a/apps/frontend/src/services/api/partner-attribution.service.ts b/apps/frontend/src/services/api/partner-attribution.service.ts new file mode 100644 index 000000000..047dfc952 --- /dev/null +++ b/apps/frontend/src/services/api/partner-attribution.service.ts @@ -0,0 +1,29 @@ +import { apiRequest } from "./api-client"; + +export type PartnerAttributionClaimOutcome = + | "created" + | "no_partner_attribution" + | "skipped_existing_assignment" + | "skipped_partner_inactive" + | "skipped_profile_missing"; + +export interface PartnerAttributionClaimResponse { + outcome: PartnerAttributionClaimOutcome; +} + +/** + * Service for claiming partner pricing attribution from a public API key + */ +export class PartnerAttributionService { + private static readonly BASE_PATH = "/partner-attribution"; + + /** + * Claims partner attribution for the authenticated user. The backend resolves the + * partner from the public API key's credential; idempotent for repeated calls. + */ + static async claim(apiKey: string): Promise { + return apiRequest("post", `${PartnerAttributionService.BASE_PATH}/claim`, undefined, { + headers: { "x-public-key": apiKey } + }); + } +} From 73bb2100ca712a1c183a04003db7136558c06fca Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 19:06:52 +0200 Subject: [PATCH 5/5] docs(api): document partner key pricing attribution Security spec invariant 14 now lists the three server-controlled assignment paths and the accepted leaked-public-key residual risk; partner docs cover attribution at widget sign-in and managed-profile creation. --- docs/api/pages/08-widget-integration.md | 6 +++++- docs/api/pages/14-managed-profiles.md | 2 +- .../03-ramp-engine/profile-partner-pricing.md | 11 +++++++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/api/pages/08-widget-integration.md b/docs/api/pages/08-widget-integration.md index 7f014e737..9823d6797 100644 --- a/docs/api/pages/08-widget-integration.md +++ b/docs/api/pages/08-widget-integration.md @@ -84,7 +84,7 @@ Content-Type: application/json | `fiat` | no | Fiat currency for the fiat leg (e.g. `"BRL"`). Required in practice for fiat-side ramps. | | `cryptoLocked` | no | Pre-selects and locks the crypto asset in the widget (e.g. `"USDC"`). | | `paymentMethod` | no | Payment rail (e.g. `"pix"`). Required in practice for buy flows. | -| `apiKey` | no | Legacy body transport for the public credential value. Prefer `X-Public-Key`; if both are present, they must match. | +| `apiKey` | no | Legacy body transport for the public credential value. Prefer `X-Public-Key`; if both are present, they must match. Including it also embeds the key in the returned widget URL, which enables pricing attribution (see below). | | `countryCode` | no | ISO-3166 alpha-2 country code to pre-filter eligible options. | | `partnerId` | no | Partner identifier for attribution. | | `callbackUrl` | no | URL the widget redirects to after the user successfully creates the transaction. | @@ -92,6 +92,10 @@ Content-Type: application/json Vortex validates the route on session creation by attempting to create a probe quote with the supplied parameters; invalid combinations return `400`. +### Pricing Attribution At Sign-In + +If your credential carries negotiated partner pricing, open the widget with your public key in the URL (pass `apiKey` in the session body so it lands in the generated URL, or append `apiKey=pk_...` when linking to the widget directly). When a user signs in through such a link, Vortex permanently assigns your pricing to their profile, so later transactions keep your rates even without the link. The first assignment wins: a user who already carries a pricing assignment keeps it. Only ever expose the public `pk_*` value here — never the secret key. + Response: `201 Created` ```json diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 0b236ccb9..bfe317a93 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -128,7 +128,7 @@ 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 **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. When your credential carries partner pricing, each child you create is additionally assigned that pricing at creation time, fixing your rates to the child even if your own assignment later changes. - **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 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..31b41f73a 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`. Besides admin assignment (and seeded-discount invites, see invariant 14), an assignment is also fixed to a profile at partner onboarding: managed-profile provisioning through a partner-attributed secret credential assigns the credential's `partner_id` to the newly created child, and `POST /v1/partner-attribution/claim` assigns it to an authenticated widget user presenting the partner's validated public key. Both paths resolve the partner server-side from `api_credentials.partner_id` and never replace an existing active assignment. 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. This feature is intentionally different from partner API-key authentication: @@ -34,7 +34,7 @@ For profile-assigned frontend quotes, `quote_tickets.user_id` is set to the auth 11. **Admin active-list semantics MUST match quote-time semantics** - Default assignment listing MUST exclude rows that are inactive or expired; historical listing may include them only when explicitly requested. 12. **Fee distribution MUST use the pricing partner, not only the owner partner** - Partner markup payout uses `pricing_partner_id` when present, with `partner_id` as a backward-compatible fallback for older quotes. 13. **Dynamic discount state MUST use the pricing partner** - Quote consumption adjusts the dynamic discount state for the partner whose pricing was used, not for the quote owner. -14. **Assignment administration MUST require admin auth** - Create, list, and revoke assignment endpoints MUST be protected by `adminAuth`; partner API keys and Supabase user tokens MUST NOT manage assignments. One server-controlled exception exists: recipient-invite acceptance may create an assignment from a `discount_manager`-authored invite's seeded discounts (see `recipient-transfers.md` invariant 11) — the assignment's partner and pricing are fixed server-side at invite creation by a role-gated sender, never chosen by the accepting user, and an existing active assignment is never replaced by this path. +14. **Assignment administration MUST require admin auth** - Create, list, and revoke assignment endpoints MUST be protected by `adminAuth`; partner API keys and Supabase user tokens MUST NOT manage assignments. Three server-controlled exceptions exist, and none of them may ever replace an existing active assignment (first partner wins): (a) recipient-invite acceptance may create an assignment from a `discount_manager`-authored invite's seeded discounts (see `recipient-transfers.md` invariant 11) — the assignment's partner and pricing are fixed server-side at invite creation by a role-gated sender, never chosen by the accepting user; (b) managed-profile provisioning may create an assignment for the newly created child from the acting secret credential's `partner_id` — attribution applies only at creation, never retroactively to an existing child; (c) `POST /v1/partner-attribution/claim` may create an assignment for the authenticated profile from a validated, active public key's `partner_id`. In (b) and (c) the client presents only a credential; the partner is resolved exclusively from `api_credentials.partner_id`, which only `adminAuth` endpoints can set (self-service credential creation hard-codes `partner_id = NULL`). 15. **Assignment replacement MUST be atomic per profile** - Creating a new active assignment MUST deactivate the previous active row and insert the replacement in one database transaction. The transaction MUST lock the profile row so concurrent admin writes for the same user serialize, and any residual active-assignment unique-index conflict MUST fail with a retryable `409`. ## Threat Vectors & Mitigations @@ -52,6 +52,9 @@ For profile-assigned frontend quotes, `quote_tickets.user_id` is set to the auth | **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`. | +| **Attribution claim with an arbitrary partner** | A user calls the attribution claim with a fabricated partner reference to self-assign discounted pricing. | The claim accepts no partner field; the partner comes only from a validated, unexpired, unrevoked public key whose `partner_id` was set by an admin. Invalid keys are rejected before any claim logic runs. | +| **Attribution hijack of an existing profile** | A partner's key (or a partner-attributed provisioning call) is used against a profile that already carries a different assignment, silently switching its pricing. | All non-admin assignment paths are first-partner-wins: an active, unexpired assignment is never replaced. Replacing an assignment requires the admin endpoint. | +| **Leaked public key reuse** | Someone copies a partner's `pk_*` key out of a widget redirect URL and claims that partner's pricing for themselves. | Accepted residual risk of key-based attribution: the assignment grants pricing only (no partner access), the key is revocable and rotatable, and revoking it stops further claims. Existing assignments can be revoked via the admin endpoint. | | **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 @@ -72,3 +75,7 @@ For profile-assigned frontend quotes, `quote_tickets.user_id` is set to the auth - [x] User ownership checks continue to authorize profile-assigned quotes through `user_id`. - [x] Partner ownership checks continue to authorize API-client quotes through `partner_id`. - [x] Tests cover assigned user quote ownership, managed child override and manager fallback through delegated and direct-child authentication, ramp-specific partner-ID resolution, quote persistence of `pricing_partner_id`, expired list filtering, and the non-regression path for partner-owned quotes. +- [x] `POST /v1/partner-attribution/claim` requires a Supabase user token plus a validated public key, resolves the partner only from the credential's `partner_id`, rejects impersonation, and no-ops for unattributed keys. +- [x] Managed-profile provisioning assigns the acting credential's `partner_id` only to newly created children; retries against existing children never create or change assignments. +- [x] All non-admin assignment paths (invite seeding, provisioning attribution, public-key claim) skip profiles holding an active, unexpired assignment and deactivate expired-but-active rows before inserting, so the active-assignment unique index cannot collide. +- [x] Tests cover claim idempotency, first-partner-wins against a competing partner's key, inactive-partner and missing-profile skips, and the no-assignment path for unattributed credentials.