diff --git a/.changeset/ten-memes-tease.md b/.changeset/ten-memes-tease.md new file mode 100644 index 0000000000..c48e01794f --- /dev/null +++ b/.changeset/ten-memes-tease.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add business onboarding applications diff --git a/.do/app.yaml b/.do/app.yaml index 5c329eb376..63e215bf1b 100644 --- a/.do/app.yaml +++ b/.do/app.yaml @@ -136,6 +136,9 @@ services: scope: RUN_TIME type: SECRET value: ${{ env.ENCRYPTED_PERSONA_API_KEY || env.PERSONA_API_KEY }} + - key: PERSONA_BUSINESS_ACCOUNT_TYPE_ID + scope: RUN_TIME + value: acttp_AWN3X1Rb7Rnt5o7EA4e8VcU7D61xH2 # cspell:ignore acttp - key: PERSONA_URL scope: RUN_TIME value: ${{ env.PERSONA_URL }} diff --git a/cspell.json b/cspell.json index 882e5df51e..92a5feb163 100644 --- a/cspell.json +++ b/cspell.json @@ -17,6 +17,7 @@ "words": [ "abigen", "abitype", + "accounttype", "adduser", "aguxez", "airalo", @@ -191,6 +192,7 @@ "subproject", "subprojects", "substreams", + "acttp", "surl", "tamagui", "tanstack", diff --git a/infra/Pulumi.base-sepolia.yaml b/infra/Pulumi.base-sepolia.yaml index 94797a12b2..b73bd491ab 100644 --- a/infra/Pulumi.base-sepolia.yaml +++ b/infra/Pulumi.base-sepolia.yaml @@ -3,3 +3,4 @@ config: gcp:project: exa-dev exa:subscribeTimeout: 600s exa:whatsappPhoneNumberId: "1284591438064923" + exa:personaBusinessAccountTypeId: acttp_AWN3X1Rb7Rnt5o7EA4e8VcU7D61xH2 diff --git a/infra/Pulumi.base.yaml b/infra/Pulumi.base.yaml index 66a02b699e..6e9994face 100644 --- a/infra/Pulumi.base.yaml +++ b/infra/Pulumi.base.yaml @@ -3,3 +3,4 @@ config: gcp:project: eexxxaa exa:pokeMinimum: 1 exa:subscribeTimeout: 900s + exa:personaBusinessAccountTypeId: acttp_AWN3X1Rb7Rnt5o7EA4e8VcU7D61xH2 diff --git a/infra/Pulumi.production.yaml b/infra/Pulumi.production.yaml index 8b67fad4b9..33cf0c2fa7 100644 --- a/infra/Pulumi.production.yaml +++ b/infra/Pulumi.production.yaml @@ -5,3 +5,4 @@ config: exa:pokeMinimum: 1 exa:subscribeTimeout: 900s exa:whatsappPhoneNumberId: "1287864854409817" + exa:personaBusinessAccountTypeId: acttp_AWN3X1Rb7Rnt5o7EA4e8VcU7D61xH2 diff --git a/infra/Pulumi.sandbox.yaml b/infra/Pulumi.sandbox.yaml index cf94bad2e3..1e209c0252 100644 --- a/infra/Pulumi.sandbox.yaml +++ b/infra/Pulumi.sandbox.yaml @@ -3,3 +3,4 @@ config: gcp:project: exa-dev exa:subscribeTimeout: 600s exa:whatsappPhoneNumberId: "1287864854409817" + exa:personaBusinessAccountTypeId: acttp_AWN3X1Rb7Rnt5o7EA4e8VcU7D61xH2 diff --git a/infra/utils/modules.ts b/infra/utils/modules.ts index 4142ba6d7d..8863f35837 100644 --- a/infra/utils/modules.ts +++ b/infra/utils/modules.ts @@ -3,6 +3,9 @@ export default define({ crema: ["redis-address", "redis-password", "redis-username"], services: { api: { + env: { + PERSONA_BUSINESS_ACCOUNT_TYPE_ID: "personaBusinessAccountTypeId", + }, secrets: [ "auth-secret", "bridge-api-key", @@ -46,6 +49,7 @@ export default define({ signers: ["settler", "issuer"], }, persona: { + env: { PERSONA_BUSINESS_ACCOUNT_TYPE_ID: "personaBusinessAccountTypeId" }, secrets: [ "panda-api-key", "pax-associate-id-key", diff --git a/server/api/auth/authentication.ts b/server/api/auth/authentication.ts index e9d9f43ae3..0452fe65be 100644 --- a/server/api/auth/authentication.ts +++ b/server/api/auth/authentication.ts @@ -45,6 +45,8 @@ import { Address, Base64URL, Credential, Hex } from "@exactly/common/validation" import { credentials } from "../../database/schema"; import androidOrigins from "../../utils/android/origins"; import appOrigin from "../../utils/appOrigin"; +import { decode, encode } from "../../utils/authChallenge"; +import { accountSalt, isBusinessSalt } from "../../utils/createCredential"; import decodePublicKey from "../../utils/decodePublicKey"; import publicClient from "../../utils/publicClient"; import { IpAddress } from "../../utils/sardine"; @@ -176,6 +178,7 @@ When called with an Ethereum address as \`credentialId\`, this endpoint creates tags: ["Credential"], validateResponse: true, }), + vValidator("header", optional(object({ "account-type": optional(literal("business")) }))), vValidator( "query", object({ @@ -208,6 +211,7 @@ When called with an Ethereum address as \`credentialId\`, this endpoint creates ...(domain === "localhost" ? { sameSite: "lax", secure: false } : { domain, sameSite: "none", secure: true }), }); c.header("X-Session-Id", sessionId); + const { "account-type": accountType } = c.req.valid("header") ?? {}; const { credentialId } = c.req.valid("query"); if (credentialId && (isAddress as (address: string) => address is Address)(credentialId)) { const message = createSiweMessage({ @@ -223,7 +227,7 @@ When called with an Ethereum address as \`credentialId\`, this endpoint creates domain, scheme, }); - await redis.set(sessionId, message, "PX", timeout); + await redis.set(sessionId, encode(message, accountType), "PX", timeout); return c.json( { method: "siwe" as const, address: credentialId, message } satisfies InferOutput< typeof AuthenticationOptions @@ -236,7 +240,7 @@ When called with an Ethereum address as \`credentialId\`, this endpoint creates allowCredentials: credentialId ? [{ id: credentialId }] : undefined, timeout, }); - await redis.set(sessionId, options.challenge, "PX", timeout); + await redis.set(sessionId, encode(options.challenge, accountType), "PX", timeout); return c.json( { method: "webauthn" as const, @@ -283,6 +287,7 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se object({ "Client-Fid": optional(pipe(string(), maxLength(36))), "Client-Platform": optional(literal("ios")), + "account-type": optional(literal("business")), "do-connecting-ip": fallback(optional(IpAddress), () => undefined), }), ), @@ -365,22 +370,25 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se setContext("auth", assertion); const sessionId = c.req.header("x-session-id") ?? c.req.valid("cookie").session_id; if (!sessionId) return c.json({ code: "bad session" }, 400); - const [credential, challenge] = await Promise.all([ + const [credential, storedChallenge] = await Promise.all([ database.query.credentials.findFirst({ columns: { publicKey: true, account: true, factory: true, salt: true, transports: true }, where: eq(credentials.id, assertion.id), }), redis.getdel(sessionId), ]); - if (!challenge) return c.json({ code: "no authentication", legacy: "no authentication" }, 400); + if (!storedChallenge) return c.json({ code: "no authentication", legacy: "no authentication" }, 400); + const challenge = decode(storedChallenge); + if (!challenge) return c.json({ code: "bad authentication", legacy: "bad authentication" }, 400); + if (challenge.accountType !== headers?.["account-type"]) return c.json({ code: "bad account type" }, 400); if (!credential) { if (assertion.method !== "siwe") return c.json({ code: "no credential", legacy: "no credential" }, 400); try { - const message = parseSiweMessage(challenge); + const message = parseSiweMessage(challenge.challenge); if ( !validateSiweMessage({ message, address: assertion.id, nonce: sessionId, domain, scheme }) || !(await publicClient.verifySiweMessage({ - message: challenge, + message: challenge.challenge, address: assertion.id, signature: assertion.signature, })) @@ -390,6 +398,7 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se if (factory && !validFactories.has(factory)) return c.json({ code: "bad factory" }, 400); const result = await createCredential(c, assertion.id, { factory, + salt: accountSalt(headers?.["account-type"]), source: c.req.header("Client-Fid"), ip: headers?.["do-connecting-ip"], }); @@ -410,16 +419,19 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se } } if (factory && factory !== parse(Address, credential.factory)) return c.json({ code: "bad factory" }, 400); + if (headers?.["account-type"] === "business" && !isBusinessSalt(parse(Address, credential.salt))) { + return c.json({ code: "bad account type" }, 400); + } setUser({ id: parse(Address, credential.account) }); try { switch (assertion.method) { case "siwe": { - const message = parseSiweMessage(challenge); + const message = parseSiweMessage(challenge.challenge); if ( !validateSiweMessage({ message, address: assertion.id, nonce: sessionId, domain, scheme }) || !(await publicClient.verifySiweMessage({ - message: challenge, + message: challenge.challenge, address: assertion.id, signature: assertion.signature, })) @@ -433,7 +445,7 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se response: assertion, expectedRPID: domain, expectedOrigin: [appOrigin, ...androidOrigins], - expectedChallenge: challenge, + expectedChallenge: challenge.challenge, credential: { id: assertion.id, publicKey: credential.publicKey, diff --git a/server/api/auth/registration.ts b/server/api/auth/registration.ts index cdfd3bfdb1..73b7b99381 100644 --- a/server/api/auth/registration.ts +++ b/server/api/auth/registration.ts @@ -42,6 +42,8 @@ import { Address, Base64URL, Hex } from "@exactly/common/validation"; import { Authentication } from "./authentication"; import androidOrigins from "../../utils/android/origins"; import appOrigin from "../../utils/appOrigin"; +import { decode, encode } from "../../utils/authChallenge"; +import { accountSalt } from "../../utils/createCredential"; import publicClient from "../../utils/publicClient"; import { IpAddress } from "../../utils/sardine"; import validatorHook from "../../utils/validatorHook"; @@ -181,6 +183,7 @@ export default function route({ tags: ["Credential"], validateResponse: true, }), + vValidator("header", optional(object({ "account-type": optional(literal("business")) }))), vValidator( "query", optional( @@ -209,6 +212,7 @@ export default function route({ }); c.header("X-Session-Id", sessionId); const query = c.req.valid("query"); + const accountType = c.req.valid("header")?.["account-type"]; if (query?.credentialId) { const message = createSiweMessage({ resources: ["https://exactly.github.io/exa"], @@ -223,7 +227,7 @@ export default function route({ domain, scheme, }); - await redis.set(sessionId, message, "PX", timeout); + await redis.set(sessionId, encode(message, accountType), "PX", timeout); return c.json({ method: "siwe" as const, address: query.credentialId, message }, 200); } const userName = new Date().toISOString().slice(0, 16); @@ -237,7 +241,7 @@ export default function route({ // TODO excludeCredentials? timeout, }); - await redis.set(sessionId, options.challenge, "PX", timeout); + await redis.set(sessionId, encode(options.challenge, accountType), "PX", timeout); return c.json( { method: "webauthn" as const, @@ -277,6 +281,7 @@ export default function route({ object({ "Client-Fid": optional(pipe(string(), maxLength(36))), "Client-Platform": optional(literal("ios")), + "account-type": optional(literal("business")), "do-connecting-ip": fallback(optional(IpAddress), () => undefined), }), ), @@ -355,18 +360,21 @@ export default function route({ const sessionId = c.req.header("x-session-id") ?? c.req.valid("cookie").session_id; if (!sessionId) return c.json({ code: "bad session" }, 400); if (factory && !validFactories.has(factory)) return c.json({ code: "bad factory" }, 400); - const challenge = await redis.getdel(sessionId); - if (!challenge) return c.json({ code: "no registration", legacy: "no registration" }, 400); + const storedChallenge = await redis.getdel(sessionId); + if (!storedChallenge) return c.json({ code: "no registration", legacy: "no registration" }, 400); + const challenge = decode(storedChallenge); + if (!challenge) return c.json({ code: "bad registration", legacy: "bad registration" }, 400); + if (challenge.accountType !== headers?.["account-type"]) return c.json({ code: "bad account type" }, 400); let webauthn: undefined | WebAuthnCredential; try { switch (attestation.method) { case "siwe": { - const message = parseSiweMessage(challenge); + const message = parseSiweMessage(challenge.challenge); if ( !validateSiweMessage({ message, address: attestation.id, nonce: sessionId, domain, scheme }) || !(await publicClient.verifySiweMessage({ - message: challenge, + message: challenge.challenge, address: attestation.id, signature: attestation.signature, })) @@ -387,7 +395,7 @@ export default function route({ }, expectedRPID: domain, expectedOrigin: [appOrigin, ...androidOrigins], - expectedChallenge: challenge, + expectedChallenge: challenge.challenge, supportedAlgorithmIDs: [cose.COSEALG.ES256], }); if (!verified) return c.json({ code: "bad registration", legacy: "bad registration" }, 400); @@ -409,6 +417,7 @@ export default function route({ try { const result = await createCredential(c, attestation.id, { factory, + salt: accountSalt(headers?.["account-type"]), webauthn, source: headers?.["Client-Fid"], ip: headers?.["do-connecting-ip"], diff --git a/server/api/kyc.ts b/server/api/kyc.ts index 8c5da105c6..f5c1ff7232 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -4,7 +4,22 @@ import { eq } from "drizzle-orm"; import { Hono } from "hono"; import * as honoOpenapi from "hono-openapi"; import { resolver, validator as vValidator } from "hono-openapi/valibot"; -import { array, literal, metadata, number, object, optional, parse, picklist, pipe, string, union } from "valibot"; +import { + array, + fallback, + literal, + metadata, + number, + object, + optional, + parse, + picklist, + pipe, + strictObject, + string, + transform, + union, +} from "valibot"; import { getAddress, sha256, verifyMessage } from "viem"; import { parseSiweMessage } from "viem/siwe"; @@ -18,16 +33,28 @@ import chain, { import { Address, Hex } from "@exactly/common/validation"; import { credentials, walletAddresses } from "../database/schema"; +import { isBusinessSalt } from "../utils/createCredential"; import decodePublicKey from "../utils/decodePublicKey"; -import { Application, UpdateApplicationRequest as ApplicationUpdate } from "../utils/panda"; import { + Application, + UpdateApplicationRequest as ApplicationUpdate, + BusinessApplicationError, + businessCodes, + CompanyApplicationResponse, + CompanyApplicationStatusResponse, + withMutex, +} from "../utils/panda"; +import { + businessAccountTypeId, CARD_LIMIT_TEMPLATE, CRYPTOMATE_TEMPLATE, + PANDA_BUSINESS_TEMPLATE, PANDA_TEMPLATE, parseAccount, scopeValidationErrors, } from "../utils/persona"; import publicClient from "../utils/publicClient"; +import { IpAddress } from "../utils/sardine"; import ServiceError from "../utils/ServiceError"; import validatorHook from "../utils/validatorHook"; @@ -81,7 +108,7 @@ export default function route({ "query", object({ countryCode: optional(literal("true")), - scope: optional(picklist(["basic", "bridge", "cardLimit", "manteca"])), + scope: optional(picklist(["basic", "bridge", "business", "cardLimit", "manteca"])), }), validatorHook(), ), @@ -98,6 +125,9 @@ export default function route({ const account = parse(Address, credential.account); setUser({ id: account }); setContext("exa", { credential }); + if ((scope === "business") !== isBusinessSalt(parse(Address, credential.salt))) { + return c.json({ code: "not supported" }, 400); + } if (scope === "cardLimit") { const unknownAccount = c.req.valid("query").countryCode @@ -166,7 +196,7 @@ export default function route({ throw error; } if (!inquiryTemplateId) { - if (c.req.valid("query").countryCode) { + if (scope !== "business" && c.req.valid("query").countryCode) { const personaAccount = await persona.getAccount(credentialId, scope).catch((error: unknown) => { captureException(error, { level: "error", contexts: { details: { credentialId, scope } } }); }); @@ -206,7 +236,7 @@ export default function route({ "json", object({ redirectURI: optional(string()), - scope: optional(picklist(["basic", "bridge", "cardLimit", "manteca"])), + scope: optional(picklist(["basic", "bridge", "business", "cardLimit", "manteca"])), }), validatorHook({ debug }), ), @@ -216,13 +246,18 @@ export default function route({ const scope = payload.scope ?? "basic"; const redirectURI = payload.redirectURI; const credential = await database.query.credentials.findFirst({ - columns: { id: true, account: true, pandaId: true }, + columns: { id: true, account: true, pandaId: true, salt: true }, where: eq(credentials.id, credentialId), }); if (!credential) return c.json({ code: "no credential", legacy: "no credential" }, 500); - setUser({ id: parse(Address, credential.account) }); + const account = parse(Address, credential.account); + setUser({ id: account }); setContext("exa", { credential }); + if ((scope === "business") !== isBusinessSalt(parse(Address, credential.salt))) { + return c.json({ code: "not supported" }, 400); + } + if (scope === "cardLimit") { const cardLimit = await persona.getCardLimitStatus(credentialId); switch (cardLimit.status) { @@ -243,17 +278,15 @@ export default function route({ contexts: { details: { credentialId, scope: "cardLimit" } }, }); }); - const { data } = await persona.createInquiry( - credentialId, - CARD_LIMIT_TEMPLATE, + const { data } = await persona.createInquiry(credentialId, CARD_LIMIT_TEMPLATE, { redirectURI, - basicAccount + fields: basicAccount ? { "name-first": basicAccount.attributes["name-first"], "name-last": basicAccount.attributes["name-last"], } : undefined, - ); + }); return c.json(await generateInquiryTokens(data.id, persona), 200); } case "completed": @@ -271,54 +304,58 @@ export default function route({ } } - let inquiryTemplateId: Awaited>; - try { - inquiryTemplateId = await persona.getPendingInquiryTemplate(credentialId, scope); - } catch (error: unknown) { - if (error instanceof Error && error.message === scopeValidationErrors.NOT_SUPPORTED) { - return c.json({ code: "not supported" }, 400); + const processInquiry = async () => { + const inquiryTemplateId = await persona.getPendingInquiryTemplate(credentialId, scope); + if (!inquiryTemplateId) { + return c.json({ code: "already approved", legacy: "kyc already approved" }, 400); } - throw error; - } - if (!inquiryTemplateId) { - return c.json({ code: "already approved", legacy: "kyc already approved" }, 400); - } - const inquiry = await persona.getInquiry(credentialId, inquiryTemplateId); - if (!inquiry) { - const { data } = await persona.createInquiry(credentialId, inquiryTemplateId, redirectURI); - return c.json(await generateInquiryTokens(data.id, persona), 200); - } - - switch (inquiry.attributes.status) { - case "approved": - captureException(new Error("inquiry approved but account not updated"), { - level: "error", - contexts: { inquiry: { templateId: inquiryTemplateId, referenceId: credentialId } }, + const inquiry = await persona.getInquiry(credentialId, inquiryTemplateId); + if (!inquiry) { + const { data } = await persona.createInquiry(credentialId, inquiryTemplateId, { + redirectURI, + ...(inquiryTemplateId === PANDA_BUSINESS_TEMPLATE && { accountTypeId: businessAccountTypeId() }), }); - return c.json({ code: "already approved", legacy: "kyc already approved" }, 400); - case "failed": - case "declined": - return c.json({ code: "failed", legacy: "kyc failed" }, 400); - case "completed": - case "needs_review": - return c.json({ code: "processing", legacy: "kyc failed" }, 400); - case "pending": - case "created": - case "expired": - return c.json(await generateInquiryTokens(inquiry.id, persona), 200); - default: - throw new Error("unknown inquiry status"); - } + return c.json(await generateInquiryTokens(data.id, persona), 200); + } + + switch (inquiry.attributes.status) { + case "approved": + captureException(new Error("inquiry approved but account not updated"), { + level: "error", + contexts: { inquiry: { templateId: inquiryTemplateId, referenceId: credentialId } }, + }); + return c.json({ code: "already approved", legacy: "kyc already approved" }, 400); + case "failed": + case "declined": + return c.json({ code: "failed", legacy: "kyc failed" }, 400); + case "completed": + case "needs_review": + return c.json({ code: "processing", legacy: "kyc failed" }, 400); + case "pending": + case "created": + case "expired": + return c.json(await generateInquiryTokens(inquiry.id, persona), 200); + default: + throw new Error("unknown inquiry status"); + } + }; + return ( + scope === "business" ? withMutex(account, processInquiry) : processInquiry() + ).catch((error: unknown) => { + if (error instanceof Error && error.message === scopeValidationErrors.NOT_SUPPORTED) + return c.json({ code: "not supported" }, 400); + throw error; + }); }, ) .post( "/application", auth, honoOpenapi.describeRoute({ - summary: "Submit KYC application", + summary: "Submit KYC or KYB application", description: ` -Submit information for KYC application. +Submit information for KYC or KYB application. **Encrypted kyc payload** @@ -416,7 +453,10 @@ The admin should add a member using [addMember method](https://www.better-auth.c description: "KYC application submitted successfully", content: { "application/json": { - schema: resolver(object({ status: string() }), { errorMode: "ignore" }), + schema: resolver( + union([CompanyApplicationResponse, CompanyApplicationStatusResponse, object({ status: string() })]), + { errorMode: "ignore" }, + ), }, }, }, @@ -427,10 +467,15 @@ The admin should add a member using [addMember method](https://www.better-auth.c schema: resolver( union([ object({ code: picklist(["invalid encryption", "no account", "bad chain"]), message: string() }), + object({ code: literal("not supported") }), object({ ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, message: optional(array(string())), }), + object({ + code: picklist(businessCodes), + message: optional(array(string())), + }), ]), { errorMode: "ignore", @@ -483,25 +528,96 @@ The admin should add a member using [addMember method](https://www.better-auth.c }, validateResponse: true, }), + vValidator( + "header", + optional( + pipe( + object({ + "account-type": optional(literal("business")), + "do-connecting-ip": fallback(optional(IpAddress), () => undefined), + "x-forwarded-for": fallback(optional(string()), () => undefined), + }), + transform(({ "do-connecting-ip": ip, "x-forwarded-for": forwarded, ...headers }) => ({ + ...headers, + "client-ip": ip ?? forwarded?.split(",").at(-1)?.trim(), + })), + ), + ), + validatorHook({ debug }), + ), vValidator( "json", - union([ - object({ - ...Application.entries, - verify: object({ message: string(), signature: Hex, walletAddress: Address, chainId: number() }), - }), - object({ - key: string(), - iv: string(), - ciphertext: string(), - tag: string(), - verify: object({ message: string(), signature: Hex, walletAddress: Address, chainId: number() }), - }), - ]), + optional( + union([ + object({ + ...Application.entries, + verify: object({ message: string(), signature: Hex, walletAddress: Address, chainId: number() }), + }), + object({ + key: string(), + iv: string(), + ciphertext: string(), + tag: string(), + verify: object({ message: string(), signature: Hex, walletAddress: Address, chainId: number() }), + }), + strictObject({}), + object({ scope: picklist(["panda", "bridge"]) }), + ]), + ), validatorHook({ debug }), ), async (c) => { const payload = c.req.valid("json"); + const isBusiness = c.req.valid("header")?.["account-type"] === "business"; + const credentialId = c.req.valid("cookie").credentialId; + if (isBusiness) { + const credential = await database.query.credentials.findFirst({ + columns: { account: true, salt: true }, + where: eq(credentials.id, credentialId), + }); + if (!credential) return c.json({ code: "no credential" }, 500); + const account = parse(Address, credential.account); + if (!isBusinessSalt(parse(Address, credential.salt))) return c.json({ code: "not supported" }, 400); + if (payload && "verify" in payload) return c.json({ code: BadRequestCodes.BAD_REQUEST }, 400); + if (!payload || !("scope" in payload) || payload.scope !== "panda") + return c.json({ code: "not supported" }, 400); + return withMutex(account, async () => { + const current = await database.query.credentials.findFirst({ + columns: { pandaId: true }, + where: eq(credentials.id, credentialId), + }); + if (!current) return c.json({ code: "no credential" }, 500); + try { + if (current.pandaId) return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409); + const application = + (await panda.getCompanyApplication(credentialId)) ?? + (await panda.createCompanyApplication( + await panda.businessApplication( + credentialId, + account, + c.req.valid("header")?.["client-ip"], + persona, + ), + { idempotencyKey: `business-application:${credentialId}` }, + )); + if ( + application.applicationStatus && + ["denied", "locked", "canceled"].includes(application.applicationStatus) + ) + return c.json({ code: "bad kyb" }, 400); + setUser({ id: account }); + return c.json(application, 200); + } catch (error) { + if (error instanceof BusinessApplicationError) + return c.json({ code: error.code, message: [error.message] }, 400); + if (error instanceof ServiceError && error.status === 400) + return c.json({ code: BadRequestCodes.BAD_REQUEST, message: [error.message] }, 400); + throw error; + } + }); + } + if (!payload || !("verify" in payload)) + return c.json({ code: BadRequestCodes.BAD_REQUEST, legacy: BadRequestCodes.BAD_REQUEST }, 400); const { message, signature, walletAddress: address } = payload.verify; if (!(await verifyMessage({ address, message, signature }))) { @@ -528,12 +644,12 @@ The admin should add a member using [addMember method](https://www.better-auth.c if (member.role !== "admin" && member.role !== "owner") return c.json({ code: "no permission" }, 403); if (member.organization.role !== "kyc") return c.json({ code: "no permission" }, 403); - const { credentialId } = c.req.valid("cookie"); const credential = await database.query.credentials.findFirst({ - columns: { id: true, account: true, pandaId: true }, + columns: { id: true, account: true, pandaId: true, salt: true }, where: eq(credentials.id, credentialId), }); if (!credential) return c.json({ code: "no credential" }, 500); + if (isBusinessSalt(parse(Address, credential.salt))) return c.json({ code: "not supported" }, 400); setUser({ id: parse(Address, credential.account) }); setContext("exa", { credential }); @@ -687,12 +803,26 @@ The admin should add a member using [addMember method](https://www.better-auth.c async (c) => { const { credentialId } = c.req.valid("cookie"); const credential = await database.query.credentials.findFirst({ - columns: { id: true, account: true, pandaId: true }, + columns: { id: true, account: true, pandaId: true, salt: true }, where: eq(credentials.id, credentialId), }); if (!credential) return c.json({ code: "no credential", legacy: "no credential" }, 500); setUser({ id: parse(Address, credential.account) }); setContext("exa", { credential }); + if (isBusinessSalt(parse(Address, credential.salt))) { + const application = await panda.getCompanyApplication(credentialId); + if (!application) + return c.json({ code: BadRequestCodes.NOT_STARTED, legacy: BadRequestCodes.NOT_STARTED }, 400); + return c.json( + { + code: "ok", + legacy: "ok", + status: application.applicationStatus ?? "unknown", + reason: application.applicationReason ?? "unknown", + }, + 200, + ); + } if (!credential.pandaId) { return c.json({ code: BadRequestCodes.NOT_STARTED, legacy: BadRequestCodes.NOT_STARTED }, 400); } diff --git a/server/hooks/persona.ts b/server/hooks/persona.ts index 0cd42504ee..24239311c1 100644 --- a/server/hooks/persona.ts +++ b/server/hooks/persona.ts @@ -38,6 +38,7 @@ import { headerValidator, MANTECA_TEMPLATE_EXTRA_FIELDS, MANTECA_TEMPLATE_WITH_ID_CLASS, + PANDA_BUSINESS_TEMPLATE, PANDA_TEMPLATE, } from "../utils/persona"; import validatorHook from "../utils/validatorHook"; @@ -249,6 +250,7 @@ export default function hook({ CARD_LIMIT_TEMPLATE, CRYPTOMATE_TEMPLATE, MANTECA_TEMPLATE_EXTRA_FIELDS, + PANDA_BUSINESS_TEMPLATE, ]), }), }), @@ -266,7 +268,30 @@ export default function hook({ async (c) => { const payload = c.req.valid("json").data.attributes.payload; - if (payload.template === "ignored") return c.json({ code: "ok" }, 200); + if (payload.template === "ignored") { + if ( + payload.data.attributes.status === "approved" && + payload.data.relationships.inquiryTemplate.data.id === PANDA_BUSINESS_TEMPLATE + ) { + const credential = await database.query.credentials.findFirst({ + columns: { account: true, factory: true, publicKey: true, salt: true, source: true }, + where: eq(credentials.id, payload.data.attributes.referenceId), + }); + if (credential) { + const account = safeParse(Address, credential.account); + if (account.success && firewallAddress) + await allow.enqueue({ + account: account.output, + chainId: chain.id, + factory: parse(Address, credential.factory), + publicKey: bytesToHex(credential.publicKey), + salt: parse(Address, credential.salt), + source: credential.source, + }); + } + } + return c.json({ code: "ok" }, 200); + } if (payload.template === "cardLimit") { getActiveSpan()?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, "persona.case.card-limit"); if (payload.data.attributes.status !== "Approved") return c.json({ code: "ok" }, 200); diff --git a/server/test/api/auth.test.ts b/server/test/api/auth.test.ts index 0fa59f6a7b..f22faa16c5 100644 --- a/server/test/api/auth.test.ts +++ b/server/test/api/auth.test.ts @@ -12,17 +12,18 @@ import { decodeJwt, decodeProtectedHeader, jwtVerify } from "jose"; import assert from "node:assert"; import { env } from "node:process"; import { nonEmpty, parse, pipe, string, type InferOutput } from "valibot"; -import { getAddress, keccak256, padHex, slice, toBytes, zeroAddress } from "viem"; +import { getAddress, keccak256, padHex, slice, toBytes, zeroAddress, zeroHash } from "viem"; import { optimism } from "viem/chains"; import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, onTestFinished, vi } from "vitest"; -import * as derive from "@exactly/common/deriveAddress"; +import deriveAddress, * as derive from "@exactly/common/deriveAddress"; import chain, { exaAccountFactoryAddress } from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; import authentication, { Authentication } from "../../api/auth/authentication"; import registration from "../../api/auth/registration"; import database, { credentials } from "../../database"; +import { decode, encode } from "../../utils/authChallenge"; import authSecret from "../../utils/authSecret"; import createCredentialFactory from "../../utils/createCredential"; import createIntercom from "../../utils/intercom"; @@ -91,6 +92,21 @@ describe("authentication", () => { await redis.del("test-session"); }); + it.each([ + { name: "siwe", query: { credentialId: zeroAddress } }, + { name: "webauthn", query: {} }, + ])("stores a business account type for $name authentication", async ({ query }) => { + const response = await appClient.index.$get({ query }, { headers: { "account-type": "business" } }); + const sessionId = response.headers.get("X-Session-Id") ?? ""; + onTestFinished(async () => { + await redis.del(sessionId); + }); + + expect(response.status).toBe(200); + expect(sessionId).not.toBe(""); + expect(decode((await redis.get(sessionId)) ?? "")).toMatchObject({ accountType: "business" }); + }); + it("returns intercom token on successful login", async () => { const response = await appClient.index.$post( { @@ -254,6 +270,117 @@ describe("authentication", () => { expect(await response.json()).toEqual(expect.objectContaining({ code: "no authentication" })); }); + it("rejects malformed structured authentication challenges", async () => { + await redis.set("test-session", JSON.stringify({ accountType: "business" })); + + const response = await appClient.index.$post( + { + json: { + method: "webauthn", + id: "dGVzdC1jcmVkLWlk", + rawId: "dGVzdC1jcmVkLWlk", + response: { clientDataJSON: "dGVzdA", authenticatorData: "dGVzdA", signature: "dGVzdA" }, + clientExtensionResults: {}, + type: "public-key", + }, + }, + { headers: { cookie: "session_id=test-session" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "bad authentication", legacy: "bad authentication" }); + await expect(redis.exists("test-session")).resolves.toBe(0); + }); + + it("rejects account type mismatch between challenge and request", async () => { + await redis.set("test-session", JSON.stringify({ challenge: "test-challenge", accountType: "business" })); + + const response = await appClient.index.$post( + { + json: { + method: "webauthn", + id: "dGVzdC1jcmVkLWlk", + rawId: "dGVzdC1jcmVkLWlk", + response: { clientDataJSON: "dGVzdA", authenticatorData: "dGVzdA", signature: "dGVzdA" }, + clientExtensionResults: {}, + type: "public-key", + }, + }, + { headers: { cookie: "session_id=test-session" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "bad account type" }); + await expect(redis.exists("test-session")).resolves.toBe(0); + }); + + it("rejects business auth for a credential without business salt", async () => { + await redis.set("test-session", JSON.stringify({ challenge: "test-challenge", accountType: "business" })); + + const response = await appClient.index.$post( + { + json: { + method: "webauthn", + id: "dGVzdC1jcmVkLWlk", + rawId: "dGVzdC1jcmVkLWlk", + response: { clientDataJSON: "dGVzdA", authenticatorData: "dGVzdA", signature: "dGVzdA" }, + clientExtensionResults: {}, + type: "public-key", + }, + }, + { headers: { cookie: "session_id=test-session", "account-type": "business" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "bad account type" }); + await expect(redis.exists("test-session")).resolves.toBe(0); + }); + + it("authenticates a business credential with a business salt", async () => { + const id = own(parse(Address, slice(keccak256(toBytes("auth:business-authentication")), 12))); + const salt = parse(Address, slice(keccak256(toBytes("salt:business-authentication")), 0, 20)); + const factory = parse(Address, inject("ExaAccountFactory")); + await database.insert(credentials).values({ + id, + publicKey: new Uint8Array(65), + account: deriveAddress(factory, { x: zeroHash, y: zeroHash, salt }), + factory, + salt, + transports: [], + }); + await redis.set("test-session", encode("test-challenge", "business")); + vi.mocked(verifyAuthenticationResponse).mockResolvedValueOnce({ + verified: true, + authenticationInfo: { + credentialID: id, + newCounter: 0, + userVerified: false, + credentialDeviceType: "singleDevice", + credentialBackedUp: false, + origin: "http://localhost", + rpID: "localhost", + }, + }); + + const response = await appClient.index.$post( + { + json: { + method: "webauthn", + id, + rawId: id, + response: { clientDataJSON: "dGVzdA", authenticatorData: "dGVzdA", signature: "dGVzdA" }, + clientExtensionResults: {}, + type: "public-key", + }, + }, + { headers: { cookie: "session_id=test-session", "account-type": "business" } }, + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(expect.objectContaining({ salt })); + await expect(redis.exists("test-session")).resolves.toBe(0); + }); + it("returns 400 for missing credential with non-siwe assertion", async () => { const response = await appClient.index.$post( { @@ -660,6 +787,21 @@ describe("registration", () => { await redis.del("test-session"); }); + it.each([ + { name: "siwe", query: { credentialId: zeroAddress } }, + { name: "webauthn", query: {} }, + ])("stores a business account type for $name registration", async ({ query }) => { + const response = await registrationAppClient.index.$get({ query }, { headers: { "account-type": "business" } }); + const sessionId = response.headers.get("X-Session-Id") ?? ""; + onTestFinished(async () => { + await redis.del(sessionId); + }); + + expect(response.status).toBe(200); + expect(sessionId).not.toBe(""); + expect(decode((await redis.get(sessionId)) ?? "")).toMatchObject({ accountType: "business" }); + }); + it("returns 400 if registration challenge is missing", async () => { await redis.del("test-session"); const response = await postRegistrationWebauthn(); @@ -668,6 +810,26 @@ describe("registration", () => { expect(await response.json()).toEqual(expect.objectContaining({ code: "no registration" })); }); + it.each([ + { + name: "malformed", + storedChallenge: JSON.stringify({ accountType: "business" }), + expected: { code: "bad registration", legacy: "bad registration" }, + }, + { + name: "mismatched", + storedChallenge: encode("test-challenge", "business"), + expected: { code: "bad account type" }, + }, + ])("rejects $name structured registration challenge", async ({ storedChallenge, expected }) => { + await redis.set("test-session", storedChallenge); + const response = await postRegistrationWebauthn(); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual(expected); + await expect(redis.exists("test-session")).resolves.toBe(0); + }); + it("consumes challenge before verifier exceptions", async () => { vi.mocked(verifyRegistrationResponse).mockRejectedValueOnce(new Error("boom")); @@ -959,6 +1121,28 @@ describe("registration", () => { expect(credential?.source).toBeNull(); await expect(redis.exists("test-session")).resolves.toBe(0); }); + + it("creates a business credential with a nonzero salt using webauthn", async () => { + const id = own("YnVzaW5lc3MtcmVnaXN0cmF0aW9u"); // cspell:ignore YnVzaW5lc3MtcmVnaXN0cmF0aW9u + await redis.set("test-session", encode("test-challenge", "business")); + const response = await registrationAppClient.index.$post( + { json: registrationWebauthnAssertion({ id, rawId: id }) }, + { headers: { cookie: "session_id=test-session", "account-type": "business" } }, + ); + + expect(response.status).toBe(200); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, id), + columns: { account: true, salt: true }, + }); + if (!credential) throw new Error("missing credential"); + expect(credential.salt).not.toBe(zeroAddress); + expect(await response.json()).toEqual(expect.objectContaining({ salt: credential.salt })); + expect(credential.account).toBe( + deriveAddress(exaAccountFactoryAddress, { x: zeroHash, y: zeroHash, salt: credential.salt }), + ); + await expect(redis.exists("test-session")).resolves.toBe(0); + }); }); vi.mock("@simplewebauthn/server", async (importOriginal) => { diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index 393e658af5..bfbdb94a52 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -11,13 +11,14 @@ import { testClient } from "hono/testing"; import crypto from "node:crypto"; import { env } from "node:process"; import { nonEmpty, parse, pipe, string } from "valibot"; -import { getAddress, sha256 } from "viem"; +import { getAddress, padHex, sha256 } from "viem"; import { mnemonicToAccount } from "viem/accounts"; import { createSiweMessage, generateSiweNonce } from "viem/siwe"; -import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest"; import domain from "@exactly/common/domain"; import chain from "@exactly/common/generated/chain"; +import { Address } from "@exactly/common/validation"; import route from "../../api/kyc"; import database, { credentials, organizations, sources } from "../../database"; @@ -367,7 +368,7 @@ describe("authenticated", () => { ); expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "basic"); - expect(createInquiry).toHaveBeenCalledWith("bob", persona.PANDA_TEMPLATE, undefined); + expect(createInquiry).toHaveBeenCalledWith("bob", persona.PANDA_TEMPLATE, { redirectURI: undefined }); await expect(response.json()).resolves.toStrictEqual({ sessionToken, inquiryId: resumeTemplate.data.id, @@ -913,7 +914,9 @@ describe("authenticated", () => { ); expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "manteca"); - expect(createInquiry).toHaveBeenCalledWith("bob", persona.MANTECA_TEMPLATE_EXTRA_FIELDS, undefined); + expect(createInquiry).toHaveBeenCalledWith("bob", persona.MANTECA_TEMPLATE_EXTRA_FIELDS, { + redirectURI: undefined, + }); await expect(response.json()).resolves.toStrictEqual({ sessionToken, inquiryId: resumeTemplate.data.id, @@ -943,7 +946,9 @@ describe("authenticated", () => { ); expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "manteca"); - expect(createInquiry).toHaveBeenCalledWith("bob", persona.MANTECA_TEMPLATE_WITH_ID_CLASS, undefined); + expect(createInquiry).toHaveBeenCalledWith("bob", persona.MANTECA_TEMPLATE_WITH_ID_CLASS, { + redirectURI: undefined, + }); await expect(response.json()).resolves.toStrictEqual({ sessionToken, inquiryId: resumeTemplate.data.id, @@ -1218,7 +1223,7 @@ describe("authenticated", () => { ); expect(getPendingInquiryTemplate).toHaveBeenCalledWith("bob", "bridge"); - expect(createInquiry).toHaveBeenCalledWith("bob", persona.PANDA_TEMPLATE, undefined); + expect(createInquiry).toHaveBeenCalledWith("bob", persona.PANDA_TEMPLATE, { redirectURI: undefined }); await expect(response.json()).resolves.toStrictEqual({ sessionToken, inquiryId: resumeTemplate.data.id, @@ -1481,9 +1486,12 @@ describe("authenticated", () => { ); expect(persona.getAccount).toHaveBeenCalledWith("bob", "basic"); - expect(persona.createInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE, undefined, { - "name-first": "ALEXANDER J", - "name-last": "SAMPLE", + expect(persona.createInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE, { + redirectURI: undefined, + fields: { + "name-first": "ALEXANDER J", + "name-last": "SAMPLE", + }, }); await expect(response.json()).resolves.toStrictEqual({ inquiryId: resumeTemplate.data.id, sessionToken }); expect(response.status).toBe(200); @@ -1506,7 +1514,10 @@ describe("authenticated", () => { ); expect(persona.getAccount).toHaveBeenCalledWith("bob", "basic"); - expect(persona.createInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE, undefined, undefined); + expect(persona.createInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE, { + redirectURI: undefined, + fields: undefined, + }); await expect(response.json()).resolves.toStrictEqual({ inquiryId: resumeTemplate.data.id, sessionToken }); expect(response.status).toBe(200); }); @@ -1532,7 +1543,10 @@ describe("authenticated", () => { level: "error", contexts: { details: { credentialId: "bob", scope: "cardLimit" } }, }); - expect(persona.createInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE, undefined, undefined); + expect(persona.createInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE, { + redirectURI: undefined, + fields: undefined, + }); await expect(response.json()).resolves.toStrictEqual({ inquiryId: resumeTemplate.data.id, sessionToken }); expect(response.status).toBe(200); }); @@ -1643,9 +1657,12 @@ describe("authenticated", () => { ); expect(persona.getAccount).toHaveBeenCalledWith("bob", "basic"); - expect(persona.createInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE, "https://example.com", { - "name-first": "ALEXANDER J", - "name-last": "SAMPLE", + expect(persona.createInquiry).toHaveBeenCalledWith("bob", persona.CARD_LIMIT_TEMPLATE, { + redirectURI: "https://example.com", + fields: { + "name-first": "ALEXANDER J", + "name-last": "SAMPLE", + }, }); await expect(response.json()).resolves.toStrictEqual({ inquiryId: resumeTemplate.data.id, sessionToken }); expect(response.status).toBe(200); @@ -1791,6 +1808,10 @@ describe("authenticated", () => { }); describe("submit", () => { + const businessId = "bob-business-individual"; + const businessAccount = getAddress(padHex("0xb0c", { size: 20 })); + const businessSalt = getAddress(padHex("0xb1c", { size: 20 })); + beforeAll(async () => { await database.insert(sources).values([ { @@ -1802,6 +1823,17 @@ describe("authenticated", () => { }, }, ]); + await database.insert(credentials).values({ + id: businessId, + publicKey: new Uint8Array(), + account: businessAccount, + factory: inject("ExaAccountFactory"), + salt: businessSalt, + }); + }); + + afterAll(async () => { + await database.delete(credentials).where(eq(credentials.id, businessId)); }); it("returns ok when payload is valid and kyc is not started", async () => { @@ -1952,16 +1984,16 @@ describe("authenticated", () => { }); it("returns 400 when payload is invalid", async () => { - const response = await appClient.application.$post( - { json: {} as unknown as NonNullable[0]["json"]> }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); + const response = await app.request("/application", { + method: "POST", + headers: { "content-type": "application/json", "test-credential-id": account, SessionID: "fakeSession" }, + body: "{}", + }); expect(response.status).toBe(400); await expect(response.json()).resolves.toMatchObject({ code: "bad request", legacy: "bad request", - message: expect.any(Array), // eslint-disable-line @typescript-eslint/no-unsafe-assignment }); }); @@ -2061,6 +2093,33 @@ describe("authenticated", () => { expect(submitApplication).not.toHaveBeenCalled(); }); + it("returns not supported when a business credential uses the individual application", async () => { + const statement = `I apply for KYC approval on behalf of address ${businessAccount} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://${domain}`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain, + }); + const signature = await owner.signMessage({ message }); + const verify = { message, signature, walletAddress: owner.address, chainId: chain.id }; + const submitApplication = vi.spyOn(panda, "submitApplication"); + + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": businessId, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "not supported" }); + expect(submitApplication).not.toHaveBeenCalled(); + }); + describe("with encrypted payload", () => { const publicKey = `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZixoAuo015iMt+JND0y @@ -2526,6 +2585,510 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE }); }); }); + + describe("business application", () => { + const businessId = "bob-business"; + const businessAccount = parse(Address, padHex("0xb0b", { size: 20 })); + const businessSalt = parse(Address, padHex("0xb1b", { size: 20 })); + const businessFields = { + i_company_name: { value: "Account Acme" }, + company_description: { value: "Account software" }, + company_industry: { value: "541511" }, + company_registration_number: { value: "123" }, + company_tax_id: { value: "456" }, + company_website: { value: "https://example.com" }, + company_type: { value: "corporation" }, + company_expected_spend: { value: 1000 }, + i_auth_user_name: { value: "Jane" }, + i_auth_user_last_name: { value: "Doe" }, + birth_date: { value: "1990-01-01" }, + id_number: { value: "123456789" }, + id_country: { value: "US" }, + collected_email_address: { value: "jane@example.com" }, + authorized_user_phone_country_code: { value: "1" }, + authorized_user_phone_number: { value: "5555555555" }, + terms_and_conditions: { value: true }, + street_1: { value: "1 Main St" }, + city: { value: "New York" }, + subdivision: { value: "NY" }, + postal_code: { value: "10001" }, + country_code: { value: "US" }, + street_1_1: { value: "1 Main St" }, + city_1: { value: "New York" }, + subdivision_1: { value: "NY" }, + postal_code_1: { value: "10001" }, + country_code_1: { value: "US" }, + }; + + beforeAll(async () => { + await database.insert(credentials).values([ + { + id: businessId, + publicKey: new Uint8Array(), + account: businessAccount, + factory: inject("ExaAccountFactory"), + salt: businessSalt, + }, + ]); + }); + + afterEach(async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, businessId)); + }); + + afterAll(async () => { + await database.delete(credentials).where(eq(credentials.id, businessId)); + }); + + it("serializes business inquiry creation", async () => { + let created = false; + vi.spyOn(persona, "getPendingInquiryTemplate").mockResolvedValue(persona.PANDA_BUSINESS_TEMPLATE); + vi.spyOn(persona, "getInquiry").mockImplementation(() => + Promise.resolve(created ? personaTemplate : undefined), + ); + const createInquiry = vi.spyOn(persona, "createInquiry").mockImplementation(() => { + created = true; + return Promise.resolve(inquiry); + }); + + await Promise.all([ + appClient.index.$post( + { json: { scope: "business" } }, + { headers: { "test-credential-id": businessId, SessionID: "fakeSession" } }, + ), + appClient.index.$post( + { json: { scope: "business" } }, + { headers: { "test-credential-id": businessId, SessionID: "fakeSession" } }, + ), + ]); + + expect(createInquiry).toHaveBeenCalledOnce(); + }); + + it("submits a company application for a business credential", async () => { + vi.spyOn(persona, "getInquiry").mockResolvedValue({ + id: "inquiry-id", + type: "inquiry", + attributes: { + status: "approved", + "reference-id": businessId, + fields: { "company-description": { value: "Inquiry software" } }, + }, + }); + vi.spyOn(persona, "getAccount").mockResolvedValue({ + id: "account-id", + type: "account", + attributes: { "reference-id": businessId, fields: businessFields }, + relationships: { "account-type": { data: { id: "acttp_company" } } }, + }); + const companyApplication = { + id: "company-1", + name: "Account Acme", + address: { + line1: "1 Main St", + city: "New York", + region: "NY", + postalCode: "10001", + countryCode: "US", + }, + applicationStatus: "pending" as const, + }; + const getCompanyApplication = vi.spyOn(panda, "getCompanyApplication").mockResolvedValue(undefined); // eslint-disable-line unicorn/no-useless-undefined + const createCompanyApplication = vi + .spyOn(panda, "createCompanyApplication") + .mockResolvedValue(companyApplication); + + const response = await appClient.application.$post( + { json: { scope: "panda" } }, + { + headers: { + "test-credential-id": businessId, + SessionID: "fakeSession", + "do-connecting-ip": "127.0.0.1", + "account-type": "business", + }, + }, + ); + + expect(response.status).toBe(200); + expect(getCompanyApplication).toHaveBeenCalledWith(businessId); + expect(createCompanyApplication).toHaveBeenCalledWith(expect.objectContaining({ name: "Account Acme" }), { + idempotencyKey: `business-application:${businessId}`, + }); + await expect(response.json()).resolves.toStrictEqual(companyApplication); + }); + + it("reads the client ip from the forwarded header", async () => { + vi.spyOn(persona, "getInquiry").mockResolvedValue({ + id: "inquiry-id", + type: "inquiry", + attributes: { status: "approved", "reference-id": businessId, fields: {} }, + }); + vi.spyOn(persona, "getAccount").mockResolvedValue({ + id: "account-id", + type: "account", + attributes: { "reference-id": businessId, fields: businessFields }, + relationships: { "account-type": { data: { id: "acttp_company" } } }, + }); + const createCompanyApplication = vi.spyOn(panda, "createCompanyApplication").mockResolvedValue({ + id: "company-1", + name: "Account Acme", + address: { + line1: "1 Main St", + city: "New York", + region: "NY", + postalCode: "10001", + countryCode: "US", + }, + applicationStatus: "pending", + }); + const getCompanyApplication = vi.spyOn(panda, "getCompanyApplication").mockResolvedValue(undefined); // eslint-disable-line unicorn/no-useless-undefined + + const response = await appClient.application.$post( + { json: { scope: "panda" } }, + { + headers: { + "test-credential-id": businessId, + SessionID: "fakeSession", + "x-forwarded-for": "1.2.3.4, 203.0.113.7", + "account-type": "business", + }, + }, + ); + + expect(response.status).toBe(200); + expect(getCompanyApplication).toHaveBeenCalledWith(businessId); + expect(createCompanyApplication.mock.calls.at(-1)?.[0].initialUser.ipAddress).toBe("203.0.113.7"); + expect(createCompanyApplication.mock.calls.at(-1)?.[1]).toStrictEqual({ + idempotencyKey: `business-application:${businessId}`, + }); + }); + + it("returns an existing company application without a status", async () => { + const companyApplication = { + id: "company-1", + name: "Account Acme", + address: { + line1: "1 Main St", + city: "New York", + region: "NY", + postalCode: "10001", + countryCode: "US", + }, + applicationStatus: null, + }; + const getCompanyApplication = vi.spyOn(panda, "getCompanyApplication").mockResolvedValue(companyApplication); + const createCompanyApplication = vi.spyOn(panda, "createCompanyApplication"); + + const response = await appClient.application.$post( + { json: { scope: "panda" } }, + { + headers: { + "test-credential-id": businessId, + SessionID: "fakeSession", + "account-type": "business", + }, + }, + ); + + expect(response.status).toBe(200); + expect(getCompanyApplication).toHaveBeenCalledWith(businessId); + expect(createCompanyApplication).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toStrictEqual(companyApplication); + }); + + it("returns a business application error", async () => { + const error = new Panda.BusinessApplicationError("business account is not complete", "processing"); + const businessApplication = vi.spyOn(panda, "businessApplication").mockRejectedValueOnce(error); + vi.spyOn(panda, "getCompanyApplication").mockResolvedValue(undefined); // eslint-disable-line unicorn/no-useless-undefined + + const response = await appClient.application.$post( + { json: { scope: "panda" } }, + { + headers: { + "test-credential-id": businessId, + SessionID: "fakeSession", + "do-connecting-ip": "127.0.0.1", + "account-type": "business", + }, + }, + ); + + expect(response.status).toBe(400); + expect(businessApplication).toHaveBeenCalledOnce(); + await expect(response.json()).resolves.toStrictEqual({ + code: "processing", + message: ["business account is not complete"], + }); + }); + + it("returns conflict when a business application already started", async () => { + await database.update(credentials).set({ pandaId: "panda-id" }).where(eq(credentials.id, businessId)); + const businessApplication = vi.spyOn(panda, "businessApplication"); + const getCompanyApplication = vi.spyOn(panda, "getCompanyApplication"); + const createCompanyApplication = vi.spyOn(panda, "createCompanyApplication"); + + const response = await appClient.application.$post( + { json: { scope: "panda" } }, + { + headers: { + "test-credential-id": businessId, + SessionID: "fakeSession", + "account-type": "business", + }, + }, + ); + + expect(response.status).toBe(409); + expect(businessApplication).not.toHaveBeenCalled(); + expect(getCompanyApplication).not.toHaveBeenCalled(); + expect(createCompanyApplication).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toStrictEqual({ code: "already started" }); + }); + + it("returns no credential when the business credential disappears", async () => { + const credential = await database.query.credentials.findFirst({ where: eq(credentials.id, businessId) }); + const findFirst = vi + .spyOn(database.query.credentials, "findFirst") + .mockResolvedValueOnce(credential) + .mockResolvedValueOnce(undefined); // eslint-disable-line unicorn/no-useless-undefined + + const response = await appClient.application.$post( + { json: { scope: "panda" } }, + { + headers: { + "test-credential-id": businessId, + SessionID: "fakeSession", + "account-type": "business", + }, + }, + ); + + expect(response.status).toBe(500); + expect(findFirst).toHaveBeenCalledTimes(2); + await expect(response.json()).resolves.toStrictEqual({ code: "no credential" }); + }); + + it("returns bad request for a Panda validation error", async () => { + vi.spyOn(panda, "getCompanyApplication").mockResolvedValue(undefined); // eslint-disable-line unicorn/no-useless-undefined + vi.spyOn(persona, "getInquiry").mockResolvedValue({ + id: "inquiry-id", + type: "inquiry", + attributes: { + status: "approved", + "reference-id": businessId, + fields: { "company-description": { value: "Inquiry software" } }, + }, + }); + vi.spyOn(persona, "getAccount").mockResolvedValue({ + id: "account-id", + type: "account", + attributes: { "reference-id": businessId, fields: businessFields }, + relationships: { "account-type": { data: { id: "acttp_company" } } }, + }); + vi.spyOn(panda, "createCompanyApplication").mockRejectedValueOnce( + new ServiceError("Panda", 400, '{"message":"invalid company"}', undefined, "invalid company"), + ); + + const response = await appClient.application.$post( + { json: { scope: "panda" } }, + { + headers: { + "test-credential-id": businessId, + SessionID: "fakeSession", + "do-connecting-ip": "127.0.0.1", + "account-type": "business", + }, + }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "bad request", + message: ["invalid company"], + }); + }); + + it("returns bad request when a business application includes a verify payload", async () => { + const response = await appClient.application.$post( + { + json: { + email: "test@example.com", + lastName: "Doe", + firstName: "John", + nationalId: "12345678", + birthDate: "1990-01-01", + countryOfIssue: "US", + phoneCountryCode: "1", + phoneNumber: "5551234567", + address: { + line1: "123 Main St", + city: "New York", + region: "NY", + country: "US", + postalCode: "10001", + countryCode: "US", + }, + ipAddress: "127.0.0.1", + occupation: "Engineer", + annualSalary: "100000", + accountPurpose: "Personal", + expectedMonthlyVolume: "5000", + isTermsOfServiceAccepted: true as const, + verify: { message: "x", signature: "0x", walletAddress: businessAccount, chainId: chain.id }, + }, + }, + { + headers: { "test-credential-id": businessId, SessionID: "fakeSession", "account-type": "business" }, + }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "bad request" }); + }); + + it("returns not supported for a business application without a business credential", async () => { + const response = await appClient.application.$post( + { json: { scope: "panda" } }, + { headers: { "test-credential-id": "bob", SessionID: "fakeSession", "account-type": "business" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "not supported" }); + }); + + it("returns not supported for an unaccepted business application scope", async () => { + const businessApplication = vi.spyOn(panda, "businessApplication"); + const createCompanyApplication = vi.spyOn(panda, "createCompanyApplication"); + + const response = await appClient.application.$post( + { json: { scope: "other" } }, + { + headers: { "test-credential-id": businessId, SessionID: "fakeSession", "account-type": "business" }, + }, + ); + + expect(response.status).toBe(400); + expect(businessApplication).not.toHaveBeenCalled(); + expect(createCompanyApplication).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toMatchObject({ code: "bad request" }); + }); + + it("does not route bridge applications through panda", async () => { + const businessApplication = vi.spyOn(panda, "businessApplication"); + const createCompanyApplication = vi.spyOn(panda, "createCompanyApplication"); + + const response = await appClient.application.$post( + { json: { scope: "bridge" } }, + { + headers: { "test-credential-id": businessId, SessionID: "fakeSession", "account-type": "business" }, + }, + ); + + expect(response.status).toBe(400); + expect(businessApplication).not.toHaveBeenCalled(); + expect(createCompanyApplication).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toStrictEqual({ code: "not supported" }); + }); + + it("returns not supported for a business application without a scope", async () => { + const businessApplication = vi.spyOn(panda, "businessApplication"); + const createCompanyApplication = vi.spyOn(panda, "createCompanyApplication"); + + const response = await appClient.application.$post( + { json: {} }, + { + headers: { "test-credential-id": businessId, SessionID: "fakeSession", "account-type": "business" }, + }, + ); + + expect(response.status).toBe(400); + expect(businessApplication).not.toHaveBeenCalled(); + expect(createCompanyApplication).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toStrictEqual({ code: "not supported" }); + }); + + it("returns company application status", async () => { + const getCompanyApplication = vi.spyOn(panda, "getCompanyApplication").mockResolvedValue({ + id: businessId, + applicationStatus: "approved", + applicationReason: "", + }); + + const response = await appClient.application.$get( + { query: {} }, + { headers: { "test-credential-id": businessId, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(200); + expect(getCompanyApplication).toHaveBeenCalledWith(businessId); + await expect(response.json()).resolves.toStrictEqual({ + code: "ok", + legacy: "ok", + status: "approved", + reason: "", + }); + }); + + it("returns not started when the company application is missing", async () => { + const getCompanyApplication = vi.spyOn(panda, "getCompanyApplication").mockResolvedValue(undefined); // eslint-disable-line unicorn/no-useless-undefined + + const response = await appClient.application.$get( + { query: {} }, + { headers: { "test-credential-id": businessId, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + expect(getCompanyApplication).toHaveBeenCalledWith(businessId); + await expect(response.json()).resolves.toStrictEqual({ + code: "not started", + legacy: "not started", + }); + }); + + it("returns bad kyb when the company application is denied", async () => { + const getCompanyApplication = vi.spyOn(panda, "getCompanyApplication").mockResolvedValue({ + id: businessId, + applicationStatus: "denied", + applicationReason: "bad kyb", + }); + + const response = await appClient.application.$post( + { json: { scope: "panda" } }, + { + headers: { + "test-credential-id": businessId, + SessionID: "fakeSession", + "do-connecting-ip": "127.0.0.1", + "account-type": "business", + }, + }, + ); + + expect(response.status).toBe(400); + expect(getCompanyApplication).toHaveBeenCalledWith(businessId); + await expect(response.json()).resolves.toStrictEqual({ code: "bad kyb" }); + }); + + it("returns not started while the business inquiry is pending", async () => { + vi.spyOn(persona, "getInquiry").mockResolvedValue({ + id: "inquiry-id", + type: "inquiry", + attributes: { status: "pending", "reference-id": businessId, fields: {} }, + }); + + const response = await appClient.index.$get( + { query: { scope: "business" } }, + { headers: { "test-credential-id": businessId, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "not started", + legacy: "kyc not started", + }); + }); + }); }); }); diff --git a/server/test/hooks/persona.test.ts b/server/test/hooks/persona.test.ts index 8c4ef4d7e1..a8f4cab247 100644 --- a/server/test/hooks/persona.test.ts +++ b/server/test/hooks/persona.test.ts @@ -326,7 +326,7 @@ describe("with reference", () => { 'data/attributes/status Invalid type: Expected ("Approved" | "Declined" | "Open" | "Pending") but received "approved"', 'data/relationships/caseTemplate Invalid key: Expected "caseTemplate" but received undefined', 'data/relationships/inquiries Invalid key: Expected "inquiries" but received undefined', - 'data/relationships/inquiryTemplate/data/id Invalid type: Expected ("itmpl_FTHNSXqJjoMvUTBc85QECGHogrZx" | "itmpl_HSA4M3SwiH2wiWVpvFn4ny1kPws2" | "itmpl_8uim4FvD5P3kFpKHX37CW817" | "itmpl_gjYZshv7bc1DK8DNL8YYTQ1muejo") but received "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"', + 'data/relationships/inquiryTemplate/data/id Invalid type: Expected ("itmpl_FTHNSXqJjoMvUTBc85QECGHogrZx" | "itmpl_HSA4M3SwiH2wiWVpvFn4ny1kPws2" | "itmpl_8uim4FvD5P3kFpKHX37CW817" | "itmpl_gjYZshv7bc1DK8DNL8YYTQ1muejo" | "itmpl_AWN3X1RhJtk9rW529jr9nuoh1Ks7Km") but received "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"', ], }); expect(panda.createUser).not.toHaveBeenCalled(); @@ -379,7 +379,7 @@ describe("with reference", () => { 'data/attributes/status Invalid type: Expected ("Approved" | "Declined" | "Open" | "Pending") but received "approved"', 'data/relationships/caseTemplate Invalid key: Expected "caseTemplate" but received undefined', 'data/relationships/inquiries Invalid key: Expected "inquiries" but received undefined', - 'data/relationships/inquiryTemplate/data/id Invalid type: Expected ("itmpl_FTHNSXqJjoMvUTBc85QECGHogrZx" | "itmpl_HSA4M3SwiH2wiWVpvFn4ny1kPws2" | "itmpl_8uim4FvD5P3kFpKHX37CW817" | "itmpl_gjYZshv7bc1DK8DNL8YYTQ1muejo") but received "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"', + 'data/relationships/inquiryTemplate/data/id Invalid type: Expected ("itmpl_FTHNSXqJjoMvUTBc85QECGHogrZx" | "itmpl_HSA4M3SwiH2wiWVpvFn4ny1kPws2" | "itmpl_8uim4FvD5P3kFpKHX37CW817" | "itmpl_gjYZshv7bc1DK8DNL8YYTQ1muejo" | "itmpl_AWN3X1RhJtk9rW529jr9nuoh1Ks7Km") but received "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"', ], }); expect(panda.createUser).not.toHaveBeenCalled(); @@ -432,7 +432,7 @@ describe("with reference", () => { 'data/attributes/status Invalid type: Expected ("Approved" | "Declined" | "Open" | "Pending") but received "approved"', 'data/relationships/caseTemplate Invalid key: Expected "caseTemplate" but received undefined', 'data/relationships/inquiries Invalid key: Expected "inquiries" but received undefined', - 'data/relationships/inquiryTemplate/data/id Invalid type: Expected ("itmpl_FTHNSXqJjoMvUTBc85QECGHogrZx" | "itmpl_HSA4M3SwiH2wiWVpvFn4ny1kPws2" | "itmpl_8uim4FvD5P3kFpKHX37CW817" | "itmpl_gjYZshv7bc1DK8DNL8YYTQ1muejo") but received "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"', + 'data/relationships/inquiryTemplate/data/id Invalid type: Expected ("itmpl_FTHNSXqJjoMvUTBc85QECGHogrZx" | "itmpl_HSA4M3SwiH2wiWVpvFn4ny1kPws2" | "itmpl_8uim4FvD5P3kFpKHX37CW817" | "itmpl_gjYZshv7bc1DK8DNL8YYTQ1muejo" | "itmpl_AWN3X1RhJtk9rW529jr9nuoh1Ks7Km") but received "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"', ], }); expect(panda.createUser).not.toHaveBeenCalled(); @@ -668,6 +668,18 @@ describe("ignored template", () => { expect(panda.createUser).not.toHaveBeenCalled(); expect(persona.addDocument).not.toHaveBeenCalled(); }); + + it("returns ok for business template", async () => { + const response = await appClient.index.$post({ + header: { "persona-signature": "t=1,v1=sha256" }, + json: ignoredPayload(persona.PANDA_BUSINESS_TEMPLATE), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(panda.createUser).not.toHaveBeenCalled(); + expect(persona.addDocument).not.toHaveBeenCalled(); + }); }); const cardLimitUpdateResponse = { data: { id: "acct_case" } }; diff --git a/server/test/mocks/panda.ts b/server/test/mocks/panda.ts index 0aeec6332b..8035df27d8 100644 --- a/server/test/mocks/panda.ts +++ b/server/test/mocks/panda.ts @@ -17,12 +17,18 @@ const mock = vi.hoisted(() => { instance = value; }, panda: { + businessApplication: (...parameters: Parameters) => + current().businessApplication(...parameters), createCard: (...parameters: Parameters) => current().createCard(...parameters), + createCompanyApplication: (...parameters: Parameters) => + current().createCompanyApplication(...parameters), createUser: (...parameters: Parameters) => current().createUser(...parameters), getApplicationStatus: (...parameters: Parameters) => current().getApplicationStatus(...parameters), getCard: (...parameters: Parameters) => current().getCard(...parameters), getCards: (...parameters: Parameters) => current().getCards(...parameters), + getCompanyApplication: (...parameters: Parameters) => + current().getCompanyApplication(...parameters), getNonce: (...parameters: Parameters) => current().getNonce(...parameters), getPIN: (...parameters: Parameters) => current().getPIN(...parameters), getProcessorDetails: (...parameters: Parameters) => diff --git a/server/test/utils/authChallenge.test.ts b/server/test/utils/authChallenge.test.ts new file mode 100644 index 0000000000..b51d471563 --- /dev/null +++ b/server/test/utils/authChallenge.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { decode, encode } from "../../utils/authChallenge"; + +describe("auth challenge", () => { + it("preserves the legacy format for ordinary challenges", () => { + expect(encode("challenge")).toBe("challenge"); + expect(decode(encode("challenge"))).toStrictEqual({ challenge: "challenge" }); + }); + + it("stores the account type for business challenges", () => { + const challenge = encode("challenge", "business"); + + expect(challenge).toBe(JSON.stringify({ challenge: "challenge", accountType: "business" })); + expect(decode(challenge)).toStrictEqual({ challenge: "challenge", accountType: "business" }); + }); +}); diff --git a/server/test/utils/panda.test.ts b/server/test/utils/panda.test.ts index 6d91d1b31d..52a220c775 100644 --- a/server/test/utils/panda.test.ts +++ b/server/test/utils/panda.test.ts @@ -9,7 +9,9 @@ import { usdcAddress } from "@exactly/common/generated/chain"; import { PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; import { Address } from "@exactly/common/validation"; -import createPanda, * as Panda from "../../utils/panda"; +import createPanda, { BusinessApplicationError } from "../../utils/panda"; +import * as Panda from "../../utils/panda"; +import createPersona from "../../utils/persona"; import ServiceError from "../../utils/ServiceError"; const chainMock = vi.hoisted(() => ({ id: 0, testnet: true as boolean | undefined })); @@ -22,6 +24,7 @@ vi.mock("@exactly/common/generated/chain", async (importOriginal) => ({ })); const panda = { ...Panda, ...createPanda({ key: "panda", url: "https://panda.test" }) }; +const persona = createPersona("persona", "https://persona.test"); describe("panda request", () => { it("extracts entity from url on not found", async () => { @@ -71,6 +74,347 @@ describe("panda request", () => { }); }); +describe("business application", () => { + const account = parse(Address, padHex("0xb0b", { size: 20 })); + const fields = { + i_company_name: { value: "Account Acme" }, + company_description: { value: "Account software" }, + company_industry: { value: "541511" }, + company_registration_number: { value: "123" }, + company_tax_id: { value: "456" }, + company_website: { value: "https://example.com" }, + i_auth_user_name: { value: "Jane" }, + i_auth_user_last_name: { value: "Doe" }, + birth_date: { value: "1990-01-01" }, + id_number: { value: "123456789" }, + id_country: { value: "US" }, + collected_email_address: { value: "jane@example.com" }, + street_1: { value: "1 Main St" }, + city: { value: "New York" }, + subdivision: { value: "NY" }, + postal_code: { value: "10001" }, + country_code: { value: "US" }, + street_1_1: { value: "1 Main St" }, + city_1: { value: "New York" }, + subdivision_1: { value: "NY" }, + postal_code_1: { value: "10001" }, + country_code_1: { value: "US" }, + }; + const address = { + line1: "1 Main St", + city: "New York", + region: "NY", + postalCode: "10001", + countryCode: "US", + }; + + function mockBusiness({ + inquiryFields = {}, + accountFields = fields, + accountReferenceId = "reference-id", + inquiryStatus = "completed", + }: { + accountFields?: Record; + accountReferenceId?: string; + inquiryFields?: Record; + inquiryStatus?: + | "approved" + | "completed" + | "created" + | "declined" + | "expired" + | "failed" + | "needs_review" + | "pending"; + } = {}) { + vi.spyOn(persona, "getInquiry").mockResolvedValue({ + id: "inquiry-id", + type: "inquiry", + attributes: { + status: inquiryStatus, + "reference-id": "reference-id", + fields: inquiryFields, + }, + }); + vi.spyOn(persona, "getAccount").mockResolvedValue({ + id: "account-id", + type: "account", + attributes: { "reference-id": accountReferenceId, fields: accountFields }, + relationships: { "account-type": { data: { id: "acttp_company" } } }, + }); + } + + it("prefers account fields over inquiry fields", async () => { + mockBusiness({ + inquiryFields: { "company-description": { value: "Inquiry software" } }, + }); + + const application = await panda.businessApplication("reference-id", account, "127.0.0.1", persona); + + const person = { + firstName: "Jane", + lastName: "Doe", + birthDate: "1990-01-01", + nationalId: "123456789", + countryOfIssue: "US", + email: "jane@example.com", + address: { ...address, line2: undefined }, + }; + expect(application).toStrictEqual({ + initialUser: { ...person, ipAddress: "127.0.0.1", walletAddress: account }, + name: "Account Acme", + address: { ...address, line2: undefined }, + entity: { + name: "Account Acme", + description: "Account software", + industry: "541511", + registrationNumber: "123", + taxId: "456", + website: "https://example.com", + }, + representatives: [person], + ultimateBeneficialOwners: [], + sourceKey: "EXA", + externalId: "reference-id", + }); + }); + + it("falls back to inquiry fields when account field is missing", async () => { + const incompleteFields: Record = { ...fields }; + delete incompleteFields.company_description; + mockBusiness({ + accountFields: incompleteFields, + inquiryFields: { "company-description": { value: "Inquiry software" } }, + }); + + const application = await panda.businessApplication("reference-id", account, "127.0.0.1", persona); + + expect(application.entity.description).toBe("Inquiry software"); + }); + + it("rejects a mismatched reference id", async () => { + mockBusiness(); + + await expect(panda.businessApplication("other-reference-id", account, "127.0.0.1", persona)).rejects.toThrow( + BusinessApplicationError, + ); + }); + + it("rejects a missing business account", async () => { + mockBusiness(); + vi.mocked(persona.getAccount).mockImplementationOnce(() => Promise.resolve(undefined)); // eslint-disable-line unicorn/no-useless-undefined + + await expect(panda.businessApplication("reference-id", account, "127.0.0.1", persona)).rejects.toMatchObject({ + message: "business account not started", + code: "not started", + }); + }); + + it("rejects a business account field with the wrong type", async () => { + mockBusiness({ accountFields: { ...fields, i_company_name: { value: 123 } } }); + + await expect(panda.businessApplication("reference-id", account, "127.0.0.1", persona)).rejects.toMatchObject({ + message: "invalid business Persona fields", + code: "bad request", + }); + }); + + it.each([[null], [""], [" "], ["Suite 2"]] as const)("normalizes a line2 value %s", async (line2) => { + mockBusiness({ accountFields: { ...fields, street_2: { value: line2 } } }); + const application = await panda.businessApplication("reference-id", account, "127.0.0.1", persona); + expect(application.address.line2).toBe(typeof line2 === "string" ? line2.trim() || undefined : undefined); + }); + + it("rejects a malformed line2 value", async () => { + mockBusiness({ accountFields: { ...fields, street_2: { value: 123 } } }); + + await expect(panda.businessApplication("reference-id", account, "127.0.0.1", persona)).rejects.toMatchObject({ + message: "invalid business Persona fields", + code: "bad request", + }); + }); + + it("rejects malformed business account attributes", async () => { + vi.spyOn(persona, "getInquiry").mockResolvedValue({ + id: "inquiry-id", + type: "inquiry", + attributes: { status: "completed", "reference-id": "reference-id", fields: {} }, + }); + vi.spyOn(persona, "getAccount").mockResolvedValue({ + id: "account-id", + type: "account", + attributes: { "reference-id": "reference-id", fields: { company_name: "malformed" } }, + relationships: { "account-type": { data: { id: "acttp_company" } } }, + } as never); + await expect(panda.businessApplication("reference-id", account, "127.0.0.1", persona)).rejects.toMatchObject({ + message: "business account is not complete", + code: "processing", + }); + }); + + it("rejects semantic company-field validation failures", async () => { + mockBusiness({ accountFields: { ...fields, company_website: { value: "not a url" } } }); + await expect(panda.businessApplication("reference-id", account, "127.0.0.1", persona)).rejects.toMatchObject({ + message: "invalid business Persona fields", + code: "bad request", + }); + }); + + it("rejects a mismatched business account", async () => { + mockBusiness({ accountReferenceId: "other-reference-id" }); + + await expect(panda.businessApplication("reference-id", account, "127.0.0.1", persona)).rejects.toMatchObject({ + message: "business account is not complete", + code: "processing", + }); + }); + + it("rejects a missing client IP address", async () => { + mockBusiness(); + + await expect(panda.businessApplication("reference-id", account, undefined, persona)).rejects.toThrow( + BusinessApplicationError, + ); + }); + + it.each([ + ["created", "business inquiry is not started", "not started"], + ["expired", "business inquiry is not started", "not started"], + ["pending", "business inquiry is not started", "not started"], + ["failed", "business inquiry failed", "bad kyb"], + ["declined", "business inquiry failed", "bad kyb"], + ["needs_review", "business inquiry is not complete", "processing"], + ] as const)("rejects a %s inquiry", async (inquiryStatus, message, code) => { + mockBusiness({ inquiryStatus }); + + await expect(panda.businessApplication("reference-id", account, "127.0.0.1", persona)).rejects.toMatchObject({ + message, + code, + }); + }); + + it("preserves the verification link signature", async () => { + const externalId = "0x7fE85c89A8406B6Fc911f064e344fac2DFfD1C1b"; + const signature = "x".repeat(156); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + Response.json({ + id: "company-1", + externalId, + applicationStatus: "needsVerification", + applicationExternalVerificationLink: { + url: "https://cardmemberportal.com/kyc", + params: { userId: "0e3c467c-01e3-4fe8-8778-1c88e02fd000", signature }, + }, + }), + ); + const application = await panda.getCompanyApplication(externalId); + + expect(application?.applicationExternalVerificationLink).toStrictEqual({ + url: "https://cardmemberportal.com/kyc", + params: { userId: "0e3c467c-01e3-4fe8-8778-1c88e02fd000", signature }, + }); + }); + + it("returns nothing when the company application does not exist", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("Not Found", { status: 404 })); + + await expect(panda.getCompanyApplication("0x269E1Eb82cc3c3Ee64b47cDA34acAED8203aF066")).resolves.toBeUndefined(); + }); + + it("rethrows non-404 company application errors", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("server error", { status: 500 })); + await expect(panda.getCompanyApplication("0xbeef")).rejects.toMatchObject({ status: 500 }); + }); + + it("rejects a company application for another reference id", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + Response.json({ id: "company-1", externalId: "0xdead", applicationStatus: "pending" }), + ); + + await expect(panda.getCompanyApplication("0xbeef")).rejects.toThrow("panda company external id mismatch"); + }); + + it("accepts a company application without a reference id", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + Response.json({ id: "company-1", applicationStatus: "pending" }), + ); + + await expect(panda.getCompanyApplication("0xbeef")).resolves.toMatchObject({ id: "company-1" }); + }); + + it("creates a company application with and without an idempotency key", async () => { + const application = { + initialUser: { + firstName: "Jane", + lastName: "Doe", + birthDate: "1990-01-01", + nationalId: "123456789", + countryOfIssue: "US", + email: "jane@example.com", + ipAddress: "127.0.0.1", + walletAddress: account, + address, + }, + name: "Account Acme", + address, + entity: { + name: "Account Acme", + description: "Account software", + industry: "541511", + registrationNumber: "123", + taxId: "456", + website: "https://example.com", + }, + representatives: [], + ultimateBeneficialOwners: [], + sourceKey: "EXA", + externalId: "reference-id", + }; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(Response.json({ id: "company-1", name: "Account Acme", address })) + .mockResolvedValueOnce(Response.json({ id: "company-1", name: "Account Acme", address })); + + await panda.createCompanyApplication(application, { idempotencyKey: "business-application:reference-id" }); + await panda.createCompanyApplication(application); + + const [keyed, plain] = fetchSpy.mock.calls.slice(-2); + if (!keyed || !plain) throw new Error("missing panda requests"); + const raw = keyed[1]?.body; + if (typeof raw !== "string") throw new Error("missing panda request body"); + expect(JSON.parse(raw)).toStrictEqual(application); + expect(keyed[0]).toEqual(expect.stringContaining("/issuing/applications/company")); + expect(keyed[1]?.headers).toMatchObject({ + "Idempotency-Key": "business-application:reference-id", + }); + expect(plain[1]?.headers).not.toHaveProperty("Idempotency-Key"); + }); +}); + +describe("mutex", () => { + it("purges the mutex entry after the exclusive run", async () => { + const account = parse(Address, "0x29684075a3C86ea11D9964BcAf0F956e801396bD"); + await Panda.withMutex(account, async () => { + expect(Panda.getMutex(account)).toBeDefined(); + }); + expect(Panda.getMutex(account)).toBeUndefined(); + }); + + it("serializes concurrent exclusive runs for the same account", async () => { + const account = parse(Address, "0x29684075a3C86ea11D9964BcAf0F956e801396bD"); + const order: string[] = []; + const run = (name: string) => + Panda.withMutex(account, async () => { + order.push(`${name}:start`); + await new Promise((resolve) => setTimeout(resolve, 10)); + order.push(`${name}:end`); + }); + await Promise.all([run("a"), run("b")]); + expect(order).toEqual(["a:start", "a:end", "b:start", "b:end"]); + expect(Panda.getMutex(account)).toBeUndefined(); + }); +}); + describe("withdrawals", () => { const account = parse(Address, padHex("0xb0b", { size: 20 })); diff --git a/server/test/utils/persona.test.ts b/server/test/utils/persona.test.ts index e8a59d5dcf..a3240bed64 100644 --- a/server/test/utils/persona.test.ts +++ b/server/test/utils/persona.test.ts @@ -170,6 +170,67 @@ describe("is missing or null util", () => { }); }); +describe("createInquiry", () => { + it("selects the configured Persona account type when provided", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + Response.json({ + data: { + id: "inquiry-id", + type: "inquiry", + attributes: { status: "created", "reference-id": "reference-id" }, + }, + }), + ); + + await persona.createInquiry("reference-id", "template-id", { accountTypeId: "acttp_company" }); + + const body = fetchSpy.mock.calls[0]?.[1]?.body; + if (typeof body !== "string") throw new Error("missing request body"); + expect(JSON.parse(body)).toMatchObject({ + meta: { + "auto-create-account": true, + "auto-create-account-reference-id": "reference-id", + "auto-create-account-type-id": "acttp_company", + }, + }); + }); +}); + +describe("getInquiry", () => { + it("rejects duplicate business inquiries", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + Response.json({ + data: [ + { id: "inquiry-1", type: "inquiry", attributes: { status: "approved", "reference-id": "reference-id" } }, + { id: "inquiry-2", type: "inquiry", attributes: { status: "approved", "reference-id": "reference-id" } }, + ], + }), + ); + + await expect(persona.getInquiry("reference-id", persona.PANDA_BUSINESS_TEMPLATE)).rejects.toThrow( + "multiple persona business inquiries", + ); + expect(fetchSpy.mock.lastCall?.[0]).toContain("/inquiries?page[size]=100"); + }); + + it("rejects duplicate non-approved business inquiries", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(Response.json({ data: [] })) + .mockResolvedValueOnce( + Response.json({ + data: [ + { id: "inquiry-1", type: "inquiry", attributes: { status: "pending", "reference-id": "reference-id" } }, + { id: "inquiry-2", type: "inquiry", attributes: { status: "expired", "reference-id": "reference-id" } }, + ], + }), + ); + + await expect(persona.getInquiry("reference-id", persona.PANDA_BUSINESS_TEMPLATE)).rejects.toThrow( + "multiple persona business inquiries", + ); + }); +}); + describe("evaluateAccount", () => { let fetchSpy: MockInstance; beforeEach(() => { @@ -575,6 +636,33 @@ describe("evaluateAccount", () => { }); }); + describe("business", () => { + it("returns the business template without a persona lookup", async () => { + await expect(persona.getPendingInquiryTemplate("reference-id", "business")).resolves.toBe( + persona.PANDA_BUSINESS_TEMPLATE, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns the business template when the account is missing", async () => { + await expect(persona.evaluateAccount({ data: [] }, "business")).resolves.toBe(persona.PANDA_BUSINESS_TEMPLATE); + }); + + it("returns undefined when the business account is complete", async () => { + const account = { + data: [ + { + id: "business", + type: "account", + attributes: {}, + relationships: { "account-type": { data: { id: "business" } } }, + }, + ], + }; + await expect(persona.evaluateAccount(account as never, "business")).resolves.toBeUndefined(); + }); + }); + describe("bridge", () => { it("returns panda template when account not found", async () => { const result = await persona.evaluateAccount({ data: [] }, "bridge"); @@ -833,6 +921,80 @@ describe("getUnknownAccount", () => { }); }); +describe("getAccount", () => { + let fetchSpy: MockInstance; + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not return a personal account for a business lookup", async () => { + fetchSpy.mockResolvedValueOnce( + Response.json({ + data: [ + { + id: "personal-account", + type: "account", + attributes: { "reference-id": "ref_123", "account-type-name": "User" }, + relationships: { "account-type": { data: { type: "account-type", id: "acttp_user" } } }, + }, + ], + }), + ); + + await expect(persona.getAccount("ref_123", "business")).resolves.toBeUndefined(); + }); + + it("returns the configured business account", async () => { + fetchSpy.mockResolvedValueOnce( + Response.json({ + data: [ + { + id: "personal-account", + type: "account", + attributes: { "reference-id": "ref_123", "account-type-name": "User" }, + relationships: { "account-type": { data: { type: "account-type", id: "acttp_user" } } }, + }, + { + id: "business-account", + type: "account", + attributes: { "reference-id": "ref_123", "account-type-name": "Company" }, + relationships: { "account-type": { data: { type: "account-type", id: "acttp_company" } } }, + }, + ], + }), + ); + + await expect(persona.getAccount("ref_123", "business")).resolves.toMatchObject({ id: "business-account" }); + expect(fetchSpy.mock.calls[0]?.[0]).toContain("/accounts?page[size]=100&filter[reference-id]=ref_123"); + }); + + it("rejects multiple business accounts", async () => { + fetchSpy.mockResolvedValueOnce( + Response.json({ + data: [ + { + id: "business-account-1", + type: "account", + attributes: { "reference-id": "ref_123" }, + relationships: { "account-type": { data: { type: "account-type", id: "acttp_company" } } }, + }, + { + id: "business-account-2", + type: "account", + attributes: { "reference-id": "ref_123" }, + relationships: { "account-type": { data: { type: "account-type", id: "acttp_company" } } }, + }, + ], + }), + ); + + await expect(persona.getAccount("ref_123", "business")).rejects.toThrow("multiple persona business accounts"); + }); +}); + describe("getCardLimitStatus", () => { let fetchSpy: MockInstance; beforeEach(() => { diff --git a/server/utils/authChallenge.ts b/server/utils/authChallenge.ts new file mode 100644 index 0000000000..469af597db --- /dev/null +++ b/server/utils/authChallenge.ts @@ -0,0 +1,13 @@ +import { literal, object, optional, parse, string } from "valibot"; + +export function encode(challenge: string, accountType?: "business") { + return accountType ? JSON.stringify({ challenge, accountType }) : challenge; +} + +export function decode(value: string) { + try { + return parse(object({ challenge: string(), accountType: optional(literal("business")) }), JSON.parse(value)); + } catch (error) { + return error instanceof SyntaxError ? { challenge: value } : undefined; + } +} diff --git a/server/utils/createCredential.ts b/server/utils/createCredential.ts index a2447800e0..41461dee12 100644 --- a/server/utils/createCredential.ts +++ b/server/utils/createCredential.ts @@ -1,7 +1,8 @@ import { captureException, setUser } from "@sentry/node"; import { setSignedCookie } from "hono/cookie"; +import { randomBytes } from "node:crypto"; import { parse } from "valibot"; -import { hexToBytes, isAddress, zeroAddress } from "viem"; +import { bytesToHex, hexToBytes, isAddress, zeroAddress } from "viem"; import { optimism } from "viem/chains"; import AUTH_EXPIRY from "@exactly/common/AUTH_EXPIRY"; @@ -38,15 +39,15 @@ export default function createCredential({ return async function credential( c: Context, credentialId: C, - options?: { factory?: Address; ip?: IpAddress; source?: string; webauthn?: WebAuthnCredential }, + options?: { factory?: Address; ip?: IpAddress; salt?: Address; source?: string; webauthn?: WebAuthnCredential }, ) { if (chain.id === optimism.id && isAddress(credentialId)) throw new Error("siwe registration disabled"); // TODO remove const factory = options?.factory ?? exaAccountFactoryAddress; + const salt = options?.salt ?? parse(Address, zeroAddress); const publicKey = options?.webauthn?.publicKey ?? (isAddress(credentialId) ? new Uint8Array(hexToBytes(credentialId)) : undefined); if (!publicKey) throw new Error("bad credential"); const { x, y } = decodePublicKey(publicKey); - const salt = parse(Address, zeroAddress); const account = deriveAddress(factory, { x, y, salt }); setUser({ id: account }); @@ -92,3 +93,12 @@ export default function createCredential({ return { credentialId, factory: parse(Address, factory), x, y, salt, auth: expires.getTime() }; }; } + +export function accountSalt(accountType?: "business") { + if (accountType !== "business") return parse(Address, zeroAddress); + return parse(Address, bytesToHex(randomBytes(20))); +} + +export function isBusinessSalt(value: Address) { + return value !== parse(Address, zeroAddress); +} diff --git a/server/utils/panda.ts b/server/utils/panda.ts index c531d1ffb8..0888b48e48 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -1,4 +1,5 @@ import { vValidator } from "@hono/valibot-validator"; +import { setContext } from "@sentry/node"; import { Mutex, withTimeout, type MutexInterface } from "async-mutex"; import { array, @@ -6,6 +7,8 @@ import { check, digits, email, + flatten, + ip, ipv4, ipv6, isoTimestamp, @@ -25,11 +28,16 @@ import { partial, picklist, pipe, + record, regex, + safeParse, string, transform, tuple, union, + unknown, + url as urlValidator, + uuid, variant, type BaseIssue, type BaseSchema, @@ -43,14 +51,18 @@ import { BASE_PRODUCT_ID, PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exa import { Address, Hex } from "@exactly/common/validation"; import { proposalManager } from "@exactly/plugin/deploy.json"; +import { FieldValue, PANDA_BUSINESS_TEMPLATE } from "./persona"; import ServiceError from "./ServiceError"; import verifySignature from "./verifySignature"; +import type createPersona from "./persona"; export default function panda({ key, url }: { key: string; url: string }) { return { createCard, + createCompanyApplication, createUser, getApplicationStatus, + getCompanyApplication, getCard, getCards, getNonce, @@ -68,6 +80,7 @@ export default function panda({ key, url }: { key: string; url: string }) { updateUser, verify, verifyPandaSignature, + businessApplication, }; async function createCard( @@ -110,6 +123,36 @@ export default function panda({ key, url }: { key: string; url: string }) { }) { return await request(object({ id: string() }), "/issuing/applications/user", {}, user, "POST", 10_000); } + function createCompanyApplication( + application: InferInput, + options: { idempotencyKey?: string } = {}, + ) { + return request( + CompanyApplicationResponse, + "/issuing/applications/company", + options.idempotencyKey ? { "Idempotency-Key": options.idempotencyKey } : {}, + parse(CreateCompanyApplicationRequest, application), + "POST", + 10_000, + ); + } + async function getCompanyApplication(externalId: string) { + const application = await request( + CompanyApplicationStatusResponse, + `/issuing/applications/company/external/${externalId}`, + {}, + undefined, + "GET", + 10_000, + ).catch((error: unknown) => { + if (error instanceof ServiceError && error.status === 404) return; + throw error; + }); + if (!application) return; + if (application.externalId != null && application.externalId !== externalId) + throw new Error("panda company external id mismatch"); + return application; + } async function getApplicationStatus(applicationId: string) { return request( ApplicationStatusResponse, @@ -699,6 +742,80 @@ export function signIssuerOp( message: { account, amount: amount < 0n ? -amount : amount, timestamp }, }); } +async function businessApplication( + credentialId: string, + accountAddress: Address, + ipAddress: string | undefined, + persona: ReturnType, +) { + if (!safeParse(pipe(string(), ip()), ipAddress).success) + throw new BusinessApplicationError("missing valid client IP address", "bad request"); + const inquiry = await persona.getInquiry(credentialId, PANDA_BUSINESS_TEMPLATE); + if (!inquiry) throw new BusinessApplicationError("business inquiry not started", "not started"); + if (inquiry.attributes["reference-id"] !== credentialId) + throw new BusinessApplicationError("business inquiry does not match credential", "bad request"); + switch (inquiry.attributes.status) { + case "created": + case "expired": + case "pending": + throw new BusinessApplicationError("business inquiry is not started", "not started"); + case "failed": + case "declined": + throw new BusinessApplicationError("business inquiry failed", "bad kyb"); + case "needs_review": + throw new BusinessApplicationError("business inquiry is not complete", "processing"); + case "approved": + case "completed": + break; + } + const account = await persona.getAccount(credentialId, "business"); + if (!account) throw new BusinessApplicationError("business account not started", "not started"); + const accountResult = safeParse(BusinessAccount, account.attributes); + if (!accountResult.success || accountResult.output["reference-id"] !== credentialId) + throw new BusinessApplicationError("business account is not complete", "processing"); + const fields = accountResult.output.fields; + for (const [inquiryName, inquiryField] of Object.entries(inquiry.attributes.fields ?? {})) { + const name = inquiryName.replaceAll("-", "_"); + if (fields[name]?.value == null && inquiryField.value != null) fields[name] = inquiryField; + } + const field = (name: keyof typeof keys) => requireField(fields, keys[name]); + const person = { + firstName: field("userFirstName"), + lastName: field("userLastName"), + birthDate: field("userBirthDate"), + nationalId: field("userNationalId"), + countryOfIssue: field("userCountryOfIssue"), + email: field("userEmail"), + address: toAddress(fields, "_1"), + }; + const companyName = field("companyName"); + const initialUser = { + ...person, + ipAddress, + walletAddress: accountAddress, + }; + const application = safeParse(CreateCompanyApplicationRequest, { + initialUser, + name: companyName, + address: toAddress(fields), + entity: { + name: companyName, + description: field("companyDescription"), + industry: field("companyIndustry"), + registrationNumber: field("companyRegistrationNumber"), + taxId: field("companyTaxId"), + website: field("companyWebsite"), + }, + representatives: [person], + ultimateBeneficialOwners: [], + sourceKey: "EXA", + externalId: credentialId, + }); + if (application.success) return application.output; + setContext("validation", { flatten: flatten(application.issues) }); + throw new BusinessApplicationError("invalid business Persona fields", "bad request"); +} + const mutexes = new Map(); export function createMutex(address: Address) { const mutex = withTimeout( @@ -711,6 +828,12 @@ export function createMutex(address: Address) { export function getMutex(address: Address) { return mutexes.get(address); } +export function withMutex(address: Address, fn: () => Promise) { + const mutex = getMutex(address) ?? createMutex(address); + return mutex.runExclusive(fn).finally(() => { + if (!mutex.isLocked()) mutexes.delete(address); + }); +} const AddressSchema = object({ line1: pipe(string(), minLength(1), maxLength(100)), @@ -722,6 +845,173 @@ const AddressSchema = object({ countryCode: pipe(string(), length(2), regex(/^[A-Z]{2}$/i)), }); +const CorporatePerson = object({ + firstName: pipe( + string(), + check((value) => value.trim().length > 0), + maxLength(50), + ), + lastName: pipe( + string(), + check((value) => value.trim().length > 0), + maxLength(50), + ), + birthDate: pipe(string(), regex(/^\d{4}-\d{2}-\d{2}$/)), + nationalId: pipe( + string(), + check((value) => value.trim().length > 0), + maxLength(50), + ), + countryOfIssue: pipe(string(), length(2), regex(/^[A-Z]{2}$/i)), + email: pipe(string(), email()), + address: AddressSchema, +}); + +const BusinessAccount = object({ + "reference-id": string(), + fields: record(string(), FieldValue), +}); + +const CreateCompanyApplicationRequest = object({ + initialUser: object({ + ...CorporatePerson.entries, + ipAddress: pipe(string(), maxLength(50), ip()), + walletAddress: Address, + }), + name: pipe( + string(), + check((value) => value.trim().length > 0), + maxLength(100), + ), + address: AddressSchema, + entity: object({ + name: pipe( + string(), + check((value) => value.trim().length > 0), + maxLength(100), + ), + description: pipe( + string(), + check((value) => value.trim().length > 0), + maxLength(500), + ), + industry: pipe(string(), regex(/^\d{6}$/)), + registrationNumber: pipe( + string(), + check((value) => value.trim().length > 0), + maxLength(100), + ), + taxId: pipe( + string(), + check((value) => value.trim().length > 0), + maxLength(100), + ), + website: pipe( + string(), + check((value) => value.trim().length > 0), + maxLength(255), + urlValidator(), + ), + }), + representatives: array(CorporatePerson), + ultimateBeneficialOwners: array(CorporatePerson), + sourceKey: string(), + externalId: string(), +}); + +const ApplicationLink = object({ + url: pipe(string(), urlValidator()), + params: object({ signature: string(), userId: pipe(string(), uuid()) }), +}); +const ApplicationReview = { + applicationReason: optional(nullable(string())), + applicationCompletionLink: optional(nullable(ApplicationLink)), + applicationExternalVerificationLink: optional(nullable(ApplicationLink)), +}; + +export const CompanyApplicationStatusResponse = object({ + id: string(), + externalId: optional(nullable(string())), + applicationStatus: optional( + nullable( + picklist([ + "needsVerification", + "needsInformation", + "manualReview", + "approved", + "canceled", + "pending", + "denied", + "locked", + ]), + ), + ), + ...ApplicationReview, +}); + +export const CompanyApplicationResponse = object({ + ...CompanyApplicationStatusResponse.entries, + name: string(), + address: AddressSchema, + ultimateBeneficialOwners: optional(nullable(array(object({ id: string(), ...ApplicationReview })))), + sourceKey: optional(nullable(string())), +}); + +export const businessCodes = ["bad kyb", "bad request", "not started", "processing"] as const; + +export class BusinessApplicationError extends Error { + constructor( + message: string, + readonly code: (typeof businessCodes)[number], + ) { + super(message); + } +} + +const keys = { + companyName: "i_company_name", + companyDescription: "company_description", + companyIndustry: "company_industry", + companyRegistrationNumber: "company_registration_number", + companyTaxId: "company_tax_id", + companyWebsite: "company_website", + userFirstName: "i_auth_user_name", + userLastName: "i_auth_user_last_name", + userBirthDate: "birth_date", + userNationalId: "id_number", + userCountryOfIssue: "id_country", + userEmail: "collected_email_address", +}; + +function requireField(fields: Record | undefined, name: string) { + const value = fields?.[name]?.value; + if (value == null || (typeof value === "string" && value.trim().length === 0)) + throw new BusinessApplicationError("business account is not complete", "processing"); + if (typeof value !== "string") { + setContext("validation", { field: name }); + throw new BusinessApplicationError("invalid business Persona fields", "bad request"); + } + return value; +} + +function toAddress(fields: Record | undefined, suffix = "") { + const field = (name: string) => requireField(fields, `${name}${suffix}`); + const line2 = fields?.[`street_2${suffix}`]?.value; + return { + line1: field("street_1"), + line2: + line2 == null + ? undefined + : typeof line2 === "string" + ? line2.trim() || undefined + : requireField(fields, `street_2${suffix}`), + city: field("city"), + region: field("subdivision"), + postalCode: field("postal_code"), + countryCode: field("country_code"), + }; +} + export const Application = object({ email: pipe( string(), diff --git a/server/utils/persona.ts b/server/utils/persona.ts index ea0be716ef..2fbdef61b7 100644 --- a/server/utils/persona.ts +++ b/server/utils/persona.ts @@ -16,6 +16,7 @@ import { optional, picklist, pipe, + record, safeParse, string, unknown, @@ -37,11 +38,17 @@ export const CARD_LIMIT_CASE_TEMPLATE = "ctmpl_5cCoj56PD6NpsX3H3ZoMynZVfXbF"; // export const CARD_LIMIT_TEMPLATE = "itmpl_HSA4M3SwiH2wiWVpvFn4ny1kPws2"; // cspell:ignore itmpl_HSA4M3SwiH2wiWVpvFn4ny1kPws2 export const CRYPTOMATE_TEMPLATE = "itmpl_8uim4FvD5P3kFpKHX37CW817"; export const PANDA_TEMPLATE = "itmpl_1igCJVqgf3xuzqKYD87HrSaDavU2"; +export const PANDA_BUSINESS_TEMPLATE = "itmpl_AWN3X1RhJtk9rW529jr9nuoh1Ks7Km"; export const MANTECA_TEMPLATE_EXTRA_FIELDS = "itmpl_gjYZshv7bc1DK8DNL8YYTQ1muejo"; export const MANTECA_TEMPLATE_WITH_ID_CLASS = "itmpl_TjaqJdQYkht17v645zNFUfkaWNan"; export const ADDRESS_TEMPLATE = "itmpl_FTHNSXqJjoMvUTBc85QECGHogrZx"; const PERSONA_API_VERSION = "2023-01-05"; +export function businessAccountTypeId() { + const accountTypeId = env.PERSONA_BUSINESS_ACCOUNT_TYPE_ID; + if (!accountTypeId) throw new Error("missing persona business account type id"); + return accountTypeId; +} export default function persona(key: string, url: string) { return { @@ -106,8 +113,11 @@ export default function persona(key: string, url: string) { function createInquiry( referenceId: string, templateId: string, - redirectURI?: string, - fields?: { "name-first": string; "name-last": string }, + options: { + accountTypeId?: string; + fields?: { "name-first": string; "name-last": string }; + redirectURI?: string; + } = {}, ) { return request( CreateInquiryResponse, @@ -116,11 +126,15 @@ export default function persona(key: string, url: string) { data: { attributes: { "inquiry-template-id": templateId, - "redirect-uri": `${redirectURI ?? appOrigin}/card`, - ...(fields && { fields }), + "redirect-uri": `${options.redirectURI ?? appOrigin}/card`, + ...(options.fields && { fields: options.fields }), }, }, - meta: { "auto-create-account": true, "auto-create-account-reference-id": referenceId }, + meta: { + "auto-create-account": true, + "auto-create-account-reference-id": referenceId, + ...(options.accountTypeId && { "auto-create-account-type-id": options.accountTypeId }), + }, }, "POST", 10_000, @@ -133,10 +147,22 @@ export default function persona(key: string, url: string) { | typeof CARD_LIMIT_TEMPLATE | typeof MANTECA_TEMPLATE_EXTRA_FIELDS | typeof MANTECA_TEMPLATE_WITH_ID_CLASS + | typeof PANDA_BUSINESS_TEMPLATE | typeof PANDA_TEMPLATE | undefined > { switch (scope) { + case "business": { + const result = safeParse(accountScopeSchemas[scope], unknownAccount); + if (!result.success) { + const notMissingFieldsIssues = result.issues.filter((issue) => !isMissingOrNull(issue)); + if (notMissingFieldsIssues.length === 0) return PANDA_BUSINESS_TEMPLATE; + setContext("validation", { ...result, flatten: flatten(result.issues) }); + throw new Error(scopeValidationErrors.INVALID_SCOPE_VALIDATION); + } + if (!result.output.data[0]) return PANDA_BUSINESS_TEMPLATE; + return; + } case "document": throw new Error("document account scope not supported"); case "cardLimit": @@ -212,13 +238,21 @@ export default function persona(key: string, url: string) { referenceId: string, scope: T, ): Promise | undefined> { + if (scope === "business") { + const { data } = await getAccounts(referenceId, "business"); + const accounts = data.filter( + (account) => account.relationships["account-type"].data.id === businessAccountTypeId(), + ); + if (accounts.length > 1) throw new Error("multiple persona business accounts"); + return accounts[0]; + } const { data } = await getAccounts(referenceId, scope); return data[0]; } function getAccounts(referenceId: string, scope: T) { return request, BaseIssue>( accountScopeSchemas[scope], - `/accounts?page[size]=1&filter[reference-id]=${referenceId}`, + `/accounts?page[size]=${scope === "business" ? 100 : 1}&filter[reference-id]=${referenceId}`, undefined, "GET", 10_000, @@ -267,21 +301,25 @@ export default function persona(key: string, url: string) { return getValidDocumentForManteca(documents, allowedIds); } async function getInquiry(referenceId: string, templateId: string) { + const business = templateId === PANDA_BUSINESS_TEMPLATE; + const size = business ? 100 : 1; const { data: approvedInquiries } = await request( GetInquiriesResponse, - `/inquiries?page[size]=1&filter[reference-id]=${referenceId}&filter[inquiry-template-id]=${templateId}&filter[status]=approved`, + `/inquiries?page[size]=${size}&filter[reference-id]=${referenceId}&filter[inquiry-template-id]=${templateId}&filter[status]=approved`, undefined, "GET", 10_000, ); + if (business && approvedInquiries.length > 1) throw new Error("multiple persona business inquiries"); if (approvedInquiries[0]) return approvedInquiries[0]; const { data: inquiries } = await request( GetInquiriesResponse, - `/inquiries?page[size]=1&filter[reference-id]=${referenceId}&filter[inquiry-template-id]=${templateId}`, + `/inquiries?page[size]=${size}&filter[reference-id]=${referenceId}&filter[inquiry-template-id]=${templateId}`, undefined, "GET", 10_000, ); + if (business && inquiries.length > 1) throw new Error("multiple persona business inquiries"); return inquiries[0]; } function getInquiryById(inquiryId: string) { @@ -294,6 +332,7 @@ export default function persona(key: string, url: string) { ); } async function getPendingInquiryTemplate(referenceId: string, scope: AccountScope) { + if (scope === "business") return PANDA_BUSINESS_TEMPLATE; const unknownAccount = await getUnknownAccount(referenceId); return evaluateAccount(unknownAccount, scope); } @@ -520,12 +559,25 @@ const CardLimitAccount = object({ }), }); +export const FieldValue = object({ value: unknown() }); + +const BusinessAccount = object({ + id: string(), + type: literal("account"), + attributes: object({ + "reference-id": optional(string()), + fields: optional(record(string(), FieldValue)), + }), + relationships: object({ "account-type": object({ data: object({ id: string() }) }) }), +}); + const accountScopeSchemas = { bridge: object({ data: array(BridgeAccount) }), basic: object({ data: array(BaseAccount) }), manteca: object({ data: array(MantecaAccount) }), document: object({ data: array(DocumentAccount) }), cardLimit: object({ data: array(CardLimitAccount) }), + business: object({ data: array(BusinessAccount) }), } as const; export type AccountScope = keyof typeof accountScopeSchemas; @@ -548,6 +600,7 @@ export const Inquiry = object({ attributes: object({ status: picklist(["created", "pending", "expired", "failed", "needs_review", "declined", "completed", "approved"]), "reference-id": string(), + fields: optional(record(string(), FieldValue)), }), }); diff --git a/server/vitest.config.mts b/server/vitest.config.mts index 22d57764e9..9f06a4e031 100644 --- a/server/vitest.config.mts +++ b/server/vitest.config.mts @@ -47,6 +47,7 @@ VuNOZKwaXFtqgA== PAX_API_URL: "https://pax.test", PAX_ASSOCIATE_ID_KEY: "pax", PERSONA_API_KEY: "persona", + PERSONA_BUSINESS_ACCOUNT_TYPE_ID: "acttp_company", PERSONA_URL: "https://persona.test", BRIDGE_WEBHOOK_PUBLIC_KEY: `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4f9QAuHfZxnrz+xXumvm