Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chilly-suns-dress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ add wallet provisioning endpoint
65 changes: 64 additions & 1 deletion server/api/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
getCard,
getPIN,
getProcessorDetails,
getSecrets,
getUser,
setPIN,
updateCard,
} from "../utils/panda";
import { addCapita, deriveAssociateId } from "../utils/pax";
import { getAccount } from "../utils/persona";
import { customer } from "../utils/sardine";
Expand Down Expand Up @@ -77,6 +87,8 @@ const CreatedCardResponse = object({
productId: pipe(string(), metadata({ examples: ["402"] })),
});

const WalletResponse = object({ cardId: string(), cardSecret: string() });

const UpdateCard = union([
pipe(
strictObject({ mode: pipe(number(), integer(), minValue(0), maxValue(MAX_INSTALLMENTS)) }),
Expand Down Expand Up @@ -566,6 +578,57 @@ async function encryptPIN(pin: string) {
if (!mutex.isLocked()) mutexes.delete(credentialId);
});
},
)
.get(
"/wallet",
auth(),
describeRoute({
summary: "Get wallet provisioning credentials",
tags: ["Card"],
security: [{ credentialAuth: [] }],
validateResponse: true,
responses: {
200: {
description: "Wallet provisioning credentials",
content: {
"application/json": {
schema: resolver(WalletResponse, { errorMode: "ignore" }),
},
},
},
403: {
description: "Forbidden",
content: {
"application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) },
},
},
404: {
description: "Not found",
content: {
"application/json": { schema: resolver(object({ code: literal("no card") }), { errorMode: "ignore" }) },
},
},
},
Comment on lines +590 to +611
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Document the 500 responses this route already emits.

validateResponse is enabled, but the handler returns 500 { code: "no credential" } on Line 620 and can propagate other 5xx failures on Lines 627-629. The OpenAPI block omits 500 entirely, so the contract does not match runtime behavior.

🩹 Proposed contract update
       responses: {
           description: "Wallet provisioning credentials",
           content: {
             "application/json": {
               schema: resolver(WalletResponse, { errorMode: "ignore" }),
             },
           },
         },
           description: "Forbidden",
           content: {
             "application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) },
           },
         },
           description: "Not found",
           content: {
             "application/json": { schema: resolver(object({ code: literal("no card") }), { errorMode: "ignore" }) },
           },
         },
+        500: {
+          description: "Internal server error",
+          content: {
+            "application/json": { schema: resolver(object({ code: literal("no credential") }), { errorMode: "ignore" }) },
+          },
+        },
       },

Also applies to: 620-629

}),
async (c) => {
const { credentialId } = c.req.valid("cookie");
const credential = await database.query.credentials.findFirst({
where: eq(credentials.id, credentialId),
columns: { pandaId: true, account: true },
with: { cards: { columns: { id: true }, where: inArray(cards.status, ["ACTIVE", "FROZEN"]) } },
});
if (!credential) return c.json({ code: "no credential" }, 500);
setUser({ id: parse(Address, credential.account) });
if (!credential.pandaId) return c.json({ code: "no panda" }, 403);
if (!credential.cards[0]) return c.json({ code: "no card" }, 404);
try {
const provisioning = await getProcessorDetails(credential.cards[0].id);
return c.json({ cardId: provisioning.processorCardId, cardSecret: provisioning.timeBasedSecret } satisfies InferOutput<typeof WalletResponse>, 200);
Comment on lines +582 to +626
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Protect cardSecret the same way card PAN/CVC are protected.

timeBasedSecret is wallet-provisioning material, but this route returns it in clear text behind only the signed auth cookie. The existing GET / flow requires a caller-provided sessionid and encrypts the returned card secrets; /wallet drops that extra protection for an equally sensitive credential. Please reuse the same session-bound envelope here, or another equivalent proof-of-possession mechanism, before returning cardSecret.

Comment on lines +615 to +626
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Choose the wallet card deterministically.

The relation query can return multiple ACTIVE/FROZEN rows, and credential.cards[0] then depends on database order. The schema shown for cards does not enforce a single non-deleted card per credential, so this endpoint can provision the wrong card if stale eligible rows coexist. Add an explicit ordering or otherwise constrain the query to the intended current card before calling Panda.

} catch (error) {
if (error instanceof ServiceError && error.status === 404) return c.json({ code: "no card" }, 404);
throw error;
}
},
);

