From 9a62409c645a2f8eac606fd1595b7b60949c93df Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Mon, 9 Feb 2026 16:20:20 -0300 Subject: [PATCH 01/12] =?UTF-8?q?=E2=9E=95=20server:=20install=20canonical?= =?UTF-8?q?ize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pnpm-lock.yaml | 9 +++++++++ server/package.json | 1 + 2 files changed, 10 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4397976b..b058b2b9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -778,6 +778,9 @@ importers: bullmq: specifier: ^5.71.1 version: 5.71.1 + canonicalize: + specifier: ^2.1.0 + version: 2.1.0 debug: specifier: ^4.4.3 version: 4.4.3 @@ -7194,6 +7197,10 @@ packages: caniuse-lite@1.0.30001781: resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + canonicalize@2.1.0: + resolution: {integrity: sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==} + hasBin: true + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -21929,6 +21936,8 @@ snapshots: caniuse-lite@1.0.30001781: {} + canonicalize@2.1.0: {} + ccount@2.0.1: {} chai@6.2.2: {} diff --git a/server/package.json b/server/package.json index a6391b032..06107c08f 100644 --- a/server/package.json +++ b/server/package.json @@ -47,6 +47,7 @@ "async-mutex": "^0.5.0", "better-auth": "^1.6.3", "bullmq": "^5.71.1", + "canonicalize": "^2.1.0", "debug": "^4.4.3", "drizzle-orm": "^0.45.2", "graphql": "^16.13.2", From 035e4408b15c3f1f9a92fc53d2fe4abac773de37 Mon Sep 17 00:00:00 2001 From: mainqueg Date: Wed, 23 Jul 2025 10:11:46 -0300 Subject: [PATCH 02/12] =?UTF-8?q?=E2=9C=A8=20server:=20implement=20kyc=20d?= =?UTF-8?q?ata=20submission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/four-numbers-worry.md | 5 + server/api/card.ts | 23 ++- server/api/kyc.ts | 200 +++++++++++++++++++++++- server/test/api/card.test.ts | 115 ++------------ server/test/api/kyc.test.ts | 252 ++++++++++++++++++++++++++++++- server/test/e2e.ts | 1 + server/utils/panda.ts | 117 +++++++++++++- 7 files changed, 605 insertions(+), 108 deletions(-) create mode 100644 .changeset/four-numbers-worry.md diff --git a/.changeset/four-numbers-worry.md b/.changeset/four-numbers-worry.md new file mode 100644 index 000000000..6d50428b8 --- /dev/null +++ b/.changeset/four-numbers-worry.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ implement kyc data submission diff --git a/server/api/card.ts b/server/api/card.ts index dee223d5d..3aaa86d50 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -31,7 +31,17 @@ import { Address } from "@exactly/common/validation"; import database, { cards, credentials } from "../database"; import auth from "../middleware/auth"; import { sendPushNotification } from "../utils/onesignal"; -import { autoCredit, createCard, getCard, getPIN, getSecrets, getUser, setPIN, updateCard } from "../utils/panda"; +import { + autoCredit, + createCard, + getApplicationStatus, + getCard, + getPIN, + getSecrets, + getUser, + setPIN, + updateCard, +} from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; import { getAccount } from "../utils/persona"; import { customer } from "../utils/sardine"; @@ -292,7 +302,12 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str 403: { description: "Forbidden", content: { - "application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) }, + "application/json": { + schema: resolver( + union([object({ code: literal("no panda") }), object({ code: literal("kyc not approved") })]), + { errorMode: "ignore" }, + ), + }, }, }, }, @@ -317,6 +332,10 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str setUser({ id: account }); if (!credential.pandaId) return c.json({ code: "no panda" }, 403); + const kyc = await getApplicationStatus(credential.pandaId); + if (kyc.applicationStatus !== "approved") { + return c.json({ code: "kyc not approved" }, 403); + } let isUpgradeFromPlatinum = credential.cards.some( ({ status, productId }) => status === "DELETED" && productId === PLATINUM_PRODUCT_ID, diff --git a/server/api/kyc.ts b/server/api/kyc.ts index f864858aa..2401160c6 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -2,8 +2,9 @@ import { captureException, setContext, setUser, startSpan } from "@sentry/node"; import createDebug from "debug"; import { eq } from "drizzle-orm"; import { Hono } from "hono"; -import { validator as vValidator } from "hono-openapi/valibot"; -import { literal, object, optional, parse, picklist, string } from "valibot"; +import { describeRoute } from "hono-openapi"; +import { resolver, validator as vValidator } from "hono-openapi/valibot"; +import { array, literal, metadata, object, optional, parse, picklist, pipe, string, union } from "valibot"; import { getAddress } from "viem"; import accountInit from "@exactly/common/accountInit"; @@ -17,6 +18,13 @@ import { Address } from "@exactly/common/validation"; import database, { credentials } from "../database/index"; import auth from "../middleware/auth"; import decodePublicKey from "../utils/decodePublicKey"; +import { + SubmitApplicationRequest as Application, + UpdateApplicationRequest as ApplicationUpdate, + getApplicationStatus, + submitApplication, + updateApplication, +} from "../utils/panda"; import { createInquiry, CRYPTOMATE_TEMPLATE, @@ -33,6 +41,19 @@ import validatorHook from "../utils/validatorHook"; const debug = createDebug("exa:kyc"); Object.assign(debug, { inspectOpts: { depth: undefined } }); +const KYCStatusResponse = object({ + code: pipe(string(), metadata({ examples: ["ok"] })), + legacy: pipe(string(), metadata({ examples: ["ok"] })), + status: pipe(string(), metadata({ examples: ["approved", "rejected"] })), + reason: pipe(string(), metadata({ examples: ["", "BAD_SELFIE"] })), +}); + +const BadRequestCodes = { + ALREADY_STARTED: "already started", + NOT_STARTED: "not started", + BAD_REQUEST: "bad request", +} as const; + export default new Hono() .get( "/", @@ -184,6 +205,174 @@ export default new Hono() throw new Error("Unknown inquiry status"); } }, + ) + .post( + "/application", + auth(), + describeRoute({ + summary: "Submit KYC application", + description: "Submit information for KYC application", + tags: ["KYC"], + responses: { + 200: { + description: "KYC application submitted successfully", + content: { + "application/json": { + schema: resolver(buildBaseResponse("ok"), { errorMode: "ignore" }), + }, + }, + }, + 400: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + union([ + buildBaseResponse(BadRequestCodes.ALREADY_STARTED), + object({ + ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, + message: optional(array(string())), + }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + validateResponse: true, + }), + vValidator("json", Application, validatorHook({ debug })), + async (c) => { + const { credentialId } = c.req.valid("cookie"); + const payload = c.req.valid("json"); + const credential = await database.query.credentials.findFirst({ + columns: { id: true, account: true, pandaId: 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 (credential.pandaId) { + return c.json({ code: BadRequestCodes.ALREADY_STARTED, legacy: BadRequestCodes.ALREADY_STARTED }, 400); + } + + const application = await submitApplication(payload); + await database + .update(credentials) + .set({ pandaId: application.id, source: "uphold" }) // TODO get source from signer + .where(eq(credentials.id, credentialId)); + return c.json({ code: "ok", legacy: "ok" }, 200); + }, + ) + .patch( + "/application", + auth(), + describeRoute({ + summary: "Update KYC application", + description: "Update the KYC application", + tags: ["KYC"], + responses: { + 200: { + description: "KYC application updated successfully", + content: { + "application/json": { + schema: resolver(buildBaseResponse("ok"), { errorMode: "ignore" }), + }, + }, + }, + 400: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + union([ + buildBaseResponse(BadRequestCodes.NOT_STARTED), + object({ + ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, + message: optional(array(string())), + }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + validateResponse: true, + }), + vValidator("json", ApplicationUpdate, validatorHook({ debug })), + async (c) => { + const { credentialId } = c.req.valid("cookie"); + const payload = c.req.valid("json"); + const credential = await database.query.credentials.findFirst({ + columns: { id: true, account: true, pandaId: 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 (!credential.pandaId) { + return c.json({ code: BadRequestCodes.NOT_STARTED, legacy: BadRequestCodes.NOT_STARTED }, 400); + } + await updateApplication(credential.pandaId, payload); + return c.json({ code: "ok", legacy: "ok" }, 200); + }, + ) + .get( + "/application", + auth(), + describeRoute({ + summary: "Get KYC application status", + description: "Get the status of the KYC application", + tags: ["KYC"], + responses: { + 200: { + description: "KYC application status", + content: { + "application/json": { + schema: resolver(KYCStatusResponse, { errorMode: "ignore" }), + }, + }, + }, + 400: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + union([ + buildBaseResponse(BadRequestCodes.NOT_STARTED), + object({ + ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, + message: optional(array(string())), + }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + }), + async (c) => { + const { credentialId } = c.req.valid("cookie"); + const credential = await database.query.credentials.findFirst({ + columns: { id: true, account: true, pandaId: 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 (!credential.pandaId) { + return c.json({ code: BadRequestCodes.NOT_STARTED, legacy: BadRequestCodes.NOT_STARTED }, 400); + } + const status = await getApplicationStatus(credential.pandaId); + return c.json( + { code: "ok", legacy: "ok", status: status.applicationStatus, reason: status.applicationReason ?? "unknown" }, + 200, + ); + }, ); async function isLegacy( @@ -216,3 +405,10 @@ async function generateInquiryTokens(inquiryId: string): Promise<{ inquiryId: st const { meta: sessionTokenMeta } = await resumeInquiry(inquiryId); return { inquiryId, sessionToken: sessionTokenMeta["session-token"] }; } + +function buildBaseResponse(example = "string") { + return object({ + code: pipe(string(), metadata({ examples: [example] })), + legacy: pipe(string(), metadata({ examples: [example] })), + }); +} diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index 7f5c24490..8928c988e 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -95,6 +95,7 @@ describe("authenticated", () => { afterEach(() => vi.resetAllMocks()); it("returns 404 card not found", async () => { + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, { headers: { "test-credential-id": "404" } }, @@ -115,14 +116,13 @@ describe("authenticated", () => { }); it("returns panda card as default platinum product", async () => { + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); - vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); - const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, { headers: { "test-credential-id": "default" } }, @@ -190,11 +190,6 @@ describe("authenticated", () => { factory: inject("ExaAccountFactory"), }, ]); - await database.insert(cards).values([{ id: `card-${foo}`, credentialId: foo, lastFour: "4567" }]); - - vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); - vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); - vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -202,6 +197,7 @@ describe("authenticated", () => { ); expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); }); it("returns 403 when panda user is not found", async () => { @@ -344,6 +340,7 @@ describe("authenticated", () => { }); it("returns 403 when panda user exists but is not approved", async () => { + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "denied" }); const credentialId = "not-approved"; await database.insert(credentials).values({ id: credentialId, @@ -353,104 +350,13 @@ describe("authenticated", () => { pandaId: credentialId, }); - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 403, - text: () => - Promise.resolve('{"message":"User exists, but is not approved","error":"ForbiddenError","statusCode":403}'), - } as Response); - - const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); - expect(captureException).not.toHaveBeenCalled(); - }); - - it("returns 403 when createCard fails with plain-text not approved", async () => { - const credentialId = "not-approved-plain"; - await database.insert(credentials).values({ - id: credentialId, - publicKey: new Uint8Array(), - account: padHex("0x4043", { size: 20 }), - factory: inject("ExaAccountFactory"), - pandaId: credentialId, - }); - - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 403, - text: () => Promise.resolve("user exists but is not approved"), - } as Response); - const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); + await expect(response.json()).resolves.toStrictEqual({ code: "kyc not approved" }); expect(captureException).not.toHaveBeenCalled(); }); - it("returns 403 when createCard fails with panda user not found", async () => { - const credentialId = "panda-user-not-found"; - await database.insert(credentials).values({ - id: credentialId, - publicKey: new Uint8Array(), - account: padHex("0x4042", { size: 20 }), - factory: inject("ExaAccountFactory"), - pandaId: credentialId, - }); - - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 404, - text: () => Promise.resolve('{"message":"User not found","error":"NotFoundError","statusCode":404}'), - } as Response); - - const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); - expect(captureException).toHaveBeenCalledOnce(); - }); - - it("returns 403 when createCard fails with panda user not found and empty body", async () => { - const credentialId = "panda-user-not-found-empty"; - await database.insert(credentials).values({ - id: credentialId, - publicKey: new Uint8Array(), - account: padHex("0x4044", { size: 20 }), - factory: inject("ExaAccountFactory"), - pandaId: credentialId, - }); - - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 404, - text: () => Promise.resolve(""), - } as Response); - - const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); - expect(captureException).toHaveBeenCalledOnce(); - }); - - it("captures forbidden no-user on createCard when credential has card history", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 403, - text: () => - Promise.resolve('{"message":"User exists, but is not approved","error":"ForbiddenError","statusCode":403}'), - } as Response); - - const response = await appClient.index.$post({ header: { "test-credential-id": "404" } }); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); - expect(captureException).toHaveBeenCalledOnce(); - }); - it("throws when createCard fails with empty-body 403", async () => { const credentialId = "not-approved-empty"; await database.insert(credentials).values({ @@ -489,6 +395,7 @@ describe("authenticated", () => { it("creates a panda debit card with signature product id", async () => { vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "createCard" }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); const sigCredential = await database.query.credentials.findFirst({ columns: { account: true }, where: eq(credentials.id, "sig"), @@ -517,6 +424,7 @@ describe("authenticated", () => { it("creates a panda credit card with signature product id", async () => { vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "createCreditCard", last4: "1224" }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); const ethCredential = await database.query.credentials.findFirst({ columns: { account: true }, where: eq(credentials.id, "eth"), @@ -615,7 +523,7 @@ describe("authenticated", () => { }, }, }; - + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockAccount); vi.spyOn(pax, "addCapita").mockResolvedValueOnce({}); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "pax-card", last4: "5555" }); @@ -650,6 +558,7 @@ describe("authenticated", () => { pandaId: "new-user-panda", }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(pax, "addCapita").mockResolvedValueOnce({}); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, @@ -733,7 +642,7 @@ describe("authenticated", () => { }, }, }; - + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockAccount); vi.spyOn(pax, "addCapita").mockRejectedValueOnce(new Error("pax api error")); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "error-card", last4: "6666" }); @@ -763,6 +672,7 @@ describe("authenticated", () => { productId: PLATINUM_PRODUCT_ID, }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(pax, "addCapita").mockResolvedValueOnce({}); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "no-account-card", last4: "7777" }); @@ -777,6 +687,7 @@ describe("authenticated", () => { const cardResponse = { ...cardTemplate, id: "cardForCancel", last4: "1224", status: "active" as const }; vi.spyOn(panda, "createCard").mockResolvedValueOnce(cardResponse); vi.spyOn(panda, "updateCard").mockResolvedValueOnce({ ...cardResponse, status: "canceled" }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); const response = await appClient.index.$post({ header: { "test-credential-id": "eth" } }); @@ -801,6 +712,7 @@ describe("authenticated", () => { it("creates a panda card having a cm card with upgraded plugin", async () => { await database.insert(cards).values([{ id: "cm", credentialId: "default", lastFour: "1234" }]); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(panda, "getCard").mockRejectedValueOnce(new ServiceError("Panda", 404, "card not found")); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "migration:cm" }); vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); @@ -818,6 +730,7 @@ describe("authenticated", () => { it("creates a panda card having a cm card with invalid uuid", async () => { await database.insert(cards).values([{ id: "not-uuid", credentialId: "default", lastFour: "1234" }]); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "migration:not-uuid" }); vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index 0855ea299..8e0d9fec0 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -5,19 +5,41 @@ import "../mocks/sentry"; import { captureException } from "@sentry/node"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; -import { afterEach, beforeEach, describe, expect, inject, it, vi } from "vitest"; +import { padHex, zeroAddress, zeroHash } from "viem"; +import { privateKeyToAddress } from "viem/accounts"; +import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest"; + +import deriveAddress from "@exactly/common/deriveAddress"; import app from "../../api/kyc"; -import database, { credentials } from "../../database"; +import database, { credentials, sources } from "../../database"; +import * as panda from "../../utils/panda"; import * as persona from "../../utils/persona"; import { scopeValidationErrors } from "../../utils/persona"; import publicClient from "../../utils/publicClient"; +import type * as v from "valibot"; + const appClient = testClient(app); vi.mock("@sentry/node", { spy: true }); describe("authenticated", () => { + const bob = privateKeyToAddress(padHex("0xb0b2")); + const account = deriveAddress(inject("ExaAccountFactory"), { x: padHex(bob), y: zeroHash }); + + beforeAll(async () => { + await database.insert(credentials).values([ + { + id: account, + publicKey: new Uint8Array(), + account, + factory: zeroAddress, + pandaId: "pandaId", + }, + ]); + }); + beforeEach(async () => { await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); }); @@ -1262,6 +1284,206 @@ describe("authenticated", () => { }); }); }); + + describe("application", () => { + describe("status", () => { + it("returns status", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, account)); + const getApplicationStatus = vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ + id: "pandaId", + applicationStatus: "approved", + applicationReason: "", + }); + const response = await appClient.application.$get( + { query: {} }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ + code: "ok", + legacy: "ok", + status: "approved", + reason: "", + }); + expect(getApplicationStatus).toHaveBeenCalledWith("pandaId"); + expect(response.status).toBe(200); + }); + + it("returns not started when no panda id", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const response = await appClient.application.$get( + { query: {} }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "not started", + legacy: "not started", + }); + }); + }); + + describe("submit", () => { + beforeAll(async () => { + await database.insert(sources).values([ + { + id: "uphold", + config: { + type: "uphold", + secrets: { test: { key: "secret", type: "HMAC-SHA256" } }, + webhooks: { sandbox: { url: "https://exa.test", secretId: "test" } }, + }, + }, + ]); + }); + + it("returns ok when payload is valid and kyc is not started", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: () => + Promise.resolve( + new TextEncoder().encode( + JSON.stringify({ + id: "pandaId", + applicationStatus: "approved", + }), + ).buffer, + ), + } as Response); + + const response = await appClient.application.$post( + { json: applicationPayload }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + const updatedCredential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const calls = mockFetch.mock.calls; + const body = calls[0]?.[1]?.body; + + expect(response.status).toBe(200); + expect(updatedCredential?.pandaId).toBe("pandaId"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/issuing/applications/user`), + expect.objectContaining({ + method: "POST", + }), + ); + expect(JSON.parse(body as string)).toStrictEqual(applicationPayload); + await expect(response.json()).resolves.toStrictEqual({ code: "ok", legacy: "ok" }); + }); + + it("returns 400 when kyc is already started", async () => { + const submitApplication = vi.spyOn(panda, "submitApplication"); + + const response = await appClient.application.$post( + { json: applicationPayload }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "already started", + legacy: "already started", + }); + expect(submitApplication).not.toHaveBeenCalled(); + }); + + it("returns 400 when payload is invalid", async () => { + const response = await appClient.application.$post( + { json: {} as unknown as v.InferOutput }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + 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 + }); + }); + + it("returns 400 if terms of service are not accepted", async () => { + const response = await appClient.application.$post( + { json: { ...applicationPayload, isTermsOfServiceAccepted: false } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "bad request", + legacy: "bad request", + message: ["isTermsOfServiceAccepted Invalid type: Expected true but received false"], + }); + }); + }); + + describe("update", () => { + it("returns ok when kyc is started", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(new TextEncoder().encode("{}").buffer), + } as Response); + + const response = await appClient.application.$patch( + { json: { firstName: "john-updated" } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + const calls = mockFetch.mock.calls; + const body = calls[0]?.[1]?.body; + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok", legacy: "ok" }); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/issuing/applications/user/pandaId`), + expect.objectContaining({ + method: "PATCH", + }), + ); + expect(JSON.parse(body as string)).toStrictEqual({ firstName: "john-updated" }); + }); + + it("returns 400 when kyc is not started", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const response = await appClient.application.$patch( + { json: { firstName: "john-updated" } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "not started", + legacy: "not started", + }); + }); + + it("returns 400 when payload is invalid", async () => { + const response = await appClient.application.$patch( + { + json: { + address: { + line1: "123 main street", + }, + } as unknown as v.InferOutput, + }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "bad request", + legacy: "bad request", + message: expect.any(Array), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }); + }); + }); + }); }); const basicAccount = { @@ -1541,3 +1763,29 @@ const inquiry = { }, }, } as const; + +const applicationPayload = { + firstName: "john", + lastName: "doe", + birthDate: "1990-01-15", + nationalId: "123456789", + countryOfIssue: "AA", + email: "john.doe@example.com", + phoneCountryCode: "1", + phoneNumber: "5551234567", + ipAddress: "192.168.1.1", + occupation: "occupation", + annualSalary: "1234", + accountPurpose: "purpose", + expectedMonthlyVolume: "1234", + isTermsOfServiceAccepted: true, + address: { + line1: "123 main street", + line2: "apt 1", + city: "city", + region: "region", + postalCode: "1234", + countryCode: "AA", + country: "country", + }, +} as const; diff --git a/server/test/e2e.ts b/server/test/e2e.ts index 84b86b0a0..b4512e9b5 100644 --- a/server/test/e2e.ts +++ b/server/test/e2e.ts @@ -108,6 +108,7 @@ vi.mock("../utils/panda", async (importOriginal: () => Promise) => }); }), getUser: vi.fn().mockImplementation((userId: string) => Promise.resolve(users.get(userId))), + getApplicationStatus: vi.fn().mockResolvedValue({ id: "pandaId", applicationStatus: "approved" }), isPanda: vi.fn().mockResolvedValue(true), setPIN: vi.fn().mockResolvedValue({}), signIssuerOp: vi.fn().mockResolvedValue("0x" + "ab".repeat(65)), diff --git a/server/utils/panda.ts b/server/utils/panda.ts index cbca7e0bc..f21456a63 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -3,20 +3,31 @@ import { Mutex, withTimeout, type MutexInterface } from "async-mutex"; import { eq } from "drizzle-orm"; import { boolean, + check, + email, + ipv4, + ipv6, length, literal, maxLength, + metadata, minLength, nullable, number, object, + omit, + optional, parse, + partial, picklist, pipe, + regex, string, transform, + union, type BaseIssue, type BaseSchema, + type InferInput, } from "valibot"; import { BaseError, ContractFunctionZeroDataError } from "viem"; import { privateKeyToAccount } from "viem/accounts"; @@ -47,7 +58,6 @@ const baseURL = process.env.PANDA_API_URL; if (!process.env.PANDA_API_KEY) throw new Error("missing panda api key"); const key = process.env.PANDA_API_KEY; -export default key; export async function createCard(userId: string, productId: typeof PLATINUM_PRODUCT_ID | typeof SIGNATURE_PRODUCT_ID) { return await request( @@ -366,3 +376,108 @@ export function createMutex(address: Address) { export function getMutex(address: Address) { return mutexes.get(address); } + +export async function submitApplication(payload: InferInput) { + return request(ApplicationResponse, "/issuing/applications/user", {}, payload, "POST"); +} + +export async function getApplicationStatus(applicationId: string) { + return request(ApplicationStatusResponse, `/issuing/applications/user/${applicationId}`, {}, undefined, "GET"); +} + +export async function updateApplication(applicationId: string, payload: InferInput) { + return request(object({}), `/issuing/applications/user/${applicationId}`, {}, payload, "PATCH"); +} + +const AddressSchema = object({ + line1: pipe(string(), minLength(1), maxLength(100)), + line2: optional(pipe(string(), minLength(1), maxLength(100))), + city: pipe(string(), minLength(1), maxLength(50)), + region: pipe(string(), minLength(1), maxLength(50)), + country: optional(pipe(string(), minLength(1), maxLength(50))), + postalCode: pipe(string(), minLength(1), maxLength(15), regex(/^[a-z0-9]{1,15}$/i)), + countryCode: pipe(string(), length(2), regex(/^[A-Z]{2}$/i)), +}); + +export const SubmitApplicationRequest = object({ + email: pipe( + string(), + email("Invalid email address"), + metadata({ description: "Email address", examples: ["user@domain.com"] }), + ), + lastName: pipe(string(), maxLength(50), metadata({ description: "The person's last name" })), + firstName: pipe(string(), maxLength(50), metadata({ description: "The person's first name" })), + nationalId: pipe(string(), maxLength(50), metadata({ description: "The person's national ID" })), + birthDate: pipe( + string(), + regex(/^\d{4}-\d{2}-\d{2}$/, "must be YYYY-MM-DD format"), + check((value) => { + const date = new Date(value); + return !Number.isNaN(date.getTime()); + }, "must be a valid date"), + metadata({ description: "Birth date (YYYY-MM-DD)", examples: ["1970-01-01"] }), + ), + countryOfIssue: pipe( + string(), + length(2), + regex(/^[A-Z]{2}$/i, "Must be exactly 2 letters"), + metadata({ description: "The person's country of issue of their national id, as a 2-letter country code" }), + ), + phoneCountryCode: pipe( + string(), + minLength(1), + maxLength(3), + regex(/^\d{1,3}$/, "Must be a valid country code"), + metadata({ description: "The user's phone country code" }), + ), + phoneNumber: pipe( + string(), + minLength(1), + maxLength(15), + regex(/^\d{1,15}$/, "Must be a valid phone number"), + metadata({ description: "The user's phone number" }), + ), + address: pipe(AddressSchema, metadata({ description: "The person's address" })), + ipAddress: pipe( + union([pipe(string(), maxLength(50), ipv4()), pipe(string(), maxLength(50), ipv6())]), + metadata({ description: "The user's IP address (IPv4 or IPv6)" }), + ), + occupation: pipe(string(), maxLength(50), metadata({ description: "The user's occupation" })), + annualSalary: pipe(string(), maxLength(50), metadata({ description: "The user's annual salary" })), + accountPurpose: pipe(string(), maxLength(50), metadata({ description: "The user's account purpose" })), + expectedMonthlyVolume: pipe(string(), maxLength(50), metadata({ description: "The user's expected monthly volume" })), + isTermsOfServiceAccepted: pipe( + boolean(), + literal(true), + metadata({ description: "Whether the user has accepted the terms of service" }), + ), +}); + +export const UpdateApplicationRequest = object({ + ...partial(omit(SubmitApplicationRequest, ["email", "phoneCountryCode", "phoneNumber", "address"])).entries, + address: optional(AddressSchema), +}); + +const ApplicationResponse = object({ + id: pipe(string(), maxLength(50)), + applicationStatus: pipe(string(), maxLength(50)), +}); + +export const kycStatus = [ + "needsVerification", + "needsInformation", + "manualReview", + "notStarted", + "approved", + "canceled", + "pending", + "denied", + "locked", +] as const; + +const ApplicationStatusResponse = object({ + id: string(), + applicationStatus: picklist(kycStatus), + applicationReason: optional(string()), +}); +// #endregion schemas From be987e403a20560d9c767020126e56c790d5c5b1 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Tue, 23 Sep 2025 17:01:48 -0300 Subject: [PATCH 03/12] =?UTF-8?q?=E2=9C=A8=20server:=20add=20encrypted=20k?= =?UTF-8?q?yc=20submission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cspell.json | 1 + .../docs/organization-authentication.md | 118 ++++ server/api/kyc.ts | 244 +++++++- server/test/api/kyc.test.ts | 570 ++++++++++++------ server/utils/auth.ts | 3 + server/utils/panda.ts | 89 ++- 6 files changed, 821 insertions(+), 204 deletions(-) diff --git a/cspell.json b/cspell.json index 81cd7eb01..eae95e10d 100644 --- a/cspell.json +++ b/cspell.json @@ -40,6 +40,7 @@ "checksummed", "CLABE", "clippy", + "ciphertext", "codegen", "codepoint", "colocating", diff --git a/docs/src/content/docs/organization-authentication.md b/docs/src/content/docs/organization-authentication.md index 8571a80b0..f9b5514c6 100644 --- a/docs/src/content/docs/organization-authentication.md +++ b/docs/src/content/docs/organization-authentication.md @@ -219,3 +219,121 @@ authClient.siwe }); ``` + +## How to create the encrypted KYC payload with SIWE statement + + +```typescript +import { createAuthClient } from "better-auth/client"; +import { siweClient, organizationClient } from "better-auth/client/plugins"; +import crypto from "node:crypto"; +import { getAddress, sha256 } from "viem"; +import { mnemonicToAccount } from "viem/accounts"; +import { optimismSepolia } from "viem/chains"; +import { createSiweMessage } from "viem/siwe"; + +const chainId = optimismSepolia.id; + +const authClient = createAuthClient({ + baseURL: "https://sandbox.exactly.app", + plugins: [siweClient(), organizationClient()], +}); + +const owner = mnemonicToAccount("test test test test test test test test test test test siwe"); + +function encrypt(payload: string) { + const aesKey = crypto.randomBytes(32); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", aesKey, iv); + + const ciphertext = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + + const publicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZixoAuo015iMt+JND0y +usAvU2iJhtKRM+7uAxd8iXq7Z/3kXlGmoOJAiSNfpLnBAG0SCWslNCBzxf9+2p5t +HGbQUkZGkfrYvpAzmXKsoCrhWkk1HKk9f7hMHsyRlOmXbFmIgQHggEzEArjhkoXD +pl2iMP1ykCY0YAS+ni747DqcDOuFqLrNA138AxLNZdFsySHbxn8fzcfd3X0J/m/T +2dZuy6ChfDZhGZxSJMjJcintFyXKv7RkwrYdtXuqD3IQYakY3u6R1vfcKVZl0yGY +S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE +2wIDAQAB +-----END PUBLIC KEY-----`; + + const key = crypto.publicEncrypt( + { + key: publicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: "sha256", + }, + aesKey, + ); + + return { + key: key.toString("base64"), + iv: iv.toString("base64"), + ciphertext: ciphertext.toString("base64"), + tag: tag.toString("base64"), + hash: sha256(ciphertext), + }; +} + +authClient.siwe + .nonce({ + walletAddress: owner.address, + chainId, + }) + .then(async ({ data: nonceResult }) => { + if (!nonceResult) throw new Error("No nonce"); + const data = { + email: "john.doe@example.com", + lastName: "Doe", + firstName: "John", + nationalId: "123456789", + birthDate: "1990-05-15", + countryOfIssue: "US", + phoneCountryCode: "1", + phoneNumber: "5551234567", + address: { + line1: "123 Main Street", + line2: "Apt 4B", + city: "New York", + region: "NY", + postalCode: "10001", + countryCode: "US", + }, + ipAddress: "192.168.1.100", + occupation: "11-1011", + annualSalary: "75000", + accountPurpose: "Personal Banking", + expectedMonthlyVolume: "5000", + isTermsOfServiceAccepted: true, + }; + const encryptedPayload = encrypt(JSON.stringify(data)); + const exaAccountUserAddress = "0xa7d5e73027844145A538F4bfD7b8d9b41d8B89d3"; + const statement = `I apply for KYC approval on behalf of address ${getAddress(exaAccountUserAddress)} with payload hash ${encryptedPayload.hash}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: nonceResult.nonce, + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + + const verify = { + message, + signature, + walletAddress: owner.address, + chainId, + }; + const { hash, ...payload } = encryptedPayload; + console.log("application payload", { ...payload, verify }); + }) + .catch((error: unknown) => { + console.error("nonce error", error); + }); + ``` diff --git a/server/api/kyc.ts b/server/api/kyc.ts index 2401160c6..af293f893 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -1,11 +1,13 @@ import { captureException, setContext, setUser, startSpan } from "@sentry/node"; +import canonicalize from "canonicalize"; import createDebug from "debug"; import { eq } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute } from "hono-openapi"; +import * as honoOpenapi from "hono-openapi"; import { resolver, validator as vValidator } from "hono-openapi/valibot"; import { array, literal, metadata, object, optional, parse, picklist, pipe, string, union } from "valibot"; -import { getAddress } from "viem"; +import { getAddress, sha256 } from "viem"; +import { parseSiweMessage } from "viem/siwe"; import accountInit from "@exactly/common/accountInit"; import { @@ -17,11 +19,13 @@ import { Address } from "@exactly/common/validation"; import database, { credentials } from "../database/index"; import auth from "../middleware/auth"; +import betterAuth from "../utils/auth"; import decodePublicKey from "../utils/decodePublicKey"; import { SubmitApplicationRequest as Application, UpdateApplicationRequest as ApplicationUpdate, getApplicationStatus, + KycError, submitApplication, updateApplication, } from "../utils/panda"; @@ -54,6 +58,13 @@ const BadRequestCodes = { BAD_REQUEST: "bad request", } as const; +function buildBaseResponse(example = "string") { + return object({ + code: pipe(string(), metadata({ examples: [example] })), + legacy: pipe(string(), metadata({ examples: [example] })), + }); +} + export default new Hono() .get( "/", @@ -209,16 +220,107 @@ export default new Hono() .post( "/application", auth(), - describeRoute({ + honoOpenapi.describeRoute({ summary: "Submit KYC application", - description: "Submit information for KYC application", + description: ` +Submit information for KYC application. + +**Encrypted kyc payload** + +When the header has encrypted=true, the payload should be encrypted. + +The steps to encrypt are: + +1. Generate AES Key: Create a random 256-bit AES key +2. Encrypt Payload: Use AES-256-GCM to encrypt your KYC JSON data +3. Encrypt AES Key: Use Rain-provided RSA public key with OAEP padding +4. Encode Components: Base64-encode all encrypted components +5. Set Header: Include encrypted: "true" header in your request +6. Submit Request + +KYC Encryption Public Key for sandbox is: + +\`\`\` +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZixoAuo015iMt+JND0y +usAvU2iJhtKRM+7uAxd8iXq7Z/3kXlGmoOJAiSNfpLnBAG0SCWslNCBzxf9+2p5t +HGbQUkZGkfrYvpAzmXKsoCrhWkk1HKk9f7hMHsyRlOmXbFmIgQHggEzEArjhkoXD +pl2iMP1ykCY0YAS+ni747DqcDOuFqLrNA138AxLNZdFsySHbxn8fzcfd3X0J/m/T +2dZuy6ChfDZhGZxSJMjJcintFyXKv7RkwrYdtXuqD3IQYakY3u6R1vfcKVZl0yGY +S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE +2wIDAQAB +-----END PUBLIC KEY----- +\`\`\` + +KYC Encryption Public Key for production needs to be provided. + +A working and tested [example is available in here](../../../organization-authentication/#how-to-create-the-encrypted-kyc-payload-with-siwe-statement) + +**Payload structure before encryption** + +1. Personal information (name, date of birth, address) +2. Identity verification documents +3. Compliance information (occupation, income, etc.) +4. Terms of service acceptance + +Here's the markdown table with object notation for nested fields: + +| fieldName | type | example | notes | +|-----------|------|---------|-------| +| email | string | user@domain.com | | +| lastName | string | Doe | | +| firstName | string | John | | +| nationalId | string | 123456789 | | +| birthDate | string | 1970-01-01 | | +| countryOfIssue | string | US | | +| phoneCountryCode | string | 1 | | +| phoneNumber | string | 5551234567 | | +| address.line1 | string | 123 Main Street | | +| address.line2 | string | Apt 4B | | +| address.city | string | New York | | +| address.region | string | NY | | +| address.postalCode | string | 10001 | | +| address.countryCode | string | US | | +| ipAddress | string | 192.168.1.100 | | +| occupation | string | 11-1011 | Ask for the mandatory occupation codes | +| annualSalary | string | 75000 | | +| accountPurpose | string | Personal Banking | | +| expectedMonthlyVolume | string | 5000 | | +| isTermsOfServiceAccepted | boolean | true | | + +**Authentication and organization verification** + +The exa account needs to be authenticated but also a member of the organization that submit the KYC application needs to probe that +belong to the organization and needs to have *kyc* permission, every owner and admin of an organization has this permission. + +To probe the member of the organization needs to generate a SIWE message with the following statement and viem library is recommended: + +"I apply for KYC approval on behalf of [lowercase exa account address]" + +The siwe message will be: + +| fieldName | type | example | notes | +|-----------|------|---------|-------| +| verify.message | string | SIWE message that includes the statement | | +| verify.signature | string | signature of the message | | +| verify.walletAddress | string | address of the member of the organization that signed the message | | +| verify.chainId | number | 11155420 | | + +A working and tested [example is available in here](../../../organization-authentication/#how-to-create-the-encrypted-kyc-payload-with-siwe-statement) + +Note that the member of the organization must be created, the organization must exist and the member must be added as admin by another admin or owner. + +Working example about how to login is [here](../../../organization-authentication/#siwe-authentication) + +The admin should add a member using [addMember method](https://www.better-auth.com/docs/plugins/organization#add-member). +`, tags: ["KYC"], responses: { 200: { description: "KYC application submitted successfully", content: { "application/json": { - schema: resolver(buildBaseResponse("ok"), { errorMode: "ignore" }), + schema: resolver(object({ id: string(), status: string() }), { errorMode: "ignore" }), }, }, }, @@ -228,12 +330,48 @@ export default new Hono() "application/json": { schema: resolver( union([ - buildBaseResponse(BadRequestCodes.ALREADY_STARTED), + object({ code: literal("invalid encryption"), message: string() }), object({ ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, message: optional(array(string())), }), ]), + { + errorMode: "ignore", + }, + ), + }, + }, + }, + 401: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + union([ + object({ code: literal(BadRequestCodes.ALREADY_STARTED) }), + object({ + code: literal("invalid payload"), + message: string(), + }), + object({ + code: string(), + }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + 403: { + description: "Forbidden", + content: { + "application/json": { + schema: resolver( + object({ + code: literal("no permission"), + message: optional(string()), + }), { errorMode: "ignore" }, ), }, @@ -243,33 +381,96 @@ export default new Hono() validateResponse: true, }), vValidator("json", Application, validatorHook({ debug })), + vValidator("header", optional(object({ encrypted: optional(string()) })), validatorHook({ debug })), async (c) => { - const { credentialId } = c.req.valid("cookie"); const payload = c.req.valid("json"); + const verifyResponse = await betterAuth.api.verifySiweMessage({ + body: payload.verify, + request: c.req.raw, + asResponse: true, + }); + if (!verifyResponse.ok) { + const errorBody = parse(object({ code: string(), message: string() }), await verifyResponse.json()); + return c.json({ code: "no permission", message: errorBody.message }, 403); + } + const headers = new Headers(); + headers.set("cookie", verifyResponse.headers.get("set-cookie") ?? ""); + const organizations = await betterAuth.api.listOrganizations({ headers }); + const source = organizations[0]?.id; + if (!source) return c.json({ code: "no organization" }, 403); + + const { success: canCreate } = await betterAuth.api.hasPermission({ + headers, + body: { + organizationId: source, + permissions: { + kyc: ["create"], + }, + }, + }); + if (!canCreate) 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 }, where: eq(credentials.id, credentialId), }); - if (!credential) return c.json({ code: "no credential", legacy: "no credential" }, 500); + if (!credential) return c.json({ code: "no credential" }, 500); setUser({ id: parse(Address, credential.account) }); setContext("exa", { credential }); - if (credential.pandaId) { - return c.json({ code: BadRequestCodes.ALREADY_STARTED, legacy: BadRequestCodes.ALREADY_STARTED }, 400); + const siweMessage = parseSiweMessage(payload.verify.message); + const { verify, ...body } = payload; + const hash = + "ciphertext" in body + ? sha256(Buffer.from(body.ciphertext, "base64")) + : sha256( + Buffer.from( + (() => { + const canon = canonicalize(body); + if (!canon) throw new Error("bad body"); + return canon; + })(), + "utf8", + ), + ); + + if ( + siweMessage.statement !== + `I apply for KYC approval on behalf of address ${parse(Address, credential.account)} with payload hash ${hash}` + ) { + return c.json({ code: "no permission", message: "invalid statement" }, 403); } - const application = await submitApplication(payload); - await database - .update(credentials) - .set({ pandaId: application.id, source: "uphold" }) // TODO get source from signer - .where(eq(credentials.id, credentialId)); - return c.json({ code: "ok", legacy: "ok" }, 200); + if (credential.pandaId) { + return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 401); + } + try { + const application = await submitApplication(payload, c.req.header("encrypted") === "true"); + await database + .update(credentials) + .set({ pandaId: application.id, source }) + .where(eq(credentials.id, credentialId)); + return c.json({ id: application.id, status: application.applicationStatus }, 200); + } catch (error) { + if (error instanceof KycError) { + switch (error.statusCode) { + case 400: + return c.json({ code: "invalid encryption", message: error.message }, 400); + case 401: + return c.json({ code: "invalid payload", message: error.message }, 401); + default: + return c.json({ code: error.message }, 401); + } + } + throw error; + } }, ) .patch( "/application", auth(), - describeRoute({ + honoOpenapi.describeRoute({ summary: "Update KYC application", description: "Update the KYC application", tags: ["KYC"], @@ -323,7 +524,7 @@ export default new Hono() .get( "/application", auth(), - describeRoute({ + honoOpenapi.describeRoute({ summary: "Get KYC application status", description: "Get the status of the KYC application", tags: ["KYC"], @@ -405,10 +606,3 @@ async function generateInquiryTokens(inquiryId: string): Promise<{ inquiryId: st const { meta: sessionTokenMeta } = await resumeInquiry(inquiryId); return { inquiryId, sessionToken: sessionTokenMeta["session-token"] }; } - -function buildBaseResponse(example = "string") { - return object({ - code: pipe(string(), metadata({ examples: [example] })), - legacy: pipe(string(), metadata({ examples: [example] })), - }); -} diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index 8e0d9fec0..679191403 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -3,16 +3,21 @@ import "../mocks/deployments"; import "../mocks/sentry"; import { captureException } from "@sentry/node"; +import canonicalize from "canonicalize"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; -import { padHex, zeroAddress, zeroHash } from "viem"; -import { privateKeyToAddress } from "viem/accounts"; +import crypto from "node:crypto"; +import { getAddress, padHex, sha256, zeroAddress, zeroHash } from "viem"; +import { mnemonicToAccount, privateKeyToAddress } from "viem/accounts"; +import { createSiweMessage } from "viem/siwe"; import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest"; import deriveAddress from "@exactly/common/deriveAddress"; +import chain from "@exactly/common/generated/chain"; import app from "../../api/kyc"; import database, { credentials, sources } from "../../database"; +import auth from "../../utils/auth"; import * as panda from "../../utils/panda"; import * as persona from "../../utils/persona"; import { scopeValidationErrors } from "../../utils/persona"; @@ -1286,200 +1291,421 @@ describe("authenticated", () => { }); describe("application", () => { - describe("status", () => { - it("returns status", async () => { - await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, account)); - const getApplicationStatus = vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ - id: "pandaId", - applicationStatus: "approved", - applicationReason: "", - }); - const response = await appClient.application.$get( - { query: {} }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); - - await expect(response.json()).resolves.toStrictEqual({ - code: "ok", - legacy: "ok", - status: "approved", - reason: "", - }); - expect(getApplicationStatus).toHaveBeenCalledWith("pandaId"); - expect(response.status).toBe(200); - }); - - it("returns not started when no panda id", async () => { - await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); - const response = await appClient.application.$get( - { query: {} }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toStrictEqual({ - code: "not started", - legacy: "not started", - }); - }); - }); + describe("with organization", () => { + const owner = mnemonicToAccount("test test test test test test test test test test test kyc"); + const ownerHeaders: Headers = new Headers(); + let organizationId: string; - describe("submit", () => { beforeAll(async () => { - await database.insert(sources).values([ - { - id: "uphold", - config: { - type: "uphold", - secrets: { test: { key: "secret", type: "HMAC-SHA256" } }, - webhooks: { sandbox: { url: "https://exa.test", secretId: "test" } }, - }, + const adminNonceResult = await auth.api.getSiweNonce({ + body: { walletAddress: owner.address, chainId: chain.id }, + }); + + const statement = "I accept Exa terms and conditions"; + const ownerMessage = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: adminNonceResult.nonce, + uri: `https://localhost`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + + const adminResponse = await auth.api.verifySiweMessage({ + body: { + message: ownerMessage, + signature: await owner.signMessage({ message: ownerMessage }), + walletAddress: owner.address, + chainId: chain.id, }, - ]); - }); - - it("returns ok when payload is valid and kyc is not started", async () => { - await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); - const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - arrayBuffer: () => - Promise.resolve( - new TextEncoder().encode( - JSON.stringify({ - id: "pandaId", - applicationStatus: "approved", - }), - ).buffer, - ), - } as Response); - - const response = await appClient.application.$post( - { json: applicationPayload }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); - - const updatedCredential = await database.query.credentials.findFirst({ - where: eq(credentials.id, account), + request: new Request("https://localhost"), + asResponse: true, }); - const calls = mockFetch.mock.calls; - const body = calls[0]?.[1]?.body; + ownerHeaders.set("cookie", `${adminResponse.headers.get("set-cookie")}`); - expect(response.status).toBe(200); - expect(updatedCredential?.pandaId).toBe("pandaId"); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining(`/issuing/applications/user`), - expect.objectContaining({ - method: "POST", - }), - ); - expect(JSON.parse(body as string)).toStrictEqual(applicationPayload); - await expect(response.json()).resolves.toStrictEqual({ code: "ok", legacy: "ok" }); + const externalOrganization = await auth.api.createOrganization({ + headers: ownerHeaders, + body: { + name: "Organization", + slug: "organization", + keepCurrentActiveOrganization: false, + }, + }); + organizationId = externalOrganization?.id ?? ""; }); - it("returns 400 when kyc is already started", async () => { - const submitApplication = vi.spyOn(panda, "submitApplication"); + describe("status", () => { + it("returns status", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, account)); + const getApplicationStatus = vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ + id: "pandaId", + applicationStatus: "approved", + applicationReason: "", + }); + const response = await appClient.application.$get( + { query: {} }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ + code: "ok", + legacy: "ok", + status: "approved", + reason: "", + }); + expect(getApplicationStatus).toHaveBeenCalledWith("pandaId"); + expect(response.status).toBe(200); + }); - const response = await appClient.application.$post( - { json: applicationPayload }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); + it("returns not started when no panda id", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const response = await appClient.application.$get( + { query: {} }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); - expect(response.status).toBe(400); - await expect(response.json()).resolves.toStrictEqual({ - code: "already started", - legacy: "already started", + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "not started", + legacy: "not started", + }); }); - expect(submitApplication).not.toHaveBeenCalled(); }); - it("returns 400 when payload is invalid", async () => { - const response = await appClient.application.$post( - { json: {} as unknown as v.InferOutput }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); - - 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 + describe("submit", () => { + beforeAll(async () => { + await database.insert(sources).values([ + { + id: organizationId, + config: { + type: "uphold", + secrets: { test: { key: "secret", type: "HMAC-SHA256" } }, + webhooks: { sandbox: { url: "https://exa.test", secretId: "test" } }, + }, + }, + ]); }); - }); - it("returns 400 if terms of service are not accepted", async () => { - const response = await appClient.application.$post( - { json: { ...applicationPayload, isTermsOfServiceAccepted: false } }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); + it("returns ok when payload is valid and kyc is not started", async () => { + const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; + const { nonce } = await auth.api.getSiweNonce({ + body: { walletAddress: owner.address, chainId: chain.id }, + }); + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + + const verify = { + message, + signature, + walletAddress: owner.address, + chainId: chain.id, + }; + + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: () => + Promise.resolve( + new TextEncoder().encode( + JSON.stringify({ + id: "pandaId", + applicationStatus: "approved", + }), + ).buffer, + ), + } as Response); + + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + const updatedCredential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const calls = mockFetch.mock.calls; + const body = calls[0]?.[1]?.body; + + expect(response.status).toBe(200); + expect(updatedCredential?.pandaId).toBe("pandaId"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/issuing/applications/user`), + expect.objectContaining({ + method: "POST", + }), + ); + expect(JSON.parse(body as string)).toStrictEqual({ ...applicationPayload, verify }); + await expect(response.json()).resolves.toStrictEqual({ id: "pandaId", status: "approved" }); + }); + + it("returns 401 when kyc is already started", async () => { + const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; + const { nonce } = await auth.api.getSiweNonce({ + body: { walletAddress: owner.address, chainId: chain.id }, + }); + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); - expect(response.status).toBe(400); - await expect(response.json()).resolves.toStrictEqual({ - code: "bad request", - legacy: "bad request", - message: ["isTermsOfServiceAccepted Invalid type: Expected true but received false"], - }); - }); - }); + const verify = { + message, + signature, + walletAddress: owner.address, + chainId: chain.id, + }; - describe("update", () => { - it("returns ok when kyc is started", async () => { - const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - arrayBuffer: () => Promise.resolve(new TextEncoder().encode("{}").buffer), - } as Response); - - const response = await appClient.application.$patch( - { json: { firstName: "john-updated" } }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); + const submitApplication = vi.spyOn(panda, "submitApplication"); - const calls = mockFetch.mock.calls; - const body = calls[0]?.[1]?.body; + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); - expect(response.status).toBe(200); - await expect(response.json()).resolves.toStrictEqual({ code: "ok", legacy: "ok" }); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining(`/issuing/applications/user/pandaId`), - expect.objectContaining({ - method: "PATCH", - }), - ); - expect(JSON.parse(body as string)).toStrictEqual({ firstName: "john-updated" }); - }); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ + code: "already started", + }); + expect(submitApplication).not.toHaveBeenCalled(); + }); - it("returns 400 when kyc is not started", async () => { - await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); - const response = await appClient.application.$patch( - { json: { firstName: "john-updated" } }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); + it("returns 400 when payload is invalid", async () => { + const response = await appClient.application.$post( + { json: {} as unknown as v.InferOutput }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); - expect(response.status).toBe(400); - await expect(response.json()).resolves.toStrictEqual({ - code: "not started", - legacy: "not started", + 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 + }); }); - }); - it("returns 400 when payload is invalid", async () => { - const response = await appClient.application.$patch( - { - json: { - address: { - line1: "123 main street", + it("returns 400 if terms of service are not accepted", async () => { + const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; + const { nonce } = await auth.api.getSiweNonce({ + body: { walletAddress: owner.address, chainId: chain.id }, + }); + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + + const verify = { + message, + signature, + walletAddress: owner.address, + chainId: chain.id, + }; + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify, isTermsOfServiceAccepted: false } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + }); + + describe("with encrypted payload", () => { + const publicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZixoAuo015iMt+JND0y +usAvU2iJhtKRM+7uAxd8iXq7Z/3kXlGmoOJAiSNfpLnBAG0SCWslNCBzxf9+2p5t +HGbQUkZGkfrYvpAzmXKsoCrhWkk1HKk9f7hMHsyRlOmXbFmIgQHggEzEArjhkoXD +pl2iMP1ykCY0YAS+ni747DqcDOuFqLrNA138AxLNZdFsySHbxn8fzcfd3X0J/m/T +2dZuy6ChfDZhGZxSJMjJcintFyXKv7RkwrYdtXuqD3IQYakY3u6R1vfcKVZl0yGY +S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE +2wIDAQAB +-----END PUBLIC KEY-----`; + + function encrypt(payload: string) { + const aesKey = crypto.randomBytes(32); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", aesKey, iv); + const ciphertext = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + const key = crypto.publicEncrypt( + { + key: publicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: "sha256", }, - } as unknown as v.InferOutput, - }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); + aesKey, + ); + + return { key, iv, ciphertext, tag }; + } + + it("returns ok when payload is valid", async () => { + const encryptedPayload = encrypt(JSON.stringify(applicationPayload)); + const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(encryptedPayload.ciphertext)}`; + const { nonce } = await auth.api.getSiweNonce({ + body: { walletAddress: owner.address, chainId: chain.id }, + }); + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + + const verify = { + message, + signature, + walletAddress: owner.address, + chainId: chain.id, + }; + + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: () => + Promise.resolve( + new TextEncoder().encode( + JSON.stringify({ + id: "pandaId", + applicationStatus: "approved", + }), + ).buffer, + ), + } as Response); + + const response = await appClient.application.$post( + { + json: { + key: encryptedPayload.key.toString("base64"), + iv: encryptedPayload.iv.toString("base64"), + ciphertext: encryptedPayload.ciphertext.toString("base64"), + tag: encryptedPayload.tag.toString("base64"), + verify, + }, + }, + { headers: { "test-credential-id": account, SessionID: "fakeSession", encrypted: "true" } }, + ); + + const updatedCredential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const calls = mockFetch.mock.calls; + const body = calls[0]?.[1]?.body; + + expect(response.status).toBe(200); + expect(updatedCredential?.pandaId).toBe("pandaId"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/issuing/applications/user"), + expect.objectContaining({ encrypted: "true" }), + expect.objectContaining({ + method: "POST", + }), + ); + expect(JSON.parse(body as string)).toStrictEqual({ + key: encryptedPayload.key.toString("base64"), + iv: encryptedPayload.iv.toString("base64"), + ciphertext: encryptedPayload.ciphertext.toString("base64"), + tag: encryptedPayload.tag.toString("base64"), + verify, + }); + await expect(response.json()).resolves.toStrictEqual({ id: "pandaId", status: "approved" }); + }); + }); + }); - expect(response.status).toBe(400); - await expect(response.json()).resolves.toStrictEqual({ - code: "bad request", - legacy: "bad request", - message: expect.any(Array), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + describe("update", () => { + it("returns ok when kyc is started", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(new TextEncoder().encode("{}").buffer), + } as Response); + + const response = await appClient.application.$patch( + { json: { firstName: "john-updated" } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + const calls = mockFetch.mock.calls; + const body = calls[0]?.[1]?.body; + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok", legacy: "ok" }); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/issuing/applications/user/pandaId`), + expect.objectContaining({ + method: "PATCH", + }), + ); + expect(JSON.parse(body as string)).toStrictEqual({ firstName: "john-updated" }); + }); + + it("returns 400 when kyc is not started", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const response = await appClient.application.$patch( + { json: { firstName: "john-updated" } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "not started", + legacy: "not started", + }); + }); + + it("returns 400 when payload is invalid", async () => { + const response = await appClient.application.$patch( + { + json: { + address: { + line1: "123 main street", + }, + } as unknown as v.InferOutput, + }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "bad request", + legacy: "bad request", + message: expect.any(Array), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }); }); }); }); diff --git a/server/utils/auth.ts b/server/utils/auth.ts index ef68eea5e..f042562a3 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -17,6 +17,7 @@ import { authAdapter } from "../database/index"; const ac = createAccessControl({ ...defaultStatements, webhook: ["create", "delete", "read"], + kyc: ["create", "delete", "read"], }); export default betterAuth({ @@ -54,10 +55,12 @@ export default betterAuth({ roles: { admin: ac.newRole({ webhook: ["create", "delete", "read"], + kyc: ["create"], ...adminAc.statements, }), owner: ac.newRole({ webhook: ["create", "delete", "read"], + kyc: ["create"], ...ownerAc.statements, }), member: ac.newRole({ diff --git a/server/utils/panda.ts b/server/utils/panda.ts index f21456a63..ae9d4c9c2 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -29,7 +29,7 @@ import { type BaseSchema, type InferInput, } from "valibot"; -import { BaseError, ContractFunctionZeroDataError } from "viem"; +import { BaseError, ContractFunctionZeroDataError, type MaybePromise } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { base, optimism } from "viem/chains"; @@ -174,6 +174,7 @@ async function request>( body?: unknown, method: "GET" | "PATCH" | "POST" | "PUT" = body === undefined ? "GET" : "POST", timeout = 10_000, + onError?: (response: Response) => MaybePromise, ) { const response = await fetch(`${baseURL}${url}`, { method, @@ -377,16 +378,67 @@ export function getMutex(address: Address) { return mutexes.get(address); } -export async function submitApplication(payload: InferInput) { - return request(ApplicationResponse, "/issuing/applications/user", {}, payload, "POST"); +export async function submitApplication(payload: InferInput, encrypted = false) { + return request( + ApplicationResponse, + "/issuing/applications/user", + { ...(encrypted && { encrypted: "true" }) }, + payload, + "POST", + 10_000, + async (response) => { + const text = await response.text(); + try { + const error = parse(object({ message: string() }), JSON.parse(text)); + throw new KycError(error.message, response.status); + } catch (error) { + if (error instanceof KycError) throw error; + throw new Error(`${response.status} ${text}`); + } + }, + ); } export async function getApplicationStatus(applicationId: string) { - return request(ApplicationStatusResponse, `/issuing/applications/user/${applicationId}`, {}, undefined, "GET"); + return request( + ApplicationStatusResponse, + `/issuing/applications/user/${applicationId}`, + {}, + undefined, + "GET", + 10_000, + async (response) => { + const text = await response.text(); + try { + const error = parse(object({ message: string() }), JSON.parse(text)); + throw new KycError(error.message, response.status); + } catch (error) { + if (error instanceof KycError) throw error; + throw new Error(`${response.status} ${text}`); + } + }, + ); } export async function updateApplication(applicationId: string, payload: InferInput) { - return request(object({}), `/issuing/applications/user/${applicationId}`, {}, payload, "PATCH"); + return request( + object({}), + `/issuing/applications/user/${applicationId}`, + {}, + payload, + "PATCH", + 10_000, + async (response) => { + const text = await response.text(); + try { + const error = parse(object({ message: string() }), JSON.parse(text)); + throw new KycError(error.message, response.status); + } catch (error) { + if (error instanceof KycError) throw error; + throw new Error(`${response.status} ${text}`); + } + }, + ); } const AddressSchema = object({ @@ -399,7 +451,7 @@ const AddressSchema = object({ countryCode: pipe(string(), length(2), regex(/^[A-Z]{2}$/i)), }); -export const SubmitApplicationRequest = object({ +export const Application = object({ email: pipe( string(), email("Invalid email address"), @@ -451,10 +503,22 @@ export const SubmitApplicationRequest = object({ literal(true), metadata({ description: "Whether the user has accepted the terms of service" }), ), + verify: object({ message: string(), signature: string(), walletAddress: string(), chainId: number() }), }); +export const SubmitApplicationRequest = union([ + Application, + object({ + key: string(), + iv: string(), + ciphertext: string(), + tag: string(), + verify: object({ message: string(), signature: string(), walletAddress: string(), chainId: number() }), + }), +]); + export const UpdateApplicationRequest = object({ - ...partial(omit(SubmitApplicationRequest, ["email", "phoneCountryCode", "phoneNumber", "address"])).entries, + ...partial(omit(Application, ["email", "phoneCountryCode", "phoneNumber", "address"])).entries, address: optional(AddressSchema), }); @@ -480,4 +544,15 @@ const ApplicationStatusResponse = object({ applicationStatus: picklist(kycStatus), applicationReason: optional(string()), }); + +export class KycError extends Error { + constructor( + message: string, + public statusCode: number, + ) { + super(message); + this.name = "KycError"; + } +} + // #endregion schemas From aedc95b769599d5feb66bb3c876958313da1948c Mon Sep 17 00:00:00 2001 From: mainqueg Date: Wed, 8 Oct 2025 14:36:01 -0300 Subject: [PATCH 04/12] =?UTF-8?q?=F0=9F=93=9D=20server:=20update=20kyc=20s?= =?UTF-8?q?tatus=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ^ Conflicts: ^ server/test/api/kyc.test.ts --- server/api/kyc.ts | 25 ++++++++++++++----------- server/test/api/kyc.test.ts | 8 ++++---- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/server/api/kyc.ts b/server/api/kyc.ts index af293f893..ee9887306 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -320,7 +320,7 @@ 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({ id: string(), status: string() }), { errorMode: "ignore" }), + schema: resolver(object({ status: string() }), { errorMode: "ignore" }), }, }, }, @@ -349,7 +349,6 @@ The admin should add a member using [addMember method](https://www.better-auth.c "application/json": { schema: resolver( union([ - object({ code: literal(BadRequestCodes.ALREADY_STARTED) }), object({ code: literal("invalid payload"), message: string(), @@ -363,17 +362,21 @@ The admin should add a member using [addMember method](https://www.better-auth.c }, }, }, + 409: { + description: "Conflict", + content: { + "application/json": { + schema: resolver(object({ code: literal(BadRequestCodes.ALREADY_STARTED) }), { errorMode: "ignore" }), + }, + }, + }, 403: { description: "Forbidden", content: { "application/json": { - schema: resolver( - object({ - code: literal("no permission"), - message: optional(string()), - }), - { errorMode: "ignore" }, - ), + schema: resolver(object({ code: literal("no permission"), message: optional(string()) }), { + errorMode: "ignore", + }), }, }, }, @@ -443,7 +446,7 @@ The admin should add a member using [addMember method](https://www.better-auth.c } if (credential.pandaId) { - return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 401); + return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409); } try { const application = await submitApplication(payload, c.req.header("encrypted") === "true"); @@ -451,7 +454,7 @@ The admin should add a member using [addMember method](https://www.better-auth.c .update(credentials) .set({ pandaId: application.id, source }) .where(eq(credentials.id, credentialId)); - return c.json({ id: application.id, status: application.applicationStatus }, 200); + return c.json({ status: application.applicationStatus }, 200); } catch (error) { if (error instanceof KycError) { switch (error.statusCode) { diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index 679191403..d78d802ee 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -1449,10 +1449,10 @@ describe("authenticated", () => { }), ); expect(JSON.parse(body as string)).toStrictEqual({ ...applicationPayload, verify }); - await expect(response.json()).resolves.toStrictEqual({ id: "pandaId", status: "approved" }); + await expect(response.json()).resolves.toStrictEqual({ status: "approved" }); }); - it("returns 401 when kyc is already started", async () => { + it("returns 409 when kyc is already started", async () => { const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; const { nonce } = await auth.api.getSiweNonce({ body: { walletAddress: owner.address, chainId: chain.id }, @@ -1484,7 +1484,7 @@ describe("authenticated", () => { { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, ); - expect(response.status).toBe(401); + expect(response.status).toBe(409); await expect(response.json()).resolves.toStrictEqual({ code: "already started", }); @@ -1642,7 +1642,7 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE tag: encryptedPayload.tag.toString("base64"), verify, }); - await expect(response.json()).resolves.toStrictEqual({ id: "pandaId", status: "approved" }); + await expect(response.json()).resolves.toStrictEqual({ status: "approved" }); }); }); }); From d6f96683e4970aa300fd0e47cf0fc6d8c1d09830 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 22 Oct 2025 15:09:18 -0300 Subject: [PATCH 05/12] =?UTF-8?q?=F0=9F=A6=BA=20server:=20improve=20kyc=20?= =?UTF-8?q?application=20response=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/kyc.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/server/api/kyc.ts b/server/api/kyc.ts index ee9887306..4f4ed1c0e 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -374,9 +374,13 @@ The admin should add a member using [addMember method](https://www.better-auth.c description: "Forbidden", content: { "application/json": { - schema: resolver(object({ code: literal("no permission"), message: optional(string()) }), { - errorMode: "ignore", - }), + schema: resolver( + object({ + code: picklist(["no permission", "no organization"]), + message: optional(string()), + }), + { errorMode: "ignore" }, + ), }, }, }, From 6ffebf3b568d3b09ac582bec5d421c2d5871bb54 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 2 Oct 2025 10:21:24 -0300 Subject: [PATCH 06/12] =?UTF-8?q?=E2=9C=A8=20server:=20improve=20nonce=20u?= =?UTF-8?q?sage=20for=20encrypted=20kyc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/cuddly-streets-like.md | 6 + .../docs/organization-authentication.md | 98 ++++------ server/api/kyc.ts | 64 +++--- server/test/api/kyc.test.ts | 185 ++++++++++++------ server/utils/panda.ts | 6 +- 5 files changed, 202 insertions(+), 157 deletions(-) create mode 100644 .changeset/cuddly-streets-like.md diff --git a/.changeset/cuddly-streets-like.md b/.changeset/cuddly-streets-like.md new file mode 100644 index 000000000..336464b4c --- /dev/null +++ b/.changeset/cuddly-streets-like.md @@ -0,0 +1,6 @@ +--- +"@exactly/server": patch +"@exactly/docs": patch +--- + +✨ improve nonce usage for encrypted kyc diff --git a/docs/src/content/docs/organization-authentication.md b/docs/src/content/docs/organization-authentication.md index f9b5514c6..0ce439f5b 100644 --- a/docs/src/content/docs/organization-authentication.md +++ b/docs/src/content/docs/organization-authentication.md @@ -224,21 +224,14 @@ authClient.siwe ```typescript -import { createAuthClient } from "better-auth/client"; -import { siweClient, organizationClient } from "better-auth/client/plugins"; import crypto from "node:crypto"; import { getAddress, sha256 } from "viem"; import { mnemonicToAccount } from "viem/accounts"; import { optimismSepolia } from "viem/chains"; -import { createSiweMessage } from "viem/siwe"; +import { createSiweMessage, generateSiweNonce } from "viem/siwe"; const chainId = optimismSepolia.id; -const authClient = createAuthClient({ - baseURL: "https://sandbox.exactly.app", - plugins: [siweClient(), organizationClient()], -}); - const owner = mnemonicToAccount("test test test test test test test test test test test siwe"); function encrypt(payload: string) { @@ -277,53 +270,46 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE }; } -authClient.siwe - .nonce({ - walletAddress: owner.address, - chainId, - }) - .then(async ({ data: nonceResult }) => { - if (!nonceResult) throw new Error("No nonce"); - const data = { - email: "john.doe@example.com", - lastName: "Doe", - firstName: "John", - nationalId: "123456789", - birthDate: "1990-05-15", - countryOfIssue: "US", - phoneCountryCode: "1", - phoneNumber: "5551234567", - address: { - line1: "123 Main Street", - line2: "Apt 4B", - city: "New York", - region: "NY", - postalCode: "10001", - countryCode: "US", - }, - ipAddress: "192.168.1.100", - occupation: "11-1011", - annualSalary: "75000", - accountPurpose: "Personal Banking", - expectedMonthlyVolume: "5000", - isTermsOfServiceAccepted: true, - }; - const encryptedPayload = encrypt(JSON.stringify(data)); - const exaAccountUserAddress = "0xa7d5e73027844145A538F4bfD7b8d9b41d8B89d3"; - const statement = `I apply for KYC approval on behalf of address ${getAddress(exaAccountUserAddress)} with payload hash ${encryptedPayload.hash}`; - const message = createSiweMessage({ - statement, - resources: ["https://exactly.github.io/exa"], - nonce: nonceResult.nonce, - uri: `https://sandbox.exactly.app`, - address: owner.address, - chainId, - scheme: "https", - version: "1", - domain: "sandbox.exactly.app", - }); - const signature = await owner.signMessage({ message }); - +const data = { + email: "john.doe@example.com", + lastName: "Doe", + firstName: "John", + nationalId: "123456789", + birthDate: "1990-05-15", + countryOfIssue: "US", + phoneCountryCode: "1", + phoneNumber: "5551234567", + address: { + line1: "123 Main Street", + line2: "Apt 4B", + city: "New York", + region: "NY", + postalCode: "10001", + countryCode: "US", + }, + ipAddress: "192.168.1.100", + occupation: "11-1011", + annualSalary: "75000", + accountPurpose: "Personal Banking", + expectedMonthlyVolume: "5000", + isTermsOfServiceAccepted: true, +}; +const encryptedPayload = encrypt(JSON.stringify(data)); +const exaAccountUserAddress = "0xa7d5e73027844145A538F4bfD7b8d9b41d8B89d3"; +const statement = `I apply for KYC approval on behalf of address ${getAddress(exaAccountUserAddress)} with payload hash ${encryptedPayload.hash}`; +const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", +}); +owner.signMessage({ message }) + .then((signature) => { const verify = { message, signature, @@ -334,6 +320,6 @@ authClient.siwe console.log("application payload", { ...payload, verify }); }) .catch((error: unknown) => { - console.error("nonce error", error); + console.error("error", error); }); ``` diff --git a/server/api/kyc.ts b/server/api/kyc.ts index 4f4ed1c0e..7f79d1012 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -6,20 +6,19 @@ import { Hono } from "hono"; import * as honoOpenapi from "hono-openapi"; import { resolver, validator as vValidator } from "hono-openapi/valibot"; import { array, literal, metadata, object, optional, parse, picklist, pipe, string, union } from "valibot"; -import { getAddress, sha256 } from "viem"; +import { getAddress, sha256, verifyMessage } from "viem"; import { parseSiweMessage } from "viem/siwe"; import accountInit from "@exactly/common/accountInit"; -import { +import chain, { exaAccountFactoryAddress, exaPluginAddress, upgradeableModularAccountAbi, } from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; -import database, { credentials } from "../database/index"; +import database, { credentials, walletAddresses } from "../database/index"; import auth from "../middleware/auth"; -import betterAuth from "../utils/auth"; import decodePublicKey from "../utils/decodePublicKey"; import { SubmitApplicationRequest as Application, @@ -295,15 +294,17 @@ belong to the organization and needs to have *kyc* permission, every owner and a To probe the member of the organization needs to generate a SIWE message with the following statement and viem library is recommended: -"I apply for KYC approval on behalf of [lowercase exa account address]" +"I apply for KYC approval on behalf of address [checksum address] with payload hash [hash]"; + +The hash is sha256(encryptedPayload.ciphertext) The siwe message will be: | fieldName | type | example | notes | |-----------|------|---------|-------| | verify.message | string | SIWE message that includes the statement | | -| verify.signature | string | signature of the message | | -| verify.walletAddress | string | address of the member of the organization that signed the message | | +| verify.signature | Hex | signature of the message | | +| verify.walletAddress | Address | address of the member of the organization that signed the message | | | verify.chainId | number | 11155420 | | A working and tested [example is available in here](../../../organization-authentication/#how-to-create-the-encrypted-kyc-payload-with-siwe-statement) @@ -330,7 +331,7 @@ The admin should add a member using [addMember method](https://www.better-auth.c "application/json": { schema: resolver( union([ - object({ code: literal("invalid encryption"), message: string() }), + object({ code: picklist(["invalid encryption", "no account", "bad chain"]), message: string() }), object({ ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, message: optional(array(string())), @@ -391,31 +392,23 @@ The admin should add a member using [addMember method](https://www.better-auth.c vValidator("header", optional(object({ encrypted: optional(string()) })), validatorHook({ debug })), async (c) => { const payload = c.req.valid("json"); - const verifyResponse = await betterAuth.api.verifySiweMessage({ - body: payload.verify, - request: c.req.raw, - asResponse: true, - }); - if (!verifyResponse.ok) { - const errorBody = parse(object({ code: string(), message: string() }), await verifyResponse.json()); - return c.json({ code: "no permission", message: errorBody.message }, 403); + const { message, signature, walletAddress: address } = payload.verify; + + if (!(await verifyMessage({ address, message, signature }))) { + return c.json({ code: "no permission", message: "invalid signature" }, 403); } - const headers = new Headers(); - headers.set("cookie", verifyResponse.headers.get("set-cookie") ?? ""); - const organizations = await betterAuth.api.listOrganizations({ headers }); - const source = organizations[0]?.id; - if (!source) return c.json({ code: "no organization" }, 403); - - const { success: canCreate } = await betterAuth.api.hasPermission({ - headers, - body: { - organizationId: source, - permissions: { - kyc: ["create"], - }, + + const account = await database.query.walletAddresses.findFirst({ + where: eq(walletAddresses.address, address), + with: { + user: { columns: { id: true }, with: { members: { columns: { organizationId: true, role: true } } } }, }, }); - if (!canCreate) return c.json({ code: "no permission" }, 403); + + if (!account) return c.json({ code: "no account", message: `no account found for address ${address}` }, 400); + const member = account.user.members[0]; + if (!member) return c.json({ code: "no organization" }, 403); + if (member.role !== "admin" && member.role !== "owner") return c.json({ code: "no permission" }, 403); const { credentialId } = c.req.valid("cookie"); const credential = await database.query.credentials.findFirst({ @@ -427,6 +420,10 @@ The admin should add a member using [addMember method](https://www.better-auth.c setContext("exa", { credential }); const siweMessage = parseSiweMessage(payload.verify.message); + + if (siweMessage.chainId !== chain.id) + return c.json({ code: "bad chain", message: `expected ${chain.id} but got ${siweMessage.chainId}` }, 400); + const { verify, ...body } = payload; const hash = "ciphertext" in body @@ -449,14 +446,13 @@ The admin should add a member using [addMember method](https://www.better-auth.c return c.json({ code: "no permission", message: "invalid statement" }, 403); } - if (credential.pandaId) { - return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409); - } + if (credential.pandaId) return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409); + try { const application = await submitApplication(payload, c.req.header("encrypted") === "true"); await database .update(credentials) - .set({ pandaId: application.id, source }) + .set({ pandaId: application.id, source: member.organizationId }) .where(eq(credentials.id, credentialId)); return c.json({ status: application.applicationStatus }, 200); } catch (error) { diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index d78d802ee..293c672ce 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -7,12 +7,11 @@ import canonicalize from "canonicalize"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; import crypto from "node:crypto"; -import { getAddress, padHex, sha256, zeroAddress, zeroHash } from "viem"; -import { mnemonicToAccount, privateKeyToAddress } from "viem/accounts"; -import { createSiweMessage } from "viem/siwe"; +import { getAddress, 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 deriveAddress from "@exactly/common/deriveAddress"; import chain from "@exactly/common/generated/chain"; import app from "../../api/kyc"; @@ -30,21 +29,6 @@ const appClient = testClient(app); vi.mock("@sentry/node", { spy: true }); describe("authenticated", () => { - const bob = privateKeyToAddress(padHex("0xb0b2")); - const account = deriveAddress(inject("ExaAccountFactory"), { x: padHex(bob), y: zeroHash }); - - beforeAll(async () => { - await database.insert(credentials).values([ - { - id: account, - publicKey: new Uint8Array(), - account, - factory: zeroAddress, - pandaId: "pandaId", - }, - ]); - }); - beforeEach(async () => { await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); }); @@ -1294,6 +1278,35 @@ describe("authenticated", () => { describe("with organization", () => { const owner = mnemonicToAccount("test test test test test test test test test test test kyc"); const ownerHeaders: Headers = new Headers(); + const outsider = mnemonicToAccount("test test test test test test test test test test test bob"); + const outsiderHeaders: Headers = new Headers(); + const account = "bob"; + + const applicationPayload = { + 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, + }; + let organizationId: string; beforeAll(async () => { @@ -1314,7 +1327,7 @@ describe("authenticated", () => { domain: "localhost", }); - const adminResponse = await auth.api.verifySiweMessage({ + const ownerLogin = await auth.api.verifySiweMessage({ body: { message: ownerMessage, signature: await owner.signMessage({ message: ownerMessage }), @@ -1324,7 +1337,7 @@ describe("authenticated", () => { request: new Request("https://localhost"), asResponse: true, }); - ownerHeaders.set("cookie", `${adminResponse.headers.get("set-cookie")}`); + ownerHeaders.set("cookie", ownerLogin.headers.get("set-cookie") ?? ""); const externalOrganization = await auth.api.createOrganization({ headers: ownerHeaders, @@ -1335,6 +1348,35 @@ describe("authenticated", () => { }, }); organizationId = externalOrganization?.id ?? ""; + + await auth.api + .getSiweNonce({ + body: { walletAddress: outsider.address, chainId: chain.id }, + }) + .then((result) => { + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: result.nonce, + uri: `https://localhost`, + address: outsider.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + return outsider.signMessage({ message }).then((signature) => { + return auth.api + .verifySiweMessage({ + body: { message, signature, walletAddress: outsider.address, chainId: chain.id }, + request: new Request("https://localhost"), + asResponse: true, + }) + .then((response) => { + outsiderHeaders.set("cookie", response.headers.get("set-cookie") ?? ""); + }); + }); + }); }); describe("status", () => { @@ -1390,14 +1432,14 @@ describe("authenticated", () => { }); it("returns ok when payload is valid and kyc is not started", async () => { - const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; - const { nonce } = await auth.api.getSiweNonce({ - body: { walletAddress: owner.address, chainId: chain.id }, + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; const message = createSiweMessage({ statement, resources: ["https://exactly.github.io/exa"], - nonce, + nonce: generateSiweNonce(), uri: `https://sandbox.exactly.app`, address: owner.address, chainId: chain.id, @@ -1453,14 +1495,15 @@ describe("authenticated", () => { }); it("returns 409 when kyc is already started", async () => { - const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; - const { nonce } = await auth.api.getSiweNonce({ - body: { walletAddress: owner.address, chainId: chain.id }, + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, account)); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; const message = createSiweMessage({ statement, resources: ["https://exactly.github.io/exa"], - nonce, + nonce: generateSiweNonce(), uri: `https://sandbox.exactly.app`, address: owner.address, chainId: chain.id, @@ -1506,14 +1549,14 @@ describe("authenticated", () => { }); it("returns 400 if terms of service are not accepted", async () => { - const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; - const { nonce } = await auth.api.getSiweNonce({ - body: { walletAddress: owner.address, chainId: chain.id }, + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; const message = createSiweMessage({ statement, resources: ["https://exactly.github.io/exa"], - nonce, + nonce: generateSiweNonce(), uri: `https://sandbox.exactly.app`, address: owner.address, chainId: chain.id, @@ -1567,15 +1610,15 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE } it("returns ok when payload is valid", async () => { - const encryptedPayload = encrypt(JSON.stringify(applicationPayload)); - const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(encryptedPayload.ciphertext)}`; - const { nonce } = await auth.api.getSiweNonce({ - body: { walletAddress: owner.address, chainId: chain.id }, + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), }); + const encryptedPayload = encrypt(JSON.stringify(applicationPayload)); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(encryptedPayload.ciphertext)}`; const message = createSiweMessage({ statement, resources: ["https://exactly.github.io/exa"], - nonce, + nonce: generateSiweNonce(), uri: `https://sandbox.exactly.app`, address: owner.address, chainId: chain.id, @@ -1644,11 +1687,51 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE }); await expect(response.json()).resolves.toStrictEqual({ status: "approved" }); }); + + it("returns 403 no organization", async () => { + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const encryptedPayload = encrypt(JSON.stringify(applicationPayload)); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(encryptedPayload.ciphertext)}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: outsider.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + + const response = await appClient.application.$post( + { + json: { + key: encryptedPayload.key.toString("base64"), + iv: encryptedPayload.iv.toString("base64"), + ciphertext: encryptedPayload.ciphertext.toString("base64"), + tag: encryptedPayload.tag.toString("base64"), + verify: { + message, + signature: await outsider.signMessage({ message }), + walletAddress: outsider.address, + chainId: chain.id, + }, + }, + }, + { headers: { "test-credential-id": account, SessionID: "fakeSession", encrypted: "true" } }, + ); + + expect(response.status).toBe(403); + }); }); }); describe("update", () => { it("returns ok when kyc is started", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, account)); const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true, status: 200, @@ -1989,29 +2072,3 @@ const inquiry = { }, }, } as const; - -const applicationPayload = { - firstName: "john", - lastName: "doe", - birthDate: "1990-01-15", - nationalId: "123456789", - countryOfIssue: "AA", - email: "john.doe@example.com", - phoneCountryCode: "1", - phoneNumber: "5551234567", - ipAddress: "192.168.1.1", - occupation: "occupation", - annualSalary: "1234", - accountPurpose: "purpose", - expectedMonthlyVolume: "1234", - isTermsOfServiceAccepted: true, - address: { - line1: "123 main street", - line2: "apt 1", - city: "city", - region: "region", - postalCode: "1234", - countryCode: "AA", - country: "country", - }, -} as const; diff --git a/server/utils/panda.ts b/server/utils/panda.ts index ae9d4c9c2..58ec23c9a 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -43,7 +43,7 @@ import chain, { upgradeableModularAccountAbi, } from "@exactly/common/generated/chain"; import { PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; -import { Address, Hash } from "@exactly/common/validation"; +import { Address, Hash, Hex } from "@exactly/common/validation"; import { proposalManager } from "@exactly/plugin/deploy.json"; import ServiceError from "./ServiceError"; @@ -503,7 +503,7 @@ export const Application = object({ literal(true), metadata({ description: "Whether the user has accepted the terms of service" }), ), - verify: object({ message: string(), signature: string(), walletAddress: string(), chainId: number() }), + verify: object({ message: string(), signature: Hex, walletAddress: Address, chainId: number() }), }); export const SubmitApplicationRequest = union([ @@ -513,7 +513,7 @@ export const SubmitApplicationRequest = union([ iv: string(), ciphertext: string(), tag: string(), - verify: object({ message: string(), signature: string(), walletAddress: string(), chainId: number() }), + verify: object({ message: string(), signature: Hex, walletAddress: Address, chainId: number() }), }), ]); From a3e32f699e902e013454e682f9e0ec0b2bc9ba8b Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Fri, 12 Dec 2025 16:08:09 -0300 Subject: [PATCH 07/12] =?UTF-8?q?=F0=9F=97=83=EF=B8=8F=20server:=20add=20r?= =?UTF-8?q?ole=20to=20organization=20database=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/sharp-squids-push.md | 5 +++++ server/database/schema.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/sharp-squids-push.md diff --git a/.changeset/sharp-squids-push.md b/.changeset/sharp-squids-push.md new file mode 100644 index 000000000..02c1901c8 --- /dev/null +++ b/.changeset/sharp-squids-push.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🗃️ add role to organization database table diff --git a/server/database/schema.ts b/server/database/schema.ts index 6fb1d0457..68684f87d 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -214,6 +214,7 @@ export const organizations = pgTable("organizations", { logo: text("logo"), createdAt: timestamp("created_at").notNull(), metadata: text("metadata"), + role: text("role"), }); export const members = pgTable( From 30b07083b459e70bacc1aa996a6472ede5447ced Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 12 Nov 2025 12:27:09 -0300 Subject: [PATCH 08/12] =?UTF-8?q?=E2=9C=A8=20server:=20add=20kyc=20role=20?= =?UTF-8?q?to=20organizations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/upset-seas-sink.md | 5 ++++ server/api/kyc.ts | 14 ++++++++--- server/test/api/kyc.test.ts | 46 ++++++++++++++++++++++++++++++++++- server/utils/auth.ts | 3 +++ 4 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 .changeset/upset-seas-sink.md diff --git a/.changeset/upset-seas-sink.md b/.changeset/upset-seas-sink.md new file mode 100644 index 000000000..e46154ae8 --- /dev/null +++ b/.changeset/upset-seas-sink.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add kyc role to organizations diff --git a/server/api/kyc.ts b/server/api/kyc.ts index 7f79d1012..7f643d40c 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -397,11 +397,18 @@ The admin should add a member using [addMember method](https://www.better-auth.c if (!(await verifyMessage({ address, message, signature }))) { return c.json({ code: "no permission", message: "invalid signature" }, 403); } - const account = await database.query.walletAddresses.findFirst({ where: eq(walletAddresses.address, address), with: { - user: { columns: { id: true }, with: { members: { columns: { organizationId: true, role: true } } } }, + user: { + columns: { id: true }, + with: { + members: { + columns: { role: true }, + with: { organization: { columns: { id: true, role: true } } }, + }, + }, + }, }, }); @@ -409,6 +416,7 @@ The admin should add a member using [addMember method](https://www.better-auth.c const member = account.user.members[0]; if (!member) return c.json({ code: "no organization" }, 403); 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({ @@ -452,7 +460,7 @@ The admin should add a member using [addMember method](https://www.better-auth.c const application = await submitApplication(payload, c.req.header("encrypted") === "true"); await database .update(credentials) - .set({ pandaId: application.id, source: member.organizationId }) + .set({ pandaId: application.id, source: member.organization.id }) .where(eq(credentials.id, credentialId)); return c.json({ status: application.applicationStatus }, 200); } catch (error) { diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index 293c672ce..b0f0dcaa1 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -15,7 +15,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } fr import chain from "@exactly/common/generated/chain"; import app from "../../api/kyc"; -import database, { credentials, sources } from "../../database"; +import database, { credentials, organizations, sources } from "../../database"; import auth from "../../utils/auth"; import * as panda from "../../utils/panda"; import * as persona from "../../utils/persona"; @@ -1348,6 +1348,7 @@ describe("authenticated", () => { }, }); organizationId = externalOrganization?.id ?? ""; + await database.update(organizations).set({ role: "kyc" }).where(eq(organizations.id, organizationId)); await auth.api .getSiweNonce({ @@ -1726,6 +1727,49 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE expect(response.status).toBe(403); }); + + it("returns 403 no permission when organization role is not kyc", async () => { + await database.update(organizations).set({ role: null }).where(eq(organizations.id, organizationId)); + try { + const credential = await database.query.credentials.findFirst({ where: eq(credentials.id, account) }); + const encryptedPayload = encrypt(JSON.stringify(applicationPayload)); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(encryptedPayload.ciphertext)}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + + const response = await appClient.application.$post( + { + json: { + key: encryptedPayload.key.toString("base64"), + iv: encryptedPayload.iv.toString("base64"), + ciphertext: encryptedPayload.ciphertext.toString("base64"), + tag: encryptedPayload.tag.toString("base64"), + verify: { + message, + signature: await owner.signMessage({ message }), + walletAddress: owner.address, + chainId: chain.id, + }, + }, + }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no permission" }); + } finally { + await database.update(organizations).set({ role: "kyc" }).where(eq(organizations.id, organizationId)); + } + }); }); }); diff --git a/server/utils/auth.ts b/server/utils/auth.ts index f042562a3..cbe51b40e 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -67,6 +67,9 @@ export default betterAuth({ ...memberAc.statements, }), }, + additionalFields: { + role: { type: "string", required: false, input: false }, + }, allowUserToCreateOrganization: () => true, }), ], From f46f4bb38b50d50f2b37b53cb1eb1278c15124f0 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 12 Feb 2026 15:35:52 -0300 Subject: [PATCH 09/12] =?UTF-8?q?=F0=9F=93=9D=20docs:=20add=20kyc=20permis?= =?UTF-8?q?sions=20requirement=20in=20organization=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/src/content/docs/organization-authentication.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/src/content/docs/organization-authentication.md b/docs/src/content/docs/organization-authentication.md index 0ce439f5b..4f6152946 100644 --- a/docs/src/content/docs/organization-authentication.md +++ b/docs/src/content/docs/organization-authentication.md @@ -10,6 +10,9 @@ Then the owner can add members with admin role and those admins will be able to Better auth client and viem are the recommended libraries to use for authentication and signing using SIWE. +> ⚠️ **Note:** +> If you need to perform an encrypted KYC operation, please ask the exa team for `kyc` permissions. + ## SIWE Authentication Example code to authenticate using SIWE, it will create the user if doesn't exist. From 3096e10ac8c4fa4aa5378ca05810df4857d8842e Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 9 Apr 2026 15:57:13 -0300 Subject: [PATCH 10/12] =?UTF-8?q?=E2=9A=B0=EF=B8=8F=20server:=20drop=20kyc?= =?UTF-8?q?=20error=20in=20favor=20of=20service=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/kyc.ts | 8 +-- server/test/api/kyc.test.ts | 139 +++++++++++++++++++++++++++++++++++- server/utils/panda.ts | 52 +------------- 3 files changed, 143 insertions(+), 56 deletions(-) diff --git a/server/api/kyc.ts b/server/api/kyc.ts index 7f643d40c..500716753 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -24,7 +24,6 @@ import { SubmitApplicationRequest as Application, UpdateApplicationRequest as ApplicationUpdate, getApplicationStatus, - KycError, submitApplication, updateApplication, } from "../utils/panda"; @@ -39,6 +38,7 @@ import { scopeValidationErrors, } from "../utils/persona"; import publicClient from "../utils/publicClient"; +import ServiceError from "../utils/ServiceError"; import validatorHook from "../utils/validatorHook"; const debug = createDebug("exa:kyc"); @@ -464,14 +464,12 @@ The admin should add a member using [addMember method](https://www.better-auth.c .where(eq(credentials.id, credentialId)); return c.json({ status: application.applicationStatus }, 200); } catch (error) { - if (error instanceof KycError) { - switch (error.statusCode) { + if (error instanceof ServiceError) { + switch (error.status) { case 400: return c.json({ code: "invalid encryption", message: error.message }, 400); case 401: return c.json({ code: "invalid payload", message: error.message }, 401); - default: - return c.json({ code: error.message }, 401); } } throw error; diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index b0f0dcaa1..330345e0e 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -21,6 +21,7 @@ import * as panda from "../../utils/panda"; import * as persona from "../../utils/persona"; import { scopeValidationErrors } from "../../utils/persona"; import publicClient from "../../utils/publicClient"; +import ServiceError from "../../utils/ServiceError"; import type * as v from "valibot"; @@ -1553,7 +1554,7 @@ describe("authenticated", () => { const credential = await database.query.credentials.findFirst({ where: eq(credentials.id, account), }); - const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; const message = createSiweMessage({ statement, resources: ["https://exactly.github.io/exa"], @@ -1771,6 +1772,142 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE } }); }); + + describe("panda errors", () => { + it("returns invalid encryption on bad request", async () => { + vi.spyOn(panda, "submitApplication").mockRejectedValueOnce( + new ServiceError("Panda", 400, '{"message":"bad encryption"}', undefined, "bad encryption"), + ); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + const verify = { message, signature, walletAddress: owner.address, chainId: chain.id }; + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const initialCalls = vi.mocked(captureException).mock.calls.length; + + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "invalid encryption", + message: "bad encryption", + }); + expect(vi.mocked(captureException).mock.calls.slice(initialCalls)).toStrictEqual([]); + }); + + it("returns invalid payload on unauthorized", async () => { + vi.spyOn(panda, "submitApplication").mockRejectedValueOnce( + new ServiceError("Panda", 401, '{"message":"invalid data"}', undefined, "invalid data"), + ); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + const verify = { message, signature, walletAddress: owner.address, chainId: chain.id }; + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const initialCalls = vi.mocked(captureException).mock.calls.length; + + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ + code: "invalid payload", + message: "invalid data", + }); + expect(vi.mocked(captureException).mock.calls.slice(initialCalls)).toStrictEqual([]); + }); + + it("propagates panda errors with unexpected status to global handler", async () => { + vi.spyOn(panda, "submitApplication").mockRejectedValueOnce( + new ServiceError("Panda", 500, '{"message":"server error"}', undefined, "server error"), + ); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + const verify = { message, signature, walletAddress: owner.address, chainId: chain.id }; + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(500); + }); + + it("propagates non-panda errors to global handler", async () => { + vi.spyOn(panda, "submitApplication").mockRejectedValueOnce(new Error("network failure")); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + const verify = { message, signature, walletAddress: owner.address, chainId: chain.id }; + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(500); + }); + }); }); describe("update", () => { diff --git a/server/utils/panda.ts b/server/utils/panda.ts index 58ec23c9a..aecb57686 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -29,7 +29,7 @@ import { type BaseSchema, type InferInput, } from "valibot"; -import { BaseError, ContractFunctionZeroDataError, type MaybePromise } from "viem"; +import { BaseError, ContractFunctionZeroDataError } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { base, optimism } from "viem/chains"; @@ -174,7 +174,6 @@ async function request>( body?: unknown, method: "GET" | "PATCH" | "POST" | "PUT" = body === undefined ? "GET" : "POST", timeout = 10_000, - onError?: (response: Response) => MaybePromise, ) { const response = await fetch(`${baseURL}${url}`, { method, @@ -386,16 +385,6 @@ export async function submitApplication(payload: InferInput { - const text = await response.text(); - try { - const error = parse(object({ message: string() }), JSON.parse(text)); - throw new KycError(error.message, response.status); - } catch (error) { - if (error instanceof KycError) throw error; - throw new Error(`${response.status} ${text}`); - } - }, ); } @@ -407,38 +396,11 @@ export async function getApplicationStatus(applicationId: string) { undefined, "GET", 10_000, - async (response) => { - const text = await response.text(); - try { - const error = parse(object({ message: string() }), JSON.parse(text)); - throw new KycError(error.message, response.status); - } catch (error) { - if (error instanceof KycError) throw error; - throw new Error(`${response.status} ${text}`); - } - }, ); } export async function updateApplication(applicationId: string, payload: InferInput) { - return request( - object({}), - `/issuing/applications/user/${applicationId}`, - {}, - payload, - "PATCH", - 10_000, - async (response) => { - const text = await response.text(); - try { - const error = parse(object({ message: string() }), JSON.parse(text)); - throw new KycError(error.message, response.status); - } catch (error) { - if (error instanceof KycError) throw error; - throw new Error(`${response.status} ${text}`); - } - }, - ); + return request(object({}), `/issuing/applications/user/${applicationId}`, {}, payload, "PATCH", 10_000); } const AddressSchema = object({ @@ -545,14 +507,4 @@ const ApplicationStatusResponse = object({ applicationReason: optional(string()), }); -export class KycError extends Error { - constructor( - message: string, - public statusCode: number, - ) { - super(message); - this.name = "KycError"; - } -} - // #endregion schemas From ece07d71d7ab4e9ba297a7be3ebb97752088975b Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 9 Apr 2026 16:59:55 -0300 Subject: [PATCH 11/12] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20server:=20strip?= =?UTF-8?q?=20siwe=20from=20panda=20application?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/kyc.ts | 26 +++++++++++++++++++++----- server/test/api/kyc.test.ts | 5 ++--- server/utils/panda.ts | 13 ++----------- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/server/api/kyc.ts b/server/api/kyc.ts index 500716753..0b0f5ffe2 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -5,7 +5,7 @@ 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, object, optional, parse, picklist, pipe, string, union } from "valibot"; +import { array, literal, metadata, number, object, optional, parse, picklist, pipe, string, union } from "valibot"; import { getAddress, sha256, verifyMessage } from "viem"; import { parseSiweMessage } from "viem/siwe"; @@ -15,13 +15,13 @@ import chain, { exaPluginAddress, upgradeableModularAccountAbi, } from "@exactly/common/generated/chain"; -import { Address } from "@exactly/common/validation"; +import { Address, Hex } from "@exactly/common/validation"; import database, { credentials, walletAddresses } from "../database/index"; import auth from "../middleware/auth"; import decodePublicKey from "../utils/decodePublicKey"; import { - SubmitApplicationRequest as Application, + Application, UpdateApplicationRequest as ApplicationUpdate, getApplicationStatus, submitApplication, @@ -388,7 +388,23 @@ The admin should add a member using [addMember method](https://www.better-auth.c }, validateResponse: true, }), - vValidator("json", Application, 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() }), + }), + ]), + validatorHook({ debug }), + ), vValidator("header", optional(object({ encrypted: optional(string()) })), validatorHook({ debug })), async (c) => { const payload = c.req.valid("json"); @@ -457,7 +473,7 @@ The admin should add a member using [addMember method](https://www.better-auth.c if (credential.pandaId) return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409); try { - const application = await submitApplication(payload, c.req.header("encrypted") === "true"); + const application = await submitApplication(body, c.req.header("encrypted") === "true"); await database .update(credentials) .set({ pandaId: application.id, source: member.organization.id }) diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index 330345e0e..a8ad7c879 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -1492,7 +1492,7 @@ describe("authenticated", () => { method: "POST", }), ); - expect(JSON.parse(body as string)).toStrictEqual({ ...applicationPayload, verify }); + expect(JSON.parse(body as string)).toStrictEqual(applicationPayload); await expect(response.json()).resolves.toStrictEqual({ status: "approved" }); }); @@ -1538,7 +1538,7 @@ describe("authenticated", () => { it("returns 400 when payload is invalid", async () => { const response = await appClient.application.$post( - { json: {} as unknown as v.InferOutput }, + { json: {} as unknown as NonNullable[0]["json"]> }, { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, ); @@ -1685,7 +1685,6 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE iv: encryptedPayload.iv.toString("base64"), ciphertext: encryptedPayload.ciphertext.toString("base64"), tag: encryptedPayload.tag.toString("base64"), - verify, }); await expect(response.json()).resolves.toStrictEqual({ status: "approved" }); }); diff --git a/server/utils/panda.ts b/server/utils/panda.ts index aecb57686..7435bc195 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -43,7 +43,7 @@ import chain, { upgradeableModularAccountAbi, } from "@exactly/common/generated/chain"; import { PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; -import { Address, Hash, Hex } from "@exactly/common/validation"; +import { Address, Hash } from "@exactly/common/validation"; import { proposalManager } from "@exactly/plugin/deploy.json"; import ServiceError from "./ServiceError"; @@ -465,18 +465,11 @@ export const Application = object({ literal(true), metadata({ description: "Whether the user has accepted the terms of service" }), ), - verify: object({ message: string(), signature: Hex, walletAddress: Address, chainId: number() }), }); export const SubmitApplicationRequest = union([ Application, - object({ - key: string(), - iv: string(), - ciphertext: string(), - tag: string(), - verify: object({ message: string(), signature: Hex, walletAddress: Address, chainId: number() }), - }), + object({ key: string(), iv: string(), ciphertext: string(), tag: string() }), ]); export const UpdateApplicationRequest = object({ @@ -506,5 +499,3 @@ const ApplicationStatusResponse = object({ applicationStatus: picklist(kycStatus), applicationReason: optional(string()), }); - -// #endregion schemas From d7fa3958e29de23a55afe589798e72ccd4b80501 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Mon, 13 Apr 2026 09:54:27 -0300 Subject: [PATCH 12/12] =?UTF-8?q?=F0=9F=94=A5=20server:=20drop=20header=20?= =?UTF-8?q?from=20encrypted=20kyc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/calm-tigers-stop.md | 6 ++++++ server/api/kyc.ts | 8 +++----- server/test/api/kyc.test.ts | 8 ++++---- server/utils/panda.ts | 4 ++-- 4 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 .changeset/calm-tigers-stop.md diff --git a/.changeset/calm-tigers-stop.md b/.changeset/calm-tigers-stop.md new file mode 100644 index 000000000..1d9d4d795 --- /dev/null +++ b/.changeset/calm-tigers-stop.md @@ -0,0 +1,6 @@ +--- +"@exactly/server": patch +"@exactly/docs": patch +--- + +🔥 drop header from encrypted kyc diff --git a/server/api/kyc.ts b/server/api/kyc.ts index 0b0f5ffe2..8d8e947fb 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -226,7 +226,7 @@ Submit information for KYC application. **Encrypted kyc payload** -When the header has encrypted=true, the payload should be encrypted. +When the payload includes the \`ciphertext\` field (alongside \`key\`, \`iv\`, \`tag\`), it is treated as encrypted. Encryption is auto-detected from the payload shape. The steps to encrypt are: @@ -234,8 +234,7 @@ The steps to encrypt are: 2. Encrypt Payload: Use AES-256-GCM to encrypt your KYC JSON data 3. Encrypt AES Key: Use Rain-provided RSA public key with OAEP padding 4. Encode Components: Base64-encode all encrypted components -5. Set Header: Include encrypted: "true" header in your request -6. Submit Request +5. Submit Request KYC Encryption Public Key for sandbox is: @@ -405,7 +404,6 @@ The admin should add a member using [addMember method](https://www.better-auth.c ]), validatorHook({ debug }), ), - vValidator("header", optional(object({ encrypted: optional(string()) })), validatorHook({ debug })), async (c) => { const payload = c.req.valid("json"); const { message, signature, walletAddress: address } = payload.verify; @@ -473,7 +471,7 @@ The admin should add a member using [addMember method](https://www.better-auth.c if (credential.pandaId) return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409); try { - const application = await submitApplication(body, c.req.header("encrypted") === "true"); + const application = await submitApplication(body); await database .update(credentials) .set({ pandaId: application.id, source: member.organization.id }) diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index a8ad7c879..a78da448b 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -1348,7 +1348,7 @@ describe("authenticated", () => { keepCurrentActiveOrganization: false, }, }); - organizationId = externalOrganization?.id ?? ""; + organizationId = externalOrganization.id; await database.update(organizations).set({ role: "kyc" }).where(eq(organizations.id, organizationId)); await auth.api @@ -1662,7 +1662,7 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE verify, }, }, - { headers: { "test-credential-id": account, SessionID: "fakeSession", encrypted: "true" } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, ); const updatedCredential = await database.query.credentials.findFirst({ @@ -1675,9 +1675,9 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE expect(updatedCredential?.pandaId).toBe("pandaId"); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining("/issuing/applications/user"), - expect.objectContaining({ encrypted: "true" }), expect.objectContaining({ method: "POST", + headers: expect.objectContaining({ encrypted: "true" }), // eslint-disable-line @typescript-eslint/no-unsafe-assignment }), ); expect(JSON.parse(body as string)).toStrictEqual({ @@ -1722,7 +1722,7 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE }, }, }, - { headers: { "test-credential-id": account, SessionID: "fakeSession", encrypted: "true" } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, ); expect(response.status).toBe(403); diff --git a/server/utils/panda.ts b/server/utils/panda.ts index 7435bc195..4eec355be 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -377,11 +377,11 @@ export function getMutex(address: Address) { return mutexes.get(address); } -export async function submitApplication(payload: InferInput, encrypted = false) { +export async function submitApplication(payload: InferInput) { return request( ApplicationResponse, "/issuing/applications/user", - { ...(encrypted && { encrypted: "true" }) }, + { ...("ciphertext" in payload && { encrypted: "true" }) }, payload, "POST", 10_000,