diff --git a/.changeset/chilly-suns-dress.md b/.changeset/chilly-suns-dress.md new file mode 100644 index 000000000..e8c0c5abe --- /dev/null +++ b/.changeset/chilly-suns-dress.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add provisioning to card diff --git a/server/api/card.ts b/server/api/card.ts index 4c94d51e2..9605c017c 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -47,6 +47,7 @@ import { getCard, getNonce, getPIN, + getProcessorDetails, getSecrets, getUser, setPIN, @@ -92,6 +93,12 @@ const CardResponse = object({ }), productId: pipe(string(), metadata({ examples: ["402"] })), challenge: optional(pipe(string(), metadata({ examples: ["1a2b3c"] }))), + provisioning: optional( + object({ + id: pipe(string(), metadata({ examples: ["card_abc123"] })), + secret: pipe(string(), metadata({ examples: ["otp_xyz"] })), + }), + ), }); const CreatedCardResponse = object({ @@ -145,7 +152,7 @@ const UpdatedCardResponse = union([ object({ verification: literal("OK") }), ]); -const Scopes = picklist(["siwe", "webauthn"]); +const Scopes = picklist(["provisioning", "siwe", "webauthn"]); export default new Hono() .get( @@ -183,6 +190,8 @@ The \`sessionid\` header and the \`scope\` query parameter are independent and m - Provide \`sessionid\` to receive \`encryptedPan\`, \`encryptedCvc\`, and \`pin\`. Without it, only the card profile is returned. - Provide \`scope=siwe\` or \`scope=webauthn\` to receive a \`challenge\` to be signed and submitted via \`PATCH /\`. \`siwe\` and \`webauthn\` are mutually exclusive within a single request. +Successful responses include push-provisioning credentials in the \`provisioning\` field only when the \`scope=provisioning\` query parameter is sent. + **Retrieving encrypted card details** 1. **Generate a session ID**: Encrypt a 32‑character hexadecimal secret (no spaces/dashes) with the provided public RSA key using RSA‑OAEP. 2. **Send the request**: Include the encrypted secret in the header \`sessionid\` when calling this endpoint. @@ -273,9 +282,9 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str }, }), async (c) => { - const { scope } = c.req.valid("query"); + const query = c.req.valid("query"); function include(type: InferInput) { - return Array.isArray(scope) ? scope.includes(type) : scope === type; + return Array.isArray(query.scope) ? query.scope.includes(type) : query.scope === type; } const { credentialId } = c.req.valid("cookie"); const credential = await database.query.credentials.findFirst({ @@ -293,78 +302,84 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str setUser({ id: account }); if (!credential.pandaId) return c.json({ code: "no panda" }, 403); const sessionid = c.req.valid("header").sessionid; - if (credential.cards.length > 0 && credential.cards[0]) { - const { id, lastFour, status, mode, productId } = credential.cards[0]; - if (status === "DELETED") throw new Error("card deleted"); - const [{ expirationMonth, expirationYear, limit }, pan, user, pin, challenge] = await Promise.all([ - getCard(id), - sessionid && getSecrets(id, sessionid), - getUser(credential.pandaId).catch((error: unknown) => { - const issue = noUser(error); - if (!issue) throw error; - const shouldCapture = issue.error.status === 404 || status === "ACTIVE"; - if (shouldCapture) { - withScope((s) => { - s.addEventProcessor((event) => { - if (event.exception?.values?.[0]) event.exception.values[0].type = issue.type; - return event; - }); - captureException(issue.error, { - level: "warning", - fingerprint: ["{{ default }}", issue.type], - extra: { - cardId: id, - credentialId, - pandaId: credential.pandaId, - status, - shouldCapture, - userIssue: issue.type, - }, - }); + if (credential.cards.length === 0 || !credential.cards[0]) return c.json({ code: "no card" }, 404); + const { id, lastFour, status, mode, productId } = credential.cards[0]; + if (status === "DELETED") throw new Error("card deleted"); + const [{ expirationMonth, expirationYear, limit }, pan, user, pin, challenge, provisioning] = await Promise.all([ + getCard(id), + sessionid && getSecrets(id, sessionid), + getUser(credential.pandaId).catch((error: unknown) => { + const issue = noUser(error); + if (!issue) throw error; + const shouldCapture = issue.error.status === 404 || status === "ACTIVE"; + if (shouldCapture) { + withScope((scope) => { + scope.addEventProcessor((event) => { + if (event.exception?.values?.[0]) event.exception.values[0].type = issue.type; + return event; }); - } - return null; - }), - sessionid && getPIN(id, sessionid), - (async () => { - if (include("siwe")) { - if (!credential.pandaId) return; - return getNonce(credential.pandaId).then(({ nonce }) => - createSiweMessage({ - domain, - address: parse(Address, credentialId), - statement: `I authorize the account ${account} to be linked with the card ending in ${lastFour} for my user (${credential.pandaId})`, - uri: `https://${domain}`, - version: "1", - chainId: chain.id, - nonce, - }), - ); - } else if (include("webauthn")) { - return `I authorize the account ${account} to be linked with the card ending in ${lastFour} for my user (${credential.pandaId})`; - } - })(), - ]); - if (!user) return c.json({ code: "no panda" }, 403); - - return c.json( - { - ...(pan && { ...pan }), - ...(pin && { ...pin }), - displayName: `${user.firstName} ${user.lastName}`, - expirationMonth, - expirationYear, - lastFour, - mode, - provider: "panda" as const, - status, - limit, - productId, - ...(challenge && { challenge }), - } satisfies InferOutput, - 200, - ); - } else return c.json({ code: "no card" }, 404); + captureException(issue.error, { + level: "warning", + fingerprint: ["{{ default }}", issue.type], + extra: { + cardId: id, + credentialId, + pandaId: credential.pandaId, + status, + shouldCapture, + userIssue: issue.type, + }, + }); + }); + } + return null; + }), + sessionid && getPIN(id, sessionid), + (async () => { + if (include("siwe")) { + if (!credential.pandaId) return; + return getNonce(credential.pandaId).then(({ nonce }) => + createSiweMessage({ + domain, + address: parse(Address, credentialId), + statement: `I authorize the account ${account} to be linked with the card ending in ${lastFour} for my user (${credential.pandaId})`, + uri: `https://${domain}`, + version: "1", + chainId: chain.id, + nonce, + }), + ); + } else if (include("webauthn")) { + return `I authorize the account ${account} to be linked with the card ending in ${lastFour} for my user (${credential.pandaId})`; + } + })(), + include("provisioning") + ? getProcessorDetails(id).then(({ processorCardId, timeBasedSecret }) => ({ + id: processorCardId, + secret: timeBasedSecret, + })) + : undefined, + ]); + if (!user) return c.json({ code: "no panda" }, 403); + if (include("provisioning")) c.header("Cache-Control", "no-store"); + return c.json( + { + ...pan, + ...pin, + displayName: `${user.firstName} ${user.lastName}`, + expirationMonth, + expirationYear, + lastFour, + mode, + provider: "panda" as const, + status, + limit, + productId, + ...(challenge && { challenge }), + ...(provisioning && { provisioning }), + } satisfies InferOutput, + 200, + ); }, ) .post( diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index 75455aa04..2ed1bfcb5 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -4,8 +4,10 @@ import "../mocks/keeper"; import "../mocks/onesignal"; import "../mocks/pax"; import "../mocks/persona"; +import "../mocks/sardine"; import { eq } from "drizzle-orm"; +import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { testClient } from "hono/testing"; import { parse } from "valibot"; @@ -28,6 +30,8 @@ import * as pax from "../../utils/pax"; import * as persona from "../../utils/persona"; import ServiceError from "../../utils/ServiceError"; +import type { UnofficialStatusCode } from "hono/utils/http-status"; + const appClient = testClient(app); describe("authenticated", () => { @@ -122,6 +126,10 @@ describe("authenticated", () => { vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); + const processorDetails = vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-default", + timeBasedSecret: "secret-default", + }); vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); @@ -132,6 +140,7 @@ describe("authenticated", () => { const json = await response.json(); expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBeNull(); expect(json).toStrictEqual({ ...panTemplate, ...pinTemplate, @@ -145,6 +154,44 @@ describe("authenticated", () => { limit: { amount: 5000, frequency: "per24HourPeriod" }, productId: PLATINUM_PRODUCT_ID, }); + expect(processorDetails).not.toHaveBeenCalled(); + }); + + it("returns panda card provisioning when requested", async () => { + vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); + vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); + vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); + vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); + const processorDetails = vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-default", + timeBasedSecret: "secret-default", + }); + + vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); + + const response = await appClient.index.$get( + { header: { sessionid: "fakeSession" }, query: { scope: "provisioning" } }, + { headers: { "test-credential-id": "default" } }, + ); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(json).toStrictEqual({ + ...panTemplate, + ...pinTemplate, + displayName: "First Last", + expirationMonth: "9", + expirationYear: "2029", + lastFour: "1234", + mode: 0, + provider: "panda", + status: "ACTIVE", + limit: { amount: 5000, frequency: "per24HourPeriod" }, + productId: PLATINUM_PRODUCT_ID, + provisioning: { id: "proc-default", secret: "secret-default" }, + }); + expect(processorDetails).toHaveBeenCalledExactlyOnceWith("default"); }); it("returns panda card with signature product id", async () => { @@ -153,6 +200,10 @@ describe("authenticated", () => { vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); + const processorDetails = vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-sig", + timeBasedSecret: "secret-sig", + }); vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); @@ -163,6 +214,7 @@ describe("authenticated", () => { const json = await response.json(); expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBeNull(); expect(json).toStrictEqual({ ...panTemplate, ...pinTemplate, @@ -176,6 +228,7 @@ describe("authenticated", () => { limit: { amount: 5000, frequency: "per24HourPeriod" }, productId: SIGNATURE_PRODUCT_ID, }); + expect(processorDetails).not.toHaveBeenCalled(); }); it("returns 403 no panda when no panda customer", async () => { @@ -219,6 +272,10 @@ describe("authenticated", () => { "Not Found", ), ); + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-x", + timeBasedSecret: "secret-x", + }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -242,6 +299,10 @@ describe("authenticated", () => { "User exists but is not approved yet", ), ); + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-x", + timeBasedSecret: "secret-x", + }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -266,6 +327,10 @@ describe("authenticated", () => { "user exists but is not approved", ), ); + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-x", + timeBasedSecret: "secret-x", + }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -282,6 +347,10 @@ describe("authenticated", () => { vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); vi.spyOn(panda, "getUser").mockRejectedValueOnce(new ServiceError("Panda", 404, "", "NotFoundError")); + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-x", + timeBasedSecret: "secret-x", + }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -298,6 +367,10 @@ describe("authenticated", () => { vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); vi.spyOn(panda, "getUser").mockRejectedValueOnce(new HTTPException(500, { message: "unexpected panda failure" })); + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-x", + timeBasedSecret: "secret-x", + }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -320,6 +393,10 @@ describe("authenticated", () => { "User exists, but is not approved", ), ); + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-x", + timeBasedSecret: "secret-x", + }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -336,6 +413,10 @@ describe("authenticated", () => { vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); vi.spyOn(panda, "getUser").mockRejectedValueOnce(new HTTPException(500, { message: "internal server error" })); + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-x", + timeBasedSecret: "secret-x", + }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -1432,6 +1513,106 @@ describe("authenticated", () => { }); }); + it("propagates stale card provisioning errors", async () => { + 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, "getProcessorDetails").mockRejectedValueOnce(new ServiceError("Panda", 404, "not found")); + + const response = await appClient.index.$get( + { header: { sessionid: "fakeSession" }, query: { scope: "provisioning" } }, + { headers: { "test-credential-id": "default" } }, + ); + + expect(response.status).toBe(500); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("bubbles provisioning errors through parent onError", async () => { + 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, "getProcessorDetails").mockRejectedValueOnce(new ServiceError("Panda", 404, "not found")); + + const server = new Hono().route("/api/card", app); + server.onError((_error, c) => + c.json({ code: "unexpected error", legacy: "unexpected error" }, 555 as UnofficialStatusCode), + ); + + const response = await server.request("http://example.com/api/card?scope=provisioning", { + headers: { sessionid: "fakeSession", "test-credential-id": "default" }, + }); + + expect(response.status).toBe(555); + await expect(response.json()).resolves.toStrictEqual({ code: "unexpected error", legacy: "unexpected error" }); + }); + + it("propagates stale card provisioning errors when user lookup fails", async () => { + const stale = new ServiceError("Panda", 404, "not found"); + const forbidden = new ServiceError( + "Panda", + 403, + '{"message":"User exists but is not approved yet","error":"ForbiddenError","statusCode":403}', + "ForbiddenError", + "User exists but is not approved yet", + ); + vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); + vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); + vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); + vi.spyOn(panda, "getUser").mockRejectedValueOnce(forbidden); + vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(stale); + + const response = await appClient.index.$get( + { header: { sessionid: "fakeSession" }, query: { scope: "provisioning" } }, + { headers: { "test-credential-id": "default" } }, + ); + + expect(response.status).toBe(500); + }); + + it("propagates unapproved user provisioning errors", async () => { + 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, "getProcessorDetails").mockRejectedValueOnce( + new ServiceError( + "Panda", + 403, + '{"message":"User exists but is not approved yet","error":"ForbiddenError","statusCode":403}', + "ForbiddenError", + "User exists but is not approved yet", + ), + ); + + const response = await appClient.index.$get( + { header: { sessionid: "fakeSession" }, query: { scope: "provisioning" } }, + { headers: { "test-credential-id": "default" } }, + ); + + expect(response.status).toBe(500); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("returns 500 when provisioning reports unexpected error", async () => { + 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, "getProcessorDetails").mockRejectedValueOnce(new ServiceError("Panda", 500, "internal error")); + + const response = await appClient.index.$get( + { header: { sessionid: "fakeSession" }, query: { scope: "provisioning" } }, + { headers: { "test-credential-id": "default" } }, + ); + + expect(response.status).toBe(500); + expect(panda.getProcessorDetails).toHaveBeenCalledWith("default"); + expect(captureException).not.toHaveBeenCalled(); + }); + describe("migration", () => { it("creates a panda card having a cm card with upgraded plugin", async () => { await database.insert(cards).values([{ id: "cm", credentialId: "default", lastFour: "1234" }]); diff --git a/server/test/e2e.ts b/server/test/e2e.ts index 84b86b0a0..a0bcf0371 100644 --- a/server/test/e2e.ts +++ b/server/test/e2e.ts @@ -86,6 +86,11 @@ vi.mock("../utils/panda", async (importOriginal: () => Promise) => }), getCard: vi.fn().mockImplementation((cardId: string) => Promise.resolve(cards.get(cardId))), getPIN: vi.fn().mockResolvedValue({ pin: null }), + getProcessorDetails: vi + .fn() + .mockImplementation((cardId: string) => + Promise.resolve({ processorCardId: `proc_${cardId}`, timeBasedSecret: `secret_${cardId}` }), + ), getSecrets: vi.fn().mockImplementation((_cardId: string, sessionId: string) => { const privateKey = process.env.PANDA_E2E_PRIVATE_KEY; if (!privateKey) throw new Error("PANDA_E2E_PRIVATE_KEY not set"); diff --git a/server/utils/panda.ts b/server/utils/panda.ts index d38499078..3c1a0b28e 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -113,6 +113,13 @@ export async function getCard(cardId: string) { return await request(CardResponse, `/issuing/cards/${cardId}`); } +export function getProcessorDetails(cardId: string) { + return request( + object({ processorCardId: string(), timeBasedSecret: string() }), + `/issuing/cards/${cardId}/processorDetails`, + ); +} + export async function updateCard(card: { billing?: { city: string;