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/twco-dcxi-kedl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

🥅 add bridge credential fallback and retry on timeout
2 changes: 1 addition & 1 deletion server/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
56 changes: 52 additions & 4 deletions server/hooks/bridge.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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)
Comment on lines +106 to +107

Choose a reason for hiding this comment

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

P1 Badge Avoid pairing terminal Bridge statuses during fallback

When a customer.updated.status_transitioned webhook arrives for an unpaired Bridge customer, this branch will persist bridgeId before we inspect payload.event_object.status. That is dangerous for terminal statuses like rejected, paused, or offboarded: once the credential is paired, bridge.onboarding() immediately returns ALREADY_ONBOARDED for any non-null customerId (server/utils/ramps/bridge.ts:299-300), and getProvider() maps those same statuses to NOT_AVAILABLE (server/utils/ramps/bridge.ts:184-193). In other words, a rejected/offboarded webhook that used to be ignored now permanently blocks the user from starting a fresh Bridge onboarding flow.

Useful? React with 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is a fallback for the user creation endpoint, doesn't matter the customer status

.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");
Comment on lines +124 to +125

Choose a reason for hiding this comment

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

🟡 Nonexistent credential misreported as "already paired" with fatal severity

When the persona searchAccounts fallback returns a reference-id that doesn't correspond to any credential in the database, the UPDATE at server/hooks/bridge.ts:120-122 returns 0 rows because eq(credentials.id, referenceId) matches nothing. The destructured updated is undefined, so line 125 throws "credential already paired". The .catch at line 136 matches this message and logs a captureEvent at level "fatal" with message "bridge credential already paired" — even though no credential was found at all.

This conflates two distinct failure modes: (a) the credential exists but already has a bridgeId (true "already paired"), and (b) no credential with that referenceId exists. In case (b), a fatal-level Sentry event is created with an incorrect message, which will generate false alerts and hamper debugging in production. The test at line 360-384 exercises this exact path without asserting on the misleading captureEvent.

Prompt for agents
In server/hooks/bridge.ts, lines 124-125, the code assumes that 0 rows returned from the UPDATE means the credential is "already paired", but it could also mean the credential with that referenceId doesn't exist at all. To fix this, after getting 0 rows from the UPDATE, perform an additional check (e.g., a SELECT to see if a credential with that referenceId exists). If it doesn't exist, either return undefined silently or log a different, more accurate message (e.g., "credential not found for reference-id"). Only log "bridge credential already paired" at fatal level when you can confirm the credential exists but already has a bridgeId.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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,
);
Comment on lines +106 to +151

Choose a reason for hiding this comment

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

🚩 Fallback chain errors propagate to Hono's error handler

The fallback logic in server/hooks/bridge.ts:107-151 calls external services (getCustomer, searchAccounts) that can throw on network errors or API failures. These exceptions are not caught locally and would propagate to Hono's default error handler, returning a 500 to Bridge. This is likely acceptable since Bridge would retry the webhook on non-200 responses, giving a natural retry mechanism for transient failures. However, a persistent Persona API outage would cause repeated 500s and potentially exhaust Bridge's retry budget for the webhook.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

not relevant, as it is the intended behavior

}
if (!credential) {
captureException(new Error("credential not found"), {
level: "error",
Expand Down
1 change: 1 addition & 0 deletions server/test/api/ramp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,7 @@ const mantecaUser = {

const bridgeCustomer = {
id: "bridge-customer-123",
email: "test@example.com",
status: "active" as const,
endorsements: [],
};
187 changes: 185 additions & 2 deletions server/test/hooks/bridge.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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([
Expand All @@ -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",
},
]);
});

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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" },
Expand Down
Loading
Loading