const CardUUID = pipe(string(), uuid());
Expand Down
107 changes: 107 additions & 0 deletions server/test/api/card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,113 @@ describe("authenticated", () => {
expect(card?.status).toBe("DELETED");
});

describe("wallet", () => {
const walletCardId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
const walletFrozenCardId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";

beforeAll(async () => {
await database.insert(credentials).values([
{
id: "wallet-active",
publicKey: new Uint8Array(),
account: padHex("0xaa01", { size: 20 }),
factory: inject("ExaAccountFactory"),
pandaId: "wallet-active",
},
{
id: "wallet-frozen",
publicKey: new Uint8Array(),
account: padHex("0xaa02", { size: 20 }),
factory: inject("ExaAccountFactory"),
pandaId: "wallet-frozen",
},
{
id: "wallet-no-card",
publicKey: new Uint8Array(),
account: padHex("0xaa03", { size: 20 }),
factory: inject("ExaAccountFactory"),
pandaId: "wallet-no-card",
},
{
id: "wallet-no-panda",
publicKey: new Uint8Array(),
account: padHex("0xaa04", { size: 20 }),
factory: inject("ExaAccountFactory"),
},
]);
await database.insert(cards).values([
{ id: walletCardId, credentialId: "wallet-active", lastFour: "0001" },
{ id: walletFrozenCardId, credentialId: "wallet-frozen", lastFour: "0002", status: "FROZEN" },
{ id: "wallet-deleted", credentialId: "wallet-no-card", lastFour: "0003", status: "DELETED" },
]);
});

it("returns credentials for active card", async () => {
vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({
processorCardId: "proc-active",
timeBasedSecret: "secret-active",
});

const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-active" } });

expect(response.status).toBe(200);
await expect(response.json()).resolves.toStrictEqual({ cardId: "proc-active", cardSecret: "secret-active" });
expect(panda.getProcessorDetails).toHaveBeenCalledWith(walletCardId);
});

it("returns credentials for frozen card", async () => {
vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({
processorCardId: "proc-frozen",
timeBasedSecret: "secret-frozen",
});

const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-frozen" } });

expect(response.status).toBe(200);
await expect(response.json()).resolves.toStrictEqual({ cardId: "proc-frozen", cardSecret: "secret-frozen" });
expect(panda.getProcessorDetails).toHaveBeenCalledWith(walletFrozenCardId);
});

it("returns 500 when panda api fails", async () => {
vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(new ServiceError("Rain", 500, "internal error"));

const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-active" } });

expect(response.status).toBe(500);
});

it("returns 404 when panda card is stale", async () => {
vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(new ServiceError("Panda", 404, "not found"));

const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-active" } });

expect(response.status).toBe(404);
await expect(response.json()).resolves.toStrictEqual({ code: "no card" });
expect(panda.getProcessorDetails).toHaveBeenCalledWith(walletCardId);
});

it("returns 404 when only deleted card", async () => {
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-no-card" } });

expect(response.status).toBe(404);
await expect(response.json()).resolves.toStrictEqual({ code: "no card" });
});

it("returns 403 when no panda customer", async () => {
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "wallet-no-panda" } });

expect(response.status).toBe(403);
await expect(response.json()).resolves.toStrictEqual({ code: "no panda" });
});

it("returns 500 when credential not found", async () => {
const response = await appClient.wallet.$get({}, { headers: { "test-credential-id": "nonexistent" } });

expect(response.status).toBe(500);
await expect(response.json()).resolves.toStrictEqual({ code: "no credential" });
});
});

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" }]);
Expand Down
7 changes: 7 additions & 0 deletions server/utils/panda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ export async function getCard(cardId: string) {
return await request(CardResponse, `/issuing/cards/${cardId}`);
}

export async function getProcessorDetails(cardId: string) {
return await request(
object({ processorCardId: string(), timeBasedSecret: string() }),
`/issuing/cards/${cardId}/processorDetails`,
);
}

export async function updateCard(card: {
billing?: {
city: string;
Expand Down
Loading