diff --git a/.changeset/twco-dcxi-kedl.md b/.changeset/twco-dcxi-kedl.md new file mode 100644 index 000000000..bd306ca29 --- /dev/null +++ b/.changeset/twco-dcxi-kedl.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🥅 add bridge credential fallback and retry on timeout diff --git a/server/database/schema.ts b/server/database/schema.ts index 1829cc5bb..27f8c9fa7 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -35,7 +35,7 @@ export const credentials = pgTable( bridgeId: text("bridge_id"), source: text("source"), }, - ({ account, bridgeId }) => [uniqueIndex("account_index").on(account), index("bridge_id_index").on(bridgeId)], + ({ account, bridgeId }) => [uniqueIndex("account_index").on(account), uniqueIndex("bridge_id_index").on(bridgeId)], ); export const cards = pgTable( diff --git a/server/hooks/bridge.ts b/server/hooks/bridge.ts index 790c3a659..8601011a1 100644 --- a/server/hooks/bridge.ts +++ b/server/hooks/bridge.ts @@ -1,7 +1,7 @@ import { vValidator } from "@hono/valibot-validator"; -import { captureException, setUser } from "@sentry/core"; +import { captureEvent, captureException, setUser } from "@sentry/core"; import createDebug from "debug"; -import { eq } from "drizzle-orm"; +import { and, DrizzleQueryError, eq, isNull } from "drizzle-orm"; import { Hono } from "hono"; import { validator } from "hono/validator"; import { createHash, createVerify } from "node:crypto"; @@ -11,7 +11,8 @@ import { Address } from "@exactly/common/validation"; import database, { credentials } from "../database"; import { sendPushNotification } from "../utils/onesignal"; -import { BridgeCurrency, publicKey } from "../utils/ramps/bridge"; +import { searchAccounts } from "../utils/persona"; +import { BridgeCurrency, getCustomer, publicKey } from "../utils/ramps/bridge"; import { track } from "../utils/segment"; import validatorHook from "../utils/validatorHook"; @@ -98,10 +99,57 @@ export default new Hono().post( payload.event_type === "customer.updated.status_transitioned" ? payload.event_object.id : payload.event_object.customer_id; - const credential = await database.query.credentials.findFirst({ + let credential = await database.query.credentials.findFirst({ columns: { account: true, source: true }, where: eq(credentials.bridgeId, bridgeId), }); + if (!credential && payload.event_type === "customer.updated.status_transitioned") { + credential = await getCustomer(bridgeId) + .then((customer) => (customer ? searchAccounts(customer.email) : undefined)) + .then((accounts) => { + if (accounts && accounts.length > 1) + captureException(new Error("multiple persona accounts found"), { + level: "fatal", + contexts: { details: { bridgeId, matches: accounts.length } }, + }); + return accounts?.length === 1 ? accounts[0]?.attributes["reference-id"] : undefined; + }) + .then((referenceId) => + referenceId + ? database + .update(credentials) + .set({ bridgeId }) + .where(and(eq(credentials.id, referenceId), isNull(credentials.bridgeId))) + .returning({ account: credentials.account, source: credentials.source }) + .then(([updated]) => { + if (!updated) throw new Error("credential already paired"); + captureEvent({ + message: "bridge credential paired", + level: "warning", + contexts: { details: { bridgeId, referenceId } }, + }); + return updated; + }) + .catch((error: unknown): undefined => { + if ( + !( + (error instanceof Error && error.message === "credential already paired") || + (error instanceof DrizzleQueryError && + error.cause && + "code" in error.cause && + error.cause.code === "23505") + ) + ) + throw error; + captureEvent({ + message: "bridge credential already paired", + level: "fatal", + contexts: { details: { bridgeId } }, + }); + }) + : undefined, + ); + } if (!credential) { captureException(new Error("credential not found"), { level: "error", diff --git a/server/test/api/ramp.test.ts b/server/test/api/ramp.test.ts index 48d719590..55cfbe8af 100644 --- a/server/test/api/ramp.test.ts +++ b/server/test/api/ramp.test.ts @@ -908,6 +908,7 @@ const mantecaUser = { const bridgeCustomer = { id: "bridge-customer-123", + email: "test@example.com", status: "active" as const, endorsements: [], }; diff --git a/server/test/hooks/bridge.test.ts b/server/test/hooks/bridge.test.ts index efaf00585..15b1e165d 100644 --- a/server/test/hooks/bridge.test.ts +++ b/server/test/hooks/bridge.test.ts @@ -1,7 +1,8 @@ import "../mocks/onesignal"; import "../mocks/sentry"; -import { captureException } from "@sentry/core"; +import { captureEvent, captureException } from "@sentry/core"; +import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; import { createHash, createPrivateKey, createSign, generateKeyPairSync } from "node:crypto"; import { hexToBytes, padHex, zeroHash } from "viem"; @@ -13,14 +14,19 @@ import deriveAddress from "@exactly/common/deriveAddress"; import database, { credentials } from "../../database"; import app from "../../hooks/bridge"; import * as onesignal from "../../utils/onesignal"; +import * as persona from "../../utils/persona"; +import * as bridge from "../../utils/ramps/bridge"; import * as segment from "../../utils/segment"; const appClient = testClient(app); describe("bridge hook", () => { const owner = privateKeyToAddress(padHex("0xb1e")); + const fallbackOwner = privateKeyToAddress(padHex("0xfa11")); + const conflictOwner = privateKeyToAddress(padHex("0xc0f1")); const factory = inject("ExaAccountFactory"); const account = deriveAddress(factory, { x: padHex(owner), y: zeroHash }); + const fallbackAccount = deriveAddress(factory, { x: padHex(fallbackOwner), y: zeroHash }); beforeAll(async () => { await database.insert(credentials).values([ @@ -32,6 +38,19 @@ describe("bridge hook", () => { pandaId: "bridgePandaId", bridgeId: "bridgeCustomerId", }, + { + id: "fallback-test", + publicKey: new Uint8Array(hexToBytes(fallbackOwner)), + account: fallbackAccount, + factory, + }, + { + id: "conflict-test", + publicKey: new Uint8Array(hexToBytes(conflictOwner)), + account: deriveAddress(factory, { x: padHex(conflictOwner), y: zeroHash }), + factory, + bridgeId: "conflict-bridge-id", + }, ]); }); @@ -202,6 +221,169 @@ describe("bridge hook", () => { ); }); + it("resolves credential via persona email fallback on status_transitioned", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + vi.spyOn(bridge, "getCustomer").mockResolvedValue({ + id: "fallback-bridge-id", + email: "fallback@example.com", + status: "active", + endorsements: [], + }); + vi.spyOn(persona, "searchAccounts").mockResolvedValue([{ attributes: { "reference-id": "fallback-test" } }]); + const payload = { + ...statusTransitioned, + event_object: { ...statusTransitioned.event_object, id: "fallback-bridge-id" }, + }; + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(payload) }, + json: payload as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(captureException).not.toHaveBeenCalled(); + expect(captureEvent).toHaveBeenCalledWith( + expect.objectContaining({ + message: "bridge credential paired", + level: "warning", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + contexts: expect.objectContaining({ + details: { bridgeId: "fallback-bridge-id", referenceId: "fallback-test" }, + }), + }), + ); + const updated = await database.query.credentials.findFirst({ + columns: { bridgeId: true }, + where: eq(credentials.id, "fallback-test"), + }); + expect(updated?.bridgeId).toBe("fallback-bridge-id"); + expect(segment.track).toHaveBeenCalledWith({ + userId: fallbackAccount, + event: "RampAccount", + properties: { provider: "bridge", source: null }, + }); + expect(sendPushNotification).toHaveBeenCalledWith({ + userId: fallbackAccount, + headings: { en: "Fiat onramp activated" }, + contents: { en: "Your fiat onramp account has been activated" }, + }); + }); + + it("returns 200 with already paired on duplicate bridgeId fallback", async () => { + vi.spyOn(database.query.credentials, "findFirst").mockResolvedValueOnce(undefined); // eslint-disable-line unicorn/no-useless-undefined + vi.spyOn(bridge, "getCustomer").mockResolvedValue({ + id: "conflict-bridge-id", + email: "conflict@example.com", + status: "active", + endorsements: [], + }); + vi.spyOn(persona, "searchAccounts").mockResolvedValue([{ attributes: { "reference-id": "fallback-test" } }]); + const payload = { + ...statusTransitioned, + event_object: { ...statusTransitioned.event_object, id: "conflict-bridge-id" }, + }; + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(payload) }, + json: payload as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "credential not found" }); + expect(captureEvent).toHaveBeenCalledWith( + expect.objectContaining({ + message: "bridge credential already paired", + level: "fatal", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + contexts: expect.objectContaining({ details: { bridgeId: "conflict-bridge-id" } }), + }), + ); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: "credential not found" }), + { level: "error", contexts: { details: { bridgeId: "conflict-bridge-id" } } }, + ); + }); + + it("returns credential not found when multiple persona accounts found on status_transitioned fallback", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue({ + id: "multi-bridge-id", + email: "multi@example.com", + status: "active", + endorsements: [], + }); + vi.spyOn(persona, "searchAccounts").mockResolvedValue([ + { attributes: { "reference-id": "ref-1" } }, + { attributes: { "reference-id": "ref-2" } }, + ]); + const payload = { + ...statusTransitioned, + event_object: { ...statusTransitioned.event_object, id: "multi-bridge-id" }, + }; + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(payload) }, + json: payload as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "credential not found" }); + expect(captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: "multiple persona accounts found" }), + { level: "fatal", contexts: { details: { bridgeId: "multi-bridge-id", matches: 2 } } }, + ); + }); + + it("returns credential not found when status_transitioned fallback finds no accounts", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue({ + id: "empty-bridge-id", + email: "empty@example.com", + status: "active", + endorsements: [], + }); + vi.spyOn(persona, "searchAccounts").mockResolvedValue([]); + const payload = { + ...statusTransitioned, + event_object: { ...statusTransitioned.event_object, id: "empty-bridge-id" }, + }; + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(payload) }, + json: payload as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "credential not found" }); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: "credential not found" }), + { level: "error", contexts: { details: { bridgeId: "empty-bridge-id" } } }, + ); + }); + + it("returns credential not found when status_transitioned fallback reference-id has no credential", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue({ + id: "orphan-bridge-id", + email: "orphan@example.com", + status: "active", + endorsements: [], + }); + vi.spyOn(persona, "searchAccounts").mockResolvedValue([ + { attributes: { "reference-id": "nonexistent-credential" } }, + ]); + const payload = { + ...statusTransitioned, + event_object: { ...statusTransitioned.event_object, id: "orphan-bridge-id" }, + }; + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(payload) }, + json: payload as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "credential not found" }); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: "credential not found" }), + { level: "error", contexts: { details: { bridgeId: "orphan-bridge-id" } } }, + ); + }); + it("tracks RampAccount and sends notification on status_transitioned to active", async () => { vi.spyOn(segment, "track").mockReturnValue(); const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); @@ -257,7 +439,8 @@ describe("bridge hook", () => { expect(sendPushNotification).not.toHaveBeenCalled(); }); - it("captures sentry exception when status_transitioned credential not found", async () => { + it("captures sentry exception when status_transitioned credential not found and customer not in bridge", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue(undefined); // eslint-disable-line unicorn/no-useless-undefined const payload = { ...statusTransitioned, event_object: { ...statusTransitioned.event_object, id: "unknown-customer" }, diff --git a/server/test/utils/bridge.test.ts b/server/test/utils/bridge.test.ts index f23b8389d..bcdad64ca 100644 --- a/server/test/utils/bridge.test.ts +++ b/server/test/utils/bridge.test.ts @@ -2,6 +2,7 @@ import "../mocks/sentry"; import { captureException } from "@sentry/core"; +import { eq } from "drizzle-orm"; import { parse } from "valibot"; import { hexToBytes, padHex, zeroHash } from "viem"; import { privateKeyToAddress } from "viem/accounts"; @@ -25,15 +26,25 @@ vi.mock("@sentry/core", { spy: true }); describe("bridge utils", () => { const owner = privateKeyToAddress(padHex("0xb1d")); + const conflictOwner = privateKeyToAddress(padHex("0xb1f")); const factory = inject("ExaAccountFactory"); beforeAll(async () => { - await database.insert(credentials).values({ - id: "cred-1", - publicKey: new Uint8Array(hexToBytes(owner)), - account: deriveAddress(factory, { x: padHex(owner), y: zeroHash }), - factory, - }); + await database.insert(credentials).values([ + { + id: "cred-1", + publicKey: new Uint8Array(hexToBytes(owner)), + account: deriveAddress(factory, { x: padHex(owner), y: zeroHash }), + factory, + }, + { + id: "cred-conflict", + publicKey: new Uint8Array(hexToBytes(conflictOwner)), + account: deriveAddress(factory, { x: padHex(conflictOwner), y: zeroHash }), + factory, + bridgeId: "taken-bridge-id", + }, + ]); }); beforeEach(() => { @@ -744,6 +755,110 @@ describe("bridge utils", () => { const body = JSON.parse(createCall?.[1]?.body as string) as { endorsements: string[] }; expect(body.endorsements).toContain("pix"); }); + + it("retries on timeout and succeeds", async () => { + vi.spyOn(persona, "getAccount").mockResolvedValueOnce(personaAccount); + vi.spyOn(persona, "getDocument").mockResolvedValueOnce(documentResponse); + const timeout = Object.assign(new Error("signal timed out"), { name: "TimeoutError" }); + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(blobResponse()) + .mockResolvedValueOnce(blobResponse()) + .mockRejectedValueOnce(timeout) + .mockResolvedValueOnce(fetchResponse({ id: "cust-retry", status: "not_started" })); + + await bridge.onboarding({ credentialId: "cred-1", customerId: null, acceptedTermsId: "terms-1" }); + + expect(captureException).toHaveBeenCalledWith(timeout, { level: "warning" }); + const updated = await database.query.credentials.findFirst({ + columns: { bridgeId: true }, + where: eq(credentials.id, "cred-1"), + }); + expect(updated?.bridgeId).toBe("cust-retry"); + }); + + it("retries on 500 and succeeds", async () => { + vi.spyOn(persona, "getAccount").mockResolvedValueOnce(personaAccount); + vi.spyOn(persona, "getDocument").mockResolvedValueOnce(documentResponse); + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(blobResponse()) + .mockResolvedValueOnce(blobResponse()) + .mockResolvedValueOnce(fetchError(500, "internal server error")) + .mockResolvedValueOnce(fetchResponse({ id: "cust-retry-500", status: "not_started" })); + + await bridge.onboarding({ credentialId: "cred-1", customerId: null, acceptedTermsId: "terms-1" }); + + expect(captureException).toHaveBeenCalledWith(expect.any(Error), { level: "warning" }); + const updated = await database.query.credentials.findFirst({ + columns: { bridgeId: true }, + where: eq(credentials.id, "cred-1"), + }); + expect(updated?.bridgeId).toBe("cust-retry-500"); + }); + + it("throws after exhausting retries on timeout", async () => { + vi.spyOn(persona, "getAccount").mockResolvedValueOnce(personaAccount); + vi.spyOn(persona, "getDocument").mockResolvedValueOnce(documentResponse); + const timeout = Object.assign(new Error("signal timed out"), { name: "TimeoutError" }); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(blobResponse()) + .mockResolvedValueOnce(blobResponse()) + .mockRejectedValueOnce(timeout) + .mockRejectedValueOnce(timeout) + .mockRejectedValueOnce(timeout); + + await expect( + bridge.onboarding({ credentialId: "cred-1", customerId: null, acceptedTermsId: "terms-1" }), + ).rejects.toThrow("signal timed out"); + expect(fetchSpy).toHaveBeenCalledTimes(5); + }); + + it("does not retry on non-retryable errors", async () => { + vi.spyOn(persona, "getAccount").mockResolvedValueOnce(personaAccount); + vi.spyOn(persona, "getDocument").mockResolvedValueOnce(documentResponse); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(blobResponse()) + .mockResolvedValueOnce(blobResponse()) + .mockResolvedValueOnce(fetchError(400, '{"message":"A customer with this email already exists"}')); + + await expect( + bridge.onboarding({ credentialId: "cred-1", customerId: null, acceptedTermsId: "terms-1" }), + ).rejects.toThrow(bridge.ErrorCodes.EMAIL_ALREADY_EXISTS); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it("rejects duplicate bridgeId on customer creation", async () => { + vi.spyOn(persona, "getAccount").mockResolvedValueOnce(personaAccount); + vi.spyOn(persona, "getDocument").mockResolvedValueOnce(documentResponse); + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(blobResponse()) + .mockResolvedValueOnce(blobResponse()) + .mockResolvedValueOnce(fetchResponse({ id: "taken-bridge-id", status: "not_started" })); + + await expect( + bridge.onboarding({ credentialId: "cred-1", customerId: null, acceptedTermsId: "terms-1" }), + ).rejects.toThrow("Failed query"); + }); + + it("uses same idempotency key across retries", async () => { + vi.spyOn(persona, "getAccount").mockResolvedValueOnce(personaAccount); + vi.spyOn(persona, "getDocument").mockResolvedValueOnce(documentResponse); + const timeout = Object.assign(new Error("signal timed out"), { name: "TimeoutError" }); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(blobResponse()) + .mockResolvedValueOnce(blobResponse()) + .mockRejectedValueOnce(timeout) + .mockResolvedValueOnce(fetchResponse({ id: "cust-idem", status: "not_started" })); + + await bridge.onboarding({ credentialId: "cred-1", customerId: null, acceptedTermsId: "terms-1" }); + + const firstKey = (fetchSpy.mock.calls[2]?.[1]?.headers as Record)["Idempotency-Key"]; + const retryKey = (fetchSpy.mock.calls[3]?.[1]?.headers as Record)["Idempotency-Key"]; + expect(firstKey).toBeDefined(); + expect(firstKey).toBe(retryKey); + }); }); describe("getDepositDetails", () => { @@ -1199,6 +1314,7 @@ function endorsement( const activeCustomer = { id: "cust-123", + email: "test@example.com", status: "active" as const, endorsements: [] as ReturnType[], }; diff --git a/server/utils/persona.ts b/server/utils/persona.ts index cd2d08373..dc5dc71be 100644 --- a/server/utils/persona.ts +++ b/server/utils/persona.ts @@ -257,6 +257,10 @@ const MantecaAccount = object({ }), }); +const SearchAccountsResponse = object({ + data: array(object({ attributes: object({ "reference-id": string() }) })), +}); + const UnknownAccount = object({ data: array(object({ id: string(), type: literal("account"), attributes: unknown() })), }); @@ -276,6 +280,16 @@ export function getAccounts(referenceId: string, scope: return request(accountScopeSchemas[scope], `/accounts?page[size]=1&filter[reference-id]=${referenceId}`); } +export async function searchAccounts(email: string) { + const { data } = await request( + SearchAccountsResponse, + "/accounts/search", + { query: { attribute: "fields.email_address", operator: "eq", value: email } }, + "POST", + ); + return data; +} + export async function getAccount( referenceId: string, scope: T, diff --git a/server/utils/ramps/bridge.ts b/server/utils/ramps/bridge.ts index 1dbaefbd0..df2f08b17 100644 --- a/server/utils/ramps/bridge.ts +++ b/server/utils/ramps/bridge.ts @@ -21,6 +21,7 @@ import { type InferInput, type InferOutput, } from "valibot"; +import { withRetry } from "viem"; import { optimism, optimismSepolia } from "viem/chains"; import domain from "@exactly/common/domain"; @@ -41,8 +42,8 @@ const baseURL = process.env.BRIDGE_API_URL; if (!process.env.BRIDGE_API_KEY) throw new Error("missing bridge api key"); const apiKey = process.env.BRIDGE_API_KEY; -export async function createCustomer(user: InferInput) { - return await request(NewCustomer, "/customers", {}, user, "POST").catch((error: unknown) => { +export async function createCustomer(user: InferInput, idempotencyKey?: string) { + return await request(NewCustomer, "/customers", {}, user, "POST", 15_000, idempotencyKey).catch((error: unknown) => { if (error instanceof ServiceError && typeof error.cause === "string") { if (error.cause.includes(BridgeApiErrorCodes.EMAIL_ALREADY_EXISTS)) { withScope((scope) => { @@ -355,27 +356,43 @@ export async function onboarding(params: { acceptedTermsId: string; credentialId }); } - const customer = await createCustomer({ - type: "individual", - first_name: personaAccount.attributes.fields.name.value.first.value, - last_name: personaAccount.attributes.fields.name.value.last.value, - email: personaAccount.attributes["email-address"], - phone: personaAccount.attributes.fields.phone_number.value, - residential_address: { - street_line_1: personaAccount.attributes["address-street-1"], - street_line_2: personaAccount.attributes["address-street-2"] ?? undefined, - postal_code: personaAccount.attributes["address-postal-code"], - subdivision: countryCode === "US" ? personaAccount.attributes["address-subdivision"] : undefined, - country, - city: personaAccount.attributes["address-city"], + const idempotencyKey = crypto.randomUUID(); + const customer = await withRetry( + () => + createCustomer( + { + type: "individual", + first_name: personaAccount.attributes.fields.name.value.first.value, + last_name: personaAccount.attributes.fields.name.value.last.value, + email: personaAccount.attributes["email-address"], + phone: personaAccount.attributes.fields.phone_number.value, + residential_address: { + street_line_1: personaAccount.attributes["address-street-1"], + street_line_2: personaAccount.attributes["address-street-2"] ?? undefined, + postal_code: personaAccount.attributes["address-postal-code"], + subdivision: countryCode === "US" ? personaAccount.attributes["address-subdivision"] : undefined, + country, + city: personaAccount.attributes["address-city"], + }, + birth_date: personaAccount.attributes.fields.birthdate.value, + signed_agreement_id: params.acceptedTermsId, + endorsements, + nationality: country, + identifying_information: identifyingInformation, + }, + idempotencyKey, + ), + { + retryCount: 2, + shouldRetry: ({ error }) => { + const retryable = + (error instanceof Error && error.name === "TimeoutError") || + (error instanceof ServiceError && error.status >= 500); + if (retryable) captureException(error, { level: "warning" }); + return retryable; + }, }, - birth_date: personaAccount.attributes.fields.birthdate.value, - signed_agreement_id: params.acceptedTermsId, - endorsements, - nationality: country, - identifying_information: identifyingInformation, - }); - + ); await database.update(credentials).set({ bridgeId: customer.id }).where(eq(credentials.id, params.credentialId)); } @@ -681,6 +698,7 @@ const AgreementLinkResponse = object({ url: string() }); const CustomerResponse = object({ id: string(), + email: string(), status: picklist(CustomerStatus), capabilities: optional( object({ @@ -852,13 +870,14 @@ async function request>( body?: unknown, method: "GET" | "PATCH" | "POST" | "PUT" = body === undefined ? "GET" : "POST", timeout = 10_000, + idempotencyKey?: string, ) { const response = await fetch(`${baseURL}${url}`, { method, headers: { ...headers, "api-key": apiKey, - ...(method === "POST" && { "Idempotency-Key": crypto.randomUUID() }), + ...(method === "POST" && { "Idempotency-Key": idempotencyKey ?? crypto.randomUUID() }), accept: "application/json", "content-type": "application/json", },