From acdb8f62c58d90de831e5d17ba3830c71ba2dcd2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 20 May 2026 13:56:42 +0200 Subject: [PATCH 01/12] Add deployment environment configuration and validation --- apps/api/.env.example | 1 + apps/api/src/config/vars.ts | 35 ++++++++++++++++++++++++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/api/.env.example b/apps/api/.env.example index 620abc67d..7596c38e8 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,5 +1,6 @@ # Application NODE_ENV=development +DEPLOYMENT_ENV=development PORT=3000 LOG_LEVEL=info diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index a43b1214a..f44b17742 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -22,8 +22,24 @@ interface SpreadsheetConfig { ratingSheetId: string | undefined; } +type DeploymentEnv = "development" | "production" | "sandbox" | "staging" | "test"; + +const nodeEnv = process.env.NODE_ENV || "production"; +const deploymentEnvValues: DeploymentEnv[] = ["development", "production", "sandbox", "staging", "test"]; + +function readDeploymentEnv(): DeploymentEnv { + const rawDeploymentEnv = process.env.DEPLOYMENT_ENV || (nodeEnv === "production" ? "production" : nodeEnv); + + if (!deploymentEnvValues.includes(rawDeploymentEnv as DeploymentEnv)) { + throw new Error(`DEPLOYMENT_ENV must be one of: ${deploymentEnvValues.join(", ")}`); + } + + return rawDeploymentEnv as DeploymentEnv; +} + interface Config { env: string; + deploymentEnv: DeploymentEnv; port: string | number; amplitudeWss: string; pendulumWss: string; @@ -106,7 +122,7 @@ export const config: Config = { database: process.env.DB_NAME || "vortex", dialect: "postgres", host: process.env.DB_HOST || "localhost", - logging: process.env.NODE_ENV !== "production", + logging: nodeEnv !== "production", password: process.env.DB_PASSWORD || "postgres", port: parseInt(process.env.DB_PORT || "5432", 10), username: process.env.DB_USERNAME || "postgres" @@ -114,7 +130,8 @@ export const config: Config = { defaults: { vortexEvmPayoutAddress: process.env.DEFAULT_VORTEX_EVM_PAYOUT_ADDRESS }, - env: process.env.NODE_ENV || "production", + deploymentEnv: readDeploymentEnv(), + env: nodeEnv, integrations: { alchemy: { @@ -129,7 +146,7 @@ export const config: Config = { webhookToken: process.env.SLACK_WEB_HOOK_TOKEN } }, - logs: process.env.NODE_ENV === "production" ? "combined" : "dev", + logs: nodeEnv === "production" ? "combined" : "dev", pendulumWss: process.env.PENDULUM_WSS || "wss://rpc-pendulum.prd.pendulumchain.tech", port: process.env.PORT || 3000, priceProviders: { @@ -197,11 +214,15 @@ export const config: Config = { export const SEP10_MASTER_SECRET = config.secrets.stellarFundingSecret; export const EVM_FUNDING_PRIVATE_KEY = process.env.EVM_FUNDING_PRIVATE_KEY ?? config.secrets.moonbeamExecutorPrivateKey; -if (config.env === "production") { - if (config.sandboxEnabled) { - throw new Error("SANDBOX_ENABLED must not be 'true' in production — refusing to start"); - } +if (config.deploymentEnv === "production" && config.sandboxEnabled) { + throw new Error("SANDBOX_ENABLED=true requires DEPLOYMENT_ENV=sandbox; refusing to start production deployment"); +} +if (config.deploymentEnv === "sandbox" && !config.sandboxEnabled) { + throw new Error("DEPLOYMENT_ENV=sandbox requires SANDBOX_ENABLED=true"); +} + +if (config.env === "production") { const missing: string[] = []; if (!config.supabase.url) missing.push("SUPABASE_URL"); From 82f7c857780088641b1306b8664ebe8b95a2f3bf Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 20 May 2026 14:11:33 +0200 Subject: [PATCH 02/12] fix(api): require auth on BRLA endpoints reachable via SDK - /kyb/attempt-status now requires Supabase auth (CRITICAL-1: was unauthenticated, leaked KYB attempt data by subAccountId). - /getUser, /getUserRemainingLimit, /validatePixKey accept partner API key OR Supabase token via requirePartnerOrUserAuth, so SDK partners can reach them during ramp pre-flight. --- apps/api/src/api/routes/v1/brla.route.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/api/src/api/routes/v1/brla.route.ts b/apps/api/src/api/routes/v1/brla.route.ts index 7a7b5d5f5..520bc4b9a 100644 --- a/apps/api/src/api/routes/v1/brla.route.ts +++ b/apps/api/src/api/routes/v1/brla.route.ts @@ -1,5 +1,6 @@ import { RequestHandler, Router } from "express"; import * as brlaController from "../../controllers/brla.controller"; +import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; import { optionalAuth, requireAuth } from "../../middlewares/supabaseAuth"; import { validateStartKyc2, validateSubaccountCreation } from "../../middlewares/validators"; @@ -8,15 +9,19 @@ const router: Router = Router({ mergeParams: true }); // Controllers use typed Request generics (e.g. Request) // which don't extend Express's ParsedQs. Double-cast via unknown is the standard Express pattern // for combining middleware with narrowly-typed handlers. Runtime query validation is in each controller. -router.get("/getUser", requireAuth, brlaController.getAveniaUser as unknown as RequestHandler); +router.get("/getUser", requirePartnerOrUserAuth(), brlaController.getAveniaUser as unknown as RequestHandler); -router.get("/getUserRemainingLimit", requireAuth, brlaController.getAveniaUserRemainingLimit as unknown as RequestHandler); +router.get( + "/getUserRemainingLimit", + requirePartnerOrUserAuth(), + brlaController.getAveniaUserRemainingLimit as unknown as RequestHandler +); router.get("/getKycStatus", requireAuth, brlaController.fetchSubaccountKycStatus as unknown as RequestHandler); router.get("/getSelfieLivenessUrl", requireAuth, brlaController.getSelfieLivenessUrl as unknown as RequestHandler); -router.get("/validatePixKey", requireAuth, brlaController.validatePixKey as unknown as RequestHandler); +router.get("/validatePixKey", requirePartnerOrUserAuth(), brlaController.validatePixKey as unknown as RequestHandler); router.route("/createSubaccount").post(validateSubaccountCreation, optionalAuth, brlaController.createSubaccount); @@ -26,7 +31,7 @@ router.route("/newKyc").post(optionalAuth, brlaController.newKyc); router.route("/kyb/new-level-1/web-sdk").post(optionalAuth, brlaController.initiateKybLevel1); -router.route("/kyb/attempt-status").get(brlaController.getKybAttemptStatus); +router.route("/kyb/attempt-status").get(requireAuth, brlaController.getKybAttemptStatus); router.route("/kyc/record-attempt").post(requireAuth, brlaController.recordInitialKycAttempt); From 8a905e186a29dd2a903b10eb86c41a6911a3a6dc Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 20 May 2026 14:11:45 +0200 Subject: [PATCH 03/12] fix(api): block subaccount takeover via createSubaccount taxId collision CRITICAL-2: createSubaccount unconditionally overwrote the TaxId.subAccountId row on any taxId match, letting an attacker (anonymous or authenticated) hijack another user's subaccount by reusing their taxId and pointing it at a freshly created BRLA subaccount they control. - Look up the existing TaxId row before calling the BRLA API. If it is owned by a different userId, or owned by no one while the caller is authenticated, reject with 409 instead of overwriting. Overwrite is still allowed when the row is in the safe Consulted state (no real subaccount bound yet). - Surface APIError status codes from handleApiError so the 409 reaches clients. - Add controller tests covering getAveniaUser query handling and the new createSubaccount ownership cases. --- .../api/controllers/brla.controller.test.ts | 259 ++++++++++++++++++ .../src/api/controllers/brla.controller.ts | 24 +- 2 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/api/controllers/brla.controller.test.ts diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts new file mode 100644 index 000000000..1d56cf5ed --- /dev/null +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -0,0 +1,259 @@ +import { AveniaAccountType, BrlaApiService } from "@vortexfi/shared"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import httpStatus from "http-status"; +import logger from "../../config/logger"; +import TaxId, { TaxIdInternalStatus } from "../../models/taxId.model"; +import { createSubaccount, getAveniaUser } from "./brla.controller"; + +function createResponse() { + const res = { + body: undefined as unknown, + statusCode: Number(httpStatus.OK), + json: mock((body: unknown) => { + res.body = body; + return res; + }), + status: mock((statusCode: number) => { + res.statusCode = statusCode; + return res; + }) + }; + + return res; +} + +describe("getAveniaUser", () => { + const originalFindOne = TaxId.findOne; + const originalGetInstance = BrlaApiService.getInstance; + const originalLoggerError = logger.error; + const originalLoggerInfo = logger.info; + + beforeEach(() => { + logger.error = mock(() => logger) as typeof logger.error; + logger.info = mock(() => logger) as typeof logger.info; + }); + + afterEach(() => { + TaxId.findOne = originalFindOne; + BrlaApiService.getInstance = originalGetInstance; + logger.error = originalLoggerError; + logger.info = originalLoggerInfo; + }); + + function mockConfirmedAveniaUser() { + TaxId.findOne = mock(async () => ({ subAccountId: "subaccount-1" })) as typeof TaxId.findOne; + BrlaApiService.getInstance = mock( + () => + ({ + subaccountInfo: mock(async () => ({ + accountInfo: { identityStatus: "CONFIRMED" }, + wallets: [{ chain: "EVM", walletAddress: "0x1234567890123456789012345678901234567890" }] + })) + }) as unknown as BrlaApiService + ); + } + + const expectedConfirmedBody = { + evmAddress: "0x1234567890123456789012345678901234567890", + identityStatus: "CONFIRMED", + kycLevel: 1, + subAccountId: "subaccount-1" + }; + + it("returns 400 when taxId is missing", async () => { + mockConfirmedAveniaUser(); + + const res = createResponse(); + await getAveniaUser( + { + query: {} + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(res.body).toEqual({ error: "Missing taxId query parameters" }); + }); + + it("allows partner API key lookups without quoteId", async () => { + mockConfirmedAveniaUser(); + + const res = createResponse(); + await getAveniaUser( + { + authenticatedPartner: { id: "partner-1", name: "Partner" }, + query: { taxId: "08786985906" } + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(res.body).toEqual(expectedConfirmedBody); + }); + + it("allows Supabase-authenticated user lookups", async () => { + mockConfirmedAveniaUser(); + + const res = createResponse(); + await getAveniaUser( + { + query: { taxId: "08786985906" }, + userId: "user-1" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(res.body).toEqual(expectedConfirmedBody); + }); +}); + +describe("createSubaccount", () => { + const originalFindByPk = TaxId.findByPk; + const originalCreate = TaxId.create; + const originalGetInstance = BrlaApiService.getInstance; + const originalLoggerError = logger.error; + + beforeEach(() => { + logger.error = mock(() => logger) as typeof logger.error; + }); + + afterEach(() => { + TaxId.findByPk = originalFindByPk; + TaxId.create = originalCreate; + BrlaApiService.getInstance = originalGetInstance; + logger.error = originalLoggerError; + }); + + const createAveniaSubaccountMock = mock(async () => ({ id: "new-subaccount" })); + + function mockBrlaApi() { + BrlaApiService.getInstance = mock( + () => + ({ + createAveniaSubaccount: createAveniaSubaccountMock + }) as unknown as BrlaApiService + ); + } + + const validBody = { + accountType: AveniaAccountType.INDIVIDUAL, + name: "Attacker", + quoteId: "quote-1", + taxId: "08786985906" + }; + + it("rejects when an existing subaccount belongs to a different Supabase user", async () => { + mockBrlaApi(); + createAveniaSubaccountMock.mockClear(); + TaxId.findByPk = mock(async () => ({ + internalStatus: TaxIdInternalStatus.Accepted, + userId: "victim-user" + })) as typeof TaxId.findByPk; + + const res = createResponse(); + await createSubaccount( + { + body: validBody, + userId: "attacker-user" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(res.body).toEqual({ error: "A subaccount already exists for this taxId" }); + expect(createAveniaSubaccountMock).not.toHaveBeenCalled(); + }); + + it("rejects when an authenticated caller targets an anonymously-owned existing subaccount", async () => { + mockBrlaApi(); + createAveniaSubaccountMock.mockClear(); + TaxId.findByPk = mock(async () => ({ + internalStatus: TaxIdInternalStatus.Requested, + userId: null + })) as typeof TaxId.findByPk; + + const res = createResponse(); + await createSubaccount( + { + body: validBody, + userId: "some-user" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(createAveniaSubaccountMock).not.toHaveBeenCalled(); + }); + + it("allows an authenticated user to (re)create their own subaccount", async () => { + mockBrlaApi(); + createAveniaSubaccountMock.mockClear(); + const updateMock = mock(async () => undefined); + TaxId.findByPk = mock(async () => ({ + internalStatus: TaxIdInternalStatus.Accepted, + update: updateMock, + userId: "same-user" + })) as typeof TaxId.findByPk; + + const res = createResponse(); + await createSubaccount( + { + body: validBody, + userId: "same-user" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(res.body).toEqual({ subAccountId: "new-subaccount" }); + expect(createAveniaSubaccountMock).toHaveBeenCalled(); + expect(updateMock).toHaveBeenCalled(); + }); + + it("allows creation when no existing subaccount record exists", async () => { + mockBrlaApi(); + createAveniaSubaccountMock.mockClear(); + const createTaxIdMock = mock(async () => undefined); + TaxId.findByPk = mock(async () => null) as typeof TaxId.findByPk; + TaxId.create = createTaxIdMock as unknown as typeof TaxId.create; + + const res = createResponse(); + await createSubaccount( + { + body: validBody, + userId: "new-user" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(res.body).toEqual({ subAccountId: "new-subaccount" }); + expect(createAveniaSubaccountMock).toHaveBeenCalled(); + expect(createTaxIdMock).toHaveBeenCalled(); + }); + + it("allows overwrite when the existing record is only in Consulted state", async () => { + mockBrlaApi(); + createAveniaSubaccountMock.mockClear(); + const updateMock = mock(async () => undefined); + TaxId.findByPk = mock(async () => ({ + internalStatus: TaxIdInternalStatus.Consulted, + update: updateMock, + userId: "victim-user" + })) as typeof TaxId.findByPk; + + const res = createResponse(); + await createSubaccount( + { + body: validBody, + userId: "attacker-user" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(createAveniaSubaccountMock).toHaveBeenCalled(); + expect(updateMock).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index bc168570b..66c0a92d4 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -102,6 +102,11 @@ function mapKycFailureReason(webhookReason: string | undefined): KycFailureReaso function handleApiError(error: unknown, res: Response, apiMethod: string): void { logger.error(`Error while performing ${apiMethod}: `, error); + if (error instanceof APIError) { + res.status(error.status ?? httpStatus.INTERNAL_SERVER_ERROR).json({ error: error.message }); + return; + } + if (error instanceof Error && error.message.includes("status '400'")) { const splitError = error.message.split("Error: ", 1); if (splitError.length > 1) { @@ -133,7 +138,7 @@ function handleApiError(error: unknown, res: Response, apiMethod: string): void * * @returns void - Sends JSON response with evmAddress on success, or appropriate error status * - * @throws 400 - If taxId or pixId are missing or if KYC level is invalid + * @throws 400 - If taxId is missing * @throws 404 - If the subaccount cannot be found * @throws 500 - For any server-side errors during processing */ @@ -192,7 +197,7 @@ export const getAveniaUser = async ( export const recordInitialKycAttempt = async ( req: Request, - res: Response<{} | BrlaErrorResponse> + res: Response | BrlaErrorResponse> ): Promise => { try { const { taxId, quoteId, sessionId } = req.body; @@ -311,9 +316,22 @@ export const createSubaccount = async ( // Use the accountType from the request if provided, otherwise determine from taxId const accountType = requestAccountType || (isCnpj ? AveniaAccountType.COMPANY : AveniaAccountType.INDIVIDUAL); + // Ownership check BEFORE calling the BRLA API to avoid creating a stranded subaccount + // on every conflict and to prevent account-takeover via subAccountId overwrite. + const existingTaxId = await TaxId.findByPk(normalizedTaxId); + if (existingTaxId && existingTaxId.internalStatus !== TaxIdInternalStatus.Consulted) { + const ownedByAnotherUser = existingTaxId.userId !== null && existingTaxId.userId !== (req.userId ?? null); + const ownedByAnonymousAndCallerIsUser = existingTaxId.userId === null && !!req.userId; + if (ownedByAnotherUser || ownedByAnonymousAndCallerIsUser) { + res.status(httpStatus.CONFLICT).json({ + error: "A subaccount already exists for this taxId" + } as unknown as BrlaCreateSubaccountResponse); + return; + } + } + const brlaApiService = BrlaApiService.getInstance(); const { id } = await brlaApiService.createAveniaSubaccount(accountType, name); - const existingTaxId = await TaxId.findByPk(normalizedTaxId); if (existingTaxId) { await existingTaxId.update({ From 2c3fb20d267e7234363136e416afdac897c08525 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 20 May 2026 14:11:54 +0200 Subject: [PATCH 04/12] fix(sdk): drop quoteId from getBrlKycStatus to match /getUser contract The shared BrlaGetUserRequest type no longer carries quoteId, and /brla/getUser now authenticates via partner key or user token instead of a quote-ownership check. Update the SDK call sites accordingly so BRL pre-flight KYC validation works for partner SDK callers without a stale quoteId being appended to the query string. --- packages/sdk/src/handlers/BrlHandler.ts | 8 ++++---- packages/sdk/src/services/ApiService.ts | 5 +---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/sdk/src/handlers/BrlHandler.ts b/packages/sdk/src/handlers/BrlHandler.ts index 0f2409478..213585346 100644 --- a/packages/sdk/src/handlers/BrlHandler.ts +++ b/packages/sdk/src/handlers/BrlHandler.ts @@ -60,7 +60,7 @@ export class BrlHandler implements RampHandler { throw new Error("Tax ID is required for BRL onramp"); } - await this.validateBrlKyc(additionalData.taxId, quoteId); + await this.validateBrlKyc(additionalData.taxId); const { ephemerals, accountMetas } = await this.generateEphemerals(); @@ -99,7 +99,7 @@ export class BrlHandler implements RampHandler { throw new Error("Tax ID is required for BRL offramps"); } - await this.validateBrlKyc(additionalData.taxId, quoteId); + await this.validateBrlKyc(additionalData.taxId); const { ephemerals, accountMetas } = await this.generateEphemerals(); @@ -162,12 +162,12 @@ export class BrlHandler implements RampHandler { return this.apiService.startRamp(startRequest); } - private async validateBrlKyc(taxId: string, quoteId: string): Promise { + private async validateBrlKyc(taxId: string): Promise { if (!taxId) { throw new BrlKycStatusError("Tax ID is required", 400); } - const kycStatus = await this.apiService.getBrlKycStatus(taxId, quoteId); + const kycStatus = await this.apiService.getBrlKycStatus(taxId); if (kycStatus.kycLevel < 1) { throw new Error(`Insufficient KYC level. Current: ${kycStatus.kycLevel}`); } diff --git a/packages/sdk/src/services/ApiService.ts b/packages/sdk/src/services/ApiService.ts index 20923683a..bad5e01aa 100644 --- a/packages/sdk/src/services/ApiService.ts +++ b/packages/sdk/src/services/ApiService.ts @@ -86,12 +86,9 @@ export class ApiService { return handleAPIResponse(response, `/v1/ramp/status?id=${rampId}`); } - async getBrlKycStatus(taxId: string, quoteId: string): Promise { + async getBrlKycStatus(taxId: string): Promise { const url = new URL(`${this.apiBaseUrl}/v1/brla/getUser`); url.searchParams.append("taxId", taxId); - if (quoteId) { - url.searchParams.append("quoteId", quoteId); - } const response = await fetch(url.toString(), { headers: this.buildHeaders(), From e07e05bbfa7b6ddf37a386ffa7fff38822cfbd0a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 20 May 2026 14:12:46 +0200 Subject: [PATCH 05/12] Small refactor --- .../src/api/controllers/ramp.controller.ts | 2 +- apps/api/src/api/middlewares/dualAuth.test.ts | 22 +++- apps/api/src/api/middlewares/dualAuth.ts | 88 +-------------- apps/api/src/api/middlewares/ownershipAuth.ts | 101 ++++++++++++++++++ apps/api/src/config/vars.test.ts | 70 ++++++++++++ 5 files changed, 194 insertions(+), 89 deletions(-) create mode 100644 apps/api/src/api/middlewares/ownershipAuth.ts create mode 100644 apps/api/src/config/vars.test.ts diff --git a/apps/api/src/api/controllers/ramp.controller.ts b/apps/api/src/api/controllers/ramp.controller.ts index ea4e3172b..e0d3498a8 100644 --- a/apps/api/src/api/controllers/ramp.controller.ts +++ b/apps/api/src/api/controllers/ramp.controller.ts @@ -15,7 +15,7 @@ import { NextFunction, Request, Response } from "express"; import httpStatus from "http-status"; import logger from "../../config/logger"; import { APIError } from "../errors/api-error"; -import { assertQuoteOwnership, assertRampOwnership } from "../middlewares/dualAuth"; +import { assertQuoteOwnership, assertRampOwnership } from "../middlewares/ownershipAuth"; import rampService from "../services/ramp/ramp.service"; /** diff --git a/apps/api/src/api/middlewares/dualAuth.test.ts b/apps/api/src/api/middlewares/dualAuth.test.ts index 4a1315cc0..9b96414bc 100644 --- a/apps/api/src/api/middlewares/dualAuth.test.ts +++ b/apps/api/src/api/middlewares/dualAuth.test.ts @@ -1,12 +1,15 @@ -import { afterEach, describe, expect, it, mock } from "bun:test"; +import {afterEach, describe, expect, it, mock} from "bun:test"; +import Partner from "../../models/partner.model"; import QuoteTicket from "../../models/quoteTicket.model"; -import { assertQuoteOwnership } from "./dualAuth"; +import {assertQuoteOwnership} from "./ownershipAuth"; describe("assertQuoteOwnership", () => { const originalFindByPk = QuoteTicket.findByPk; + const originalPartnerFindByPk = Partner.findByPk; afterEach(() => { QuoteTicket.findByPk = originalFindByPk; + Partner.findByPk = originalPartnerFindByPk; }); it("rejects a Supabase user registering another user's quote", async () => { @@ -37,4 +40,19 @@ describe("assertQuoteOwnership", () => { await expect(assertQuoteOwnership({ userId: "user-1" }, "quote-1")).resolves.toBeUndefined(); }); + + it("allows partner API keys to access quotes owned by another active partner row with the same name", async () => { + QuoteTicket.findByPk = mock(async () => ({ + partnerId: "quote-partner-id", + userId: null + })) as typeof QuoteTicket.findByPk; + Partner.findByPk = mock(async () => ({ + id: "quote-partner-id", + name: "Partner" + })) as typeof Partner.findByPk; + + await expect( + assertQuoteOwnership({ authenticatedPartner: { id: "api-key-partner-id", name: "Partner" } }, "quote-1") + ).resolves.toBeUndefined(); + }); }); diff --git a/apps/api/src/api/middlewares/dualAuth.ts b/apps/api/src/api/middlewares/dualAuth.ts index 6bbb2327a..cb9df4e0e 100644 --- a/apps/api/src/api/middlewares/dualAuth.ts +++ b/apps/api/src/api/middlewares/dualAuth.ts @@ -1,12 +1,10 @@ import { NextFunction, Request, Response } from "express"; -import httpStatus from "http-status"; import logger from "../../config/logger"; -import QuoteTicket from "../../models/quoteTicket.model"; -import RampState from "../../models/rampState.model"; -import { APIError } from "../errors/api-error"; import { SupabaseAuthService } from "../services/auth"; import { getKeyType, isValidSecretKeyFormat, validateSecretApiKey } from "./apiKeyAuth.helpers"; +export { assertQuoteOwnership, assertRampOwnership } from "./ownershipAuth"; + /** * Dual-track authentication: accepts either a partner secret API key * (X-API-Key: sk_*) or a Supabase user Bearer token (Authorization: Bearer ...). @@ -76,85 +74,3 @@ export function requirePartnerOrUserAuth() { } }; } - -/** - * Verify the authenticated principal owns the ramp identified by req.params.id - * or req.body.rampId. Partner principals must match the quote's partnerId; - * user principals must match the ramp state's userId. - */ -export async function assertRampOwnership( - req: Pick, - rampId: string -): Promise { - const ramp = await RampState.findByPk(rampId); - if (!ramp) { - throw new APIError({ message: "Ramp not found", status: httpStatus.NOT_FOUND }); - } - - if (req.authenticatedPartner) { - const quote = await QuoteTicket.findByPk(ramp.quoteId); - if (!quote) { - throw new APIError({ message: "Associated quote not found", status: httpStatus.NOT_FOUND }); - } - if (quote.partnerId !== req.authenticatedPartner.id) { - throw new APIError({ - message: "Authenticated partner does not own this ramp", - status: httpStatus.FORBIDDEN - }); - } - return; - } - - if (req.userId) { - if (ramp.userId !== req.userId) { - throw new APIError({ - message: "Authenticated user does not own this ramp", - status: httpStatus.FORBIDDEN - }); - } - return; - } - - throw new APIError({ message: "Authentication required", status: httpStatus.UNAUTHORIZED }); -} - -/** - * Ownership check for the register flow, which references a quote (not yet a ramp). - */ -export async function assertQuoteOwnership( - req: Pick, - quoteId: string -): Promise { - const quote = await QuoteTicket.findByPk(quoteId); - if (!quote) { - throw new APIError({ message: "Quote not found", status: httpStatus.NOT_FOUND }); - } - - if (req.authenticatedPartner) { - if (quote.partnerId !== req.authenticatedPartner.id) { - throw new APIError({ - message: "Authenticated partner does not own this quote", - status: httpStatus.FORBIDDEN - }); - } - return; - } - - if (req.userId) { - if (quote.partnerId !== null) { - throw new APIError({ - message: "This quote belongs to a partner; user authentication is not sufficient", - status: httpStatus.FORBIDDEN - }); - } - if (quote.userId !== null && quote.userId !== req.userId) { - throw new APIError({ - message: "Authenticated user does not own this quote", - status: httpStatus.FORBIDDEN - }); - } - return; - } - - throw new APIError({ message: "Authentication required", status: httpStatus.UNAUTHORIZED }); -} diff --git a/apps/api/src/api/middlewares/ownershipAuth.ts b/apps/api/src/api/middlewares/ownershipAuth.ts new file mode 100644 index 000000000..efddba346 --- /dev/null +++ b/apps/api/src/api/middlewares/ownershipAuth.ts @@ -0,0 +1,101 @@ +import { Request } from "express"; +import httpStatus from "http-status"; +import Partner from "../../models/partner.model"; +import QuoteTicket from "../../models/quoteTicket.model"; +import RampState from "../../models/rampState.model"; +import { APIError } from "../errors/api-error"; +import type { AuthenticatedPartner } from "./apiKeyAuth.helpers"; + +async function ownsPartnerRecord(authenticatedPartner: AuthenticatedPartner, partnerId: string | null): Promise { + if (!partnerId) { + return false; + } + if (partnerId === authenticatedPartner.id) { + return true; + } + + const quotePartner = await Partner.findByPk(partnerId); + return quotePartner?.name === authenticatedPartner.name; +} + +/** + * Verify the authenticated principal owns the ramp identified by req.params.id + * or req.body.rampId. Partner principals must match the quote's partnerId; + * user principals must match the ramp state's userId. + */ +export async function assertRampOwnership( + req: Pick, + rampId: string +): Promise { + const ramp = await RampState.findByPk(rampId); + if (!ramp) { + throw new APIError({ message: "Ramp not found", status: httpStatus.NOT_FOUND }); + } + + if (req.authenticatedPartner) { + const quote = await QuoteTicket.findByPk(ramp.quoteId); + if (!quote) { + throw new APIError({ message: "Associated quote not found", status: httpStatus.NOT_FOUND }); + } + if (!(await ownsPartnerRecord(req.authenticatedPartner, quote.partnerId))) { + throw new APIError({ + message: "Authenticated partner does not own this ramp", + status: httpStatus.FORBIDDEN + }); + } + return; + } + + if (req.userId) { + if (ramp.userId !== req.userId) { + throw new APIError({ + message: "Authenticated user does not own this ramp", + status: httpStatus.FORBIDDEN + }); + } + return; + } + + throw new APIError({ message: "Authentication required", status: httpStatus.UNAUTHORIZED }); +} + +/** + * Ownership check for flows that reference a quote before a ramp exists. + */ +export async function assertQuoteOwnership( + req: Pick, + quoteId: string +): Promise { + const quote = await QuoteTicket.findByPk(quoteId); + if (!quote) { + throw new APIError({ message: "Quote not found", status: httpStatus.NOT_FOUND }); + } + + if (req.authenticatedPartner) { + if (!(await ownsPartnerRecord(req.authenticatedPartner, quote.partnerId))) { + throw new APIError({ + message: "Authenticated partner does not own this quote", + status: httpStatus.FORBIDDEN + }); + } + return; + } + + if (req.userId) { + if (quote.partnerId !== null) { + throw new APIError({ + message: "This quote belongs to a partner; user authentication is not sufficient", + status: httpStatus.FORBIDDEN + }); + } + if (quote.userId !== null && quote.userId !== req.userId) { + throw new APIError({ + message: "Authenticated user does not own this quote", + status: httpStatus.FORBIDDEN + }); + } + return; + } + + throw new APIError({ message: "Authentication required", status: httpStatus.UNAUTHORIZED }); +} diff --git a/apps/api/src/config/vars.test.ts b/apps/api/src/config/vars.test.ts new file mode 100644 index 000000000..41ed0cc5f --- /dev/null +++ b/apps/api/src/config/vars.test.ts @@ -0,0 +1,70 @@ +import {describe, expect, it} from "bun:test"; + +const varsModuleUrl = new URL("./vars.ts", import.meta.url).href; +const bunExecutable = Bun.argv[0]; + +const requiredProductionEnv = { + ADMIN_SECRET: "test-admin-secret", + SUPABASE_ANON_KEY: "test-anon-key", + SUPABASE_SERVICE_KEY: "test-service-key", + SUPABASE_URL: "https://example.supabase.co", + WEBHOOK_PRIVATE_KEY: "test-webhook-private-key" +}; + +async function importVarsWithEnv(env: Record) { + const proc = Bun.spawn({ + cmd: [ + bunExecutable, + "-e", + `import(${JSON.stringify(varsModuleUrl)}).then(() => console.log("ok")).catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); })` + ], + env: { + PATH: process.env.PATH ?? "", + ...requiredProductionEnv, + ...env + }, + stderr: "pipe", + stdout: "pipe" + }); + + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text() + ]); + + return { exitCode, stderr, stdout }; +} + +describe("vars deployment environment validation", () => { + it("allows sandbox mode with a production runtime when the deployment is explicitly sandbox", async () => { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "sandbox", + NODE_ENV: "production", + SANDBOX_ENABLED: "true" + }); + + expect(result).toEqual({ exitCode: 0, stderr: "", stdout: "ok\n" }); + }); + + it("rejects sandbox mode when the deployment defaults to production", async () => { + const result = await importVarsWithEnv({ + NODE_ENV: "production", + SANDBOX_ENABLED: "true" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("DEPLOYMENT_ENV=sandbox"); + }); + + it("rejects a sandbox deployment without sandbox mode enabled", async () => { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "sandbox", + NODE_ENV: "production", + SANDBOX_ENABLED: "false" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("DEPLOYMENT_ENV=sandbox requires SANDBOX_ENABLED=true"); + }); +}); From c0592a0ee2dcb100535eed83b4e1a13fd7b5055d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 20 May 2026 14:15:55 +0200 Subject: [PATCH 06/12] fix(api): enforce per-user ownership on KYC/KYB BRLA endpoints Several BRLA endpoints accepted any authenticated (or even anonymous) caller and operated on taxId/subAccountId without checking that the row belongs to the caller. That let one Supabase user read or progress another user's KYC. - getAveniaUser: when authenticated as a Supabase user, reject with 403 if the taxId row's userId differs (HIGH-1). Partner SDK callers are unaffected. - /getUploadUrls: switch to requireAuth and require taxIdRecord.userId to match the caller (HIGH-3). - /newKyc: switch to requireAuth, validate subAccountId, and require the bound taxId row's userId to match the caller (HIGH-2). - /kyb/new-level-1/web-sdk: switch to requireAuth and require taxIdRecord.userId to match before initiating KYB (HIGH-4). - Extend getAveniaUser tests with a cross-user 403 case. --- .../api/controllers/brla.controller.test.ts | 22 ++++++++++-- .../src/api/controllers/brla.controller.ts | 34 +++++++++++++++++++ apps/api/src/api/routes/v1/brla.route.ts | 6 ++-- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index 1d56cf5ed..3f88a8a3b 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -40,8 +40,8 @@ describe("getAveniaUser", () => { logger.info = originalLoggerInfo; }); - function mockConfirmedAveniaUser() { - TaxId.findOne = mock(async () => ({ subAccountId: "subaccount-1" })) as typeof TaxId.findOne; + function mockConfirmedAveniaUser(userId: string | null = null) { + TaxId.findOne = mock(async () => ({ subAccountId: "subaccount-1", userId })) as typeof TaxId.findOne; BrlaApiService.getInstance = mock( () => ({ @@ -92,7 +92,7 @@ describe("getAveniaUser", () => { }); it("allows Supabase-authenticated user lookups", async () => { - mockConfirmedAveniaUser(); + mockConfirmedAveniaUser("user-1"); const res = createResponse(); await getAveniaUser( @@ -106,6 +106,22 @@ describe("getAveniaUser", () => { expect(res.statusCode).toBe(httpStatus.OK); expect(res.body).toEqual(expectedConfirmedBody); }); + + it("rejects Supabase-authenticated lookups for another user's taxId", async () => { + mockConfirmedAveniaUser("victim-user"); + + const res = createResponse(); + await getAveniaUser( + { + query: { taxId: "08786985906" }, + userId: "attacker-user" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.FORBIDDEN); + expect(res.body).toEqual({ error: "Forbidden" }); + }); }); describe("createSubaccount", () => { diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 66c0a92d4..09470eb73 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -168,6 +168,13 @@ export const getAveniaUser = async ( return; } + // When the caller authenticated as a Supabase user, only the owning user may read this taxId. + // Partner SDK callers (no req.userId) are intentionally exempt: they are scoped by API key, not by user. + if (req.userId && taxIdRecord.userId !== req.userId) { + res.status(httpStatus.FORBIDDEN).json({ error: "Forbidden" }); + return; + } + const accountInfo = await brlaApiService.subaccountInfo(taxIdRecord.subAccountId); if (!accountInfo) { res.status(httpStatus.NOT_FOUND).json({ error: "Subaccount info not found" }); @@ -532,6 +539,11 @@ export const getUploadUrls = async ( return; } + if (!req.userId || taxIdRecord.userId !== req.userId) { + res.status(httpStatus.FORBIDDEN).json({ error: "Forbidden" }); + return; + } + const brlaApiService = BrlaApiService.getInstance(); const selfieUrl = await brlaApiService.getDocumentUploadUrls( @@ -572,6 +584,23 @@ export const newKyc = async ( const brlaApiService = BrlaApiService.getInstance(); await new Promise(resolve => setTimeout(resolve, 5000)); const subAccountId = req.body.subAccountId; + + if (!subAccountId) { + res.status(httpStatus.BAD_REQUEST).json({ error: "Missing subAccountId" }); + return; + } + + const taxIdRecord = await TaxId.findOne({ where: { subAccountId } }); + if (!taxIdRecord) { + res.status(httpStatus.NOT_FOUND).json({ error: "Subaccount not found" }); + return; + } + + if (!req.userId || taxIdRecord.userId !== req.userId) { + res.status(httpStatus.FORBIDDEN).json({ error: "Forbidden" }); + return; + } + await brlaApiService.getUploadedDocuments(subAccountId); const response = await brlaApiService.submitKycLevel1(req.body); @@ -607,6 +636,11 @@ export const initiateKybLevel1 = async ( return; } + if (!req.userId || taxIdRecord.userId !== req.userId) { + res.status(httpStatus.FORBIDDEN).json({ error: "Forbidden" }); + return; + } + if (taxIdRecord.accountType !== AveniaAccountType.COMPANY) { res.status(httpStatus.BAD_REQUEST).json({ error: "KYB Level 1 is only available for COMPANY accounts. This account is registered as " + taxIdRecord.accountType diff --git a/apps/api/src/api/routes/v1/brla.route.ts b/apps/api/src/api/routes/v1/brla.route.ts index 520bc4b9a..29cff39b0 100644 --- a/apps/api/src/api/routes/v1/brla.route.ts +++ b/apps/api/src/api/routes/v1/brla.route.ts @@ -25,11 +25,11 @@ router.get("/validatePixKey", requirePartnerOrUserAuth(), brlaController.validat router.route("/createSubaccount").post(validateSubaccountCreation, optionalAuth, brlaController.createSubaccount); -router.route("/getUploadUrls").post(validateStartKyc2, optionalAuth, brlaController.getUploadUrls); +router.route("/getUploadUrls").post(validateStartKyc2, requireAuth, brlaController.getUploadUrls); -router.route("/newKyc").post(optionalAuth, brlaController.newKyc); +router.route("/newKyc").post(requireAuth, brlaController.newKyc); -router.route("/kyb/new-level-1/web-sdk").post(optionalAuth, brlaController.initiateKybLevel1); +router.route("/kyb/new-level-1/web-sdk").post(requireAuth, brlaController.initiateKybLevel1); router.route("/kyb/attempt-status").get(requireAuth, brlaController.getKybAttemptStatus); From 327aea7a035f6d3994e005e5e56544b032d42132 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 20 May 2026 14:24:53 +0200 Subject: [PATCH 07/12] feat(sdk): pre-flight pix key + remaining-limit checks on BRL ramps Before this, BRL ramp registration only validated KYC level. Invalid pix keys and over-limit amounts were only surfaced deep in the phase processor, with opaque errors. - Add ApiService.validateBrlPixKey and ApiService.getBrlRemainingLimit, both hitting the partner-or-user-authenticated BRLA endpoints. - registerBrlOfframp now rejects invalid pix destinations up front with InvalidPixKeyError. - Both BRL register flows fetch the quote, look up the user's remaining BRL limit for the matching ramp direction, and throw AmountExceedsLimitError before any ephemeral generation or ramp registration when the BRL amount on the quote exceeds the remaining limit. --- packages/sdk/src/handlers/BrlHandler.ts | 31 ++++++++++++++++++++++++- packages/sdk/src/services/ApiService.ts | 26 +++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/handlers/BrlHandler.ts b/packages/sdk/src/handlers/BrlHandler.ts index 213585346..fb86ddab8 100644 --- a/packages/sdk/src/handlers/BrlHandler.ts +++ b/packages/sdk/src/handlers/BrlHandler.ts @@ -2,12 +2,13 @@ import { AccountMeta, EphemeralAccount, EphemeralAccountType, + RampDirection, RampProcess, RegisterRampRequest, UnsignedTx, UpdateRampRequest } from "@vortexfi/shared"; -import { BrlKycStatusError } from "../errors"; +import { AmountExceedsLimitError, BrlKycStatusError, InvalidPixKeyError } from "../errors"; import type { ApiService } from "../services/ApiService"; import type { BrlOfframpAdditionalData, @@ -61,6 +62,7 @@ export class BrlHandler implements RampHandler { } await this.validateBrlKyc(additionalData.taxId); + await this.assertWithinBrlLimit(additionalData.taxId, quoteId, RampDirection.BUY); const { ephemerals, accountMetas } = await this.generateEphemerals(); @@ -100,6 +102,8 @@ export class BrlHandler implements RampHandler { } await this.validateBrlKyc(additionalData.taxId); + await this.assertValidPixKey(additionalData.pixDestination); + await this.assertWithinBrlLimit(additionalData.taxId, quoteId, RampDirection.SELL); const { ephemerals, accountMetas } = await this.generateEphemerals(); @@ -172,4 +176,29 @@ export class BrlHandler implements RampHandler { throw new Error(`Insufficient KYC level. Current: ${kycStatus.kycLevel}`); } } + + private async assertValidPixKey(pixKey: string): Promise { + let result: { valid: boolean }; + try { + result = await this.apiService.validateBrlPixKey(pixKey); + } catch { + throw new InvalidPixKeyError(); + } + if (!result.valid) { + throw new InvalidPixKeyError(); + } + } + + private async assertWithinBrlLimit(taxId: string, quoteId: string, direction: RampDirection): Promise { + const quote = await this.apiService.getQuote(quoteId); + // BRL is the input on BUY (onramp) and the output on SELL (offramp). + const brlAmount = Number(direction === RampDirection.BUY ? quote.inputAmount : quote.outputAmount); + if (!Number.isFinite(brlAmount)) { + return; + } + const { remainingLimit } = await this.apiService.getBrlRemainingLimit(taxId, direction); + if (brlAmount > remainingLimit) { + throw new AmountExceedsLimitError(); + } + } } diff --git a/packages/sdk/src/services/ApiService.ts b/packages/sdk/src/services/ApiService.ts index bad5e01aa..52e374741 100644 --- a/packages/sdk/src/services/ApiService.ts +++ b/packages/sdk/src/services/ApiService.ts @@ -1,6 +1,7 @@ import type { CreateQuoteRequest, QuoteResponse, + RampDirection, RampProcess, RegisterRampRequest, RegisterRampResponse, @@ -97,4 +98,29 @@ export class ApiService { return handleAPIResponse(response, "/v1/brla/getUser"); } + + async getBrlRemainingLimit(taxId: string, direction: RampDirection): Promise<{ remainingLimit: number }> { + const url = new URL(`${this.apiBaseUrl}/v1/brla/getUserRemainingLimit`); + url.searchParams.append("taxId", taxId); + url.searchParams.append("direction", direction); + + const response = await fetch(url.toString(), { + headers: this.buildHeaders(), + method: "GET" + }); + + return handleAPIResponse<{ remainingLimit: number }>(response, "/v1/brla/getUserRemainingLimit"); + } + + async validateBrlPixKey(pixKey: string): Promise<{ valid: boolean }> { + const url = new URL(`${this.apiBaseUrl}/v1/brla/validatePixKey`); + url.searchParams.append("pixKey", pixKey); + + const response = await fetch(url.toString(), { + headers: this.buildHeaders(), + method: "GET" + }); + + return handleAPIResponse<{ valid: boolean }>(response, "/v1/brla/validatePixKey"); + } } From 2d47fdeaf1ef0e5ac8536743321c805d961fa308 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 14:36:34 +0000 Subject: [PATCH 08/12] Address review comments from PR #1151 and PR #1152 - Fix handleApiError split bug: use limit 2 so JSON parsing branch is reachable - Fix subaccount ownership: allow authenticated users to claim anonymous TaxId records - Widen createSubaccount response type to BrlaCreateSubaccountResponse | BrlaErrorResponse - Enforce sandboxEnabled => deploymentEnv === "sandbox" for all environments - Require partner isActive flag in ownsPartnerRecord ownership check - Fail-closed on non-finite BRL amount in assertWithinBrlLimit - Update InvalidPixKeyError message to be pixKey-specific - Move newKyc validation before 5-second sleep to reject invalid requests early - Fix misleading "scoped by API key" comment in getAveniaUser Agent-Logs-Url: https://github.com/pendulum-chain/vortex/sessions/64b0a8ee-18e3-4c9b-a61b-973f3ef3a63b Co-authored-by: ebma <6690623+ebma@users.noreply.github.com> --- .../src/api/controllers/brla.controller.ts | 19 ++++++++++++------- apps/api/src/api/middlewares/ownershipAuth.ts | 2 +- apps/api/src/config/vars.ts | 4 ++-- packages/sdk/src/errors.ts | 2 +- packages/sdk/src/handlers/BrlHandler.ts | 2 +- 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 09470eb73..565fa35b2 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -108,7 +108,7 @@ function handleApiError(error: unknown, res: Response, apiMethod: string): void } if (error instanceof Error && error.message.includes("status '400'")) { - const splitError = error.message.split("Error: ", 1); + const splitError = error.message.split("Error: ", 2); if (splitError.length > 1) { const errorMessageString = splitError[1]; try { @@ -169,7 +169,8 @@ export const getAveniaUser = async ( } // When the caller authenticated as a Supabase user, only the owning user may read this taxId. - // Partner SDK callers (no req.userId) are intentionally exempt: they are scoped by API key, not by user. + // Partner SDK callers (no req.userId) are intentionally exempt: they authenticate via API key + // and may need to look up any taxId for their integration flow. if (req.userId && taxIdRecord.userId !== req.userId) { res.status(httpStatus.FORBIDDEN).json({ error: "Forbidden" }); return; @@ -311,7 +312,7 @@ export const getAveniaUserRemainingLimit = async ( export const createSubaccount = async ( req: Request, - res: Response + res: Response ): Promise => { try { const { name, taxId, accountType: requestAccountType, quoteId, sessionId } = req.body; @@ -328,13 +329,16 @@ export const createSubaccount = async ( const existingTaxId = await TaxId.findByPk(normalizedTaxId); if (existingTaxId && existingTaxId.internalStatus !== TaxIdInternalStatus.Consulted) { const ownedByAnotherUser = existingTaxId.userId !== null && existingTaxId.userId !== (req.userId ?? null); - const ownedByAnonymousAndCallerIsUser = existingTaxId.userId === null && !!req.userId; - if (ownedByAnotherUser || ownedByAnonymousAndCallerIsUser) { + if (ownedByAnotherUser) { res.status(httpStatus.CONFLICT).json({ error: "A subaccount already exists for this taxId" - } as unknown as BrlaCreateSubaccountResponse); + }); return; } + // Allow authenticated users to claim anonymous records by updating userId + if (existingTaxId.userId === null && req.userId) { + await existingTaxId.update({ userId: req.userId }); + } } const brlaApiService = BrlaApiService.getInstance(); @@ -582,7 +586,6 @@ export const newKyc = async ( ): Promise => { try { const brlaApiService = BrlaApiService.getInstance(); - await new Promise(resolve => setTimeout(resolve, 5000)); const subAccountId = req.body.subAccountId; if (!subAccountId) { @@ -601,6 +604,8 @@ export const newKyc = async ( return; } + // Wait for document propagation before fetching uploaded documents + await new Promise(resolve => setTimeout(resolve, 5000)); await brlaApiService.getUploadedDocuments(subAccountId); const response = await brlaApiService.submitKycLevel1(req.body); diff --git a/apps/api/src/api/middlewares/ownershipAuth.ts b/apps/api/src/api/middlewares/ownershipAuth.ts index efddba346..5492ccbdc 100644 --- a/apps/api/src/api/middlewares/ownershipAuth.ts +++ b/apps/api/src/api/middlewares/ownershipAuth.ts @@ -15,7 +15,7 @@ async function ownsPartnerRecord(authenticatedPartner: AuthenticatedPartner, par } const quotePartner = await Partner.findByPk(partnerId); - return quotePartner?.name === authenticatedPartner.name; + return quotePartner?.isActive === true && quotePartner.name === authenticatedPartner.name; } /** diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index f44b17742..f39fa69ed 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -214,8 +214,8 @@ export const config: Config = { export const SEP10_MASTER_SECRET = config.secrets.stellarFundingSecret; export const EVM_FUNDING_PRIVATE_KEY = process.env.EVM_FUNDING_PRIVATE_KEY ?? config.secrets.moonbeamExecutorPrivateKey; -if (config.deploymentEnv === "production" && config.sandboxEnabled) { - throw new Error("SANDBOX_ENABLED=true requires DEPLOYMENT_ENV=sandbox; refusing to start production deployment"); +if (config.sandboxEnabled && config.deploymentEnv !== "sandbox") { + throw new Error(`SANDBOX_ENABLED=true requires DEPLOYMENT_ENV=sandbox (got '${config.deploymentEnv}'); refusing to start`); } if (config.deploymentEnv === "sandbox" && !config.sandboxEnabled) { diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 106c754a1..c111618bb 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -132,7 +132,7 @@ export class MissingBrlOfframpParametersError extends BrlOfframpError { export class InvalidPixKeyError extends BrlOfframpError { constructor() { - super("Invalid pixKey or receiverTaxId", 400); + super("Invalid pixKey", 400); this.name = "InvalidPixKeyError"; } } diff --git a/packages/sdk/src/handlers/BrlHandler.ts b/packages/sdk/src/handlers/BrlHandler.ts index fb86ddab8..ac7b9248e 100644 --- a/packages/sdk/src/handlers/BrlHandler.ts +++ b/packages/sdk/src/handlers/BrlHandler.ts @@ -194,7 +194,7 @@ export class BrlHandler implements RampHandler { // BRL is the input on BUY (onramp) and the output on SELL (offramp). const brlAmount = Number(direction === RampDirection.BUY ? quote.inputAmount : quote.outputAmount); if (!Number.isFinite(brlAmount)) { - return; + throw new AmountExceedsLimitError(); } const { remainingLimit } = await this.apiService.getBrlRemainingLimit(taxId, direction); if (brlAmount > remainingLimit) { From 8d752e292729b55a9991d5c7b3970a1ea7c0439b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 14:38:40 +0000 Subject: [PATCH 09/12] Require isActive check even on direct partner ID match, fix sleep comment Agent-Logs-Url: https://github.com/pendulum-chain/vortex/sessions/64b0a8ee-18e3-4c9b-a61b-973f3ef3a63b Co-authored-by: ebma <6690623+ebma@users.noreply.github.com> --- apps/api/src/api/controllers/brla.controller.ts | 2 +- apps/api/src/api/middlewares/ownershipAuth.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 565fa35b2..189f0cf35 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -604,7 +604,7 @@ export const newKyc = async ( return; } - // Wait for document propagation before fetching uploaded documents + // Wait for previously uploaded documents to propagate before submitting KYC await new Promise(resolve => setTimeout(resolve, 5000)); await brlaApiService.getUploadedDocuments(subAccountId); const response = await brlaApiService.submitKycLevel1(req.body); diff --git a/apps/api/src/api/middlewares/ownershipAuth.ts b/apps/api/src/api/middlewares/ownershipAuth.ts index 5492ccbdc..0552523c2 100644 --- a/apps/api/src/api/middlewares/ownershipAuth.ts +++ b/apps/api/src/api/middlewares/ownershipAuth.ts @@ -10,12 +10,12 @@ async function ownsPartnerRecord(authenticatedPartner: AuthenticatedPartner, par if (!partnerId) { return false; } - if (partnerId === authenticatedPartner.id) { - return true; - } const quotePartner = await Partner.findByPk(partnerId); - return quotePartner?.isActive === true && quotePartner.name === authenticatedPartner.name; + if (!quotePartner?.isActive) { + return false; + } + return partnerId === authenticatedPartner.id || quotePartner.name === authenticatedPartner.name; } /** From 7f4893d09359e4bdb71e05a14e6801162ec3a399 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 20 May 2026 16:55:13 +0200 Subject: [PATCH 10/12] Improve error handling in subaccount creation --- .../frontend/src/services/api/brla.service.ts | 10 +++ apps/frontend/src/services/signingService.tsx | 70 ++++++++----------- packages/sdk/src/handlers/BrlHandler.ts | 38 ++++++++-- 3 files changed, 71 insertions(+), 47 deletions(-) diff --git a/apps/frontend/src/services/api/brla.service.ts b/apps/frontend/src/services/api/brla.service.ts index eb5d1314a..5d4f7df10 100644 --- a/apps/frontend/src/services/api/brla.service.ts +++ b/apps/frontend/src/services/api/brla.service.ts @@ -8,6 +8,7 @@ import { BrlaGetUserRemainingLimitResponse, BrlaGetUserResponse, BrlaValidatePixKeyResponse, + KycLevel1Payload, RampDirection } from "@vortexfi/shared"; import { apiRequest } from "./api-client"; @@ -90,6 +91,15 @@ export class BrlaService { return apiRequest("post", `${this.BASE_PATH}/createSubaccount`, request); } + /** + * Submit a Level 1 KYC payload for an existing subaccount + * @param payload The KYC Level 1 payload + * @returns The KYC attempt ID + */ + static async submitNewKyc(payload: KycLevel1Payload): Promise<{ id: string }> { + return apiRequest<{ id: string }>("post", `${this.BASE_PATH}/newKyc`, payload); + } + /** * Get urls to upload KYC documents for a new subaccount * @param request The subaccount creation request diff --git a/apps/frontend/src/services/signingService.tsx b/apps/frontend/src/services/signingService.tsx index ee5975636..d30a9e2c3 100644 --- a/apps/frontend/src/services/signingService.tsx +++ b/apps/frontend/src/services/signingService.tsx @@ -1,6 +1,8 @@ import { useQuery } from "@tanstack/react-query"; import { BrlaCreateSubaccountRequest, BrlaGetKycStatusResponse, FiatToken, KycLevel1Payload } from "@vortexfi/shared"; import { SIGNING_SERVICE_URL } from "../constants/constants"; +import { isApiError } from "./api/api-client"; +import { BrlaService } from "./api/brla.service"; interface AccountStatusResponse { status: boolean; @@ -210,51 +212,35 @@ export const createSubaccount = async ({ quoteId, sessionId }: BrlaCreateSubaccountRequest): Promise<{ subAccountId: string }> => { - const accountCreationResponse = await fetch(`${SIGNING_SERVICE_URL}/v1/brla/createSubaccount`, { - body: JSON.stringify({ accountType, name, quoteId, sessionId, taxId }), - credentials: "include", - headers: { "Content-Type": "application/json" }, - method: "POST" - }); - - if (accountCreationResponse.status === 400) { - const { details } = await accountCreationResponse.json(); - throw new SubaccountCreationRejectedError(details.error || "Sub-account creation was rejected."); - } - - if (accountCreationResponse.status >= 500) { - throw new SubaccountCreationNetworkError( - `Failed to create sub-account due to a server error: ${accountCreationResponse.statusText}` - ); - } - - if (accountCreationResponse.status !== 200) { - throw new SubaccountCreationNetworkError(`Failed to create sub-account: ${accountCreationResponse.statusText}`); + try { + return await BrlaService.createSubaccount({ accountType, name, quoteId, sessionId, taxId }); + } catch (error) { + if (isApiError(error)) { + if (error.status === 400) { + throw new SubaccountCreationRejectedError(error.data?.details || error.message || "Sub-account creation was rejected."); + } + if (error.status >= 500) { + throw new SubaccountCreationNetworkError(`Failed to create sub-account due to a server error: ${error.message}`); + } + throw new SubaccountCreationNetworkError(`Failed to create sub-account: ${error.message}`); + } + throw error; } - - return await accountCreationResponse.json(); }; export const submitNewKyc = async (kycData: KycLevel1Payload): Promise<{ id: string }> => { - const response = await fetch(`${SIGNING_SERVICE_URL}/v1/brla/newKyc`, { - body: JSON.stringify(kycData), - credentials: "include", - headers: { "Content-Type": "application/json" }, - method: "POST" - }); - - if (response.status === 400) { - const { details } = await response.json(); - throw new KycSubmissionRejectedError(details || "Submission was rejected."); - } - - if (response.status >= 500) { - throw new KycSubmissionNetworkError(`Failed to submit KYC due to a server error: ${response.statusText}`); - } - - if (response.status !== 200) { - throw new KycSubmissionNetworkError(`Failed to submit KYC: ${response.statusText}`); + try { + return await BrlaService.submitNewKyc(kycData); + } catch (error) { + if (isApiError(error)) { + if (error.status === 400) { + throw new KycSubmissionRejectedError(error.data?.details || error.message || "Submission was rejected."); + } + if (error.status >= 500) { + throw new KycSubmissionNetworkError(`Failed to submit KYC due to a server error: ${error.message}`); + } + throw new KycSubmissionNetworkError(`Failed to submit KYC: ${error.message}`); + } + throw error; } - - return await response.json(); }; diff --git a/packages/sdk/src/handlers/BrlHandler.ts b/packages/sdk/src/handlers/BrlHandler.ts index ac7b9248e..87ac6f397 100644 --- a/packages/sdk/src/handlers/BrlHandler.ts +++ b/packages/sdk/src/handlers/BrlHandler.ts @@ -8,7 +8,7 @@ import { UnsignedTx, UpdateRampRequest } from "@vortexfi/shared"; -import { AmountExceedsLimitError, BrlKycStatusError, InvalidPixKeyError } from "../errors"; +import { AmountExceedsLimitError, BrlKycStatusError, InvalidPixKeyError, VortexSdkError } from "../errors"; import type { ApiService } from "../services/ApiService"; import type { BrlOfframpAdditionalData, @@ -181,8 +181,14 @@ export class BrlHandler implements RampHandler { let result: { valid: boolean }; try { result = await this.apiService.validateBrlPixKey(pixKey); - } catch { - throw new InvalidPixKeyError(); + } catch (error) { + // Only treat client-side validation errors (4xx) as invalid PIX key. + // Network/server errors (5xx, connection failures) must propagate so the + // user retries instead of being told the key is invalid. + if (error instanceof VortexSdkError && error.status >= 400 && error.status < 500) { + throw new InvalidPixKeyError(); + } + throw error; } if (!result.valid) { throw new InvalidPixKeyError(); @@ -192,11 +198,33 @@ export class BrlHandler implements RampHandler { private async assertWithinBrlLimit(taxId: string, quoteId: string, direction: RampDirection): Promise { const quote = await this.apiService.getQuote(quoteId); // BRL is the input on BUY (onramp) and the output on SELL (offramp). - const brlAmount = Number(direction === RampDirection.BUY ? quote.inputAmount : quote.outputAmount); + // On SELL, `outputAmount` is the user-received BRL (net of the anchor fee), + // but the BRLA debit/limit applies to the gross amount before the anchor fee. + // So we add `anchorFeeFiat` back to compare against the remaining limit. + let brlAmount: number; + if (direction === RampDirection.BUY) { + brlAmount = Number(quote.inputAmount); + } else { + const net = Number(quote.outputAmount); + const anchorFee = Number(quote.anchorFeeFiat ?? 0); + brlAmount = net + (Number.isFinite(anchorFee) ? anchorFee : 0); + } if (!Number.isFinite(brlAmount)) { throw new AmountExceedsLimitError(); } - const { remainingLimit } = await this.apiService.getBrlRemainingLimit(taxId, direction); + let remainingLimit: number; + try { + ({ remainingLimit } = await this.apiService.getBrlRemainingLimit(taxId, direction)); + } catch (error) { + // The backend returns 404 "Limits not found" for KYC-approved users whose + // BRLA subaccount has not yet been initialized for limits. Treat this as + // permissive (skip pre-flight) so legitimate users are not blocked; the + // backend will enforce limits authoritatively during ramp execution. + if (error instanceof VortexSdkError && error.status === 404) { + return; + } + throw error; + } if (brlAmount > remainingLimit) { throw new AmountExceedsLimitError(); } From 81cf0e079c8dd9facda1a7f93c90dec71666d634 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 20 May 2026 17:09:56 +0200 Subject: [PATCH 11/12] Allow usage of anonymous quotes and ramps --- apps/api/src/api/middlewares/dualAuth.test.ts | 75 ++++++++++++++++++- apps/api/src/api/middlewares/dualAuth.ts | 20 +++++ apps/api/src/api/middlewares/ownershipAuth.ts | 20 +++++ apps/api/src/api/routes/v1/ramp.route.ts | 12 +-- docs/security-spec/01-auth/api-keys.md | 2 +- .../03-ramp-engine/quote-lifecycle.md | 2 +- docs/security-spec/SPEC-DELTA-2026-05.md | 20 ++--- 7 files changed, 133 insertions(+), 18 deletions(-) diff --git a/apps/api/src/api/middlewares/dualAuth.test.ts b/apps/api/src/api/middlewares/dualAuth.test.ts index 9b96414bc..97d2a31e5 100644 --- a/apps/api/src/api/middlewares/dualAuth.test.ts +++ b/apps/api/src/api/middlewares/dualAuth.test.ts @@ -1,7 +1,8 @@ import {afterEach, describe, expect, it, mock} from "bun:test"; import Partner from "../../models/partner.model"; import QuoteTicket from "../../models/quoteTicket.model"; -import {assertQuoteOwnership} from "./ownershipAuth"; +import RampState from "../../models/rampState.model"; +import {assertQuoteOwnership, assertRampOwnership} from "./ownershipAuth"; describe("assertQuoteOwnership", () => { const originalFindByPk = QuoteTicket.findByPk; @@ -55,4 +56,76 @@ describe("assertQuoteOwnership", () => { assertQuoteOwnership({ authenticatedPartner: { id: "api-key-partner-id", name: "Partner" } }, "quote-1") ).resolves.toBeUndefined(); }); + + it("allows an anonymous caller to register a fully-anonymous quote", async () => { + QuoteTicket.findByPk = mock(async () => ({ + partnerId: null, + userId: null + })) as typeof QuoteTicket.findByPk; + + await expect(assertQuoteOwnership({}, "quote-1")).resolves.toBeUndefined(); + }); + + it("rejects an anonymous caller from registering a user-owned quote", async () => { + QuoteTicket.findByPk = mock(async () => ({ + partnerId: null, + userId: "user-1" + })) as typeof QuoteTicket.findByPk; + + await expect(assertQuoteOwnership({}, "quote-1")).rejects.toThrow("Authentication required"); + }); + + it("rejects an anonymous caller from registering a partner-owned quote", async () => { + QuoteTicket.findByPk = mock(async () => ({ + partnerId: "partner-id", + userId: null + })) as typeof QuoteTicket.findByPk; + + await expect(assertQuoteOwnership({}, "quote-1")).rejects.toThrow("Authentication required"); + }); +}); + +describe("assertRampOwnership", () => { + const originalRampFindByPk = RampState.findByPk; + const originalQuoteFindByPk = QuoteTicket.findByPk; + + afterEach(() => { + RampState.findByPk = originalRampFindByPk; + QuoteTicket.findByPk = originalQuoteFindByPk; + }); + + it("allows an anonymous caller to access a fully-anonymous ramp", async () => { + RampState.findByPk = mock(async () => ({ + quoteId: "quote-1", + userId: null + })) as typeof RampState.findByPk; + QuoteTicket.findByPk = mock(async () => ({ + partnerId: null, + userId: null + })) as typeof QuoteTicket.findByPk; + + await expect(assertRampOwnership({}, "ramp-1")).resolves.toBeUndefined(); + }); + + it("rejects an anonymous caller from accessing a user-owned ramp", async () => { + RampState.findByPk = mock(async () => ({ + quoteId: "quote-1", + userId: "user-1" + })) as typeof RampState.findByPk; + + await expect(assertRampOwnership({}, "ramp-1")).rejects.toThrow("Authentication required"); + }); + + it("rejects an anonymous caller from accessing a ramp whose source quote has a partner owner", async () => { + RampState.findByPk = mock(async () => ({ + quoteId: "quote-1", + userId: null + })) as typeof RampState.findByPk; + QuoteTicket.findByPk = mock(async () => ({ + partnerId: "partner-id", + userId: null + })) as typeof QuoteTicket.findByPk; + + await expect(assertRampOwnership({}, "ramp-1")).rejects.toThrow("Authentication required"); + }); }); diff --git a/apps/api/src/api/middlewares/dualAuth.ts b/apps/api/src/api/middlewares/dualAuth.ts index cb9df4e0e..a7ca0e554 100644 --- a/apps/api/src/api/middlewares/dualAuth.ts +++ b/apps/api/src/api/middlewares/dualAuth.ts @@ -11,6 +11,22 @@ export { assertQuoteOwnership, assertRampOwnership } from "./ownershipAuth"; * Exactly one of req.authenticatedPartner or req.userId is populated on success. */ export function requirePartnerOrUserAuth() { + return dualAuthHandler({ requireCredentials: true }); +} + +/** + * Dual-track authentication that does not reject anonymous callers. + * If credentials are provided, they MUST be valid (same checks as + * `requirePartnerOrUserAuth`). If no credentials are provided, the request + * proceeds and downstream ownership checks decide whether the resource is + * accessible. Use only on endpoints where anonymous access is intentionally + * allowed for fully-anonymous resources (no userId, no partnerId). + */ +export function optionalPartnerOrUserAuth() { + return dualAuthHandler({ requireCredentials: false }); +} + +function dualAuthHandler({ requireCredentials }: { requireCredentials: boolean }) { return async (req: Request, res: Response, next: NextFunction) => { try { const apiKey = req.headers["x-api-key"] as string | undefined; @@ -61,6 +77,10 @@ export function requirePartnerOrUserAuth() { return next(); } + if (!requireCredentials) { + return next(); + } + return res.status(401).json({ error: { code: "AUTHENTICATION_REQUIRED", diff --git a/apps/api/src/api/middlewares/ownershipAuth.ts b/apps/api/src/api/middlewares/ownershipAuth.ts index 0552523c2..e4fc5ff27 100644 --- a/apps/api/src/api/middlewares/ownershipAuth.ts +++ b/apps/api/src/api/middlewares/ownershipAuth.ts @@ -56,6 +56,19 @@ export async function assertRampOwnership( return; } + // Anonymous caller: allow only when the ramp itself is fully anonymous + // (no user owner AND its source quote has no partner owner). Owned ramps + // always require matching credentials. + if (ramp.userId === null) { + const quote = await QuoteTicket.findByPk(ramp.quoteId); + if (!quote) { + throw new APIError({ message: "Associated quote not found", status: httpStatus.NOT_FOUND }); + } + if (quote.partnerId === null) { + return; + } + } + throw new APIError({ message: "Authentication required", status: httpStatus.UNAUTHORIZED }); } @@ -97,5 +110,12 @@ export async function assertQuoteOwnership( return; } + // Anonymous caller: allow only when the quote is fully anonymous + // (no partner owner AND no user owner). Owned quotes always require + // matching credentials. + if (quote.partnerId === null && quote.userId === null) { + return; + } + throw new APIError({ message: "Authentication required", status: httpStatus.UNAUTHORIZED }); } diff --git a/apps/api/src/api/routes/v1/ramp.route.ts b/apps/api/src/api/routes/v1/ramp.route.ts index 7847a1c0a..2ecae7553 100644 --- a/apps/api/src/api/routes/v1/ramp.route.ts +++ b/apps/api/src/api/routes/v1/ramp.route.ts @@ -1,6 +1,6 @@ import { RequestHandler, Router } from "express"; import * as rampController from "../../controllers/ramp.controller"; -import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; +import { optionalPartnerOrUserAuth, requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; const router = Router(); @@ -30,7 +30,7 @@ const router = Router(); * @apiError (Not Found 404) NotFound Quote does not exist */ -router.post("/register", requirePartnerOrUserAuth(), rampController.registerRamp as unknown as RequestHandler); +router.post("/register", optionalPartnerOrUserAuth(), rampController.registerRamp as unknown as RequestHandler); /** * @api {post} v1/ramp/update Update ramping process @@ -57,7 +57,7 @@ router.post("/register", requirePartnerOrUserAuth(), rampController.registerRamp * @apiError (Not Found 404) NotFound Ramp does not exist * @apiError (Conflict 409) ConflictError Ramp is not in a state that allows updates */ -router.post("/update", requirePartnerOrUserAuth(), rampController.updateRamp as unknown as RequestHandler); +router.post("/update", optionalPartnerOrUserAuth(), rampController.updateRamp as unknown as RequestHandler); /** * @api {post} v1/ramp/start Start ramping process @@ -83,7 +83,7 @@ router.post("/update", requirePartnerOrUserAuth(), rampController.updateRamp as * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values * @apiError (Not Found 404) NotFound Quote does not exist */ -router.post("/start", requirePartnerOrUserAuth(), rampController.startRamp as unknown as RequestHandler); +router.post("/start", optionalPartnerOrUserAuth(), rampController.startRamp as unknown as RequestHandler); /** * @api {get} v1/ramp/:id Get ramp status @@ -106,7 +106,7 @@ router.post("/start", requirePartnerOrUserAuth(), rampController.startRamp as un * * @apiError (Not Found 404) NotFound Ramp does not exist */ -router.get("/:id", requirePartnerOrUserAuth(), rampController.getRampStatus as unknown as RequestHandler); +router.get("/:id", optionalPartnerOrUserAuth(), rampController.getRampStatus as unknown as RequestHandler); /** * @api {get} v1/ramp/:id/errors Get error logs @@ -122,7 +122,7 @@ router.get("/:id", requirePartnerOrUserAuth(), rampController.getRampStatus as u * * @apiError (Not Found 404) NotFound Ramp does not exist */ -router.get("/:id/errors", requirePartnerOrUserAuth(), rampController.getErrorLogs as unknown as RequestHandler); +router.get("/:id/errors", optionalPartnerOrUserAuth(), rampController.getErrorLogs as unknown as RequestHandler); /** * @api {get} v1/ramp/history/:walletAddress Get transaction history diff --git a/docs/security-spec/01-auth/api-keys.md b/docs/security-spec/01-auth/api-keys.md index a7b5373ff..a41b85ad6 100644 --- a/docs/security-spec/01-auth/api-keys.md +++ b/docs/security-spec/01-auth/api-keys.md @@ -40,7 +40,7 @@ Three middleware components: ## Audit Checklist -- [x] All endpoints requiring partner auth use `apiKeyAuth({ required: true })` or `enforcePartnerAuth()` — **PASS: `enforcePartnerAuth()` is now active on `POST /v1/ramp/quotes` and `POST /v1/ramp/quotes/best`. Ramp endpoints additionally enforce sk_ OR Supabase via `requirePartnerOrUserAuth()`.** +- [x] All endpoints requiring partner auth use `apiKeyAuth({ required: true })` or `enforcePartnerAuth()` — **PASS: `enforcePartnerAuth()` is now active on `POST /v1/ramp/quotes` and `POST /v1/ramp/quotes/best`. Ramp endpoints additionally enforce sk_ OR Supabase via `requirePartnerOrUserAuth()` (history) or `optionalPartnerOrUserAuth()` (register/update/start/status/errors). Anonymous access is permitted on the optional set only when the underlying quote/ramp is fully anonymous (no partner, no user owner).** - [x] Secret key validation (`validateSecretApiKey`) always uses bcrypt comparison, never plaintext comparison — **PASS** - [x] Public key validation (`validatePublicApiKey`) stores keys in plaintext (by design for lookup) but never returns auth credentials — **PASS** - [x] `getKeyType()` correctly identifies `pk_` as public, `sk_` as secret, and anything else as `null` — **PASS** diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index ce4c46cbc..f9d6fd27c 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -66,7 +66,7 @@ The system maintains an **in-memory** `Map` (Supabase frontend); anonymous access is rejected. Per-principal ownership guards (`assertRampOwnership`, `assertQuoteOwnership`) prevent cross-tenant access: partners are scoped via `RampState.quoteId → QuoteTicket.partnerId`, Supabase users via `RampState.userId`. `getRampHistory` filters at the service layer by the same chain. The previous backwards-compat carve-out for `/ramp/start` and `/ramp/update` has been removed. `enforcePartnerAuth()` is now active on `/quotes` and `/quotes/best`, closing the partner-spoofing vector. | +| 13 | **F-013** — Multiple security-sensitive endpoints have no authentication. | RESOLVED — dual-track auth wired across all `/v1/ramp/*` and `/v1/ramp/quotes(/best)` endpoints. Each request carrying credentials must present **either** `X-API-Key: sk_*` (partner SDK) **or** `Authorization: Bearer ` (Supabase frontend); invalid credentials are always rejected. Per-principal ownership guards (`assertRampOwnership`, `assertQuoteOwnership`) prevent cross-tenant access: partners are scoped via `RampState.quoteId → QuoteTicket.partnerId`, Supabase users via `RampState.userId`. Anonymous access is permitted only on register/update/start/status/errors and only when the underlying resource is fully anonymous (no partner, no user owner); `getRampHistory` always requires credentials. `enforcePartnerAuth()` is active on `/quotes` and `/quotes/best`, closing the partner-spoofing vector. | --- ## 6. Auth Posture (Post-Delta) -The dual-track auth model — partner SDK key OR Supabase user session — is the canonical model going forward. There is **no anonymous access** to ramp or quote endpoints. +The dual-track auth model — partner SDK key OR Supabase user session — is the canonical model going forward. Anonymous access is permitted **only** on register/update/start/status/errors endpoints, and **only** when the underlying quote/ramp is itself fully anonymous (no `partnerId` and no `userId`). Owned resources always require matching credentials. | Endpoint | Auth | Owner check | |---|---|---| | `POST /v1/ramp/quotes` | `apiKeyAuth({required: false})` + `enforcePartnerAuth()` | Partner key, if present, must match `partnerId` in body | | `POST /v1/ramp/quotes/best` | `apiKeyAuth({required: false})` + `enforcePartnerAuth()` | Same as above | -| `POST /v1/ramp/register` | `requirePartnerOrUserAuth()` | `assertQuoteOwnership(req, quoteId)` | -| `POST /v1/ramp/update` | `requirePartnerOrUserAuth()` | `assertRampOwnership(req, rampId)` | -| `POST /v1/ramp/start` | `requirePartnerOrUserAuth()` | `assertRampOwnership(req, rampId)` | -| `GET /v1/ramp/:id` | `requirePartnerOrUserAuth()` | `assertRampOwnership(req, id)` | -| `GET /v1/ramp/:id/errors` | `requirePartnerOrUserAuth()` | `assertRampOwnership(req, id)` | -| `GET /v1/ramp/history/:walletAddress` | `requirePartnerOrUserAuth()` | Service-layer filter: partner → owned `quoteId`s; user → matching `userId` | +| `POST /v1/ramp/register` | `optionalPartnerOrUserAuth()` | `assertQuoteOwnership(req, quoteId)` — anonymous caller allowed iff quote has `partnerId === null AND userId === null` | +| `POST /v1/ramp/update` | `optionalPartnerOrUserAuth()` | `assertRampOwnership(req, rampId)` — anonymous caller allowed iff ramp has `userId === null` AND its quote has `partnerId === null` | +| `POST /v1/ramp/start` | `optionalPartnerOrUserAuth()` | `assertRampOwnership(req, rampId)` — same condition as update | +| `GET /v1/ramp/:id` | `optionalPartnerOrUserAuth()` | `assertRampOwnership(req, id)` — same condition as update | +| `GET /v1/ramp/:id/errors` | `optionalPartnerOrUserAuth()` | `assertRampOwnership(req, id)` — same condition as update | +| `GET /v1/ramp/history/:walletAddress` | `requirePartnerOrUserAuth()` | Service-layer filter: partner → owned `quoteId`s; user → matching `userId`. **Never anonymous.** | | `/v1/brla/*` user data | `requireAuth` | Supabase userId scoping | | `/v1/maintenance/*` | `adminAuth` | n/a | | `/v1/webhook/*` | `apiKeyAuth` | Partner ownership | -Frontend uses `Authorization: Bearer` (Supabase). SDK uses `X-API-Key: sk_*`. Both grant equal access subject to per-principal ownership scoping. +`optionalPartnerOrUserAuth()` accepts a request with no credentials, but a request that *presents* invalid credentials (malformed `X-API-Key` or expired/forged Bearer) is still rejected with 401. The downstream ownership checks then decide whether the resource is reachable: anonymous callers are admitted only for fully-anonymous quotes/ramps. This preserves the principle that owned resources are never reachable without matching credentials, while allowing API clients without keys (or first-time users without a Supabase session) to drive a ramp end-to-end. + +Frontend uses `Authorization: Bearer` (Supabase). SDK partners use `X-API-Key: sk_*`. SDK clients without keys may operate against fully-anonymous quotes (no partner-rate benefits). Both authenticated principals grant equal access subject to per-principal ownership scoping. From 758a7d80154f88df4ebcdb3d9102d79ab68b4a0e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 20 May 2026 17:35:45 +0200 Subject: [PATCH 12/12] Use optional partner or user authentication for BRL ramp endpoints --- apps/api/src/api/routes/v1/brla.route.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/api/src/api/routes/v1/brla.route.ts b/apps/api/src/api/routes/v1/brla.route.ts index 29cff39b0..115d185f5 100644 --- a/apps/api/src/api/routes/v1/brla.route.ts +++ b/apps/api/src/api/routes/v1/brla.route.ts @@ -1,6 +1,6 @@ import { RequestHandler, Router } from "express"; import * as brlaController from "../../controllers/brla.controller"; -import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; +import { optionalPartnerOrUserAuth } from "../../middlewares/dualAuth"; import { optionalAuth, requireAuth } from "../../middlewares/supabaseAuth"; import { validateStartKyc2, validateSubaccountCreation } from "../../middlewares/validators"; @@ -9,11 +9,16 @@ const router: Router = Router({ mergeParams: true }); // Controllers use typed Request generics (e.g. Request) // which don't extend Express's ParsedQs. Double-cast via unknown is the standard Express pattern // for combining middleware with narrowly-typed handlers. Runtime query validation is in each controller. -router.get("/getUser", requirePartnerOrUserAuth(), brlaController.getAveniaUser as unknown as RequestHandler); +// +// /getUser, /getUserRemainingLimit, and /validatePixKey use optionalPartnerOrUserAuth so that SDK +// clients without API keys can drive a BRL ramp pre-flight against fully-anonymous quotes. The +// controllers themselves apply ownership scoping when req.userId is set; anonymous callers see +// the same data surface a partner X-API-Key caller would (taxId is the lookup key in both cases). +router.get("/getUser", optionalPartnerOrUserAuth(), brlaController.getAveniaUser as unknown as RequestHandler); router.get( "/getUserRemainingLimit", - requirePartnerOrUserAuth(), + optionalPartnerOrUserAuth(), brlaController.getAveniaUserRemainingLimit as unknown as RequestHandler ); @@ -21,7 +26,7 @@ router.get("/getKycStatus", requireAuth, brlaController.fetchSubaccountKycStatus router.get("/getSelfieLivenessUrl", requireAuth, brlaController.getSelfieLivenessUrl as unknown as RequestHandler); -router.get("/validatePixKey", requirePartnerOrUserAuth(), brlaController.validatePixKey as unknown as RequestHandler); +router.get("/validatePixKey", optionalPartnerOrUserAuth(), brlaController.validatePixKey as unknown as RequestHandler); router.route("/createSubaccount").post(validateSubaccountCreation, optionalAuth, brlaController.createSubaccount);