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/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts new file mode 100644 index 000000000..3f88a8a3b --- /dev/null +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -0,0 +1,275 @@ +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(userId: string | null = null) { + TaxId.findOne = mock(async () => ({ subAccountId: "subaccount-1", userId })) 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("user-1"); + + 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); + }); + + 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", () => { + 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..189f0cf35 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -102,8 +102,13 @@ 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); + const splitError = error.message.split("Error: ", 2); if (splitError.length > 1) { const errorMessageString = splitError[1]; try { @@ -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 */ @@ -163,6 +168,14 @@ 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 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; + } + const accountInfo = await brlaApiService.subaccountInfo(taxIdRecord.subAccountId); if (!accountInfo) { res.status(httpStatus.NOT_FOUND).json({ error: "Subaccount info not found" }); @@ -192,7 +205,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; @@ -299,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; @@ -311,9 +324,25 @@ 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); + if (ownedByAnotherUser) { + res.status(httpStatus.CONFLICT).json({ + error: "A subaccount already exists for this taxId" + }); + 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(); const { id } = await brlaApiService.createAveniaSubaccount(accountType, name); - const existingTaxId = await TaxId.findByPk(normalizedTaxId); if (existingTaxId) { await existingTaxId.update({ @@ -514,6 +543,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( @@ -552,8 +586,26 @@ export const newKyc = async ( ): Promise => { try { 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; + } + + // 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); @@ -589,6 +641,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/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..97d2a31e5 100644 --- a/apps/api/src/api/middlewares/dualAuth.test.ts +++ b/apps/api/src/api/middlewares/dualAuth.test.ts @@ -1,12 +1,16 @@ -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 RampState from "../../models/rampState.model"; +import {assertQuoteOwnership, assertRampOwnership} 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 +41,91 @@ 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(); + }); + + 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 6bbb2327a..a7ca0e554 100644 --- a/apps/api/src/api/middlewares/dualAuth.ts +++ b/apps/api/src/api/middlewares/dualAuth.ts @@ -1,18 +1,32 @@ 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 ...). * 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; @@ -63,6 +77,10 @@ export function requirePartnerOrUserAuth() { return next(); } + if (!requireCredentials) { + return next(); + } + return res.status(401).json({ error: { code: "AUTHENTICATION_REQUIRED", @@ -76,85 +94,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..e4fc5ff27 --- /dev/null +++ b/apps/api/src/api/middlewares/ownershipAuth.ts @@ -0,0 +1,121 @@ +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; + } + + const quotePartner = await Partner.findByPk(partnerId); + if (!quotePartner?.isActive) { + return false; + } + return partnerId === authenticatedPartner.id || 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; + } + + // 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 }); +} + +/** + * 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; + } + + // 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/brla.route.ts b/apps/api/src/api/routes/v1/brla.route.ts index 7a7b5d5f5..115d185f5 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 { optionalPartnerOrUserAuth } from "../../middlewares/dualAuth"; import { optionalAuth, requireAuth } from "../../middlewares/supabaseAuth"; import { validateStartKyc2, validateSubaccountCreation } from "../../middlewares/validators"; @@ -8,25 +9,34 @@ 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("/getUserRemainingLimit", requireAuth, brlaController.getAveniaUserRemainingLimit 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", + optionalPartnerOrUserAuth(), + 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", optionalPartnerOrUserAuth(), brlaController.validatePixKey as unknown as RequestHandler); 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(brlaController.getKybAttemptStatus); +router.route("/kyb/attempt-status").get(requireAuth, brlaController.getKybAttemptStatus); router.route("/kyc/record-attempt").post(requireAuth, brlaController.recordInitialKycAttempt); 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/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"); + }); +}); diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index a43b1214a..f39fa69ed 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.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) { + 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"); 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/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. 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 0f2409478..87ac6f397 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, VortexSdkError } from "../errors"; import type { ApiService } from "../services/ApiService"; import type { BrlOfframpAdditionalData, @@ -60,7 +61,8 @@ 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); + await this.assertWithinBrlLimit(additionalData.taxId, quoteId, RampDirection.BUY); const { ephemerals, accountMetas } = await this.generateEphemerals(); @@ -99,7 +101,9 @@ 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); + await this.assertValidPixKey(additionalData.pixDestination); + await this.assertWithinBrlLimit(additionalData.taxId, quoteId, RampDirection.SELL); const { ephemerals, accountMetas } = await this.generateEphemerals(); @@ -162,14 +166,67 @@ 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}`); } } + + private async assertValidPixKey(pixKey: string): Promise { + let result: { valid: boolean }; + try { + result = await this.apiService.validateBrlPixKey(pixKey); + } 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(); + } + } + + 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). + // 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(); + } + 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(); + } + } } diff --git a/packages/sdk/src/services/ApiService.ts b/packages/sdk/src/services/ApiService.ts index 20923683a..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, @@ -86,12 +87,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(), @@ -100,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"); + } }