Skip to content

Commit 62102c9

Browse files
committed
✨ server: add provisioning to card
1 parent 8d34f98 commit 62102c9

4 files changed

Lines changed: 282 additions & 74 deletions

File tree

.changeset/chilly-suns-dress.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@exactly/server": patch
3+
---
4+
5+
✨ add provisioning to card

server/api/card.ts

Lines changed: 89 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
getCard,
4848
getNonce,
4949
getPIN,
50+
getProcessorDetails,
5051
getSecrets,
5152
getUser,
5253
setPIN,
@@ -92,6 +93,12 @@ const CardResponse = object({
9293
}),
9394
productId: pipe(string(), metadata({ examples: ["402"] })),
9495
challenge: optional(pipe(string(), metadata({ examples: ["1a2b3c"] }))),
96+
provisioning: optional(
97+
object({
98+
id: pipe(string(), metadata({ examples: ["card_abc123"] })),
99+
secret: pipe(string(), metadata({ examples: ["otp_xyz"] })),
100+
}),
101+
),
95102
});
96103

97104
const CreatedCardResponse = object({
@@ -145,7 +152,7 @@ const UpdatedCardResponse = union([
145152
object({ verification: literal("OK") }),
146153
]);
147154

148-
const Scopes = picklist(["siwe", "webauthn"]);
155+
const Scopes = picklist(["provisioning", "siwe", "webauthn"]);
149156

150157
export default new Hono()
151158
.get(
@@ -183,6 +190,8 @@ The \`sessionid\` header and the \`scope\` query parameter are independent and m
183190
- Provide \`sessionid\` to receive \`encryptedPan\`, \`encryptedCvc\`, and \`pin\`. Without it, only the card profile is returned.
184191
- 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.
185192
193+
Successful responses include push-provisioning credentials in the \`provisioning\` field only when the \`scope=provisioning\` query parameter is sent.
194+
186195
**Retrieving encrypted card details**
187196
1. **Generate a session ID**: Encrypt a 32‑character hexadecimal secret (no spaces/dashes) with the provided public RSA key using RSA‑OAEP.
188197
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
273282
},
274283
}),
275284
async (c) => {
276-
const { scope } = c.req.valid("query");
285+
const query = c.req.valid("query");
277286
function include(type: InferInput<typeof Scopes>) {
278-
return Array.isArray(scope) ? scope.includes(type) : scope === type;
287+
return Array.isArray(query.scope) ? query.scope.includes(type) : query.scope === type;
279288
}
280289
const { credentialId } = c.req.valid("cookie");
281290
const credential = await database.query.credentials.findFirst({
@@ -293,78 +302,84 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str
293302
setUser({ id: account });
294303
if (!credential.pandaId) return c.json({ code: "no panda" }, 403);
295304
const sessionid = c.req.valid("header").sessionid;
296-
if (credential.cards.length > 0 && credential.cards[0]) {
297-
const { id, lastFour, status, mode, productId } = credential.cards[0];
298-
if (status === "DELETED") throw new Error("card deleted");
299-
const [{ expirationMonth, expirationYear, limit }, pan, user, pin, challenge] = await Promise.all([
300-
getCard(id),
301-
sessionid && getSecrets(id, sessionid),
302-
getUser(credential.pandaId).catch((error: unknown) => {
303-
const issue = noUser(error);
304-
if (!issue) throw error;
305-
const shouldCapture = issue.error.status === 404 || status === "ACTIVE";
306-
if (shouldCapture) {
307-
withScope((s) => {
308-
s.addEventProcessor((event) => {
309-
if (event.exception?.values?.[0]) event.exception.values[0].type = issue.type;
310-
return event;
311-
});
312-
captureException(issue.error, {
313-
level: "warning",
314-
fingerprint: ["{{ default }}", issue.type],
315-
extra: {
316-
cardId: id,
317-
credentialId,
318-
pandaId: credential.pandaId,
319-
status,
320-
shouldCapture,
321-
userIssue: issue.type,
322-
},
323-
});
305+
if (credential.cards.length === 0 || !credential.cards[0]) return c.json({ code: "no card" }, 404);
306+
const { id, lastFour, status, mode, productId } = credential.cards[0];
307+
if (status === "DELETED") throw new Error("card deleted");
308+
const [{ expirationMonth, expirationYear, limit }, pan, user, pin, challenge, provisioning] = await Promise.all([
309+
getCard(id),
310+
sessionid && getSecrets(id, sessionid),
311+
getUser(credential.pandaId).catch((error: unknown) => {
312+
const issue = noUser(error);
313+
if (!issue) throw error;
314+
const shouldCapture = issue.error.status === 404 || status === "ACTIVE";
315+
if (shouldCapture) {
316+
withScope((scope) => {
317+
scope.addEventProcessor((event) => {
318+
if (event.exception?.values?.[0]) event.exception.values[0].type = issue.type;
319+
return event;
324320
});
325-
}
326-
return null;
327-
}),
328-
sessionid && getPIN(id, sessionid),
329-
(async () => {
330-
if (include("siwe")) {
331-
if (!credential.pandaId) return;
332-
return getNonce(credential.pandaId).then(({ nonce }) =>
333-
createSiweMessage({
334-
domain,
335-
address: parse(Address, credentialId),
336-
statement: `I authorize the account ${account} to be linked with the card ending in ${lastFour} for my user (${credential.pandaId})`,
337-
uri: `https://${domain}`,
338-
version: "1",
339-
chainId: chain.id,
340-
nonce,
341-
}),
342-
);
343-
} else if (include("webauthn")) {
344-
return `I authorize the account ${account} to be linked with the card ending in ${lastFour} for my user (${credential.pandaId})`;
345-
}
346-
})(),
347-
]);
348-
if (!user) return c.json({ code: "no panda" }, 403);
349-
350-
return c.json(
351-
{
352-
...(pan && { ...pan }),
353-
...(pin && { ...pin }),
354-
displayName: `${user.firstName} ${user.lastName}`,
355-
expirationMonth,
356-
expirationYear,
357-
lastFour,
358-
mode,
359-
provider: "panda" as const,
360-
status,
361-
limit,
362-
productId,
363-
...(challenge && { challenge }),
364-
} satisfies InferOutput<typeof CardResponse>,
365-
200,
366-
);
367-
} else return c.json({ code: "no card" }, 404);
321+
captureException(issue.error, {
322+
level: "warning",
323+
fingerprint: ["{{ default }}", issue.type],
324+
extra: {
325+
cardId: id,
326+
credentialId,
327+
pandaId: credential.pandaId,
328+
status,
329+
shouldCapture,
330+
userIssue: issue.type,
331+
},
332+
});
333+
});
334+
}
335+
return null;
336+
}),
337+
sessionid && getPIN(id, sessionid),
338+
(async () => {
339+
if (include("siwe")) {
340+
if (!credential.pandaId) return;
341+
return getNonce(credential.pandaId).then(({ nonce }) =>
342+
createSiweMessage({
343+
domain,
344+
address: parse(Address, credentialId),
345+
statement: `I authorize the account ${account} to be linked with the card ending in ${lastFour} for my user (${credential.pandaId})`,
346+
uri: `https://${domain}`,
347+
version: "1",
348+
chainId: chain.id,
349+
nonce,
350+
}),
351+
);
352+
} else if (include("webauthn")) {
353+
return `I authorize the account ${account} to be linked with the card ending in ${lastFour} for my user (${credential.pandaId})`;
354+
}
355+
})(),
356+
include("provisioning")
357+
? getProcessorDetails(id).then(({ processorCardId, timeBasedSecret }) => ({
358+
id: processorCardId,
359+
secret: timeBasedSecret,
360+
}))
361+
: undefined,
362+
]);
363+
if (!user) return c.json({ code: "no panda" }, 403);
364+
if (include("provisioning")) c.header("Cache-Control", "no-store");
365+
return c.json(
366+
{
367+
...pan,
368+
...pin,
369+
displayName: `${user.firstName} ${user.lastName}`,
370+
expirationMonth,
371+
expirationYear,
372+
lastFour,
373+
mode,
374+
provider: "panda" as const,
375+
status,
376+
limit,
377+
productId,
378+
...(challenge && { challenge }),
379+
...(provisioning && { provisioning }),
380+
} satisfies InferOutput<typeof CardResponse>,
381+
200,
382+
);
368383
},
369384
)
370385
.post(

0 commit comments

Comments
 (0)