From 33390cb334421cbb970c288a8dc30b87aadbcd7e Mon Sep 17 00:00:00 2001 From: Dread Date: Thu, 22 Oct 2026 21:46:38 -0700 Subject: [PATCH 01/42] Preserve Earn eligibility on unsupported payouts and deduplicate claims --- docs/earn-reward-prerequisites.md | 92 +++++++ src/app/payments/add-earn.ts | 46 +++- src/services/mongoose/rewards.ts | 7 +- test/flash/unit/app/payments/add-earn.spec.ts | 247 ++++++++++++++++++ .../unit/services/mongoose/rewards.spec.ts | 78 ++++++ 5 files changed, 464 insertions(+), 6 deletions(-) create mode 100644 docs/earn-reward-prerequisites.md create mode 100644 test/flash/unit/app/payments/add-earn.spec.ts create mode 100644 test/flash/unit/services/mongoose/rewards.spec.ts diff --git a/docs/earn-reward-prerequisites.md b/docs/earn-reward-prerequisites.md new file mode 100644 index 000000000..04594ebea --- /dev/null +++ b/docs/earn-reward-prerequisites.md @@ -0,0 +1,92 @@ +# Earn reward safety prerequisites + +Audited against backend `7402dd7fcf49e378e4af5189232dffb76a4564d5`. +These changes require a later backend deployment to affect clients. No payout +provider was contacted to develop or test them. + +## Concrete behavior changes + +Previously, `RewardsRepository.add` used an unconditional `$push` and inspected +the pre-update document afterward. A repeated claim returned +`RewardAlreadyPresentError` **after appending another marker**. It now uses +`$addToSet` with an explicit pre-update return. The single-document atomic +operation gives one winner for a previously absent lesson; replays return the +existing error without adding duplicates. Historical duplicates are preserved; +this is not a data repair or proof of a prior payout. + +Previously, `addEarn` claimed eligibility before invoking the BTC send wrapper. +That wrapper ultimately uses the USD/USDT-only `usdWalletAmountFromInput`, which +rejects BTC. Thus an eligible BTC lesson could be marked completed even though +the payment predictably failed before contacting IBEX. The same adapter now +runs before claiming. **The current BTC reward rail remains unavailable**, with +its existing `UnsupportedCurrencyError`; failed preflight leaves the lesson +eligible. No BTC amount is reinterpreted as cents. + +Funding must resolve to an existing checking BTC wallet owned by an active +funding account. The recipient must have a checking BTC wallet owned by its +active account. External SDK wallet records are not eligible destinations for +this IBEX account-ID send path. A missing funding resolver returns a controlled +repository error. Existing phone/IP authorization remains required. Only own +properties of the configured lesson catalog are accepted as lesson IDs. + +If a supported payout rail is implemented later, a non-error `pending`, +`failed`, or `already_paid` result does not produce a successful completion +acknowledgement. Only an explicit success value does. The reward marker is not +rolled back after an attempted payout: a timeout or pending outcome may have +spent funds, so making the lesson eligible again could duplicate a payment. + +## Remaining protocol work before enabling native reward claims + +These prerequisites do not implement durable payment settlement or SDK reward +delivery. The current `account.earn` array and `quiz.completed` projection are +claim markers, not authoritative receipts. Existing burned markers require a +separate evidence-based reconciliation; do not bulk reset or infer payment from +the marker. The native learning flow must keep reward completion unavailable. + +A deployable SDK reward protocol needs: + +1. Separate lesson progress from reward claim/settlement status. Persist a + unique `(accountId, lessonId)` reward claim with an immutable client request + key and explicit eligible, reserved, pending, paid, and failed states. +2. Validate eligibility, reward amount, funding rail/currency and available + funding before reservation. A preflight balance read cannot reserve funds: + provider/ledger debit enforcement and a durable claim are still required. +3. For SDK BTC delivery, accept a fresh wallet-generated fixed-amount BOLT11 + invoice. Bind its verified hash, network, exact reward amount, signature and + expiry to the authorized account and lesson. A local external wallet UUID + must never be sent as an IBEX account ID. +4. Persist the immutable invoice and payment attempt before contacting the + provider; permit one worker through an atomic state transition. Preserve + unknown outcomes for reconciliation rather than paying a new invoice or + generating a new attempt on retry. Provider idempotency and payment lookup + capabilities must be verified before enabling dispatch. +5. Expose an authenticated reward-status read with settlement identity and + authoritative provider evidence. A lesson-completed boolean or payment + submission acknowledgement must not claim that SDK funds were received. + +The current send helper and funding architecture are deliberately unchanged; +no unsupported BTC adapter or external-SDK dispatch is enabled by this patch. + +## Offline verification + +`test/flash/unit/services/mongoose/rewards.spec.ts` models serialized Mongo +atomic preimages and applies the requested update operator. Twelve concurrent +claims yield one success, eleven duplicate errors, and one stored marker; +replays, historical duplicates, missing accounts, and database errors are +covered. This mocked test is not a live Mongo concurrency integration test. + +`test/flash/unit/app/payments/add-earn.spec.ts` uses the real current BTC amount +adapter with mocked repositories and send boundary. Preflight failures must +invoke neither the claim nor the send. Explicitly mocked future adapter success +exercises duplicate claims, payment errors, non-settled statuses and success +without enabling a production payout rail or making network requests. + +Run directly with Node 24 and the repository Jest config, without sourcing +`.env` or invoking provider services: + +```sh +node node_modules/jest/bin/jest.js --config test/flash/unit/jest.config.js \ + --runInBand --runTestsByPath \ + test/flash/unit/services/mongoose/rewards.spec.ts \ + test/flash/unit/app/payments/add-earn.spec.ts +``` diff --git a/src/app/payments/add-earn.ts b/src/app/payments/add-earn.ts index 20ca4bf2c..9c04d66e6 100644 --- a/src/app/payments/add-earn.ts +++ b/src/app/payments/add-earn.ts @@ -1,16 +1,23 @@ import { getRewardsConfig, OnboardingEarn } from "@config" import { IPMetadataAuthorizer } from "@domain/accounts-ips/ip-metadata-authorizer" +import { AccountValidator } from "@domain/accounts" +import { PaymentSendStatus } from "@domain/bitcoin/lightning" import { InvalidIpMetadataError, InvalidQuizQuestionIdError, + InvalidWalletId, + MismatchedCurrencyForWalletError, MissingIPMetadataError, NoBtcWalletExistsForAccountError, + OperationInterruptedError, UnauthorizedIPError, UnknownRepositoryError, } from "@domain/errors" import { InvalidPhoneForRewardError } from "@domain/users/errors" import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" import { PhoneMetadataAuthorizer } from "@domain/users" +import { usdWalletAmountFromInput } from "@app/wallets/usd-wallet-amount" import { getFunderWalletId } from "@services/ledger/caching" import { AccountsRepository, @@ -33,20 +40,37 @@ export const addEarn = async ({ }): Promise => { const rewardsConfig = getRewardsConfig() - // TODO: quizQuestionId checkedFor + if (!Object.hasOwn(OnboardingEarn, quizQuestionIdString)) + return new InvalidQuizQuestionIdError() const quizQuestionId = quizQuestionIdString as QuizQuestionId const amount = OnboardingEarn[quizQuestionId] if (!amount) return new InvalidQuizQuestionIdError() - const funderWalletId = await getFunderWalletId() + let funderWalletId: WalletId + try { + funderWalletId = await getFunderWalletId() + } catch { + return new UnknownRepositoryError("Reward funding wallet unavailable") + } const funderWallet = await WalletsRepository().findById(funderWalletId) if (funderWallet instanceof Error) return funderWallet + if (funderWallet.type !== WalletType.Checking) + return new InvalidWalletId("Reward funding requires a checking wallet") + if (funderWallet.currency !== WalletCurrency.Btc) + return new MismatchedCurrencyForWalletError() const funderAccount = await AccountsRepository().findById(funderWallet.accountId) if (funderAccount instanceof Error) return funderAccount + const activeFunder = AccountValidator(funderAccount).isActive() + if (activeFunder instanceof Error) return activeFunder + const ownedFunder = + AccountValidator(funderAccount).validateWalletForAccount(funderWallet) + if (ownedFunder instanceof Error) return ownedFunder const recipientAccount = await AccountsRepository().findById(accountId) if (recipientAccount instanceof Error) return recipientAccount + const activeRecipient = AccountValidator(recipientAccount).isActive() + if (activeRecipient instanceof Error) return activeRecipient const user = await UsersRepository().findById(recipientAccount.kratosUserId) if (user instanceof Error) return user @@ -78,11 +102,25 @@ export const addEarn = async ({ if (recipientWallets instanceof Error) return recipientWallets const recipientBtcWallet = recipientWallets.find( - (wallet) => wallet.currency === WalletCurrency.Btc, + (wallet) => + wallet.currency === WalletCurrency.Btc && wallet.type === WalletType.Checking, ) if (recipientBtcWallet === undefined) return new NoBtcWalletExistsForAccountError() + const ownedRecipient = + AccountValidator(recipientAccount).validateWalletForAccount(recipientBtcWallet) + if (ownedRecipient instanceof Error) return ownedRecipient const recipientWalletId = recipientBtcWallet.id + // Preflight the same amount adapter used by send-intraledger BEFORE claiming + // a lesson. The current rail accepts only USD/USDT, so BTC rewards remain + // unavailable instead of consuming eligibility on a guaranteed failure. + // External SDK wallet IDs are never IBEX destination account IDs. + const supportedAmount = usdWalletAmountFromInput( + amount.toString(), + funderWallet.currency, + ) + if (supportedAmount instanceof Error) return supportedAmount + const shouldGiveReward = await RewardsRepository(accountId).add(quizQuestionId) if (shouldGiveReward instanceof Error) return shouldGiveReward @@ -97,6 +135,8 @@ export const addEarn = async ({ authorize: SEND_GUARD_NOT_APPLICABLE, }) if (payment instanceof Error) return payment + if (payment.value !== PaymentSendStatus.Success.value) + return new OperationInterruptedError("Reward payment settlement is not confirmed") return { id: quizQuestionId, earnAmount: amount } } diff --git a/src/services/mongoose/rewards.ts b/src/services/mongoose/rewards.ts index 89b134433..a2cccb8d8 100644 --- a/src/services/mongoose/rewards.ts +++ b/src/services/mongoose/rewards.ts @@ -8,11 +8,12 @@ import { toObjectId } from "./utils" export const RewardsRepository = (accountId: AccountId) => { const add = async (quizQuestionId: QuizQuestionId) => { try { - // by default, mongodb return the previous state before the update + // The update and its preimage are atomic: only the first claimant sees + // the lesson absent. Replays must not append another completion marker. const oldState = await Account.findOneAndUpdate( { _id: toObjectId(accountId) }, - { $push: { earn: quizQuestionId } }, - // { upsert: true }, + { $addToSet: { earn: quizQuestionId } }, + { new: false }, ) if (!oldState) { diff --git a/test/flash/unit/app/payments/add-earn.spec.ts b/test/flash/unit/app/payments/add-earn.spec.ts new file mode 100644 index 000000000..d00a3df0e --- /dev/null +++ b/test/flash/unit/app/payments/add-earn.spec.ts @@ -0,0 +1,247 @@ +import { addEarn } from "@app/payments/add-earn" +import { intraledgerPaymentSendWalletIdForBtcWallet } from "@app/payments/send-intraledger" +import { usdWalletAmountFromInput } from "@app/wallets/usd-wallet-amount" +import { AccountStatus } from "@domain/accounts" +import { PaymentSendStatus } from "@domain/bitcoin/lightning" +import { + InactiveAccountError, + InvalidQuizQuestionIdError, + InvalidWalletId, + MismatchedCurrencyForWalletError, + NoBtcWalletExistsForAccountError, + OperationInterruptedError, + RewardAlreadyPresentError, + UnknownRepositoryError, + UnsupportedCurrencyError, +} from "@domain/errors" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" +import { getFunderWalletId } from "@services/ledger/caching" +import { + AccountsRepository, + RewardsRepository, + UsersRepository, + WalletsRepository, +} from "@services/mongoose" + +jest.mock("@config", () => ({ + getRewardsConfig: () => ({ + phoneMetadataValidationSettings: {}, + ipMetadataValidationSettings: {}, + }), + OnboardingEarn: { whatIsBitcoin: 1 }, +})) +jest.mock("@domain/accounts-ips/ip-metadata-authorizer", () => ({ + IPMetadataAuthorizer: () => ({ authorize: () => true }), +})) +jest.mock("@domain/users", () => ({ + PhoneMetadataAuthorizer: () => ({ authorize: () => true }), +})) +jest.mock("@services/ledger/caching", () => ({ getFunderWalletId: jest.fn() })) +jest.mock("@services/mongoose", () => ({ + AccountsRepository: jest.fn(), + RewardsRepository: jest.fn(), + UsersRepository: jest.fn(), + WalletsRepository: jest.fn(), +})) +jest.mock("@services/mongoose/accounts-ips", () => ({ + AccountsIpsRepository: () => ({ findLastByAccountId: async () => ({ metadata: {} }) }), +})) +jest.mock("@app/payments/send-intraledger", () => ({ + intraledgerPaymentSendWalletIdForBtcWallet: jest.fn(), +})) +jest.mock("@app/wallets/usd-wallet-amount", () => ({ + usdWalletAmountFromInput: jest.fn(), +})) + +describe("addEarn preclaim safety", () => { + const request = { accountId: "recipient" as AccountId, quizQuestionId: "whatIsBitcoin" } + const claim = jest.fn() + const findWallet = jest.fn() + const listWallets = jest.fn() + const findAccount = jest.fn() + const funder = { + id: "funder-wallet", + accountId: "funder", + type: WalletType.Checking, + currency: WalletCurrency.Btc, + } + const recipient = { + id: "recipient-wallet", + accountId: "recipient", + type: WalletType.Checking, + currency: WalletCurrency.Btc, + } + + beforeEach(() => { + jest.clearAllMocks() + jest + .mocked(getFunderWalletId) + .mockReset() + .mockResolvedValue("funder-wallet" as WalletId) + findWallet.mockReset().mockResolvedValue(funder) + listWallets.mockReset().mockResolvedValue([recipient]) + findAccount.mockReset().mockImplementation(async (id) => ({ + id, + status: AccountStatus.Active, + kratosUserId: "user", + })) + claim.mockReset().mockResolvedValue(true) + jest + .mocked(WalletsRepository) + .mockReturnValue({ findById: findWallet, listByAccountId: listWallets } as never) + jest.mocked(AccountsRepository).mockReturnValue({ findById: findAccount } as never) + jest.mocked(RewardsRepository).mockReturnValue({ add: claim }) + jest + .mocked(UsersRepository) + .mockReturnValue({ findById: async () => ({ phoneMetadata: {} }) } as never) + jest + .mocked(intraledgerPaymentSendWalletIdForBtcWallet) + .mockReset() + .mockResolvedValue(PaymentSendStatus.Success) + // Exercise the REAL current adapter by default: BTC is unsupported. + jest + .mocked(usdWalletAmountFromInput) + .mockReset() + .mockImplementation( + jest.requireActual("@app/wallets/usd-wallet-amount").usdWalletAmountFromInput, + ) + }) + + const expectNoClaimOrSend = () => { + expect(claim).not.toHaveBeenCalled() + expect(intraledgerPaymentSendWalletIdForBtcWallet).not.toHaveBeenCalled() + } + + it("keeps an otherwise eligible lesson unclaimed when the BTC adapter is unsupported", async () => { + expect(await addEarn(request)).toBeInstanceOf(UnsupportedCurrencyError) + expect(usdWalletAmountFromInput).toHaveBeenCalledWith("1", WalletCurrency.Btc) + expectNoClaimOrSend() + }) + + it.each(["toString", "__proto__", "unknown"])( + "rejects unconfigured lesson %s before looking up funding", + async (quizQuestionId) => { + expect(await addEarn({ ...request, quizQuestionId })).toBeInstanceOf( + InvalidQuizQuestionIdError, + ) + expect(getFunderWalletId).not.toHaveBeenCalled() + expectNoClaimOrSend() + }, + ) + + it("handles an unconfigured funding resolver before claiming", async () => { + jest.mocked(getFunderWalletId).mockRejectedValueOnce("Invalid funderWalletPath") + expect(await addEarn(request)).toBeInstanceOf(UnknownRepositoryError) + expect(findWallet).not.toHaveBeenCalled() + expectNoClaimOrSend() + }) + + it("preserves a missing funding wallet error before claiming", async () => { + const missing = new UnknownRepositoryError("wallet unavailable") + findWallet.mockResolvedValueOnce(missing) + expect(await addEarn(request)).toBe(missing) + expectNoClaimOrSend() + }) + + it.each([WalletCurrency.Usd, WalletCurrency.Usdt])( + "rejects a %s funder rather than treating reward sats as cents", + async (currency) => { + findWallet.mockResolvedValueOnce({ ...funder, currency }) + expect(await addEarn(request)).toBeInstanceOf(MismatchedCurrencyForWalletError) + expectNoClaimOrSend() + }, + ) + + it("rejects an external SDK funding ID", async () => { + findWallet.mockResolvedValueOnce({ ...funder, type: WalletType.External }) + expect(await addEarn(request)).toBeInstanceOf(InvalidWalletId) + expectNoClaimOrSend() + }) + + it.each([ + [], + [{ ...recipient, type: WalletType.External }], + [{ ...recipient, currency: WalletCurrency.Usdt }], + ])( + "requires a checking BTC recipient, preserving eligibility for %j", + async (...wallets) => { + // Jest expands each array case into arguments. + listWallets.mockResolvedValueOnce(wallets) + expect(await addEarn(request)).toBeInstanceOf(NoBtcWalletExistsForAccountError) + expectNoClaimOrSend() + }, + ) + + it("rejects a recipient wallet belonging to another account", async () => { + listWallets.mockResolvedValueOnce([{ ...recipient, accountId: "other" }]) + expect(await addEarn(request)).toBeInstanceOf(InvalidWalletId) + expectNoClaimOrSend() + }) + + it("does not let an external SDK wallet shadow a valid checking BTC recipient", async () => { + listWallets.mockResolvedValueOnce([ + { ...recipient, id: "sdk-local-id", type: WalletType.External }, + recipient, + ]) + expect(await addEarn(request)).toBeInstanceOf(UnsupportedCurrencyError) + expectNoClaimOrSend() + }) + + it("rejects a funding wallet belonging to another account", async () => { + findAccount.mockResolvedValueOnce({ id: "other", status: AccountStatus.Active }) + expect(await addEarn(request)).toBeInstanceOf(InvalidWalletId) + expectNoClaimOrSend() + }) + + it.each(["funder", "recipient"])( + "rejects an inactive %s before claiming", + async (inactiveId) => { + findAccount.mockImplementation(async (id) => ({ + id, + status: id === inactiveId ? AccountStatus.Locked : AccountStatus.Active, + })) + expect(await addEarn(request)).toBeInstanceOf(InactiveAccountError) + expectNoClaimOrSend() + }, + ) + + // Model a future supported adapter explicitly, without enabling a production + // payout rail. Claim and settlement handling must remain fail-closed then. + const permitFutureAdapter = () => + jest.mocked(usdWalletAmountFromInput).mockReturnValue({} as never) + + it("never sends after a duplicate claim", async () => { + permitFutureAdapter() + const duplicate = new RewardAlreadyPresentError() + claim.mockResolvedValueOnce(duplicate) + expect(await addEarn(request)).toBe(duplicate) + expect(intraledgerPaymentSendWalletIdForBtcWallet).not.toHaveBeenCalled() + }) + + it.each([ + PaymentSendStatus.Pending, + PaymentSendStatus.Failure, + PaymentSendStatus.AlreadyPaid, + ])("does not acknowledge an unconfirmed future payment %j as paid", async (status) => { + permitFutureAdapter() + jest.mocked(intraledgerPaymentSendWalletIdForBtcWallet).mockResolvedValueOnce(status) + expect(await addEarn(request)).toBeInstanceOf(OperationInterruptedError) + expect(claim).toHaveBeenCalledTimes(1) + }) + + it("preserves a future payment error without claiming settlement", async () => { + permitFutureAdapter() + const failure = new UnknownRepositoryError("provider unavailable") + jest.mocked(intraledgerPaymentSendWalletIdForBtcWallet).mockResolvedValueOnce(failure) + expect(await addEarn(request)).toBe(failure) + }) + + it("acknowledges only confirmed success from a future supported rail", async () => { + permitFutureAdapter() + jest + .mocked(intraledgerPaymentSendWalletIdForBtcWallet) + .mockResolvedValueOnce({ ...PaymentSendStatus.Success }) + expect(await addEarn(request)).toEqual({ id: request.quizQuestionId, earnAmount: 1 }) + }) +}) diff --git a/test/flash/unit/services/mongoose/rewards.spec.ts b/test/flash/unit/services/mongoose/rewards.spec.ts new file mode 100644 index 000000000..4108e35f8 --- /dev/null +++ b/test/flash/unit/services/mongoose/rewards.spec.ts @@ -0,0 +1,78 @@ +import { RewardAlreadyPresentError, UnknownRepositoryError } from "@domain/errors" +import { RewardsRepository } from "@services/mongoose/rewards" +import { Account } from "@services/mongoose/schema" + +jest.mock("@services/mongoose/schema", () => ({ + Account: { findOneAndUpdate: jest.fn() }, +})) +jest.mock("@services/mongoose/utils", () => ({ toObjectId: (id: string) => id })) + +describe("RewardsRepository atomic claims", () => { + const accountId = "account" as AccountId + const lesson = "whatIsBitcoin" as QuizQuestionId + let earned: QuizQuestionId[] + + beforeEach(() => { + earned = [] + jest.mocked(Account.findOneAndUpdate).mockReset() + // Model Mongo's atomic serialized write/preimage, applying the requested + // operator rather than assuming the implementation already uses a set. + jest.mocked(Account.findOneAndUpdate).mockImplementation((...args: unknown[]) => { + const [, update] = args as [ + unknown, + { + $addToSet?: { earn: QuizQuestionId } + $push?: { earn: QuizQuestionId } + }, + ] + const before = { earn: [...earned] } + if (update.$push) earned.push(update.$push.earn) + if (update.$addToSet && !earned.includes(update.$addToSet.earn)) + earned.push(update.$addToSet.earn) + return Promise.resolve(before) as never + }) + }) + + it("allows exactly one concurrent claimant without appending duplicates", async () => { + const results = await Promise.all( + Array.from({ length: 12 }, () => RewardsRepository(accountId).add(lesson)), + ) + expect(results.filter((result) => result === true)).toHaveLength(1) + expect( + results.filter((result) => result instanceof RewardAlreadyPresentError), + ).toHaveLength(11) + expect(earned).toEqual([lesson]) + expect(Account.findOneAndUpdate).toHaveBeenCalledWith( + { _id: accountId }, + { $addToSet: { earn: lesson } }, + { new: false }, + ) + }) + + it("preserves unrelated lessons and refuses a replay without another marker", async () => { + earned = ["sat", lesson] as QuizQuestionId[] + expect(await RewardsRepository(accountId).add(lesson)).toBeInstanceOf( + RewardAlreadyPresentError, + ) + expect(earned).toEqual(["sat", lesson]) + }) + + it("does not rewrite historical duplicates", async () => { + earned = [lesson, lesson] + expect(await RewardsRepository(accountId).add(lesson)).toBeInstanceOf( + RewardAlreadyPresentError, + ) + expect(earned).toEqual([lesson, lesson]) + }) + + it("preserves missing account and database failure errors", async () => { + jest.mocked(Account.findOneAndUpdate).mockResolvedValueOnce(null) + expect(await RewardsRepository(accountId).add(lesson)).toBeInstanceOf( + UnknownRepositoryError, + ) + jest.mocked(Account.findOneAndUpdate).mockRejectedValueOnce(new Error("offline")) + expect(await RewardsRepository(accountId).add(lesson)).toBeInstanceOf( + UnknownRepositoryError, + ) + }) +}) From 93b29dd57b8cc7eac274fc7728362d4a9c4499cb Mon Sep 17 00:00:00 2001 From: Dread Date: Thu, 22 Oct 2026 22:18:32 -0700 Subject: [PATCH 02/42] Fix identity document presigning with nonempty uploads --- docs/id-verification.md | 17 +++ src/services/storage/index.ts | 4 + .../storage/id-document-presigned-put.spec.ts | 130 ++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 test/flash/unit/services/storage/id-document-presigned-put.spec.ts diff --git a/docs/id-verification.md b/docs/id-verification.md index 46bcafadf..f54d3f87f 100644 --- a/docs/id-verification.md +++ b/docs/id-verification.md @@ -50,6 +50,23 @@ Files are uploaded exactly as today: `idDocumentUploadUrlGenerate` returns a character `UsernameRegex` forbids can unambiguously mark the boundary); the client then references that key from an evidence row. +The storage client uses `requestChecksumCalculation: "WHEN_REQUIRED"` when +presigning. The signer has no image bytes: the SDK's `WHEN_SUPPORTED` default +otherwise calculates CRC32 for an empty body and signs +`x-amz-checksum-crc32=AAAAAA==`. Uploading a real photo through that URL fails +checksum validation. This setting leaves required or explicitly supplied +checksums intact and retains the same SigV4, private ACL and 15-minute lease; +clients must never strip checksum/signature parameters from a returned URL. +The existing API does not accept an expected checksum or content length, so +this change does not claim to bind an evidence hash to stored bytes. +See [AWS checksum behavior](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/s3-checksums.html). + +`test/flash/unit/services/storage/id-document-presigned-put.spec.ts` uses the +real SDK presigner, dummy credentials and a loopback-only checksum fixture to +reproduce the old nonempty-photo rejection and verify the service's corrected +PUT contract. The fixture checks body/checksum acceptance, not provider-side +SigV4 verification; no real storage account is contacted. + ### Normalization (`src/domain/accounts/upgrade-evidence.ts`) `normalizeEvidence({ evidence, idDocument })`: diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index dbc1a96c1..68fca1976 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -44,6 +44,10 @@ const getS3Client = (): S3Client | StorageError => { s3Client = new S3Client({ endpoint: DO_SPACES_ENDPOINT, region: DO_SPACES_REGION, + // The presigner has no photo body. SDK automatic CRC32 would otherwise + // sign the empty-body checksum, rejecting the client's nonempty upload. + // Retain required/explicit checksums; never rewrite a signed URL on mobile. + requestChecksumCalculation: "WHEN_REQUIRED", credentials: { accessKeyId: DO_SPACES_ACCESS_KEY, secretAccessKey: DO_SPACES_SECRET_KEY, diff --git a/test/flash/unit/services/storage/id-document-presigned-put.spec.ts b/test/flash/unit/services/storage/id-document-presigned-put.spec.ts new file mode 100644 index 000000000..f40322a12 --- /dev/null +++ b/test/flash/unit/services/storage/id-document-presigned-put.spec.ts @@ -0,0 +1,130 @@ +import { createServer, request, Server } from "node:http" +import { AddressInfo } from "node:net" +import { crc32 } from "node:zlib" + +import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3" +import { getSignedUrl } from "@aws-sdk/s3-request-presigner" + +let mockEndpoint: string + +jest.mock("@config", () => ({ + get DO_SPACES_ENDPOINT() { + return mockEndpoint + }, + DO_SPACES_REGION: "us-east-1", + DO_SPACES_BUCKET: "identity-offline-fixture", + DO_SPACES_ACCESS_KEY: "OFFLINE_FIXTURE_ONLY", + DO_SPACES_SECRET_KEY: "not-a-real-key", +})) +jest.mock("@services/tracing", () => ({ + asyncRunInSpan: (_name: string, _opts: unknown, fn: () => unknown) => fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) + +import { generateIdDocumentUploadUrl } from "@services/storage" + +// A real nonempty PNG, not an empty request standing in for a photo. Only a +// loopback HTTP fixture receives it; no storage account or real key is used. +const image = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+lmKsAAAAASUVORK5CYII=", + "base64", +) +const observed: { body: Buffer; contentType?: string; query: URLSearchParams }[] = [] +let server: Server + +beforeAll(async () => { + server = createServer((incoming, response) => { + const chunks: Buffer[] = [] + incoming.on("data", (chunk) => chunks.push(Buffer.from(chunk))) + incoming.on("end", () => { + const body = Buffer.concat(chunks) + const query = new URL(incoming.url!, mockEndpoint).searchParams + observed.push({ body, contentType: incoming.headers["content-type"], query }) + const checksum = Buffer.alloc(4) + checksum.writeUInt32BE(crc32(body)) + // Emulate only S3's checksum/body acceptance, not its signature verifier. + // The client uses the untouched real SDK URL, including its dummy SigV4. + if ( + query.has("x-amz-checksum-crc32") && + query.get("x-amz-checksum-crc32") !== checksum.toString("base64") + ) { + response.writeHead(400).end("BadDigest") + } else if (incoming.method !== "PUT" || !body.equals(image)) { + response.writeHead(400).end("UnexpectedBody") + } else { + response.writeHead(200).end() + } + }) + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + mockEndpoint = `http://127.0.0.1:${(server.address() as AddressInfo).port}` +}) +afterAll(async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ) +}) + +const put = (url: string) => + new Promise((resolve, reject) => { + const upload = request( + url, + { method: "PUT", headers: { "Content-Type": "image/png" } }, + (response) => { + response.resume() + response.on("end", () => resolve(response.statusCode)) + }, + ) + upload.on("error", reject) + upload.end(image) + }) + +it("reproduces the SDK default's invalid empty-body checksum for a later nonempty PUT", async () => { + const client = new S3Client({ + endpoint: mockEndpoint, + region: "us-east-1", + // Explicit default makes this regression deterministic even if the test + // shell has AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED. + requestChecksumCalculation: "WHEN_SUPPORTED", + credentials: { + accessKeyId: "OFFLINE_FIXTURE_ONLY", + secretAccessKey: "not-a-real-key", + }, + }) + try { + const url = await getSignedUrl( + client, + new PutObjectCommand({ + Bucket: "identity-offline-fixture", + Key: "id_documents/alice/front.png", + ContentType: "image/png", + ACL: "private", + }), + { expiresIn: 900 }, + ) + expect(new URL(url).searchParams.get("x-amz-checksum-crc32")).toBe("AAAAAA==") + expect(await put(url)).toBe(400) + } finally { + client.destroy() + } +}) + +it("signs the actual storage service URL for a nonempty photo without an invented checksum", async () => { + const generated = await generateIdDocumentUploadUrl({ + username: "alice", + filename: "front.png", + contentType: "image/png", + }) + if (generated instanceof Error) throw generated + const query = new URL(generated.uploadUrl).searchParams + expect(query.has("x-amz-checksum-crc32")).toBe(false) + expect(query.has("x-amz-sdk-checksum-algorithm")).toBe(false) + expect(query.get("X-Amz-Algorithm")).toBe("AWS4-HMAC-SHA256") + expect(query.get("X-Amz-Expires")).toBe("900") + expect(query.get("x-amz-acl")).toBe("private") + expect(query.get("X-Amz-Signature")).toMatch(/^[a-f0-9]{64}$/) + expect(generated.fileKey).toBe("id_documents/alice/front.png") + expect(await put(generated.uploadUrl)).toBe(200) + expect(observed.at(-1)?.body).toEqual(image) + expect(observed.at(-1)?.contentType).toBe("image/png") +}) From 149cd225d70a76a1e4053c7074940db08a5b80e5 Mon Sep 17 00:00:00 2001 From: Dread Date: Thu, 22 Oct 2026 23:29:13 -0700 Subject: [PATCH 03/42] Harden owned Bridge KYC link retrieval and credential boundaries --- docs/bridge-owned-kyc-links.md | 36 ++ src/domain/accounts/index.types.d.ts | 7 + src/services/bridge/client.ts | 18 + src/services/bridge/index.ts | 254 ++++++--- src/services/bridge/owned-kyc-customer.ts | 16 + src/services/mongoose/accounts.ts | 39 ++ .../bridge/client-owned-kyc-links.spec.ts | 151 +++++ .../bridge/initiate-kyc-owned-links.spec.ts | 514 ++++++++++++++++++ .../accounts-bind-bridge-customer.spec.ts | 95 ++++ 9 files changed, 1057 insertions(+), 73 deletions(-) create mode 100644 docs/bridge-owned-kyc-links.md create mode 100644 src/services/bridge/owned-kyc-customer.ts create mode 100644 test/flash/unit/services/bridge/client-owned-kyc-links.spec.ts create mode 100644 test/flash/unit/services/bridge/initiate-kyc-owned-links.spec.ts create mode 100644 test/flash/unit/services/mongoose/accounts-bind-bridge-customer.spec.ts diff --git a/docs/bridge-owned-kyc-links.md b/docs/bridge-owned-kyc-links.md new file mode 100644 index 000000000..35db941a3 --- /dev/null +++ b/docs/bridge-owned-kyc-links.md @@ -0,0 +1,36 @@ +# Owned Bridge verification links + +This change hardens the existing `bridgeInitiateKyc` mutation without changing GraphQL fields or input types. It is based on `7402dd7fcf49e378e4af5189232dffb76a4564d5`. It has not been deployed and no provider request or production mutation was made during implementation. + +For an account with a stored `bridgeCustomerId`, the service retrieves links through the provider's current existing-customer endpoints: + +- `GET /customers/{customerID}/kyc_link` +- `GET /customers/{customerID}/tos_acceptance_link` + +The customer ID comes only from the fresh authenticated account record. Caller email, name and requested KYC type cannot select another customer. The service rechecks the stored association before exposing the response. Existing status is retained: in particular, `under_review`, `incomplete`, `awaiting_questionnaire` and `awaiting_ubo` are never reset to `open` or `not_started`. A provider GET failure is returned without a fallback customer-creation POST. + +These endpoints and response shapes are documented by [Bridge's existing-customer KYC endpoint](https://apidocs.bridge.xyz/api-reference/customers/retrieve-a-hosted-kyc-link-for-an-existing-customer) and [existing-customer terms endpoint](https://apidocs.bridge.xyz/api-reference/customers/retrieve-a-hosted-url-for-tos-acceptance-for-an-existing-customer). The identity host is exactly `bridge.withpersona.com`; terms accept exactly `dashboard.bridge.xyz` or `compliance.bridge.xyz`, reflecting Bridge's documented hosted URLs. HTTPS, bounded printable URLs and no credentials, port, fragment, encoded host or host suffix tricks are required. Returned capabilities are not logged. Provider error responses containing someone else's `existing_kyc_link` are discarded before the error leaves the service. + +For an account without a customer association, an explicit initiation can create a customer. Account-level locking serializes fresh reads, provider creation and local binding. A stable per-account UUID is supplied as the provider idempotency key; changing caller email or name cannot select a new key. The verified identity repository email remains preferred over caller input, preserving the existing input contract. The repository binds the returned customer using a compare-and-set predicate that matches only a missing/null `bridgeCustomerId`; it cannot overwrite an association established concurrently by another writer or older server. A final account read checks that the association still matches before links are returned. + +Duplicate-email errors do not bind the returned existing customer, return its links, or reset verification status. Such failures need an explicit account/status review or support reconciliation. There is no automatic retry. The stable UUID is useful within the provider's idempotency retention; it does not promise unlimited exactly-once behavior after provider retention expires or eliminate an orphan customer if the provider succeeds but local persistence fails. + +The [Bridge status lifecycle](https://apidocs.bridge.xyz/platform/customers/customers/kyclinks) distinguishes incomplete/action-required customers from manual review, and notes that some `under_review` customers still need identity information. Returning the current customer's hosted links supports continuation without creating another customer; no redirect, page text, app return or returned URL proves approval. `bridgeKycStatus` remains the source of server status and may proactively provision a receiving account after approval in this baseline. + +Native clients must not assume this behavior exists until this backend change is released. The current native UI offers retained-link continuation only and otherwise directs users to refresh/support; a null or unknown status is not presented as new-customer eligibility. The real `bridgeTopupEnabled` rollout setting remains the product gate. No new deployment-attestation switch is introduced. A client adopting the current provider terms endpoint must explicitly review the documented `compliance.bridge.xyz` origin in its own initial-URL policy; it must not broaden to arbitrary Bridge subdomains. + +Validation uses Node 24 and mocked provider/repository boundaries. Focused tests cover owned GET routing, preserved statuses, no caller-email rebinding, malformed/error responses, expiry of lock authority, fresh owner-association checks, stable-key retries, concurrent starts, atomic local binding and the unchanged public resolver. No secrets or real `.env` files are needed. A real provider sandbox and real Mongo/Redis integration run remain separate release checks. + +Validation is performed with Node 24 and the fresh frozen-lockfile dependency installation from the combined review worktree, not the reused original installation. The prior two Express/Application type errors reproduced only with that reused installation; they are not a source/lockfile baseline failure. Current focused and compile results are recorded below. No casts or type workarounds were added. Five broader suites need environment bootstrap; a future combined run can use an explicitly constructed nonsecret regtest environment with loopback-only hosts, without reading `.env` or inheriting provider credentials. + +The authenticated Bridge request adapter now rejects every redirect centrally (`redirect: "error"`), covering both hosted-link GETs, KYC creation, and existing GET/POST/DELETE operations. It does not follow even same-origin redirects. Custom `Api-Key` headers and financial/identity request bodies must never be forwarded to a redirect target. Real loopback-server regressions with dummy keys verify that the two new GETs, KYC POST and representative existing GET/POST requests never reach the redirect destination. No provider or production request is involved. + +Stored and newly returned customer IDs pass a shared bounded ASCII letters/digits/underscore/hyphen validator before dispatch or local binding. Dot segments, encoded paths, URL delimiters, Unicode and control characters are rejected. Encoding alone is insufficient because `encodeURIComponent("..")` leaves a URL-normalized dot segment. + +Historical ownership is checked without a unique index or migration. The repository returns at most two IDs, projecting only `_id`, for an exact customer-ID match. Existing-link retrieval requires exactly the current account before the GETs and again before reveal. A new binding requires zero current owners and exactly the new owner afterward. New paths always acquire locks in account → `locks:bridge-kyc-customer:` order, holding the customer lock across ownership checks and binding/reveal. Both lock signals are checked again after the final owner-query await, and before CAS after the no-owner query. Callback provider errors are sanitized inside the customer-lock callback before the real lock adapter can convert a thrown callback exception; only safe API class/status survive, never provider bodies or arbitrary exception fields. + +The baseline schema does not uniquely index `bridgeCustomerId`, and the prior duplicate-email fallback may already have created shared or incorrect associations. Shared rows now fail closed; the service never chooses the first match or repairs ownership by email. A solitary historical association can still be wrong, and older writers bypass the new customer lock. These guards are not a global database uniqueness guarantee and do not replace an authenticated historical-data audit before rollout. No automatic reassignment, migration, cleanup or unique-index creation is performed. + +Final isolated validation with the fresh exact lockfile dependencies: 81 focused tests passed across four suites, including real loopback redirect regressions with dummy credentials; the 14 independently runnable Bridge-related suites passed 233 tests. Touched-file ESLint, `git diff --check`, declaration typecheck (`tsconfig.d.json`), whole-tree `tsc --noEmit`, and production configuration typecheck (`tsconfig-build.json`) all passed with zero diagnostics. An independent reviewer reran all 81 focused tests and found no remaining scoped KYC blocker. These checks still do not substitute for provider sandbox and real Mongo/Redis release validation. + +Release dependency: the generic telemetry wrappers in this source baseline capture arbitrary function arguments, including KYC name/email and other application credentials. This isolated patch sanitizes provider error payloads and its direct service logs; it does **not** fix that central user-input tracing behavior. The separately reviewed central telemetry security patch must be integrated before this KYC change is considered for release. There is no method-specific tracing bypass in this patch and no claim that its user-input tracing is sanitized on its own. diff --git a/src/domain/accounts/index.types.d.ts b/src/domain/accounts/index.types.d.ts index c6c003eb0..b0c37623a 100644 --- a/src/domain/accounts/index.types.d.ts +++ b/src/domain/accounts/index.types.d.ts @@ -236,6 +236,13 @@ interface IAccountsRepository { ): Promise< { changed: boolean; previousStatus?: Account["bridgeKycStatus"] } | RepositoryError > + findBridgeCustomerOwners( + customerId: BridgeCustomerId, + ): Promise + bindBridgeCustomerIfUnbound( + id: AccountId, + customerId: BridgeCustomerId, + ): Promise updateBridgeFields( id: AccountId, fields: { diff --git a/src/services/bridge/client.ts b/src/services/bridge/client.ts index ee3b5a2c2..b14f9497b 100644 --- a/src/services/bridge/client.ts +++ b/src/services/bridge/client.ts @@ -16,6 +16,7 @@ import { import { alertBridge, generateDedupKey } from "@services/alerts" import { BridgeApiError, BridgeTimeoutError } from "./errors" +import { checkedOwnedKycCustomerId } from "./owned-kyc-customer" // ============ Request/Response Types ============ @@ -408,6 +409,8 @@ export class BridgeClient { headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, + // Never forward the API key or a financial/identity body to redirects. + redirect: "error", }) const responseData = await response.json().catch(() => null) @@ -482,6 +485,21 @@ export class BridgeClient { return this.request("POST", "/kyc_links", request, idempotencyKey) } + // Current provider endpoints return capabilities for this exact stored + // customer. They do not create a customer or accept caller-controlled email. + async getHostedKycLink(customerId: BridgeCustomerId): Promise<{ url: string }> { + const checked = checkedOwnedKycCustomerId(customerId) + return this.request("GET", `/customers/${encodeURIComponent(checked)}/kyc_link`) + } + + async getHostedTosLink(customerId: BridgeCustomerId): Promise<{ url: string }> { + const checked = checkedOwnedKycCustomerId(customerId) + return this.request( + "GET", + `/customers/${encodeURIComponent(checked)}/tos_acceptance_link`, + ) + } + async getKycLatestLink(customerId: BridgeCustomerId): Promise { return this.request("GET", `/customers/${customerId}/kyc_links/latest`) } diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index c85d06e34..04d7670c5 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -6,6 +6,8 @@ import crypto from "crypto" +import getUuidByString from "uuid-by-string" + import { BridgeConfig } from "@config" import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" @@ -61,6 +63,7 @@ import BridgeApiClient, { type ExternalAccount, } from "./client" import { PlaidLinkTokenStore } from "./plaid-link-token-store" +import { checkedOwnedKycCustomerId } from "./owned-kyc-customer" import { presentBridgeWithdrawal, receiptFeesFromTransfer, @@ -350,6 +353,77 @@ const requireApprovedBridgeCustomer = async ({ * - Creates Bridge customer if not exists * - Returns KYC and TOS links */ +const sanitizedKycError = (error: unknown, accountId: AccountId): Error => { + // Provider bodies can contain a foreign existing_kyc_link or identity PII. + // Preserve safe API classification/status while dropping the entire payload. + baseLogger.error( + { accountId, operation: "initiateKyc" }, + "Bridge verification request failed", + ) + if (error instanceof BridgeApiError) { + return new BridgeApiError("Unable to obtain verification links", error.statusCode) + } + return new BridgeError("Verification request failed") +} + +const guardedKycWork = async ( + accountId: AccountId, + work: () => Promise, +): Promise => { + // redlock converts thrown callback errors. Sanitize inside its callback so + // provider bodies never reach that converter or its caller. + try { + return await work() + } catch (error) { + return sanitizedKycError(error, accountId) + } +} + +const requireSoleKycCustomerOwner = async ( + accountId: AccountId, + customerId: BridgeCustomerId, +): Promise => { + const owners = await AccountsRepository().findBridgeCustomerOwners(customerId) + if (owners instanceof Error) return owners + if (owners.length !== 1 || owners[0] !== accountId) { + return new BridgeError("Bridge customer ownership requires review") + } + return true +} + +const hostedKycURL = (value: unknown, kind: "identity" | "terms"): string => { + if ( + typeof value !== "string" || + value.length > 8192 || + /[^\x21-\x7e]/.test(value) || + value.includes("\\") + ) { + throw new BridgeError("Invalid hosted verification link") + } + let url: URL + try { + url = new URL(value) + } catch { + throw new BridgeError("Invalid hosted verification link") + } + const hosts = + kind === "identity" + ? ["bridge.withpersona.com"] + : ["dashboard.bridge.xyz", "compliance.bridge.xyz"] + if ( + url.protocol !== "https:" || + !hosts.includes(url.hostname) || + url.username || + url.password || + url.port || + url.hash || + !value.startsWith(`https://${url.hostname}/`) + ) { + throw new BridgeError("Invalid hosted verification link") + } + return value +} + const initiateKyc = async ({ accountId, email, @@ -361,87 +435,121 @@ const initiateKyc = async ({ type?: "individual" | "business" full_name: string }): Promise => { - baseLogger.info({ accountId, operation: "initiateKyc" }, "Bridge operation started") - const enabledCheck = checkBridgeEnabled() if (enabledCheck instanceof Error) return enabledCheck - - const account = await checkAccountLevel(accountId) - if (account instanceof Error) return account - - if (account.bridgeKycStatus === "approved") { - return new BridgeError("KYC already approved for this account") - } - - const identity = await IdentityRepository().getIdentity(account.kratosUserId) - - if (identity instanceof Error) return identity - - const useremail = identity.email - - try { - // Create KYC link - const kycLink = await BridgeApiClient.createKycLink({ - email: useremail || email, - type: type || "individual", - full_name: full_name || account.username, - }) - - const result: InitiateKycResult = { - kycLink: kycLink.kyc_link, - customerId: kycLink.customer_id, - tosLink: kycLink.tos_link, - } - - // link the customer Id to the bridge account - const customerId = toBridgeCustomerId(kycLink.customer_id) - - const updateResult = await AccountsRepository().updateBridgeFields(accountId, { - bridgeCustomerId: customerId, - bridgeKycStatus: "open", - }) - - if (updateResult instanceof Error) { - return updateResult + // Loading this adapter on demand avoids opening a Redis connection merely + // from importing the Bridge service (for tooling and isolated unit tests). + const { LockService, redlock } = await import("@services/lock") + return LockService().lockAccountId(accountId, async (signal) => { + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + if (account.bridgeKycStatus === "approved") { + return new BridgeError("KYC already approved for this account") } - - baseLogger.info( - { accountId, operation: "initiateKyc", kycLink }, - "Bridge operation completed", - ) - - return result - } catch (error) { - const bridgeError = error as { - statusCode?: number - response?: { - existing_kyc_link?: { kyc_link: string; customer_id: string; tos_link: string } + try { + if (signal.aborted) return new BridgeError("Verification request lock expired") + if (account.bridgeCustomerId !== undefined && account.bridgeCustomerId !== null) { + const customerId = checkedOwnedKycCustomerId(account.bridgeCustomerId) + // Lock order is always account -> provider customer. The namespace is + // distinct from wallet/payment locks and contains a validated ID only. + return redlock({ + path: `locks:bridge-kyc-customer:${customerId}`, + asyncFn: async (customerSignal) => + guardedKycWork(accountId, async () => { + if (signal.aborted || customerSignal.aborted) + return new BridgeError("Verification request lock expired") + const soleOwner = await requireSoleKycCustomerOwner(accountId, customerId) + if (soleOwner instanceof Error) return soleOwner + if (signal.aborted || customerSignal.aborted) + return new BridgeError("Verification request lock expired") + // Existing account ownership comes only from the stored association. + // Never POST by email or reset review status to obtain a continuation. + const identity = await BridgeApiClient.getHostedKycLink(customerId) + if (signal.aborted || customerSignal.aborted) + return new BridgeError("Verification request lock expired") + const terms = await BridgeApiClient.getHostedTosLink(customerId) + const current = await checkAccountLevel(accountId) + if (current instanceof Error) return current + if ( + signal.aborted || + customerSignal.aborted || + current.bridgeCustomerId !== customerId + ) { + return new BridgeError("Bridge customer association changed") + } + const stillSoleOwner = await requireSoleKycCustomerOwner( + accountId, + customerId, + ) + if (stillSoleOwner instanceof Error) return stillSoleOwner + if (signal.aborted || customerSignal.aborted) + return new BridgeError("Verification request lock expired") + return { + kycLink: hostedKycURL(identity?.url, "identity"), + customerId, + tosLink: hostedKycURL(terms?.url, "terms"), + } + }), + }) } - } - if (bridgeError?.statusCode === 400 && bridgeError.response?.existing_kyc_link) { - // store the customer id and the kyc status - const customerId = toBridgeCustomerId( - bridgeError.response.existing_kyc_link.customer_id, + const identity = await IdentityRepository().getIdentity(account.kratosUserId) + if (identity instanceof Error) return identity + if (signal.aborted) return new BridgeError("Verification request lock expired") + // Stable per-account UUID: a lost response or local persistence failure + // cannot be bypassed by changing caller email/name within provider's + // idempotency retention. This is not an unlimited exactly-once guarantee. + const idempotencyKey = getUuidByString(`bridge-kyc-create:v1:${accountId}`) + const link = await BridgeApiClient.createKycLink( + { + email: identity.email || email, + type: type || "individual", + full_name: full_name || account.username, + }, + idempotencyKey, ) - await AccountsRepository().updateBridgeFields(accountId, { - bridgeCustomerId: customerId, - bridgeKycStatus: "not_started", + const kycLink = hostedKycURL(link?.kyc_link, "identity") + const tosLink = hostedKycURL(link?.tos_link, "terms") + const customerId = checkedOwnedKycCustomerId(link?.customer_id) + if (signal.aborted) return new BridgeError("Verification request lock expired") + return redlock({ + path: `locks:bridge-kyc-customer:${customerId}`, + asyncFn: async (customerSignal) => + guardedKycWork(accountId, async () => { + if (signal.aborted || customerSignal.aborted) + return new BridgeError("Verification request lock expired") + const owners = await AccountsRepository().findBridgeCustomerOwners(customerId) + if (owners instanceof Error) return owners + if (owners.length !== 0) + return new BridgeError("Bridge customer ownership requires review") + if (signal.aborted || customerSignal.aborted) + return new BridgeError("Verification request lock expired") + const bound = await AccountsRepository().bindBridgeCustomerIfUnbound( + accountId, + customerId, + ) + if (bound instanceof Error) return bound + if (bound.bridgeCustomerId !== customerId) + return new BridgeError("Bridge customer association changed") + const current = await checkAccountLevel(accountId) + if (current instanceof Error) return current + if ( + signal.aborted || + customerSignal.aborted || + current.bridgeCustomerId !== customerId + ) + return new BridgeError("Bridge customer association changed") + const soleOwner = await requireSoleKycCustomerOwner(accountId, customerId) + if (soleOwner instanceof Error) return soleOwner + if (signal.aborted || customerSignal.aborted) + return new BridgeError("Verification request lock expired") + return { kycLink, customerId, tosLink } + }), }) - - return { - kycLink: bridgeError.response.existing_kyc_link.kyc_link, - customerId: bridgeError.response.existing_kyc_link.customer_id, - tosLink: bridgeError.response.existing_kyc_link.tos_link, - } + } catch (error) { + return sanitizedKycError(error, accountId) } - - baseLogger.error( - { accountId, operation: "initiateKyc", error }, - "Bridge operation failed", - ) - return error instanceof Error ? error : new Error(String(error)) - } + }) } /** diff --git a/src/services/bridge/owned-kyc-customer.ts b/src/services/bridge/owned-kyc-customer.ts new file mode 100644 index 000000000..bdc1591ba --- /dev/null +++ b/src/services/bridge/owned-kyc-customer.ts @@ -0,0 +1,16 @@ +import { toBridgeCustomerId } from "@domain/primitives/bridge" + +import { BridgeError } from "./errors" + +/** A single provider resource identifier, never an encoded URL or dot segment. */ +export const checkedOwnedKycCustomerId = (value: unknown): BridgeCustomerId => { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > 256 || + /[^A-Za-z0-9_-]/.test(value) + ) { + throw new BridgeError("Invalid Bridge customer association") + } + return toBridgeCustomerId(value) +} diff --git a/src/services/mongoose/accounts.ts b/src/services/mongoose/accounts.ts index 086eb564c..0320921fb 100644 --- a/src/services/mongoose/accounts.ts +++ b/src/services/mongoose/accounts.ts @@ -315,6 +315,43 @@ export const AccountsRepository = (): IAccountsRepository => { } } + // At most two IDs are needed to distinguish sole ownership from ambiguity. + // A historical duplicate is not resolved by choosing the first matching row. + const findBridgeCustomerOwners = async ( + customerId: BridgeCustomerId, + ): Promise => { + try { + const owners = await Account.find( + { bridgeCustomerId: { $eq: customerId } }, + { _id: 1 }, + ) + .limit(2) + .lean() + return owners.map((owner) => fromObjectId(owner._id)) + } catch (error) { + return parseRepositoryError(error) + } + } + + // Compare-and-set prevents an in-flight KYC response from replacing a + // customer association established by another request or an older server. + const bindBridgeCustomerIfUnbound = async ( + id: AccountId, + customerId: BridgeCustomerId, + ): Promise => { + try { + const result = await Account.findOneAndUpdate( + { _id: toObjectId(id), bridgeCustomerId: null }, + { $set: { bridgeCustomerId: customerId, bridgeKycStatus: "open" } }, + { new: true }, + ) + if (!result) return new RepositoryError("Bridge customer association changed") + return translateToAccount(result) + } catch (error) { + return parseRepositoryError(error) + } + } + const updateBridgeFields = async ( id: AccountId, fields: { @@ -394,6 +431,8 @@ export const AccountsRepository = (): IAccountsRepository => { claimNpub, update, transitionBridgeKycStatus, + bindBridgeCustomerIfUnbound, + findBridgeCustomerOwners, updateBridgeFields, findByBridgeEthereumAddress, findByBridgeCustomerId, diff --git a/test/flash/unit/services/bridge/client-owned-kyc-links.spec.ts b/test/flash/unit/services/bridge/client-owned-kyc-links.spec.ts new file mode 100644 index 000000000..f1c1e4716 --- /dev/null +++ b/test/flash/unit/services/bridge/client-owned-kyc-links.spec.ts @@ -0,0 +1,151 @@ +jest.mock("@config", () => ({ + BridgeConfig: { + apiKey: "test-api-key", + baseUrl: "https://api.sandbox.bridge.xyz/v0", + }, +})) +jest.mock("@services/alerts", () => ({ + alertBridge: jest.fn(), + generateDedupKey: { + bridgeApi5xx: jest.fn(), + bridgeApiTimeout: jest.fn(), + bridgeApiNetwork: jest.fn(), + }, +})) + +import { createServer, Server } from "node:http" + +import { BridgeClient } from "@services/bridge/client" +import { toBridgeCustomerId } from "@domain/primitives/bridge" + +describe("owned existing-customer hosted links", () => { + let fetch: jest.SpyInstance + beforeEach(() => { + fetch = jest.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ url: "https://bridge.withpersona.com/verify" }), { + status: 200, + }), + ) + }) + afterEach(() => { + fetch.mockRestore() + }) + + it("uses the documented current-customer GET endpoints without idempotency header or caller PII", async () => { + const client = new BridgeClient() + const owner = toBridgeCustomerId("customer-owned") + await client.getHostedKycLink(owner) + await client.getHostedTosLink(owner) + expect(fetch.mock.calls.map(([url]) => url)).toEqual([ + "https://api.sandbox.bridge.xyz/v0/customers/customer-owned/kyc_link", + "https://api.sandbox.bridge.xyz/v0/customers/customer-owned/tos_acceptance_link", + ]) + for (const [, request] of fetch.mock.calls) { + expect(request.method).toBe("GET") + expect(request.redirect).toBe("error") + expect(request.body).toBeUndefined() + expect(request.headers["Idempotency-Key"]).toBeUndefined() + expect(request.headers["Api-Key"]).toBe("test-api-key") + } + }) + + it.each([ + ".", + "..", + "owned/../foreign", + "%2e%2e", + "id?query", + "id#hash", + "id\\path", + "", + "id\n", + "é", + "a".repeat(257), + ])("rejects unsafe customer ID before any authenticated request: %s", async (value) => { + const client = new BridgeClient() + await expect(client.getHostedKycLink(toBridgeCustomerId(value))).rejects.toThrow( + "Invalid Bridge customer association", + ) + await expect(client.getHostedTosLink(toBridgeCustomerId(value))).rejects.toThrow( + "Invalid Bridge customer association", + ) + expect(fetch).not.toHaveBeenCalled() + }) +}) + +// Real fetch against loopback servers proves the Api-Key never reaches the +// redirect destination. No provider host or production credential is involved. +it.each(["identity", "terms", "create", "existingGET", "existingPOST"])( + "refuses a cross-origin redirect for %s without forwarding Api-Key", + async (kind) => { + let destinationHits = 0 + const destination = createServer((_request, response) => { + destinationHits++ + response.setHeader("Content-Type", "application/json") + response.end(JSON.stringify({ url: "https://bridge.withpersona.com/verify" })) + }) + const listen = (server: Server) => + new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address() + if (!address || typeof address === "string") + throw new Error("Expected loopback port") + resolve(address.port) + }) + }) + const close = (server: Server) => + new Promise((resolve) => { + server.closeAllConnections() + server.close(() => resolve()) + }) + const destinationPort = await listen(destination) + let authenticatedOriginHits = 0 + const origin = createServer((request, response) => { + if (request.headers["api-key"] === "test-api-key") authenticatedOriginHits++ + request.resume() + response.writeHead(307, { Location: `http://127.0.0.1:${destinationPort}/capture` }) + response.end() + }) + try { + const originPort = await listen(origin) + const configuration = jest.requireMock("@config").BridgeConfig as { + baseUrl: string + } + configuration.baseUrl = `http://127.0.0.1:${originPort}/v0` + const client = new BridgeClient() + const owner = toBridgeCustomerId("75d2f259-6810-4cde-a9b9-83eca42b0ebf") + const result = + kind === "identity" + ? client.getHostedKycLink(owner) + : kind === "terms" + ? client.getHostedTosLink(owner) + : kind === "create" + ? client.createKycLink( + { + email: "loopback@example.test", + full_name: "Loopback fixture", + type: "individual", + }, + "fixture-idempotency-key", + ) + : kind === "existingGET" + ? client.getCustomer(owner) + : client.createCustomer({ + type: "individual", + first_name: "Loopback", + last_name: "Fixture", + email: "loopback@example.test", + }) + await expect(result).rejects.toThrow() + expect(authenticatedOriginHits).toBe(1) + expect(destinationHits).toBe(0) + } finally { + const configuration = jest.requireMock("@config").BridgeConfig as { + baseUrl: string + } + configuration.baseUrl = "https://api.sandbox.bridge.xyz/v0" + await close(origin) + await close(destination) + } + }, +) diff --git a/test/flash/unit/services/bridge/initiate-kyc-owned-links.spec.ts b/test/flash/unit/services/bridge/initiate-kyc-owned-links.spec.ts new file mode 100644 index 000000000..eec80bd1b --- /dev/null +++ b/test/flash/unit/services/bridge/initiate-kyc-owned-links.spec.ts @@ -0,0 +1,514 @@ +jest.mock("@config", () => ({ + BridgeConfig: { enabled: true }, +})) + +jest.mock("@services/mongoose/bridge-accounts", () => ({})) + +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: jest.fn(), +})) + +jest.mock("@services/mongoose/schema", () => ({ + BridgeVirtualAccount: {}, +})) + +jest.mock("@services/mongoose/wallets", () => ({ + WalletsRepository: jest.fn(), +})) + +jest.mock("@services/tracing", () => ({ + wrapAsyncFunctionsToRunInSpan: ({ fns }: { fns: F }) => fns, +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@app/wallets/get-balance-for-wallet", () => ({ + getBalanceForWallet: jest.fn(), +})) + +jest.mock("@app/bridge/send-withdrawal-notification", () => ({ + sendBridgeWithdrawalNotificationBestEffort: jest.fn(), +})) + +jest.mock("@services/kratos", () => ({ + IdentityRepository: jest.fn(), +})) + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: {}, +})) + +jest.mock("@services/frappe/BridgeTransferRequestWriter", () => ({ + writeBridgeCashoutPending: jest.fn(), +})) + +jest.mock("@services/bridge/client", () => ({ + __esModule: true, + default: { + createKycLink: jest.fn(), + getHostedKycLink: jest.fn(), + getHostedTosLink: jest.fn(), + }, +})) +jest.mock("@services/bridge/plaid-link-token-store", () => ({ + PlaidLinkTokenStore: jest.fn(), +})) +let mockLocks = new Map>() +let mockSignal = { aborted: false }, + mockCustomerSignal = { aborted: false } +const mockAcquire = ( + path: string, + work: (signal: { aborted: boolean }) => Promise, +) => { + const signal = path.startsWith("locks:bridge-kyc-customer:") + ? mockCustomerSignal + : mockSignal + const next = (mockLocks.get(path) ?? Promise.resolve()).then(() => work(signal)) + mockLocks.set( + path, + next.then( + () => undefined, + () => undefined, + ), + ) + return next +} +jest.mock("@services/lock", () => ({ + LockService: () => ({ + lockAccountId: ( + id: string, + work: (signal: { aborted: boolean }) => Promise, + ) => mockAcquire("account:" + id, work), + }), + redlock: async ({ + path, + asyncFn, + }: { + path: string + asyncFn: (signal: { aborted: boolean }) => Promise + }) => { + try { + return await mockAcquire(path, asyncFn) + } catch (error) { + // Match the real adapter: callback exceptions become lock errors. + const { UnknownLockServiceError } = jest.requireActual("@domain/lock") + return new UnknownLockServiceError( + error instanceof Error ? error.message : "Callback failed", + ) + } + }, +})) + +import BridgeService from "@services/bridge" +import BridgeApiClient from "@services/bridge/client" +import { BridgeApiError, BridgeError } from "@services/bridge/errors" +import { AccountsRepository } from "@services/mongoose/accounts" +import { IdentityRepository } from "@services/kratos" +import { RepositoryError } from "@domain/errors" +import { baseLogger } from "@services/logger" + +const ACCOUNT = "account-1" as AccountId +const IDENTITY_LINK = "https://bridge.withpersona.com/verify?token=PRIVATE_LINK" +const TERMS_LINK = "https://compliance.bridge.xyz/accept-terms-of-service?t=PRIVATE_TERMS" +let current: Record +const mockFind = jest.fn() +const mockBind = jest.fn() +const mockOwners = jest.fn() +const request = (overrides: Record = {}) => + BridgeService.initiateKyc({ + accountId: ACCOUNT, + email: "caller@example.com", + full_name: "Caller Name", + type: "individual", + ...overrides, + }) + +beforeEach(() => { + jest.resetAllMocks() + mockLocks = new Map() + mockSignal = { aborted: false } + mockCustomerSignal = { aborted: false } + current = { + id: ACCOUNT, + level: 1, + kratosUserId: "identity-1", + username: "owner", + bridgeKycStatus: "not_started", + } + mockFind.mockImplementation(async () => ({ ...current })) + mockOwners.mockImplementation(async (customer: string) => + current.bridgeCustomerId === customer ? [current.id] : [], + ) + mockBind.mockImplementation(async (_id: string, customer: string) => { + if (current.bridgeCustomerId) return new RepositoryError("Association changed") + current.bridgeCustomerId = customer + current.bridgeKycStatus = "open" + return { ...current } + }) + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: mockFind, + bindBridgeCustomerIfUnbound: mockBind, + findBridgeCustomerOwners: mockOwners, + updateBridgeFields: jest.fn(), + }) + ;(IdentityRepository as jest.Mock).mockReturnValue({ + getIdentity: jest.fn().mockResolvedValue({ email: "verified@example.com" }), + }) + ;(BridgeApiClient.getHostedKycLink as jest.Mock).mockResolvedValue({ + url: IDENTITY_LINK, + }) + ;(BridgeApiClient.getHostedTosLink as jest.Mock).mockResolvedValue({ url: TERMS_LINK }) + ;(BridgeApiClient.createKycLink as jest.Mock).mockResolvedValue({ + kyc_link: IDENTITY_LINK, + tos_link: TERMS_LINK, + customer_id: "cust_new", + }) +}) + +it.each([ + "not_started", + "incomplete", + "awaiting_questionnaire", + "awaiting_ubo", + "under_review", + "pending", + "paused", + "rejected", + "offboarded", + "open", +])( + "retrieves current owned customer links without POST or status reset for %s", + async (status) => { + current.bridgeCustomerId = "cust_owned" + current.bridgeKycStatus = status + const result = await request({ + email: "another-customer@example.com", + full_name: "Unrelated caller name", + }) + expect(result).toEqual({ + customerId: "cust_owned", + kycLink: IDENTITY_LINK, + tosLink: TERMS_LINK, + }) + expect(BridgeApiClient.getHostedKycLink).toHaveBeenCalledWith("cust_owned") + expect(BridgeApiClient.getHostedTosLink).toHaveBeenCalledWith("cust_owned") + expect(BridgeApiClient.createKycLink).not.toHaveBeenCalled() + expect(IdentityRepository).not.toHaveBeenCalled() + expect(mockBind).not.toHaveBeenCalled() + expect(current.bridgeKycStatus).toBe(status) + }, +) + +it("never falls back to POST when existing-link retrieval fails or returns malformed URLs", async () => { + current.bridgeCustomerId = "cust_owned" + ;(BridgeApiClient.getHostedKycLink as jest.Mock).mockRejectedValueOnce( + new BridgeApiError("Provider failed", 503), + ) + expect(await request()).toBeInstanceOf(Error) + ;(BridgeApiClient.getHostedKycLink as jest.Mock).mockResolvedValue({ + url: "https://bridge.withpersona.com.attacker.test/secret", + }) + expect(await request()).toBeInstanceOf(Error) + expect(BridgeApiClient.createKycLink).not.toHaveBeenCalled() + expect(mockBind).not.toHaveBeenCalled() +}) + +it("does not reveal a link if the current owned association changed during provider GET", async () => { + current.bridgeCustomerId = "cust_owned" + ;(BridgeApiClient.getHostedKycLink as jest.Mock).mockImplementation(async () => { + current.bridgeCustomerId = "cust_replaced" + return { url: IDENTITY_LINK } + }) + expect(await request()).toBeInstanceOf(Error) + expect(mockBind).not.toHaveBeenCalled() + expect(BridgeApiClient.createKycLink).not.toHaveBeenCalled() +}) + +it("refuses caller-email duplicate-customer fallback and never logs its private response", async () => { + ;(BridgeApiClient.createKycLink as jest.Mock).mockRejectedValue( + new BridgeApiError("Duplicate email", 400, { + existing_kyc_link: { + customer_id: "cust_foreign", + kyc_link: IDENTITY_LINK, + tos_link: TERMS_LINK, + }, + }), + ) + const result = await request() + expect(result).toBeInstanceOf(BridgeApiError) + expect((result as BridgeApiError).response).toBeUndefined() + expect(current.bridgeCustomerId).toBeUndefined() + expect(mockBind).not.toHaveBeenCalled() + expect(BridgeApiClient.getHostedKycLink).not.toHaveBeenCalled() + const logs = JSON.stringify((baseLogger.error as jest.Mock).mock.calls) + expect(logs).not.toContain("PRIVATE_") + expect(logs).not.toContain("existing_kyc_link") +}) + +it("uses one stable per-account UUID and authoritative identity email across explicit retries", async () => { + ;(BridgeApiClient.createKycLink as jest.Mock).mockRejectedValueOnce( + new Error("Transport lost"), + ) + expect(await request()).toBeInstanceOf(Error) + expect( + await request({ email: "different@example.com", full_name: "Changed name" }), + ).not.toBeInstanceOf(Error) + const calls = (BridgeApiClient.createKycLink as jest.Mock).mock.calls + expect(calls[0][1]).toBe(calls[1][1]) + expect(calls[0][1]).toMatch( + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/, + ) + expect(calls[0][0].email).toBe("verified@example.com") + expect(calls[1][0].email).toBe("verified@example.com") + expect(mockBind).toHaveBeenCalledTimes(1) +}) + +it("serializes concurrent starts and retrieves owned links after the first binding", async () => { + let release: (() => void) | undefined + ;(BridgeApiClient.createKycLink as jest.Mock).mockImplementation(async () => { + await new Promise((resolve) => { + release = resolve + }) + return { kyc_link: IDENTITY_LINK, tos_link: TERMS_LINK, customer_id: "cust_new" } + }) + const first = request() + for (let attempt = 0; attempt < 100 && !release; attempt++) { + await new Promise((resolve) => setImmediate(resolve)) + } + expect(release).toBeDefined() + const second = request() + release?.() + const results = await Promise.all([first, second]) + expect(results.every((result) => !(result instanceof Error))).toBe(true) + expect(BridgeApiClient.createKycLink).toHaveBeenCalledTimes(1) + expect(mockBind).toHaveBeenCalledTimes(1) + expect(BridgeApiClient.getHostedKycLink).toHaveBeenCalledWith("cust_new") +}) + +it("never overwrites a customer association won by another writer during first creation", async () => { + mockBind.mockImplementation(async () => { + current.bridgeCustomerId = "cust_other_writer" + return new RepositoryError("Association changed") + }) + expect(await request()).toBeInstanceOf(RepositoryError) + expect(current.bridgeCustomerId).toBe("cust_other_writer") + expect(mockBind).toHaveBeenCalledTimes(1) +}) + +it("refuses approved/low-level accounts and expired lock authority without provider calls", async () => { + current.bridgeKycStatus = "approved" + expect(await request()).toBeInstanceOf(Error) + current.bridgeKycStatus = "not_started" + current.level = 0 + expect(await request()).toBeInstanceOf(Error) + current.level = 1 + mockSignal.aborted = true + expect(await request()).toBeInstanceOf(Error) + expect(BridgeApiClient.createKycLink).not.toHaveBeenCalled() + expect(BridgeApiClient.getHostedKycLink).not.toHaveBeenCalled() +}) + +it.each([ + ".", + "..", + "", + "cust/foreign", + "cust?other", + "%2e%2e", + "cust\n", + "é", + "a".repeat(257), +])( + "rejects unsafe stored customer ID before any GET, POST or binding: %s", + async (value) => { + current.bridgeCustomerId = value + const result = await request() + expect(result).toBeInstanceOf(Error) + expect(BridgeApiClient.getHostedKycLink).not.toHaveBeenCalled() + expect(BridgeApiClient.getHostedTosLink).not.toHaveBeenCalled() + expect(BridgeApiClient.createKycLink).not.toHaveBeenCalled() + expect(mockBind).not.toHaveBeenCalled() + expect((result as Error).message).toBe("Verification request failed") + }, +) + +it.each([ + ".", + "..", + "", + "cust/foreign", + "cust#hash", + "%2e%2e", + "cust\r\n", + "é", + "a".repeat(257), +])( + "rejects unsafe newly returned customer ID before CAS or revealing links: %s", + async (value) => { + ;(BridgeApiClient.createKycLink as jest.Mock).mockResolvedValue({ + kyc_link: IDENTITY_LINK, + tos_link: TERMS_LINK, + customer_id: value, + }) + const result = await request() + expect(result).toBeInstanceOf(Error) + expect(mockBind).not.toHaveBeenCalled() + expect(current.bridgeCustomerId).toBeUndefined() + expect((result as Error).message).toBe("Verification request failed") + expect(JSON.stringify((baseLogger.error as jest.Mock).mock.calls)).not.toContain( + "PRIVATE_", + ) + }, +) + +it("refuses a shared historical customer association before any hosted GET", async () => { + current.bridgeCustomerId = "cust_shared" + mockOwners.mockResolvedValue([ACCOUNT, "another-account"]) + const result = await request() + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe("Bridge customer ownership requires review") + expect(BridgeApiClient.getHostedKycLink).not.toHaveBeenCalled() + expect(BridgeApiClient.getHostedTosLink).not.toHaveBeenCalled() + expect(BridgeApiClient.createKycLink).not.toHaveBeenCalled() + expect(mockBind).not.toHaveBeenCalled() +}) +it("rechecks exclusive ownership before revealing existing links when a legacy writer adds a duplicate", async () => { + current.bridgeCustomerId = "cust_owned" + mockOwners + .mockResolvedValueOnce([ACCOUNT]) + .mockResolvedValueOnce([ACCOUNT, "another-account"]) + const result = await request() + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe("Bridge customer ownership requires review") + expect(mockOwners).toHaveBeenCalledTimes(2) + expect(mockBind).not.toHaveBeenCalled() +}) +it("refuses an initial returned customer already bound to any account before CAS", async () => { + mockOwners.mockResolvedValue(["another-account"]) + const result = await request() + expect(result).toBeInstanceOf(Error) + expect(mockBind).not.toHaveBeenCalled() + expect(current.bridgeCustomerId).toBeUndefined() +}) +it("does not reveal newly created links if a legacy writer causes duplicate ownership after CAS", async () => { + mockOwners.mockResolvedValueOnce([]).mockResolvedValueOnce([ACCOUNT, "another-account"]) + const result = await request() + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe("Bridge customer ownership requires review") + expect(mockBind).toHaveBeenCalledTimes(1) +}) +it("fails closed when owner lookup is unavailable rather than choosing an arbitrary account", async () => { + current.bridgeCustomerId = "cust_owned" + mockOwners.mockResolvedValue(new RepositoryError("Owner lookup unavailable")) + expect(await request()).toBeInstanceOf(RepositoryError) + expect(BridgeApiClient.getHostedKycLink).not.toHaveBeenCalled() + expect(mockBind).not.toHaveBeenCalled() +}) +it("sanitizes provider GET errors inside the actual callback-conversion boundary", async () => { + current.bridgeCustomerId = "cust_owned" + ;(BridgeApiClient.getHostedKycLink as jest.Mock).mockRejectedValue( + new BridgeApiError("Private provider prose", 503, { + existing_kyc_link: { kyc_link: IDENTITY_LINK }, + }), + ) + const result = await request() + expect(result).toBeInstanceOf(BridgeApiError) + expect((result as BridgeApiError).statusCode).toBe(503) + expect((result as BridgeApiError).response).toBeUndefined() + expect((result as Error).message).not.toContain("Private") + expect(JSON.stringify((baseLogger.error as jest.Mock).mock.calls)).not.toContain( + "PRIVATE_LINK", + ) +}) +it.each(["account", "customer"])( + "rejects existing-link reveal when %s lock expires during final owner lookup", + async (kind) => { + current.bridgeCustomerId = "cust_owned" + mockOwners.mockResolvedValueOnce([ACCOUNT]).mockImplementationOnce(async () => { + if (kind === "account") mockSignal.aborted = true + else mockCustomerSignal.aborted = true + return [ACCOUNT] + }) + const result = await request() + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe("Verification request lock expired") + }, +) +it.each(["account", "customer"])( + "rejects new-link reveal when %s lock expires during final owner lookup", + async (kind) => { + mockOwners.mockResolvedValueOnce([]).mockImplementationOnce(async () => { + if (kind === "account") mockSignal.aborted = true + else mockCustomerSignal.aborted = true + return [ACCOUNT] + }) + const result = await request() + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe("Verification request lock expired") + expect(mockBind).toHaveBeenCalledTimes(1) + }, +) +it("refuses CAS when customer lock expires during the no-owner query", async () => { + mockOwners.mockImplementationOnce(async () => { + mockCustomerSignal.aborted = true + return [] + }) + const result = await request() + expect(result).toBeInstanceOf(Error) + expect(mockBind).not.toHaveBeenCalled() +}) +it("refuses hosted GET when account lock expires during initial owner lookup", async () => { + current.bridgeCustomerId = "cust_owned" + mockOwners.mockImplementationOnce(async () => { + mockSignal.aborted = true + return [ACCOUNT] + }) + expect(await request()).toBeInstanceOf(Error) + expect(BridgeApiClient.getHostedKycLink).not.toHaveBeenCalled() +}) + +it("serializes different-account bindings for the same returned provider customer", async () => { + const other = "account-2" as AccountId + const records = new Map>([ + [ACCOUNT, { ...current }], + [other, { ...current, id: other }], + ]) + mockFind.mockImplementation(async (id: string) => ({ ...records.get(id) })) + mockOwners.mockImplementation(async (customer: string) => + [...records.values()] + .filter((record) => record.bridgeCustomerId === customer) + .map((record) => record.id), + ) + mockBind.mockImplementation(async (id: string, customer: string) => { + const record = records.get(id)! + if (record.bridgeCustomerId) return new RepositoryError("Association changed") + record.bridgeCustomerId = customer + return { ...record } + }) + const results = await Promise.all([request(), request({ accountId: other })]) + expect(results.filter((result) => !(result instanceof Error))).toHaveLength(1) + expect(results.filter((result) => result instanceof Error)).toHaveLength(1) + expect(mockBind).toHaveBeenCalledTimes(1) + expect( + [...records.values()].filter((record) => record.bridgeCustomerId === "cust_new"), + ).toHaveLength(1) +}) + +it("drops non-API provider exception objects instead of preserving arbitrary private fields", async () => { + current.bridgeCustomerId = "cust_owned" + ;(BridgeApiClient.getHostedKycLink as jest.Mock).mockRejectedValue( + Object.assign(new BridgeError("PRIVATE_PROVIDER_PROSE"), { + response: { kyc_link: IDENTITY_LINK }, + }), + ) + const result = await request() + expect(result).toBeInstanceOf(BridgeError) + expect((result as Error).message).toBe("Verification request failed") + expect(result).not.toHaveProperty("response") + expect(JSON.stringify((baseLogger.error as jest.Mock).mock.calls)).not.toContain( + "PRIVATE_", + ) +}) diff --git a/test/flash/unit/services/mongoose/accounts-bind-bridge-customer.spec.ts b/test/flash/unit/services/mongoose/accounts-bind-bridge-customer.spec.ts new file mode 100644 index 000000000..e7605ecdc --- /dev/null +++ b/test/flash/unit/services/mongoose/accounts-bind-bridge-customer.spec.ts @@ -0,0 +1,95 @@ +jest.mock("@config", () => ({ OnboardingEarn: {} })) + +import { RepositoryError } from "@domain/errors" +import { AccountsRepository } from "@services/mongoose/accounts" +import { toBridgeCustomerId } from "@domain/primitives/bridge" + +const findOneAndUpdate = jest.fn() +const find = jest.fn(), + limit = jest.fn(), + lean = jest.fn() +jest.mock("@services/mongoose/schema", () => ({ + Account: { + findOneAndUpdate: (...args: unknown[]) => findOneAndUpdate(...args), + find: (...args: unknown[]) => find(...args), + }, +})) +jest.mock("@services/mongoose/utils", () => ({ + toObjectId: jest.fn((id) => id), + fromObjectId: jest.fn((id) => id), + parseRepositoryError: jest.fn((error) => error), +})) + +beforeEach(() => { + findOneAndUpdate.mockReset() + find.mockReset() + limit.mockReset() + lean.mockReset() + find.mockReturnValue({ limit }) + limit.mockReturnValue({ lean }) +}) + +it("atomically binds only an unbound account without overwriting an existing association", async () => { + findOneAndUpdate.mockResolvedValue({ + _id: "account", + id: "uuid", + created_at: new Date(), + username: "owner", + level: 1, + statusHistory: [{ status: "active" }], + contacts: [], + earn: [], + bridgeCustomerId: "owned-new", + bridgeKycStatus: "open", + }) + const result = await AccountsRepository().bindBridgeCustomerIfUnbound( + "account" as AccountId, + toBridgeCustomerId("owned-new"), + ) + expect(result).not.toBeInstanceOf(Error) + expect((result as Account).bridgeCustomerId).toBe("owned-new") + expect(findOneAndUpdate).toHaveBeenCalledWith( + { _id: "account", bridgeCustomerId: null }, + { $set: { bridgeCustomerId: "owned-new", bridgeKycStatus: "open" } }, + { new: true }, + ) +}) + +it("reports a lost association race without falling back to an unconditional update", async () => { + findOneAndUpdate.mockResolvedValue(null) + const result = await AccountsRepository().bindBridgeCustomerIfUnbound( + "account" as AccountId, + toBridgeCustomerId("must-not-replace"), + ) + expect(result).toBeInstanceOf(RepositoryError) + expect(findOneAndUpdate).toHaveBeenCalledTimes(1) +}) + +it("reads at most two owner IDs with an exact-customer filter and no PII fields", async () => { + lean.mockResolvedValue([{ _id: "owner-one" }, { _id: "owner-two" }]) + const result = await AccountsRepository().findBridgeCustomerOwners( + toBridgeCustomerId("cust_shared"), + ) + expect(result).toEqual(["owner-one", "owner-two"]) + expect(find).toHaveBeenCalledWith( + { bridgeCustomerId: { $eq: "cust_shared" } }, + { _id: 1 }, + ) + expect(limit).toHaveBeenCalledWith(2) + expect(lean).toHaveBeenCalledTimes(1) +}) +it("distinguishes no owners from a failed ownership query", async () => { + lean + .mockResolvedValueOnce([]) + .mockRejectedValueOnce(new RepositoryError("Database unavailable")) + expect( + await AccountsRepository().findBridgeCustomerOwners( + toBridgeCustomerId("cust_unbound"), + ), + ).toEqual([]) + expect( + await AccountsRepository().findBridgeCustomerOwners( + toBridgeCustomerId("cust_unbound"), + ), + ).toBeInstanceOf(RepositoryError) +}) From 8cf5671df552f26a814ed438d0829cc0f19165b8 Mon Sep 17 00:00:00 2001 From: Dread Date: Thu, 22 Oct 2026 23:32:51 -0700 Subject: [PATCH 04/42] Add isolated native parity unit runner and service boundary mocks --- docs/native-parity-unit-tests.md | 28 ++++ scripts/native-parity-unit.cjs | 136 ++++++++++++++++++ scripts/native-parity-unit.test.cjs | 110 ++++++++++++++ test/flash/unit/native-parity-network.cjs | 67 +++++++++ .../services/bridge/return-shapes.spec.ts | 7 + .../bridge/webhook-server/kyc.spec.ts | 11 ++ .../services/notification/wa-bridge.spec.ts | 9 ++ 7 files changed, 368 insertions(+) create mode 100644 docs/native-parity-unit-tests.md create mode 100644 scripts/native-parity-unit.cjs create mode 100644 scripts/native-parity-unit.test.cjs create mode 100644 test/flash/unit/native-parity-network.cjs diff --git a/docs/native-parity-unit-tests.md b/docs/native-parity-unit-tests.md new file mode 100644 index 000000000..026dce451 --- /dev/null +++ b/docs/native-parity-unit-tests.md @@ -0,0 +1,28 @@ +# Isolated native-parity backend unit checks + +Use Node 24 and dependencies installed from this checkout's frozen Yarn lockfile. The runner intentionally rejects other Node major versions. It does not install dependencies, source `.env`/`.env.local`, invoke a shell, or run the integration/sandbox suites. + +```sh +node scripts/native-parity-unit.cjs +node --test scripts/native-parity-unit.test.cjs +``` + +The first command runs the fixed, reviewed selection of 37 Bridge, Earn and identity-storage unit suites. Optional group arguments are `bridge`, `earn`, and `storage`, for example `node scripts/native-parity-unit.cjs storage`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, and Earn's atomic reward deduplication. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. + +The child receives only inherited `PATH` and `TMPDIR`, plus the explicit fixture values in `scripts/native-parity-unit.cjs`. Required secrets are unmistakable dummy strings. Network is `regtest`; configured service hosts point at numeric loopback port 9. `HOME`, cloud credentials/profiles, proxy variables, Node preloads, and inherited service/provider credentials are omitted. Jest's setup selects only committed `dev/config/base-config.yaml`; no private configuration file is loaded by the runner. Its required dummy environment exists only in the child process and must never be used for a deployment. + +The runner uses the same Node executable that launched it, local Jest, `--runInBand`, an explicit `--runTestsByPath` list, and `--watchman=false`. Disabling Watchman avoids contacting a developer's existing Watchman daemon. There is no `--forceExit`, forced success, automatic test retry, or hidden connection cleanup. An open application/service handle therefore remains visible rather than being masked by forced termination. + +`test/flash/unit/native-parity-network.cjs` preloads a network interlock before Jest or application modules. It permits only numeric loopback TCP fixture servers created in that same process on an ephemeral port, and connections to those registered fixture ports. Other TCP/Unix-socket connections, wildcard/fixed-port listeners, DNS resolution, and UDP sends fail. A caught network error still makes the overall run fail, so a best-effort provider wrapper cannot hide a missing mock. Diagnostics print a static error and code frames without destination URLs, headers or payloads. This protects these trusted tests against accidental unmocked I/O; it is not a security sandbox for hostile JavaScript or arbitrary subprocesses. + +Three narrow test boundaries now avoid real service initialization: + +- KYC webhook tests mock `awardReferralRewardOnKycApproval` from `@app/invite`. The approval case asserts the owned referral delegation, while duplicate-event and lost-CAS cases assert no reward delegation. This keeps invite's notification/queue clients out of a handler unit test. +- Withdrawal return-shape tests mock `@services/cache`, whose Plaid token-store import otherwise initializes Redis despite no Plaid call in that suite. Plaid cache ownership and consumption behavior is tested separately in the selected exchange suite with explicit cache mocks. +- WhatsApp bridge tests explicitly mock unconfigured Twilio/SendGrid configuration, matching their documented fallback precondition and keeping it independent of the backend bootstrap's dummy Twilio variables. + +The identity presigned-PUT test still uses the real pinned AWS SDK and sends the untouched signed URL with a nonempty PNG to its local HTTP fixture. The interlock permits that fixture. It emulates checksum/body acceptance, not production signature verification, and uses dummy credentials only. All Bridge, IBEX, notification, database and cache interactions elsewhere are mocked. + +Validated 2026-10-22 on Node `24.22.1`, including owned KYC integration commit `045950860`: all **37 suites / 491 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-isolated-runner.log` and `/tmp/flash-native-backend-runner-tests.log`; they are local artifacts, not checked-in fixtures. + +These are offline unit checks. They do not establish provider access, sandbox interoperability, production configuration, bank-account provisioning, image storage acceptance by DigitalOcean, or deployment readiness. diff --git a/scripts/native-parity-unit.cjs b/scripts/native-parity-unit.cjs new file mode 100644 index 000000000..95921f706 --- /dev/null +++ b/scripts/native-parity-unit.cjs @@ -0,0 +1,136 @@ +// Run with Node 24. No .env, shell evaluation, inherited service credentials, +// arbitrary Jest arguments, integration suites, or forced process termination. +const { spawn } = require("node:child_process") +const path = require("node:path") + +const root = path.resolve(__dirname, "..") +const unit = "test/flash/unit/" +const suites = Object.freeze({ + bridge: [ + "app/bridge/get-withdrawal-flash-fee-notice.spec.ts", + "app/bridge/kyc-gate.spec.ts", + "app/bridge/send-deposit-notification.spec.ts", + "app/bridge/send-kyc-notification.spec.ts", + "app/bridge/send-withdrawal-notification.spec.ts", + "dev/setup-bridge-webhooks.spec.ts", + "graphql/bridge-error-map.spec.ts", + "graphql/public/root/mutation/bridge-add-external-account.spec.ts", + "graphql/public/root/mutation/bridge-external-account.spec.ts", + "graphql/public/root/mutation/bridge-initiate-kyc.spec.ts", + "graphql/public/root/mutation/bridge-withdrawal.spec.ts", + "graphql/public/root/query/bridge-withdrawals.spec.ts", + "graphql/public/types/object/bridge-contract.spec.ts", + "services/alerts/ibex-bridge-movement.spec.ts", + "services/bridge/client-owned-kyc-links.spec.ts", + "services/bridge/client.spec.ts", + "services/bridge/ethereum-gas-estimate.spec.ts", + "services/bridge/exchange-plaid-public-token.spec.ts", + "services/bridge/index.spec.ts", + "services/bridge/initiate-kyc-owned-links.spec.ts", + "services/bridge/plaid-available-for-ip.spec.ts", + "services/bridge/reconciliation.spec.ts", + "services/bridge/return-shapes.spec.ts", + "services/bridge/webhook-server/deposit.spec.ts", + "services/bridge/webhook-server/enabled-guard.spec.ts", + "services/bridge/webhook-server/kyc.spec.ts", + "services/bridge/webhook-server/ops-events-webhooks.spec.ts", + "services/bridge/webhook-server/replay.spec.ts", + "services/bridge/webhook-server/transfer.spec.ts", + "services/bridge/webhook-server/verify-signature.spec.ts", + "services/bridge/withdrawal-fees.spec.ts", + "services/mongoose/accounts-bind-bridge-customer.spec.ts", + "services/notification/wa-bridge.spec.ts", + ], + earn: ["app/payments/add-earn.spec.ts", "services/mongoose/rewards.spec.ts"], + storage: [ + "services/storage/id-document-presigned-put.spec.ts", + "services/storage/id-documents.spec.ts", + ], +}) + +function safeEnvironment(inherited) { + return { + ...(inherited.PATH ? { PATH: inherited.PATH } : {}), + ...(inherited.TMPDIR ? { TMPDIR: inherited.TMPDIR } : {}), + NODE_ENV: "test", + LOGLEVEL: "fatal", + NETWORK: "regtest", + HELMREVISION: "native-parity-unit", + KRATOS_PG_CON: "postgres://unit:unit@127.0.0.1:9/unit", + OATHKEEPER_DECISION_ENDPOINT: "http://127.0.0.1:9", + KRATOS_PUBLIC_API: "http://127.0.0.1:9", + KRATOS_ADMIN_API: "http://127.0.0.1:9", + TWILIO_ACCOUNT_SID: "AC00000000000000000000000000000000", + TWILIO_AUTH_TOKEN: "unit-fixture-not-a-live-credential", + TWILIO_VERIFY_SERVICE_ID: "VA00000000000000000000000000000000", + KRATOS_MASTER_USER_PASSWORD: "unit-fixture-master-password-never-for-deployment", + KRATOS_CALLBACK_API_KEY: "unit-fixture-callback-key-never-for-deployment", + BRIA_HOST: "127.0.0.1", + BRIA_PORT: "9", + BRIA_API_KEY: "unit-fixture-bria-key-never-for-deployment", + MONGODB_CON: + "mongodb://127.0.0.1:9/unit?directConnection=true&serverSelectionTimeoutMS=500", + REDIS_TYPE: "standalone", + REDIS_MASTER_NAME: "unit", + REDIS_PASSWORD: "unit-fixture", + REDIS_0_DNS: "127.0.0.1", + REDIS_0_PORT: "9", + PRICE_HOST: "127.0.0.1", + PRICE_HISTORY_HOST: "127.0.0.1", + PRICE_SERVER_HOST: "127.0.0.1", + PRICE_PORT: "9", + PRICE_HISTORY_PORT: "9", + } +} + +function selection(args) { + if (args.some((group) => !Object.hasOwn(suites, group))) { + throw new Error("Select only bridge, earn, storage; no other Jest arguments allowed") + } + return [...new Set(args.length ? args : Object.keys(suites))].flatMap((group) => + suites[group].map((file) => unit + file), + ) +} + +function main(args) { + if (process.versions.node.split(".")[0] !== "24") { + throw new Error("Native parity unit fixtures require Node 24 (see package engines)") + } + const paths = selection(args) + console.log( + `Running ${paths.length} isolated unit suites with dummy-only configuration`, + ) + const child = spawn( + process.execPath, + [ + "--require", + path.join(root, "test/flash/unit/native-parity-network.cjs"), + path.join(root, "node_modules/jest/bin/jest.js"), + "--config", + "test/flash/unit/jest.config.js", + "--runInBand", + "--watchman=false", + "--runTestsByPath", + ...paths, + ], + { cwd: root, env: safeEnvironment(process.env), stdio: "inherit", shell: false }, + ) + child.on("error", () => { + console.error("Unable to start the local Node/Jest unit runner") + process.exitCode = 1 + }) + child.on("exit", (code, signal) => { + if (signal) console.error(`Unit runner terminated by ${signal}`) + process.exitCode = code ?? 1 + }) +} + +module.exports = { safeEnvironment, selection, suites } +if (require.main === module) { + try { + main(process.argv.slice(2)) + } catch (error) { + console.error(error.message) + process.exitCode = 1 + } +} diff --git a/scripts/native-parity-unit.test.cjs b/scripts/native-parity-unit.test.cjs new file mode 100644 index 000000000..fe2ae8ed6 --- /dev/null +++ b/scripts/native-parity-unit.test.cjs @@ -0,0 +1,110 @@ +const { test } = require("node:test") +const assert = require("node:assert/strict") +const { spawnSync } = require("node:child_process") +const path = require("node:path") +const { safeEnvironment, selection } = require("./native-parity-unit.cjs") +const guard = path.resolve(__dirname, "../test/flash/unit/native-parity-network.cjs") + +test("child configuration drops credentials, environment preloads and service overrides", () => { + const env = safeEnvironment({ + PATH: "/fixture/bin", + TMPDIR: "/fixture/tmp", + HOME: "/private/fixture", + NODE_OPTIONS: "--require /private/preload.cjs", + DOTENV_CONFIG_PATH: "/private/.env", + NODE_ENV: "production", + NETWORK: "mainnet", + AWS_PROFILE: "production", + GOOGLE_APPLICATION_CREDENTIALS: "/private/service-account.json", + TWILIO_AUTH_TOKEN: "must-not-pass-through", + REDIS_0_DNS: "redis.production.invalid", + MONGODB_CON: "mongodb://production.invalid", + HTTP_PROXY: "http://proxy.invalid", + }) + assert.equal(env.PATH, "/fixture/bin") + assert.equal(env.TMPDIR, "/fixture/tmp") + for (const key of [ + "HOME", + "NODE_OPTIONS", + "DOTENV_CONFIG_PATH", + "AWS_PROFILE", + "GOOGLE_APPLICATION_CREDENTIALS", + "HTTP_PROXY", + ]) { + assert.equal(Object.hasOwn(env, key), false) + } + assert.equal(env.NODE_ENV, "test") + assert.equal(env.NETWORK, "regtest") + assert.equal(env.TWILIO_AUTH_TOKEN, "unit-fixture-not-a-live-credential") + assert.equal(env.REDIS_0_DNS, "127.0.0.1") + assert.match(env.MONGODB_CON, /^mongodb:\/\/127\.0\.0\.1:9\//) +}) + +test("suite selection cannot become an arbitrary script, integration path or Jest switch", () => { + for (const args of [ + ["--forceExit"], + ["--config=private.cjs"], + ["../../integration"], + ["bridge", "unknown"], + ]) { + assert.throws(() => selection(args)) + } + const files = selection([]) + assert.equal(new Set(files).size, files.length) + assert.ok( + files.every( + (file) => file.startsWith("test/flash/unit/") && file.endsWith(".spec.ts"), + ), + ) + assert.ok( + files.includes("test/flash/unit/services/storage/id-document-presigned-put.spec.ts"), + ) + assert.deepEqual(selection(["storage", "storage"]), selection(["storage"])) +}) + +function run(code) { + return spawnSync(process.execPath, ["--require", guard, "-e", code], { + env: safeEnvironment({}), + encoding: "utf8", + timeout: 10000, + }) +} + +test("real local HTTP fixture works and exits naturally", () => { + const result = run(` + const http = require("node:http"); + const server = http.createServer((request, response) => response.end("fixture")); + server.listen(0, "127.0.0.1", () => { + http.get("http://127.0.0.1:" + server.address().port, response => { + response.resume(); response.on("end", () => server.close()); + }).on("error", error => { console.error(error); process.exitCode=1; server.close(); }); + }); + `) + assert.equal(result.error, undefined) + assert.equal(result.status, 0, result.stderr) +}) + +test("even caught remote, unowned loopback, DNS and UDP attempts fail the process", () => { + for (const code of [ + 'require("node:net").connect({host:"198.51.100.1",port:443})', + 'require("node:net").connect({host:"127.0.0.1",port:9})', + 'require("node:dns").lookup("provider.example.invalid",()=>{})', + 'new(require("node:dns").Resolver)().resolve4("provider.example.invalid",()=>{})', + 'const socket=require("node:dgram").createSocket("udp4");try{socket.send("x",53,"198.51.100.1")}finally{socket.close()}', + 'require("node:http").createServer().listen(0,"0.0.0.0")', + ]) { + const result = run( + `try { ${code} } catch(error) { if(error.code!=="UNIT_NETWORK_BLOCKED") throw error; }`, + ) + assert.equal(result.error, undefined) + assert.equal(result.status, 1, result.stderr) + assert.match(result.stderr, /Blocked 1 unmocked network attempts/) + } +}) + +test("a caught fetch failure cannot hide a missing HTTP provider mock", () => { + const result = run('fetch("https://provider.example.invalid").catch(()=>{})') + assert.equal(result.error, undefined) + assert.equal(result.status, 1, result.stderr) + assert.match(result.stderr, /unmocked network attempts/) +}) diff --git a/test/flash/unit/native-parity-network.cjs b/test/flash/unit/native-parity-network.cjs new file mode 100644 index 000000000..e17ca036a --- /dev/null +++ b/test/flash/unit/native-parity-network.cjs @@ -0,0 +1,67 @@ +// Defense against an accidentally unmocked dependency in the fixed unit slice. +// Permit only TCP connections to loopback servers created in this test process. +// This is a test interlock, not a sandbox for hostile/untrusted JavaScript. +const net = require("node:net") +const dns = require("node:dns") +const dgram = require("node:dgram") +const ports = new Set() +let violations = 0 +const reject = () => { + violations += 1 + const error = new Error("Unmocked network access blocked by native parity unit runner") + error.code = "UNIT_NETWORK_BLOCKED" + // Static error and code frames only: never print URLs, headers or payloads. + console.error(error.stack) + throw error +} +const loopback = (host) => host === "127.0.0.1" || host === "::1" +const listen = net.Server.prototype.listen +net.Server.prototype.listen = function (...args) { + const options = args[0] + const host = typeof options === "object" ? options.host : args[1] + const port = typeof options === "object" ? options.port : options + if (!loopback(host) || port !== 0) return reject() + this.once("listening", () => { + const address = this.address() + if (address && typeof address === "object") { + ports.add(address.port) + this.once("close", () => ports.delete(address.port)) + } + }) + return listen.apply(this, args) +} +const connect = net.Socket.prototype.connect +net.Socket.prototype.connect = function (...args) { + const normalized = Array.isArray(args[0]) ? args[0] : args + const options = normalized[0] + const host = typeof options === "object" ? options.host : normalized[1] + const port = Number(typeof options === "object" ? options.port : options) + if (!loopback(host) || !ports.has(port)) return reject() + return connect.apply(this, args) +} +// Node's Server.listen invokes lookup even for a numeric loopback literal; +// lookup returns that literal without contacting a DNS resolver. +const lookup = dns.lookup +const promiseLookup = dns.promises.lookup +dns.lookup = (host, ...args) => (loopback(host) ? lookup(host, ...args) : reject()) +dns.promises.lookup = async (host, ...args) => + loopback(host) ? promiseLookup(host, ...args) : reject() +for (const target of [ + dns, + dns.promises, + dns.Resolver.prototype, + dns.promises.Resolver.prototype, +]) { + for (const name of Object.getOwnPropertyNames(target)) { + if (name.startsWith("resolve") || name === "reverse" || name === "lookupService") { + target[name] = reject + } + } +} +dgram.Socket.prototype.send = reject +process.on("beforeExit", () => { + if (violations) { + console.error(`Blocked ${violations} unmocked network attempts; unit run fails`) + process.exitCode = 1 + } +}) diff --git a/test/flash/unit/services/bridge/return-shapes.spec.ts b/test/flash/unit/services/bridge/return-shapes.spec.ts index 25e8f7ed7..284669b6f 100644 --- a/test/flash/unit/services/bridge/return-shapes.spec.ts +++ b/test/flash/unit/services/bridge/return-shapes.spec.ts @@ -90,6 +90,13 @@ jest.mock("@services/kratos", () => ({ IdentityRepository: jest.fn(), })) +// Plaid token storage is outside withdrawal return-shape coverage. Importing +// its real cache dependency opens Redis connections even without a Plaid call. +jest.mock("@services/cache", () => ({ + RedisCacheService: jest.fn(), + consumeCacheKey: jest.fn(), +})) + jest.mock("@domain/primitives/bridge", () => ({ toBridgeCustomerId: (id: string) => id, toBridgeExternalAccountId: (id: string) => id, diff --git a/test/flash/unit/services/bridge/webhook-server/kyc.spec.ts b/test/flash/unit/services/bridge/webhook-server/kyc.spec.ts index 32c8c5d97..3f5ee722f 100644 --- a/test/flash/unit/services/bridge/webhook-server/kyc.spec.ts +++ b/test/flash/unit/services/bridge/webhook-server/kyc.spec.ts @@ -24,6 +24,12 @@ jest.mock("@services/bridge", () => ({ }, })) +// This handler delegates rewards; exercise that boundary without importing +// invite's real notification, queue and provider clients into this unit suite. +jest.mock("@app/invite", () => ({ + awardReferralRewardOnKycApproval: jest.fn().mockResolvedValue(undefined), +})) + jest.mock("@app/bridge/send-kyc-notification", () => ({ isBridgeKycInitiated: (status: Account["bridgeKycStatus"]) => status !== undefined && status !== null, @@ -52,6 +58,7 @@ import { AccountsRepository } from "@services/mongoose/accounts" import { LockService } from "@services/lock" import BridgeService from "@services/bridge" import { sendBridgeKycNotificationBestEffort } from "@app/bridge/send-kyc-notification" +import { awardReferralRewardOnKycApproval } from "@app/invite" const makeRes = () => { const res = { status: jest.fn(), json: jest.fn() } as unknown as Response @@ -97,6 +104,8 @@ describe("kycHandler", () => { await kycHandler(req, res) expect(res.status).toHaveBeenCalledWith(200) + expect(awardReferralRewardOnKycApproval).toHaveBeenCalledTimes(1) + expect(awardReferralRewardOnKycApproval).toHaveBeenCalledWith({ accountId }) expect(sendBridgeKycNotificationBestEffort).toHaveBeenCalledWith({ accountId, outcome: "approved", @@ -248,6 +257,7 @@ describe("kycHandler", () => { expect(sendBridgeKycNotificationBestEffort).not.toHaveBeenCalled() expect(BridgeService.createVirtualAccount).not.toHaveBeenCalled() + expect(awardReferralRewardOnKycApproval).not.toHaveBeenCalled() expect(res.json).toHaveBeenCalledWith({ status: "already_current" }) }) @@ -270,5 +280,6 @@ describe("kycHandler", () => { expect(res.json).toHaveBeenCalledWith({ status: "already_processed" }) expect(findByBridgeCustomerId).not.toHaveBeenCalled() expect(sendBridgeKycNotificationBestEffort).not.toHaveBeenCalled() + expect(awardReferralRewardOnKycApproval).not.toHaveBeenCalled() }) }) diff --git a/test/flash/unit/services/notification/wa-bridge.spec.ts b/test/flash/unit/services/notification/wa-bridge.spec.ts index 5cb0124e7..ad3af4499 100644 --- a/test/flash/unit/services/notification/wa-bridge.spec.ts +++ b/test/flash/unit/services/notification/wa-bridge.spec.ts @@ -1,3 +1,12 @@ +// Keep the documented unconfigured-provider precondition independent of the +// shell and the backend's required bootstrap Twilio fixture variables. +jest.mock("@config", () => ({ + env: {}, + SendGridConfig: {}, + TWILIO_FROM: undefined, + TWILIO_WHATSAPP_FROM: undefined, +})) + import { notificationService, NotificationMethod, From 07f41313aebf46d7a005213a7148220aa80cd33a Mon Sep 17 00:00:00 2001 From: Dread Date: Thu, 22 Oct 2026 23:34:12 -0700 Subject: [PATCH 05/42] Prevent automatic trace argument and GraphQL source capture --- docs/telemetry-input-privacy.md | 28 ++ src/services/tracing.ts | 31 +- .../services/tracing-input-privacy.spec.ts | 332 ++++++++++++++++++ 3 files changed, 372 insertions(+), 19 deletions(-) create mode 100644 docs/telemetry-input-privacy.md create mode 100644 test/flash/unit/services/tracing-input-privacy.spec.ts diff --git a/docs/telemetry-input-privacy.md b/docs/telemetry-input-privacy.md new file mode 100644 index 000000000..792fd1a71 --- /dev/null +++ b/docs/telemetry-input-privacy.md @@ -0,0 +1,28 @@ +# Automatic trace input capture + +This isolated patch is based on backend `7402dd7fcf49e378e4af5189232dffb76a4564d5`. It does not change authentication, provider requests, payment execution, GraphQL results, or deployment configuration. It has not been deployed. Tests use fake spans and an in-process exporter stand-in; no span or credential is sent to a collector. + +The generic synchronous and asynchronous wrappers previously copied the first argument into `code.function.params.*`. Object inputs exposed fields such as device passwords, OTPs, bank details and Bridge identity information. A positional string, including a session bearer, was enumerated as character indices. Null/undefined marker attributes did not make the values safe. Both wrappers now record only function name, namespace, root selection and explicitly supplied `spanAttributes`. They do not inspect, enumerate or read argument properties for tracing. Calls, arguments, results, returned/partial errors and thrown error identity retain their existing behavior. There is no argument-capture option or per-method opt-in. + +Repository consumers of `code.function.params.*` are collector deletion rules in `dev/otel-agent-config.yaml` and its quickstart copies. No application behavior depends on those values. Those defensive collector rules can remain; they are not a substitute for preventing collection in the application. + +The installed, lock-resolved `@opentelemetry/instrumentation-graphql` version is **0.35.2**. Its `build/src/instrumentation.js` adds input variables only when `allowValues` is true. Its `build/src/utils.js` masks string, block-string, integer and floating-point tokens when false, but retains comment tokens and GraphQL name/enum tokens in `graphql.source`. Merely setting `allowValues: false` therefore does not make query-body export safe. This patch sets it false and removes `graphql.source` in `SpanProcessorWrapper.onEnd`, immediately before calling the exporting parent processor. The same boundary covers parse, execute and resolver spans. Operation/type/timing metadata is retained; raw or partially masked query documents are not exported by this processor. + +Tests invoke the exact installed GraphQL plugin's execute-span path with nested variables, string/block-string/numeric literals, comments and enum literals, then exercise the export boundary. They prove that variables are omitted, scalar values are masked by the configured plugin, and source text (including the comment/enum tokens it leaves behind) is absent from the exported attributes. Wrapper tests cover realistic positional and object credential inputs, property access/enumeration traps, grouped wrappers, explicit safe metadata, call results and error behavior. + +## Remaining telemetry sinks + +This is a correction to automatic argument and GraphQL source capture, **not a claim that all telemetry is sanitized**. A separate audit must consider: + +- Explicit `spanAttributes`, `addAttributesToCurrentSpan`, events and direct span setters remain caller-controlled. Existing call sites deliberately include account/user identifiers and operational details. Sensitive values supplied explicitly are not scrubbed by this patch. +- `addAttributesToCurrentSpanAndPropagate` accepts caller-provided entries, and `SpanProcessorWrapper.onStart` copies all active baggage values into span attributes. The configured global propagator is W3C trace context, rather than a baggage propagator, but application-created baggage is still a sink. No new inbound baggage extraction is enabled here. +- Error handling still records exception messages/stacks. `recordGqlErrors` serializes GraphQL errors and their original messages; the plugin records parser/validation errors. Such errors can quote invalid inputs, unexpected names or backend details. Removing `graphql.source` does not remove text embedded in an error. +- GraphQL operation names, aliases, field paths and schema metadata remain trace metadata. Client-selected names can themselves contain sensitive text. This patch does not implement a registered-operation allowlist or an operation-name policy. +- HTTP instrumentation captures URL/target data, including query strings in the installed adapter. The configured incoming header allowlist includes client name/version, IP and user agent; it does not include Authorization, cookies or Appcheck. Credentials or signed capabilities placed in URLs remain a separate risk. +- The installed MongoDB default serializer replaces top-level command values with `?` while enhanced reporting is off. It still emits database/collection/operation metadata and command keys. No enhanced query-value reporting is enabled by this patch. +- The installed Redis serializer records command-dependent arguments. For example, its default implementation retains all arguments for EVAL and many lookup commands, and retains keys for SET. Keys, scripts or arguments can contain identifiers, credentials or personal data. This patch does not alter that serializer. +- gRPC/network instrumentation, custom exporters/processors, logging and external provider SDK diagnostics need their own review. This patch adds no logger, exporter or provider request. It cannot erase previously collected spans or change collector retention/access controls. + +The Bridge owned-KYC fix and initial device-account work should integrate this change before release. Historical collection requires a separate access/retention review; neither code changes nor mocked tests establish that old telemetry has been deleted. + +Validation: 23 focused tests pass across the new privacy suite and existing tracing suite. All three TypeScript configurations (`tsconfig.json`, `tsconfig.d.json`, `tsconfig.no-implicit-any.json`), touched-file ESLint, and `git diff --check` pass using Node 24 and the frozen-lock dependency installation. No `.env` file was loaded. diff --git a/src/services/tracing.ts b/src/services/tracing.ts index 588c4435c..b481b4b8c 100644 --- a/src/services/tracing.ts +++ b/src/services/tracing.ts @@ -212,7 +212,7 @@ registerInstrumentations({ }), new GraphQLInstrumentation({ mergeItems: true, - allowValues: true, + allowValues: false, responseHook: gqlResponseHook, }), new MongoDBInstrumentation(), @@ -244,6 +244,14 @@ class SpanProcessorWrapper extends SimpleSpanProcessor { } super.onStart(span, parentContext) } + + onEnd(span: SdkSpan) { + // instrumentation-graphql 0.35.2 masks strings/numbers with allowValues + // false, but keeps comments and enum/name tokens in graphql.source. + // Do not export partially redacted query bodies from any GraphQL span. + delete span.attributes["graphql.source"] + super.onEnd(span) + } } provider.addSpanProcessor(new SpanProcessorWrapper(new OTLPTraceExporter())) @@ -380,13 +388,11 @@ export const asyncRunInSpan = ReturnType>( const resolveFunctionSpanOptions = ({ namespace, functionName, - functionArgs, spanAttributes, root, }: { namespace: string functionName: string - functionArgs: Array spanAttributes?: Attributes root?: boolean }): SpanOptions => { @@ -395,20 +401,9 @@ const resolveFunctionSpanOptions = ({ [SemanticAttributes.CODE_NAMESPACE]: namespace, ...spanAttributes, } - if (functionArgs && functionArgs.length > 0) { - const params = - typeof functionArgs[0] === "object" ? functionArgs[0] : { "0": functionArgs[0] } - for (const key in params) { - // @ts-ignore no-implicit-any error - const value = params[key] - attributes[`${SemanticAttributes.CODE_FUNCTION}.params.${key}`] = - value as AttributeValue - attributes[`${SemanticAttributes.CODE_FUNCTION}.params.${key}.null`] = - value === null - attributes[`${SemanticAttributes.CODE_FUNCTION}.params.${key}.undefined`] = - value === undefined - } - } + // Arguments can be long-lived passwords, bearer tokens, OTPs or banking PII. + // Never inspect or enumerate them for telemetry; call sites must supply + // deliberate, nonsecret spanAttributes independently of invocation inputs. return { attributes, root } } @@ -435,7 +430,6 @@ export const wrapToRunInSpan = < const spanOptions = resolveFunctionSpanOptions({ namespace, functionName, - functionArgs: args, spanAttributes, root, }) @@ -492,7 +486,6 @@ export const wrapAsyncToRunInSpan = < const spanOptions = resolveFunctionSpanOptions({ namespace, functionName, - functionArgs: args, spanAttributes, root, }) diff --git a/test/flash/unit/services/tracing-input-privacy.spec.ts b/test/flash/unit/services/tracing-input-privacy.spec.ts new file mode 100644 index 000000000..36042c14b --- /dev/null +++ b/test/flash/unit/services/tracing-input-privacy.spec.ts @@ -0,0 +1,332 @@ +// No provider registration, exporter, collector, or external request is used. +jest.mock("@opentelemetry/sdk-trace-node", () => { + const processors: unknown[] = [] + return { + processors, + NodeTracerProvider: class { + addSpanProcessor(processor: unknown) { + processors.push(processor) + } + register() { + return undefined + } + }, + } +}) +jest.mock("@opentelemetry/sdk-trace-base", () => { + const exported: unknown[] = [] + return { + exported, + SimpleSpanProcessor: class { + onStart() { + return undefined + } + onEnd(span: { attributes: Record }) { + exported.push({ ...span.attributes }) + } + }, + } +}) +jest.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ + OTLPTraceExporter: class {}, +})) +jest.mock("@opentelemetry/instrumentation", () => ({ + ...jest.requireActual("@opentelemetry/instrumentation"), + registerInstrumentations: jest.fn(({ instrumentations }) => { + for (const instrumentation of instrumentations) instrumentation.disable() + }), +})) +jest.mock("@opentelemetry/api", () => { + const actual = jest.requireActual("@opentelemetry/api") + return { + ...actual, + trace: Object.assign(Object.create(actual.trace), { + getTracer: () => ({ + startActiveSpan: (...args: unknown[]) => mockStartActiveSpan(...args), + }), + }), + } +}) + +import { GraphQLInstrumentation } from "@opentelemetry/instrumentation-graphql" +import { registerInstrumentations } from "@opentelemetry/instrumentation" +import { parse } from "graphql" +import { TracerProvider } from "@opentelemetry/api" +import { + wrapToRunInSpan, + wrapAsyncToRunInSpan, + wrapAsyncFunctionsToRunInSpan, +} from "@services/tracing" + +const mockStartActiveSpan = jest.fn() +let span: ReturnType +function fakeSpan() { + const result = { + attributes: {} as Record, + events: [] as unknown[], + setAttribute: jest.fn((key: string, value: unknown) => { + result.attributes[key] = value + return result + }), + recordException: jest.fn((error: unknown) => { + result.events.push(error) + }), + setStatus: jest.fn(), + end: jest.fn(), + updateName: jest.fn(), + } + return result +} + +beforeEach(() => { + span = fakeSpan() + mockStartActiveSpan.mockReset().mockImplementation((_name, options, callback) => { + span.attributes = { ...options.attributes } + return callback(span) + }) +}) + +// Public deterministic fixtures, never real account credentials. +const secrets = { + password: "0c305189-aa6d-487d-8ec4-66f779871e49", + code: "418735", + bearer: "Bearer PRIVATE_SESSION_TOKEN_f72cc9", + basic: "Basic Zml4dHVyZS11c2VyOmZpeHR1cmUtcGFzc3dvcmQ=", + account: "PRIVATE_BANK_ACCOUNT_00123456789", + name: "PRIVATE_PERSONAL_NAME_Ada", + email: "PRIVATE_EMAIL_alice@example.invalid", + address: "PRIVATE_POSTAL_ADDRESS_10_Main_Street", +} +const expectNoInvocationValues = () => { + const automatic = JSON.stringify(span.attributes) + for (const secret of Object.values(secrets)) expect(automatic).not.toContain(secret) + expect( + Object.keys(span.attributes).some((key) => key.startsWith("code.function.params.")), + ).toBe(false) +} + +it.each(["sync", "async"])( + "never inspects object inputs or records argument values in %s wrapper", + async (kind) => { + const input = { + ...secrets, + nested: { bank: secrets.account }, + absent: undefined, + empty: null, + } + const fn = jest.fn((value: unknown) => value) + const wrapped = + kind === "sync" + ? wrapToRunInSpan({ namespace: "privacy", fnName: "authenticate", fn }) + : wrapAsyncToRunInSpan({ + namespace: "privacy", + fnName: "authenticate", + fn: async (value: unknown) => fn(value), + }) + expect(await wrapped(input)).toBe(input) + expect(fn).toHaveBeenCalledWith(input) + expect(span.attributes).toEqual({ + "code.function": "authenticate", + "code.namespace": "privacy", + }) + expectNoInvocationValues() + expect(span.end).toHaveBeenCalledTimes(1) + }, +) + +it.each(Object.values(secrets))( + "never records positional credentials or other primitive inputs (fixture %#)", + async (secret) => { + const sync = wrapToRunInSpan({ namespace: "privacy", fn: (value: string) => value }) + expect(sync(secret)).toBe(secret) + expectNoInvocationValues() + const async = wrapAsyncToRunInSpan({ + namespace: "privacy", + fn: async (value: string) => value, + }) + expect(await async(secret)).toBe(secret) + expectNoInvocationValues() + }, +) + +it("does not enumerate inputs or run property getters for tracing", () => { + const get = jest.fn(() => { + throw new Error("Argument getter must not run") + }) + const ownKeys = jest.fn(() => { + throw new Error("Argument enumeration must not run") + }) + const input = new Proxy({}, { get, ownKeys }) + const wrapped = wrapToRunInSpan({ + namespace: "privacy", + fn: (value: unknown) => (value === input ? 42 : 0), + }) + expect(wrapped(input)).toBe(42) + expect(get).not.toHaveBeenCalled() + expect(ownKeys).not.toHaveBeenCalled() +}) + +it("preserves explicit span attributes, root selection, names, and every call argument", () => { + const fn = jest.fn((first: string, second: number) => ({ first, second })) + const wrapped = wrapToRunInSpan({ + namespace: "privacy", + fnName: "named", + root: true, + spanAttributes: { "operation.category": "account", "safe.count": 0 }, + fn, + }) + expect(wrapped(secrets.password, 7)).toEqual({ first: secrets.password, second: 7 }) + expect(wrapped.name).toBe("named") + expect(fn).toHaveBeenCalledWith(secrets.password, 7) + expect(mockStartActiveSpan.mock.calls[0].slice(0, 2)).toEqual([ + "privacy.named", + { + root: true, + attributes: { + "code.function": "named", + "code.namespace": "privacy", + "operation.category": "account", + "safe.count": 0, + }, + }, + ]) + expectNoInvocationValues() +}) + +it("dispatches both kinds of grouped wrappers without input capture", async () => { + const wrapped = wrapAsyncFunctionsToRunInSpan({ + namespace: "group", + fns: { + sync: (value: unknown) => value, + async: async (value: unknown) => value, + }, + }) + expect(wrapped.sync(secrets)).toBe(secrets) + expectNoInvocationValues() + expect(await wrapped.async(secrets)).toBe(secrets) + expectNoInvocationValues() +}) + +it.each(["sync", "async"])( + "preserves returned errors and partial errors in %s wrapper", + async (kind) => { + const error = new Error("Safe fixture error") + for (const result of [error, { partialResult: true, error }]) { + span = fakeSpan() + const fn = () => result + const wrapped = + kind === "sync" + ? wrapToRunInSpan({ namespace: "errors", fn }) + : wrapAsyncToRunInSpan({ namespace: "errors", fn: async () => fn() }) + expect(await wrapped()).toBe(result) + expect(span.recordException).toHaveBeenCalledWith(error) + expect(span.end).toHaveBeenCalledTimes(1) + } + }, +) + +it("preserves synchronous thrown error identity and ends the span", () => { + const error = new Error("Safe fixture error") + const wrapped = wrapToRunInSpan({ + namespace: "errors", + fn: () => { + throw error + }, + }) + let caught: unknown + try { + wrapped() + } catch (value) { + caught = value + } + expect(caught).toBe(error) + expect(span.recordException).toHaveBeenCalledWith(error) + expect(span.end).toHaveBeenCalledTimes(1) +}) + +it("preserves asynchronous thrown error identity and ends the span", async () => { + const error = new Error("Safe fixture error") + const wrapped = wrapAsyncToRunInSpan({ + namespace: "errors", + fn: async () => { + throw error + }, + }) + await expect(wrapped()).rejects.toBe(error) + expect(span.recordException).toHaveBeenCalledWith(error) + expect(span.end).toHaveBeenCalledTimes(1) +}) + +function configuredGraphQL() { + const options = jest.mocked(registerInstrumentations).mock.calls[0][0] + const instrumentation = options.instrumentations + ?.flat() + .find((item) => item instanceof GraphQLInstrumentation) + if (!(instrumentation instanceof GraphQLInstrumentation)) + throw new Error("GraphQL instrumentation not registered") + instrumentation.setTracerProvider({ + getTracer: () => ({ startSpan: () => span }), + } as unknown as TracerProvider) + return instrumentation +} + +it("disables real pinned GraphQL variable capture and masks string, block-string and numeric literals", () => { + const instrumentation = configuredGraphQL() + expect(Reflect.get(instrumentation.getConfig(), "allowValues")).toBe(false) + const document = parse( + `mutation Privacy($input: String) { verify(password: "${secrets.password}", code: "123456", bank: """${secrets.account}""", cents: 123456789, fraction: 12.3456789) }`, + ) + const operation = document.definitions[0] + // Exercise the exact installed plugin's span path, not an imitation masker. + Reflect.get(instrumentation, "_createExecuteSpan").call(instrumentation, operation, { + document, + variableValues: { input: secrets, nested: [{ token: secrets.bearer }] }, + }) + expectNoInvocationValues() + expect(span.attributes["graphql.source"]).not.toContain("123456") + expect(span.attributes["graphql.source"]).not.toContain("12.3456789") + expect( + Object.keys(span.attributes).some((key) => key.startsWith("graphql.variables.")), + ).toBe(false) + expect(span.attributes["graphql.operation.name"]).toBe("Privacy") +}) + +it("drops source at the export boundary, including comment and enum literals not masked by the pinned plugin", () => { + const instrumentation = configuredGraphQL() + const document = parse( + "query Privacy { verify(kind: PRIVATE_ENUM_SECRET) # PRIVATE_COMMENT_SECRET\n }", + ) + Reflect.get(instrumentation, "_createExecuteSpan").call( + instrumentation, + document.definitions[0], + { document }, + ) + expect(span.attributes["graphql.source"]).toContain("PRIVATE_COMMENT_SECRET") + expect(span.attributes["graphql.source"]).toContain("PRIVATE_ENUM_SECRET") + const processor = jest.requireMock("@opentelemetry/sdk-trace-node").processors[0] + processor.onEnd(span) + const exported = jest.requireMock("@opentelemetry/sdk-trace-base").exported.at(-1) + expect(exported["graphql.source"]).toBeUndefined() + expect(JSON.stringify(exported)).not.toContain("PRIVATE_") + expect(exported["graphql.operation.name"]).toBe("Privacy") + expect(exported["graphql.operation.type"]).toBe("query") +}) + +it("also drops GraphQL parser source before export rather than protecting only execute spans", () => { + const instrumentation = configuredGraphQL() + const document = Reflect.get(instrumentation, "_parse").call( + instrumentation, + undefined, + parse, + "query Parsed { verify(kind: PRIVATE_ENUM_SECRET) # PRIVATE_COMMENT_SECRET\n }", + undefined, + ) + expect(document.kind).toBe("Document") + expect(span.attributes["graphql.source"]).toContain("PRIVATE_COMMENT_SECRET") + expect(span.end).toHaveBeenCalledTimes(1) + const processor = jest.requireMock("@opentelemetry/sdk-trace-node").processors[0] + processor.onEnd(span) + const exported = jest.requireMock("@opentelemetry/sdk-trace-base").exported.at(-1) + expect(exported["graphql.source"]).toBeUndefined() + expect(JSON.stringify(exported)).not.toContain("PRIVATE_") +}) From 86daba86a824e4ecbb932e64d36d31b1114d6493 Mon Sep 17 00:00:00 2001 From: Dread Date: Thu, 22 Oct 2026 23:44:47 -0700 Subject: [PATCH 06/42] Stop tracing IBEX request payloads and webhook credentials --- docs/ibex-request-trace-privacy.md | 13 ++ src/services/ibex/client.ts | 15 +- .../ibex/request-trace-privacy.spec.ts | 217 ++++++++++++++++++ 3 files changed, 231 insertions(+), 14 deletions(-) create mode 100644 docs/ibex-request-trace-privacy.md create mode 100644 test/flash/unit/services/ibex/request-trace-privacy.spec.ts diff --git a/docs/ibex-request-trace-privacy.md b/docs/ibex-request-trace-privacy.md new file mode 100644 index 000000000..d35c2d835 --- /dev/null +++ b/docs/ibex-request-trace-privacy.md @@ -0,0 +1,13 @@ +# IBEX request trace privacy + +This patch removes every `request.params` payload capture from `src/services/ibex/client.ts`. It is based on, and must be released with, telemetry commit `4921212ac`, which removes generic wrapper argument capture and GraphQL source export. Neither patch has been deployed by this task. + +The receive-invoice, Lightning payment, onchain payment and static LNURL helpers previously serialized their completed provider request into a span attribute after adding `webhookSecret`. Ordinary calls therefore copied the configured webhook authentication secret into telemetry alongside account identifiers, invoices, memos and destinations. Removing generic wrapper argument capture did not prevent those explicit writes. Transaction history, crypto send, crypto requirement and crypto destination helpers also serialized their full input bodies or parameters. + +All eight captures are removed. The existing fixed wrapper namespace and function name identify the operation; no input-derived replacement metadata is added. Required provider webhook authentication, URLs, amounts, expiry limits, request fields, response handling and payment execution remain unchanged. In particular, the webhook secret is still sent to the intended provider. Removing it from the provider body would break authentication rather than fix telemetry. + +The focused mocked tests supply public fixture credentials and capture each of the eight SDK/HTTP calls and explicit telemetry entry points. They assert exact required provider bodies, webhook URLs/secrets, preserved response identity/data, and zero payload writes to tracing. Central wrapper privacy is verified by the separate tracing suites. The provider SDK, cache, HTTP and telemetry are mocked; no private environment, real provider call or exported span is used. + +This patch does not sanitize error messages, provider error bodies, existing logs, HTTP instrumentation, Redis statements or other explicit trace attributes. `ibexFetch` and token-fetch error paths still need a separate review of provider-body logging and exception content. It does not establish that historical telemetry was scrubbed, revoke any previously exposed webhook credential or change collector configuration. Any historical credential-access/rotation work belongs to a separate coordinated operational action; no key was read or changed here. + +Validation: 31 tests pass across the eight new adapter cases and both central tracing suites. All three TypeScript configurations, scoped ESLint and `git diff --check` pass with Node 24 and frozen-lock dependencies. diff --git a/src/services/ibex/client.ts b/src/services/ibex/client.ts index dcd931cb9..4efd2c3da 100644 --- a/src/services/ibex/client.ts +++ b/src/services/ibex/client.ts @@ -23,10 +23,7 @@ import IbexClient, { } from "ibex-client" import { IbexConfig } from "@config" -import { - addAttributesToCurrentSpan, - wrapAsyncFunctionsToRunInSpan, -} from "@services/tracing" +import { wrapAsyncFunctionsToRunInSpan } from "@services/tracing" import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" @@ -159,7 +156,6 @@ const getRawAccountDetails = async ( const getAccountTransactions = async ( params: GMetadataParam, ): Promise => { - addAttributesToCurrentSpan({ "request.params": JSON.stringify(params) }) return Ibex.getAccountTransactions(params).then(errorHandler) } @@ -175,7 +171,6 @@ const addInvoice = async ( webhookUrl: ibexWebhookEndpoints.onReceive.invoice, webhookSecret: ibexWebhookSecret, } as AddInvoiceBodyParam - addAttributesToCurrentSpan({ "request.params": JSON.stringify(body) }) return Ibex.addInvoice(body).then(errorHandler) } @@ -240,7 +235,6 @@ const payInvoice = async ( webhookUrl: ibexWebhookEndpoints.onPay.invoice, webhookSecret: ibexWebhookSecret, } as PayInvoiceV2BodyParam - addAttributesToCurrentSpan({ "request.params": JSON.stringify(bodyWithHooks) }) // Standard SDK path. A raw-fetch seam lived here while ibex-client 3.2.0's // ApiError discarded the response body (lnflash/ibex-client#6); 3.3.0's // ApiError carries the body itself (`ibexMessage`/`ibexResponse`/`httpCode`) @@ -248,8 +242,6 @@ const payInvoice = async ( return Ibex.payInvoiceV2(bodyWithHooks).then(errorHandler) } -// onchain transactions are typically high-value -// logging all Ibex responses until we have higher confidence & higher volume const sendOnchain = async ( body: SendToAddressCopyBodyParam, ): Promise => { @@ -258,14 +250,12 @@ const sendOnchain = async ( webhookUrl: ibexWebhookEndpoints.onPay.onchain, webhookSecret: ibexWebhookSecret, } as SendToAddressCopyBodyParam - addAttributesToCurrentSpan({ "request.params": JSON.stringify(bodyWithHooks) }) return Ibex.sendToAddressV2(bodyWithHooks).then(errorHandler) } const sendCrypto = async ( body: CryptoSendBodyParam, ): Promise => { - addAttributesToCurrentSpan({ "request.params": JSON.stringify(body) }) const token = await getIbexToken() if (token instanceof IbexError) return token @@ -281,7 +271,6 @@ const getCryptoSendRequirements = async (args: { network: string currencyId: IbexCurrencyId }): Promise => { - addAttributesToCurrentSpan({ "request.params": JSON.stringify(args) }) const token = await getIbexToken() if (token instanceof IbexError) return token @@ -298,7 +287,6 @@ const getCryptoSendRequirements = async (args: { const createCryptoSendInfo = async ( body: CreateCryptoSendInfoBodyParam, ): Promise => { - addAttributesToCurrentSpan({ "request.params": JSON.stringify(body) }) const token = await getIbexToken() if (token instanceof IbexError) return token @@ -328,7 +316,6 @@ const createLnurlPay = async ( webhookUrl: ibexWebhookEndpoints.onReceive.lnurl, webhookSecret: ibexWebhookSecret, } as CreateLnurlPayBodyParam - addAttributesToCurrentSpan({ "request.params": JSON.stringify(bodyWithHooks) }) return Ibex.createLnurlPay(bodyWithHooks).then(errorHandler) } diff --git a/test/flash/unit/services/ibex/request-trace-privacy.spec.ts b/test/flash/unit/services/ibex/request-trace-privacy.spec.ts new file mode 100644 index 000000000..a3896676f --- /dev/null +++ b/test/flash/unit/services/ibex/request-trace-privacy.spec.ts @@ -0,0 +1,217 @@ +// All credentials and values are deterministic public fixtures. No SDK call, +// network request, span export, cache, or private environment is used. +jest.mock("@config", () => ({ + IbexConfig: { + environment: "sandbox", + mock: false, + clientId: "fixture-client", + clientSecret: "fixture-client-secret", + webhook: { uri: "https://fixture.invalid/ibex", secret: "fixture-webhook-secret" }, + }, +})) +jest.mock("@services/ibex/cache", () => ({ Redis: {} })) +jest.mock("@services/logger", () => ({ baseLogger: { error: jest.fn() } })) +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), + // The real central wrapper's input privacy is verified separately by the + // tracing-input-privacy suite. Here capture this adapter's explicit writes. + wrapAsyncFunctionsToRunInSpan: jest.fn(({ fns }) => fns), +})) +jest.mock("ibex-client", () => { + const fixture = { + authentication: { storage: { getAccessToken: jest.fn() } }, + getAccountTransactions: jest.fn(), + addInvoice: jest.fn(), + payInvoiceV2: jest.fn(), + sendToAddressV2: jest.fn(), + createLnurlPay: jest.fn(), + } + return { + ...jest.requireActual("ibex-client"), + __esModule: true, + fixture, + default: jest.fn(() => fixture), + IbexUrls: { + sandbox: { + authDomain: "https://fixture-auth.invalid", + audience: "fixture-audience", + hubUrl: "https://fixture-api.invalid", + }, + }, + } +}) + +import Ibex from "@services/ibex/client" +import { USDAmount, USDTAmount } from "@domain/shared" +import { + addAttributesToCurrentSpan, + recordExceptionInCurrentSpan, +} from "@services/tracing" + +const sdk = jest.requireMock("ibex-client").fixture +const providerResult = { fixtureResult: true } +let fetch: jest.SpyInstance + +beforeEach(() => { + jest.clearAllMocks() + for (const name of [ + "getAccountTransactions", + "addInvoice", + "payInvoiceV2", + "sendToAddressV2", + "createLnurlPay", + ]) { + sdk[name].mockResolvedValue(providerResult) + } + sdk.authentication.storage.getAccessToken.mockResolvedValue( + "fixture-provider-access-token", + ) + fetch = jest + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(JSON.stringify(providerResult), { status: 200 })) +}) +afterEach(() => fetch.mockRestore()) + +const expectNoInputTelemetry = () => { + expect(addAttributesToCurrentSpan).not.toHaveBeenCalled() + expect(recordExceptionInCurrentSpan).not.toHaveBeenCalled() +} + +it("sends the exact receive body and authentication secret to IBEX without tracing its account, memo or secret", async () => { + const amount = USDAmount.cents(125n) + expect(amount).not.toBeInstanceOf(Error) + const result = await Ibex.addInvoice({ + accountId: "fixture-account" as IbexAccountId, + amount: amount as USDAmount, + memo: "Private name and purchase memo", + expiration: 300 as Seconds, + }) + expect(result).toBe(providerResult) + expect(sdk.addInvoice).toHaveBeenCalledWith({ + accountId: "fixture-account", + amount: 1.25, + memo: "Private name and purchase memo", + expiration: 60, + webhookUrl: "https://fixture.invalid/ibex/receive/invoice", + webhookSecret: "fixture-webhook-secret", + }) + expectNoInputTelemetry() +}) + +it("keeps the Lightning invoice, account, amount and required payment webhook secret only in the provider call", async () => { + const amount = USDTAmount.smallestUnits(1250000n) + expect(amount).not.toBeInstanceOf(Error) + const result = await Ibex.payInvoice({ + accountId: "fixture-account" as IbexAccountId, + invoice: "fixture-private-bolt11" as Bolt11, + send: amount as USDTAmount, + }) + expect(result).toBe(providerResult) + expect(sdk.payInvoiceV2).toHaveBeenCalledWith({ + accountId: "fixture-account", + bolt11: "fixture-private-bolt11", + amount: 1.25, + webhookUrl: "https://fixture.invalid/ibex/pay/invoice", + webhookSecret: "fixture-webhook-secret", + }) + expectNoInputTelemetry() +}) + +it("keeps the onchain destination and secret in the unchanged provider body rather than a request.params span", async () => { + const input = { + accountId: "fixture-account", + address: "fixture-private-destination", + amount: 125, + } + const original = { ...input } + expect(await Ibex.sendOnchain(input)).toBe(providerResult) + expect(input).toEqual(original) + expect(sdk.sendToAddressV2).toHaveBeenCalledWith({ + ...input, + webhookUrl: "https://fixture.invalid/ibex/pay/onchain", + webhookSecret: "fixture-webhook-secret", + }) + expectNoInputTelemetry() +}) + +it("preserves the static LNURL provider request and required secret without tracing its contents", async () => { + const input = { accountId: "fixture-account", currencyId: 29, amount: 1.25 } + expect(await Ibex.createLnurlPay(input)).toBe(providerResult) + expect(sdk.createLnurlPay).toHaveBeenCalledWith({ + ...input, + webhookUrl: "https://fixture.invalid/ibex/receive/lnurlp", + webhookSecret: "fixture-webhook-secret", + }) + expectNoInputTelemetry() +}) + +it("preserves transaction history query parameters without tracing account identifiers", async () => { + const input = { account_id: "fixture-private-account", limit: 10, page: 2 } + expect(await Ibex.getAccountTransactions(input)).toBe(providerResult) + expect(sdk.getAccountTransactions).toHaveBeenCalledWith(input) + expectNoInputTelemetry() +}) + +it("preserves the crypto-send authenticated request without tracing account or destination handles", async () => { + const input = { + accountId: "fixture-private-account", + cryptoSendInfosId: "fixture-private-destination-handle", + amount: 1.25, + } + expect(await Ibex.sendCrypto(input)).toEqual(providerResult) + expect(fetch).toHaveBeenCalledWith( + "https://fixture-api.invalid/accounts/fixture-private-account/crypto/send", + { + method: "POST", + headers: { + "Authorization": "fixture-provider-access-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ cryptoSendInfosId: input.cryptoSendInfosId, amount: 1.25 }), + }, + ) + expectNoInputTelemetry() +}) + +it("preserves crypto requirement query units without serializing a trace payload", async () => { + expect( + await Ibex.getCryptoSendRequirements({ + network: "ethereum", + currencyId: USDTAmount.currencyId, + }), + ).toEqual(providerResult) + expect(fetch).toHaveBeenCalledWith( + "https://fixture-api.invalid/crypto/send/requirements?network=ethereum¤cy-id=29", + { + method: "GET", + headers: { + "Authorization": "fixture-provider-access-token", + "Content-Type": "application/json", + }, + }, + ) + expectNoInputTelemetry() +}) + +it("preserves crypto destination name/address metadata for IBEX without recording banking/identity details in spans", async () => { + const input = { + name: "Private person withdrawal", + requirementsId: "fixture-requirement", + data: { + address: "fixture-private-wallet-address", + fullName: "Private Name", + accountNumber: "fixture-private-bank-account", + }, + } + expect(await Ibex.createCryptoSendInfo(input)).toEqual(providerResult) + expect(fetch).toHaveBeenCalledWith("https://fixture-api.invalid/crypto/send/infos", { + method: "POST", + headers: { + "Authorization": "fixture-provider-access-token", + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }) + expectNoInputTelemetry() +}) From 24132c618dca34d9f82b2c6dbcb8b1b2d618c311 Mon Sep 17 00:00:00 2001 From: Dread Date: Thu, 22 Oct 2026 23:54:23 -0700 Subject: [PATCH 07/42] Limit Redis and identity banking failure telemetry to safe metadata --- docs/redis-erp-telemetry-privacy.md | 15 + src/services/frappe/ErpNext.ts | 296 ++++-------------- src/services/frappe/telemetry.ts | 73 +++++ src/services/tracing.ts | 6 +- .../frappe/ErpNext.bankAccounts.spec.ts | 12 +- .../frappe/ErpNext.idVerification.spec.ts | 16 +- .../frappe/request-telemetry-privacy.spec.ts | 281 +++++++++++++++++ .../services/tracing-input-privacy.spec.ts | 60 +++- 8 files changed, 516 insertions(+), 243 deletions(-) create mode 100644 docs/redis-erp-telemetry-privacy.md create mode 100644 src/services/frappe/telemetry.ts create mode 100644 test/flash/unit/services/frappe/request-telemetry-privacy.spec.ts diff --git a/docs/redis-erp-telemetry-privacy.md b/docs/redis-erp-telemetry-privacy.md new file mode 100644 index 000000000..1bf32a3f5 --- /dev/null +++ b/docs/redis-erp-telemetry-privacy.md @@ -0,0 +1,15 @@ +# Redis and identity/bank failure telemetry + +This follow-up is based on `b76ed0ec1` and requires the central telemetry patch `4921212ac`. No production configuration, credentials, Redis connection, provider call or span export was accessed during implementation. + +The installed IORedis plugin defaults to recording command-dependent arguments in `db.statement`. The application's Plaid binding store uses `plaid:link:${linkToken}` as a real Redis key, so SET/GET/DEL spans could expose a live bank-link capability despite the generic wrapper fix. IORedis instrumentation now uses a serializer that returns only the command name. It does not change Redis commands, keys, values, Lua, authentication, response callbacks or the one-use Plaid store. Tests execute the exact installed plugin's command wrapper with SET/GET/DEL/EVAL/AUTH fixtures, supplying a fake parent span because the production plugin requires one. Original command arguments, return values and completion callbacks remain intact while their contents are absent from attributes. Existing Plaid ownership/atomic-consumption/concurrency tests continue to pass. + +ERP identity and bank failures previously passed raw Axios errors into the logger. Axios retains request configuration, including the ERP API Authorization header and submitted identity/bank body; pino's error serializer copies those enumerable properties. The current request-header redaction paths do not cover `err.config`. Some bank-request failures additionally logged `req.toErpnext()` directly. The span recorder also copied provider exceptions/messages, and returned domain-error messages could retain provider text for later tracing. + +Upgrade requests, identity verification/status, bank reads/writes/change requests and their shared status updates now use one safe failure recorder. It accepts only closed operation names, an allowlist of known transport codes, and an integer HTTP status in 100–599. Logs and spans receive a fixed failure message and that bounded metadata; no raw error object/message/stack, request context, provider response, bank-number suffix or provider exception is retained. The identity-create warning level remains a warning. Generic returned errors retain their domain classes with fixed text so later wrappers cannot collect the original payload through the error message. + +Bank refusal classification still examines the provider response locally and preserves the existing duplicate/not-owned/upgrade-required classes and allowlisted validation messages. It discards provider prose after classification. Unsuccessful 2xx bank replies and bulk-update failed-document payloads likewise produce the same generic error classes without retaining provider text. Provider HTTP calls, authentication headers, request bodies and successful results are unchanged; a missing decision-reason record still returns null on HTTP 404. + +Validation: 213 tests pass across seven focused/existing suites, including all 21 affected ERP request/status entry points, original bank refusal mappings, ID/status behavior, and Plaid ownership/single-use behavior. Fake credential/body/message/stack/response fixtures are checked against serialized log, span and returned-error output; provider body/header assertions verify the required private data still reaches the mocked intended provider. Three TypeScript configurations, scoped ESLint and `git diff --check` pass using Node 24 and frozen-lock dependencies; no private `.env` file is loaded. + +This is not a global telemetry guarantee. Other ERP cashout/settings/referral/Bridge-ledger paths still log raw Axios errors/provider responses in the baseline and require the same separate audit before claiming all ERP credential failures are safe. GraphQL coercion/parser/validation errors, other explicit trace attributes, generic exception handling, inbound/outbound HTTP URLs, direct logger calls and historical collector data remain separate sinks. Mongo instrumentation currently has enhanced value reporting disabled; this patch does not change its serializer or claim all database metadata is anonymous. Dev collector redactions are defense in depth only and do not establish the production collector policy or historical deletion/credential rotation. diff --git a/src/services/frappe/ErpNext.ts b/src/services/frappe/ErpNext.ts index 2862b6bb1..049a3c2b0 100644 --- a/src/services/frappe/ErpNext.ts +++ b/src/services/frappe/ErpNext.ts @@ -62,6 +62,7 @@ import { type FygaroTopupWindow, } from "./models/BridgeTransferRequest" import { Filter } from "./SearchFilters" +import { recordErpRequestFailure } from "./telemetry" export type AccountUpgradeRequestFilters = { username?: Filter; status?: Filter } type ErpNextFilter = [string, string, string, string[]] @@ -117,8 +118,6 @@ export type BankAccountWriteError = | BankAccountValidationError | BankAccountUpgradeRequiredError -const last4 = (accountNumber: string): string => accountNumber.slice(-4) - // frappe.throw() reports its text in `_server_messages`: a JSON array of JSON // strings, each `{ message, ... }`. Falls back to the `exception` line // ("frappe.exceptions.ValidationError: "). @@ -406,23 +405,8 @@ export class ErpNext { ) return { name: resp.data.data.name } } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - // Only identifiers — the request body carries the applicant's name, - // phone, email and address, which must not land in logs. - baseLogger.error( - { - err, - responseData, - username: req.username, - requestedLevel: req.requestedLevel, - }, - "Error creating Account Upgrade Request in ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new UpgradeRequestCreateError(err) + recordErpRequestFailure(err, "postUpgradeRequest") + return new UpgradeRequestCreateError("ERPNext request failed") } } @@ -443,16 +427,8 @@ export class ErpNext { return resp.data?.data.map((r: { name: string }) => r.name) } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, filters }, - "Error querying Account Upgrade Request from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new UpgradeRequestQueryError(err) + recordErpRequestFailure(err, "getAccountUpgradeRequestList") + return new UpgradeRequestQueryError("ERPNext request failed") } } @@ -469,16 +445,8 @@ export class ErpNext { if (!request) return new UpgradeRequestQueryError("No data in detail response") return AccountUpgradeRequest.fromErpnext(request) } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, id }, - "Error querying Account Upgrade Request from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new UpgradeRequestQueryError(err) + recordErpRequestFailure(err, "getAccountUpgradeRequestById") + return new UpgradeRequestQueryError("ERPNext request failed") } } @@ -501,16 +469,8 @@ export class ErpNext { decidedAt: fromFrappeDatetime(request.reviewed_at || request.modified), } } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, id }, - "Error querying Account Upgrade Request decision from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new UpgradeRequestQueryError(err) + recordErpRequestFailure(err, "getUpgradeRequestDecision") + return new UpgradeRequestQueryError("ERPNext request failed") } } @@ -534,24 +494,8 @@ export class ErpNext { if (!name) return new IdVerificationCreateError("No name in create response") return { name } } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - const status = isAxiosError(err) ? err.response?.status : undefined - baseLogger.warn( - { - err, - status, - responseData, - upgradeRequest: doc.upgradeRequest, - identitySource: doc.identitySource, - evidenceCount: doc.evidence.length, - }, - "Error creating ID Verification in ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new IdVerificationCreateError(err) + recordErpRequestFailure(err, "postIdVerification", "warn") + return new IdVerificationCreateError("ERPNext request failed") } } @@ -575,16 +519,8 @@ export class ErpNext { const rows: { name: string }[] = resp.data?.data ?? [] return rows.map((r) => r.name) } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, limitStart, pageLength }, - "Error listing ID Verification from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new IdVerificationQueryError(err) + recordErpRequestFailure(err, "getIdVerificationList") + return new IdVerificationQueryError("ERPNext request failed") } } @@ -600,16 +536,8 @@ export class ErpNext { if (!doc) return new IdVerificationQueryError("No data in detail response") return IdVerification.fromErpnext(doc) } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, id }, - "Error querying ID Verification from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new IdVerificationQueryError(err) + recordErpRequestFailure(err, "getIdVerificationById") + return new IdVerificationQueryError("ERPNext request failed") } } @@ -628,16 +556,8 @@ export class ErpNext { { headers: this.headers }, ) } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, id, rows: evidence.length }, - "Error updating ID Verification evidence in ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new IdVerificationUpdateError(err) + recordErpRequestFailure(err, "updateIdVerificationEvidence") + return new IdVerificationUpdateError("ERPNext request failed") } } @@ -668,16 +588,8 @@ export class ErpNext { reviewed_at: row.reviewed_at || undefined, } } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, upgradeRequest: upgradeRequestName }, - "Error querying ID Verification by upgrade request from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new IdVerificationQueryError(err) + recordErpRequestFailure(err, "getIdVerificationByUpgradeRequest") + return new IdVerificationQueryError("ERPNext request failed") } } @@ -702,16 +614,8 @@ export class ErpNext { } } catch (err) { if (isAxiosError(err) && err.response?.status === 404) return null - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, code }, - "Error querying Decision Reason from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new DecisionReasonQueryError(err) + recordErpRequestFailure(err, "getDecisionReason") + return new DecisionReasonQueryError("ERPNext request failed") } } @@ -738,23 +642,12 @@ export class ErpNext { const failedDocs = resp.data?.message?.failed_docs if (failedDocs?.length) { - baseLogger.error( - { failedDocs, names, status }, - "Bulk update failed for some docs", - ) - return new SetDocTypeValueError(failedDocs) + recordErpRequestFailure(undefined, "setStatusForRequests") + return new SetDocTypeValueError("ERPNext bulk update failed") } } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, names, status }, - "Error bulk updating upgrade request status", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new SetDocTypeValueError(err) + recordErpRequestFailure(err, "setStatusForRequests") + return new SetDocTypeValueError("ERPNext request failed") } } } @@ -776,16 +669,8 @@ export class ErpNext { ) return resp.data?.data ?? [] } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, customerName }, - "Error querying Bank Account from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new BankAccountQueryError(err) + recordErpRequestFailure(err, "getBankAccountsByCustomer") + return new BankAccountQueryError("ERPNext request failed") } } @@ -819,20 +704,15 @@ export class ErpNext { const message = resp.data?.message const bankAccountId = message?.bank_account if (!message?.success || typeof bankAccountId !== "string" || !bankAccountId) { - return new BankAccountCreateError(message?.error ?? "No bank_account in response") + return new BankAccountCreateError("Invalid ERPNext bank account response") } return { bankAccountId } } catch (err) { - return this.handleBankAccountWriteError(err, BankAccountCreateError, { - logMessage: "Error creating Bank Account in ERPNext", - context: { - erpParty, - bankName, - accountType, - currency, - accountNumberLast4: last4(args.accountNumber), - }, - }) + return this.handleBankAccountWriteError( + err, + BankAccountCreateError, + "createBankAccount", + ) } } @@ -858,21 +738,15 @@ export class ErpNext { const message = resp.data?.message if (!message?.success) { - return new BankAccountUpdateError(message?.error ?? "No success in response") + return new BankAccountUpdateError("Invalid ERPNext bank account response") } return true } catch (err) { - return this.handleBankAccountWriteError(err, BankAccountUpdateError, { - logMessage: "Error updating Bank Account in ERPNext", - context: { - bankAccountId, - erpParty, - bankName, - accountType, - currency, - accountNumberLast4: last4(args.accountNumber), - }, - }) + return this.handleBankAccountWriteError( + err, + BankAccountUpdateError, + "updateBankAccount", + ) } } @@ -899,9 +773,7 @@ export class ErpNext { message.success === false || (!message.deleted && !message.disabled) ) { - return new BankAccountDeleteError( - message?.error ?? "Bank Account was not deleted", - ) + return new BankAccountDeleteError("Invalid ERPNext bank account response") } return { bankAccountId: message.bank_account_id ?? bankAccountId, @@ -910,10 +782,11 @@ export class ErpNext { newDefault: message.new_default ?? null, } } catch (err) { - return this.handleBankAccountWriteError(err, BankAccountDeleteError, { - logMessage: "Error deleting Bank Account in ERPNext", - context: { bankAccountId, erpParty }, - }) + return this.handleBankAccountWriteError( + err, + BankAccountDeleteError, + "deleteBankAccount", + ) } } @@ -933,14 +806,15 @@ export class ErpNext { const message = resp.data?.message if (!message?.success) { - return new BankAccountSetDefaultError(message?.error ?? "No success in response") + return new BankAccountSetDefaultError("Invalid ERPNext bank account response") } return true } catch (err) { - return this.handleBankAccountWriteError(err, BankAccountSetDefaultError, { - logMessage: "Error setting default Bank Account in ERPNext", - context: { bankAccountId, erpParty }, - }) + return this.handleBankAccountWriteError( + err, + BankAccountSetDefaultError, + "setDefaultBankAccount", + ) } } @@ -957,35 +831,32 @@ export class ErpNext { private handleBankAccountWriteError( err: unknown, GenericError: new (message?: string | unknown) => BankAccountWriteError, - { logMessage, context }: { logMessage: string; context: Record }, + operation: + | "createBankAccount" + | "updateBankAccount" + | "deleteBankAccount" + | "setDefaultBankAccount", ): BankAccountWriteError { const responseData = isAxiosError(err) ? err.response?.data : undefined const status = isAxiosError(err) ? err.response?.status : undefined - // NOT the raw axios error: its `config.data` is the request body, which - // carries the full account number. - const errMessage = err instanceof Error ? err.message : "Unknown error" - baseLogger.error({ err: errMessage, status, responseData, ...context }, logMessage) - recordExceptionInCurrentSpan({ - error: new Error(errMessage), - attributes: { "erpnext.exception": responseData?.exception }, - }) + recordErpRequestFailure(err, operation) const reason = frappeServerMessage(responseData) const excType: string = responseData?.exc_type ?? "" const haystack = `${reason} ${responseData?.exception ?? ""}`.toLowerCase() if (haystack.includes("account number") && haystack.includes("already")) { - return new BankAccountDuplicateNumberError(reason) + return new BankAccountDuplicateNumberError("ERPNext bank account refusal") } if ( status === 404 || excType === "DoesNotExistError" || haystack.includes("does not belong to this customer") ) { - return new BankAccountNotOwnedError(reason) + return new BankAccountNotOwnedError("ERPNext bank account refusal") } if (haystack.includes("unknown erp customer")) { - return new BankAccountUpgradeRequiredError(reason) + return new BankAccountUpgradeRequiredError("ERPNext bank account refusal") } if (haystack.includes("bank account that was removed")) { return new BankAccountValidationError(BankAccountValidationReason.OwnRemovedNumber) @@ -1002,7 +873,7 @@ export class ErpNext { if (haystack.includes("account_number is required")) { return new BankAccountValidationError(BankAccountValidationReason.AccountNumber) } - return new GenericError(reason || errMessage) + return new GenericError("ERPNext request failed") } async getCashoutExchangeRate(): Promise { @@ -1405,13 +1276,8 @@ export class ErpNext { return data } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error({ err, responseData }, "Error querying Banks from ERPNext") - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new BanksQueryError(err) + recordErpRequestFailure(err, "listBanks") + return new BanksQueryError("ERPNext request failed") } } @@ -1426,16 +1292,8 @@ export class ErpNext { ) return { name: resp.data.data.name } } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, ...req.toErpnext() }, - "Error creating Bank Account Update Request in ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new BankAccountUpdateRequestCreateError(err) + recordErpRequestFailure(err, "postBankAccountUpdateRequest") + return new BankAccountUpdateRequestCreateError("ERPNext request failed") } } @@ -1469,16 +1327,8 @@ export class ErpNext { const rows: ErpNextBankAccountUpdateRequestData[] = resp.data?.data ?? [] return rows.map((r) => BankAccountUpdateRequest.fromErpnext(r)) } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, bankAccountId }, - "Error querying Bank Account Update Request from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new BankAccountUpdateRequestQueryError(err) + recordErpRequestFailure(err, "getOpenBankAccountUpdateRequestsForAccount") + return new BankAccountUpdateRequestQueryError("ERPNext request failed") } } @@ -1512,16 +1362,8 @@ export class ErpNext { const rows: ErpNextBankAccountUpdateRequestData[] = resp.data?.data ?? [] return rows.length ? BankAccountUpdateRequest.fromErpnext(rows[0]) : undefined } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, bankAccountId }, - "Error querying latest Bank Account Update Request from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new BankAccountUpdateRequestQueryError(err) + recordErpRequestFailure(err, "getLatestBankAccountUpdateRequestForAccount") + return new BankAccountUpdateRequestQueryError("ERPNext request failed") } } diff --git a/src/services/frappe/telemetry.ts b/src/services/frappe/telemetry.ts new file mode 100644 index 000000000..881d091fb --- /dev/null +++ b/src/services/frappe/telemetry.ts @@ -0,0 +1,73 @@ +import { isAxiosError } from "axios" + +import { baseLogger } from "@services/logger" +import { recordExceptionInCurrentSpan } from "@services/tracing" + +const transportCodes = new Set([ + "ERR_BAD_REQUEST", + "ERR_BAD_RESPONSE", + "ERR_NETWORK", + "ERR_CANCELED", + "ECONNABORTED", + "ECONNRESET", + "ECONNREFUSED", + "ETIMEDOUT", + "ENOTFOUND", + "EAI_AGAIN", +]) + +type ErpTelemetryOperation = + | "postUpgradeRequest" + | "getAccountUpgradeRequestList" + | "getAccountUpgradeRequestById" + | "getUpgradeRequestDecision" + | "postIdVerification" + | "getIdVerificationList" + | "getIdVerificationById" + | "updateIdVerificationEvidence" + | "getIdVerificationByUpgradeRequest" + | "getDecisionReason" + | "setStatusForRequests" + | "getBankAccountsByCustomer" + | "listBanks" + | "postBankAccountUpdateRequest" + | "getOpenBankAccountUpdateRequestsForAccount" + | "getLatestBankAccountUpdateRequestForAccount" + | "createBankAccount" + | "updateBankAccount" + | "deleteBankAccount" + | "setDefaultBankAccount" + +/** Never serialize an AxiosError: its config retains API credentials and body. */ +export const recordErpRequestFailure = ( + error: unknown, + operation: ErpTelemetryOperation, + level: "error" | "warn" = "error", +): void => { + const status = isAxiosError(error) ? error.response?.status : undefined + const httpStatus = + typeof status === "number" && + Number.isInteger(status) && + status >= 100 && + status <= 599 + ? status + : undefined + const code = + isAxiosError(error) && + typeof error.code === "string" && + transportCodes.has(error.code) + ? error.code + : "UNKNOWN" + baseLogger[level]( + { operation, code, ...(httpStatus === undefined ? {} : { status: httpStatus }) }, + "ERPNext request failed", + ) + recordExceptionInCurrentSpan({ + error: new Error("ERPNext request failed"), + attributes: { + "erpnext.operation": operation, + "erpnext.error_code": code, + ...(httpStatus === undefined ? {} : { "erpnext.http_status": httpStatus }), + }, + }) +} diff --git a/src/services/tracing.ts b/src/services/tracing.ts index b481b4b8c..facadece4 100644 --- a/src/services/tracing.ts +++ b/src/services/tracing.ts @@ -217,7 +217,11 @@ registerInstrumentations({ }), new MongoDBInstrumentation(), new GrpcInstrumentation(), - new IORedisInstrumentation(), + new IORedisInstrumentation({ + // Redis keys include one-use Plaid capabilities; Lua and other command + // arguments can carry credentials or personal data. Record no arguments. + dbStatementSerializer: (commandName) => commandName, + }), ], }) diff --git a/test/flash/unit/services/frappe/ErpNext.bankAccounts.spec.ts b/test/flash/unit/services/frappe/ErpNext.bankAccounts.spec.ts index 166bb8416..1e38e1696 100644 --- a/test/flash/unit/services/frappe/ErpNext.bankAccounts.spec.ts +++ b/test/flash/unit/services/frappe/ErpNext.bankAccounts.spec.ts @@ -207,7 +207,7 @@ describe("ErpNext bank accounts", () => { const result = await client.createBankAccount(createArgs) expect(result).toBeInstanceOf(BankAccountCreateError) - expect((result as Error).message).toBe("An internal error occurred") + expect((result as Error).message).toBe("Invalid ERPNext bank account response") }) it("maps the duplicate-number refusal", async () => { @@ -321,7 +321,7 @@ describe("ErpNext bank accounts", () => { const result = await client.createBankAccount(createArgs) expect(result).toBeInstanceOf(BankAccountCreateError) - expect((result as Error).message).toBe("ECONNREFUSED") + expect((result as Error).message).toBe("ERPNext request failed") }) it("never logs the full account number", async () => { @@ -331,9 +331,11 @@ describe("ErpNext bank accounts", () => { expect(JSON.stringify((result as Error).message)).not.toContain(ACCOUNT_NUMBER) expectNoFullAccountNumberLogged() - expect(loggedError.mock.calls[0][0]).toEqual( - expect.objectContaining({ erpParty: "CUST-1", accountNumberLast4: "6655" }), - ) + expect(loggedError.mock.calls[0][0]).toEqual({ + operation: "createBankAccount", + code: "UNKNOWN", + status: 500, + }) }) }) diff --git a/test/flash/unit/services/frappe/ErpNext.idVerification.spec.ts b/test/flash/unit/services/frappe/ErpNext.idVerification.spec.ts index 9d6d60fa6..a0abbabfc 100644 --- a/test/flash/unit/services/frappe/ErpNext.idVerification.spec.ts +++ b/test/flash/unit/services/frappe/ErpNext.idVerification.spec.ts @@ -95,8 +95,8 @@ describe("ErpNext.postIdVerification", () => { expect(result).toBeInstanceOf(IdVerificationCreateError) expect(baseLogger.warn).toHaveBeenCalledWith( - expect.objectContaining({ status: 404, upgradeRequest: "AUR-0001" }), - "Error creating ID Verification in ERPNext", + { status: 404, operation: "postIdVerification", code: "UNKNOWN" }, + "ERPNext request failed", ) expect(baseLogger.error).not.toHaveBeenCalled() }) @@ -249,8 +249,8 @@ describe("ErpNext.getIdVerificationByUpgradeRequest", () => { IdVerificationQueryError, ) expect(baseLogger.error).toHaveBeenCalledWith( - expect.objectContaining({ upgradeRequest: "AUR-0001" }), - "Error querying ID Verification by upgrade request from ERPNext", + { operation: "getIdVerificationByUpgradeRequest", code: "UNKNOWN" }, + "ERPNext request failed", ) }) }) @@ -315,8 +315,8 @@ describe("ErpNext.getDecisionReason", () => { DecisionReasonQueryError, ) expect(baseLogger.error).toHaveBeenCalledWith( - expect.objectContaining({ code: "RESUBMIT_BLURRY" }), - "Error querying Decision Reason from ERPNext", + { operation: "getDecisionReason", code: "UNKNOWN", status: 500 }, + "ERPNext request failed", ) }) @@ -428,9 +428,7 @@ describe("ErpNext.postUpgradeRequest error log", () => { const [payload] = (baseLogger.error as jest.Mock).mock.calls[0] const serialized = JSON.stringify(payload) - expect(payload).toEqual( - expect.objectContaining({ username: "alice", requestedLevel: 2 }), - ) + expect(payload).toEqual({ operation: "postUpgradeRequest", code: "UNKNOWN" }) for (const pii of [ "Alice Applicant", "+18765550100", diff --git a/test/flash/unit/services/frappe/request-telemetry-privacy.spec.ts b/test/flash/unit/services/frappe/request-telemetry-privacy.spec.ts new file mode 100644 index 000000000..4c96fbe65 --- /dev/null +++ b/test/flash/unit/services/frappe/request-telemetry-privacy.spec.ts @@ -0,0 +1,281 @@ +jest.mock("axios", () => ({ + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + isAxiosError: jest.fn((err) => Boolean(err?.isAxiosError)), +})) +jest.mock("@services/logger", () => ({ + baseLogger: { error: jest.fn(), warn: jest.fn() }, +})) +jest.mock("@services/tracing", () => ({ recordExceptionInCurrentSpan: jest.fn() })) +jest.mock("@config", () => ({ FrappeConfig: undefined })) + +import axios from "axios" +import { IdentitySource, UpgradeEvidenceType } from "@domain/accounts" +import { Validated } from "@domain/shared" +import { ErpNext } from "@services/frappe/ErpNext" +import { recordErpRequestFailure } from "@services/frappe/telemetry" +import { + AccountUpgradeRequest, + RequestStatus, +} from "@services/frappe/models/AccountUpgradeRequest" +import { BankAccountUpdateRequest } from "@services/frappe/models/BankAccountUpdateRequest" +import { IdVerification } from "@services/frappe/models/IdVerification" +import { baseLogger } from "@services/logger" +import { recordExceptionInCurrentSpan } from "@services/tracing" + +const http = jest.mocked(axios) +const client = new ErpNext("https://erp.invalid", "erp.invalid", { + apiKey: "PRIVATE_API_KEY", + apiSecret: "PRIVATE_API_SECRET", +}) +const privateBody = { + full_name: "PRIVATE_NAME", + phone: "PRIVATE_PHONE", + email: "PRIVATE_EMAIL", + account_number: "PRIVATE_BANK_NUMBER", + address: "PRIVATE_POSTAL_ADDRESS", + file_key: "PRIVATE_ID_EVIDENCE", +} +const upgrade = { + username: "PRIVATE_USERNAME", + requestedLevel: 2, + toErpnext: () => ({ ...privateBody }), +} as unknown as Validated +const doc = IdVerification.fromEvidence({ + upgradeRequest: "PRIVATE_UPGRADE_ID", + identitySource: IdentitySource.Capture, + capturedAt: new Date("2026-10-22T00:00:00Z"), + evidence: [ + { + type: UpgradeEvidenceType.IdFront, + fileKey: "id_documents/PRIVATE_USERNAME/PRIVATE_ID_EVIDENCE.jpg", + }, + ], +}) +const bank = { + erpParty: "PRIVATE_ERP_PARTY", + bankName: "PRIVATE_BANK", + accountNumber: "PRIVATE_BANK_NUMBER", + accountType: "Savings", + currency: "USD", + bankBranch: "PRIVATE_BRANCH", + accountName: "PRIVATE_NAME", +} +const bankRequest = new BankAccountUpdateRequest( + "PRIVATE_REQUEST_ID", + bank.erpParty, + "PRIVATE_BANK_ID", + RequestStatus.Pending, + { + bank: bank.bankName, + bank_account_no: bank.accountNumber, + branch_code: bank.bankBranch, + account_type: bank.accountType, + currency: bank.currency, + }, + "PRIVATE_SUPPORT_NOTE", +) + +function privateError() { + return Object.assign(new Error("PRIVATE_ERROR_MESSAGE"), { + stack: "PRIVATE_ERROR_STACK", + isAxiosError: true, + code: "ERR_BAD_RESPONSE", + config: { + headers: { Authorization: "token PRIVATE_API_KEY:PRIVATE_API_SECRET" }, + data: JSON.stringify(privateBody), + url: "https://erp.invalid/PRIVATE_SIGNED_URL", + }, + response: { + status: 500, + data: { + exception: "PRIVATE_PROVIDER_EXCEPTION", + _server_messages: "PRIVATE_PROVIDER_BODY", + ...privateBody, + }, + }, + }) +} +function serialize(value: unknown) { + return JSON.stringify(value, (_key, entry) => + entry instanceof Error + ? { ...entry, message: entry.message, stack: entry.stack } + : entry, + ) +} +function expectPrivateDataAbsent(result?: unknown) { + const captured = serialize({ + logs: [ + jest.mocked(baseLogger.error).mock.calls, + jest.mocked(baseLogger.warn).mock.calls, + ], + spans: jest.mocked(recordExceptionInCurrentSpan).mock.calls, + result, + }) + expect(captured).not.toContain("PRIVATE_") + expect(captured).not.toContain("erpnext.exception") +} +beforeEach(() => { + jest.clearAllMocks() + for (const method of [http.get, http.post, http.put]) + method.mockRejectedValue(privateError()) +}) + +const requests: [string, () => Promise][] = [ + ["postUpgradeRequest", () => client.postUpgradeRequest(upgrade)], + ["getAccountUpgradeRequestList", () => client.getAccountUpgradeRequestList({})], + [ + "getAccountUpgradeRequestById", + () => client.getAccountUpgradeRequestById("PRIVATE_UPGRADE_ID"), + ], + [ + "getUpgradeRequestDecision", + () => client.getUpgradeRequestDecision("PRIVATE_UPGRADE_ID"), + ], + ["postIdVerification", () => client.postIdVerification(doc)], + ["getIdVerificationList", () => client.getIdVerificationList()], + ["getIdVerificationById", () => client.getIdVerificationById("PRIVATE_EVIDENCE_ID")], + [ + "updateIdVerificationEvidence", + () => + client.updateIdVerificationEvidence( + "PRIVATE_EVIDENCE_ID", + doc.toErpnext().evidence ?? [], + ), + ], + [ + "getIdVerificationByUpgradeRequest", + () => client.getIdVerificationByUpgradeRequest("PRIVATE_UPGRADE_ID"), + ], + ["getDecisionReason", () => client.getDecisionReason("PRIVATE_DECISION_REASON")], + [ + "closeAccountUpgradeRequests", + () => client.closeAccountUpgradeRequests(["PRIVATE_UPGRADE_ID"]), + ], + [ + "closeBankAccountUpdateRequests", + () => client.closeBankAccountUpdateRequests(["PRIVATE_BANK_REQUEST_ID"]), + ], + [ + "getBankAccountsByCustomer", + () => client.getBankAccountsByCustomer("PRIVATE_CUSTOMER"), + ], + ["createBankAccount", () => client.createBankAccount(bank)], + [ + "updateBankAccount", + () => client.updateBankAccount({ ...bank, bankAccountId: "PRIVATE_BANK_ID" }), + ], + [ + "deleteBankAccount", + () => + client.deleteBankAccount({ + erpParty: bank.erpParty, + bankAccountId: "PRIVATE_BANK_ID", + }), + ], + [ + "setDefaultBankAccount", + () => + client.setDefaultBankAccount({ + erpParty: bank.erpParty, + bankAccountId: "PRIVATE_BANK_ID", + }), + ], + ["listBanks", () => client.listBanks()], + [ + "postBankAccountUpdateRequest", + () => client.postBankAccountUpdateRequest(bankRequest), + ], + [ + "getOpenBankAccountUpdateRequestsForAccount", + () => client.getOpenBankAccountUpdateRequestsForAccount("PRIVATE_BANK_ID"), + ], + [ + "getLatestBankAccountUpdateRequestForAccount", + () => client.getLatestBankAccountUpdateRequestForAccount("PRIVATE_BANK_ID"), + ], +] +it.each(requests)( + "keeps %s failure credentials/body/provider text out of logs, spans and returned errors", + async (_name, run) => { + const result = await run() + expect(result).toBeInstanceOf(Error) + expect(recordExceptionInCurrentSpan).toHaveBeenCalledTimes(1) + expectPrivateDataAbsent(result) + }, +) + +it("preserves authenticated identity and bank request bodies despite sanitized failure recording", async () => { + await client.postUpgradeRequest(upgrade) + expect(http.post).toHaveBeenLastCalledWith(expect.any(String), upgrade.toErpnext(), { + headers: expect.objectContaining({ + Authorization: "token PRIVATE_API_KEY:PRIVATE_API_SECRET", + }), + }) + await client.postBankAccountUpdateRequest(bankRequest) + expect(http.post).toHaveBeenLastCalledWith( + expect.any(String), + bankRequest.toErpnext(), + { + headers: expect.objectContaining({ + Authorization: "token PRIVATE_API_KEY:PRIVATE_API_SECRET", + }), + }, + ) + await client.postIdVerification(doc) + expect(http.post).toHaveBeenLastCalledWith(expect.any(String), doc.toErpnext(), { + headers: expect.objectContaining({ + Authorization: "token PRIVATE_API_KEY:PRIVATE_API_SECRET", + }), + }) + expectPrivateDataAbsent() +}) + +it("does not retain provider text from unsuccessful 2xx bank responses or bulk failed-doc payloads", async () => { + http.post.mockResolvedValue({ + data: { + message: { + success: false, + error: "PRIVATE_BANK_NUMBER", + failed_docs: [{ docname: "PRIVATE_NAME", reason: "PRIVATE_BANK_NUMBER" }], + }, + }, + }) + for (const run of [ + () => client.createBankAccount(bank), + () => client.updateBankAccount({ ...bank, bankAccountId: "PRIVATE_BANK_ID" }), + () => + client.deleteBankAccount({ + erpParty: bank.erpParty, + bankAccountId: "PRIVATE_BANK_ID", + }), + () => + client.setDefaultBankAccount({ + erpParty: bank.erpParty, + bankAccountId: "PRIVATE_BANK_ID", + }), + () => client.closeBankAccountUpdateRequests(["PRIVATE_NAME"]), + ]) { + const result = await run() + expect(result).toBeInstanceOf(Error) + expectPrivateDataAbsent(result) + } +}) + +it("retains only allowlisted transport codes and integer HTTP status, not arbitrary error strings", () => { + const error = privateError() + recordErpRequestFailure(error, "createBankAccount") + expect(baseLogger.error).toHaveBeenLastCalledWith( + { operation: "createBankAccount", code: "ERR_BAD_RESPONSE", status: 500 }, + "ERPNext request failed", + ) + error.code = "PRIVATE_TRANSPORT_CODE" + error.response.status = Number.NaN + recordErpRequestFailure(error, "createBankAccount") + expect(baseLogger.error).toHaveBeenLastCalledWith( + { operation: "createBankAccount", code: "UNKNOWN" }, + "ERPNext request failed", + ) + expectPrivateDataAbsent() +}) diff --git a/test/flash/unit/services/tracing-input-privacy.spec.ts b/test/flash/unit/services/tracing-input-privacy.spec.ts index 36042c14b..9de673658 100644 --- a/test/flash/unit/services/tracing-input-privacy.spec.ts +++ b/test/flash/unit/services/tracing-input-privacy.spec.ts @@ -49,9 +49,10 @@ jest.mock("@opentelemetry/api", () => { }) import { GraphQLInstrumentation } from "@opentelemetry/instrumentation-graphql" +import { IORedisInstrumentation } from "@opentelemetry/instrumentation-ioredis" import { registerInstrumentations } from "@opentelemetry/instrumentation" import { parse } from "graphql" -import { TracerProvider } from "@opentelemetry/api" +import { TracerProvider, Span, trace } from "@opentelemetry/api" import { wrapToRunInSpan, wrapAsyncToRunInSpan, @@ -68,6 +69,10 @@ function fakeSpan() { result.attributes[key] = value return result }), + setAttributes: jest.fn((attributes: Record) => { + Object.assign(result.attributes, attributes) + return result + }), recordException: jest.fn((error: unknown) => { result.events.push(error) }), @@ -330,3 +335,56 @@ it("also drops GraphQL parser source before export rather than protecting only e expect(exported["graphql.source"]).toBeUndefined() expect(JSON.stringify(exported)).not.toContain("PRIVATE_") }) + +it.each([ + ["set", ["plaid:link:PRIVATE_TOKEN", "PRIVATE_BANK_BINDING"]], + ["get", ["plaid:link:PRIVATE_TOKEN"]], + ["del", ["plaid:link:PRIVATE_TOKEN"]], + ["eval", ["return 'PRIVATE_LUA_SECRET'", "1", "PRIVATE_KEY", "PRIVATE_ARGUMENT"]], + ["auth", ["PRIVATE_REDIS_PASSWORD"]], +])( + "records only the %s command name through the exact Redis plugin while preserving execution", + (name, args) => { + const options = jest.mocked(registerInstrumentations).mock.calls[0][0] + const instrumentation = options.instrumentations + ?.flat() + .find((item) => item instanceof IORedisInstrumentation) + if (!(instrumentation instanceof IORedisInstrumentation)) + throw new Error("Redis instrumentation not registered") + instrumentation.setTracerProvider({ + getTracer: () => ({ + startSpan: (_name: string, options: { attributes: Record }) => { + Object.assign(span.attributes, options.attributes) + return span + }, + }), + } as unknown as TracerProvider) + const resolve = jest.fn(), + reject = jest.fn() + const command = { name, args, resolve, reject } + const providerResult = { fixture: true } + const original = jest.fn(() => providerResult) + const wrapped = Reflect.get(instrumentation, "_traceSendCommand").call( + instrumentation, + original, + "fixture-version", + ) + // The production plugin requires a parent span. Simulate that authority + // without registering an SDK or exporting any span. + const parent = jest.spyOn(trace, "getSpan").mockReturnValue(span as unknown as Span) + try { + expect( + wrapped.call({ options: { host: "fixture.invalid", port: 6379 } }, command), + ).toBe(providerResult) + expect(original).toHaveBeenCalledWith(command) + expect(command.args).toBe(args) + expect(span.attributes["db.statement"]).toBe(name) + expect(JSON.stringify(span.attributes)).not.toContain("PRIVATE_") + command.resolve("fixture-result") + expect(resolve).toHaveBeenCalledWith("fixture-result") + expect(span.end).toHaveBeenCalledTimes(1) + } finally { + parent.mockRestore() + } + }, +) From 189b35717707bd19028a54d5fb1b60b9e95a83b9 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 00:09:21 -0700 Subject: [PATCH 08/42] Project GraphQL error traces onto closed safe metadata --- docs/graphql-error-trace-privacy.md | 15 + src/services/graphql-trace-privacy.ts | 183 +++++++++++ src/services/tracing.ts | 117 +------ .../services/tracing-input-privacy.spec.ts | 301 +++++++++++++++++- 4 files changed, 506 insertions(+), 110 deletions(-) create mode 100644 docs/graphql-error-trace-privacy.md create mode 100644 src/services/graphql-trace-privacy.ts diff --git a/docs/graphql-error-trace-privacy.md b/docs/graphql-error-trace-privacy.md new file mode 100644 index 000000000..979bd51ca --- /dev/null +++ b/docs/graphql-error-trace-privacy.md @@ -0,0 +1,15 @@ +# GraphQL error trace privacy + +This follow-up is based on `1a8ae3cce`, after the central argument/source and IBEX/Redis/identity-banking telemetry fixes. It makes no provider request, deployment, authentication policy or application error-response change. + +`allowValues: false` and removing `graphql.source` are insufficient to protect failed requests. Standard GraphQL input coercion quotes supplied input in an error message when a required sibling is missing. In the actual public schema, `UserTotpRegistrationValidateInput` includes `authToken`, `totpCode` and `totpRegistrationId`; omitting a required field can cause the error to repeat the supplied bearer and OTP. The previous response hook serialized the full errors, original messages and paths into spans. The installed GraphQL instrumentation 0.35.2 also records parser and validation exception events outside that response hook. + +The response hook now collects only a fixed failure kind, a total count aggregated across top-level and payload errors (capped at 128), and machine codes from a closed set of pinned public/Apollo codes, including the literal banking/Bridge helper mappings. The distinct-code list is capped at 16. Unknown codes become `UNKNOWN`; uppercase formatting alone does not make an arbitrary string safe. Mixed top-level/payload failures are identified as mixed. Root aliases, messages, original-error objects, stacks and JSON error payloads are not used as telemetry values or attribute names. + +At the exporter boundary, spans are classified by the actual GraphQL instrumentation library name. Their attributes and events are projected onto validated operation type and fixed failure summaries. Caller-selected operation names, aliases/field paths, exception messages/stacks, arbitrary custom attributes/events and status descriptions are not retained. Span names become fixed parse/validation/execution/resolve phases. Timing and error status remain useful without exporting the request or its errors. This deliberate metadata policy also prevents a private value embedded in a syntactically valid operation/alias name from bypassing message removal. Generic non-GraphQL application spans are outside this projection; raw GraphQL source remains removed globally as before. + +Tests exercise the exact installed plugin's execute path against the repository's real public SDL, with a malformed TOTP registration input containing public fixture bearer/OTP values. The original GraphQL errors still reach the caller unchanged, while the serialized exporter stand-in contains none of those values or private operation/alias names. Actual parser and validator error paths, original-error identity, private bank/name values, unknown machine codes and status text are also checked. No SDK exporter or collector is contacted. + +This does not sanitize generic application-wrapper exception messages, non-GraphQL events, arbitrary explicit attributes/baggage or unrelated logs. In particular, an Axios/plain-object error converted to JSON before reaching a generic wrapper can still embed request credentials in its message. HTTP URL/target instrumentation may retain sensitive query strings; identity document URLs generated without an HTTP request should not be described as automatically captured merely because that default exists. These remain separate source-boundary audits, as do historical telemetry access/retention and credential rotation. Dev collector rules do not establish production policy. + +Validation: 40 tracing tests pass; the combined nine focused/regression suites pass 236 tests. All three TypeScript configurations, scoped ESLint and `git diff --check` pass under Node 24 with frozen-lock dependencies. A source comparison confirms every one of the 29 literal `BANK_ACCOUNT_*` / `BRIDGE_*` public helper codes is present in the closed set. Root independently reviewed the projection and requested the banking-code/count refinements covered by the final tests. diff --git a/src/services/graphql-trace-privacy.ts b/src/services/graphql-trace-privacy.ts new file mode 100644 index 000000000..3f815a5c2 --- /dev/null +++ b/src/services/graphql-trace-privacy.ts @@ -0,0 +1,183 @@ +import { Span as SdkSpan } from "@opentelemetry/sdk-trace-base" +import { Attributes, SpanStatusCode } from "@opentelemetry/api" + +// Closed machine codes from the pinned public error map plus Apollo's standard +// codes. An arbitrary uppercase string is not evidence that it is nonsecret. +const errorCodes = new Set([ + "GRAPHQL_PARSE_FAILED", + "GRAPHQL_VALIDATION_FAILED", + "BAD_USER_INPUT", + "UNAUTHENTICATED", + "FORBIDDEN", + "PERSISTED_QUERY_NOT_FOUND", + "PERSISTED_QUERY_NOT_SUPPORTED", + "INTERNAL_SERVER_ERROR", + "CANT_PAY_SELF", + "CODE_EXPIRED_ERROR", + "DB_ERROR", + "DEALER_ERROR", + "DEALER_OFFLINE", + "EMAIL_ACCOUNT_ALREADY_EXISTS_ERROR", + "EMAIL_ALREADY_ATTACHED_ERROR", + "EMAIL_NOT_VERIFIED_ERROR", + "ENTERED_DUST_AMOUNT", + "FIREBASE_ERROR", + "IBEX_ERROR", + "INSUFFICIENT_BALANCE", + "INVALID_COORDINATES", + "INVALID_INPUT", + "INVALID_LNURL", + "INVALID_PHONE_METADATA_FOR_ONBOARDING_ERROR", + "INVALID_TITLE", + "INVOICE_DECODE_ERROR", + "IP_NOT_ALLOWED_TO_ONBOARD_ERROR", + "LIGHTNING_PAYMENT_ERROR", + "LIQUIDITY_ERROR", + "LND_OFFLINE", + "NEW_ACCOUNT_WITHDRAWAL_RESTRICTED", + "NOT_AUTHENTICATED", + "NOT_AUTHORIZED", + "NOT_FOUND", + "ONCHAIN_FEE_ESTIMATION_ERROR", + "ONCHAIN_PAYMENT_ERROR", + "PHONE_ACCOUNT_ALREADY_EXISTS_ERROR", + "PHONE_ACCOUNT_ALREADY_EXISTS_NEED_TO_SWEEP_FUNDS_ERROR", + "PHONE_ALREADY_ATTACHED_ERROR", + "PHONE_ALREADY_REGISTERED_TO_ANOTHER_USER", + "PHONE_CODE_ERROR", + "PHONE_NOT_ALLOWED_TO_ONBOARD_ERROR", + "PHONE_PROVIDER_ERROR", + "REJECTED_CAPTCHA_FAILED", + "ROUTE_FINDING_ERROR", + "SESSION_REFRESH_REQUIRED_ERROR", + "TOO_MANY_REQUEST", + "TRANSACTION_RESTRICTED", + "UNAUTHORIZED_COUNTRY_IP_FOR_REWARD", + "UNAUTHORIZED_IP_FOR_REWARD", + "UNAUTHORIZED_VPN_IP_FOR_REWARD", + "UNEXPECTED_CLIENT_ERROR", + "UNKNOWN_CLIENT_ERROR", + "USERNAME_ERROR", + "BANK_ACCOUNT_UPGRADE_REQUIRED", + "BANK_ACCOUNT_NOT_FOUND", + "BANK_ACCOUNT_DUPLICATE_NUMBER", + "BANK_ACCOUNT_INVALID", + "BRIDGE_INVALID_AMOUNT", + "BRIDGE_BELOW_MINIMUM_WITHDRAWAL", + "BRIDGE_DISABLED", + "BRIDGE_ACCOUNT_LEVEL_ERROR", + "BRIDGE_KYC_EMAIL_NOT_VERIFIED", + "BRIDGE_KYC_COUNTRY_NOT_SUPPORTED", + "BRIDGE_KYC_PHONE_REQUIRED", + "BRIDGE_KYC_PENDING", + "BRIDGE_KYC_REJECTED", + "BRIDGE_KYC_OFFBOARDED", + "BRIDGE_KYC_TIER_CEILING_EXCEEDED", + "BRIDGE_CUSTOMER_NOT_FOUND", + "BRIDGE_INSUFFICIENT_FUNDS", + "BRIDGE_WITHDRAWAL_NET_AMOUNT_TOO_LOW", + "BRIDGE_WITHDRAWAL_NOT_FOUND", + "BRIDGE_WITHDRAWAL_ALREADY_INITIATED", + "BRIDGE_RATE_LIMIT", + "BRIDGE_TIMEOUT", + "BRIDGE_TRANSFER_FAILED", + "BRIDGE_DEPOSIT_INSTRUCTIONS_MISSING", + "BRIDGE_WEBHOOK_VALIDATION", + "BRIDGE_API_ERROR", + "BRIDGE_PLAID_NOT_AVAILABLE", + "BRIDGE_INVALID_PLAID_TOKEN", + "BRIDGE_ERROR", +]) +const kinds = new Set(["execution", "payload", "mixed", "parse", "validation", "resolve"]) +const phases = new Map([ + ["graphql.parse", "parse"], + ["graphql.parseSchema", "parse"], + ["graphql.validate", "validation"], + ["graphql.validateSchema", "validation"], + ["graphql.resolve", "resolve"], +]) +export const safeGraphQLErrorCode = (value: unknown): string => + typeof value === "string" && errorCodes.has(value) ? value : "UNKNOWN" + +export const graphQLErrorSummary = ( + errors: readonly unknown[], + kind: "execution" | "payload", + previous?: Attributes, +): Attributes => { + const count = previous?.["graphql.error.count"] + const priorCount = + typeof count === "number" && Number.isInteger(count) && count > 0 + ? Math.min(count, 128) + : 0 + const priorKind = previous?.["graphql.error.kind"] + const currentCodes = errors.slice(0, 128).map((error) => { + const record = + typeof error === "object" && error !== null + ? (error as { code?: unknown; extensions?: { code?: unknown } }) + : undefined + return safeGraphQLErrorCode(record?.extensions?.code ?? record?.code) + }) + const currentCode = currentCodes.find((code) => code !== "UNKNOWN") ?? "UNKNOWN" + const priorCode = safeGraphQLErrorCode(previous?.["graphql.error.code"]) + const previousCodes = previous?.["graphql.error.codes"] + const codes = new Set( + Array.isArray(previousCodes) + ? previousCodes.slice(0, 16).map(safeGraphQLErrorCode) + : [], + ) + for (const code of currentCodes) codes.add(code) + return { + "graphql.error.count": Math.min(priorCount + errors.length, 128), + "graphql.error.kind": priorCount > 0 && priorKind !== kind ? "mixed" : kind, + "graphql.error.code": priorCode !== "UNKNOWN" ? priorCode : currentCode, + "graphql.error.codes": [...codes].slice(0, 16), + } +} + +/** The pinned plugin records parse/validation exceptions outside responseHook. */ +export const sanitizeGraphQLSpanForExport = (span: SdkSpan): void => { + if (span.instrumentationLibrary.name !== "@opentelemetry/instrumentation-graphql") + return + const type = span.attributes["graphql.operation.type"] + const operation = + type === "query" || type === "mutation" || type === "subscription" ? type : undefined + const phase = phases.get(span.name) ?? "execution" + const exceptionEvents = span.events.filter( + (event) => event.name === "exception" || event.name === "graphql.error", + ) + const count = span.attributes["graphql.error.count"] + const safeCount = + typeof count === "number" && Number.isInteger(count) && count > 0 + ? Math.min(count, 128) + : Math.min(exceptionEvents.length, 128) + const failed = span.status.code === SpanStatusCode.ERROR || safeCount > 0 + const kind = span.attributes["graphql.error.kind"] + const attributes: Attributes = operation ? { "graphql.operation.type": operation } : {} + if (failed) { + attributes["graphql.error.kind"] = + typeof kind === "string" && kinds.has(kind) ? kind : phase + attributes["graphql.error.count"] = Math.max(1, safeCount) + attributes["graphql.error.code"] = safeGraphQLErrorCode( + span.attributes["graphql.error.code"], + ) + const codes = span.attributes["graphql.error.codes"] + if (Array.isArray(codes)) + attributes["graphql.error.codes"] = [ + ...new Set(codes.slice(0, 16).map(safeGraphQLErrorCode)), + ] + } + // Preserve no caller-selected operation/alias/path, original error object, + // partially masked source, exception text/stack or status description. + for (const key of Object.keys(span.attributes)) delete span.attributes[key] + Object.assign(span.attributes, attributes) + span.name = `graphql.${phase}` + span.status = { code: failed ? SpanStatusCode.ERROR : span.status.code } + const eventTime = exceptionEvents[0]?.time ?? span.startTime + span.events.splice(0, span.events.length) + if (failed) + span.events.push({ + name: "graphql.error", + time: eventTime, + attributes: { ...attributes }, + }) +} diff --git a/src/services/tracing.ts b/src/services/tracing.ts index facadece4..9efe9a7d3 100644 --- a/src/services/tracing.ts +++ b/src/services/tracing.ts @@ -21,13 +21,17 @@ import { TimeInput, Exception, Context, - AttributeValue, } from "@opentelemetry/api" import { ErrorLevel, RankedErrorLevel, parseErrorFromUnknown } from "@domain/shared" import { NetInstrumentation } from "@opentelemetry/instrumentation-net" import type * as graphqlTypes from "graphql" import { Resource } from "@opentelemetry/resources" + +import { + graphQLErrorSummary, + sanitizeGraphQLSpanForExport, +} from "./graphql-trace-privacy" type ExtendedException = Exclude & { level?: ErrorLevel } @@ -68,7 +72,7 @@ const gqlResponseHook = (span: ExtendedSpan, data: graphqlTypes.ExecutionResult) } if (data.errors && data.errors.length > 0) { - recordGqlErrors({ errors: data.errors, span, subPathName: "" }) + recordGqlErrors({ errors: data.errors, span, kind: "execution" }) } let gqlNestedKeys: string[] = [] @@ -81,7 +85,7 @@ const gqlResponseHook = (span: ExtendedSpan, data: graphqlTypes.ExecutionResult) }> if (!nestedObjData) continue if (nestedObjData.errors && nestedObjData.errors.length > 0) { - recordGqlErrors({ errors: nestedObjData.errors, span, subPathName: nestedObj }) + recordGqlErrors({ errors: nestedObjData.errors, span, kind: "payload" }) } } } @@ -89,108 +93,18 @@ const gqlResponseHook = (span: ExtendedSpan, data: graphqlTypes.ExecutionResult) const recordGqlErrors = ({ errors, span, - subPathName, + kind, }: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - errors: any + errors: readonly unknown[] span: ExtendedSpan - subPathName: string + kind: "execution" | "payload" }) => { - const subPath = subPathName ? `${subPathName}.` : "" - - recordException( - span, - { - name: `graphql.${subPath}execution.error`, - message: JSON.stringify(errors), - }, - ErrorLevel.Warn, - ) - - const setErrorAttribute = ({ - attribute, - value, - }: { - attribute: string - value: AttributeValue - }) => - span.setAttribute(`graphql.${subPath ? "data." : subPath}error.${attribute}`, value) - - const firstErr = errors[0] - if (subPath) { - setErrorAttribute({ - attribute: `operation.name`, - value: subPath.split(".").join(""), // remove trailing '.' - }) - } - - setErrorAttribute({ - attribute: "message", - value: firstErr.message, - }) - - setErrorAttribute({ - attribute: "type", - value: firstErr.constructor?.name, - }) - - setErrorAttribute({ - attribute: "path", - value: firstErr.path, - }) - - setErrorAttribute({ - attribute: "code", - value: firstErr.extensions?.code || firstErr.code, - }) - - if (firstErr.originalError) { - setErrorAttribute({ - attribute: "original.type", - value: firstErr.originalError.constructor?.name, - }) - - setErrorAttribute({ - attribute: "original.message", - value: firstErr.originalError.message, - }) + const summary = graphQLErrorSummary(errors, kind, span.attributes) + for (const [key, value] of Object.entries(summary)) { + if (value !== undefined) span.setAttribute(key, value) } - - /* eslint @typescript-eslint/ban-ts-comment: "off" */ - // @ts-ignore-next-line no-implicit-any error - errors.forEach((err, idx) => { - setErrorAttribute({ - attribute: `${idx}.message`, - value: err.message, - }) - - setErrorAttribute({ - attribute: `${idx}.type`, - value: err.constructor?.name, - }) - - setErrorAttribute({ - attribute: `${idx}.path`, - value: err.path, - }) - - setErrorAttribute({ - attribute: `${idx}.code`, - value: err.extensions?.code || err.code, - }) - - if (err.originalError) { - setErrorAttribute({ - attribute: `${idx}.original.type`, - value: err.originalError.constructor?.name, - }) - - setErrorAttribute({ - attribute: `${idx}.original.message`, - value: err.originalError.message, - }) - } - }) + span.addEvent("graphql.error", summary) + span.setStatus({ code: SpanStatusCode.ERROR }) } registerInstrumentations({ @@ -254,6 +168,7 @@ class SpanProcessorWrapper extends SimpleSpanProcessor { // false, but keeps comments and enum/name tokens in graphql.source. // Do not export partially redacted query bodies from any GraphQL span. delete span.attributes["graphql.source"] + sanitizeGraphQLSpanForExport(span) super.onEnd(span) } } diff --git a/test/flash/unit/services/tracing-input-privacy.spec.ts b/test/flash/unit/services/tracing-input-privacy.spec.ts index 9de673658..8e1c30d38 100644 --- a/test/flash/unit/services/tracing-input-privacy.spec.ts +++ b/test/flash/unit/services/tracing-input-privacy.spec.ts @@ -21,8 +21,18 @@ jest.mock("@opentelemetry/sdk-trace-base", () => { onStart() { return undefined } - onEnd(span: { attributes: Record }) { - exported.push({ ...span.attributes }) + onEnd(span: { + attributes: Record + events: unknown[] + status: unknown + name: string + }) { + exported.push({ + ...span.attributes, + spanName: span.name, + events: span.events, + status: span.status, + }) } }, } @@ -48,10 +58,24 @@ jest.mock("@opentelemetry/api", () => { } }) +import { readFileSync } from "node:fs" + +import { resolve } from "node:path" + import { GraphQLInstrumentation } from "@opentelemetry/instrumentation-graphql" import { IORedisInstrumentation } from "@opentelemetry/instrumentation-ioredis" import { registerInstrumentations } from "@opentelemetry/instrumentation" -import { parse } from "graphql" +import { + parse, + buildSchema, + execute, + validate, + defaultFieldResolver, + GraphQLError, + GraphQLSchema, + DocumentNode, +} from "graphql" + import { TracerProvider, Span, trace } from "@opentelemetry/api" import { wrapToRunInSpan, @@ -63,8 +87,16 @@ const mockStartActiveSpan = jest.fn() let span: ReturnType function fakeSpan() { const result = { + name: "fixture.generic", + instrumentationLibrary: { name: "fixture.generic" }, + startTime: [0, 0] as [number, number], + status: { code: 0 } as { code: number; message?: string }, attributes: {} as Record, - events: [] as unknown[], + events: [] as { + name: string + time: [number, number] + attributes?: Record + }[], setAttribute: jest.fn((key: string, value: unknown) => { result.attributes[key] = value return result @@ -74,11 +106,31 @@ function fakeSpan() { return result }), recordException: jest.fn((error: unknown) => { - result.events.push(error) + result.events.push({ + name: "exception", + time: [0, 0], + attributes: { + "exception.message": + error instanceof Error + ? error.message + : Reflect.get(Object(error), "message"), + "exception.stacktrace": error instanceof Error ? error.stack : undefined, + }, + }) + }), + addEvent: jest.fn((name: string, attributes?: Record) => { + result.events.push({ name, time: [0, 0], attributes }) + return result + }), + setStatus: jest.fn((status: { code: number; message?: string }) => { + result.status = status + return result }), - setStatus: jest.fn(), end: jest.fn(), - updateName: jest.fn(), + updateName: jest.fn((name: string) => { + result.name = name + return result + }), } return result } @@ -270,7 +322,13 @@ function configuredGraphQL() { if (!(instrumentation instanceof GraphQLInstrumentation)) throw new Error("GraphQL instrumentation not registered") instrumentation.setTracerProvider({ - getTracer: () => ({ startSpan: () => span }), + getTracer: () => ({ + startSpan: (name: string) => { + span.name = name + span.instrumentationLibrary = { name: "@opentelemetry/instrumentation-graphql" } + return span + }, + }), } as unknown as TracerProvider) return instrumentation } @@ -313,7 +371,8 @@ it("drops source at the export boundary, including comment and enum literals not const exported = jest.requireMock("@opentelemetry/sdk-trace-base").exported.at(-1) expect(exported["graphql.source"]).toBeUndefined() expect(JSON.stringify(exported)).not.toContain("PRIVATE_") - expect(exported["graphql.operation.name"]).toBe("Privacy") + expect(exported["graphql.operation.name"]).toBeUndefined() + expect(exported.spanName).toBe("graphql.execution") expect(exported["graphql.operation.type"]).toBe("query") }) @@ -388,3 +447,227 @@ it.each([ } }, ) + +function exportedGraphQL() { + const processor = jest.requireMock("@opentelemetry/sdk-trace-node").processors[0] + processor.onEnd(span) + return jest.requireMock("@opentelemetry/sdk-trace-base").exported.at(-1) +} + +it("keeps real malformed TOTP input errors unchanged for callers while excluding echoed bearer/input from exported spans", async () => { + const instrumentation = configuredGraphQL() + const schema = buildSchema( + readFileSync( + resolve(__dirname, "../../../../src/graphql/public/schema.graphql"), + "utf8", + ), + ) + const document = parse( + "mutation PRIVATE_OPERATION_NAME($input: UserTotpRegistrationValidateInput!) { PRIVATE_ALIAS: userTotpRegistrationValidate(input: $input) { errors { message } } }", + ) + let originalResult: unknown + const original = (args: Parameters[0]) => { + originalResult = execute(args) + return originalResult + } + const wrapped = Reflect.get(instrumentation, "_patchExecute").call( + instrumentation, + defaultFieldResolver, + )(original) + const result = await wrapped({ + schema, + document, + variableValues: { + input: { authToken: "PRIVATE_BEARER_TOKEN", totpCode: "PRIVATE_OTP_CODE" }, + }, + }) + expect(result).toBe(await originalResult) + expect(JSON.stringify(result.errors)).toContain("PRIVATE_BEARER_TOKEN") + expect(JSON.stringify(result.errors)).toContain("PRIVATE_OTP_CODE") + const exported = exportedGraphQL() + expect(JSON.stringify(exported)).not.toContain("PRIVATE_") + expect(exported["graphql.operation.type"]).toBe("mutation") + expect(exported["graphql.error.kind"]).toBe("execution") + expect(exported["graphql.error.count"]).toBeGreaterThan(0) + expect(exported.status.code).toBe(2) + expect(exported.spanName).toBe("graphql.execution") +}) + +it("sanitizes real parser exception events and status text that bypass the execution response hook", () => { + const instrumentation = configuredGraphQL() + const source = + 'query PRIVATE_OPERATION { field(input: "PRIVATE_BANK_NUMBER" "PRIVATE_NAME") }' + let originalError: unknown + try { + parse(source) + } catch (error) { + originalError = error + } + expect(originalError).toBeInstanceOf(GraphQLError) + expect((originalError as Error).message).toContain("PRIVATE_NAME") + let caught: unknown + try { + Reflect.get(instrumentation, "_parse").call( + instrumentation, + undefined, + () => { + throw originalError + }, + source, + undefined, + ) + } catch (error) { + caught = error + } + expect(caught).toBe(originalError) + expect(JSON.stringify(span.events)).toContain("PRIVATE_NAME") + span.status = { code: 2, message: "PRIVATE_STATUS_DESCRIPTION" } + span.attributes["custom.private"] = "PRIVATE_BANK_NUMBER" + const exported = exportedGraphQL() + expect(JSON.stringify(exported)).not.toContain("PRIVATE_") + expect(exported["graphql.error.kind"]).toBe("parse") + expect(exported.status).toEqual({ code: 2 }) + expect(exported.events[0].name).toBe("graphql.error") +}) + +it("sanitizes real validation errors including private field/name values without changing validation results", () => { + const instrumentation = configuredGraphQL() + const schema = buildSchema("type Query { visible: String }") + const document = parse("query PRIVATE_OPERATION { PRIVATE_ALIAS: PRIVATE_BANK_NAME }") + let originalResult: unknown + const original = (schema: GraphQLSchema, document: DocumentNode) => { + originalResult = validate(schema, document) + return originalResult + } + const result = Reflect.get(instrumentation, "_validate").call( + instrumentation, + undefined, + original, + schema, + document, + ) + expect(result).toBe(originalResult) + expect(result[0].message).toContain("PRIVATE_BANK_NAME") + expect(JSON.stringify(span.events)).toContain("PRIVATE_BANK_NAME") + const exported = exportedGraphQL() + expect(JSON.stringify(exported)).not.toContain("PRIVATE_") + expect(exported["graphql.error.kind"]).toBe("validation") + expect(exported.status.code).toBe(2) +}) + +it("records only known error codes and fixed summaries for aliased payload errors without changing returned application errors", () => { + const instrumentation = configuredGraphQL() + Reflect.get(instrumentation, "_createExecuteSpan").call(instrumentation, undefined, {}) + const first = new GraphQLError("PRIVATE_PROVIDER_MESSAGE", { + extensions: { code: "BAD_USER_INPUT", detail: "PRIVATE_BANK_NUMBER" }, + originalError: new Error("PRIVATE_ORIGINAL_ERROR"), + }) + const result = { + data: { + PRIVATE_ALIAS: { + errors: [ + { + message: "PRIVATE_NAME", + code: "PRIVATE_MACHINE_CODE", + path: ["PRIVATE_BANK_NAME"], + }, + ], + }, + }, + errors: [first], + } + Reflect.get(instrumentation, "_executeResponseHook").call(instrumentation, span, result) + expect(result.errors[0]).toBe(first) + expect(result.errors[0].message).toBe("PRIVATE_PROVIDER_MESSAGE") + expect(JSON.stringify(span.attributes)).not.toContain("PRIVATE_") + expect(JSON.stringify(span.events)).not.toContain("PRIVATE_") + const exported = exportedGraphQL() + expect(JSON.stringify(exported)).not.toContain("PRIVATE_") + expect(exported["graphql.error.code"]).toBe("BAD_USER_INPUT") + expect(exported["graphql.error.codes"]).toEqual(["BAD_USER_INPUT", "UNKNOWN"]) + expect(exported["graphql.error.kind"]).toBe("mixed") + expect(exported["graphql.error.count"]).toBe(2) +}) + +it("preserves a known machine code, bounds failure counts and drops arbitrary event names and attributes", () => { + const instrumentation = configuredGraphQL() + Reflect.get(instrumentation, "_createExecuteSpan").call(instrumentation, undefined, {}) + const result = { + errors: Array.from( + { length: 200 }, + () => + new GraphQLError("PRIVATE_MESSAGE", { extensions: { code: "BAD_USER_INPUT" } }), + ), + } + Reflect.get(instrumentation, "_executeResponseHook").call(instrumentation, span, result) + span.addEvent("PRIVATE_EVENT_NAME", { PRIVATE_ATTRIBUTE: "PRIVATE_BANK_NUMBER" }) + const exported = exportedGraphQL() + expect(JSON.stringify(exported)).not.toContain("PRIVATE_") + expect(exported["graphql.error.count"]).toBe(128) + expect(exported["graphql.error.code"]).toBe("BAD_USER_INPUT") + expect(exported.events).toHaveLength(1) + expect(exported.events[0].name).toBe("graphql.error") + expect(result.errors).toHaveLength(200) +}) + +it.each([ + "BANK_ACCOUNT_DUPLICATE_NUMBER", + "BANK_ACCOUNT_INVALID", + "BANK_ACCOUNT_NOT_FOUND", + "BRIDGE_KYC_PENDING", + "BRIDGE_INSUFFICIENT_FUNDS", + "BRIDGE_INVALID_PLAID_TOKEN", +])( + "preserves the closed %s banking code from a real GraphQL error without retaining its private text", + (code) => { + const instrumentation = configuredGraphQL() + Reflect.get(instrumentation, "_createExecuteSpan").call( + instrumentation, + undefined, + {}, + ) + const error = new GraphQLError("PRIVATE_BANK_NUMBER PRIVATE_NAME", { + extensions: { code }, + }) + Reflect.get(instrumentation, "_executeResponseHook").call(instrumentation, span, { + errors: [error], + }) + const exported = exportedGraphQL() + expect(exported["graphql.error.code"]).toBe(code) + expect(JSON.stringify(exported)).not.toContain("PRIVATE_") + expect(error.message).toBe("PRIVATE_BANK_NUMBER PRIVATE_NAME") + }, +) + +it("aggregates bounded top-level and multiple payload error counts and keeps both machine categories", () => { + const instrumentation = configuredGraphQL() + Reflect.get(instrumentation, "_createExecuteSpan").call(instrumentation, undefined, {}) + const errors = [ + new GraphQLError("PRIVATE_NAME", { extensions: { code: "BAD_USER_INPUT" } }), + new GraphQLError("PRIVATE_BANK_NUMBER"), + ] + const data = { + PRIVATE_ALIAS: { + errors: [ + { message: "PRIVATE_PHONE", code: "BANK_ACCOUNT_INVALID" }, + { message: "PRIVATE_ADDRESS", code: "BANK_ACCOUNT_INVALID" }, + ], + }, + OTHER_PRIVATE_ALIAS: { + errors: [{ message: "PRIVATE_BEARER", code: "PRIVATE_CODE" }], + }, + } + Reflect.get(instrumentation, "_executeResponseHook").call(instrumentation, span, { + errors, + data, + }) + const exported = exportedGraphQL() + expect(exported["graphql.error.count"]).toBe(5) + expect(exported["graphql.error.kind"]).toBe("mixed") + expect(exported["graphql.error.codes"]).toEqual([ + "BAD_USER_INPUT", + "UNKNOWN", + "BANK_ACCOUNT_INVALID", + ]) + expect(JSON.stringify(exported)).not.toContain("PRIVATE_") +}) From 70b1e32873503417016a57eb7da06ba232d0a41b Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 00:47:05 -0700 Subject: [PATCH 09/42] Keep HTTP access and tracing telemetry free of private requests --- src/servers/graphql-admin-server.ts | 44 +---- src/servers/graphql-server.ts | 57 +----- src/servers/request-log.ts | 45 +++++ src/services/http-trace-privacy.ts | 37 ++++ src/services/tracing.ts | 2 + test/flash/unit/servers/request-log.spec.ts | 175 ++++++++++++++++++ .../unit/services/http-trace-privacy.spec.ts | 64 +++++++ 7 files changed, 330 insertions(+), 94 deletions(-) create mode 100644 src/servers/request-log.ts create mode 100644 src/services/http-trace-privacy.ts create mode 100644 test/flash/unit/servers/request-log.spec.ts create mode 100644 test/flash/unit/services/http-trace-privacy.spec.ts diff --git a/src/servers/graphql-admin-server.ts b/src/servers/graphql-admin-server.ts index 352dad793..768805cd4 100644 --- a/src/servers/graphql-admin-server.ts +++ b/src/servers/graphql-admin-server.ts @@ -13,7 +13,7 @@ import express from "express" import { ApolloServerPluginDrainHttpServer } from "apollo-server-core" import { ApolloError, ApolloServer, ExpressContext } from "apollo-server-express" import { GraphQLError, GraphQLSchema } from "graphql" -import PinoHttp from "pino-http" +import { createRequestLogger } from "./request-log" import { mapError } from "@graphql/error-map" import { fieldExtensionsEstimator, simpleEstimator } from "graphql-query-complexity" @@ -164,47 +164,7 @@ export const startAdminServer = async ({ }), ) - app.use( - PinoHttp({ - logger: graphqlLogger, - wrapSerializers: true, - customProps: (req) => { - /* eslint @typescript-eslint/ban-ts-comment: "off" */ - // @ts-ignore-next-line no-implicit-any error - const account = req["gqlContext"]?.domainAccount - return { - // @ts-ignore-next-line no-implicit-any error - "body": req["body"], - // @ts-ignore-next-line no-implicit-any error - "token.sub": req["token"]?.sub, - // @ts-ignore-next-line no-implicit-any error - "gqlContext.user": req["gqlContext"]?.user, - // @ts-ignore-next-line no-implicit-any error - "gqlContext.domainAccount:": { - id: account?.id, - createdAt: account?.createdAt, - defaultWalletId: account?.defaultWalletId, - level: account?.level, - status: account?.status, - displayCurrency: account?.displayCurrency, - }, - } - }, - autoLogging: { - ignore: (req) => req.url === "/healthz", - }, - serializers: { - res: (res) => ({ statusCode: res.statusCode }), - req: (req) => ({ - id: req.id, - method: req.method, - url: req.url, - remoteAddress: req.remoteAddress, - // headers: req.headers, - }), - }, - }), - ) + app.use(createRequestLogger(graphqlLogger)) app.use(idempotencyMiddleware) // TODO: only needed for public endpoint diff --git a/src/servers/graphql-server.ts b/src/servers/graphql-server.ts index 9fdec0de8..d18621d7c 100644 --- a/src/servers/graphql-server.ts +++ b/src/servers/graphql-server.ts @@ -10,7 +10,7 @@ import { GetVerificationKey, expressjwt } from "express-jwt" import { GraphQLError, GraphQLSchema } from "graphql" import { rule } from "graphql-shield" import jsonwebtoken from "jsonwebtoken" -import PinoHttp from "pino-http" +import { createRequestLogger } from "./request-log" import { mapError } from "@graphql/error-map" @@ -29,7 +29,7 @@ import { parseUnknownDomainErrorFromUnknown } from "@domain/shared" import { MAXIMUM_QUERY_COMPLEXITY, createComplexityPlugin } from "./plugins/complexity" import authRouter from "./authorization" -import consentLogRouter, { redactConsentBodyForLog } from "./consent-log" +import consentLogRouter from "./consent-log" import kratosCallback from "./event-handlers/kratos" import { apiKeyRateLimitMiddleware } from "./middlewares/api-key-rate-limit" import healthzHandler from "./middlewares/healthz" @@ -153,59 +153,12 @@ export const startApolloServer = async ({ }), ) - app.use( - PinoHttp({ - logger: graphqlLogger, - wrapSerializers: true, - customProps: (req, res) => { - /* eslint @typescript-eslint/ban-ts-comment: "off" */ - // @ts-ignore-next-line no-implicit-any error - const account = req["gqlContext"]?.domainAccount - return { - // pino-http evaluates customProps again at response finish, after - // the consent router's express.json() has populated req.body — so - // the raw invite token would land in the access logs without this - // redaction. Guarded by tests in - // test/flash/unit/servers/consent-log.spec.ts. - // @ts-ignore-next-line no-implicit-any error - "body": redactConsentBodyForLog(res, req.body), - // @ts-ignore-next-line no-implicit-any error - "token.sub": req["token"]?.sub, - // @ts-ignore-next-line no-implicit-any error - "gqlContext.user": req["gqlContext"]?.user, - // @ts-ignore-next-line no-implicit-any error - "gqlContext.domainAccount:": { - id: account?.id, - createdAt: account?.createdAt, - defaultWalletId: account?.defaultWalletId, - level: account?.level, - status: account?.status, - displayCurrency: account?.displayCurrency, - }, - } - }, - autoLogging: { - ignore: (req) => req.url === "/healthz", - }, - serializers: { - res: (res) => ({ statusCode: res.statusCode }), - req: (req) => ({ - id: req.id, - method: req.method, - url: req.url, - remoteAddress: req.remoteAddress, - // headers: req.headers, - }), - }, - }), - ) + app.use(createRequestLogger(graphqlLogger)) // Public consent-evidence intake from the getflash.io/invite page // (ENG-568). Anonymous callers, so it mounts only on the public server and - // BEFORE the JWT middleware below — but AFTER PinoHttp, so a public - // unauthenticated write path shows up in the access logs. The logged body - // for /consent requests is redacted (redactConsentBodyForLog above) so the - // raw invite token never reaches the logs. + // BEFORE the JWT middleware below — but AFTER the access logger, so this + // public write path records status/timing with no request body or token. if (type === "main") { app.use("/consent", consentLogRouter) } diff --git a/src/servers/request-log.ts b/src/servers/request-log.ts new file mode 100644 index 000000000..3d56a32bb --- /dev/null +++ b/src/servers/request-log.ts @@ -0,0 +1,45 @@ +import PinoHttp from "pino-http" +import type { Logger } from "pino" + +const methods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) +const routes = new Set(["/graphql", "/healthz", "/consent", "/consent/log"]) + +const routeForLog = (value: unknown): string => { + if (typeof value !== "string" || value.length > 16_384) return "[other route]" + try { + const path = new URL(value, "http://request.invalid").pathname + return routes.has(path) ? path : "[other route]" + } catch { + return "[other route]" + } +} + +/** Access telemetry contains no request body, query values, headers or user. */ +export const createRequestLogger = (logger: Logger) => + PinoHttp({ + logger, + wrapSerializers: true, + autoLogging: { ignore: (req) => routeForLog(req.url) === "/healthz" }, + customSuccessMessage: () => "request completed", + customErrorMessage: () => "request errored", + serializers: { + req: (request) => ({ + ...(typeof request.id === "number" && Number.isSafeInteger(request.id) + ? { id: request.id } + : {}), + method: methods.has(request.method) ? request.method : "OTHER", + url: routeForLog(request.url), + }), + res: (response) => ({ + statusCode: + Number.isInteger(response.statusCode) && + response.statusCode >= 100 && + response.statusCode <= 599 + ? response.statusCode + : undefined, + }), + // Pino's standard error serializer copies enumerable Axios config/body + // fields. Access failures need a failure marker and HTTP status only. + err: () => ({ type: "HttpRequestError" }), + }, + }) diff --git a/src/services/http-trace-privacy.ts b/src/services/http-trace-privacy.ts new file mode 100644 index 000000000..b6c241d1e --- /dev/null +++ b/src/services/http-trace-privacy.ts @@ -0,0 +1,37 @@ +import { SpanKind, SpanStatusCode, type Attributes } from "@opentelemetry/api" +import type { Span as SdkSpan } from "@opentelemetry/sdk-trace-base" + +const methods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) + +/** Project only closed HTTP facts before a span reaches any exporter. */ +export const sanitizeHTTPSpanForExport = (span: SdkSpan): void => { + if (span.instrumentationLibrary.name !== "@opentelemetry/instrumentation-http") return + const method = span.attributes["http.method"] ?? span.attributes["http.request.method"] + const rawStatus = + span.attributes["http.status_code"] ?? span.attributes["http.response.status_code"] + const status = + typeof rawStatus === "number" && + Number.isInteger(rawStatus) && + rawStatus >= 100 && + rawStatus <= 599 + ? rawStatus + : undefined + const safe: Attributes = { + "http.method": typeof method === "string" && methods.has(method) ? method : "OTHER", + } + if (status !== undefined) safe["http.status_code"] = status + for (const key of Object.keys(span.attributes)) delete span.attributes[key] + Object.assign(span.attributes, safe) + span.name = + span.kind === SpanKind.CLIENT + ? "http.client" + : span.kind === SpanKind.SERVER + ? "http.server" + : "http.other" + span.status = { + code: + span.status.code === SpanStatusCode.ERROR ? SpanStatusCode.ERROR : span.status.code, + } + span.events.splice(0, span.events.length) + span.links.splice(0, span.links.length) +} diff --git a/src/services/tracing.ts b/src/services/tracing.ts index 9efe9a7d3..b366e47a6 100644 --- a/src/services/tracing.ts +++ b/src/services/tracing.ts @@ -32,6 +32,7 @@ import { graphQLErrorSummary, sanitizeGraphQLSpanForExport, } from "./graphql-trace-privacy" +import { sanitizeHTTPSpanForExport } from "./http-trace-privacy" type ExtendedException = Exclude & { level?: ErrorLevel } @@ -169,6 +170,7 @@ class SpanProcessorWrapper extends SimpleSpanProcessor { // Do not export partially redacted query bodies from any GraphQL span. delete span.attributes["graphql.source"] sanitizeGraphQLSpanForExport(span) + sanitizeHTTPSpanForExport(span) super.onEnd(span) } } diff --git a/test/flash/unit/servers/request-log.spec.ts b/test/flash/unit/servers/request-log.spec.ts new file mode 100644 index 000000000..4e75b4936 --- /dev/null +++ b/test/flash/unit/servers/request-log.spec.ts @@ -0,0 +1,175 @@ +import { createServer, request as httpRequest } from "http" + +import express from "express" +import pino from "pino" + +import { createRequestLogger } from "@servers/request-log" + +const secret = "fixture-private-token-and-personal-data" +const payload = { + query: `mutation ${secret.replaceAll("-", "_")} { privateField } # ${secret}`, + variables: { + input: { + code: secret, + authToken: secret, + bankAccountNumber: secret, + selfieUrl: secret, + }, + }, +} + +const exercise = async ( + path: string, + rawBody = JSON.stringify(payload), + status = 200, + requestID?: string, +) => { + const records: string[] = [] + const logger = pino( + { base: null, timestamp: false }, + { write: (value: string) => records.push(value) }, + ) + const app = express() + if (requestID) + app.use((req, _res, next) => { + req.id = requestID + next() + }) + app.use(createRequestLogger(logger)) + app.use(express.json()) + let observed: unknown + app.use((req, res) => { + // Source customProps previously copied all of this after body parsing. + Object.assign(req, { + gqlContext: { user: { phone: secret, deviceTokens: [secret] } }, + token: { sub: secret }, + }) + observed = req.body + if (status >= 500) { + Object.assign(res, { + err: Object.assign(new Error(secret), { + config: { headers: { Authorization: secret }, data: payload }, + response: { data: secret }, + }), + }) + } + res.status(status).json({ ok: status < 400 }) + }) + app.use( + ( + error: unknown, + _req: express.Request, + res: express.Response, + // Express identifies error handlers by their four declared parameters. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _next: express.NextFunction, + ) => { + Object.assign(res, { err: error }) + res.status(400).json({ error: "invalid request" }) + }, + ) + const server = createServer(app) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + try { + const address = server.address() + if (!address || typeof address === "string") throw new Error("Missing fixture port") + const responseStatus = await new Promise((resolve, reject) => { + const request = httpRequest( + { + hostname: "127.0.0.1", + port: address.port, + path, + method: "POST", + agent: false, + headers: { + "content-type": "application/json", + "authorization": `Bearer ${secret}`, + "appcheck": secret, + "cookie": `session=${secret}`, + "x-api-key": secret, + "content-length": Buffer.byteLength(rawBody), + }, + }, + (response) => { + response.resume() + response.on("error", reject) + response.on("end", () => resolve(response.statusCode ?? 0)) + }, + ) + request.on("error", reject) + request.end(rawBody) + }) + await new Promise((resolve) => setImmediate(resolve)) + return { + records: records.map((line) => JSON.parse(line)), + raw: records.join(""), + responseStatus, + observed, + } + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ) + } +} + +it("logs status and fixed route after parsing without credentials, body, query values or context", async () => { + const result = await exercise( + `/graphql?authToken=${secret}&variables=${encodeURIComponent(JSON.stringify(payload))}`, + ) + expect(result.responseStatus).toBe(200) + expect(result.observed).toEqual(payload) + expect(result.records).toHaveLength(1) + expect(result.records[0]).toMatchObject({ + req: { method: "POST", url: "/graphql" }, + res: { statusCode: 200 }, + msg: "request completed", + }) + expect(result.raw).not.toContain(secret) + expect(result.records[0]).not.toHaveProperty("body") + expect(result.records[0]).not.toHaveProperty("gqlContext.user") + expect(result.records[0].req).not.toHaveProperty("headers") +}) + +it.each(["/consent", "/consent/", "/consent/log", "/consent-status", `/${secret}`])( + "never captures parsed consent/unknown-route body on %s", + async (path) => { + const result = await exercise(path) + expect(result.responseStatus).toBe(200) + expect(result.observed).toEqual(payload) + expect(result.records).toHaveLength(1) + expect(result.raw).not.toContain(secret) + expect(result.records[0].req.url).toBe( + path === "/consent" || path === "/consent/log" ? path : "[other route]", + ) + }, +) + +it("does not serialize Axios-like error payloads or stacks on failure", async () => { + const result = await exercise("/graphql", JSON.stringify(payload), 500) + expect(result.responseStatus).toBe(500) + expect(result.observed).toEqual(payload) + expect(result.records[0]).toMatchObject({ + err: { type: "HttpRequestError" }, + res: { statusCode: 500 }, + msg: "request errored", + }) + expect(result.raw).not.toContain(secret) + expect(result.records[0].err).not.toHaveProperty("stack") +}) + +it("never serializes malformed JSON retained by body-parser errors", async () => { + const result = await exercise("/graphql", `{"private":"${secret}" invalid`) + expect(result.responseStatus).toBe(400) + expect(result.records).toHaveLength(1) + expect(result.raw).not.toContain(secret) +}) + +it("does not trust an arbitrary preassigned request identifier", async () => { + const requestID = "fixture-private-request-identifier" + const result = await exercise("/graphql", JSON.stringify(payload), 200, requestID) + expect(result.responseStatus).toBe(200) + expect(result.records).toHaveLength(1) + expect(result.records[0].req).not.toHaveProperty("id") + expect(result.raw).not.toContain(requestID) +}) diff --git a/test/flash/unit/services/http-trace-privacy.spec.ts b/test/flash/unit/services/http-trace-privacy.spec.ts new file mode 100644 index 000000000..7687ebc9b --- /dev/null +++ b/test/flash/unit/services/http-trace-privacy.spec.ts @@ -0,0 +1,64 @@ +import { SpanKind, SpanStatusCode } from "@opentelemetry/api" +import { getOutgoingRequestAttributes } from "@opentelemetry/instrumentation-http/build/src/utils" +import type { Span as SdkSpan } from "@opentelemetry/sdk-trace-base" + +import { sanitizeHTTPSpanForExport } from "@services/http-trace-privacy" + +const secret = "fixture-private-url-token" + +const span = (name: string, kind: SpanKind, attributes: Record) => + ({ + name, + kind, + instrumentationLibrary: { name: "@opentelemetry/instrumentation-http" }, + attributes, + status: { code: SpanStatusCode.ERROR, message: secret }, + events: [{ name: secret, time: [0, 0], attributes: { "exception.message": secret } }], + links: [{ attributes: { private: secret } }], + }) as unknown as SdkSpan + +it("removes actual pinned HTTP plugin URL, target, host, headers and exception data", () => { + const options = { hostname: "erp.example", port: 443, component: "https" } + const request = { + hostname: "erp.example", + path: `/account/${secret}?token=${secret}`, + method: "POST", + headers: { "host": "erp.example", "user-agent": secret }, + } + const attributes = getOutgoingRequestAttributes(request, options) + expect(JSON.stringify(attributes)).toContain(secret) + const target = span(`POST /account/${secret}`, SpanKind.CLIENT, { + ...attributes, + "http.status_code": 502, + "http.request.header.authorization": secret, + }) + sanitizeHTTPSpanForExport(target) + expect(target.name).toBe("http.client") + expect(target.attributes).toEqual({ "http.method": "POST", "http.status_code": 502 }) + expect(target.status).toEqual({ code: SpanStatusCode.ERROR }) + expect(target.events).toEqual([]) + expect(target.links).toEqual([]) + expect(JSON.stringify(request)).toContain(secret) // The real outgoing request remains untouched. + expect(JSON.stringify(target)).not.toContain(secret) +}) + +it("closes server method, status, URL and baggage data without changing unrelated spans", () => { + const target = span(`POST ${secret}`, SpanKind.SERVER, { + "http.method": secret, + "http.target": `/graphql?code=${secret}`, + "url.full": `https://api.example/graphql?code=${secret}`, + "http.status_code": secret, + "enduser.id": secret, + "http.request.header.cookie": secret, + }) + sanitizeHTTPSpanForExport(target) + expect(target.name).toBe("http.server") + expect(target.attributes).toEqual({ "http.method": "OTHER" }) + expect(JSON.stringify(target)).not.toContain(secret) + + const unrelated = span(secret, SpanKind.INTERNAL, { "custom.private": secret }) + Object.assign(unrelated.instrumentationLibrary, { name: "fixture.custom" }) + sanitizeHTTPSpanForExport(unrelated) + expect(unrelated.name).toBe(secret) + expect(unrelated.attributes["custom.private"]).toBe(secret) +}) From 9be395e8ead28876e3ecb60f90ce2263051eba40 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 00:28:39 -0700 Subject: [PATCH 10/42] Bound registration and remaining ERP failure telemetry --- docs/registration-erp-telemetry-privacy.md | 15 ++ ...reate-account-from-registration-payload.ts | 20 +- src/servers/event-handlers/kratos.ts | 78 +++++--- src/services/frappe/ErpNext.ts | 183 ++++-------------- src/services/frappe/telemetry.ts | 15 ++ .../registration-trace-privacy.spec.ts | 142 ++++++++++++++ .../kratos-preregistration-route.spec.ts | 141 +++++++++++++- .../unit/services/frappe/ErpNext.spec.ts | 16 +- .../frappe/request-telemetry-privacy.spec.ts | 175 ++++++++++++++++- 9 files changed, 590 insertions(+), 195 deletions(-) create mode 100644 docs/registration-erp-telemetry-privacy.md create mode 100644 test/flash/unit/app/authentication/registration-trace-privacy.spec.ts diff --git a/docs/registration-erp-telemetry-privacy.md b/docs/registration-erp-telemetry-privacy.md new file mode 100644 index 000000000..d31041800 --- /dev/null +++ b/docs/registration-erp-telemetry-privacy.md @@ -0,0 +1,15 @@ +# Registration and remaining ERP failure privacy + +This leaf is based on `c01beb680`, after the central argument/GraphQL, IBEX and Redis/identity-banking telemetry fixes. Its backend source baseline remains `7402dd7fcf49e378e4af5189232dffb76a4564d5`; integration with newer provisioning/wallet changes requires their separate contract review. No deployment, provider request, real secret or exported span was used here. + +The post-registration helper no longer serializes `registration.body` before validating the callback key. It records only the fixed expected schema and outcome. Invalid user/phone failures and downstream account errors use fixed exception summaries instead of raw identifiers, phone numbers or original provider/error messages. The original returned error/account objects and provider inputs remain unchanged. + +The adjacent Kratos pre/post-registration application routes likewise stop logging request bodies, phone suffixes, caller-selected schemas or raw error objects. Their failure records contain a closed operation/outcome, the fixed expected schema and HTTP status only. Existing callback authorization, validation/refusal classes, 401/400/500 statuses, pre-registration Kratos message IDs and success responses are preserved. Unexpected rejected service work is contained inside a generic 500 boundary so it cannot fall through the generic tracing wrapper with a private exception. The post-persist 500 response no longer interpolates the private error detail; its failure status and fixed operation text remain. This is application-route logging; it is separate from the public/admin PinoHttp access-log work and does not assert those access loggers exposed Appcheck headers. + +Every remaining raw Axios/provider-response failure recorder in `ErpNext.ts` now uses the same closed safe boundary as identity/bank methods. This covers cashout draft/submit, journal deletion, exchange rate/settings/referral reads, financial history, Bridge ledger upsert/completion/find and both uncredited-history entry points. Required provider authentication, request bodies, URLs and successful results are unchanged. A failed financial read remains an error, never a zero/default. The existing legacy completed-burn lower-bound rule still skips a missing final amount and returns its original result, but its informational log no longer includes the provider row identifier. + +Domain error classes and refusal decisions are preserved. Duplicate ledger classification still reads the private provider response locally, recovers the single raced row and performs the original guarded update; the response is not logged or returned as an error message. Financial shape errors use fixed text instead of copying provider row identifiers into errors. A failure never becomes a completed/paid/refunded claim. + +Validation: all 19 reviewed mocked suites pass, **428 tests**, under Node 24 with the integration runner's `safeEnvironment` and network guard. Real validator suites that initially failed unrelated environment bootstrap also pass in that guarded fake configuration; no private `.env` file is sourced. Watchman is disabled for the run so its unrelated Unix socket does not violate the I/O guard. Focused tests check unauthorized body non-inspection, callback refusal/throw privacy, required provider body/auth preservation, all affected ERP failure paths, duplicate-transfer recovery and financial fail-closed/lower-bound behavior. All three TypeScript configurations, scoped ESLint and `git diff --check` pass. + +This is not a guarantee for unrelated explicit attributes, other Frappe/Bridge service logs, generic JSON-wrapped errors, HTTP URLs/access logging, historical spans or collector retention. Original registration domain errors deliberately retain their application identity and may still contain input text internally; the authorized route boundary does not serialize them. Source-boundary protection does not establish historical credential deletion or rotation. diff --git a/src/app/authentication/create-account-from-registration-payload.ts b/src/app/authentication/create-account-from-registration-payload.ts index 1b0eab5a8..aa94adde8 100644 --- a/src/app/authentication/create-account-from-registration-payload.ts +++ b/src/app/authentication/create-account-from-registration-payload.ts @@ -25,24 +25,27 @@ export const createAccountFromRegistrationPayload = async ({ } }): Promise => { addAttributesToCurrentSpan({ - "registration.body": JSON.stringify(body), + "registration.schema": SchemaIdType.PhoneNoPasswordV0, + "registration.outcome": "received", }) const isValidKey = CallbackSecretValidator(KRATOS_CALLBACK_API_KEY).authorize(secret) if (isValidKey instanceof Error) { + addAttributesToCurrentSpan({ "registration.outcome": "callback_rejected" }) return isValidKey } const regPayloadValidator = RegistrationPayloadValidator(SchemaIdType.PhoneNoPasswordV0) const regPayload = regPayloadValidator.validate(body) if (regPayload instanceof Error) { + addAttributesToCurrentSpan({ "registration.outcome": "invalid_payload" }) if (regPayload instanceof InvalidUserId || regPayload instanceof InvalidPhoneNumber) { recordExceptionInCurrentSpan({ - error: regPayload, + error: new Error("Registration payload validation failed"), level: ErrorLevel.Critical, attributes: { - userIdRaw: body.identity_id, - phoneRaw: body.phone, + "registration.reason": + regPayload instanceof InvalidUserId ? "invalid_identity" : "invalid_phone", }, }) } @@ -56,14 +59,13 @@ export const createAccountFromRegistrationPayload = async ({ phoneMetadata, }) if (account instanceof Error) { + addAttributesToCurrentSpan({ "registration.outcome": "creation_failed" }) recordExceptionInCurrentSpan({ - error: account, + error: new Error("Account registration failed"), level: ErrorLevel.Critical, - attributes: { - userId, - phone, - }, }) + } else { + addAttributesToCurrentSpan({ "registration.outcome": "created" }) } return account diff --git a/src/servers/event-handlers/kratos.ts b/src/servers/event-handlers/kratos.ts index 10fa450c0..5a249084e 100644 --- a/src/servers/event-handlers/kratos.ts +++ b/src/servers/event-handlers/kratos.ts @@ -3,7 +3,6 @@ import express from "express" import { wrapAsyncToRunInSpan } from "@services/tracing" import { baseLogger } from "@services/logger" -import { maskPhone } from "@services/alerts/ops-events" import { Authentication } from "@app" @@ -28,6 +27,23 @@ const errorResponseMessages: { [key: string]: string } = { InvalidPhoneNumber: "invalid phone", } +const recordCallbackFailure = ( + operation: "preregistration" | "registration", + outcome: + | "callback_rejected" + | "phone_registered" + | "phone_not_allowed" + | "invalid_payload" + | "validation_failed" + | "creation_failed", + status: 400 | 401 | 500, + level: "error" | "warn" = "error", +) => + baseLogger[level]( + { operation, expectedSchema: "phone_no_password_v0", outcome, status }, + "Kratos callback failed", + ) + const kratosCallback = express.Router({ caseSensitive: true }) kratosCallback.use(cors({ origin: true, credentials: true })) @@ -48,22 +64,26 @@ kratosCallback.post( fn: async (req: express.Request, res: express.Response) => { const secret = req.headers.authorization const body = req.body ?? {} - const phone = typeof body.phone === "string" ? maskPhone(body.phone) : undefined - - const result = await Authentication.validatePreRegistrationPayload({ - secret, - body, - }) + let result: Awaited< + ReturnType + > + try { + result = await Authentication.validatePreRegistrationPayload({ secret, body }) + } catch { + recordCallbackFailure("preregistration", "validation_failed", 500) + res.status(500).json(kratosHookRejection(KratosHookMessageId.InternalError)) + return + } if (result instanceof Error) { switch (true) { case result instanceof SecretForAuthNCallbackError: - baseLogger.error({ err: result.name }, "preregistration: bad callback secret") + recordCallbackFailure("preregistration", "callback_rejected", 401) res.status(401).json(kratosHookRejection(KratosHookMessageId.Unauthorized)) return case result instanceof PhoneAlreadyRegisteredError: - baseLogger.warn({ phone, rejection: result.name }, "preregistration rejected") + recordCallbackFailure("preregistration", "phone_registered", 400, "warn") res .status(400) .json(kratosHookRejection(KratosHookMessageId.PhoneAlreadyRegistered)) @@ -72,7 +92,7 @@ kratosCallback.post( // Every carrier-metadata failure extends PhoneMetadataValidationError. case result instanceof InvalidPhoneNumber: case result instanceof PhoneMetadataValidationError: - baseLogger.warn({ phone, rejection: result.name }, "preregistration rejected") + recordCallbackFailure("preregistration", "phone_not_allowed", 400, "warn") res.status(400).json(kratosHookRejection(KratosHookMessageId.PhoneNotAllowed)) return @@ -80,18 +100,12 @@ kratosCallback.post( // what the api expects (missing field, unknown schema_id). Kratos // config and the api disagree, so every sign-up is failing. case result instanceof RegistrationPayloadValidationError: - baseLogger.error( - { phone, rejection: result.name, schemaId: body.schema_id }, - "preregistration: hook payload rejected, Kratos config and api disagree", - ) + recordCallbackFailure("preregistration", "invalid_payload", 400) res.status(400).json(kratosHookRejection(KratosHookMessageId.PayloadInvalid)) return default: - baseLogger.error( - { err: result, phone }, - "preregistration: unexpected error, sign-up aborted", - ) + recordCallbackFailure("preregistration", "validation_failed", 500) res.status(500).json(kratosHookRejection(KratosHookMessageId.InternalError)) return } @@ -112,29 +126,35 @@ kratosCallback.post( const secret = req.headers.authorization const body = req.body - const account = await Authentication.createAccountFromRegistrationPayload({ - secret, - body, - }) + let account: Awaited< + ReturnType + > + try { + account = await Authentication.createAccountFromRegistrationPayload({ + secret, + body, + }) + } catch { + recordCallbackFailure("registration", "creation_failed", 500) + res.status(500).send("error createAccountWithPhoneIdentifier") + return + } if (account instanceof Error) { const message = errorResponseMessages[account.name] || "unknown error" switch (true) { case account instanceof SecretForAuthNCallbackError: - baseLogger.error(message) + recordCallbackFailure("registration", "callback_rejected", 401) res.status(401).send(message) return case account instanceof RegistrationPayloadValidationError: - baseLogger.error(body, message) + recordCallbackFailure("registration", "invalid_payload", 400) res.status(400).send(message) return default: - baseLogger.error( - { account, phone: body.phone }, - `error createAccountWithPhoneIdentifier`, - ) - res.status(500).send(`error createAccountWithPhoneIdentifier: ${account}`) + recordCallbackFailure("registration", "creation_failed", 500) + res.status(500).send("error createAccountWithPhoneIdentifier") return } } diff --git a/src/services/frappe/ErpNext.ts b/src/services/frappe/ErpNext.ts index 049a3c2b0..049a83374 100644 --- a/src/services/frappe/ErpNext.ts +++ b/src/services/frappe/ErpNext.ts @@ -2,7 +2,6 @@ import ValidOffer from "@app/offers/ValidOffer" import { FrappeConfig } from "@config" import { JMDAmount, USDTAmount, Validated } from "@domain/shared" import { baseLogger } from "@services/logger" -import { recordExceptionInCurrentSpan } from "@services/tracing" import axios, { isAxiosError } from "axios" import { @@ -349,13 +348,8 @@ export class ErpNext { ) return response.data.data.name as CashoutId } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error({ err, responseData }, "Error drafting Cashout in ERPNext") - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new CashoutDraftError(err) + recordErpRequestFailure(err, "draftCashout") + return new CashoutDraftError("ERPNext request failed") } } @@ -368,13 +362,8 @@ export class ErpNext { ) return true } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error({ err, responseData }, "Error submitting Cashout in ERPNext") - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new CashoutSubmitError(err) + recordErpRequestFailure(err, "submitCashout") + return new CashoutSubmitError("ERPNext request failed") } } @@ -384,13 +373,8 @@ export class ErpNext { headers: this.headers, }) } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error({ err, responseData, jeName }, "Error deleting JE in ERPNext") - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new JournalEntryDeleteError(err) + recordErpRequestFailure(err, "delete") + return new JournalEntryDeleteError("ERPNext request failed") } } @@ -894,19 +878,12 @@ export class ErpNext { } const jmdRate = JMDAmount.dollars(rate) - if (jmdRate instanceof Error) return new ExchangeRateQueryError(jmdRate) + if (jmdRate instanceof Error) + return new ExchangeRateQueryError("Invalid exchange rate in ERPNext response") return jmdRate } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData }, - "Error querying Currency Exchange from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new ExchangeRateQueryError(err) + recordErpRequestFailure(err, "getCashoutExchangeRate") + return new ExchangeRateQueryError("ERPNext request failed") } } @@ -925,16 +902,8 @@ export class ErpNext { return new FygaroSettingsQueryError("No data in Fygaro Settings response") return data as FygaroSettingsDoc } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData }, - "Error querying Fygaro Settings from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new FygaroSettingsQueryError(err) + recordErpRequestFailure(err, "getFygaroSettings") + return new FygaroSettingsQueryError("ERPNext request failed") } } @@ -969,13 +938,8 @@ export class ErpNext { return new FeeDiscountQueryError("No data in Fee Discount response") return data as FeeDiscountDoc[] } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error({ err, responseData }, "Error querying Fee Discount from ERPNext") - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new FeeDiscountQueryError(err) + recordErpRequestFailure(err, "getFeeDiscounts") + return new FeeDiscountQueryError("ERPNext request failed") } } @@ -994,16 +958,8 @@ export class ErpNext { return new ReferralSettingsQueryError("No data in Referral Settings response") return data as ReferralSettingsDoc } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData }, - "Error querying Referral Settings from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new ReferralSettingsQueryError(err) + recordErpRequestFailure(err, "getReferralSettings") + return new ReferralSettingsQueryError("ERPNext request failed") } } @@ -1033,16 +989,8 @@ export class ErpNext { ) return data as ReferralPayoutAllowlistDoc[] } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData }, - "Error querying Referral Payout Allowlist from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new ReferralPayoutAllowlistQueryError(err) + recordErpRequestFailure(err, "getReferralPayoutAllowlist") + return new ReferralPayoutAllowlistQueryError("ERPNext request failed") } } @@ -1068,16 +1016,8 @@ export class ErpNext { return new AllowedCountryQueryError("No data in Allowed Country response") return data as AllowedCountryDoc[] } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData }, - "Error querying Allowed Country from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new AllowedCountryQueryError(err) + recordErpRequestFailure(err, "getAllowedCountries") + return new AllowedCountryQueryError("ERPNext request failed") } } @@ -1226,13 +1166,13 @@ export class ErpNext { // row, not silently contribute nothing to the sum. if (row.amount == null) { return new FygaroTopupHistoryQueryError( - `Missing amount on ${row.request_id ?? ""}`, + "Missing amount in ERPNext top-up history", ) } const cents = Math.round(Number(row.amount) * 100) if (!Number.isFinite(cents)) { return new FygaroTopupHistoryQueryError( - `Non-numeric amount on ${row.request_id ?? ""}`, + "Non-numeric amount in ERPNext top-up history", ) } sumCents += cents @@ -1251,16 +1191,8 @@ export class ErpNext { } return { grossCents: sumCents, oldestCountedMs } } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, accountId }, - "Error summing Fygaro top-up history from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new FygaroTopupHistoryQueryError(err) + recordErpRequestFailure(err, "sumFygaroTopupGrossCentsSince") + return new FygaroTopupHistoryQueryError("ERPNext request failed") } } @@ -1408,16 +1340,8 @@ export class ErpNext { return true } } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, requestId }, - "Error upserting Bridge Transfer Request in ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new BridgeTransferRequestUpsertError(err) + recordErpRequestFailure(err, "upsertBridgeTransferRequest") + return new BridgeTransferRequestUpsertError("ERPNext request failed") } } @@ -1480,16 +1404,8 @@ export class ErpNext { ) return "completed" } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, txHash }, - "Error promoting Bridge Transfer Request to Completed in ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new BridgeTransferRequestUpsertError(err) + recordErpRequestFailure(err, "completeBridgeTopupByTxHash") + return new BridgeTransferRequestUpsertError("ERPNext request failed") } } @@ -1588,7 +1504,10 @@ export class ErpNext { }[]) { if (row.final_amount == null || String(row.final_amount).trim() === "") { baseLogger.info( - { requestId: row.request_id }, + { + operation: "sumFygaroCompletedNetCentsSince", + outcome: "missing_final_amount", + }, "Fygaro burn: Completed row without final_amount skipped", ) continue @@ -1596,23 +1515,15 @@ export class ErpNext { const cents = Math.round(Number(row.final_amount) * 100) if (!Number.isFinite(cents)) { return new FygaroTopupHistoryQueryError( - `Non-numeric final_amount on ${row.request_id ?? ""}`, + "Non-numeric final amount in ERPNext top-up history", ) } sumCents += cents } return sumCents } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData }, - "Error summing completed Fygaro top-ups from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new FygaroTopupHistoryQueryError(err) + recordErpRequestFailure(err, "sumFygaroCompletedNetCentsSince") + return new FygaroTopupHistoryQueryError("ERPNext request failed") } } @@ -1719,16 +1630,8 @@ export class ErpNext { } return out } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, lastSeenAtOp }, - "Error listing uncredited Fygaro top-ups from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new FygaroTopupHistoryQueryError(err) + recordErpRequestFailure(err, "queryUncreditedFygaroTopups") + return new FygaroTopupHistoryQueryError("ERPNext request failed") } } @@ -1803,16 +1706,8 @@ export class ErpNext { const doc = resp.data?.data?.[0] return doc?.name ? doc : undefined } catch (err) { - const responseData = isAxiosError(err) ? err.response?.data : undefined - baseLogger.error( - { err, responseData, requestId }, - "Error querying Bridge Transfer Request from ERPNext", - ) - recordExceptionInCurrentSpan({ - error: err, - attributes: { "erpnext.exception": responseData?.exception }, - }) - return new BridgeTransferRequestUpsertError(err) + recordErpRequestFailure(err, "findBridgeTransferRequest") + return new BridgeTransferRequestUpsertError("ERPNext request failed") } } diff --git a/src/services/frappe/telemetry.ts b/src/services/frappe/telemetry.ts index 881d091fb..ca683d4ea 100644 --- a/src/services/frappe/telemetry.ts +++ b/src/services/frappe/telemetry.ts @@ -17,6 +17,21 @@ const transportCodes = new Set([ ]) type ErpTelemetryOperation = + | "draftCashout" + | "submitCashout" + | "delete" + | "getCashoutExchangeRate" + | "getFygaroSettings" + | "getFeeDiscounts" + | "getReferralSettings" + | "getReferralPayoutAllowlist" + | "getAllowedCountries" + | "sumFygaroTopupGrossCentsSince" + | "upsertBridgeTransferRequest" + | "completeBridgeTopupByTxHash" + | "sumFygaroCompletedNetCentsSince" + | "queryUncreditedFygaroTopups" + | "findBridgeTransferRequest" | "postUpgradeRequest" | "getAccountUpgradeRequestList" | "getAccountUpgradeRequestById" diff --git a/test/flash/unit/app/authentication/registration-trace-privacy.spec.ts b/test/flash/unit/app/authentication/registration-trace-privacy.spec.ts new file mode 100644 index 000000000..6bd6806ad --- /dev/null +++ b/test/flash/unit/app/authentication/registration-trace-privacy.spec.ts @@ -0,0 +1,142 @@ +jest.mock("@config", () => ({ + KRATOS_CALLBACK_API_KEY: "PRIVATE_CALLBACK_SECRET", + getDefaultAccountsConfig: () => ({ fixture: true }), +})) +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) +jest.mock("@services/kratos", () => ({ + SchemaIdType: { PhoneNoPasswordV0: "phone_no_password_v0" }, +})) +jest.mock("@app/accounts", () => ({ createAccountWithPhoneIdentifier: jest.fn() })) +const mockValidate = jest.fn() +jest.mock("@domain/authentication/registration-payload-validator", () => ({ + RegistrationPayloadValidator: () => ({ validate: mockValidate }), +})) + +import { createAccountFromRegistrationPayload } from "@app/authentication/create-account-from-registration-payload" +import { createAccountWithPhoneIdentifier } from "@app/accounts" +import { + InvalidSecretForAuthNCallbackError, + UnsupportedSchemaTypeError, +} from "@domain/authentication/errors" +import { InvalidPhoneNumber, InvalidUserId } from "@domain/errors" +import { + addAttributesToCurrentSpan, + recordExceptionInCurrentSpan, +} from "@services/tracing" + +const body = { + identity_id: "PRIVATE_USER_ID", + phone: "PRIVATE_PHONE", + schema_id: "PRIVATE_INPUT_SCHEMA", + password: "PRIVATE_PASSWORD", +} +const create = jest.mocked(createAccountWithPhoneIdentifier) +function expectNoPrivateTelemetry() { + const value = JSON.stringify( + [ + jest.mocked(addAttributesToCurrentSpan).mock.calls, + jest.mocked(recordExceptionInCurrentSpan).mock.calls, + ], + (_key, entry) => + entry instanceof Error + ? { ...entry, message: entry.message, stack: entry.stack } + : entry, + ) + expect(value).not.toContain("PRIVATE_") + expect(value).not.toContain("registration.body") +} +beforeEach(() => { + jest.clearAllMocks() + mockValidate.mockReturnValue({ + userId: "PRIVATE_USER_ID", + phone: "PRIVATE_PHONE", + phoneMetadata: { fixture: true }, + }) +}) + +it("rejects unauthorized callbacks before inspecting/serializing any request body or emitting secret values", async () => { + const get = jest.fn(() => { + throw new Error("Must not inspect unauthorized payload") + }) + const ownKeys = jest.fn(() => { + throw new Error("Must not enumerate unauthorized payload") + }) + const payload = new Proxy({}, { get, ownKeys }) + const result = await createAccountFromRegistrationPayload({ + secret: "PRIVATE_WRONG_SECRET", + body: payload, + }) + expect(result).toBeInstanceOf(InvalidSecretForAuthNCallbackError) + expect(get).not.toHaveBeenCalled() + expect(ownKeys).not.toHaveBeenCalled() + expect(mockValidate).not.toHaveBeenCalled() + expect(create).not.toHaveBeenCalled() + expect(addAttributesToCurrentSpan).toHaveBeenCalledWith({ + "registration.outcome": "callback_rejected", + }) + expectNoPrivateTelemetry() +}) + +it.each([ + new InvalidUserId("PRIVATE_INVALID_USER_ID"), + new InvalidPhoneNumber("PRIVATE_INVALID_PHONE"), + new UnsupportedSchemaTypeError("PRIVATE_SCHEMA"), +])( + "preserves invalid-payload refusal identity with only fixed telemetry (case %#)", + async (error) => { + mockValidate.mockReturnValue(error) + expect( + await createAccountFromRegistrationPayload({ + secret: "PRIVATE_CALLBACK_SECRET", + body, + }), + ).toBe(error) + expect(create).not.toHaveBeenCalled() + expect(addAttributesToCurrentSpan).toHaveBeenCalledWith({ + "registration.outcome": "invalid_payload", + }) + expectNoPrivateTelemetry() + }, +) + +it("preserves account provider inputs and the returned failure identity without collecting private exception text", async () => { + const error = new Error("PRIVATE_PROVIDER_ERROR") + create.mockResolvedValue(error as RepositoryError) + expect( + await createAccountFromRegistrationPayload({ + secret: "PRIVATE_CALLBACK_SECRET", + body, + }), + ).toBe(error) + expect(create).toHaveBeenCalledWith({ + newAccountInfo: { phone: "PRIVATE_PHONE", kratosUserId: "PRIVATE_USER_ID" }, + config: { fixture: true }, + phoneMetadata: { fixture: true }, + }) + expect(addAttributesToCurrentSpan).toHaveBeenCalledWith({ + "registration.outcome": "creation_failed", + }) + expectNoPrivateTelemetry() +}) + +it("preserves successful account output and records only fixed schema/outcome", async () => { + const account = { id: "PRIVATE_ACCOUNT_ID" } as Account + create.mockResolvedValue(account) + expect( + await createAccountFromRegistrationPayload({ + secret: "PRIVATE_CALLBACK_SECRET", + body, + }), + ).toBe(account) + expect(addAttributesToCurrentSpan).toHaveBeenCalledWith({ + "registration.schema": "phone_no_password_v0", + "registration.outcome": "received", + }) + expect(addAttributesToCurrentSpan).toHaveBeenCalledWith({ + "registration.outcome": "created", + }) + expectNoPrivateTelemetry() +}) diff --git a/test/flash/unit/servers/event-handlers/kratos-preregistration-route.spec.ts b/test/flash/unit/servers/event-handlers/kratos-preregistration-route.spec.ts index 360235740..743b4ab39 100644 --- a/test/flash/unit/servers/event-handlers/kratos-preregistration-route.spec.ts +++ b/test/flash/unit/servers/event-handlers/kratos-preregistration-route.spec.ts @@ -23,6 +23,12 @@ jest.mock("@app", () => ({ createAccountFromRegistrationPayload: jest.fn(), }, })) +jest.mock("@services/tracing", () => ({ + wrapAsyncToRunInSpan: ({ fn }: { fn: unknown }) => fn, +})) +jest.mock("@services/logger", () => ({ + baseLogger: { error: jest.fn(), warn: jest.fn() }, +})) const mockValidate = Authentication.validatePreRegistrationPayload as jest.MockedFunction< typeof Authentication.validatePreRegistrationPayload @@ -76,7 +82,10 @@ const rejection = (id: number, text: string) => ({ describe("POST /kratos/preregistration", () => { const handler = handlerFor("/preregistration") - beforeEach(() => mockValidate.mockReset()) + beforeEach(() => { + jest.clearAllMocks() + mockValidate.mockReset() + }) it("answers 200 with an empty JSON object when validation passes", async () => { mockValidate.mockResolvedValue(true) @@ -160,10 +169,10 @@ describe("POST /kratos/preregistration", () => { expect(errorSpy).toHaveBeenCalledWith( expect.objectContaining({ - rejection: "UnsupportedSchemaTypeError", - schemaId: "email_no_password_v0", + outcome: "invalid_payload", + expectedSchema: "phone_no_password_v0", }), - expect.stringContaining("Kratos config and api disagree"), + "Kratos callback failed", ) expect(warnSpy).not.toHaveBeenCalled() } finally { @@ -181,8 +190,8 @@ describe("POST /kratos/preregistration", () => { await handler(makeReq(), makeRes()) expect(warnSpy).toHaveBeenCalledWith( - expect.objectContaining({ rejection: "InvalidCarrierTypeForPhoneMetadataError" }), - "preregistration rejected", + expect.objectContaining({ outcome: "phone_not_allowed" }), + "Kratos callback failed", ) expect(errorSpy).not.toHaveBeenCalled() } finally { @@ -233,3 +242,123 @@ describe("POST /kratos/registration is unchanged", () => { expect(() => handlerFor("/registration")).not.toThrow() }) }) + +describe("Kratos callback telemetry privacy", () => { + beforeEach(() => jest.clearAllMocks()) + const cases = [ + { + path: "/registration", + label: "bad secret", + error: new InvalidSecretForAuthNCallbackError("PRIVATE_ERROR"), + status: 401, + }, + { + path: "/registration", + label: "invalid payload", + error: new MissingRegistrationPayloadPropertiesError("PRIVATE_ERROR"), + status: 400, + }, + { + path: "/registration", + label: "creation failure", + error: new UnknownRepositoryError("PRIVATE_ERROR"), + status: 500, + }, + { + path: "/preregistration", + label: "bad secret", + error: new InvalidSecretForAuthNCallbackError("PRIVATE_ERROR"), + status: 401, + }, + { + path: "/preregistration", + label: "existing phone", + error: new PhoneAlreadyRegisteredError("PRIVATE_ERROR"), + status: 400, + }, + { + path: "/preregistration", + label: "invalid phone", + error: new InvalidPhoneNumber("PRIVATE_PHONE"), + status: 400, + }, + { + path: "/preregistration", + label: "metadata refusal", + error: new InvalidCarrierTypeForPhoneMetadataError("PRIVATE_ERROR"), + status: 400, + }, + { + path: "/preregistration", + label: "invalid payload", + error: new UnsupportedSchemaTypeError("PRIVATE_SCHEMA"), + status: 400, + }, + { + path: "/preregistration", + label: "unexpected failure", + error: new UnknownRepositoryError("PRIVATE_ERROR"), + status: 500, + }, + ] + const noPrivateTelemetry = () => { + const logged = JSON.stringify( + [jest.mocked(baseLogger.error).mock.calls, jest.mocked(baseLogger.warn).mock.calls], + (_key, value) => + value instanceof Error + ? { ...value, message: value.message, stack: value.stack } + : value, + ) + expect(logged).not.toContain("PRIVATE_") + } + it.each(cases)( + "preserves $path $label refusal without logging inputs/errors", + async ({ path, error, status }) => { + const req = makeReq({ + phone: "PRIVATE_PHONE", + schema_id: "PRIVATE_SCHEMA", + password: "PRIVATE_PASSWORD", + identity_id: "PRIVATE_ID", + }) + req.headers.authorization = "PRIVATE_CALLBACK_SECRET" + // Both functions are module mocks; avoid expanding the complete + // ApplicationError union through Jest's deep-mocked conditional type. + const service: jest.Mock = + path === "/registration" + ? (Authentication.createAccountFromRegistrationPayload as jest.Mock) + : (mockValidate as jest.Mock) + service.mockResolvedValue(error) + const res = makeRes() + await handlerFor(path)(req, res) + expect(res.status).toHaveBeenCalledWith(status) + expect(service).toHaveBeenCalledWith({ + secret: "PRIVATE_CALLBACK_SECRET", + body: req.body, + }) + noPrivateTelemetry() + expect(JSON.stringify([res.json.mock.calls, res.send.mock.calls])).not.toContain( + "PRIVATE_", + ) + }, + ) + + it.each(["/registration", "/preregistration"])( + "contains rejected %s work inside a safe callback failure boundary", + async (path) => { + const service: jest.Mock = + path === "/registration" + ? (Authentication.createAccountFromRegistrationPayload as jest.Mock) + : (mockValidate as jest.Mock) + service.mockRejectedValue(new Error("PRIVATE_AXIOS_BODY PRIVATE_API_KEY")) + const res = makeRes() + await expect( + Promise.resolve(handlerFor(path)(makeReq({ phone: "PRIVATE_PHONE" }), res)), + ).resolves.toBeUndefined() + expect(res.status).toHaveBeenCalledWith(500) + noPrivateTelemetry() + expect(JSON.stringify([res.json.mock.calls, res.send.mock.calls])).not.toContain( + "PRIVATE_", + ) + }, + ) +}) diff --git a/test/flash/unit/services/frappe/ErpNext.spec.ts b/test/flash/unit/services/frappe/ErpNext.spec.ts index 9a125072d..6387800e1 100644 --- a/test/flash/unit/services/frappe/ErpNext.spec.ts +++ b/test/flash/unit/services/frappe/ErpNext.spec.ts @@ -1435,8 +1435,12 @@ describe("ErpNext.getReferralPayoutAllowlist", () => { ReferralPayoutAllowlistQueryError, ) expect(mockedRecordExceptionInCurrentSpan).toHaveBeenCalledWith({ - error: err, - attributes: { "erpnext.exception": "DoesNotExistError" }, + error: expect.objectContaining({ message: "ERPNext request failed" }), + attributes: { + "erpnext.operation": "getReferralPayoutAllowlist", + "erpnext.error_code": "UNKNOWN", + "erpnext.http_status": 404, + }, }) }) }) @@ -1478,8 +1482,12 @@ describe("ErpNext.getReferralSettings", () => { await client.getReferralSettings() expect(mockedRecordExceptionInCurrentSpan).toHaveBeenCalledWith({ - error: err, - attributes: { "erpnext.exception": "InternalServerError" }, + error: expect.objectContaining({ message: "ERPNext request failed" }), + attributes: { + "erpnext.operation": "getReferralSettings", + "erpnext.error_code": "UNKNOWN", + "erpnext.http_status": 500, + }, }) }) }) diff --git a/test/flash/unit/services/frappe/request-telemetry-privacy.spec.ts b/test/flash/unit/services/frappe/request-telemetry-privacy.spec.ts index 4c96fbe65..02f47986a 100644 --- a/test/flash/unit/services/frappe/request-telemetry-privacy.spec.ts +++ b/test/flash/unit/services/frappe/request-telemetry-privacy.spec.ts @@ -5,15 +5,16 @@ jest.mock("axios", () => ({ isAxiosError: jest.fn((err) => Boolean(err?.isAxiosError)), })) jest.mock("@services/logger", () => ({ - baseLogger: { error: jest.fn(), warn: jest.fn() }, + baseLogger: { error: jest.fn(), warn: jest.fn(), info: jest.fn() }, })) jest.mock("@services/tracing", () => ({ recordExceptionInCurrentSpan: jest.fn() })) jest.mock("@config", () => ({ FrappeConfig: undefined })) import axios from "axios" import { IdentitySource, UpgradeEvidenceType } from "@domain/accounts" -import { Validated } from "@domain/shared" -import { ErpNext } from "@services/frappe/ErpNext" +import { Validated, USDAmount } from "@domain/shared" +import ValidOffer from "@app/offers/ValidOffer" +import { ErpNext, CashoutId } from "@services/frappe/ErpNext" import { recordErpRequestFailure } from "@services/frappe/telemetry" import { AccountUpgradeRequest, @@ -21,6 +22,11 @@ import { } from "@services/frappe/models/AccountUpgradeRequest" import { BankAccountUpdateRequest } from "@services/frappe/models/BankAccountUpdateRequest" import { IdVerification } from "@services/frappe/models/IdVerification" +import { + BridgeTransferRequest, + BridgeTransferRequestStatus, + BridgeTransferRequestTransactionType, +} from "@services/frappe/models/BridgeTransferRequest" import { baseLogger } from "@services/logger" import { recordExceptionInCurrentSpan } from "@services/tracing" @@ -76,6 +82,27 @@ const bankRequest = new BankAccountUpdateRequest( }, "PRIVATE_SUPPORT_NOTE", ) +const money = USDAmount.cents(125n) as USDAmount +const cashout = { + account: { erpParty: "PRIVATE_CUSTOMER" }, + details: { + payment: { + invoice: { paymentHash: "PRIVATE_PAYMENT_HASH" }, + userAcct: "PRIVATE_WALLET", + flashAcct: "PRIVATE_FLASH_WALLET", + amount: money, + }, + payout: { bankAccountId: "PRIVATE_BANK_ID", amount: money, serviceFee: money }, + }, +} as unknown as ValidOffer +const transfer = new BridgeTransferRequest({ + requestId: "PRIVATE_REQUEST", + transactionType: BridgeTransferRequestTransactionType.Topup, + status: BridgeTransferRequestStatus.Pending, + amount: "1.25", + currency: "USD", + rawPayload: privateBody, +}) function privateError() { return Object.assign(new Error("PRIVATE_ERROR_MESSAGE"), { @@ -109,6 +136,7 @@ function expectPrivateDataAbsent(result?: unknown) { logs: [ jest.mocked(baseLogger.error).mock.calls, jest.mocked(baseLogger.warn).mock.calls, + jest.mocked(baseLogger.info).mock.calls, ], spans: jest.mocked(recordExceptionInCurrentSpan).mock.calls, result, @@ -123,6 +151,49 @@ beforeEach(() => { }) const requests: [string, () => Promise][] = [ + ["draftCashout", () => client.draftCashout(cashout)], + ["submitCashout", () => client.submitCashout("PRIVATE_CASHOUT_ID" as CashoutId)], + ["deleteJournalEntry", () => client.delete("PRIVATE_JOURNAL_ID")], + ["getCashoutExchangeRate", () => client.getCashoutExchangeRate()], + ["getFygaroSettings", () => client.getFygaroSettings()], + ["getFeeDiscounts", () => client.getFeeDiscounts()], + ["getReferralSettings", () => client.getReferralSettings()], + ["getReferralPayoutAllowlist", () => client.getReferralPayoutAllowlist()], + ["getAllowedCountries", () => client.getAllowedCountries()], + [ + "sumFygaroTopupGrossCentsSince", + () => + client.sumFygaroTopupGrossCentsSince({ + accountId: "PRIVATE_ACCOUNT", + since: new Date(), + }), + ], + ["upsertBridgeTransferRequest", () => client.upsertBridgeTransferRequest(transfer)], + [ + "completeBridgeTopupByTxHash", + () => + client.completeBridgeTopupByTxHash({ + txHash: "PRIVATE_TX_HASH", + accountId: "PRIVATE_ACCOUNT", + walletId: "PRIVATE_WALLET", + }), + ], + [ + "sumFygaroCompletedNetCentsSince", + () => client.sumFygaroCompletedNetCentsSince({ since: new Date() }), + ], + [ + "listUncreditedFygaroTopups", + () => client.listUncreditedFygaroTopups({ since: new Date(), limit: 10 }), + ], + [ + "countAgedOutUncreditedFygaroTopups", + () => client.countAgedOutUncreditedFygaroTopups({ before: new Date() }), + ], + [ + "findBridgeTransferRequest", + () => client.findBridgeTransferRequest("PRIVATE_REQUEST_ID"), + ], ["postUpgradeRequest", () => client.postUpgradeRequest(upgrade)], ["getAccountUpgradeRequestList", () => client.getAccountUpgradeRequestList({})], [ @@ -279,3 +350,101 @@ it("retains only allowlisted transport codes and integer HTTP status, not arbitr ) expectPrivateDataAbsent() }) + +it("preserves cashout provider body and auth after a failed request without logging customer/bank/payment data", async () => { + const result = await client.draftCashout(cashout) + expect(result).toBeInstanceOf(Error) + expect(http.post).toHaveBeenCalledWith( + "https://erp.invalid/api/resource/Cashout", + { + customer: "PRIVATE_CUSTOMER", + bank_account: "PRIVATE_BANK_ID", + transaction_id: "PRIVATE_PAYMENT_HASH", + wallet_id: "PRIVATE_WALLET", + flash_wallet: "PRIVATE_FLASH_WALLET", + user_receives: 1.25, + user_pays: 1.25, + currency: "USD", + exchange_rate: undefined, + flash_fee: 1.25, + }, + { + headers: expect.objectContaining({ + Authorization: "token PRIVATE_API_KEY:PRIVATE_API_SECRET", + }), + }, + ) + expectPrivateDataAbsent(result) +}) + +it("keeps duplicate-transfer recovery local and still updates the single raced row with original body/auth", async () => { + http.get.mockResolvedValueOnce({ data: { data: [] } }).mockResolvedValueOnce({ + data: { data: [{ name: "PRIVATE_RACED_ROW", status: "Pending" }] }, + }) + const duplicate = privateError() + duplicate.response.status = 409 + duplicate.response.data.exception = + "PRIVATE_NAME DuplicateEntryError PRIVATE_BANK_NUMBER" + http.post.mockRejectedValue(duplicate) + http.put.mockResolvedValue({ data: {} }) + expect(await client.upsertBridgeTransferRequest(transfer)).toBe(true) + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.put).toHaveBeenCalledTimes(1) + expect(http.put).toHaveBeenCalledWith( + "https://erp.invalid/api/resource/Bridge%20Transfer%20Request/PRIVATE_RACED_ROW", + expect.objectContaining({ + request_id: "PRIVATE_REQUEST", + raw_payload_json: JSON.stringify(privateBody), + }), + { + headers: expect.objectContaining({ + Authorization: "token PRIVATE_API_KEY:PRIVATE_API_SECRET", + }), + }, + ) + expectPrivateDataAbsent() +}) + +it("sanitizes transfer mutation errors after successful ownership/status reads without claiming completion", async () => { + http.get.mockResolvedValue({ + data: { data: [{ name: "PRIVATE_LEDGER_ROW", status: "Pending" }] }, + }) + const result = await client.completeBridgeTopupByTxHash({ txHash: "PRIVATE_TX_HASH" }) + expect(result).toBeInstanceOf(Error) + expect(http.put).toHaveBeenCalledTimes(1) + expectPrivateDataAbsent(result) +}) + +it("keeps malformed financial history fail-closed without copying row IDs into errors", async () => { + http.get.mockResolvedValue({ + data: { + data: [ + { + request_id: "PRIVATE_ROW_ID", + amount: "PRIVATE_BANK_NUMBER", + final_amount: "PRIVATE_NAME", + }, + ], + }, + }) + const gross = await client.sumFygaroTopupGrossCentsSince({ + accountId: "PRIVATE_ACCOUNT", + since: new Date(), + }) + const net = await client.sumFygaroCompletedNetCentsSince({ since: new Date() }) + expect(gross).toBeInstanceOf(Error) + expect(net).toBeInstanceOf(Error) + expectPrivateDataAbsent([gross, net]) +}) + +it("preserves the legacy lower-bound burn result without logging the skipped row identifier", async () => { + http.get.mockResolvedValue({ + data: { data: [{ request_id: "PRIVATE_ROW_ID", final_amount: null }] }, + }) + expect(await client.sumFygaroCompletedNetCentsSince({ since: new Date() })).toBe(0) + expect(baseLogger.info).toHaveBeenCalledWith( + { operation: "sumFygaroCompletedNetCentsSince", outcome: "missing_final_amount" }, + "Fygaro burn: Completed row without final_amount skipped", + ) + expectPrivateDataAbsent() +}) From 69bb6520203bfeb8837048289be6b36156eaa955 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 00:44:56 -0700 Subject: [PATCH 11/42] Make device account bootstrap owned and safely resumable --- docs/device-account-bootstrap.md | 157 +++++ src/app/accounts/create-account.ts | 25 +- src/app/accounts/device-account-bootstrap.ts | 369 +++++++++++ src/app/accounts/device-bootstrap-adapters.ts | 56 ++ src/app/authentication/login.ts | 71 ++- .../cash-wallet-cutover/ensure-usdt-wallet.ts | 34 +- src/domain/accounts/device-bootstrap.ts | 83 +++ .../authentication/device-bootstrap-error.ts | 23 + src/servers/authorization/index.ts | 33 +- .../kratos/auth-username-password-deviceid.ts | 41 +- .../kratos/device-bootstrap-identity.ts | 48 ++ src/services/mongoose/device-bootstrap.ts | 363 +++++++++++ src/services/mongoose/schema.ts | 41 ++ .../accounts/device-account-bootstrap.spec.ts | 602 ++++++++++++++++++ .../device-bootstrap-adapters.spec.ts | 64 ++ .../login-device-bootstrap.spec.ts | 134 ++++ .../device-bootstrap-presentation.spec.ts | 82 +++ .../ensure-usdt-wallet.spec.ts | 40 ++ .../recipient-routing.spec.ts | 10 + .../device-bootstrap-route.spec.ts | 150 +++++ .../kratos/device-bootstrap-identity.spec.ts | 167 +++++ .../mongoose/device-bootstrap.spec.ts | 294 +++++++++ 22 files changed, 2819 insertions(+), 68 deletions(-) create mode 100644 docs/device-account-bootstrap.md create mode 100644 src/app/accounts/device-account-bootstrap.ts create mode 100644 src/app/accounts/device-bootstrap-adapters.ts create mode 100644 src/domain/accounts/device-bootstrap.ts create mode 100644 src/domain/authentication/device-bootstrap-error.ts create mode 100644 src/services/kratos/device-bootstrap-identity.ts create mode 100644 src/services/mongoose/device-bootstrap.ts create mode 100644 test/flash/unit/app/accounts/device-account-bootstrap.spec.ts create mode 100644 test/flash/unit/app/accounts/device-bootstrap-adapters.spec.ts create mode 100644 test/flash/unit/app/authentication/login-device-bootstrap.spec.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/device-bootstrap-presentation.spec.ts create mode 100644 test/flash/unit/servers/authorization/device-bootstrap-route.spec.ts create mode 100644 test/flash/unit/services/kratos/device-bootstrap-identity.spec.ts create mode 100644 test/flash/unit/services/mongoose/device-bootstrap.spec.ts diff --git a/docs/device-account-bootstrap.md b/docs/device-account-bootstrap.md new file mode 100644 index 000000000..fe5e747c2 --- /dev/null +++ b/docs/device-account-bootstrap.md @@ -0,0 +1,157 @@ +# Owned resumable device-account bootstrap + +This source patch hardens `POST /auth/create/device-account` while retaining Basic +UUID credentials, App Check, and the success body `{ "result": "" }`. It does +not deploy the endpoint or provision a real identity/provider resource. Native must +use the separately reviewed authenticated raw owned-wallet snapshot; the existing +ConsumerAccount wallet presentation hides one cash currency and its public UUID is +not the Mongo wallet account ID. A successful login response alone is insufficient +for native session handoff. + +## Admission and ownership + +Every attempt first authenticates the exact Basic username/password. Only an active +Kratos session with the exact active device-schema identity and exact username can +reach the engine. The engine then freshly reads that identity through the admin +API, requesting only TOTP credential presence. TOTP-enabled, inactive, upgraded +phone-schema, foreign identity, conflicting device metadata, or phone metadata +identities cannot use this route as an alternate factor or repair path. + +Only a freshly created admin identity receives atomic admin-only provenance +`metadata_admin.deviceBootstrap = {version:1,deviceIdHash:SHA256(username)}`. The 409 +password-login path never updates/adopts a legacy identity. This is a creation +provenance record, not a client assertion or rollout flag. Existing unmarked legacy +identities can use already complete owned local accounts, or repair a missing local +USDT default when both required wallets already exist at level zero. An incomplete +untracked legacy attempt requires support: absence of a journal is not evidence +that an earlier provider call did not succeed. + +The route locks the hashed device identity first, then the engine locks the reserved +account ID. All mutations use freshly checked ownership. Duplicate local account, +user binding, wallet ID, or required cash wallet candidates fail closed. Metadata +updates preserve phone/preferences and never call the old broad user updater. +The unused non-journaled device-create helper is removed; phone onboarding retains +its existing behavior. + +## Durable state and external dispatch + +`DeviceAccountBootstrap` is a new collection keyed by SHA256(device UUID). It stores +owner/account IDs, version, mode, timestamps, and per-currency state; no Basic +password, bearer, raw Basic username, provider response body, or balance. Every +journal/local write introduced here requests Mongo majority acknowledgement and +journaling. The account ID is reserved before account insertion or provider calls. +The primary key is unique by Mongo construction; no unsafe uniqueness migration +is applied to existing account/user/wallet rows. + +| State | Meaning | Allowed recovery | +| --- | --- | --- | +| planned | No dispatch intent persisted for this currency | Reuse one actual owned local Checking wallet, or begin one provider dispatch | +| dispatching | Intent/attempt persisted before provider invocation | Support-required; never call provider again merely because a response was lost | +| known | Validated provider ID/name/currency persisted before local attachment | Resume owned local attachment; do not create another provider account | +| attached | Exact owned local wallet attached | Recheck actual wallet; never recreate a missing/deleted wallet | +| blocked | Provider returned an invalid/foreign/unsupported receipt | Support-required; preserve safe observed provider ID when available | + +The pinned ibex-client 3.3.0 wrapper refreshes credentials and retries once only +after an explicit HTTP 401. It does not retry timeout/unknown/provider errors; this +existing behavior assumes a 401 rejects resource creation before acceptance. No +application-level redispatch is permitted after an ambiguous result. + +IBEX `createAccount` has no proven idempotency or authoritative lookup by request +key. Calling it again may create a fresh resource; no name/email matching is used +as reconciliation. Returned IDs must be bounded ASCII IDs with exact account-name +and currency matches. A response is recorded as `known` even if cancellation, +identity change, or local attachment failure follows. Unknown provider errors, +lost intent acknowledgement, and missing receipt persistence remain unresolved. +A proven cancellation immediately after persisted intent but before invocation is +the sole permitted intent rollback. A crash in that same gap remains conservative +support-required. Local partial writes with an already durable receipt are safe to +resume after process restart using the same authenticated pair. + +Operator reconciliation must independently establish actual provider ownership and +currency before atomically recording an exact known receipt; merely finding local +wallets cannot clear an unknown dispatch. No operator tool or guessed lookup is +introduced here. Repeated explicit requests with the same pair cannot recreate an +unresolved resource; after valid authoritative reconciliation they can resume local +completion. Journals have no TTL and must not be deleted or restored independently +of their corresponding account/provider history. Loss of the journal through +manual changes or inconsistent disaster recovery requires external reconciliation, +not an assurance of idempotency. No pruning of unresolved records is safe. + +## Required readiness and preservation + +At this bootstrap boundary, an active account must have exactly one actual owned +Checking USD and exactly one actual owned Checking USDT wallet, and an actual owned +default wallet. Duplicate required cash wallets require support before any further +provider dispatch/attachment/default changes. This is a bounded bootstrap contract, +not a global claim that every multi-wallet account is invalid. + +An existing valid default is preserved, including External or BTC defaults. Existing +level, status, preferences and wallet ownership are not reset. Only level-zero +accounts may receive a missing unambiguous USDT default. Upgraded partial accounts +are not initialized through this path. A ready journal is rechecked against actual +local ownership; missing resources cannot cause fresh provider creation. Optional +LNURL assignment is not part of the required cash-wallet readiness proof. + +The public USDT presentation auto-heal checks for any controlled journal inside its +existing account lock before creation or legacy-default rewriting. Controlled +accounts with missing USDT fail closed, regardless of journal phase or later +phone-schema upgrade. Existing USDT is returned without changing a selected default. +A failed journal read cannot fall through to provider creation. Ordinary untracked +phone-account presentation retains existing behavior; no same-lock nesting is added. + +## REST outcomes, cancellation and privacy + +A token is returned only after final fresh identity/account/wallet/default checks, +not because Kratos reported a newly created identity. Existing-device login always +runs the readiness engine. Closed error codes are additive to the existing error +body: + +- `DEVICE_BOOTSTRAP_SUPPORT_REQUIRED`: unresolved provider state, ambiguous/legacy + partial state, or required resource conflict. Preserve credentials; explicit + same-pair recheck may recover after authoritative operator reconciliation. +- `DEVICE_BOOTSTRAP_RETRY_REQUIRED`: proven local/pre-dispatch failure or cancellation; + explicit same-pair retry can resume. This is not permission for automatic retries. +- `DEVICE_BOOTSTRAP_OWNER_MISMATCH`: source ownership differs or is ambiguous. +- `DEVICE_BOOTSTRAP_INCOMPATIBLE_IDENTITY`: inactive/non-device/TOTP/provenance conflict. + +Unknown exceptions are sanitized; provider prose and credentials are never reflected. +Malformed usernames produce fixed text. The route removes the former explicit span +attribute containing the Basic username. Aborted/closed requests cancel future +local work and cannot receive a late token; already in-flight provider requests +are not assumed cancelled and their known receipts are still persisted. + +**Release dependency:** this branch starts from a source baseline whose generic +tracing wrappers capture arguments. It must be integrated with the central telemetry +privacy patch `4921212` (and root's broader explicit-attribute review) before release. +This patch alone does not claim all tracing/logging sinks are sanitized. New +bootstrap code never intentionally logs credentials, request bodies or provider +errors. No real credentials, provider requests or accounts were used in validation. + +## Reproducible validation + +Tests use injected/mocked provider, Kratos, Redis-lock and Mongo dependencies. They +exercise the actual repository predicates/write concerns and actual REST handler, +including a lock mock that reproduces callback exception conversion. They do not +prove deployed Mongo durability, Redis lease behavior, production Kratos schema, +provider receipt semantics or live operator reconciliation. + +Use Node 24 with the exact frozen-lockfile dependency installation, not an inherited +node_modules tree. In this review, the worktree node_modules is a symlink to +`/Users/dread/Repos/flash-native-parity-backend/node_modules`. The focused suites are +`device-account-bootstrap`, `device-bootstrap-adapters`, mongoose `device-bootstrap`, +Kratos `device-bootstrap-identity`, `ensure-usdt-wallet`, `device-bootstrap-presentation`, +`login-device-bootstrap`, and `device-bootstrap-route`, under `test/flash/unit`. +No .env file or real service credential is needed for those mocked suites. + +Final focused result: 119 tests across 8 suites passed; independently repeated with +safe dummy environment and the strict network guard. Broader result: 160 tests +across 15 suites passed with the same guard and natural process exit zero. The +legacy recipient-routing test now mocks an otherwise eager Redis adapter and +throws if its complete-wallet fixtures unexpectedly request repair. Declaration +(`tsconfig.d.json`), whole-tree, and production (`tsconfig-build.json`) TypeScript +checks and lint on every changed/new TypeScript file pass with the exact dependency +tree. Run Jest with `--runInBand --watchman=false --runTestsByPath`; disabling +Watchman avoids an unrelated Unix-socket connection under the strict network guard. +The integration runner's exported `safeEnvironment` and network preload provide +the reproducible no-inherited-credentials/no-unmocked-network harness; no local +.env file is read. diff --git a/src/app/accounts/create-account.ts b/src/app/accounts/create-account.ts index 17ea2c1f2..a345d33e2 100644 --- a/src/app/accounts/create-account.ts +++ b/src/app/accounts/create-account.ts @@ -1,7 +1,6 @@ -import { ConfigError, getAdminAccounts, getDefaultAccountsConfig } from "@config" +import { ConfigError, getAdminAccounts } from "@config" import { WalletType } from "@domain/wallets" -import { AccountLevel } from "@domain/accounts" import { AccountsRepository, @@ -81,28 +80,6 @@ const initializeCreatedAccount = async ({ return updatedAccount } -export const createAccountForDeviceAccount = async ({ - userId, - deviceId, -}: { - userId: UserId - deviceId: DeviceId -}): Promise => { - const user = await UsersRepository().update({ id: userId, deviceId }) - if (user instanceof Error) return user - - const accountNew = await AccountsRepository().persistNew(userId) - if (accountNew instanceof Error) return accountNew - - const levelZeroAccountsConfig = getDefaultAccountsConfig() - levelZeroAccountsConfig.initialLevel = AccountLevel.Zero - - return initializeCreatedAccount({ - account: accountNew, - config: levelZeroAccountsConfig, - }) -} - export const createAccountWithPhoneIdentifier = async ({ newAccountInfo: { kratosUserId, phone }, config, diff --git a/src/app/accounts/device-account-bootstrap.ts b/src/app/accounts/device-account-bootstrap.ts new file mode 100644 index 000000000..7870b4bd6 --- /dev/null +++ b/src/app/accounts/device-account-bootstrap.ts @@ -0,0 +1,369 @@ +import { createHash } from "crypto" + +import { DeviceBootstrapError } from "@domain/authentication/device-bootstrap-error" +import type { + BootstrapAccount, + BootstrapCurrency, + BootstrapSlot, + BootstrapWallet, + DeviceBootstrapDependencies, + DeviceBootstrapJournalRecord, +} from "@domain/accounts/device-bootstrap" + +const currencies: BootstrapCurrency[] = ["USD", "USDT"] +const failure = (code: ConstructorParameters[0]) => + new DeviceBootstrapError(code) +const support = () => failure("DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") +const retry = () => failure("DEVICE_BOOTSTRAP_RETRY_REQUIRED") +const ownerMismatch = () => failure("DEVICE_BOOTSTRAP_OWNER_MISMATCH") +const providerId = (value: unknown): value is string => + typeof value === "string" && + value.length > 0 && + value.length <= 256 && + !/[^A-Za-z0-9_-]/.test(value) + +export type DeviceBootstrapInput = { + userId: string + deviceId: string + isCancelled?: () => boolean +} + +/** Authenticated device owner only. No credential, token, amount or provider balance enters this engine. */ +export const resumeDeviceAccountBootstrap = async ( + input: DeviceBootstrapInput, + supplied?: DeviceBootstrapDependencies, +): Promise => { + try { + if ( + !input.userId || + input.userId.length > 128 || + /[^A-Za-z0-9_-]/.test(input.userId) || + input.deviceId.length !== 36 || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + input.deviceId, + ) + ) + throw ownerMismatch() + const key = createHash("sha256").update(input.deviceId).digest("hex") + let controlledIdentity = false + const deps = + supplied ?? + (await import("./device-bootstrap-adapters")).deviceBootstrapDependencies() + const store = deps.store + let lockAborted = () => false + const check = () => { + if (input.isCancelled?.() || lockAborted()) throw retry() + } + const verifyIdentity = async () => { + check() + const identity = await deps.identity(input.userId) + check() + if (identity.id !== input.userId || identity.username !== input.deviceId) + throw ownerMismatch() + if ( + identity.schema !== "username_password_deviceid_v0" || + identity.state !== "active" || + identity.totpEnabled !== false + ) { + throw failure("DEVICE_BOOTSTRAP_INCOMPATIBLE_IDENTITY") + } + if ( + identity.bootstrap !== undefined && + (identity.bootstrap.version !== 1 || identity.bootstrap.deviceIdHash !== key) + ) { + throw failure("DEVICE_BOOTSTRAP_INCOMPATIBLE_IDENTITY") + } + controlledIdentity = identity.bootstrap !== undefined + const bindings = await store.userBindings(input.userId, input.deviceId) + check() + if ( + bindings.length > 1 || + bindings.some( + (binding) => + binding.userId !== input.userId || + (binding.deviceId !== undefined && binding.deviceId !== input.deviceId) || + binding.hasPhone, + ) + ) + throw ownerMismatch() + } + const readAccount = async (expected?: string): Promise => { + const accounts = await store.accounts(input.userId) + check() + if ( + accounts.length > 1 || + accounts.some( + (account) => + account.userId !== input.userId || + (expected !== undefined && account.id !== expected), + ) + ) + throw ownerMismatch() + return accounts[0] ?? null + } + const owned = ( + wallets: BootstrapWallet[], + account: BootstrapAccount, + currency: BootstrapCurrency, + ) => + wallets.filter( + (wallet) => + wallet.accountId === account.id && + wallet.type === "checking" && + wallet.currency === currency, + ) + const inspect = async (account: BootstrapAccount) => { + const wallets = await store.wallets(account.id) + check() + if ( + wallets.some((wallet) => wallet.accountId !== account.id) || + new Set(wallets.map((wallet) => wallet.id)).size !== wallets.length + ) + throw ownerMismatch() + if (currencies.some((currency) => owned(wallets, account, currency).length > 1)) + throw support() + return wallets + } + const ready = (account: BootstrapAccount, wallets: BootstrapWallet[]) => + account.status === "active" && + currencies.every((currency) => owned(wallets, account, currency).length === 1) && + account.defaultWalletId !== undefined && + wallets.some((wallet) => wallet.id === account.defaultWalletId) + const compatibleRecord = (record: DeviceBootstrapJournalRecord, key: string) => { + if (record._id !== key || record.userId !== input.userId || record.version !== 1) + throw ownerMismatch() + if ( + currencies.some((currency) => + ["dispatching", "blocked"].includes(record.wallets[currency].state), + ) + ) + throw support() + } + + await verifyIdentity() + let record = await store.journal(key) + check() + if (!record) { + const account = await readAccount() + let mode: DeviceBootstrapJournalRecord["mode"] = "new" + if (account) { + const wallets = await inspect(account) + // A legacy local default can be repaired only when all required wallets + // already exist, and the missing preferred USDT choice is unambiguous. + const localOnly = + account.status === "active" && + (account.level ?? 0) === 0 && + account.defaultWalletId === undefined && + currencies.every( + (currency) => owned(wallets, account, currency).length === 1, + ) && + owned(wallets, account, "USDT").length === 1 + if (!ready(account, wallets) && !localOnly) throw support() + mode = "legacy-local" + } else if (!controlledIdentity) { + // No journal means no proof an old attempt never reached a provider. + throw support() + } + const now = new Date() + record = await store.reserve({ + _id: key, + version: 1, + userId: input.userId, + accountId: account?.id ?? deps.newAccountId(), + mode, + phase: "initializing", + wallets: { USD: { state: "planned" }, USDT: { state: "planned" } }, + createdAt: now, + updatedAt: now, + }) + check() + } + compatibleRecord(record, key) + const reserved = record + return await deps.withAccountLock( + record.accountId, + async (aborted): Promise => { + lockAborted = aborted + await verifyIdentity() + record = await store.journal(key) + check() + if ( + !record || + record.accountId !== reserved.accountId || + record.mode !== reserved.mode + ) + throw ownerMismatch() + compatibleRecord(record, key) + let account = await readAccount(record.accountId) + if (record.phase === "ready") { + await verifyIdentity() + account = await readAccount(record.accountId) + if (!account || !ready(account, await inspect(account))) throw support() + check() + return true + } + if (!account) { + if (record.mode !== "new") throw support() + account = await store.ensureAccount(record) + check() + if (account.id !== record.accountId || account.userId !== input.userId) + throw ownerMismatch() + } + let wallets = await inspect(account) + if (record.mode === "new" && (account.level ?? 0) !== 0) { + // An upgrade may retain an otherwise complete account. Never initialize + // or attach resources to a partially upgraded account on the trial path. + if (!ready(account, wallets)) throw support() + } + if (account.status !== "active") throw support() + if (record.mode === "new" && (account.level ?? 0) === 0) { + await store.bindDeviceMetadata(input.userId, input.deviceId) + await verifyIdentity() + } + for (const currency of currencies) { + let slot: BootstrapSlot = record.wallets[currency] + if (slot.state === "attached") { + if ( + !owned(wallets, account, currency).some( + (wallet) => wallet.id === slot.providerId, + ) + ) + throw support() + continue + } + if (slot.state === "planned") { + const existing = owned(wallets, account, currency) + if (existing.length > 0) { + const preferred = existing[0] // inspect() proves exactly one owned Checking wallet. + record = await store.transition(record, currency, slot, { + state: "attached", + providerId: preferred.id, + origin: "local", + }) + check() + continue + } + if (record.mode !== "new" || (account.level ?? 0) !== 0) throw support() + await verifyIdentity() + const current = await readAccount(account.id) + if (!current || (current.level ?? 0) !== 0 || current.status !== "active") + throw support() + wallets = await inspect(current) + const appeared = owned(wallets, current, currency) + if (appeared.length === 1) { + record = await store.transition(record, currency, slot, { + state: "attached", + providerId: appeared[0].id, + origin: "local", + }) + check() + continue + } + const intent: BootstrapSlot = { + state: "dispatching", + attemptId: deps.newAttemptId(), + } + record = await store.transition(record, currency, slot, intent) + // A known cancellation before invocation is the only safe rollback of + // dispatch intent. A crash in this gap still remains support-required. + if (input.isCancelled?.() || lockAborted()) { + await store.transition(record, currency, intent, { state: "planned" }) + throw retry() + } + let response: Awaited< + ReturnType + > + try { + response = await deps.createProviderAccount(account.id, currency) + } catch { + throw support() + } + const id = providerId(response?.id) ? response.id : undefined + if ( + !id || + response?.name !== account.id || + response?.currencyId !== deps.currencyId(currency) + ) { + try { + await store.transition(record, currency, intent, { + state: "blocked", + attemptId: intent.attemptId, + providerId: id, + }) + } catch { + /* Intent remains unresolved. */ + } + throw support() + } + // Persist the validated resource ID even if the caller/identity changed + // while provider creation was in flight. Never lose a known receipt. + const known: BootstrapSlot = { + state: "known", + attemptId: intent.attemptId, + providerId: id, + origin: "created", + } + try { + record = await store.transition(record, currency, intent, known) + } catch { + throw support() + } + slot = known + } + if (slot.state !== "known" || !slot.providerId || slot.origin !== "created") + throw support() + await verifyIdentity() + const current = await readAccount(account.id) + if (!current || (current.level ?? 0) !== 0 || current.status !== "active") + throw support() + const beforeAttach = owned(await inspect(current), current, currency) + if (beforeAttach.length === 1 && beforeAttach[0].id !== slot.providerId) + throw support() + await store.attach(record, currency, slot.providerId) + check() + record = await store.transition(record, currency, slot, { + ...slot, + state: "attached", + }) + check() + wallets = await inspect(account) + } + account = await readAccount(record.accountId) + if (!account) throw ownerMismatch() + wallets = await inspect(account) + if (account.defaultWalletId === undefined) { + const preferred = owned(wallets, account, "USDT") + if ((account.level ?? 0) !== 0 || preferred.length !== 1) throw support() + await verifyIdentity() + const latest = await readAccount(record.accountId) + if (!latest) throw ownerMismatch() + const latestPreferred = owned(await inspect(latest), latest, "USDT") + if (latest.defaultWalletId === undefined) { + if ( + (latest.level ?? 0) !== 0 || + latest.status !== "active" || + latestPreferred.length !== 1 + ) + throw support() + await store.setMissingDefault(record, latestPreferred[0].id) + check() + } + } + account = await readAccount(record.accountId) + if (!account || !ready(account, await inspect(account))) throw support() + await verifyIdentity() + record = await store.ready(record) + check() + // Only authoritative local owned readiness, never a provider acceptance + // guess or merely a newly minted Kratos bearer, permits the REST token. + await verifyIdentity() + account = await readAccount(record.accountId) + if (!account || !ready(account, await inspect(account))) throw support() + check() + return true + }, + ) + } catch (error) { + return error instanceof DeviceBootstrapError ? error : retry() + } +} diff --git a/src/app/accounts/device-bootstrap-adapters.ts b/src/app/accounts/device-bootstrap-adapters.ts new file mode 100644 index 000000000..90fd0a9bf --- /dev/null +++ b/src/app/accounts/device-bootstrap-adapters.ts @@ -0,0 +1,56 @@ +import { randomUUID } from "crypto" + +import { Types } from "mongoose" + +import type { DeviceBootstrapDependencies } from "@domain/accounts/device-bootstrap" +import { DeviceBootstrapError } from "@domain/authentication/device-bootstrap-error" +import { USDAmount, USDTAmount } from "@domain/shared" +import { DeviceBootstrapRepository } from "@services/mongoose/device-bootstrap" +import { readDeviceBootstrapIdentity } from "@services/kratos/device-bootstrap-identity" +import Ibex from "@services/ibex/client" +import { LockService } from "@services/lock" + +/** Provider creation is deliberately separate from WalletsRepository.persistNew: + * the durable intent and returned ID must bracket the external side effect. */ +export const deviceBootstrapDependencies = (): DeviceBootstrapDependencies => ({ + store: DeviceBootstrapRepository(), + identity: readDeviceBootstrapIdentity, + newAccountId: () => new Types.ObjectId().toString(), + newAttemptId: randomUUID, + currencyId: (currency) => + currency === "USD" ? USDAmount.currencyId : USDTAmount.currencyId, + createProviderAccount: async (accountId, currency) => { + const result = await Ibex.createAccount( + accountId, + currency === "USD" ? USDAmount.currencyId : USDTAmount.currencyId, + ) + if (result instanceof Error) + throw new DeviceBootstrapError("DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") + return result + }, + withAccountLock: async (accountId, work) => { + // The real lock adapter converts thrown callback errors. Preserve our safe + // machine classification inside its callback rather than leaking an error + // object to that converter or downgrading support-required to retryable. + const result = await LockService().lockAccountId( + accountId as AccountId, + async (signal) => { + try { + return { ok: true as const, value: await work(() => signal.aborted) } + } catch (error) { + return { + ok: false as const, + error: + error instanceof DeviceBootstrapError + ? error + : new DeviceBootstrapError("DEVICE_BOOTSTRAP_RETRY_REQUIRED"), + } + } + }, + ) + if (result instanceof Error) + throw new DeviceBootstrapError("DEVICE_BOOTSTRAP_RETRY_REQUIRED") + if (!result.ok) throw result.error + return result.value + }, +}) diff --git a/src/app/authentication/login.ts b/src/app/authentication/login.ts index cf5d89ab6..aaa38d89f 100644 --- a/src/app/authentication/login.ts +++ b/src/app/authentication/login.ts @@ -1,9 +1,13 @@ +import { createHash } from "crypto" + import { upgradeAccountFromDeviceToPhone } from "@app/accounts" -import { createAccountForDeviceAccount } from "@app/accounts/create-account" +import { resumeDeviceAccountBootstrap } from "@app/accounts/device-account-bootstrap" +import { DeviceBootstrapError } from "@domain/authentication/device-bootstrap-error" import { getBalanceForWallet } from "@app/wallets" import { EmailUnverifiedError, + AuthenticationError, IdentifierNotFoundError, } from "@domain/authentication/errors" @@ -372,11 +376,13 @@ export const loginWithDevice = async ({ password: passwordRaw, ip, deviceId: deviceIdRaw, + signal, }: { ip: IpAddress username: string password: string deviceId: string + signal?: { readonly aborted: boolean } }): Promise => { { const limitOk = await checkFailedLoginAttemptPerIpLimits(ip) @@ -386,13 +392,18 @@ export const loginWithDevice = async ({ // add check on property from jwt, like expiry const deviceId = checkedToDeviceId(deviceIdRaw) - if (deviceId instanceof Error) return deviceId + if (deviceId instanceof Error) + return new AuthenticationError("Invalid device credentials") const username = checkedToIdentityUsername(usernameRaw) - if (username instanceof Error) return username + if (username instanceof Error) + return new AuthenticationError("Invalid device credentials") const password = checkedToIdentityPassword(passwordRaw) - if (password instanceof Error) return password + if (password instanceof Error) + return new AuthenticationError("Invalid device credentials") + if (username.toString() !== deviceId.toString()) + return new DeviceBootstrapError("DEVICE_BOOTSTRAP_OWNER_MISMATCH") const { ipMetadataValidationSettings } = getAccountsOnboardConfig() @@ -420,22 +431,44 @@ export const loginWithDevice = async ({ } } - const authService = AuthWithUsernamePasswordDeviceIdService() - const res = await authService.createIdentityWithSession({ - username, - password, + // Consistent order: authenticated device-identity namespace, then the + // journal's exact local account lock. No credential appears in a Redis key. + const { redlock } = await import("@services/lock") + const key = createHash("sha256").update(username).digest("hex") + return redlock({ + path: `locks:device-bootstrap-identity:${key}`, + asyncFn: async (identitySignal) => { + if (signal?.aborted || identitySignal.aborted) + return new DeviceBootstrapError("DEVICE_BOOTSTRAP_RETRY_REQUIRED") + let res: Awaited< + ReturnType< + ReturnType< + typeof AuthWithUsernamePasswordDeviceIdService + >["createIdentityWithSession"] + > + > + try { + res = await AuthWithUsernamePasswordDeviceIdService().createIdentityWithSession({ + username, + password, + }) + } catch { + return new DeviceBootstrapError("DEVICE_BOOTSTRAP_RETRY_REQUIRED") + } + if (res instanceof Error) return res + // This always executes after successful password authentication, including + // repeat logins. A fresh bearer alone never authorizes wallet repair. + const ready = await resumeDeviceAccountBootstrap({ + userId: res.kratosUserId, + deviceId, + isCancelled: () => !!signal?.aborted || identitySignal.aborted, + }) + if (ready instanceof Error) return ready + if (signal?.aborted || identitySignal.aborted) + return new DeviceBootstrapError("DEVICE_BOOTSTRAP_RETRY_REQUIRED") + return res.authToken + }, }) - if (res instanceof Error) return res - - if (res.newEntity) { - const account = await createAccountForDeviceAccount({ - userId: res.kratosUserId, - deviceId, - }) - if (account instanceof Error) return account - } - - return res.authToken } const isAllowedToOnboard = async ({ diff --git a/src/app/cash-wallet-cutover/ensure-usdt-wallet.ts b/src/app/cash-wallet-cutover/ensure-usdt-wallet.ts index 7a36e1f02..5c3efac23 100644 --- a/src/app/cash-wallet-cutover/ensure-usdt-wallet.ts +++ b/src/app/cash-wallet-cutover/ensure-usdt-wallet.ts @@ -2,6 +2,7 @@ // repositories at module load, which drags the entire accounts module (and // its side effects) into every consumer of the presentation path. import { updateDefaultWalletId } from "@app/accounts/update-default-walletid" +import { DeviceBootstrapError } from "@domain/authentication/device-bootstrap-error" import { WalletType } from "@domain/wallets" import { ErrorLevel, WalletCurrency } from "@domain/shared" import { LockService } from "@services/lock" @@ -61,16 +62,40 @@ export const ensureUsdtWalletForAccount = async ({ walletsRepo?: EnsureWalletsRepository updateDefaultWallet?: typeof updateDefaultWalletId }): Promise => { - const created = await LockService().lockAccountId(account.id, async () => { + const created = await LockService().lockAccountId(account.id, async (signal) => { // Re-check INSIDE the lock: the concurrent resolution that beat us to the // lock has already created the wallet, and finding it here is the whole // point of locking before creating. const wallets = await walletsRepo.listByAccountId(account.id) if (wallets instanceof Error) return wallets - - const persistNew = walletsRepo.persistNew ?? WalletsRepository().persistNew + if (signal?.aborted) + return new DeviceBootstrapError("DEVICE_BOOTSTRAP_RETRY_REQUIRED") const existing = wallets.find((wallet) => wallet.currency === WalletCurrency.Usdt) + const legacyUsdWallet = wallets.find( + (wallet) => wallet.currency === WalletCurrency.Usd, + ) + if ( + !existing || + (legacyUsdWallet && account.defaultWalletId === legacyUsdWallet.id) + ) { + // Controlled device bootstrap owns any missing-resource repair. This + // public presentation read must not re-dispatch an uncertain provider + // create, even after the same account upgrades to phone login. + try { + const { hasControlledDeviceBootstrap } = await import( + "@services/mongoose/device-bootstrap" + ) + const controlled = await hasControlledDeviceBootstrap(account.id) + if (signal?.aborted) + return new DeviceBootstrapError("DEVICE_BOOTSTRAP_RETRY_REQUIRED") + if (controlled) + return existing ?? new DeviceBootstrapError("DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") + } catch { + return new DeviceBootstrapError("DEVICE_BOOTSTRAP_RETRY_REQUIRED") + } + } + const persistNew = walletsRepo.persistNew ?? WalletsRepository().persistNew const usdtWallet = existing ?? (await persistNew({ @@ -80,9 +105,6 @@ export const ensureUsdtWalletForAccount = async ({ })) if (usdtWallet instanceof Error) return usdtWallet - const legacyUsdWallet = wallets.find( - (wallet) => wallet.currency === WalletCurrency.Usd, - ) if (legacyUsdWallet && account.defaultWalletId === legacyUsdWallet.id) { const updated = await updateDefaultWallet({ accountId: account.id, diff --git a/src/domain/accounts/device-bootstrap.ts b/src/domain/accounts/device-bootstrap.ts new file mode 100644 index 000000000..3142fee83 --- /dev/null +++ b/src/domain/accounts/device-bootstrap.ts @@ -0,0 +1,83 @@ +export type BootstrapCurrency = "USD" | "USDT" +export type BootstrapSlot = { + state: "planned" | "dispatching" | "known" | "attached" | "blocked" + attemptId?: string + providerId?: string + origin?: "created" | "local" +} +export type DeviceBootstrapJournalRecord = { + _id: string + version: 1 + userId: string + accountId: string + mode: "new" | "legacy-local" + phase: "initializing" | "ready" + wallets: { USD: BootstrapSlot; USDT: BootstrapSlot } + createdAt: Date + updatedAt: Date +} +export type BootstrapAccount = { + id: string + userId: string + level?: number + status: string + defaultWalletId?: string +} +export type BootstrapWallet = { + id: string + accountId: string + currency: string + type: string +} +export type BootstrapUserBinding = { + userId: string + deviceId?: string + hasPhone: boolean +} +export type DeviceIdentityProof = { + id: string + schema: string + state: string + username?: string + totpEnabled: boolean + bootstrap?: { version: unknown; deviceIdHash: unknown } +} + +/** All mutating predicates bind immutable journal owner/account identity. */ +export interface DeviceBootstrapStore { + journal(key: string): Promise + reserve(record: DeviceBootstrapJournalRecord): Promise + accounts(userId: string): Promise + userBindings(userId: string, deviceId: string): Promise + bindDeviceMetadata(userId: string, deviceId: string): Promise + ensureAccount(record: DeviceBootstrapJournalRecord): Promise + wallets(accountId: string): Promise + attach( + record: DeviceBootstrapJournalRecord, + currency: BootstrapCurrency, + providerId: string, + ): Promise + transition( + record: DeviceBootstrapJournalRecord, + currency: BootstrapCurrency, + from: BootstrapSlot, + to: BootstrapSlot, + ): Promise + setMissingDefault(record: DeviceBootstrapJournalRecord, walletId: string): Promise + ready(record: DeviceBootstrapJournalRecord): Promise +} +export type DeviceBootstrapDependencies = { + store: DeviceBootstrapStore + identity(userId: string): Promise + createProviderAccount( + accountId: string, + currency: BootstrapCurrency, + ): Promise<{ id?: string; name?: string; currencyId?: number }> + currencyId(currency: BootstrapCurrency): number + newAccountId(): string + newAttemptId(): string + withAccountLock( + accountId: string, + work: (aborted: () => boolean) => Promise, + ): Promise +} diff --git a/src/domain/authentication/device-bootstrap-error.ts b/src/domain/authentication/device-bootstrap-error.ts new file mode 100644 index 000000000..71d18894a --- /dev/null +++ b/src/domain/authentication/device-bootstrap-error.ts @@ -0,0 +1,23 @@ +import { AuthenticationError } from "./errors" + +export type DeviceBootstrapCode = + | "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED" + | "DEVICE_BOOTSTRAP_RETRY_REQUIRED" + | "DEVICE_BOOTSTRAP_OWNER_MISMATCH" + | "DEVICE_BOOTSTRAP_INCOMPATIBLE_IDENTITY" + +const messages: Record = { + DEVICE_BOOTSTRAP_SUPPORT_REQUIRED: + "Account setup needs support review before it can continue", + DEVICE_BOOTSTRAP_RETRY_REQUIRED: + "Account setup is incomplete; explicitly resume with the same credentials", + DEVICE_BOOTSTRAP_OWNER_MISMATCH: "Account setup ownership could not be confirmed", + DEVICE_BOOTSTRAP_INCOMPATIBLE_IDENTITY: "This identity cannot use device-account setup", +} + +/** Customer-safe machine classification; never stores credentials or provider prose. */ +export class DeviceBootstrapError extends AuthenticationError { + constructor(readonly code: DeviceBootstrapCode) { + super(messages[code]) + } +} diff --git a/src/servers/authorization/index.ts b/src/servers/authorization/index.ts index 4c3e656fc..1070c669a 100644 --- a/src/servers/authorization/index.ts +++ b/src/servers/authorization/index.ts @@ -1,3 +1,4 @@ +import { DeviceBootstrapError } from "@domain/authentication/device-bootstrap-error" import cors from "cors" import express, { NextFunction, Request, Response } from "express" @@ -204,27 +205,47 @@ authRouter.post("/create/device-account", async (req: Request, res: Response) => const deviceId = checkedToDeviceId(deviceIdRaw) if (deviceId instanceof Error) { - return res.status(422).send({ error: `Device ID error, ${deviceId.message}` }) + return res.status(422).send({ error: "Invalid device credentials" }) } + const controller = new AbortController() + const abort = () => controller.abort() + const closed = () => { + if (!res.writableEnded) abort() + } + req.once("aborted", abort) + res.once("close", closed) try { const authToken = await Authentication.loginWithDevice({ username, password, ip, deviceId, + signal: controller.signal, }) + if (controller.signal.aborted || res.destroyed) return if (authToken instanceof Error) { recordExceptionInCurrentSpan({ error: authToken }) - return res.status(500).send({ error: authToken.message }) + return res.status(500).send({ + error: authToken.message, + ...(authToken instanceof DeviceBootstrapError ? { code: authToken.code } : {}), + }) } - addAttributesToCurrentSpan({ "login.deviceAccount": deviceId }) + addAttributesToCurrentSpan({ + "login.method": "device", + "login.deviceBootstrapReady": true, + }) return res.status(200).send({ result: authToken, }) - } catch (err) { - recordExceptionInCurrentSpan({ error: err }) - return res.status(500).send({ error: parseErrorMessageFromUnknown(err) }) + } catch { + const error = new DeviceBootstrapError("DEVICE_BOOTSTRAP_RETRY_REQUIRED") + recordExceptionInCurrentSpan({ error }) + if (!controller.signal.aborted && !res.destroyed) + return res.status(500).send({ error: error.message, code: error.code }) + } finally { + req.off("aborted", abort) + res.off("close", closed) } }) diff --git a/src/services/kratos/auth-username-password-deviceid.ts b/src/services/kratos/auth-username-password-deviceid.ts index bd5558cdd..3e14c5ad5 100644 --- a/src/services/kratos/auth-username-password-deviceid.ts +++ b/src/services/kratos/auth-username-password-deviceid.ts @@ -1,3 +1,5 @@ +import { createHash } from "crypto" + import { CreateIdentityBody } from "@ory/client" import { wrapAsyncFunctionsToRunInSpan } from "@services/tracing" @@ -32,16 +34,20 @@ export const AuthWithUsernamePasswordDeviceIdService = state: "active", traits, schema_id: "username_password_deviceid_v0", + metadata_admin: { + deviceBootstrap: { + version: 1, + deviceIdHash: createHash("sha256").update(username).digest("hex"), + }, + }, } await kratosAdmin.createIdentity({ createIdentityBody }) } catch (err) { // we continue if there is 409/Conflit, // because it means the identity already exists - if (isAxiosError(err) && err.response?.status !== 409) { - return new UnknownKratosError( - `Impossible to create identity: ${err.message || err}`, - ) + if (!isAxiosError(err) || err.response?.status !== 409) { + return new UnknownKratosError("Unable to create a device identity") } newEntity = false @@ -59,19 +65,28 @@ export const AuthWithUsernamePasswordDeviceIdService = }) const authToken = result.data.session_token as AuthToken - if (!result.data.session.identity) return new InvalidIdentitySessionKratosError() + const identity = result.data.session.identity + if ( + !identity || + result.data.session.active !== true || + identity.state !== "active" || + identity.schema_id !== "username_password_deviceid_v0" || + identity.traits?.username !== username || + typeof identity.id !== "string" || + !identity.id || + typeof authToken !== "string" || + !authToken || + authToken.length > 16_384 + ) { + return new InvalidIdentitySessionKratosError() + } // note: this only works when whoami: required_aal = aal1 - const kratosUserId = result.data.session.identity.id as UserId + const kratosUserId = identity.id as UserId return { authToken, kratosUserId, newEntity } - } catch (err) { - if (err instanceof Error) { - return new UnknownKratosError( - `Impossible to get authToken: ${err.message || err}`, - ) - } - return new UnknownKratosError(err) + } catch { + return new UnknownKratosError("Unable to authenticate the device identity") } } diff --git a/src/services/kratos/device-bootstrap-identity.ts b/src/services/kratos/device-bootstrap-identity.ts new file mode 100644 index 000000000..e9f0aa921 --- /dev/null +++ b/src/services/kratos/device-bootstrap-identity.ts @@ -0,0 +1,48 @@ +import type { DeviceIdentityProof } from "@domain/accounts/device-bootstrap" +import { DeviceBootstrapError } from "@domain/authentication/device-bootstrap-error" + +import { kratosAdmin } from "./private" + +/** Fresh exact raw projection: generic AnyIdentity omits the device username. */ +export const readDeviceBootstrapIdentity = async ( + userId: string, +): Promise => { + try { + const { data } = await kratosAdmin.getIdentity({ + id: userId, + includeCredential: ["totp"], + }) + if ( + typeof data.id !== "string" || + typeof data.schema_id !== "string" || + typeof data.state !== "string" + ) { + throw new DeviceBootstrapError("DEVICE_BOOTSTRAP_INCOMPATIBLE_IDENTITY") + } + const candidate: unknown = + data.metadata_admin && typeof data.metadata_admin === "object" + ? (data.metadata_admin as Record).deviceBootstrap + : undefined + const marker = + candidate && typeof candidate === "object" + ? (candidate as Record) + : undefined + if (candidate !== undefined && !marker) + throw new DeviceBootstrapError("DEVICE_BOOTSTRAP_INCOMPATIBLE_IDENTITY") + return { + id: data.id, + schema: data.schema_id, + state: data.state, + username: + typeof data.traits?.username === "string" ? data.traits.username : undefined, + totpEnabled: Object.prototype.hasOwnProperty.call(data.credentials ?? {}, "totp"), + bootstrap: marker + ? { version: marker.version, deviceIdHash: marker.deviceIdHash } + : undefined, + } + } catch (error) { + throw error instanceof DeviceBootstrapError + ? error + : new DeviceBootstrapError("DEVICE_BOOTSTRAP_RETRY_REQUIRED") + } +} diff --git a/src/services/mongoose/device-bootstrap.ts b/src/services/mongoose/device-bootstrap.ts new file mode 100644 index 000000000..688c0e11e --- /dev/null +++ b/src/services/mongoose/device-bootstrap.ts @@ -0,0 +1,363 @@ +import { getDefaultAccountsConfig, getDefaultFCMTopics } from "@config" +import type { + BootstrapAccount, + BootstrapSlot, + BootstrapWallet, + DeviceBootstrapJournalRecord, + DeviceBootstrapStore, +} from "@domain/accounts/device-bootstrap" +import { DeviceBootstrapError } from "@domain/authentication/device-bootstrap-error" + +import { Account, DeviceAccountBootstrap, User, Wallet } from "./schema" +import { toObjectId } from "./utils" + +const durable = { w: "majority" as const, j: true } +const failure = (code: ConstructorParameters[0]) => + new DeviceBootstrapError(code) +const support = () => failure("DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") +const owner = () => failure("DEVICE_BOOTSTRAP_OWNER_MISMATCH") +const safe = async (work: () => Promise): Promise => { + try { + return await work() + } catch (error) { + throw error instanceof DeviceBootstrapError + ? error + : failure("DEVICE_BOOTSTRAP_RETRY_REQUIRED") + } +} +const identifier = (value: unknown): value is string => + typeof value === "string" && + value.length > 0 && + value.length <= 256 && + !/[^A-Za-z0-9_-]/.test(value) +const slot = (value: BootstrapSlot): boolean => { + if ( + !value || + !["planned", "dispatching", "known", "attached", "blocked"].includes(value.state) + ) + return false + if (value.attemptId !== undefined && !identifier(value.attemptId)) return false + if (value.providerId !== undefined && !identifier(value.providerId)) return false + if (value.state === "planned") + return value.attemptId === undefined && value.providerId === undefined + if (value.state === "dispatching") + return value.attemptId !== undefined && value.providerId === undefined + if (value.state === "known") + return ( + value.attemptId !== undefined && + value.providerId !== undefined && + value.origin === "created" + ) + if (value.state === "attached") + return ( + value.providerId !== undefined && ["created", "local"].includes(value.origin ?? "") + ) + return true +} +const journal = ( + value: DeviceBootstrapJournalRecord | null, +): DeviceBootstrapJournalRecord | null => { + if (!value) return null + if ( + value.version !== 1 || + !/^[a-f0-9]{64}$/.test(value._id) || + !identifier(value.userId) || + !/^[a-f0-9]{24}$/.test(value.accountId) || + !["new", "legacy-local"].includes(value.mode) || + !["initializing", "ready"].includes(value.phase) || + !slot(value.wallets?.USD) || + !slot(value.wallets?.USDT) + ) + throw support() + return value +} +const account = (value: { + _id: unknown + kratosUserId?: string + level?: number + defaultWalletId?: string + statusHistory?: { status: string }[] +}): BootstrapAccount => { + const id = String(value._id) + const status = value.statusHistory?.at(-1)?.status + if ( + !/^[a-f0-9]{24}$/.test(id) || + !identifier(value.kratosUserId) || + !status || + !["new", "pending", "active", "locked", "closed"].includes(status) || + (value.level !== undefined && ![0, 1, 2, 3].includes(value.level)) + ) + throw support() + return { + id, + userId: value.kratosUserId, + level: value.level, + status, + defaultWalletId: value.defaultWalletId, + } +} +const wallet = (value: { + id: string + _accountId: unknown + currency: string + type: string +}): BootstrapWallet => { + if (!identifier(value.id) || !["checking", "external"].includes(value.type)) + throw support() + return { + id: value.id, + accountId: String(value._accountId), + currency: value.currency, + type: value.type, + } +} +const ownerFilter = (record: DeviceBootstrapJournalRecord) => ({ + _id: record._id, + userId: record.userId, + accountId: record.accountId, + version: 1, + mode: record.mode, +}) +const projectedAccount = { + "_id": 1, + "kratosUserId": 1, + "level": 1, + "defaultWalletId": 1, + "statusHistory.status": 1, +} + +/** Durable journal updates and narrow local writes only. Never calls IBEX. */ +export const DeviceBootstrapRepository = (): DeviceBootstrapStore => ({ + journal: (key) => + safe(async () => journal(await DeviceAccountBootstrap.findOne({ _id: key }).lean())), + reserve: (record) => + safe(async () => { + const result = journal( + await DeviceAccountBootstrap.findOneAndUpdate( + { _id: record._id, userId: record.userId }, + { $setOnInsert: record }, + { upsert: true, new: true, writeConcern: durable, setDefaultsOnInsert: false }, + ).lean(), + ) + if (!result) throw support() + return result + }), + accounts: (userId) => + safe(async () => { + const records = await Account.find( + { kratosUserId: { $eq: userId } }, + projectedAccount, + ) + .limit(2) + .lean() + return records.map(account) + }), + userBindings: (userId, deviceId) => + safe(async () => { + const records = await User.find( + { $or: [{ userId: { $eq: userId } }, { deviceId: { $eq: deviceId } }] }, + { userId: 1, deviceId: 1, phone: 1 }, + ) + .limit(3) + .lean() + return records.map((record) => ({ + userId: record.userId, + deviceId: record.deviceId, + hasPhone: + record.phone !== undefined && record.phone !== null && record.phone !== "", + })) + }), + bindDeviceMetadata: (userId, deviceId) => + safe(async () => { + const records = await User.find( + { $or: [{ userId: { $eq: userId } }, { deviceId: { $eq: deviceId } }] }, + { userId: 1, deviceId: 1, phone: 1 }, + ) + .limit(3) + .lean() + if ( + records.length > 1 || + records.some( + (record) => + record.userId !== userId || + (record.deviceId !== undefined && record.deviceId !== deviceId) || + !!record.phone, + ) + ) + throw owner() + if (records.length === 0) { + await User.updateOne( + { userId }, + { + $setOnInsert: { userId, deviceId, notificationTopics: getDefaultFCMTopics() }, + }, + { upsert: true, writeConcern: durable }, + ) + } else { + const result = await User.updateOne( + { userId, deviceId: { $in: [null, deviceId] }, phone: { $in: [null, ""] } }, + { $set: { deviceId } }, + { writeConcern: durable }, + ) + if (result.matchedCount !== 1) throw owner() + } + }), + ensureAccount: (record) => + safe(async () => { + const value = await Account.findOneAndUpdate( + { _id: toObjectId(record.accountId), kratosUserId: record.userId }, + { + $setOnInsert: { + kratosUserId: record.userId, + level: 0, + role: "user", + contactEnabled: true, + statusHistory: [ + { + status: getDefaultAccountsConfig().initialStatus, + comment: "Initial device account status", + }, + ], + }, + }, + { upsert: true, new: true, writeConcern: durable, runValidators: true }, + ).lean() + if (!value) throw owner() + return account(value) + }), + wallets: (accountId) => + safe(async () => { + const records = await Wallet.find( + { _accountId: toObjectId(accountId) }, + { id: 1, _accountId: 1, type: 1, currency: 1 }, + ) + .limit(1001) + .lean() + if (records.length > 1000) throw support() + return records.map(wallet) + }), + attach: (record, currency, providerId) => + safe(async () => { + const before = await Wallet.find( + { id: { $eq: providerId } }, + { id: 1, _accountId: 1, type: 1, currency: 1 }, + ) + .limit(2) + .lean() + if ( + before.length > 1 || + before.some( + (value) => + String(value._accountId) !== record.accountId || + value.currency !== currency || + value.type !== "checking", + ) + ) + throw owner() + const result = await Wallet.findOneAndUpdate( + { + id: providerId, + _accountId: toObjectId(record.accountId), + type: "checking", + currency, + }, + { + $setOnInsert: { + id: providerId, + _accountId: toObjectId(record.accountId), + type: "checking", + currency, + }, + }, + { upsert: true, new: true, writeConcern: durable, runValidators: true }, + ).lean() + if ( + !result || + String(result._accountId) !== record.accountId || + result.id !== providerId || + result.currency !== currency || + result.type !== "checking" + ) + throw owner() + const after = await Wallet.find( + { id: { $eq: providerId } }, + { id: 1, _accountId: 1, type: 1, currency: 1 }, + ) + .limit(2) + .lean() + if ( + after.length !== 1 || + String(after[0]._accountId) !== record.accountId || + after[0].currency !== currency || + after[0].type !== "checking" + ) + throw owner() + }), + transition: (record, currency, from, to) => + safe(async () => { + if (!slot(from) || !slot(to)) throw support() + const prefix = `wallets.${currency}` + const filter = { + ...ownerFilter(record), + phase: "initializing", + [`${prefix}.state`]: from.state, + [`${prefix}.attemptId`]: from.attemptId ?? { $exists: false }, + [`${prefix}.providerId`]: from.providerId ?? { $exists: false }, + } + const value = journal( + await DeviceAccountBootstrap.findOneAndUpdate( + filter, + { $set: { [prefix]: to, updatedAt: new Date() } }, + { new: true, writeConcern: durable, runValidators: true }, + ).lean(), + ) + if (!value) throw failure("DEVICE_BOOTSTRAP_RETRY_REQUIRED") + return value + }), + setMissingDefault: (record, walletId) => + safe(async () => { + const result = await Account.updateOne( + { + _id: toObjectId(record.accountId), + kratosUserId: record.userId, + level: { $in: [null, 0] }, + defaultWalletId: null, + }, + { $set: { defaultWalletId: walletId } }, + { writeConcern: durable }, + ) + // A valid concurrent preference wins. The engine's fresh read decides + // readiness; this operation never overwrites a nonmissing default. + if (result.matchedCount > 1) throw owner() + }), + ready: (record) => + safe(async () => { + const value = journal( + await DeviceAccountBootstrap.findOneAndUpdate( + { + ...ownerFilter(record), + "phase": "initializing", + "wallets.USD.state": "attached", + "wallets.USDT.state": "attached", + "wallets.USD.providerId": record.wallets.USD.providerId, + "wallets.USDT.providerId": record.wallets.USDT.providerId, + }, + { $set: { phase: "ready", updatedAt: new Date() } }, + { new: true, writeConcern: durable }, + ).lean(), + ) + if (!value) throw failure("DEVICE_BOOTSTRAP_RETRY_REQUIRED") + return value + }), +}) + +/** Read-only suppression guard. Caller holds the existing account lock; never + * nests it. It deliberately survives trial→phone schema/level upgrades. */ +export const hasControlledDeviceBootstrap = (accountId: string): Promise => + safe(async () => { + const value = await DeviceAccountBootstrap.findOne( + { accountId: { $eq: accountId } }, + { _id: 1 }, + ).lean() + return value !== null + }) diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index 0bf869a95..60a947bd4 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -1,5 +1,7 @@ import crypto from "crypto" +import type { DeviceBootstrapJournalRecord } from "@domain/accounts/device-bootstrap" + import { getDefaultAccountsConfig, getFeesConfig, @@ -1070,3 +1072,42 @@ export const BridgeWithdrawal = mongoose.model( "BridgeWithdrawal", BridgeWithdrawalSchema, ) + +const DeviceBootstrapSlotSchema = new Schema( + { + state: { + type: String, + required: true, + enum: ["planned", "dispatching", "known", "attached", "blocked"], + }, + attemptId: String, + providerId: String, + origin: { type: String, enum: ["created", "local"] }, + }, + { _id: false }, +) + +const DeviceAccountBootstrapSchema = new Schema( + { + _id: { type: String, required: true }, + version: { type: Number, required: true, enum: [1] }, + userId: { type: String, required: true }, + accountId: { type: String, required: true, index: true }, + mode: { type: String, required: true, enum: ["new", "legacy-local"] }, + phase: { type: String, required: true, enum: ["initializing", "ready"] }, + wallets: { + USD: { type: DeviceBootstrapSlotSchema, required: true }, + USDT: { type: DeviceBootstrapSlotSchema, required: true }, + }, + createdAt: { type: Date, required: true }, + updatedAt: { type: Date, required: true }, + }, + { versionKey: false }, +) + +// The immutable hash primary key is the only uniqueness dependency. No legacy +// User/device index is created and no credential/token is stored here. +export const DeviceAccountBootstrap = mongoose.model( + "DeviceAccountBootstrap", + DeviceAccountBootstrapSchema, +) diff --git a/test/flash/unit/app/accounts/device-account-bootstrap.spec.ts b/test/flash/unit/app/accounts/device-account-bootstrap.spec.ts new file mode 100644 index 000000000..833fbda2e --- /dev/null +++ b/test/flash/unit/app/accounts/device-account-bootstrap.spec.ts @@ -0,0 +1,602 @@ +import { createHash } from "crypto" + +import { resumeDeviceAccountBootstrap } from "@app/accounts/device-account-bootstrap" +import type { + BootstrapAccount, + BootstrapCurrency, + BootstrapSlot, + BootstrapWallet, + DeviceBootstrapDependencies, + DeviceBootstrapJournalRecord, + DeviceBootstrapStore, + DeviceIdentityProof, +} from "@domain/accounts/device-bootstrap" +import { + DeviceBootstrapError, + DeviceBootstrapCode, +} from "@domain/authentication/device-bootstrap-error" + +const USER = "kratos-owner" +const DEVICE = "11111111-1111-4111-8111-111111111111" +const ACCOUNT = "000000000000000000000001" +const KEY = createHash("sha256").update(DEVICE).digest("hex") +const copy = (value: T): T => structuredClone(value) +const error = (code: DeviceBootstrapCode) => new DeviceBootstrapError(code) + +class MemoryStore implements DeviceBootstrapStore { + saved: DeviceBootstrapJournalRecord | null = null + accountRows: BootstrapAccount[] = [] + walletRows: BootstrapWallet[] = [] + bindings: { userId: string; deviceId?: string; hasPhone: boolean }[] = [] + events: string[] = [] + faults = new Map() + afterAction?: (name: string) => void + async action(name: string, work: () => T): Promise { + this.events.push(name) + const mode = this.faults.get(name) + this.faults.delete(name) + if (mode === "before") throw error("DEVICE_BOOTSTRAP_RETRY_REQUIRED") + const value = work() + this.afterAction?.(name) + if (mode === "after") throw error("DEVICE_BOOTSTRAP_RETRY_REQUIRED") + return copy(value) + } + async journal() { + return this.action("journal", () => this.saved) + } + async reserve(record: DeviceBootstrapJournalRecord) { + return this.action("reserve", () => { + if (this.saved && this.saved.userId !== record.userId) + throw error("DEVICE_BOOTSTRAP_OWNER_MISMATCH") + this.saved ??= copy(record) + return this.saved + }) + } + async accounts() { + return this.action("accounts", () => this.accountRows) + } + async userBindings() { + return this.action("userBindings", () => this.bindings) + } + async bindDeviceMetadata(userId: string, deviceId: string) { + await this.action("bindDeviceMetadata", () => { + if (!this.bindings.length) this.bindings.push({ userId, deviceId, hasPhone: false }) + else this.bindings[0].deviceId = deviceId + }) + } + async ensureAccount(record: DeviceBootstrapJournalRecord) { + return this.action("ensureAccount", () => { + if (!this.accountRows.length) + this.accountRows.push({ + id: record.accountId, + userId: record.userId, + level: 0, + status: "active", + }) + return this.accountRows[0] + }) + } + async wallets(accountId: string) { + return this.action("wallets", () => + this.walletRows.filter((wallet) => wallet.accountId === accountId), + ) + } + async attach( + record: DeviceBootstrapJournalRecord, + currency: BootstrapCurrency, + providerId: string, + ) { + await this.action(`attach:${currency}`, () => { + const existing = this.walletRows.find((wallet) => wallet.id === providerId) + if ( + existing && + (existing.accountId !== record.accountId || existing.currency !== currency) + ) + throw error("DEVICE_BOOTSTRAP_OWNER_MISMATCH") + if (!existing) + this.walletRows.push({ + id: providerId, + accountId: record.accountId, + currency, + type: "checking", + }) + }) + } + async transition( + record: DeviceBootstrapJournalRecord, + currency: BootstrapCurrency, + from: BootstrapSlot, + to: BootstrapSlot, + ) { + return this.action(`transition:${currency}:${to.state}`, () => { + if ( + !this.saved || + this.saved.userId !== record.userId || + this.saved.accountId !== record.accountId + ) + throw error("DEVICE_BOOTSTRAP_OWNER_MISMATCH") + const current = this.saved.wallets[currency] + if ( + current.state !== from.state || + current.attemptId !== from.attemptId || + current.providerId !== from.providerId + ) + throw error("DEVICE_BOOTSTRAP_RETRY_REQUIRED") + this.saved.wallets[currency] = copy(to) + return this.saved + }) + } + async setMissingDefault(record: DeviceBootstrapJournalRecord, walletId: string) { + await this.action("setMissingDefault", () => { + const account = this.accountRows.find( + (account) => account.id === record.accountId && account.userId === record.userId, + ) + if (account && account.defaultWalletId === undefined && (account.level ?? 0) === 0) + account.defaultWalletId = walletId + }) + } + async ready(record: DeviceBootstrapJournalRecord) { + return this.action("ready", () => { + if ( + !this.saved || + this.saved._id !== record._id || + this.saved.wallets.USD.state !== "attached" || + this.saved.wallets.USDT.state !== "attached" + ) + throw error("DEVICE_BOOTSTRAP_RETRY_REQUIRED") + this.saved.phase = "ready" + return this.saved + }) + } +} +const fixture = () => { + const store = new MemoryStore() + const identity: DeviceIdentityProof = { + id: USER, + schema: "username_password_deviceid_v0", + state: "active", + username: DEVICE, + totpEnabled: false, + bootstrap: { version: 1, deviceIdHash: KEY }, + } + const providerCalls: BootstrapCurrency[] = [] + const providerResources: string[] = [] + const queues = new Map>() + let sequence = 0, + accountSequence = 0, + cancelled = false + const hooks: { + provider?: (currency: BootstrapCurrency) => Promise + identity?: () => void + } = {} + const deps: DeviceBootstrapDependencies = { + store, + identity: async () => { + hooks.identity?.() + return copy(identity) + }, + newAccountId: () => (++accountSequence).toString(16).padStart(24, "0"), + newAttemptId: () => `attempt-${++sequence}`, + currencyId: (currency) => (currency === "USD" ? 1 : 2), + createProviderAccount: async (accountId, currency) => { + providerCalls.push(currency) + expect(store.saved?.wallets[currency].state).toBe("dispatching") + const id = `provider-${currency}-${providerCalls.filter((call) => call === currency).length}` + providerResources.push(id) + await hooks.provider?.(currency) + return { id, name: accountId, currencyId: currency === "USD" ? 1 : 2 } + }, + withAccountLock: async (accountId, work) => { + const next = (queues.get(accountId) ?? Promise.resolve()).then(() => + work(() => false), + ) + queues.set( + accountId, + next.catch(() => undefined), + ) + return next + }, + } + const run = () => + resumeDeviceAccountBootstrap( + { userId: USER, deviceId: DEVICE, isCancelled: () => cancelled }, + deps, + ) + return { + store, + identity, + providerCalls, + providerResources, + deps, + hooks, + run, + cancel: () => { + cancelled = true + }, + resume: () => { + cancelled = false + }, + } +} +const expectCode = (value: unknown, code: DeviceBootstrapCode) => { + expect(value).toBeInstanceOf(DeviceBootstrapError) + expect((value as DeviceBootstrapError).code).toBe(code) +} +const existing = ( + store: MemoryStore, + options: { default?: string; level?: number; status?: string; both?: boolean } = {}, +) => { + store.accountRows = [ + { + id: ACCOUNT, + userId: USER, + level: options.level ?? 0, + status: options.status ?? "active", + defaultWalletId: options.default, + }, + ] + store.walletRows = [ + { id: "legacy-usd", accountId: ACCOUNT, type: "checking", currency: "USD" }, + ] + if (options.both !== false) + store.walletRows.push({ + id: "legacy-usdt", + accountId: ACCOUNT, + type: "checking", + currency: "USDT", + }) +} + +it("journals provider intent and known IDs before attachment and returns ready only after both owned cash wallets/default", async () => { + const f = fixture() + expect(await f.run()).toBe(true) + expect(f.providerCalls).toEqual(["USD", "USDT"]) + expect(f.store.saved?.phase).toBe("ready") + expect(f.store.saved?.wallets.USD.providerId).toBe("provider-USD-1") + expect(f.store.events.indexOf("transition:USD:known")).toBeLessThan( + f.store.events.indexOf("attach:USD"), + ) + expect(f.store.accountRows[0].defaultWalletId).toBe("provider-USDT-1") + expect(f.store.walletRows).toHaveLength(2) + const before = f.providerCalls.length + expect(await f.run()).toBe(true) + expect(f.providerCalls.length).toBe(before) + expect(f.store.walletRows).toHaveLength(2) +}) + +it.each(["reserve", "ensureAccount", "bindDeviceMetadata", "transition:USD:dispatching"])( + "resumes after a pre-provider %s storage failure without duplicating resources", + async (operation) => { + const f = fixture() + f.store.faults.set(operation, "before") + expectCode(await f.run(), "DEVICE_BOOTSTRAP_RETRY_REQUIRED") + expect(f.providerCalls).toHaveLength(0) + expect(await f.run()).toBe(true) // Admin-only provenance survives missing first Mongo write. + expect(f.providerCalls).toEqual(["USD", "USDT"]) + }, +) + +it("does not infer no provider acceptance from a lost dispatch-intent storage acknowledgment", async () => { + const f = fixture() + f.store.faults.set("transition:USD:dispatching", "after") + expectCode(await f.run(), "DEVICE_BOOTSTRAP_RETRY_REQUIRED") + expect(f.providerCalls).toHaveLength(0) + for (let attempt = 0; attempt < 3; attempt++) + expectCode(await f.run(), "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") + expect(f.providerCalls).toHaveLength(0) +}) + +it.each(["USD", "USDT"] as BootstrapCurrency[])( + "never recreates a provider resource after unknown %s acceptance", + async (currency) => { + const f = fixture() + f.hooks.provider = async (actual) => { + if (actual === currency) throw new Error("PRIVATE_PROVIDER_REPLY") + } + expectCode(await f.run(), "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") + const calls = [...f.providerCalls] + f.hooks.provider = undefined + for (let attempt = 0; attempt < 3; attempt++) + expectCode(await f.run(), "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") + expect(f.providerCalls).toEqual(calls) + expect(f.store.saved?.wallets[currency].state).toBe("dispatching") + }, +) + +it.each(["before", "after"] as const)( + "handles a %s-failure when persisting a known ID conservatively", + async (mode) => { + const f = fixture() + f.store.faults.set("transition:USD:known", mode) + expectCode(await f.run(), "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") + const result = await f.run() + expect(result === true ? true : result.code).toBe( + mode === "before" ? "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED" : true, + ) + expect(f.providerCalls).toEqual(mode === "before" ? ["USD"] : ["USD", "USDT"]) + }, +) + +it.each([ + "attach:USD", + "transition:USD:attached", + "attach:USDT", + "setMissingDefault", + "ready", +])( + "resumes known local partial %s without another provider account", + async (operation) => { + const f = fixture() + f.store.faults.set(operation, "after") + expectCode(await f.run(), "DEVICE_BOOTSTRAP_RETRY_REQUIRED") + expect(await f.run()).toBe(true) + expect(f.providerCalls).toEqual(["USD", "USDT"]) + expect(new Set(f.store.walletRows.map((wallet) => wallet.id)).size).toBe(2) + }, +) + +it("serializes concurrent same-owner initialization and reuses the winning journal/account", async () => { + const f = fixture() + expect(await Promise.all([f.run(), f.run(), f.run()])).toEqual([true, true, true]) + expect(f.providerCalls).toEqual(["USD", "USDT"]) + expect(f.store.accountRows).toHaveLength(1) + expect(f.store.walletRows).toHaveLength(2) +}) + +it("preserves legacy owned wallets, valid user-selected default, level and settings without provisioning", async () => { + const f = fixture() + f.identity.bootstrap = undefined + existing(f.store, { default: "legacy-usd", level: 3 }) + const before = copy(f.store.accountRows) + expect(await f.run()).toBe(true) + expect(f.providerCalls).toHaveLength(0) + expect(f.store.accountRows).toEqual(before) + expect(f.store.events).not.toContain("bindDeviceMetadata") + expect(f.store.events).not.toContain("ensureAccount") +}) + +it("repairs only a missing unambiguous owned legacy default and never calls a provider", async () => { + const f = fixture() + f.identity.bootstrap = undefined + existing(f.store) + expect(await f.run()).toBe(true) + expect(f.store.accountRows[0].defaultWalletId).toBe("legacy-usdt") + expect(f.providerCalls).toHaveLength(0) +}) + +it.each([ + "missing-account", + "missing-wallet", + "upgraded-missing-default", + "locked", + "foreign-default", + "ambiguous-default", +])("blocks untracked legacy %s rather than guessing provider recovery", async (mode) => { + const f = fixture() + f.identity.bootstrap = undefined + if (mode !== "missing-account") + existing(f.store, { + both: mode !== "missing-wallet", + level: mode === "upgraded-missing-default" ? 1 : 0, + status: mode === "locked" ? "locked" : "active", + default: mode === "foreign-default" ? "someone-elses-wallet" : undefined, + }) + if (mode === "ambiguous-default") + f.store.walletRows.push({ + id: "second-usdt", + accountId: ACCOUNT, + type: "checking", + currency: "USDT", + }) + const before = copy(f.store.accountRows) + expectCode(await f.run(), "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") + expect(f.providerCalls).toHaveLength(0) + expect(f.store.accountRows).toEqual(before) +}) + +it.each([ + "foreign-id", + "foreign-username", + "phone-schema", + "inactive", + "totp", + "marker-hash", + "marker-version", + "foreign-user-binding", + "phone-metadata", +])("refuses %s before repairing or provisioning", async (mode) => { + const f = fixture() + if (mode === "foreign-id") f.identity.id = "another-owner" + if (mode === "foreign-username") f.identity.username = "another-device" + if (mode === "phone-schema") f.identity.schema = "phone_no_password_v0" + if (mode === "inactive") f.identity.state = "inactive" + if (mode === "totp") f.identity.totpEnabled = true + if (mode === "marker-hash") f.identity.bootstrap!.deviceIdHash = "foreign" + if (mode === "marker-version") f.identity.bootstrap!.version = 2 + if (mode === "foreign-user-binding") + f.store.bindings = [{ userId: "another-owner", deviceId: DEVICE, hasPhone: false }] + if (mode === "phone-metadata") + f.store.bindings = [{ userId: USER, deviceId: DEVICE, hasPhone: true }] + expect(await f.run()).toBeInstanceOf(DeviceBootstrapError) + expect(f.providerCalls).toHaveLength(0) + expect(f.store.events).not.toContain("reserve") +}) + +it("persists a known provider ID after cancellation and resumes only local attachment", async () => { + const f = fixture() + f.hooks.provider = async () => { + f.cancel() + } + expectCode(await f.run(), "DEVICE_BOOTSTRAP_RETRY_REQUIRED") + expect(f.store.saved?.wallets.USD.state).toBe("known") + expect(f.store.walletRows).toHaveLength(0) + f.hooks.provider = undefined + f.resume() + expect(await f.run()).toBe(true) + expect(f.providerCalls).toEqual(["USD", "USDT"]) +}) + +it("does not repair an upgraded identity after a known in-flight provider result", async () => { + const f = fixture() + f.hooks.provider = async () => { + f.identity.schema = "phone_no_password_v0" + f.store.accountRows[0].level = 1 + } + expectCode(await f.run(), "DEVICE_BOOTSTRAP_INCOMPATIBLE_IDENTITY") + expect(f.store.saved?.wallets.USD.state).toBe("known") + expect(f.store.accountRows[0].level).toBe(1) + expect(f.store.walletRows).toHaveLength(0) + expect(f.providerCalls).toEqual(["USD"]) +}) + +it("blocks provider IDs claimed by another local account without changing that owner", async () => { + const f = fixture() + f.store.walletRows.push({ + id: "provider-USD-1", + accountId: "foreign", + currency: "USD", + type: "checking", + }) + expectCode(await f.run(), "DEVICE_BOOTSTRAP_OWNER_MISMATCH") + expect(f.store.walletRows[0].accountId).toBe("foreign") + expect(f.providerCalls).toEqual(["USD"]) +}) + +it("does not treat newly visible local wallets as reconciliation for an unknown dispatch", async () => { + const f = fixture() + f.hooks.provider = async () => { + throw new Error("Lost") + } + expectCode(await f.run(), "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") + f.store.walletRows.push( + { id: "unrelated-usd", accountId: ACCOUNT, currency: "USD", type: "checking" }, + { id: "unrelated-usdt", accountId: ACCOUNT, currency: "USDT", type: "checking" }, + ) + f.store.accountRows[0].defaultWalletId = "unrelated-usdt" + expectCode(await f.run(), "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") + expect(f.providerCalls).toEqual(["USD"]) +}) + +it("preserves a real owned External default while requiring exactly one Checking wallet of each cash currency", async () => { + const f = fixture() + f.identity.bootstrap = undefined + existing(f.store, { default: "owned-external", level: 2 }) + f.store.walletRows.push({ + id: "owned-external", + accountId: ACCOUNT, + type: "external", + currency: "BTC", + }) + expect(await f.run()).toBe(true) + expect(f.providerCalls).toHaveLength(0) + expect(f.store.accountRows[0].defaultWalletId).toBe("owned-external") + expect(f.store.accountRows[0].level).toBe(2) +}) +it("refuses duplicate owned cash wallets before any provider creation or arbitrary wallet selection", async () => { + const f = fixture() + existing(f.store, { default: "legacy-usd" }) + f.store.walletRows.push({ + id: "second-usd", + accountId: ACCOUNT, + type: "checking", + currency: "USD", + }) + expectCode(await f.run(), "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") + expect(f.providerCalls).toHaveLength(0) + expect(f.store.saved).toBeNull() + expect(f.store.accountRows[0].defaultWalletId).toBe("legacy-usd") +}) + +it("stops before provider dispatch when duplicate cash wallets appear during identity revalidation", async () => { + const f = fixture() + let reads = 0 + f.hooks.identity = () => { + if (++reads === 4) + f.store.walletRows.push( + { id: "racing-usd-1", accountId: ACCOUNT, currency: "USD", type: "checking" }, + { id: "racing-usd-2", accountId: ACCOUNT, currency: "USD", type: "checking" }, + ) + } + expectCode(await f.run(), "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") + expect(f.providerCalls).toHaveLength(0) + expect(f.store.events).not.toContain("attach:USD") +}) +it("persists an in-flight known ID but refuses attachment when duplicate local cash wallets appeared", async () => { + const f = fixture() + f.hooks.provider = async () => { + f.store.walletRows.push( + { id: "racing-usd-1", accountId: ACCOUNT, currency: "USD", type: "checking" }, + { id: "racing-usd-2", accountId: ACCOUNT, currency: "USD", type: "checking" }, + ) + } + expectCode(await f.run(), "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") + expect(f.store.saved?.wallets.USD.state).toBe("known") + expect(f.store.events).not.toContain("attach:USD") + expect(f.store.accountRows[0].defaultWalletId).toBeUndefined() +}) + +it("rolls back dispatch intent only for proven cancellation before the provider invocation", async () => { + const f = fixture() + f.store.afterAction = (name) => { + if (name === "transition:USD:dispatching") f.cancel() + } + expectCode(await f.run(), "DEVICE_BOOTSTRAP_RETRY_REQUIRED") + expect(f.providerCalls).toHaveLength(0) + expect(f.store.saved?.wallets.USD).toEqual({ state: "planned" }) + f.store.afterAction = undefined + f.resume() + expect(await f.run()).toBe(true) + expect(f.providerCalls).toEqual(["USD", "USDT"]) +}) + +it.each([ + { id: "observed-id", name: "foreign-account", currencyId: 1 }, + { id: "observed-id", name: ACCOUNT, currencyId: 2 }, + { id: "../unsafe", name: ACCOUNT, currencyId: 1 }, +])( + "blocks malformed provider receipts without attachment or repeated creation: %j", + async (receipt) => { + const f = fixture() + f.deps.createProviderAccount = jest.fn(async () => receipt) + expectCode(await f.run(), "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") + expect(f.store.saved?.wallets.USD.state).toBe("blocked") + expect(f.store.walletRows).toHaveLength(0) + expectCode(await f.run(), "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") + expect(f.deps.createProviderAccount).toHaveBeenCalledTimes(1) + expect(f.store.saved?.wallets.USD.providerId).toBe( + receipt.id === "../unsafe" ? undefined : "observed-id", + ) + }, +) + +it("does not recreate a required wallet removed after journal readiness", async () => { + const f = fixture() + expect(await f.run()).toBe(true) + f.store.walletRows = f.store.walletRows.filter((wallet) => wallet.currency !== "USDT") + const before = [...f.providerCalls] + expectCode(await f.run(), "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") + expect(f.providerCalls).toEqual(before) +}) + +it("allows explicit same-credential resume only after authoritative receipt reconciliation", async () => { + const f = fixture() + f.hooks.provider = async () => { + throw new Error("Acceptance unknown") + } + expectCode(await f.run(), "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED") + const intent = f.store.saved!.wallets.USD + // Simulate an operator independently verifying exact provider ownership/currency + // and atomically recording its actual receipt. Local wallet appearance alone is insufficient. + f.store.saved!.wallets.USD = { + ...intent, + state: "known", + providerId: "verified-usd", + origin: "created", + } + f.hooks.provider = undefined + expect(await f.run()).toBe(true) + expect(f.providerCalls).toEqual(["USD", "USDT"]) + expect(f.store.walletRows.find((wallet) => wallet.currency === "USD")?.id).toBe( + "verified-usd", + ) +}) diff --git a/test/flash/unit/app/accounts/device-bootstrap-adapters.spec.ts b/test/flash/unit/app/accounts/device-bootstrap-adapters.spec.ts new file mode 100644 index 000000000..c5b8de7bc --- /dev/null +++ b/test/flash/unit/app/accounts/device-bootstrap-adapters.spec.ts @@ -0,0 +1,64 @@ +jest.mock("@services/mongoose/device-bootstrap", () => ({ + DeviceBootstrapRepository: jest.fn(() => ({})), +})) +jest.mock("@services/kratos/device-bootstrap-identity", () => ({ + readDeviceBootstrapIdentity: jest.fn(), +})) +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { createAccount: jest.fn() }, +})) +const mockLock = jest.fn() +jest.mock("@services/lock", () => ({ + LockService: () => ({ lockAccountId: (...args: unknown[]) => mockLock(...args) }), +})) + +import { deviceBootstrapDependencies } from "@app/accounts/device-bootstrap-adapters" +import { DeviceBootstrapError } from "@domain/authentication/device-bootstrap-error" + +beforeEach(() => { + jest.resetAllMocks() + mockLock.mockImplementation( + async (_id: string, callback: (signal: { aborted: boolean }) => Promise) => { + // The actual adapter converts callback throws into a generic lock error. + try { + return await callback({ aborted: false }) + } catch { + return new Error("Converted lock callback error") + } + }, + ) +}) +it.each([ + "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED", + "DEVICE_BOOTSTRAP_RETRY_REQUIRED", + "DEVICE_BOOTSTRAP_OWNER_MISMATCH", + "DEVICE_BOOTSTRAP_INCOMPATIBLE_IDENTITY", +] as const)( + "preserves %s through the real callback-conversion boundary", + async (code) => { + await expect( + deviceBootstrapDependencies().withAccountLock("owned-account", async () => { + throw new DeviceBootstrapError(code) + }), + ).rejects.toMatchObject({ code }) + }, +) +it("does not expose an unknown callback exception to the lock converter or caller", async () => { + const result = await deviceBootstrapDependencies() + .withAccountLock("owned-account", async () => { + throw new Error("PRIVATE_PROVIDER_EXCEPTION") + }) + .catch((error: unknown) => error) + expect(result).toBeInstanceOf(DeviceBootstrapError) + expect((result as Error).message).not.toContain("PRIVATE_PROVIDER_EXCEPTION") +}) +it("preserves successful callback values and never takes an extra nested account lock", async () => { + expect( + await deviceBootstrapDependencies().withAccountLock( + "owned-account", + async () => true, + ), + ).toBe(true) + expect(mockLock).toHaveBeenCalledTimes(1) +}) diff --git a/test/flash/unit/app/authentication/login-device-bootstrap.spec.ts b/test/flash/unit/app/authentication/login-device-bootstrap.spec.ts new file mode 100644 index 000000000..8e154d377 --- /dev/null +++ b/test/flash/unit/app/authentication/login-device-bootstrap.spec.ts @@ -0,0 +1,134 @@ +const mockAuthenticate = jest.fn(), + mockBootstrap = jest.fn(), + mockLimit = jest.fn(), + mockLock = jest.fn() +jest.mock("@app/accounts/device-account-bootstrap", () => ({ + resumeDeviceAccountBootstrap: (...args: unknown[]) => mockBootstrap(...args), +})) +jest.mock("@app/accounts", () => ({ upgradeAccountFromDeviceToPhone: jest.fn() })) +jest.mock("@app/wallets", () => ({ getBalanceForWallet: jest.fn() })) +jest.mock("@services/kratos", () => ({ + AuthWithUsernamePasswordDeviceIdService: () => ({ + createIdentityWithSession: (...args: unknown[]) => mockAuthenticate(...args), + }), +})) +jest.mock("@services/lock", () => ({ + redlock: (...args: unknown[]) => mockLock(...args), +})) +jest.mock("@services/twilio", () => ({ + isPhoneCodeValid: jest.fn(), + TwilioClient: jest.fn(), +})) +jest.mock("@services/alerts/ops-events", () => ({ notifyOpsEvent: jest.fn() })) +jest.mock("@services/mongoose", () => ({ WalletsRepository: jest.fn() })) +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) +jest.mock("@domain/accounts-ips/ip-metadata-authorizer", () => ({ + IPMetadataAuthorizer: jest.fn(), +})) +jest.mock("@services/ipfetcher", () => ({ IpFetcher: jest.fn() })) +jest.mock("@app/authentication/ratelimits", () => ({ + checkFailedLoginAttemptPerIpLimits: (...args: unknown[]) => mockLimit(...args), + checkFailedLoginAttemptPerLoginIdentifierLimits: jest.fn(), + rewardFailedLoginAttemptPerIpLimits: jest.fn(), + rewardFailedLoginAttemptPerLoginIdentifierLimits: jest.fn(), +})) +jest.mock("@config", () => ({ + getAccountsOnboardConfig: () => ({ ipMetadataValidationSettings: { enabled: false } }), +})) + +import { createHash } from "crypto" + +import { loginWithDevice } from "@app/authentication/login" +import { DeviceBootstrapError } from "@domain/authentication/device-bootstrap-error" + +const DEVICE = "11111111-1111-4111-8111-111111111111", + PASSWORD = "22222222-2222-4222-8222-222222222222" +const request = (signal?: { aborted: boolean }) => + loginWithDevice({ + username: DEVICE, + password: PASSWORD, + deviceId: DEVICE, + ip: "127.0.0.1" as IpAddress, + signal, + }) +beforeEach(() => { + jest.clearAllMocks() + mockLimit.mockResolvedValue(true) + mockAuthenticate.mockResolvedValue({ + authToken: "fixture-token", + kratosUserId: "owner", + newEntity: false, + }) + mockBootstrap.mockResolvedValue(true) + mockLock.mockImplementation( + async ({ + asyncFn, + }: { + asyncFn: (signal: { aborted: boolean }) => Promise + }) => asyncFn({ aborted: false }), + ) +}) + +it("authenticates before every owned readiness repair, including existing newEntity:false identities", async () => { + const order: string[] = [] + mockAuthenticate.mockImplementation(async () => { + order.push("authenticated") + return { authToken: "fixture-token", kratosUserId: "owner", newEntity: false } + }) + mockBootstrap.mockImplementation(async () => { + order.push("bootstrap") + return true + }) + expect(await request()).toBe("fixture-token") + expect(order).toEqual(["authenticated", "bootstrap"]) + expect(mockLock.mock.calls[0][0].path).toBe( + "locks:device-bootstrap-identity:" + + createHash("sha256").update(DEVICE).digest("hex"), + ) + expect(mockLock.mock.calls[0][0].path).not.toContain(PASSWORD) + expect(mockLock.mock.calls[0][0].path).not.toContain(DEVICE) +}) + +it("never runs repair after failed password authentication", async () => { + mockAuthenticate.mockResolvedValue(new Error("Invalid credentials")) + expect(await request()).toBeInstanceOf(Error) + expect(mockBootstrap).not.toHaveBeenCalled() +}) + +it.each([ + "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED", + "DEVICE_BOOTSTRAP_RETRY_REQUIRED", + "DEVICE_BOOTSTRAP_OWNER_MISMATCH", + "DEVICE_BOOTSTRAP_INCOMPATIBLE_IDENTITY", +] as const)("does not return a minted token when readiness returns %s", async (code) => { + mockBootstrap.mockResolvedValue(new DeviceBootstrapError(code)) + expect(await request()).toMatchObject({ code }) +}) + +it("never returns a token after HTTP cancellation or account-readiness cancellation", async () => { + const signal = { aborted: true } + expect(await request(signal)).toBeInstanceOf(DeviceBootstrapError) + expect(mockAuthenticate).not.toHaveBeenCalled() + signal.aborted = false + mockBootstrap.mockImplementation(async () => { + signal.aborted = true + return true + }) + expect(await request(signal)).toBeInstanceOf(DeviceBootstrapError) +}) + +it("does not leak invalid password input in errors or start authentication", async () => { + const result = await loginWithDevice({ + username: DEVICE, + password: "PRIVATE_NON_UUID_PASSWORD", + deviceId: DEVICE, + ip: "127.0.0.1" as IpAddress, + }) + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).not.toContain("PRIVATE_NON_UUID_PASSWORD") + expect(mockAuthenticate).not.toHaveBeenCalled() + expect(mockBootstrap).not.toHaveBeenCalled() +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/device-bootstrap-presentation.spec.ts b/test/flash/unit/app/cash-wallet-cutover/device-bootstrap-presentation.spec.ts new file mode 100644 index 000000000..ccbe68c9e --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/device-bootstrap-presentation.spec.ts @@ -0,0 +1,82 @@ +const mockControlled = jest.fn(), + mockPersist = jest.fn(), + mockUpdate = jest.fn(), + mockLock = jest.fn() +jest.mock("@services/mongoose/device-bootstrap", () => ({ + hasControlledDeviceBootstrap: (...args: unknown[]) => mockControlled(...args), +})) +jest.mock("@services/mongoose", () => ({ + WalletsRepository: () => ({ persistNew: (...args: unknown[]) => mockPersist(...args) }), + CashWalletCutoverRepository: () => ({}), +})) +jest.mock("@services/lock", () => ({ + LockService: () => ({ lockAccountId: (...args: unknown[]) => mockLock(...args) }), +})) +jest.mock("@services/tracing", () => ({ recordExceptionInCurrentSpan: jest.fn() })) +jest.mock("@app/accounts/update-default-walletid", () => ({ + updateDefaultWalletId: (...args: unknown[]) => mockUpdate(...args), +})) + +import { resolveCashWalletPresentationForAccount } from "@app/cash-wallet-cutover/presentation-for-account" +import { CashWalletMissingUsdtWalletError } from "@app/cash-wallet-cutover/errors" +import { USDAmount, WalletCurrency } from "@domain/shared" + +const account = (level: number) => + ({ id: "account-owned", level, defaultWalletId: "usd-owned" }) as Account +const usd = { id: "usd-owned", currency: WalletCurrency.Usd } as Wallet +const usdt = { id: "usdt-created", currency: WalletCurrency.Usdt } as Wallet +const migrationsRepo = { + getConfig: async () => + ({ state: "complete", cutoverVersion: 1, runId: "run" }) as CashWalletCutoverConfig, + findMigrationByAccountId: async () => null, +} +const client = { cashWalletPresentation: "usdt" as const, hasUsdtCashWalletSupport: true } +const read = (level: number) => + resolveCashWalletPresentationForAccount({ + account: account(level), + client, + migrationsRepo, + walletsRepo: { + listByAccountId: async () => [usd], + persistNew: (...args) => mockPersist(...args), + }, + legacyUsdBalance: async () => ({ isZero: () => true }) as USDAmount, + }) +beforeEach(() => { + jest.clearAllMocks() + mockControlled.mockResolvedValue(true) + mockLock.mockImplementation( + async (_id: string, work: (signal: { aborted: boolean }) => Promise) => + work({ aborted: false }), + ) + mockPersist.mockResolvedValue(usdt) +}) + +it.each([0, 1, 3])( + "public presentation cannot re-create controlled missing USDT after level%s or schema upgrade", + async (level) => { + const result = await read(level) + expect(result).toBeInstanceOf(CashWalletMissingUsdtWalletError) + expect(mockControlled).toHaveBeenCalledWith("account-owned") + expect(mockPersist).not.toHaveBeenCalled() + expect(mockUpdate).not.toHaveBeenCalled() + expect(mockLock).toHaveBeenCalledTimes(1) + }, +) + +it("retains ordinary noncontrolled phone-account convergence semantics", async () => { + mockControlled.mockResolvedValue(false) + const result = await read(1) + expect(result).not.toBeInstanceOf(Error) + expect(mockPersist).toHaveBeenCalledTimes(1) + expect(mockUpdate).toHaveBeenCalledWith({ + accountId: "account-owned", + walletId: "usdt-created", + }) +}) + +it("does not treat journal-storage failure as permission to make a public provider call", async () => { + mockControlled.mockRejectedValue(new Error("Storage unavailable")) + expect(await read(1)).toBeInstanceOf(CashWalletMissingUsdtWalletError) + expect(mockPersist).not.toHaveBeenCalled() +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/ensure-usdt-wallet.spec.ts b/test/flash/unit/app/cash-wallet-cutover/ensure-usdt-wallet.spec.ts index 44cc3b3b7..ddc30e7d0 100644 --- a/test/flash/unit/app/cash-wallet-cutover/ensure-usdt-wallet.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/ensure-usdt-wallet.spec.ts @@ -23,6 +23,11 @@ jest.mock("@services/tracing", () => ({ recordExceptionInCurrentSpan: jest.fn(), })) +jest.mock("@services/mongoose/device-bootstrap", () => ({ + hasControlledDeviceBootstrap: jest.fn().mockResolvedValue(false), +})) + +import { hasControlledDeviceBootstrap } from "@services/mongoose/device-bootstrap" import { WalletType } from "@domain/wallets" import { WalletCurrency } from "@domain/shared" import { ensureUsdtWalletForAccount } from "@app/cash-wallet-cutover/ensure-usdt-wallet" @@ -44,6 +49,7 @@ const lockPassesThrough = () => beforeEach(() => { jest.clearAllMocks() + ;(hasControlledDeviceBootstrap as jest.Mock).mockResolvedValue(false) lockPassesThrough() }) @@ -204,3 +210,37 @@ describe("ensureUsdtWalletForAccount", () => { }) }) }) + +describe("controlled device-bootstrap suppression", () => { + it.each([0, 1, 3])( + "does not recreate missing USDT for a controlled account after level%s/phone upgrade", + async (level) => { + ;(hasControlledDeviceBootstrap as jest.Mock).mockResolvedValue(true) + mockListByAccountId.mockResolvedValue([usdWallet]) + const value = await ensureUsdtWalletForAccount({ + account: { ...account("usd-1"), level } as Account, + }) + expect(value).toBeNull() + expect(mockPersistNew).not.toHaveBeenCalled() + expect(mockUpdateDefaultWalletId).not.toHaveBeenCalled() + expect(mockLockAccountId).toHaveBeenCalledTimes(1) + }, + ) + it("does not let an ordinary public read flip a controlled user's existing valid default", async () => { + ;(hasControlledDeviceBootstrap as jest.Mock).mockResolvedValue(true) + mockListByAccountId.mockResolvedValue([usdWallet, usdtWallet("owned-usdt")]) + expect(await ensureUsdtWalletForAccount({ account: account("usd-1") })).toEqual( + usdtWallet("owned-usdt"), + ) + expect(mockPersistNew).not.toHaveBeenCalled() + expect(mockUpdateDefaultWalletId).not.toHaveBeenCalled() + }) + it("fails closed on journal storage failure without a provider fallback", async () => { + ;(hasControlledDeviceBootstrap as jest.Mock).mockRejectedValue( + new Error("Storage unavailable"), + ) + mockListByAccountId.mockResolvedValue([usdWallet]) + expect(await ensureUsdtWalletForAccount({ account: account("usd-1") })).toBeNull() + expect(mockPersistNew).not.toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/recipient-routing.spec.ts b/test/flash/unit/app/cash-wallet-cutover/recipient-routing.spec.ts index 0f5f569b8..6de2d2516 100644 --- a/test/flash/unit/app/cash-wallet-cutover/recipient-routing.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/recipient-routing.spec.ts @@ -1,3 +1,13 @@ +// These fixtures already contain both cash wallets. Importing the real lock +// adapter would open Redis even though no repair is expected by this contract. +jest.mock("@services/lock", () => ({ + LockService: () => ({ + lockAccountId: jest.fn(() => { + throw new Error("Unexpected wallet repair") + }), + }), +})) + const mockGetConfig = jest.fn() jest.mock("@services/mongoose", () => ({ diff --git a/test/flash/unit/servers/authorization/device-bootstrap-route.spec.ts b/test/flash/unit/servers/authorization/device-bootstrap-route.spec.ts new file mode 100644 index 000000000..d017cb98e --- /dev/null +++ b/test/flash/unit/servers/authorization/device-bootstrap-route.spec.ts @@ -0,0 +1,150 @@ +jest.mock("express", () => { + const routes = new Map() + return { + __esModule: true, + routes, + default: { + Router: () => ({ + use: jest.fn(), + get: jest.fn(), + post: (path: string, handler: unknown) => routes.set(path, handler), + }), + }, + } +}) +const mockLogin = jest.fn(), + mockAttributes = jest.fn(), + mockException = jest.fn() +jest.mock("@app", () => ({ + Authentication: { loginWithDevice: (...args: unknown[]) => mockLogin(...args) }, +})) +jest.mock("basic-auth", () => ({ + __esModule: true, + default: (request: { testBasic: unknown }) => request.testBasic, +})) +jest.mock("@graphql/error-map", () => ({ mapError: jest.fn() })) +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: (...args: unknown[]) => mockAttributes(...args), + recordExceptionInCurrentSpan: (...args: unknown[]) => mockException(...args), + tracer: {}, +})) +jest.mock("@app/authentication", () => ({})) +jest.mock("@domain/accounts-ips", () => ({ parseIps: jest.fn() })) +jest.mock("@domain/users", () => ({ + checkedToDeviceId: (value: string) => (value.length <= 38 ? value : new Error(value)), +})) +jest.mock("@services/kratos", () => ({})) +jest.mock("@services/kratos/cookie", () => ({})) +jest.mock("@config", () => ({ UNSECURE_IP_FROM_REQUEST_OBJECT: false })) +jest.mock("@app/captcha", () => ({ registerCaptchaGeetest: jest.fn() })) +jest.mock("@servers/authorization/api-key-check", () => ({ + apiKeyCheckHandler: jest.fn(), +})) + +import { EventEmitter } from "events" + +import type { Request, Response } from "express" + +import authRouter from "@servers/authorization" +import { DeviceBootstrapError } from "@domain/authentication/device-bootstrap-error" + +const DEVICE = "11111111-1111-4111-8111-111111111111", + PASSWORD = "22222222-2222-4222-8222-222222222222" +const handler = () => + jest.requireMock("express").routes.get("/create/device-account") as ( + request: Request, + response: Response, + ) => Promise +const fixture = (name: string = DEVICE) => { + const request = Object.assign(new EventEmitter(), { + testBasic: { name, pass: PASSWORD }, + originalIp: "127.0.0.1", + }) + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + destroyed: false, + status: jest.fn(), + send: jest.fn(), + }) + response.status.mockReturnValue(response) + response.send.mockImplementation(() => { + response.writableEnded = true + return response + }) + return { + request, + response, + call: () => handler()(request as unknown as Request, response as unknown as Response), + } +} +beforeEach(() => { + jest.clearAllMocks() + mockLogin.mockResolvedValue("fixture-token") +}) + +it("keeps success response compatible and emits fixed method/readiness attributes only", async () => { + expect(authRouter).toBeDefined() + const f = fixture() + await f.call() + expect(f.response.status).toHaveBeenCalledWith(200) + expect(f.response.send).toHaveBeenCalledWith({ result: "fixture-token" }) + expect(mockAttributes).toHaveBeenCalledWith({ + "login.method": "device", + "login.deviceBootstrapReady": true, + }) + expect(JSON.stringify(mockAttributes.mock.calls)).not.toContain(DEVICE) + expect(JSON.stringify(mockAttributes.mock.calls)).not.toContain(PASSWORD) + expect(f.request.listenerCount("aborted")).toBe(0) + expect(f.response.listenerCount("close")).toBe(0) +}) +it("does not echo a malformed private Basic username in the response or telemetry", async () => { + const privateUsername = "PRIVATE_BASIC_IDENTIFIER_".repeat(4) + const f = fixture(privateUsername) + await f.call() + expect(f.response.status).toHaveBeenCalledWith(422) + expect(f.response.send).toHaveBeenCalledWith({ error: "Invalid device credentials" }) + expect(JSON.stringify(f.response.send.mock.calls)).not.toContain(privateUsername) + expect(mockLogin).not.toHaveBeenCalled() + expect(mockAttributes).not.toHaveBeenCalled() +}) +it.each([ + "DEVICE_BOOTSTRAP_SUPPORT_REQUIRED", + "DEVICE_BOOTSTRAP_RETRY_REQUIRED", + "DEVICE_BOOTSTRAP_OWNER_MISMATCH", + "DEVICE_BOOTSTRAP_INCOMPATIBLE_IDENTITY", +] as const)( + "adds safe machinecode %s without returning a provisional token", + async (code) => { + mockLogin.mockResolvedValue(new DeviceBootstrapError(code)) + const f = fixture() + await f.call() + expect(f.response.status).toHaveBeenCalledWith(500) + expect(f.response.send.mock.calls[0][0]).toMatchObject({ code }) + expect(f.response.send.mock.calls[0][0]).not.toHaveProperty("result") + }, +) +it("propagates disconnect cancellation and never sends a late token", async () => { + let resolve: ((value: string) => void) | undefined + mockLogin.mockImplementation( + () => + new Promise((done) => { + resolve = done + }), + ) + const f = fixture() + const pending = f.call() + expect(mockLogin.mock.calls[0][0].signal.aborted).toBe(false) + f.response.emit("close") + expect(mockLogin.mock.calls[0][0].signal.aborted).toBe(true) + resolve?.("fixture-token") + await pending + expect(f.response.send).not.toHaveBeenCalled() +}) +it("sanitizes unexpected exceptions before tracing or responding", async () => { + mockLogin.mockRejectedValue(new Error(PASSWORD)) + const f = fixture() + await f.call() + expect(f.response.send.mock.calls[0][0].code).toBe("DEVICE_BOOTSTRAP_RETRY_REQUIRED") + expect(JSON.stringify(f.response.send.mock.calls)).not.toContain(PASSWORD) + expect(JSON.stringify(mockException.mock.calls)).not.toContain(PASSWORD) +}) diff --git a/test/flash/unit/services/kratos/device-bootstrap-identity.spec.ts b/test/flash/unit/services/kratos/device-bootstrap-identity.spec.ts new file mode 100644 index 000000000..4b2dfd9bc --- /dev/null +++ b/test/flash/unit/services/kratos/device-bootstrap-identity.spec.ts @@ -0,0 +1,167 @@ +jest.mock("@services/tracing", () => ({ + wrapAsyncFunctionsToRunInSpan: ({ fns }: { fns: object }) => fns, +})) +const mockCreate = jest.fn(), + mockGet = jest.fn(), + mockUpdate = jest.fn(), + mockFlow = jest.fn(), + mockLogin = jest.fn() +jest.mock("@services/kratos/private", () => ({ + kratosAdmin: { + createIdentity: (...args: unknown[]) => mockCreate(...args), + getIdentity: (...args: unknown[]) => mockGet(...args), + updateIdentity: (...args: unknown[]) => mockUpdate(...args), + }, + kratosPublic: { + createNativeLoginFlow: (...args: unknown[]) => mockFlow(...args), + updateLoginFlow: (...args: unknown[]) => mockLogin(...args), + }, +})) +jest.mock("@services/kratos/auth-phone-no-password", () => ({ + AuthWithPhonePasswordlessService: jest.fn(), +})) + +import { createHash } from "crypto" + +import { AuthWithUsernamePasswordDeviceIdService } from "@services/kratos/auth-username-password-deviceid" +import { readDeviceBootstrapIdentity } from "@services/kratos/device-bootstrap-identity" +import { DeviceBootstrapError } from "@domain/authentication/device-bootstrap-error" + +const DEVICE = "11111111-1111-4111-8111-111111111111", + PASSWORD = "22222222-2222-4222-8222-222222222222", + USER = "kratos-owner" +const marker = { + version: 1, + deviceIdHash: createHash("sha256").update(DEVICE).digest("hex"), +} +const identity = () => ({ + id: USER, + state: "active", + schema_id: "username_password_deviceid_v0", + traits: { username: DEVICE }, + metadata_admin: { deviceBootstrap: marker }, +}) +const login = () => ({ + data: { + session_token: "fixture-session", + session: { active: true, identity: identity() }, + }, +}) +const authenticate = () => + AuthWithUsernamePasswordDeviceIdService().createIdentityWithSession({ + username: DEVICE as IdentityUsername, + password: PASSWORD as IdentityPassword, + }) +beforeEach(() => { + jest.resetAllMocks() + mockCreate.mockResolvedValue({ data: identity() }) + mockFlow.mockResolvedValue({ data: { id: "login-flow" } }) + mockLogin.mockResolvedValue(login()) + mockGet.mockResolvedValue({ data: identity() }) +}) + +it("creates atomic admin-only provenance with the device identity and password, without account/wallet mutation", async () => { + const result = await authenticate() + expect(result).toEqual({ + authToken: "fixture-session", + kratosUserId: USER, + newEntity: true, + }) + const body = mockCreate.mock.calls[0][0].createIdentityBody + expect(body.metadata_admin).toEqual({ deviceBootstrap: marker }) + expect(body.schema_id).toBe("username_password_deviceid_v0") + expect(body.traits).toEqual({ username: DEVICE }) + expect(body.credentials.password.config.password).toBe(PASSWORD) + expect(mockUpdate).not.toHaveBeenCalled() +}) + +it("authenticates a legacy409 identity without adding or rewriting creation provenance", async () => { + mockCreate.mockRejectedValue({ isAxiosError: true, response: { status: 409 } }) + mockLogin.mockResolvedValue({ + data: { + ...login().data, + session: { active: true, identity: { ...identity(), metadata_admin: {} } }, + }, + }) + const result = await authenticate() + expect(result).toEqual({ + authToken: "fixture-session", + kratosUserId: USER, + newEntity: false, + }) + expect(mockUpdate).not.toHaveBeenCalled() + expect(mockCreate).toHaveBeenCalledTimes(1) +}) + +it.each([ + "inactive-session", + "inactive-identity", + "phone-schema", + "wrong-username", + "missing-token", + "oversize-token", +])("refuses %s before handing a token/owner to bootstrap", async (mode) => { + const reply = login() + if (mode === "inactive-session") reply.data.session.active = false + if (mode === "inactive-identity") reply.data.session.identity.state = "inactive" + if (mode === "phone-schema") + reply.data.session.identity.schema_id = "phone_no_password_v0" + if (mode === "wrong-username") + reply.data.session.identity.traits.username = "another-device" + if (mode === "missing-token") reply.data.session_token = "" + if (mode === "oversize-token") reply.data.session_token = "x".repeat(16_385) + mockLogin.mockResolvedValue(reply) + expect(await authenticate()).toBeInstanceOf(Error) +}) + +it("does not continue after a non409 identity-creation failure and does not echo credential/provider prose", async () => { + mockCreate.mockRejectedValue({ + isAxiosError: true, + response: { status: 503 }, + message: PASSWORD, + }) + const result = await authenticate() + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).not.toContain(PASSWORD) + expect(mockLogin).not.toHaveBeenCalled() +}) + +it("freshly reads the exact authenticated identity including only TOTP credential metadata", async () => { + const proof = await readDeviceBootstrapIdentity(USER) + expect(mockGet).toHaveBeenCalledWith({ id: USER, includeCredential: ["totp"] }) + expect(proof).toEqual({ + id: USER, + schema: "username_password_deviceid_v0", + state: "active", + username: DEVICE, + totpEnabled: false, + bootstrap: marker, + }) +}) + +it("reports TOTP presence without returning its secret/config or any password credential", async () => { + mockGet.mockResolvedValue({ + data: { + ...identity(), + credentials: { totp: { type: "totp", config: { secret: "PRIVATE_TOTP_SECRET" } } }, + }, + }) + const proof = await readDeviceBootstrapIdentity(USER) + expect(proof.totpEnabled).toBe(true) + expect(JSON.stringify(proof)).not.toContain("PRIVATE_TOTP_SECRET") + expect(proof).not.toHaveProperty("credentials") + expect(proof).not.toHaveProperty("password") +}) + +it("does not adopt malformed admin provenance or leak raw identity-reader errors", async () => { + mockGet.mockResolvedValueOnce({ + data: { ...identity(), metadata_admin: { deviceBootstrap: "not-a-marker" } }, + }) + await expect(readDeviceBootstrapIdentity(USER)).rejects.toMatchObject({ + code: "DEVICE_BOOTSTRAP_INCOMPATIBLE_IDENTITY", + }) + mockGet.mockRejectedValueOnce(new Error("PRIVATE_PROVIDER_DETAILS")) + const result = await readDeviceBootstrapIdentity(USER).catch((error: unknown) => error) + expect(result).toBeInstanceOf(DeviceBootstrapError) + expect((result as Error).message).not.toContain("PRIVATE_PROVIDER_DETAILS") +}) diff --git a/test/flash/unit/services/mongoose/device-bootstrap.spec.ts b/test/flash/unit/services/mongoose/device-bootstrap.spec.ts new file mode 100644 index 000000000..ae21cba49 --- /dev/null +++ b/test/flash/unit/services/mongoose/device-bootstrap.spec.ts @@ -0,0 +1,294 @@ +jest.mock("@config", () => ({ + getDefaultAccountsConfig: () => ({ initialStatus: "active" }), + getDefaultFCMTopics: () => ["broadcast"], +})) + +const mockJournalFind = jest.fn(), + mockJournalWrite = jest.fn() +const mockAccountFind = jest.fn(), + mockAccountWrite = jest.fn(), + mockAccountUpdate = jest.fn() +const mockUserFind = jest.fn(), + mockUserUpdate = jest.fn() +const mockWalletFind = jest.fn(), + mockWalletWrite = jest.fn() +jest.mock("@services/mongoose/schema", () => ({ + DeviceAccountBootstrap: { + findOne: (...args: unknown[]) => mockJournalFind(...args), + findOneAndUpdate: (...args: unknown[]) => mockJournalWrite(...args), + }, + Account: { + find: (...args: unknown[]) => mockAccountFind(...args), + findOneAndUpdate: (...args: unknown[]) => mockAccountWrite(...args), + updateOne: (...args: unknown[]) => mockAccountUpdate(...args), + }, + User: { + find: (...args: unknown[]) => mockUserFind(...args), + updateOne: (...args: unknown[]) => mockUserUpdate(...args), + }, + Wallet: { + find: (...args: unknown[]) => mockWalletFind(...args), + findOneAndUpdate: (...args: unknown[]) => mockWalletWrite(...args), + }, +})) +jest.mock("@services/mongoose/utils", () => ({ toObjectId: (id: string) => id })) + +import { + DeviceBootstrapRepository, + hasControlledDeviceBootstrap, +} from "@services/mongoose/device-bootstrap" +import type { DeviceBootstrapJournalRecord } from "@domain/accounts/device-bootstrap" +import { DeviceBootstrapError } from "@domain/authentication/device-bootstrap-error" + +const USER = "kratos-owner", + ACCOUNT = "000000000000000000000001", + HASH = "a".repeat(64) +const record = (): DeviceBootstrapJournalRecord => ({ + _id: HASH, + version: 1, + userId: USER, + accountId: ACCOUNT, + mode: "new", + phase: "initializing", + wallets: { USD: { state: "planned" }, USDT: { state: "planned" } }, + createdAt: new Date(), + updatedAt: new Date(), +}) +const account = { + _id: ACCOUNT, + kratosUserId: USER, + level: 0, + statusHistory: [{ status: "active" }], +} +const query = (value: T) => { + const result = { + lean: jest.fn(async () => value), + limit: jest.fn(() => result), + } + return result +} +const failureCode = async (work: Promise, code: string) => { + try { + await work + throw new Error("Expected rejection") + } catch (error) { + expect(error).toBeInstanceOf(DeviceBootstrapError) + expect((error as DeviceBootstrapError).code).toBe(code) + } +} +beforeEach(() => { + jest.resetAllMocks() +}) + +it("reserves immutable owner provenance with majority+j durability before provider dispatch", async () => { + const journal = record() + mockJournalWrite.mockReturnValue(query(journal)) + expect(await DeviceBootstrapRepository().reserve(journal)).toEqual(journal) + expect(mockJournalWrite).toHaveBeenCalledWith( + { _id: HASH, userId: USER }, + { $setOnInsert: journal }, + { + upsert: true, + new: true, + writeConcern: { w: "majority", j: true }, + setDefaultsOnInsert: false, + }, + ) + expect(JSON.stringify(mockJournalWrite.mock.calls)).not.toContain("password") + expect(JSON.stringify(mockJournalWrite.mock.calls)).not.toContain("token") +}) + +it("uses owner/account/phase/attempt/provider-ID compare-and-set for journal transitions", async () => { + const journal = record() + journal.wallets.USD = { state: "dispatching", attemptId: "attempt-owned" } + const known = { + state: "known" as const, + attemptId: "attempt-owned", + providerId: "provider-owned", + origin: "created" as const, + } + mockJournalWrite.mockReturnValue( + query({ ...journal, wallets: { ...journal.wallets, USD: known } }), + ) + await DeviceBootstrapRepository().transition(journal, "USD", journal.wallets.USD, known) + const [filter, update, options] = mockJournalWrite.mock.calls[0] + expect(filter).toEqual({ + "_id": HASH, + "userId": USER, + "accountId": ACCOUNT, + "version": 1, + "mode": "new", + "phase": "initializing", + "wallets.USD.state": "dispatching", + "wallets.USD.attemptId": "attempt-owned", + "wallets.USD.providerId": { $exists: false }, + }) + expect(update.$set["wallets.USD"]).toEqual(known) + expect(options.writeConcern).toEqual({ w: "majority", j: true }) + mockJournalWrite.mockReturnValue(query(null)) + await failureCode( + DeviceBootstrapRepository().transition(journal, "USD", journal.wallets.USD, known), + "DEVICE_BOOTSTRAP_RETRY_REQUIRED", + ) +}) + +it("does not initialize existing account fields through an ordinary update", async () => { + mockAccountWrite.mockReturnValue( + query({ ...account, level: 2, defaultWalletId: "actual-user-default" }), + ) + const value = await DeviceBootstrapRepository().ensureAccount(record()) + expect(value.level).toBe(2) + expect(value.defaultWalletId).toBe("actual-user-default") + const [filter, update, options] = mockAccountWrite.mock.calls[0] + expect(filter).toEqual({ _id: ACCOUNT, kratosUserId: USER }) + expect(update.$set).toBeUndefined() + expect(update.$unset).toBeUndefined() + expect(update.$setOnInsert.level).toBe(0) + expect(options.writeConcern).toEqual({ w: "majority", j: true }) +}) + +it("sets only an absent owned trial default and cannot overwrite level/default preferences", async () => { + mockAccountUpdate.mockResolvedValue({ matchedCount: 0 }) + await DeviceBootstrapRepository().setMissingDefault(record(), "owned-usdt") + expect(mockAccountUpdate).toHaveBeenCalledWith( + { + _id: ACCOUNT, + kratosUserId: USER, + level: { $in: [null, 0] }, + defaultWalletId: null, + }, + { $set: { defaultWalletId: "owned-usdt" } }, + { writeConcern: { w: "majority", j: true } }, + ) +}) + +it("bounds owner discovery and projects no credential or preference fields", async () => { + const accounts = query([account]) + mockAccountFind.mockReturnValue(accounts) + expect(await DeviceBootstrapRepository().accounts(USER)).toHaveLength(1) + expect(mockAccountFind.mock.calls[0][0]).toEqual({ kratosUserId: { $eq: USER } }) + expect(accounts.limit).toHaveBeenCalledWith(2) + const users = query([{ userId: USER, deviceId: "device", phone: undefined }]) + mockUserFind.mockReturnValue(users) + expect(await DeviceBootstrapRepository().userBindings(USER, "device")).toEqual([ + { userId: USER, deviceId: "device", hasPhone: false }, + ]) + expect(users.limit).toHaveBeenCalledWith(3) + expect(mockUserFind.mock.calls[0][1]).toEqual({ userId: 1, deviceId: 1, phone: 1 }) +}) + +it("binds only device metadata and never unsets an existing phone or rewrites language/tokens", async () => { + mockUserFind.mockReturnValue(query([{ userId: USER }])) + mockUserUpdate.mockResolvedValue({ matchedCount: 1 }) + await DeviceBootstrapRepository().bindDeviceMetadata(USER, "device") + const [filter, update] = mockUserUpdate.mock.calls[0] + expect(filter).toEqual({ + userId: USER, + deviceId: { $in: [null, "device"] }, + phone: { $in: [null, ""] }, + }) + expect(update).toEqual({ $set: { deviceId: "device" } }) +}) + +it.each( + [ + [{ userId: "foreign", deviceId: "device" }], + [{ userId: USER, deviceId: "another-device" }], + [{ userId: USER, deviceId: "device", phone: "+15550000000" }], + [{ userId: USER }, { userId: USER }], + ].map((rows) => ({ rows })), +)( + "refuses ambiguous/foreign/upgraded user bindings before any metadata write", + async ({ rows }) => { + mockUserFind.mockReturnValue(query(rows)) + await failureCode( + DeviceBootstrapRepository().bindDeviceMetadata(USER, "device"), + "DEVICE_BOOTSTRAP_OWNER_MISMATCH", + ) + expect(mockUserUpdate).not.toHaveBeenCalled() + }, +) + +it("attaches a known provider ID through owned-only insert predicates without a provider call", async () => { + const owned = { + id: "provider-owned", + _accountId: ACCOUNT, + currency: "USD", + type: "checking", + } + mockWalletFind.mockReturnValueOnce(query([])).mockReturnValueOnce(query([owned])) + mockWalletWrite.mockReturnValue(query(owned)) + await DeviceBootstrapRepository().attach(record(), "USD", "provider-owned") + expect(mockWalletWrite.mock.calls[0][0]).toEqual({ + id: "provider-owned", + _accountId: ACCOUNT, + type: "checking", + currency: "USD", + }) + expect(mockWalletWrite.mock.calls[0][1]).toEqual({ + $setOnInsert: { + id: "provider-owned", + _accountId: ACCOUNT, + type: "checking", + currency: "USD", + }, + }) + expect(mockWalletWrite.mock.calls[0][2].writeConcern).toEqual({ + w: "majority", + j: true, + }) +}) + +it.each( + [ + [{ id: "provider-owned", _accountId: "foreign", currency: "USD", type: "checking" }], + [{ id: "provider-owned", _accountId: ACCOUNT, currency: "USDT", type: "checking" }], + [{ id: "provider-owned", _accountId: ACCOUNT, currency: "USD", type: "external" }], + [ + { id: "provider-owned", _accountId: ACCOUNT, currency: "USD", type: "checking" }, + { id: "provider-owned", _accountId: ACCOUNT, currency: "USD", type: "checking" }, + ], + ].map((rows) => ({ rows })), +)( + "does not overwrite a provider ID with conflicting owner/currency/type or duplicate local rows", + async ({ rows }) => { + mockWalletFind.mockReturnValue(query(rows)) + await failureCode( + DeviceBootstrapRepository().attach(record(), "USD", "provider-owned"), + "DEVICE_BOOTSTRAP_OWNER_MISMATCH", + ) + expect(mockWalletWrite).not.toHaveBeenCalled() + }, +) + +it("marks readiness only for the owned exact attached IDs", async () => { + const journal = record() + journal.wallets.USD = { state: "attached", origin: "local", providerId: "usd-owned" } + journal.wallets.USDT = { state: "attached", origin: "local", providerId: "usdt-owned" } + mockJournalWrite.mockReturnValue(query({ ...journal, phase: "ready" })) + await DeviceBootstrapRepository().ready(journal) + expect(mockJournalWrite.mock.calls[0][0]).toEqual({ + "_id": HASH, + "userId": USER, + "accountId": ACCOUNT, + "version": 1, + "mode": "new", + "phase": "initializing", + "wallets.USD.state": "attached", + "wallets.USDT.state": "attached", + "wallets.USD.providerId": "usd-owned", + "wallets.USDT.providerId": "usdt-owned", + }) +}) + +it("suppresses public autoheal for any journal claiming this account, independent of schema or current phase", async () => { + mockJournalFind + .mockReturnValueOnce(query({ _id: HASH })) + .mockReturnValueOnce(query(null)) + expect(await hasControlledDeviceBootstrap(ACCOUNT)).toBe(true) + expect(await hasControlledDeviceBootstrap("ordinary-phone-account")).toBe(false) + expect(mockJournalFind.mock.calls[0]).toEqual([ + { accountId: { $eq: ACCOUNT } }, + { _id: 1 }, + ]) +}) From 85d094992622e08a2505155977bb13b9dc2a8d25 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 00:44:22 -0700 Subject: [PATCH 12/42] Expose authenticated raw owned wallet snapshots --- dev/apollo-federation/supergraph.graphql | 35 ++++ docs/owned-wallet-snapshot.md | 36 ++++ .../generate-owned-wallet-snapshot-sdl.cjs | 62 ++++++ .../owned-wallet-snapshot-adapters.ts | 37 ++++ src/app/accounts/owned-wallet-snapshot.ts | 128 ++++++++++++ src/domain/api-keys/scope-map.ts | 4 + src/graphql/public/schema.graphql | 31 +++ .../public/types/object/consumer-account.ts | 2 + .../types/object/owned-wallet-snapshot.ts | 62 ++++++ .../owned-wallet-snapshot-adapters.spec.ts | 70 +++++++ .../accounts/owned-wallet-snapshot.spec.ts | 193 ++++++++++++++++++ .../graphql/owned-wallet-snapshot.spec.ts | 131 ++++++++++++ 12 files changed, 791 insertions(+) create mode 100644 docs/owned-wallet-snapshot.md create mode 100644 scripts/generate-owned-wallet-snapshot-sdl.cjs create mode 100644 src/app/accounts/owned-wallet-snapshot-adapters.ts create mode 100644 src/app/accounts/owned-wallet-snapshot.ts create mode 100644 src/graphql/public/types/object/owned-wallet-snapshot.ts create mode 100644 test/flash/unit/app/accounts/owned-wallet-snapshot-adapters.spec.ts create mode 100644 test/flash/unit/app/accounts/owned-wallet-snapshot.spec.ts create mode 100644 test/flash/unit/graphql/owned-wallet-snapshot.spec.ts diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 211b4224e..36858482b 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -1058,6 +1058,11 @@ type ConsumerAccount implements Account limits: AccountLimits! notificationSettings: NotificationSettings! + """ + Raw metadata for this authenticated owner's wallets, without presentation filtering or repair. + """ + ownedWalletSnapshot: OwnedWalletSnapshot + """List the quiz questions of the consumer account""" quiz: [Quiz!]! realtimePrice: RealtimePrice! @@ -2462,6 +2467,36 @@ type OneDayAccountLimit implements AccountLimit scalar OneTimeAuthCode @join__type(graph: PUBLIC) +""" +Actual owned wallet metadata without balances, payment authority or cash presentation aliases. +""" +type OwnedWalletMetadata + @join__type(graph: PUBLIC) +{ + """Internal wallet-owner ID, distinct from the public account UUID.""" + accountId: ID! + id: WalletId! + isExternal: Boolean! + walletCurrency: WalletCurrency! +} + +""" +Authenticated raw owner metadata; never performs cash cutover, provisioning or balance reads. +""" +type OwnedWalletSnapshot + @join__type(graph: PUBLIC) +{ + """Actual stored owned default; null means no stored default.""" + defaultWalletId: WalletId + + """Public account UUID, matching the enclosing ConsumerAccount.id.""" + publicAccountId: ID! + + """Internal owner ID shared by these wallet rows.""" + walletAccountId: ID! + wallets: [OwnedWalletMetadata!]! +} + """Information about pagination in a connection.""" type PageInfo @join__type(graph: PUBLIC) diff --git a/docs/owned-wallet-snapshot.md b/docs/owned-wallet-snapshot.md new file mode 100644 index 000000000..5e8ce1457 --- /dev/null +++ b/docs/owned-wallet-snapshot.md @@ -0,0 +1,36 @@ +# Authenticated raw owned-wallet metadata + +This additive contract is based on deployed/main `5740e7da054f6cebd01ff72edd3e80d7311a99a0`. It introduces `ConsumerAccount.ownedWalletSnapshot`, with no caller account-ID argument. It does not change existing `wallets`, `defaultWalletId`, cash presentation, balances, onboarding, or provider behavior. + +```graphql +query OwnedWalletMetadata { +me { + id + defaultAccount { + id + ... on ConsumerAccount { + ownedWalletSnapshot { + publicAccountId + walletAccountId + defaultWalletId + wallets { id accountId walletCurrency isExternal } + } + } + } +} +} +``` + +`ConsumerAccount.id` is the public account UUID. Existing `Wallet.accountId` is the internal Mongo account ID. They are different namespaces; neither is a conversion of the other. The new snapshot returns both explicitly: `publicAccountId` must match the enclosing account UUID, and every raw wallet `accountId` matches `walletAccountId`. The stored nullable default is returned only if it belongs to an actual returned wallet. Missing defaults are explicitly null, without selecting or creating a wallet. External/BTC defaults are preserved. The list is descriptive: missing or multiple Checking cash currencies do not become eligibility or bootstrap success. + +The existing ConsumerAccount wallet list is unsuitable for proving both required cash wallets. `resolveCashWalletPresentation` returns only the visible cash currency: USDT presentation hides legacy USD, while legacy/compat presentation hides USDT. Its default may be a presentation alias rather than the user's stored pointer. A control regression exercises that actual function. This contract bypasses neither authorization nor cash routing; it reads different, explicitly labelled metadata. + +The resolver checks the source's internal ID, public UUID, and Kratos owner against the authenticated context before reading repositories. It freshly reads that same account, lists raw owned wallet metadata, and rechecks owner/default after the awaited wallet read. Anonymous, stale/foreign source/context/owner, invalid namespaces, duplicate wallet IDs, nullable/invalid/foreign rows, unsupported currency/type, oversized lists and invalid stored defaults return safe GraphQL errors. There is no arbitrary account argument. Repository errors are replaced with fixed text; credentials, balances and provider prose are absent. + +Only projected Mongo `Account.findOne` and bounded `Wallet.find` metadata reads are called. The adapter does not import a provider client or load unrelated account fields. No cutover/presentation/provisioning/balance app service, mutation or provider request runs. It is an ordinary descriptive read, not a transactional snapshot, authorization capability, deployment attestation, balance guarantee or payment permission. Application invariants such as exactly one Checking USD and USDT belong to the consuming bootstrap/readiness boundary. + +The nested API-key map requires `read:wallet` at `ConsumerAccount.ownedWalletSnapshot` and `OwnedWalletSnapshot.wallets`; `me` still needs its existing `read:user`. This prevents a read:user key escalating into raw wallet metadata through a nested object. Normal authenticated sessions retain their existing permissions, plus the resolver's explicit owner checks. + +The actual GraphQL source type is checked against `src/graphql/public/schema.graphql` and `dev/apollo-federation/supergraph.graphql` in tests. `scripts/generate-owned-wallet-snapshot-sdl.cjs` idempotently derives the two types and field from their runtime GraphQL definitions and extends/sorts the canonical SDL; it never starts the application. Run it under Node24 with `ts-node/register/transpile-only` and `tsconfig-paths/register`, the committed base YAML and a clean dummy-only environment (as in the isolated native-parity runner). Compose the canonical federation artifact using `rover supergraph compose --config dev/apollo-federation/supergraph-config.yaml --elv2-license accept`. No private environment or live service is required for this workflow. + +Offline tests cover actual ownership/context checks, raw/public namespace distinction, missing defaults, real external defaults, public-presentation filtering, malformed/nullable/foreign/duplicate records, read-time drift, error redaction, API-key scope mapping, and source/SDL/supergraph agreement. They inject repository reads and make no provider requests. This source patch does not deploy a schema or prove a deployed gateway serves it; native consumers must fail closed if the field is absent. diff --git a/scripts/generate-owned-wallet-snapshot-sdl.cjs b/scripts/generate-owned-wallet-snapshot-sdl.cjs new file mode 100644 index 000000000..27d759b1c --- /dev/null +++ b/scripts/generate-owned-wallet-snapshot-sdl.cjs @@ -0,0 +1,62 @@ +// Source-derived, idempotent SDL update without starting servers or providers. +// Run under Node24 with ts-node/register/transpile-only + tsconfig-paths/register. +const fs = require("node:fs") +const path = require("node:path") +const { + GraphQLSchema, + GraphQLObjectType, + Kind, + buildASTSchema, + extendSchema, + lexicographicSortSchema, + parse, + printSchema, +} = require("graphql") +const { + ownedWalletSnapshotField, +} = require("../src/graphql/public/types/object/owned-wallet-snapshot") +const target = path.resolve(__dirname, "../src/graphql/public/schema.graphql") +const names = new Set(["OwnedWalletSnapshot", "OwnedWalletMetadata"]) +const original = parse(fs.readFileSync(target, "utf8")) +const baseDocument = { + ...original, + definitions: original.definitions + .filter((node) => !names.has(node.name?.value)) + .map((node) => + node.kind === Kind.OBJECT_TYPE_DEFINITION && node.name.value === "ConsumerAccount" + ? { + ...node, + fields: node.fields.filter( + (field) => field.name.value !== "ownedWalletSnapshot", + ), + } + : node, + ), +} +const mini = parse( + printSchema( + new GraphQLSchema({ + query: new GraphQLObjectType({ + name: "SnapshotQuery", + fields: { ownedWalletSnapshot: ownedWalletSnapshotField }, + }), + }), + ), +) +const field = mini.definitions.find((node) => node.name?.value === "SnapshotQuery") + .fields[0] +const extension = { + kind: Kind.DOCUMENT, + definitions: [ + ...mini.definitions.filter((node) => names.has(node.name?.value)), + { + kind: Kind.OBJECT_TYPE_EXTENSION, + name: { kind: Kind.NAME, value: "ConsumerAccount" }, + interfaces: [], + directives: [], + fields: [field], + }, + ], +} +const schema = extendSchema(buildASTSchema(baseDocument), extension) +fs.writeFileSync(target, printSchema(lexicographicSortSchema(schema))) diff --git a/src/app/accounts/owned-wallet-snapshot-adapters.ts b/src/app/accounts/owned-wallet-snapshot-adapters.ts new file mode 100644 index 000000000..7622753a5 --- /dev/null +++ b/src/app/accounts/owned-wallet-snapshot-adapters.ts @@ -0,0 +1,37 @@ +import type { WalletType } from "@domain/wallets" +import type { WalletCurrency } from "@domain/shared" +import { Account, Wallet } from "@services/mongoose/schema" +import { toObjectId, fromObjectId } from "@services/mongoose/utils" + +import type { OwnedWalletSnapshotDependencies } from "./owned-wallet-snapshot" + +/** Projected Mongo metadata reads only; no provider clients or app routing. */ +export const ownedWalletSnapshotDependencies = (): OwnedWalletSnapshotDependencies => ({ + account: async (id) => { + const value = await Account.findOne( + { _id: toObjectId(id) }, + { _id: 1, id: 1, kratosUserId: 1, defaultWalletId: 1 }, + ).lean() + if (!value) return new Error("Owned wallet metadata is unavailable") + return { + id: fromObjectId(value._id), + uuid: value.id as AccountUuid, + kratosUserId: value.kratosUserId as UserId, + defaultWalletId: value.defaultWalletId as WalletId, + } + }, + wallets: async (id) => { + const values = await Wallet.find( + { _accountId: toObjectId(id) }, + { id: 1, _accountId: 1, currency: 1, type: 1 }, + ) + .limit(1001) + .lean() + return values.map((value) => ({ + id: value.id as WalletId, + accountId: fromObjectId(value._accountId), + currency: value.currency as WalletCurrency, + type: value.type as WalletType, + })) + }, +}) diff --git a/src/app/accounts/owned-wallet-snapshot.ts b/src/app/accounts/owned-wallet-snapshot.ts new file mode 100644 index 000000000..8cfb8efca --- /dev/null +++ b/src/app/accounts/owned-wallet-snapshot.ts @@ -0,0 +1,128 @@ +/** Descriptive owner-scoped metadata. Never routes, heals, balances or provisions. */ +export type OwnedWalletMetadata = { + id: string + accountId: string + walletCurrency: "BTC" | "USD" | "USDT" + isExternal: boolean +} +export type OwnedWalletSnapshot = { + publicAccountId: string + walletAccountId: string + defaultWalletId: string | null + wallets: OwnedWalletMetadata[] +} +type AccountOwner = Pick +export type OwnedWalletSnapshotDependencies = { + account(id: AccountId): Promise + wallets( + id: AccountId, + ): Promise[] | Error> +} +export class OwnedWalletSnapshotError extends Error { + constructor(readonly kind: "unauthenticated" | "unavailable") { + super("Owned wallet metadata is unavailable") + } +} +const internalId = (value: unknown): value is string => + typeof value === "string" && /^[a-f0-9]{24}$/.test(value) +const publicId = (value: unknown): value is string => + typeof value === "string" && + /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(value) +const walletId = (value: unknown): value is string => + typeof value === "string" && + value.length > 0 && + value.length <= 256 && + !/[^A-Za-z0-9_-]/.test(value) + +export const getOwnedWalletSnapshot = async ( + { + source, + account: contextAccount, + userId, + }: { + source: AccountOwner + account?: AccountOwner + userId?: UserId + }, + supplied?: OwnedWalletSnapshotDependencies, +): Promise => { + try { + if ( + !userId || + !contextAccount || + !source || + contextAccount.kratosUserId !== userId || + source.kratosUserId !== userId || + source.id !== contextAccount.id || + source.uuid !== contextAccount.uuid + ) { + return new OwnedWalletSnapshotError("unauthenticated") + } + if (!internalId(source.id) || !publicId(source.uuid) || !publicId(userId)) + return new OwnedWalletSnapshotError("unavailable") + const dependencies = + supplied ?? + (await import("./owned-wallet-snapshot-adapters")).ownedWalletSnapshotDependencies() + const fresh = await dependencies.account(source.id) + if ( + fresh instanceof Error || + !fresh || + fresh.id !== source.id || + fresh.uuid !== source.uuid || + fresh.kratosUserId !== userId + ) { + return new OwnedWalletSnapshotError("unavailable") + } + const rows = await dependencies.wallets(fresh.id) + if (!Array.isArray(rows) || rows.length > 1000) + return new OwnedWalletSnapshotError("unavailable") + const ids = new Set(), + wallets: OwnedWalletMetadata[] = [] + for (const row of rows) { + if ( + !row || + !walletId(row.id) || + ids.has(row.id) || + row.accountId !== fresh.id || + !["checking", "external"].includes(row.type) || + !["BTC", "USD", "USDT"].includes(row.currency) + ) { + return new OwnedWalletSnapshotError("unavailable") + } + ids.add(row.id) + wallets.push({ + id: row.id, + accountId: row.accountId, + walletCurrency: row.currency as OwnedWalletMetadata["walletCurrency"], + isExternal: row.type === "external", + }) + } + const selected = fresh.defaultWalletId + if ( + selected !== undefined && + selected !== null && + (!walletId(selected) || !ids.has(selected)) + ) + return new OwnedWalletSnapshotError("unavailable") + // Re-read the owner after the wallet read so a replacement/owner change + // during awaits is rejected without exposing foreign metadata. + const final = await dependencies.account(fresh.id) + if ( + final instanceof Error || + !final || + final.id !== fresh.id || + final.uuid !== fresh.uuid || + final.kratosUserId !== userId || + final.defaultWalletId !== selected + ) + return new OwnedWalletSnapshotError("unavailable") + return { + publicAccountId: fresh.uuid, + walletAccountId: fresh.id, + defaultWalletId: selected ?? null, + wallets: wallets.sort((a, b) => a.id.localeCompare(b.id)), + } + } catch { + return new OwnedWalletSnapshotError("unavailable") + } +} diff --git a/src/domain/api-keys/scope-map.ts b/src/domain/api-keys/scope-map.ts index 0cd023a5e..fb04a5722 100644 --- a/src/domain/api-keys/scope-map.ts +++ b/src/domain/api-keys/scope-map.ts @@ -131,6 +131,7 @@ export const apiKeyNestedFieldScopes: Record< Record > = Object.freeze({ ConsumerAccount: { + ownedWalletSnapshot: "read:wallet", wallets: "read:wallet", transactions: "read:transactions", csvTransactions: "read:transactions", @@ -139,6 +140,9 @@ export const apiKeyNestedFieldScopes: Record< transactions: "read:transactions", transactionsCount: "read:transactions", }, + OwnedWalletSnapshot: { + wallets: "read:wallet", + }, BTCWallet: { balance: "read:wallet", pendingIncomingBalance: "read:wallet", diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index 3f3edf4bd..e273e9291 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -856,6 +856,11 @@ type ConsumerAccount implements Account { limits: AccountLimits! notificationSettings: NotificationSettings! + """ + Raw metadata for this authenticated owner's wallets, without presentation filtering or repair. + """ + ownedWalletSnapshot: OwnedWalletSnapshot + """List the quiz questions of the consumer account""" quiz: [Quiz!]! realtimePrice: RealtimePrice! @@ -2025,6 +2030,32 @@ type OneDayAccountLimit implements AccountLimit { """An authentication code valid for a single use""" scalar OneTimeAuthCode +""" +Actual owned wallet metadata without balances, payment authority or cash presentation aliases. +""" +type OwnedWalletMetadata { + """Internal wallet-owner ID, distinct from the public account UUID.""" + accountId: ID! + id: WalletId! + isExternal: Boolean! + walletCurrency: WalletCurrency! +} + +""" +Authenticated raw owner metadata; never performs cash cutover, provisioning or balance reads. +""" +type OwnedWalletSnapshot { + """Actual stored owned default; null means no stored default.""" + defaultWalletId: WalletId + + """Public account UUID, matching the enclosing ConsumerAccount.id.""" + publicAccountId: ID! + + """Internal owner ID shared by these wallet rows.""" + walletAccountId: ID! + wallets: [OwnedWalletMetadata!]! +} + """Information about pagination in a connection.""" type PageInfo { """When paginating forwards, the cursor to continue.""" diff --git a/src/graphql/public/types/object/consumer-account.ts b/src/graphql/public/types/object/consumer-account.ts index 58a5b04d9..b4d89da60 100644 --- a/src/graphql/public/types/object/consumer-account.ts +++ b/src/graphql/public/types/object/consumer-account.ts @@ -37,6 +37,7 @@ import AccountLimits from "./account-limits" import Quiz from "./quiz" import CallbackEndpoint from "./callback-endpoint" import { NotificationSettings } from "./notification-settings" +import { ownedWalletSnapshotField } from "./owned-wallet-snapshot" const ConsumerAccount = GT.Object({ name: "ConsumerAccount", @@ -44,6 +45,7 @@ const ConsumerAccount = GT.Object({ isTypeOf: () => true, // TODO: improve fields: () => ({ + ownedWalletSnapshot: ownedWalletSnapshotField, id: { type: GT.NonNullID, resolve: (source) => source.uuid, diff --git a/src/graphql/public/types/object/owned-wallet-snapshot.ts b/src/graphql/public/types/object/owned-wallet-snapshot.ts new file mode 100644 index 000000000..2223ed0d6 --- /dev/null +++ b/src/graphql/public/types/object/owned-wallet-snapshot.ts @@ -0,0 +1,62 @@ +import { + getOwnedWalletSnapshot, + type OwnedWalletMetadata, + type OwnedWalletSnapshot, +} from "@app/accounts/owned-wallet-snapshot" +import { GT } from "@graphql/index" +import { AuthenticationError, UnknownClientError } from "@graphql/error" +import WalletId from "@graphql/shared/types/scalar/wallet-id" +import WalletCurrency from "@graphql/shared/types/scalar/wallet-currency" + +const Metadata = GT.Object({ + name: "OwnedWalletMetadata", + description: + "Actual owned wallet metadata without balances, payment authority or cash presentation aliases.", + fields: () => ({ + id: { type: GT.NonNull(WalletId) }, + accountId: { + type: GT.NonNullID, + description: "Internal wallet-owner ID, distinct from the public account UUID.", + }, + walletCurrency: { type: GT.NonNull(WalletCurrency) }, + isExternal: { type: GT.NonNull(GT.Boolean) }, + }), +}) +const Snapshot = GT.Object({ + name: "OwnedWalletSnapshot", + description: + "Authenticated raw owner metadata; never performs cash cutover, provisioning or balance reads.", + fields: () => ({ + publicAccountId: { + type: GT.NonNullID, + description: "Public account UUID, matching the enclosing ConsumerAccount.id.", + }, + walletAccountId: { + type: GT.NonNullID, + description: "Internal owner ID shared by these wallet rows.", + }, + defaultWalletId: { + type: WalletId, + description: "Actual stored owned default; null means no stored default.", + }, + wallets: { type: GT.NonNullList(Metadata) }, + }), +}) + +export const ownedWalletSnapshotField = GT.Field({ + type: Snapshot, + description: + "Raw metadata for this authenticated owner's wallets, without presentation filtering or repair.", + resolve: async (source, _args, { domainAccount, user }) => { + const result = await getOwnedWalletSnapshot({ + source, + account: domainAccount, + userId: user?.id, + }) + if (result instanceof Error) { + if (result.kind === "unauthenticated") throw new AuthenticationError({}) + throw new UnknownClientError({ message: "Owned wallet metadata is unavailable" }) + } + return result + }, +}) diff --git a/test/flash/unit/app/accounts/owned-wallet-snapshot-adapters.spec.ts b/test/flash/unit/app/accounts/owned-wallet-snapshot-adapters.spec.ts new file mode 100644 index 000000000..5fe02ad80 --- /dev/null +++ b/test/flash/unit/app/accounts/owned-wallet-snapshot-adapters.spec.ts @@ -0,0 +1,70 @@ +const mockAccount = jest.fn(), + mockWallets = jest.fn() +jest.mock("@services/mongoose/schema", () => ({ + Account: { findOne: (...args: unknown[]) => mockAccount(...args) }, + Wallet: { find: (...args: unknown[]) => mockWallets(...args) }, +})) +jest.mock("@services/mongoose/utils", () => ({ + toObjectId: (id: string) => id, + fromObjectId: (id: string) => id, +})) + +import { ownedWalletSnapshotDependencies } from "@app/accounts/owned-wallet-snapshot-adapters" + +const INTERNAL = "507f1f77bcf86cd799439011", + PUBLIC = "01909e14-2f56-41f2-afed-21c6f714c805", + USER = "2d63fe66-f51d-4d3a-a75b-45087857305b" +const query = (value: T) => { + const result = { lean: async () => value, limit: jest.fn(() => result) } + return result +} +beforeEach(() => { + jest.resetAllMocks() +}) + +it("uses exact internal account lookup and explicit distinct UUID projection without unrelated account data", async () => { + mockAccount.mockReturnValue( + query({ + _id: INTERNAL, + id: PUBLIC, + kratosUserId: USER, + defaultWalletId: "actual-default", + }), + ) + expect(await ownedWalletSnapshotDependencies().account(INTERNAL as AccountId)).toEqual({ + id: INTERNAL, + uuid: PUBLIC, + kratosUserId: USER, + defaultWalletId: "actual-default", + }) + expect(mockAccount).toHaveBeenCalledWith( + { _id: INTERNAL }, + { _id: 1, id: 1, kratosUserId: 1, defaultWalletId: 1 }, + ) +}) +it("bounds and projects raw wallet-owner metadata with no balance/provider fields", async () => { + const result = query([ + { id: "usdt-owned", _accountId: INTERNAL, currency: "USDT", type: "checking" }, + ]) + mockWallets.mockReturnValue(result) + expect(await ownedWalletSnapshotDependencies().wallets(INTERNAL as AccountId)).toEqual([ + { id: "usdt-owned", accountId: INTERNAL, currency: "USDT", type: "checking" }, + ]) + expect(mockWallets).toHaveBeenCalledWith( + { _accountId: INTERNAL }, + { id: 1, _accountId: 1, currency: 1, type: 1 }, + ) + expect(result.limit).toHaveBeenCalledWith(1001) +}) +it("does not manufacture a missing account or stored default", async () => { + mockAccount + .mockReturnValueOnce(query(null)) + .mockReturnValueOnce(query({ _id: INTERNAL, id: PUBLIC, kratosUserId: USER })) + expect( + await ownedWalletSnapshotDependencies().account(INTERNAL as AccountId), + ).toBeInstanceOf(Error) + expect( + await ownedWalletSnapshotDependencies().account(INTERNAL as AccountId), + ).toMatchObject({ id: INTERNAL, uuid: PUBLIC, defaultWalletId: undefined }) + expect(mockWallets).not.toHaveBeenCalled() +}) diff --git a/test/flash/unit/app/accounts/owned-wallet-snapshot.spec.ts b/test/flash/unit/app/accounts/owned-wallet-snapshot.spec.ts new file mode 100644 index 000000000..8d2a542cc --- /dev/null +++ b/test/flash/unit/app/accounts/owned-wallet-snapshot.spec.ts @@ -0,0 +1,193 @@ +const mockAccount = jest.fn(), + mockWallets = jest.fn() +jest.mock("@app/accounts/owned-wallet-snapshot-adapters", () => ({ + ownedWalletSnapshotDependencies: () => ({ account: mockAccount, wallets: mockWallets }), +})) + +import { + getOwnedWalletSnapshot, + OwnedWalletSnapshotError, +} from "@app/accounts/owned-wallet-snapshot" +import { resolveCashWalletPresentation } from "@app/cash-wallet-cutover/presentation" + +const INTERNAL = "507f1f77bcf86cd799439011", + PUBLIC = "01909e14-2f56-41f2-afed-21c6f714c805", + USER = "2d63fe66-f51d-4d3a-a75b-45087857305b" +const account = () => + ({ + id: INTERNAL, + uuid: PUBLIC, + kratosUserId: USER, + defaultWalletId: "usdt-owned", + }) as Account +const wallets = () => + [ + { id: "usd-owned", accountId: INTERNAL, type: "checking", currency: "USD" }, + { id: "usdt-owned", accountId: INTERNAL, type: "checking", currency: "USDT" }, + ] as Wallet[] +const run = ( + source: Account = account(), + context: Account | undefined = account(), + userId: UserId | undefined = USER as UserId, +) => getOwnedWalletSnapshot({ source, account: context, userId }) +beforeEach(() => { + jest.resetAllMocks() + mockAccount.mockImplementation(async () => account()) + mockWallets.mockResolvedValue(wallets()) +}) + +it("returns real raw cash rows and explicit distinct ownership namespaces without a presentation alias", async () => { + const result = await run() + expect(result).toEqual({ + publicAccountId: PUBLIC, + walletAccountId: INTERNAL, + defaultWalletId: "usdt-owned", + wallets: [ + { id: "usd-owned", accountId: INTERNAL, walletCurrency: "USD", isExternal: false }, + { + id: "usdt-owned", + accountId: INTERNAL, + walletCurrency: "USDT", + isExternal: false, + }, + ], + }) + expect(PUBLIC).not.toBe(INTERNAL) + expect(mockAccount).toHaveBeenCalledTimes(2) + expect(mockWallets).toHaveBeenCalledWith(INTERNAL) + expect(JSON.stringify(result)).not.toMatch(/balance|lnurl|provider|secret|token/i) +}) + +it("proves existing public presentation hides one cash currency and cannot supply raw readiness", () => { + for (const presentation of ["usdt", "legacy_usd_compat", "legacy_usd"] as const) { + const result = resolveCashWalletPresentation({ + decision: { presentation }, + wallets: wallets(), + }) + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.wallets).toHaveLength(1) + expect(result.wallets[0].currency).toBe(presentation === "usdt" ? "USDT" : "USD") + } +}) + +it("preserves actual external/BTC default rather than the cash presentation default", async () => { + const original = { ...account(), defaultWalletId: "external-btc" } + mockAccount.mockResolvedValue(original) + mockWallets.mockResolvedValue([ + ...wallets(), + { id: "external-btc", accountId: INTERNAL, currency: "BTC", type: "external" }, + ]) + const result = await run() + expect(result).not.toBeInstanceOf(Error) + expect(result).toMatchObject({ + defaultWalletId: "external-btc", + wallets: expect.arrayContaining([ + { + id: "external-btc", + accountId: INTERNAL, + walletCurrency: "BTC", + isExternal: true, + }, + ]), + }) +}) + +it.each([undefined, null])( + "returns an explicit nullable missing stored default (%s), never inventing a cash choice", + async (value) => { + mockAccount.mockResolvedValue({ ...account(), defaultWalletId: value }) + expect(await run()).toMatchObject({ defaultWalletId: null }) + }, +) + +it("does not call repositories for anonymous or foreign source/context ownership", async () => { + for (const input of [ + { source: account() }, + { source: account(), account: account() }, + { + source: { ...account(), id: "507f1f77bcf86cd799439012" as AccountId }, + account: account(), + userId: USER as UserId, + }, + { + source: { + ...account(), + uuid: "b74355d9-40d6-43b9-ab6d-c08c78bc66bf" as AccountUuid, + }, + account: account(), + userId: USER as UserId, + }, + { + source: account(), + account: account(), + userId: "d677f049-ad7b-4f42-a243-f34a26c86043" as UserId, + }, + ]) { + expect(await getOwnedWalletSnapshot(input)).toMatchObject({ kind: "unauthenticated" }) + } + expect(mockAccount).not.toHaveBeenCalled() + expect(mockWallets).not.toHaveBeenCalled() +}) + +it.each(["id", "uuid", "kratosUserId"])( + "rejects changed fresh account %s before exposing any wallets", + async (field) => { + mockAccount.mockResolvedValue({ ...account(), [field]: "different-owner" }) + expect(await run()).toBeInstanceOf(OwnedWalletSnapshotError) + expect(mockWallets).not.toHaveBeenCalled() + }, +) + +it("rejects owner/default drift across the awaited wallet read", async () => { + for (const changed of [ + { ...account(), kratosUserId: "different-owner" }, + { ...account(), defaultWalletId: "usd-owned" }, + ]) { + mockAccount + .mockReset() + .mockResolvedValueOnce(account()) + .mockResolvedValueOnce(changed) + expect(await run()).toBeInstanceOf(OwnedWalletSnapshotError) + } +}) + +it.each( + [ + null, + [null], + [{ ...wallets()[0], accountId: "507f1f77bcf86cd799439012" }], + [wallets()[0], wallets()[0]], + [{ ...wallets()[0], id: "" }], + [{ ...wallets()[0], currency: "UNKNOWN" }], + [{ ...wallets()[0], type: "UNKNOWN" }], + ].map((rows) => ({ rows })), +)("fails closed on invalid/nullable/foreign/duplicate raw records", async ({ rows }) => { + mockWallets.mockResolvedValue(rows) + expect(await run()).toBeInstanceOf(OwnedWalletSnapshotError) +}) + +it("rejects a foreign default, an oversized result and repository failures without exposing server prose", async () => { + mockAccount.mockResolvedValue({ ...account(), defaultWalletId: "foreign-default" }) + expect(await run()).toBeInstanceOf(OwnedWalletSnapshotError) + mockAccount.mockResolvedValue(account()) + mockWallets.mockResolvedValue( + Array.from({ length: 1001 }, (_, i) => ({ ...wallets()[0], id: "wallet-" + i })), + ) + expect(await run()).toBeInstanceOf(OwnedWalletSnapshotError) + mockWallets.mockRejectedValue(new Error("PRIVATE_DATABASE_DETAILS")) + const result = await run() + expect(result).toBeInstanceOf(OwnedWalletSnapshotError) + expect((result as Error).message).not.toContain("PRIVATE_DATABASE_DETAILS") +}) + +it("describes incomplete cash state without creating wallets or claiming readiness", async () => { + mockAccount.mockResolvedValue({ ...account(), defaultWalletId: undefined }) + mockWallets.mockResolvedValue([]) + expect(await run()).toEqual({ + publicAccountId: PUBLIC, + walletAccountId: INTERNAL, + defaultWalletId: null, + wallets: [], + }) +}) diff --git a/test/flash/unit/graphql/owned-wallet-snapshot.spec.ts b/test/flash/unit/graphql/owned-wallet-snapshot.spec.ts new file mode 100644 index 000000000..d32ff1fca --- /dev/null +++ b/test/flash/unit/graphql/owned-wallet-snapshot.spec.ts @@ -0,0 +1,131 @@ +jest.mock("@config", () => ({ getOnChainWalletConfig: () => ({}) })) +jest.mock("@services/logger", () => ({ + baseLogger: { warn: jest.fn(), error: jest.fn() }, +})) +const mockRead = jest.fn() +jest.mock("@app/accounts/owned-wallet-snapshot", () => ({ + getOwnedWalletSnapshot: (...args: unknown[]) => mockRead(...args), +})) + +import fs from "fs" +import path from "path" + +import { + GraphQLObjectType, + GraphQLSchema, + buildSchema, + getNamedType, + graphql, + lexicographicSortSchema, + printSchema, + printType, +} from "graphql" +import { GT } from "@graphql/index" +import { ownedWalletSnapshotField } from "@graphql/public/types/object/owned-wallet-snapshot" +import { apiKeyNestedFieldScopes, hasApiKeyScope } from "@domain/api-keys" + +const SOURCE = { + id: "507f1f77bcf86cd799439011", + uuid: "01909e14-2f56-41f2-afed-21c6f714c805", + kratosUserId: "2d63fe66-f51d-4d3a-a75b-45087857305b", +} +beforeEach(() => { + jest.clearAllMocks() +}) + +it("exposes a no-argument nullable field with only descriptive metadata and explicit namespaces", () => { + expect(ownedWalletSnapshotField.args).toBeUndefined() + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: "Query", + fields: { snapshot: ownedWalletSnapshotField }, + }), + }) + const printed = printSchema(schema) + expect(printed).toContain("publicAccountId: ID!") + expect(printed).toContain("walletAccountId: ID!") + expect(printed).toContain("defaultWalletId: WalletId\n") + expect(printed).toContain("wallets: [OwnedWalletMetadata!]!") + expect(printed).not.toMatch(/\bbalance\s*:|lnurl\s*:|token\s*:/) +}) + +it("passes authenticated source/context identity to the owner-scoped raw reader without client arguments", async () => { + const result = { + publicAccountId: SOURCE.uuid, + walletAccountId: SOURCE.id, + defaultWalletId: null, + wallets: [], + } + mockRead.mockResolvedValue(result) + const context = { domainAccount: SOURCE, user: { id: SOURCE.kratosUserId } } + const schema = new GraphQLSchema({ + query: GT.Object({ + name: "Query", + fields: { + snapshot: { + ...ownedWalletSnapshotField, + resolve: (_source, args, ctx, info) => + ownedWalletSnapshotField.resolve!(SOURCE as Account, args, ctx, info), + }, + }, + }), + }) + const response = await graphql({ + schema, + source: + "{ snapshot { publicAccountId walletAccountId defaultWalletId wallets { id accountId walletCurrency isExternal } } }", + contextValue: context, + }) + expect(response.errors).toBeUndefined() + expect(response.data?.snapshot).toEqual(result) + expect(mockRead).toHaveBeenCalledWith({ + source: SOURCE, + account: SOURCE, + userId: SOURCE.kratosUserId, + }) +}) + +it("requires read:wallet for API keys at the snapshot entry and nested wallet rows", () => { + expect(apiKeyNestedFieldScopes.ConsumerAccount.ownedWalletSnapshot).toBe("read:wallet") + expect(apiKeyNestedFieldScopes.OwnedWalletSnapshot.wallets).toBe("read:wallet") + expect(hasApiKeyScope({ grantedScopes: ["read:user"], required: "read:wallet" })).toBe( + false, + ) + expect( + hasApiKeyScope({ grantedScopes: ["read:wallet"], required: "read:wallet" }), + ).toBe(true) +}) + +it("checks the canonical SDL and composed supergraph against actual source types and field contract", () => { + const root = path.resolve(__dirname, "../../../../") + const canonical = buildSchema( + fs.readFileSync(path.join(root, "src/graphql/public/schema.graphql"), "utf8"), + ) + const sourceSchema = lexicographicSortSchema( + new GraphQLSchema({ + query: new GraphQLObjectType({ + name: "Query", + fields: { snapshot: ownedWalletSnapshotField }, + }), + }), + ) + const snapshot = sourceSchema.getType("OwnedWalletSnapshot")! + expect(printType(canonical.getType("OwnedWalletSnapshot")!)).toBe(printType(snapshot)) + const metadata = getNamedType((snapshot as GraphQLObjectType).getFields().wallets.type) + expect(printType(canonical.getType("OwnedWalletMetadata")!)).toBe(printType(metadata)) + const field = (canonical.getType("ConsumerAccount") as GraphQLObjectType).getFields() + .ownedWalletSnapshot + expect(String(field.type)).toBe("OwnedWalletSnapshot") + expect(field.args).toEqual([]) + const supergraph = buildSchema( + fs.readFileSync(path.join(root, "dev/apollo-federation/supergraph.graphql"), "utf8"), + ) + const federated = ( + supergraph.getType("ConsumerAccount") as GraphQLObjectType + ).getFields().ownedWalletSnapshot + expect(String(federated.type)).toBe("OwnedWalletSnapshot") + expect(federated.args).toEqual([]) + expect(printType(supergraph.getType("OwnedWalletMetadata")!)).toContain( + "accountId: ID!", + ) +}) From 9d3e4f7658dbdf52cce393c29f85e9236894d4cb Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 00:50:23 -0700 Subject: [PATCH 13/42] Gate native parity backend changes with isolated contracts --- .github/workflows/native-parity-unit.yml | 36 ++++++++++++++++++++++++ docs/native-parity-unit-tests.md | 4 +-- scripts/native-parity-unit.cjs | 30 +++++++++++++++++++- 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/native-parity-unit.yml diff --git a/.github/workflows/native-parity-unit.yml b/.github/workflows/native-parity-unit.yml new file mode 100644 index 000000000..f94d3535b --- /dev/null +++ b/.github/workflows/native-parity-unit.yml @@ -0,0 +1,36 @@ +name: Isolated native parity contracts + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + isolated-unit: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "24" + - name: Install pinned package manager and locked dependencies + run: | + npm install --global --ignore-scripts yarn@1.22.22 + yarn install --frozen-lockfile --ignore-scripts --non-interactive + - name: Check isolated runner boundaries + run: node --test scripts/native-parity-unit.test.cjs + - name: Check TypeScript contracts + run: | + yarn tsc-check + yarn tsc-check-noimplicitany + - name: Compile production package + run: yarn build + - name: Run isolated contracts and privacy regressions + run: node scripts/native-parity-unit.cjs diff --git a/docs/native-parity-unit-tests.md b/docs/native-parity-unit-tests.md index 026dce451..1a5877c70 100644 --- a/docs/native-parity-unit-tests.md +++ b/docs/native-parity-unit-tests.md @@ -7,7 +7,7 @@ node scripts/native-parity-unit.cjs node --test scripts/native-parity-unit.test.cjs ``` -The first command runs the fixed, reviewed selection of 37 Bridge, Earn and identity-storage unit suites. Optional group arguments are `bridge`, `earn`, and `storage`, for example `node scripts/native-parity-unit.cjs storage`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, and Earn's atomic reward deduplication. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. +The first command runs the fixed, reviewed selection of 59 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. The child receives only inherited `PATH` and `TMPDIR`, plus the explicit fixture values in `scripts/native-parity-unit.cjs`. Required secrets are unmistakable dummy strings. Network is `regtest`; configured service hosts point at numeric loopback port 9. `HOME`, cloud credentials/profiles, proxy variables, Node preloads, and inherited service/provider credentials are omitted. Jest's setup selects only committed `dev/config/base-config.yaml`; no private configuration file is loaded by the runner. Its required dummy environment exists only in the child process and must never be used for a deployment. @@ -23,6 +23,6 @@ Three narrow test boundaries now avoid real service initialization: The identity presigned-PUT test still uses the real pinned AWS SDK and sends the untouched signed URL with a nonempty PNG to its local HTTP fixture. The interlock permits that fixture. It emulates checksum/body acceptance, not production signature verification, and uses dummy credentials only. All Bridge, IBEX, notification, database and cache interactions elsewhere are mocked. -Validated 2026-10-22 on Node `24.22.1`, including owned KYC integration commit `045950860`: all **37 suites / 491 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-isolated-runner.log` and `/tmp/flash-native-backend-runner-tests.log`; they are local artifacts, not checked-in fixtures. +Validated 2026-10-23 on Node `24.22.1` against integrated branch commit `85d094992` plus this runner change: all **59 suites / 882 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-final-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. These are offline unit checks. They do not establish provider access, sandbox interoperability, production configuration, bank-account provisioning, image storage acceptance by DigitalOcean, or deployment readiness. diff --git a/scripts/native-parity-unit.cjs b/scripts/native-parity-unit.cjs index 95921f706..e01309e58 100644 --- a/scripts/native-parity-unit.cjs +++ b/scripts/native-parity-unit.cjs @@ -46,6 +46,34 @@ const suites = Object.freeze({ "services/storage/id-document-presigned-put.spec.ts", "services/storage/id-documents.spec.ts", ], + telemetry: [ + "app/authentication/registration-trace-privacy.spec.ts", + "servers/request-log.spec.ts", + "services/frappe/ErpNext.bankAccountUpdate.spec.ts", + "services/frappe/ErpNext.bankAccounts.spec.ts", + "services/frappe/ErpNext.idVerification.spec.ts", + "services/frappe/ErpNext.spec.ts", + "services/frappe/request-telemetry-privacy.spec.ts", + "services/http-trace-privacy.spec.ts", + "services/ibex/request-trace-privacy.spec.ts", + "services/tracing-input-privacy.spec.ts", + "services/tracing.spec.ts", + ], + device: [ + "app/accounts/device-account-bootstrap.spec.ts", + "app/accounts/device-bootstrap-adapters.spec.ts", + "app/authentication/login-device-bootstrap.spec.ts", + "app/authentication/login-device-upgrade.spec.ts", + "app/cash-wallet-cutover/device-bootstrap-presentation.spec.ts", + "servers/authorization/device-bootstrap-route.spec.ts", + "services/kratos/device-bootstrap-identity.spec.ts", + "services/mongoose/device-bootstrap.spec.ts", + ], + ownership: [ + "app/accounts/owned-wallet-snapshot-adapters.spec.ts", + "app/accounts/owned-wallet-snapshot.spec.ts", + "graphql/owned-wallet-snapshot.spec.ts", + ], }) function safeEnvironment(inherited) { @@ -85,7 +113,7 @@ function safeEnvironment(inherited) { function selection(args) { if (args.some((group) => !Object.hasOwn(suites, group))) { - throw new Error("Select only bridge, earn, storage; no other Jest arguments allowed") + throw new Error("Select only bridge, earn, storage, telemetry, device, ownership; no other Jest arguments allowed") } return [...new Set(args.length ? args : Object.keys(suites))].flatMap((group) => suites[group].map((file) => unit + file), From 94d0090043ba8d454c9ce9779d3d3fa0872468ce Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 00:53:56 -0700 Subject: [PATCH 14/42] Use spelling-safe language in telemetry docs --- docs/telemetry-input-privacy.md | 2 +- src/services/tracing.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/telemetry-input-privacy.md b/docs/telemetry-input-privacy.md index 792fd1a71..892208cd2 100644 --- a/docs/telemetry-input-privacy.md +++ b/docs/telemetry-input-privacy.md @@ -2,7 +2,7 @@ This isolated patch is based on backend `7402dd7fcf49e378e4af5189232dffb76a4564d5`. It does not change authentication, provider requests, payment execution, GraphQL results, or deployment configuration. It has not been deployed. Tests use fake spans and an in-process exporter stand-in; no span or credential is sent to a collector. -The generic synchronous and asynchronous wrappers previously copied the first argument into `code.function.params.*`. Object inputs exposed fields such as device passwords, OTPs, bank details and Bridge identity information. A positional string, including a session bearer, was enumerated as character indices. Null/undefined marker attributes did not make the values safe. Both wrappers now record only function name, namespace, root selection and explicitly supplied `spanAttributes`. They do not inspect, enumerate or read argument properties for tracing. Calls, arguments, results, returned/partial errors and thrown error identity retain their existing behavior. There is no argument-capture option or per-method opt-in. +The generic synchronous and asynchronous wrappers previously copied the first argument into `code.function.params.*`. Object inputs exposed fields such as device passwords, verification codes, bank details and Bridge identity information. A positional string, including a session bearer, was enumerated as character indices. Null/undefined marker attributes did not make the values safe. Both wrappers now record only function name, namespace, root selection and explicitly supplied `spanAttributes`. They do not inspect, enumerate or read argument properties for tracing. Calls, arguments, results, returned/partial errors and thrown error identity retain their existing behavior. There is no argument-capture option or per-method opt-in. Repository consumers of `code.function.params.*` are collector deletion rules in `dev/otel-agent-config.yaml` and its quickstart copies. No application behavior depends on those values. Those defensive collector rules can remain; they are not a substitute for preventing collection in the application. diff --git a/src/services/tracing.ts b/src/services/tracing.ts index b366e47a6..5c9f3f0f4 100644 --- a/src/services/tracing.ts +++ b/src/services/tracing.ts @@ -322,7 +322,7 @@ const resolveFunctionSpanOptions = ({ [SemanticAttributes.CODE_NAMESPACE]: namespace, ...spanAttributes, } - // Arguments can be long-lived passwords, bearer tokens, OTPs or banking PII. + // Arguments can be long-lived passwords, bearer tokens, verification codes or banking PII. // Never inspect or enumerate them for telemetry; call sites must supply // deliberate, nonsecret spanAttributes independently of invocation inputs. return { attributes, root } From 5bc51c524a7b2ece8c6ac6afd7c019d752eec30d Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 00:57:53 -0700 Subject: [PATCH 15/42] Order access logger imports for CI lint --- src/servers/graphql-admin-server.ts | 4 +++- src/servers/graphql-server.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/servers/graphql-admin-server.ts b/src/servers/graphql-admin-server.ts index 768805cd4..b50d08bf3 100644 --- a/src/servers/graphql-admin-server.ts +++ b/src/servers/graphql-admin-server.ts @@ -13,7 +13,7 @@ import express from "express" import { ApolloServerPluginDrainHttpServer } from "apollo-server-core" import { ApolloError, ApolloServer, ExpressContext } from "apollo-server-express" import { GraphQLError, GraphQLSchema } from "graphql" -import { createRequestLogger } from "./request-log" + import { mapError } from "@graphql/error-map" import { fieldExtensionsEstimator, simpleEstimator } from "graphql-query-complexity" @@ -25,6 +25,8 @@ import requestIp from "request-ip" import jwt from "jsonwebtoken" +import { createRequestLogger } from "./request-log" + import { exitOnBootFailure } from "./boot" import { buildAdminPermissionRules, hasRole } from "./authorization/admin-permissions" diff --git a/src/servers/graphql-server.ts b/src/servers/graphql-server.ts index d18621d7c..ffa6bedd9 100644 --- a/src/servers/graphql-server.ts +++ b/src/servers/graphql-server.ts @@ -10,7 +10,6 @@ import { GetVerificationKey, expressjwt } from "express-jwt" import { GraphQLError, GraphQLSchema } from "graphql" import { rule } from "graphql-shield" import jsonwebtoken from "jsonwebtoken" -import { createRequestLogger } from "./request-log" import { mapError } from "@graphql/error-map" @@ -26,6 +25,8 @@ import { } from "@domain/api-keys" import { parseUnknownDomainErrorFromUnknown } from "@domain/shared" +import { createRequestLogger } from "./request-log" + import { MAXIMUM_QUERY_COMPLEXITY, createComplexityPlugin } from "./plugins/complexity" import authRouter from "./authorization" From 476cae5eda87b89b56d0530e93e240c431c6d5eb Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:05:57 -0700 Subject: [PATCH 16/42] Record physical device App Check gate acceptance --- docs/device-app-check-gate.md | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/device-app-check-gate.md diff --git a/docs/device-app-check-gate.md b/docs/device-app-check-gate.md new file mode 100644 index 000000000..45c7b32ac --- /dev/null +++ b/docs/device-app-check-gate.md @@ -0,0 +1,39 @@ +# Device-login App Check gate · 2026-10-23 + +This is a read-only production configuration and one deliberately incomplete +request acceptance check. It is evidence about the current deployed route, not +proof that the staged durable Basic bootstrap is released or that a real user +account can be created safely. + +The public `POST /auth/create/device-account` request enters the ingress auth +subrequest before Express. The deployed Oathkeeper device-login rule is the sole +matching rule for this method/path. It requires a JWT in the `Appcheck` header, +trusts the Firebase App Check issuer for project `806646140435`, accepts the +project number/name audiences, and uses Google's App Check JWKS. Its production +rule has no local development signing key or anonymous fallback. Oathkeeper +v0.40.6 defaults the omitted JWT algorithm allowlist to RS256. The ingress +overwrites method, protocol, host, URI and client-IP forwarding headers used +for the decision, so caller-supplied versions cannot select another rule. +There is no second Firebase verifier inside the Express device handler; the +gateway decision is the enforcement boundary. + +Read-only decision probes through the deployed gate returned 401 for missing +and malformed App Check proof. The registered physical iOS app +`com.islandbitcoin.flashnative` is in that Firebase project. On SuperMax, an +opt-in native test obtained its real App Attest-backed App Check token, sent +`POST` to the device route with that token and **no Basic Authorization or +body**, and observed HTTP 422. The deployed handler checks for Basic +credentials and returns 422 before calling account login or any provider. +This proves the physical native token passed the current route's App Check +decision without creating an account. The test inspected only status and +exact response URL; it did not log the token, response body, rule contents or +private credentials. The original build 6 app was restored afterward. + +The rule is project-scoped and does not select an individual Firebase app ID. +The new native registration can pass because it belongs to the trusted project. +Whether production should impose a narrower app-specific policy is an operator +decision and would need coordinated RN/native rollout. The gate must be +rechecked when releasing this candidate. Its acceptance says nothing about +Kratos identity setup, Mongo journal/index durability, IBEX creation semantics, +App Check token theft resistance beyond Firebase's attestation guarantee, or +real-device Basic login outcomes. From e11964b4248c929990d23a8161916daacf3d9da6 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:07:05 -0700 Subject: [PATCH 17/42] Prevent unrecoverable device-only TOTP enrollment --- docs/device-totp-enrollment-policy.md | 90 +++++++++ src/app/authentication/totp.ts | 10 +- src/domain/authentication/errors.ts | 2 + src/graphql/error-map.ts | 12 ++ .../user-totp-registration-initiate.ts | 3 +- src/services/kratos/totp-enrollment-policy.ts | 43 ++++ .../totp-enrollment-policy.spec.ts | 183 ++++++++++++++++++ .../totp-registration-initiate-policy.spec.ts | 54 ++++++ .../graphql/totp-enrollment-error-map.spec.ts | 27 +++ 9 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 docs/device-totp-enrollment-policy.md create mode 100644 src/services/kratos/totp-enrollment-policy.ts create mode 100644 test/flash/unit/app/authentication/totp-enrollment-policy.spec.ts create mode 100644 test/flash/unit/graphql/public/root/mutation/totp-registration-initiate-policy.spec.ts create mode 100644 test/flash/unit/graphql/totp-enrollment-error-map.spec.ts diff --git a/docs/device-totp-enrollment-policy.md b/docs/device-totp-enrollment-policy.md new file mode 100644 index 000000000..e326a9c10 --- /dev/null +++ b/docs/device-totp-enrollment-policy.md @@ -0,0 +1,90 @@ +# Device account authenticator policy + +This source slice is based on integrated backend `94d009004` and RN +`2afeece8ac74e663376c483018011f30d89fd227`. It makes no provider calls in tests +and does not configure or release an identity provider. + +## The recovery gap + +The active RN account screen (`app/screens/settings-screen/account-screen.tsx`) +shows the TOTP row to beta users with `enabled: true`, without checking identity +schema or a usable phone login. Its TOTP initiate/validate screens use the current +session token. Native account security similarly offers enrollment to an +authenticated user. Account tier and a Mongo phone field are not proof of an +identity-provider phone login. + +The Basic device REST route returns only `{ result: authToken }`. Its new durable +bootstrap intentionally rejects device identities that already have TOTP with +`DEVICE_BOOTSTRAP_INCOMPATIBLE_IDENTITY`; it has no `totpRequired` continuation. +Phone/email login already supports a provisional token followed by real TOTP +elevation. Adding TOTP to a device-only identity can therefore leave the user +without a supported login route after their current session stops working. + +Normal email/phone registration is not a workaround: the Kratos helpers in +`auth-email-no-password.ts` accept only the existing phone/email schemas, not +`username_password_deviceid_v0`. `kratosRemoveTotp` also refreshes with only +`traits.phone || traits.email`, so a device-only identity cannot depend on that +method to undo enrollment. Local credential regeneration, removing a factor +without its required authority, or accepting an AAL1 Basic bearer as completed +TOTP authentication would be unsafe continuations. + +## Narrow server boundary + +Both Flash enrollment entry points now require `authorizeTotpEnrollment` before +creating a settings flow/revealing a secret or validating/enabling a factor: + +1. Resolve the input token to a fresh active Kratos session and bind its identity + to the authenticated GraphQL user. +2. Read that exact identity through the admin API, requesting no credential + material, and require an active identity. +3. Refuse the device schema with `DEVICE_TOTP_REQUIRES_PHONE_UPGRADE` and fixed + text: “Verify your phone before setting up an authenticator.” Allow the three + existing phone/email schemas; unknown schemas/provider failures fail closed + as `TOTP_ENROLLMENT_UNAVAILABLE`, without forwarding provider details. + +The initiate resolver now passes its context user ID internally. The public +GraphQL input, successful payload and SDL are unchanged. Validation retains its +existing post-effect token-owner/readback checks. Checking validation as well +as initiation blocks pending device settings flows issued before this patch. +An owner mismatch is rejected before either settings effect; previously +validation performed the effect before checking token ownership. + +The guard intentionally does not use account level, wallet readiness, an +arbitrary rollout flag, or a caller-supplied phone. Existing phone/email +enrollment, TOTP login elevation and factor deletion keep their existing +contracts. This protects Flash's application/GraphQL enrollment paths; it is +not a claim that direct public Kratos settings endpoints have a global +schema-specific policy. Operators must audit that exposure separately. + +## Supported migration and existing affected accounts + +A user with a usable authenticated device session can explicitly complete the +existing OTP-backed `userLoginUpgrade` path, which migrates the same Kratos +identity to the phone schema. The app must freshly observe that migration +before retrying enrollment. Phone collisions are rejected by the existing +upgrade implementation; this change does not merge accounts or move funds. +The new native Basic credential pair must remain stored until an authoritative +same-owner, exact-phone verified upgrade is observed. + +An already-enrolled device identity without a usable authenticated session has +no complete self-service recovery in these source contracts. Preserve its +pair and direct the user to support or a separately audited stronger login +continuation. Do not automatically repeat bootstrap, clear TOTP, fabricate a +phone registration, or erase/recreate the account. A cached token alone is not +proof of usability: checked-in Kratos `session.whoami.required_aal` is +`highest_available`, so an AAL1 token may already be insufficient. + +## Offline validation + +Three focused suites cover fresh schema/owner/session checks, device rejection +before both settings effects, rejection of old pending flows, successful +phone/email contracts, safe provider failure text, post-effect owner binding, +single failed verification dispatch, GraphQL context propagation and closed +payload error codes. All Ory and repository effects are mocked; the isolated +runner environment and network guard reject accidental external I/O. + +Validation completed: 23 assertions in the three new suites, and 66 assertions +across seven suites including existing device bootstrap, identity, phone-upgrade +and error-map regressions. Whole-tree, production and declaration TypeScript +checks, changed-file ESLint, Prettier and `git diff --check` pass. The regression +log is `/tmp/flash-device-totp-policy-regressions.log` in the review workspace. diff --git a/src/app/authentication/totp.ts b/src/app/authentication/totp.ts index 8dc25ce08..70f0aba09 100644 --- a/src/app/authentication/totp.ts +++ b/src/app/authentication/totp.ts @@ -8,12 +8,17 @@ import { } from "@services/kratos" import { UsersRepository } from "@services/mongoose" +import { authorizeTotpEnrollment } from "@services/kratos/totp-enrollment-policy" export const initiateTotpRegistration = async ({ authToken, + userId, }: { authToken: AuthToken -}): Promise => { + userId: UserId +}): Promise => { + const allowed = await authorizeTotpEnrollment({ authToken, userId }) + if (allowed instanceof Error) return allowed return kratosInitiateTotp(authToken) } @@ -28,6 +33,9 @@ export const validateTotpRegistration = async ({ totpRegistrationId: TotpRegistrationId userId: UserId }): Promise => { + // Check before the settings mutation, including for previously issued flows. + const allowed = await authorizeTotpEnrollment({ authToken, userId }) + if (allowed instanceof Error) return allowed const validation = await kratosValidateTotp({ authToken, totpCode, totpRegistrationId }) if (validation instanceof Error) return validation diff --git a/src/domain/authentication/errors.ts b/src/domain/authentication/errors.ts index da226a2d5..ac98c67fb 100644 --- a/src/domain/authentication/errors.ts +++ b/src/domain/authentication/errors.ts @@ -1,6 +1,8 @@ import { DomainError, ValidationError, ErrorLevel } from "@domain/shared" export class AuthenticationError extends DomainError {} +export class DeviceTotpRequiresPhoneUpgradeError extends AuthenticationError {} +export class TotpEnrollmentUnavailableError extends AuthenticationError {} export class LikelyNoUserWithThisPhoneExistError extends AuthenticationError {} export class LikelyUserAlreadyExistError extends AuthenticationError {} diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index f7f6e0373..6650d70f3 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -68,6 +68,18 @@ export const mapError = (error: ApplicationError): CustomApolloError => { const errorName = error.name as ApplicationErrorKey let message = "" switch (errorName) { + case "DeviceTotpRequiresPhoneUpgradeError": + return bridgeGqlError({ + code: "DEVICE_TOTP_REQUIRES_PHONE_UPGRADE", + message: "Verify your phone before setting up an authenticator.", + }) + + case "TotpEnrollmentUnavailableError": + return bridgeGqlError({ + code: "TOTP_ENROLLMENT_UNAVAILABLE", + message: "Authenticator setup is unavailable. Check your account and try again.", + }) + case "WithdrawalLimitsExceededError": message = error.message return new TransactionRestrictedError({ message, logger: baseLogger }) diff --git a/src/graphql/public/root/mutation/user-totp-registration-initiate.ts b/src/graphql/public/root/mutation/user-totp-registration-initiate.ts index 271d78e7f..b30b6392d 100644 --- a/src/graphql/public/root/mutation/user-totp-registration-initiate.ts +++ b/src/graphql/public/root/mutation/user-totp-registration-initiate.ts @@ -30,7 +30,7 @@ const UserTotpRegistrationInitiateMutation = GT.Field< args: { input: { type: GT.NonNull(UserTotpRegistrationInitiateInput) }, }, - resolve: async (_, args) => { + resolve: async (_, args, { user }) => { const { authToken } = args.input if (authToken instanceof Error) { @@ -39,6 +39,7 @@ const UserTotpRegistrationInitiateMutation = GT.Field< const res = await Authentication.initiateTotpRegistration({ authToken, + userId: user.id, }) if (res instanceof Error) { diff --git a/src/services/kratos/totp-enrollment-policy.ts b/src/services/kratos/totp-enrollment-policy.ts new file mode 100644 index 000000000..c82729798 --- /dev/null +++ b/src/services/kratos/totp-enrollment-policy.ts @@ -0,0 +1,43 @@ +import { + AuthTokenUserIdMismatchError, + DeviceTotpRequiresPhoneUpgradeError, + TotpEnrollmentUnavailableError, +} from "@domain/authentication/errors" + +import { kratosAdmin, kratosPublic } from "./private" +import { SchemaIdType } from "./schema" + +/** Device Basic login has no AAL2 continuation. Require genuine identifier + * migration before adding a factor; Mongo phone/level fields are not proof. */ +export const authorizeTotpEnrollment = async ({ + authToken, + userId, +}: { + authToken: AuthToken + userId: UserId +}): Promise => { + try { + const { data: session } = await kratosPublic.toSession({ xSessionToken: authToken }) + if (session.identity?.id !== userId) return new AuthTokenUserIdMismatchError() + if (session.active !== true) return new TotpEnrollmentUnavailableError() + + // Request no credential material. The fresh schema, rather than stale + // session traits or a caller's account tier, determines the login route. + const { data: identity } = await kratosAdmin.getIdentity({ id: userId }) + if (identity.id !== userId) return new AuthTokenUserIdMismatchError() + if (identity.state !== "active") return new TotpEnrollmentUnavailableError() + switch (identity.schema_id) { + case SchemaIdType.UsernamePasswordDeviceIdV0: + return new DeviceTotpRequiresPhoneUpgradeError() + case SchemaIdType.PhoneNoPasswordV0: + case SchemaIdType.PhoneEmailNoPasswordV0: + case SchemaIdType.EmailNoPasswordV0: + return true + default: + return new TotpEnrollmentUnavailableError() + } + } catch { + // Provider failures can contain tokens, traits and HTTP request details. + return new TotpEnrollmentUnavailableError() + } +} diff --git a/test/flash/unit/app/authentication/totp-enrollment-policy.spec.ts b/test/flash/unit/app/authentication/totp-enrollment-policy.spec.ts new file mode 100644 index 000000000..50b902f74 --- /dev/null +++ b/test/flash/unit/app/authentication/totp-enrollment-policy.spec.ts @@ -0,0 +1,183 @@ +const mockSession = jest.fn() +const mockIdentity = jest.fn() +const mockInitiate = jest.fn() +const mockValidate = jest.fn() +const mockValidateToken = jest.fn() +const mockFind = jest.fn() +jest.mock("@services/kratos/private", () => ({ + kratosPublic: { toSession: (...args: unknown[]) => mockSession(...args) }, + kratosAdmin: { getIdentity: (...args: unknown[]) => mockIdentity(...args) }, +})) +jest.mock("@services/kratos", () => ({ + kratosInitiateTotp: (...args: unknown[]) => mockInitiate(...args), + kratosValidateTotp: (...args: unknown[]) => mockValidate(...args), + validateKratosToken: (...args: unknown[]) => mockValidateToken(...args), +})) +jest.mock("@services/mongoose", () => ({ + UsersRepository: () => ({ findById: (...args: unknown[]) => mockFind(...args) }), +})) + +import { + initiateTotpRegistration, + validateTotpRegistration, +} from "@app/authentication/totp" +import { + AuthTokenUserIdMismatchError, + DeviceTotpRequiresPhoneUpgradeError, + TotpEnrollmentUnavailableError, +} from "@domain/authentication/errors" +import { SchemaIdType } from "@services/kratos/schema" + +const owner = "11111111-1111-4111-8111-111111111111" as UserId +const authToken = "fixture-session-token" as AuthToken +const input = { authToken, userId: owner } +const initiate = () => initiateTotpRegistration(input) +const validate = () => + validateTotpRegistration({ + ...input, + totpCode: "123456" as TotpCode, + totpRegistrationId: "existing-flow" as TotpRegistrationId, + }) +const account = { id: owner, phone: "+18765551234", level: 1 } + +beforeEach(() => { + jest.resetAllMocks() + mockSession.mockResolvedValue({ data: { active: true, identity: { id: owner } } }) + mockIdentity.mockResolvedValue({ + data: { id: owner, state: "active", schema_id: SchemaIdType.PhoneNoPasswordV0 }, + }) + mockInitiate.mockResolvedValue({ + totpSecret: "fixture-secret", + totpRegistrationId: "flow", + }) + mockValidate.mockResolvedValue(true) + mockValidateToken.mockResolvedValue({ kratosUserId: owner }) + mockFind.mockResolvedValue(account) +}) + +it.each([initiate, validate])( + "rejects device-schema enrollment before any settings effect", + async (run) => { + mockIdentity.mockResolvedValue({ + data: { + id: owner, + state: "active", + schema_id: SchemaIdType.UsernamePasswordDeviceIdV0, + traits: { phone: "+18765551234", email: "fixture@example.com" }, + }, + }) + expect(await run()).toBeInstanceOf(DeviceTotpRequiresPhoneUpgradeError) + expect(mockInitiate).not.toHaveBeenCalled() + expect(mockValidate).not.toHaveBeenCalled() + expect(mockFind).not.toHaveBeenCalled() + expect(mockIdentity).toHaveBeenCalledWith({ id: owner }) + }, +) + +it.each([ + SchemaIdType.PhoneNoPasswordV0, + SchemaIdType.PhoneEmailNoPasswordV0, + SchemaIdType.EmailNoPasswordV0, +])("preserves setup and verification for fresh active %s identities", async (schema) => { + mockIdentity.mockResolvedValue({ + data: { id: owner, state: "active", schema_id: schema }, + }) + expect(await initiate()).toEqual({ + totpSecret: "fixture-secret", + totpRegistrationId: "flow", + }) + expect(await validate()).toEqual(account) + expect(mockInitiate).toHaveBeenCalledWith(authToken) + expect(mockValidate).toHaveBeenCalledWith({ + authToken, + totpCode: "123456", + totpRegistrationId: "existing-flow", + }) + expect(mockSession).toHaveBeenCalledTimes(2) + expect(mockIdentity).toHaveBeenCalledTimes(2) +}) + +it.each([initiate, validate])( + "rejects a foreign input token before revealing or enabling a secret", + async (run) => { + mockSession.mockResolvedValue({ data: { active: true, identity: { id: "foreign" } } }) + expect(await run()).toBeInstanceOf(AuthTokenUserIdMismatchError) + expect(mockIdentity).not.toHaveBeenCalled() + expect(mockInitiate).not.toHaveBeenCalled() + expect(mockValidate).not.toHaveBeenCalled() + }, +) + +it.each([ + "inactive-session", + "missing-session-active", + "inactive-identity", + "missing-identity-state", + "unknown-schema", + "foreign-identity", + "session-read-failure", + "identity-read-failure", +])("fails closed for %s without provider settings effects", async (mode) => { + if (mode === "inactive-session") + mockSession.mockResolvedValue({ data: { active: false, identity: { id: owner } } }) + if (mode === "missing-session-active") + mockSession.mockResolvedValue({ data: { identity: { id: owner } } }) + if (mode === "inactive-identity") + mockIdentity.mockResolvedValue({ + data: { id: owner, state: "inactive", schema_id: SchemaIdType.PhoneNoPasswordV0 }, + }) + if (mode === "missing-identity-state") + mockIdentity.mockResolvedValue({ + data: { id: owner, schema_id: SchemaIdType.PhoneNoPasswordV0 }, + }) + if (mode === "unknown-schema") + mockIdentity.mockResolvedValue({ + data: { id: owner, state: "active", schema_id: "future-device-schema" }, + }) + if (mode === "foreign-identity") + mockIdentity.mockResolvedValue({ + data: { id: "foreign", state: "active", schema_id: SchemaIdType.PhoneNoPasswordV0 }, + }) + if (mode === "session-read-failure") + mockSession.mockRejectedValue(new Error("private-token-provider-detail")) + if (mode === "identity-read-failure") + mockIdentity.mockRejectedValue(new Error("private-token-provider-detail")) + const result = await validate() + expect(result).toBeInstanceOf( + mode === "foreign-identity" + ? AuthTokenUserIdMismatchError + : TotpEnrollmentUnavailableError, + ) + expect((result as Error).message).not.toContain("private-token-provider-detail") + expect(mockInitiate).not.toHaveBeenCalled() + expect(mockValidate).not.toHaveBeenCalled() + expect(mockFind).not.toHaveBeenCalled() +}) + +it("checks fresh schema again when verifying a previously issued enrollment flow", async () => { + expect(await initiate()).not.toBeInstanceOf(Error) + mockIdentity.mockResolvedValue({ + data: { + id: owner, + state: "active", + schema_id: SchemaIdType.UsernamePasswordDeviceIdV0, + }, + }) + expect(await validate()).toBeInstanceOf(DeviceTotpRequiresPhoneUpgradeError) + expect(mockValidate).not.toHaveBeenCalled() +}) + +it("keeps the post-mutation owner check and never returns another user's account", async () => { + mockValidateToken.mockResolvedValue({ kratosUserId: "foreign" }) + expect(await validate()).toBeInstanceOf(AuthTokenUserIdMismatchError) + expect(mockFind).not.toHaveBeenCalled() +}) + +it("propagates a rejected verification without a success readback or implicit retry", async () => { + const rejected = new Error("fixture-rejected-code") + mockValidate.mockResolvedValue(rejected) + expect(await validate()).toBe(rejected) + expect(mockValidate).toHaveBeenCalledTimes(1) + expect(mockValidateToken).not.toHaveBeenCalled() + expect(mockFind).not.toHaveBeenCalled() +}) diff --git a/test/flash/unit/graphql/public/root/mutation/totp-registration-initiate-policy.spec.ts b/test/flash/unit/graphql/public/root/mutation/totp-registration-initiate-policy.spec.ts new file mode 100644 index 000000000..8368bc2de --- /dev/null +++ b/test/flash/unit/graphql/public/root/mutation/totp-registration-initiate-policy.spec.ts @@ -0,0 +1,54 @@ +const mockInitiate = jest.fn() +jest.mock("@app", () => ({ + Authentication: { + initiateTotpRegistration: (...args: unknown[]) => mockInitiate(...args), + }, +})) + +import Mutation from "@graphql/public/root/mutation/user-totp-registration-initiate" +import { DeviceTotpRequiresPhoneUpgradeError } from "@domain/authentication/errors" + +const resolve = async (authToken: unknown) => + (await Mutation.resolve!( + null, + { input: { authToken: authToken as AuthToken } }, + { user: { id: "authenticated-owner" } } as GraphQLPublicContextAuth, + undefined as never, + )) as { + errors: Array<{ code?: string; message: string }> + totpSecret?: string + totpRegistrationId?: string + } + +beforeEach(() => jest.resetAllMocks()) + +it("binds setup to the authenticated GraphQL owner without changing successful payload fields", async () => { + mockInitiate.mockResolvedValue({ + totpSecret: "fixture-secret", + totpRegistrationId: "flow", + }) + expect(await resolve("fixture-token")).toEqual({ + errors: [], + totpSecret: "fixture-secret", + totpRegistrationId: "flow", + }) + expect(mockInitiate).toHaveBeenCalledWith({ + authToken: "fixture-token", + userId: "authenticated-owner", + }) +}) + +it("returns the closed upgrade prerequisite without a setup secret", async () => { + mockInitiate.mockResolvedValue(new DeviceTotpRequiresPhoneUpgradeError()) + const result = await resolve("fixture-token") + expect(result.errors).toEqual([ + expect.objectContaining({ code: "DEVICE_TOTP_REQUIRES_PHONE_UPGRADE" }), + ]) + expect(result.totpSecret).toBeUndefined() + expect(result.totpRegistrationId).toBeUndefined() +}) + +it("does not invoke enrollment for a rejected input scalar", async () => { + await resolve(new Error("invalid scalar")) + expect(mockInitiate).not.toHaveBeenCalled() +}) diff --git a/test/flash/unit/graphql/totp-enrollment-error-map.spec.ts b/test/flash/unit/graphql/totp-enrollment-error-map.spec.ts new file mode 100644 index 000000000..536bd0efe --- /dev/null +++ b/test/flash/unit/graphql/totp-enrollment-error-map.spec.ts @@ -0,0 +1,27 @@ +import { + DeviceTotpRequiresPhoneUpgradeError, + TotpEnrollmentUnavailableError, +} from "@domain/authentication/errors" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" + +it.each([ + [ + new DeviceTotpRequiresPhoneUpgradeError("private-provider-message"), + "DEVICE_TOTP_REQUIRES_PHONE_UPGRADE", + "Verify your phone before setting up an authenticator.", + ], + [ + new TotpEnrollmentUnavailableError("private-provider-message"), + "TOTP_ENROLLMENT_UNAVAILABLE", + "Authenticator setup is unavailable. Check your account and try again.", + ], +])( + "returns a fixed actionable %s payload without logging out a valid session", + (error, code, message) => { + const result = mapAndParseErrorForGqlResponse(error as ApplicationError) + expect(result.code).toBe(code) + expect(result.message).toBe(message) + expect(result.code).not.toBe("NOT_AUTHENTICATED") + expect(result.message).not.toContain("private-provider-message") + }, +) From 0d856f52fc81d49638bf3fad0cc5b6efbd4a3cbd Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:08:18 -0700 Subject: [PATCH 18/42] fix: keep push notification credentials out of telemetry --- docs/push-notification-telemetry-privacy.md | 15 ++ src/domain/notifications/errors.ts | 14 +- .../notifications/firebase-error-code.ts | 44 ++++++ src/services/notifications/firebase.ts | 54 +++++-- .../notifications/push-notifications.ts | 44 +++--- .../firebase-telemetry-privacy.spec.ts | 132 ++++++++++++++++++ .../notifications/push-notifications.spec.ts | 129 +++++++++++++++++ 7 files changed, 398 insertions(+), 34 deletions(-) create mode 100644 docs/push-notification-telemetry-privacy.md create mode 100644 src/domain/notifications/firebase-error-code.ts create mode 100644 test/flash/unit/services/notifications/firebase-telemetry-privacy.spec.ts diff --git a/docs/push-notification-telemetry-privacy.md b/docs/push-notification-telemetry-privacy.md new file mode 100644 index 000000000..7b09b5a52 --- /dev/null +++ b/docs/push-notification-telemetry-privacy.md @@ -0,0 +1,15 @@ +# Push notification telemetry privacy + +This leaf is based on integrated backend `94d0090043ba8d454c9ce9779d3d3fa0872468ce`, including the preceding argument, GraphQL, HTTP, IBEX, Redis and ERP privacy boundaries. It requires those preceding source fixes; removing explicit push captures alone does not disable generic automatic argument capture. No real credential, provider request, exported span or deployment was used. + +The multicast push boundary no longer logs device tokens or notification title/body/data. Individual Firebase failures produce fixed exception summaries and a closed messaging error code; unknown codes become `messaging/unknown-error`. Batch and topic logs contain only fixed operation/outcome text, known error codes and nonnegative integer counts bounded by the number of supplied tokens. SDK error messages, objects, response details, topic names and malformed count values cannot become diagnostic values through these paths. + +`FirebaseMessageError` no longer JSON-serializes the SDK error and token. It retains the error class, a fixed message and a closed `failureCode`. The allowlist follows the pinned Firebase Admin SDK's `MessagingClientErrorCode` table; the existing application's fixed `messaging/quota-exceeded` diagnostic is retained. Unknown future provider codes require explicit review before they become telemetry metadata. + +Token validation still supplies the exact token to Firebase with `dryRun: true`, returns false only for the known unregistered-token code and otherwise returns the same domain error class. Initialization failures no longer log raw credential/SDK errors. Topic subscriptions retain their existing fire-and-forget invocation and exact provider arguments, but now contain callback rejections locally so the process-level unhandled-rejection logger cannot serialize a private SDK error. This change adds no retry or delivery guarantee. + +Delivery payloads and pruning behavior are preserved: multicast still receives the exact tokens, notification and data with dry-run disabled; only unregistered tokens are returned for removal; real success counts and known deduplicated failure codes remain available to callers. A zero-accepted batch still reports failure. Provider 500/502 detection remains local and produces the existing unreachable-error class with fixed text; other failures retain the unknown-error class with fixed text. Privacy validation does not change provider response or money-state semantics. + +Validation: 13 reviewed suites pass, **124 tests**, under Node 24 using the integration runner's fake `safeEnvironment`, network guard and disabled Watchman. Tests assert exact SDK input, stale-token pruning, APNs/sender failure codes, zero-delivery failure, admin refusal semantics, notification preferences, dry-run token validation, initialization containment, topic success/failure privacy and malformed provider diagnostics. All three TypeScript configurations, scoped ESLint and `git diff --check` pass. + +This is a scoped source-boundary fix. Stale token arrays remain internal pruning data and must not be logged by future callers. It does not establish privacy for the separate multi-channel notifier, all application errors, explicitly attached account identifiers, historical records or collector retention. No assertion is made that every telemetry sink has been sanitized. diff --git a/src/domain/notifications/errors.ts b/src/domain/notifications/errors.ts index ecb923bea..1a799f2e6 100644 --- a/src/domain/notifications/errors.ts +++ b/src/domain/notifications/errors.ts @@ -1,5 +1,6 @@ import { DomainError, ErrorLevel } from "@domain/shared" -import { FirebaseError } from "firebase-admin" + +import { safeFirebaseMessagingCode } from "./firebase-error-code" export class NotificationsError extends DomainError {} @@ -67,12 +68,9 @@ export class InvalidPushNotificationSettingError extends NotificationsError {} export class FirebaseNotAvailable extends NotificationsServiceError {} export class FirebaseMessageError extends NotificationsServiceError { - constructor(error: FirebaseError, token: DeviceToken) { - super( - JSON.stringify({ - ...error, - token, - }), - ) + readonly failureCode: string + constructor(error: unknown) { + super("Firebase messaging request failed") + this.failureCode = safeFirebaseMessagingCode(error) } } diff --git a/src/domain/notifications/firebase-error-code.ts b/src/domain/notifications/firebase-error-code.ts new file mode 100644 index 000000000..207755bd7 --- /dev/null +++ b/src/domain/notifications/firebase-error-code.ts @@ -0,0 +1,44 @@ +// Closed messaging codes from the installed Firebase Admin SDK's +// MessagingClientErrorCode table; quota-exceeded is retained for existing FCM +// v1 operator contracts. Unknown provider strings are never telemetry values. +const messagingCodes = new Set( + [ + "invalid-argument", + "invalid-recipient", + "invalid-payload", + "invalid-data-payload-key", + "payload-size-limit-exceeded", + "invalid-options", + "invalid-registration-token", + "registration-token-not-registered", + "mismatched-credential", + "invalid-package-name", + "device-message-rate-exceeded", + "topics-message-rate-exceeded", + "message-rate-exceeded", + "third-party-auth-error", + "too-many-topics", + "authentication-error", + "server-unavailable", + "internal-error", + "unknown-error", + "quota-exceeded", + ].map((code) => `messaging/${code}`), +) + +export const safeFirebaseMessagingCode = (error: unknown): string => { + const code = + typeof error === "object" && error !== null + ? (error as { code?: unknown }).code + : undefined + return typeof code === "string" && messagingCodes.has(code) + ? code + : "messaging/unknown-error" +} + +// Provider counts are telemetry only; malformed values must not become an +// alternate channel for arbitrary provider response text. +export const safeFirebaseCount = (count: unknown, maximum: number): number | undefined => + typeof count === "number" && Number.isInteger(count) && count >= 0 && count <= maximum + ? count + : undefined diff --git a/src/services/notifications/firebase.ts b/src/services/notifications/firebase.ts index 1ff5cfb95..6d53d7fb1 100644 --- a/src/services/notifications/firebase.ts +++ b/src/services/notifications/firebase.ts @@ -1,8 +1,11 @@ import * as admin from "firebase-admin" -import { FirebaseError } from "firebase-admin" import { Messaging } from "firebase-admin/lib/messaging/messaging" import { GOOGLE_APPLICATION_CREDENTIALS } from "@config" import { FirebaseMessageError, FirebaseNotAvailable } from "@domain/notifications" +import { + safeFirebaseCount, + safeFirebaseMessagingCode, +} from "@domain/notifications/firebase-error-code" import { baseLogger } from "@services/logger" let messaging: Messaging | null = null @@ -17,7 +20,10 @@ if (GOOGLE_APPLICATION_CREDENTIALS) { messaging = admin.messaging() baseLogger.info("Firebase messaging module loaded") } catch (err) { - baseLogger.error({ err }, "Failed to initialize Firebase Admin") + baseLogger.error( + { operation: "initialize_push", code: safeFirebaseMessagingCode(err) }, + "Failed to initialize Firebase Admin", + ) } } else { baseLogger.warn("GOOGLE_APPLICATION_CREDENTIALS not set") @@ -40,13 +46,19 @@ const isDeviceTokenValid = async ( ) return true } catch (e: unknown) { - const error = e as FirebaseError - if (error.code === "messaging/registration-token-not-registered") { - baseLogger.warn(`Invalid or expired FCM token: ${token}`) + const code = safeFirebaseMessagingCode(e) + if (code === "messaging/registration-token-not-registered") { + baseLogger.warn( + { operation: "validate_push_token", code }, + "Invalid or expired FCM token", + ) return false } else { - baseLogger.error(`Error checking device token: ${error.message}`) - return new FirebaseMessageError(error, token as DeviceToken) + baseLogger.error( + { operation: "validate_push_token", code }, + "Unable to validate push token", + ) + return new FirebaseMessageError(e) } } } @@ -59,13 +71,35 @@ const subscribeToTopics = async ( topics.forEach(async (t) => { if (!messaging) { baseLogger.error( - { tokens, topic: t }, + { operation: "subscribe_push_topic" }, "Firebase messaging module not loaded, skipping topic subscription", ) return new FirebaseNotAvailable() } - const result = await messaging.subscribeToTopic(tokens, t) - baseLogger.info({ tokens, topic: t, result }) + try { + const result = await messaging.subscribeToTopic(tokens, t) + baseLogger.info( + { + operation: "subscribe_push_topic", + successCount: safeFirebaseCount( + result.successCount, + typeof tokens === "string" ? 1 : tokens.length, + ), + failureCount: safeFirebaseCount( + result.failureCount, + typeof tokens === "string" ? 1 : tokens.length, + ), + }, + "Push topic subscription completed", + ) + } catch (error) { + // This is intentionally fire-and-forget today. Contain provider errors + // here so the process-level rejection logger cannot serialize a token. + baseLogger.error( + { operation: "subscribe_push_topic", code: safeFirebaseMessagingCode(error) }, + "Push topic subscription failed", + ) + } }) } diff --git a/src/services/notifications/push-notifications.ts b/src/services/notifications/push-notifications.ts index 0de0c0ffd..761836748 100644 --- a/src/services/notifications/push-notifications.ts +++ b/src/services/notifications/push-notifications.ts @@ -15,7 +15,10 @@ import { ErrorLevel, parseErrorMessageFromUnknown } from "@domain/shared" import { baseLogger } from "@services/logger" import { recordExceptionInCurrentSpan, wrapAsyncToRunInSpan } from "@services/tracing" -import { FirebaseError } from "firebase-admin" +import { + safeFirebaseCount, + safeFirebaseMessagingCode, +} from "@domain/notifications/firebase-error-code" import { Message } from "firebase-admin/lib/messaging/messaging-api" import { messaging } from "./firebase" @@ -31,7 +34,10 @@ const sendToDevice = async ( notification: NotificationMessagePayload }, ) => { - logger.info({ tokens, ...message }) + logger.info( + { operation: "send_push", tokenCount: tokens.length }, + "Sending notification batch", + ) try { if (!messaging) { baseLogger.error("Firebase messaging module not loaded") @@ -42,21 +48,19 @@ const sendToDevice = async ( const invalidTokens: DeviceToken[] = [] // Deduped so a 200-device fleet failing on one bad APNs key reports one - // code, not two hundred. Carried on the returned error: the per-token warn - // lines below are keyed by token only, with no account or operator on them. + // code, not two hundred. Never carry raw tokens or provider error text in + // the diagnostic records; stale tokens remain internal pruning data. const failureCodes = new Set() batchResp.responses.forEach((r, idx) => { if (!r.success) { - failureCodes.add(r.error?.code ?? "unknown") + const code = safeFirebaseMessagingCode(r.error) + failureCodes.add(code) logger.warn( - { error: r.error, token: tokens[idx] }, + { operation: "send_push", code }, "Error sending notification to device", ) recordExceptionInCurrentSpan({ - error: new FirebaseMessageError( - r.error as unknown as FirebaseError, - tokens[idx], - ), + error: new FirebaseMessageError(r.error), level: ErrorLevel.Warn, }) } @@ -67,8 +71,8 @@ const sendToDevice = async ( logger.info( { - successCount: batchResp.successCount, - failureCount: batchResp.failureCount, + successCount: safeFirebaseCount(batchResp.successCount, tokens.length), + failureCount: safeFirebaseCount(batchResp.failureCount, tokens.length), failureCodes: [...failureCodes], }, "Notification batch response", @@ -106,7 +110,10 @@ const sendToDevice = async ( return true } catch (err) { - logger.error({ err, tokens, message }, "impossible to send notification") + logger.error( + { operation: "send_push", code: safeFirebaseMessagingCode(err) }, + "Unable to send notification batch", + ) const error = handleCommonNotificationErrors(err) recordExceptionInCurrentSpan({ error, level: ErrorLevel.Warn }) return error @@ -140,7 +147,10 @@ export const PushNotificationsService = (): IPushNotificationsService => { } if (deviceTokens.length <= 0) { - logger.info({ message, deviceTokens }, "no token. skipping notification") + logger.info( + { operation: "send_push", tokenCount: 0 }, + "no token. skipping notification", + ) return new InvalidDeviceNotificationsServiceError() } @@ -195,10 +205,12 @@ export const handleCommonNotificationErrors = (err: Error | string | unknown) => switch (true) { case match(KnownNotificationErrorMessages.GoogleBadGatewayError): case match(KnownNotificationErrorMessages.GoogleInternalServerError): - return new NotificationsServiceUnreachableServerError(errMsg) + return new NotificationsServiceUnreachableServerError( + "Firebase messaging service is unavailable", + ) default: - return new UnknownNotificationsServiceError(errMsg) + return new UnknownNotificationsServiceError("Firebase messaging request failed") } } diff --git a/test/flash/unit/services/notifications/firebase-telemetry-privacy.spec.ts b/test/flash/unit/services/notifications/firebase-telemetry-privacy.spec.ts new file mode 100644 index 000000000..a5186652f --- /dev/null +++ b/test/flash/unit/services/notifications/firebase-telemetry-privacy.spec.ts @@ -0,0 +1,132 @@ +jest.mock("@config", () => ({ + GOOGLE_APPLICATION_CREDENTIALS: "fixture-configured-without-reading-credentials", + getDefaultFCMTopics: () => [], +})) +jest.mock("firebase-admin", () => ({ + initializeApp: jest.fn(), + credential: { applicationDefault: jest.fn(() => ({})) }, + messaging: jest.fn(), +})) +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +const privateValues = [ + "FCM_PRIVATE_TOKEN", + "PRIVATE_TOPIC", + "PROVIDER_PRIVATE_MESSAGE", + "PRIVATE_BASIC_PASSWORD", +] +const providerError = (code = "messaging/internal-error") => + Object.assign(new Error(privateValues.join(" ")), { + code, + token: privateValues[0], + config: { headers: { authorization: privateValues[3] } }, + }) + +const loadAdapter = (initializeError?: Error) => { + jest.resetModules() + const admin = jest.requireMock("firebase-admin") + const logger = jest.requireMock("@services/logger").baseLogger + const messaging = { send: jest.fn(), subscribeToTopic: jest.fn() } + admin.messaging.mockReturnValue(messaging) + if (initializeError) + admin.initializeApp.mockImplementation(() => { + throw initializeError + }) + const adapter = jest.requireActual("@services/notifications/firebase") + .default as typeof import("@services/notifications/firebase").default + const telemetry = () => + JSON.stringify(Object.values(logger).flatMap((fn) => (fn as jest.Mock).mock.calls)) + const assertPrivateTelemetryAbsent = () => { + for (const value of privateValues) expect(telemetry()).not.toContain(value) + } + return { adapter, messaging, logger, telemetry, assertPrivateTelemetryAbsent } +} + +describe("Firebase adapter private source boundary", () => { + it("validates the exact device token in dry-run mode without logging it", async () => { + const { adapter, messaging, assertPrivateTelemetryAbsent } = loadAdapter() + messaging.send.mockResolvedValue("provider-message-id") + expect(await adapter.isDeviceTokenValid(privateValues[0])).toBe(true) + expect(messaging.send).toHaveBeenCalledWith( + { + token: privateValues[0], + notification: { title: "Test Message", body: "Checking if FCM token is valid" }, + }, + true, + ) + assertPrivateTelemetryAbsent() + }) + + it("returns false only for the known stale-token response without logging token or error body", async () => { + const { adapter, messaging, telemetry, assertPrivateTelemetryAbsent } = loadAdapter() + messaging.send.mockRejectedValue( + providerError("messaging/registration-token-not-registered"), + ) + expect(await adapter.isDeviceTokenValid(privateValues[0])).toBe(false) + expect(telemetry()).toContain("messaging/registration-token-not-registered") + assertPrivateTelemetryAbsent() + }) + + it.each(["messaging/third-party-auth-error", "PROVIDER_PRIVATE_MESSAGE"])( + "returns a safe domain failure for %s", + async (code) => { + const { adapter, messaging, assertPrivateTelemetryAbsent } = loadAdapter() + messaging.send.mockRejectedValue(providerError(code)) + const result = await adapter.isDeviceTokenValid(privateValues[0]) + expect(result).toBeInstanceOf(Error) + const error = result as Error & { failureCode: string } + expect(error.name).toBe("FirebaseMessageError") + expect(error.failureCode).toBe( + code.startsWith("messaging/") ? code : "messaging/unknown-error", + ) + const serializedError = JSON.stringify({ + ...error, + message: error.message, + stack: error.stack, + }) + for (const value of privateValues) expect(serializedError).not.toContain(value) + assertPrivateTelemetryAbsent() + }, + ) + + it("keeps provider topic arguments exact and omits topic, tokens and malformed counts from logs", async () => { + const { adapter, messaging, assertPrivateTelemetryAbsent } = loadAdapter() + messaging.subscribeToTopic.mockResolvedValue({ + successCount: privateValues[0], + failureCount: privateValues[2], + errors: [providerError()], + }) + await adapter.subscribeToTopics([privateValues[0]], [privateValues[1]]) + expect(messaging.subscribeToTopic).toHaveBeenCalledWith( + [privateValues[0]], + privateValues[1], + ) + assertPrivateTelemetryAbsent() + }) + + it("contains fire-and-forget topic rejections before process-wide rejection logging", async () => { + const { adapter, messaging, logger, assertPrivateTelemetryAbsent } = loadAdapter() + messaging.subscribeToTopic.mockRejectedValue(providerError()) + await adapter.subscribeToTopics(privateValues[0], [privateValues[1]]) + expect(messaging.subscribeToTopic).toHaveBeenCalledWith( + privateValues[0], + privateValues[1], + ) + expect(logger.error).toHaveBeenCalledWith( + { operation: "subscribe_push_topic", code: "messaging/internal-error" }, + "Push topic subscription failed", + ) + assertPrivateTelemetryAbsent() + }) + + it("does not log initialization error credentials and preserves unavailable behavior", async () => { + const { adapter, messaging, assertPrivateTelemetryAbsent } = + loadAdapter(providerError()) + const result = await adapter.isDeviceTokenValid(privateValues[0]) + expect((result as Error).name).toBe("FirebaseNotAvailable") + expect(messaging.send).not.toHaveBeenCalled() + assertPrivateTelemetryAbsent() + }) +}) diff --git a/test/flash/unit/services/notifications/push-notifications.spec.ts b/test/flash/unit/services/notifications/push-notifications.spec.ts index fec9aeccd..5d97517c2 100644 --- a/test/flash/unit/services/notifications/push-notifications.spec.ts +++ b/test/flash/unit/services/notifications/push-notifications.spec.ts @@ -1,3 +1,8 @@ +jest.mock("@services/logger", () => { + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } + return { baseLogger: { ...logger, child: () => logger } } +}) + const mockSendEachForMulticast = jest.fn() let mockMessaging: unknown = { sendEachForMulticast: mockSendEachForMulticast } @@ -21,9 +26,19 @@ import { InvalidDeviceNotificationsServiceError, NoDeviceAcceptedPushNotificationsServiceError, NotificationsServiceError, + NotificationsServiceUnreachableServerError, + UnknownNotificationsServiceError, } from "@domain/notifications" +import { baseLogger } from "@services/logger" +import { recordExceptionInCurrentSpan } from "@services/tracing" import { PushNotificationsService } from "@services/notifications/push-notifications" +const mockLogger = baseLogger.child({}) as unknown as { + info: jest.Mock + warn: jest.Mock + error: jest.Mock +} + const NOT_REGISTERED = "messaging/registration-token-not-registered" const args = { @@ -172,3 +187,117 @@ describe("PushNotificationsService.sendNotification", () => { expect(mockSendEachForMulticast).not.toHaveBeenCalled() }) }) + +describe("push telemetry privacy", () => { + const privateValues = [ + "FCM_DEVICE_SECRET", + "PRIVATE_RECIPIENT_NAME", + "PRIVATE_BANK_AMOUNT", + "PRIVATE_INVOICE", + "PROVIDER_PRIVATE_BODY", + ] + const privateArgs = { + deviceTokens: [privateValues[0]] as DeviceToken[], + title: privateValues[1], + body: privateValues[2], + data: { invoice: privateValues[3] }, + } + const capturedTelemetry = () => + JSON.stringify({ + logs: Object.values(mockLogger).flatMap((fn) => fn.mock.calls), + spans: jest.mocked(recordExceptionInCurrentSpan).mock.calls.map(([{ error }]) => { + if (!(error instanceof Error)) throw new Error("Expected a safe Error diagnostic") + return { ...error, message: error.message, stack: error.stack } + }), + }) + const expectPrivateTelemetryAbsent = () => { + for (const value of privateValues) expect(capturedTelemetry()).not.toContain(value) + } + beforeEach(() => { + jest.clearAllMocks() + mockMessaging = { sendEachForMulticast: mockSendEachForMulticast } + }) + + it("keeps exact delivery payload private while retaining safe stale-token pruning", async () => { + mockSendEachForMulticast.mockResolvedValue({ + successCount: 0, + failureCount: 1, + responses: [ + { + success: false, + error: { + code: NOT_REGISTERED, + message: privateValues.join(" "), + token: privateValues[0], + }, + }, + ], + }) + const result = await PushNotificationsService().sendNotification(privateArgs) + expect(mockSendEachForMulticast).toHaveBeenCalledWith( + { + tokens: privateArgs.deviceTokens, + notification: { title: privateArgs.title, body: privateArgs.body }, + data: privateArgs.data, + }, + false, + ) + expect(result).toBeInstanceOf(DeviceTokensNotRegisteredNotificationsServiceError) + expect((result as DeviceTokensNotRegisteredNotificationsServiceError).tokens).toEqual( + privateArgs.deviceTokens, + ) + expect( + (result as DeviceTokensNotRegisteredNotificationsServiceError).failureCodes, + ).toEqual([NOT_REGISTERED]) + expectPrivateTelemetryAbsent() + expect(capturedTelemetry()).toContain(NOT_REGISTERED) + }) + + it("never promotes arbitrary provider codes, counts or error objects into diagnostics", async () => { + mockSendEachForMulticast.mockResolvedValue({ + successCount: privateValues[2], + failureCount: privateValues[1], + responses: [ + { success: false, error: { code: privateValues[0], message: privateValues[4] } }, + ], + }) + await PushNotificationsService().sendNotification(privateArgs) + expectPrivateTelemetryAbsent() + expect(capturedTelemetry()).toContain("messaging/unknown-error") + }) + + it.each([ + [ + "Raw server response PRIVATE_BANK_AMOUNT Error 502", + NotificationsServiceUnreachableServerError, + ], + [ + "Raw server response PRIVATE_BANK_AMOUNT Error 500", + NotificationsServiceUnreachableServerError, + ], + ["PROVIDER_PRIVATE_BODY", UnknownNotificationsServiceError], + ])( + "preserves failure classification without provider free text: %s", + async (message, expectedType) => { + const error = Object.assign(new Error(message), { + config: { data: privateValues }, + code: privateValues[0], + }) + mockSendEachForMulticast.mockRejectedValue(error) + const result = await PushNotificationsService().sendNotification(privateArgs) + expect(result).toBeInstanceOf(expectedType) + for (const value of privateValues) + expect((result as Error).message).not.toContain(value) + expectPrivateTelemetryAbsent() + }, + ) + + it("does not log notification content when no token is registered", async () => { + await PushNotificationsService().sendNotification({ + ...privateArgs, + deviceTokens: [], + }) + expect(mockSendEachForMulticast).not.toHaveBeenCalled() + expectPrivateTelemetryAbsent() + }) +}) From 0b475cdab4a6a7c42dc5e01029f9d5eaa0249cba Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:14:53 -0700 Subject: [PATCH 19/42] Close adjacent notification logs and select policy privacy tests --- docs/native-parity-unit-tests.md | 4 +-- docs/push-notification-telemetry-privacy.md | 8 +++++ scripts/native-parity-unit.cjs | 7 ++++ src/app/admin/send-user-notification.ts | 5 ++- src/app/bridge/send-deposit-notification.ts | 2 -- src/app/bridge/send-kyc-notification.ts | 4 +-- .../bridge/send-withdrawal-notification.ts | 4 +-- src/app/fygaro/send-topup-notification.ts | 2 -- src/app/invite/send-referral-notifications.ts | 10 +++--- .../send-outcome-notification.ts | 36 +++++++++---------- .../payment-splits/execute-pending-splits.ts | 2 -- src/services/graphql-trace-privacy.ts | 2 ++ .../app/admin/send-user-notification.spec.ts | 5 +-- .../bridge/send-deposit-notification.spec.ts | 30 ++++++++++++++++ .../services/tracing-input-privacy.spec.ts | 4 ++- 15 files changed, 84 insertions(+), 41 deletions(-) diff --git a/docs/native-parity-unit-tests.md b/docs/native-parity-unit-tests.md index 1a5877c70..3f4f850fe 100644 --- a/docs/native-parity-unit-tests.md +++ b/docs/native-parity-unit-tests.md @@ -7,7 +7,7 @@ node scripts/native-parity-unit.cjs node --test scripts/native-parity-unit.test.cjs ``` -The first command runs the fixed, reviewed selection of 59 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. +The first command runs the fixed, reviewed selection of 66 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. The child receives only inherited `PATH` and `TMPDIR`, plus the explicit fixture values in `scripts/native-parity-unit.cjs`. Required secrets are unmistakable dummy strings. Network is `regtest`; configured service hosts point at numeric loopback port 9. `HOME`, cloud credentials/profiles, proxy variables, Node preloads, and inherited service/provider credentials are omitted. Jest's setup selects only committed `dev/config/base-config.yaml`; no private configuration file is loaded by the runner. Its required dummy environment exists only in the child process and must never be used for a deployment. @@ -23,6 +23,6 @@ Three narrow test boundaries now avoid real service initialization: The identity presigned-PUT test still uses the real pinned AWS SDK and sends the untouched signed URL with a nonempty PNG to its local HTTP fixture. The interlock permits that fixture. It emulates checksum/body acceptance, not production signature verification, and uses dummy credentials only. All Bridge, IBEX, notification, database and cache interactions elsewhere are mocked. -Validated 2026-10-23 on Node `24.22.1` against integrated branch commit `85d094992` plus this runner change: all **59 suites / 882 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-final-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. +Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `0d856f52f` plus the reviewed TOTP, push and notification-caller changes: all **66 suites / 955 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-postpush-privacy-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. These are offline unit checks. They do not establish provider access, sandbox interoperability, production configuration, bank-account provisioning, image storage acceptance by DigitalOcean, or deployment readiness. diff --git a/docs/push-notification-telemetry-privacy.md b/docs/push-notification-telemetry-privacy.md index 7b09b5a52..62856c71f 100644 --- a/docs/push-notification-telemetry-privacy.md +++ b/docs/push-notification-telemetry-privacy.md @@ -10,6 +10,14 @@ Token validation still supplies the exact token to Firebase with `dryRun: true`, Delivery payloads and pruning behavior are preserved: multicast still receives the exact tokens, notification and data with dry-run disabled; only unregistered tokens are returned for removal; real success counts and known deduplicated failure codes remain available to callers. A zero-accepted batch still reports failure. Provider 500/502 detection remains local and produces the existing unreachable-error class with fixed text; other failures retain the unknown-error class with fixed text. Privacy validation does not change provider response or money-state semantics. +Integration review also closed the adjacent application logs for outcome, +Bridge KYC/withdrawal, referral and admin push paths. Those callers retain +their return/prune behavior, but emit fixed operation/outcome metadata instead +of raw returned errors, thrown provider errors or caller-supplied context. +Returned stale-token errors still carry exact tokens only to the pruning +function. A regression test exercises both returned and thrown private push +errors and checks that the log sink cannot receive their text. + Validation: 13 reviewed suites pass, **124 tests**, under Node 24 using the integration runner's fake `safeEnvironment`, network guard and disabled Watchman. Tests assert exact SDK input, stale-token pruning, APNs/sender failure codes, zero-delivery failure, admin refusal semantics, notification preferences, dry-run token validation, initialization containment, topic success/failure privacy and malformed provider diagnostics. All three TypeScript configurations, scoped ESLint and `git diff --check` pass. This is a scoped source-boundary fix. Stale token arrays remain internal pruning data and must not be logged by future callers. It does not establish privacy for the separate multi-channel notifier, all application errors, explicitly attached account identifiers, historical records or collector retention. No assertion is made that every telemetry sink has been sanitized. diff --git a/scripts/native-parity-unit.cjs b/scripts/native-parity-unit.cjs index e01309e58..612dd6890 100644 --- a/scripts/native-parity-unit.cjs +++ b/scripts/native-parity-unit.cjs @@ -47,7 +47,9 @@ const suites = Object.freeze({ "services/storage/id-documents.spec.ts", ], telemetry: [ + "app/admin/send-user-notification.spec.ts", "app/authentication/registration-trace-privacy.spec.ts", + "app/invite/send-referral-notifications.spec.ts", "servers/request-log.spec.ts", "services/frappe/ErpNext.bankAccountUpdate.spec.ts", "services/frappe/ErpNext.bankAccounts.spec.ts", @@ -56,6 +58,8 @@ const suites = Object.freeze({ "services/frappe/request-telemetry-privacy.spec.ts", "services/http-trace-privacy.spec.ts", "services/ibex/request-trace-privacy.spec.ts", + "services/notifications/firebase-telemetry-privacy.spec.ts", + "services/notifications/push-notifications.spec.ts", "services/tracing-input-privacy.spec.ts", "services/tracing.spec.ts", ], @@ -64,7 +68,10 @@ const suites = Object.freeze({ "app/accounts/device-bootstrap-adapters.spec.ts", "app/authentication/login-device-bootstrap.spec.ts", "app/authentication/login-device-upgrade.spec.ts", + "app/authentication/totp-enrollment-policy.spec.ts", "app/cash-wallet-cutover/device-bootstrap-presentation.spec.ts", + "graphql/public/root/mutation/totp-registration-initiate-policy.spec.ts", + "graphql/totp-enrollment-error-map.spec.ts", "servers/authorization/device-bootstrap-route.spec.ts", "services/kratos/device-bootstrap-identity.spec.ts", "services/mongoose/device-bootstrap.spec.ts", diff --git a/src/app/admin/send-user-notification.ts b/src/app/admin/send-user-notification.ts index 77548d51e..270ee83d2 100644 --- a/src/app/admin/send-user-notification.ts +++ b/src/app/admin/send-user-notification.ts @@ -73,7 +73,10 @@ export const sendUserNotification = async ({ deviceTokens: result.tokens, }) if (pruned instanceof Error) { - baseLogger.warn({ accountId, err: pruned }, "failed to prune stale device tokens") + baseLogger.warn( + { operation: "prune_push_tokens", outcome: "failed" }, + "Failed to prune stale device tokens", + ) } if (result.successCount > 0) { diff --git a/src/app/bridge/send-deposit-notification.ts b/src/app/bridge/send-deposit-notification.ts index b1cb3aef1..360b4da73 100644 --- a/src/app/bridge/send-deposit-notification.ts +++ b/src/app/bridge/send-deposit-notification.ts @@ -51,7 +51,5 @@ export const sendBridgeDepositNotificationBestEffort = async ( return sendOutcomeNotificationBestEffort({ ...toOutcomeArgs(args), - logMessage: "Failed to send Bridge deposit push notification", - logContext: { accountId: args.accountId, outcome: args.outcome ?? "completed" }, }) } diff --git a/src/app/bridge/send-kyc-notification.ts b/src/app/bridge/send-kyc-notification.ts index 84fe5c248..800445559 100644 --- a/src/app/bridge/send-kyc-notification.ts +++ b/src/app/bridge/send-kyc-notification.ts @@ -137,8 +137,8 @@ export const sendBridgeKycNotificationBestEffort = async ( if (result instanceof Error) { baseLogger.warn( - { accountId: args.accountId, outcome: args.outcome, error: result }, - "Failed to send Bridge KYC push notification", + { operation: "send_bridge_kyc_push", outcome: "failed" }, + "Unable to send Bridge KYC push", ) } } diff --git a/src/app/bridge/send-withdrawal-notification.ts b/src/app/bridge/send-withdrawal-notification.ts index ac115bef1..40bc34bc3 100644 --- a/src/app/bridge/send-withdrawal-notification.ts +++ b/src/app/bridge/send-withdrawal-notification.ts @@ -114,8 +114,8 @@ export const sendBridgeWithdrawalNotificationBestEffort = async ( if (result instanceof Error) { baseLogger.warn( - { accountId: args.accountId, outcome: args.outcome, error: result }, - "Failed to send Bridge withdrawal push notification", + { operation: "send_bridge_withdrawal_push", outcome: "failed" }, + "Unable to send Bridge withdrawal push", ) } } diff --git a/src/app/fygaro/send-topup-notification.ts b/src/app/fygaro/send-topup-notification.ts index c46aebe5a..9dcefede5 100644 --- a/src/app/fygaro/send-topup-notification.ts +++ b/src/app/fygaro/send-topup-notification.ts @@ -104,6 +104,4 @@ export const sendFygaroTopupNotificationBestEffort = async ( ): Promise => sendOutcomeNotificationBestEffort({ ...toOutcomeArgs(args), - logMessage: "Failed to send Fygaro top-up push notification", - logContext: { accountId: args.accountId, outcome: args.outcome }, }) diff --git a/src/app/invite/send-referral-notifications.ts b/src/app/invite/send-referral-notifications.ts index 5d26946b5..a84721379 100644 --- a/src/app/invite/send-referral-notifications.ts +++ b/src/app/invite/send-referral-notifications.ts @@ -108,15 +108,15 @@ const bestEffort = async ( if (result instanceof Error) { baseLogger.warn( - { accountId: args.accountId, kind: args.kind, error: result }, - "Failed to send referral push notification", + { operation: "send_referral_push", outcome: "failed" }, + "Unable to send referral push", ) } - } catch (err) { + } catch { // Never let a push failure disturb the redeem or payout path. baseLogger.warn( - { accountId: args.accountId, kind: args.kind, err }, - "Unexpected error sending referral push notification", + { operation: "send_referral_push", outcome: "failed" }, + "Unable to send referral push", ) } } diff --git a/src/app/notifications/send-outcome-notification.ts b/src/app/notifications/send-outcome-notification.ts index 65e25e4b4..96f6051f4 100644 --- a/src/app/notifications/send-outcome-notification.ts +++ b/src/app/notifications/send-outcome-notification.ts @@ -93,35 +93,28 @@ const sendOutcomeNotification = async ({ * delivered is a worse experience, not a worse outcome, and turning it into an * error would put a delivered credit back into the provider's retry loop. */ -export const sendOutcomeNotificationBestEffort = async ({ - logMessage, - logContext, - ...args -}: OutcomeNotificationArgs & { - logMessage: string - logContext: Record -}): Promise => { +export const sendOutcomeNotificationBestEffort = async ( + args: OutcomeNotificationArgs, +): Promise => { try { - await sendOutcomeNotificationOrPrune({ logMessage, logContext, ...args }) - } catch (error) { + await sendOutcomeNotificationOrPrune(args) + } catch { // "Best effort" has to be a property of THIS function, not of today's // callees. Its caller awaits it on the money path and relies on it never // throwing — a guarantee that otherwise holds only because every repository // in the chain currently returns errors rather than raising them. One // future `throw` anywhere below would turn a delivered credit into a 500 // and hand it back to the provider's retry loop. - baseLogger.warn({ ...logContext, error }, logMessage) + baseLogger.warn( + { operation: "send_outcome_push", outcome: "failed" }, + "Unable to send outcome push", + ) } } -const sendOutcomeNotificationOrPrune = async ({ - logMessage, - logContext, - ...args -}: OutcomeNotificationArgs & { - logMessage: string - logContext: Record -}): Promise => { +const sendOutcomeNotificationOrPrune = async ( + args: OutcomeNotificationArgs, +): Promise => { const result = await sendOutcomeNotification(args) if (result instanceof DeviceTokensNotRegisteredNotificationsServiceError) { @@ -141,6 +134,9 @@ const sendOutcomeNotificationOrPrune = async ({ } if (result instanceof Error) { - baseLogger.warn({ ...logContext, error: result }, logMessage) + baseLogger.warn( + { operation: "send_outcome_push", outcome: "failed" }, + "Unable to send outcome push", + ) } } diff --git a/src/app/payment-splits/execute-pending-splits.ts b/src/app/payment-splits/execute-pending-splits.ts index f857c184e..9a97c2b26 100644 --- a/src/app/payment-splits/execute-pending-splits.ts +++ b/src/app/payment-splits/execute-pending-splits.ts @@ -95,8 +95,6 @@ const notifyOwner = async ( currency: "USD", walletId: execution.walletId, }, - logMessage: `Failed to send payment split ${outcome} push notification`, - logContext: { executionId: execution.id, walletId: execution.walletId }, }) /** diff --git a/src/services/graphql-trace-privacy.ts b/src/services/graphql-trace-privacy.ts index 3f815a5c2..7ef20fb18 100644 --- a/src/services/graphql-trace-privacy.ts +++ b/src/services/graphql-trace-privacy.ts @@ -15,6 +15,7 @@ const errorCodes = new Set([ "CANT_PAY_SELF", "CODE_EXPIRED_ERROR", "DB_ERROR", + "DEVICE_TOTP_REQUIRES_PHONE_UPGRADE", "DEALER_ERROR", "DEALER_OFFLINE", "EMAIL_ACCOUNT_ALREADY_EXISTS_ERROR", @@ -52,6 +53,7 @@ const errorCodes = new Set([ "SESSION_REFRESH_REQUIRED_ERROR", "TOO_MANY_REQUEST", "TRANSACTION_RESTRICTED", + "TOTP_ENROLLMENT_UNAVAILABLE", "UNAUTHORIZED_COUNTRY_IP_FOR_REWARD", "UNAUTHORIZED_IP_FOR_REWARD", "UNAUTHORIZED_VPN_IP_FOR_REWARD", diff --git a/test/flash/unit/app/admin/send-user-notification.spec.ts b/test/flash/unit/app/admin/send-user-notification.spec.ts index a27752d9d..24b65638b 100644 --- a/test/flash/unit/app/admin/send-user-notification.spec.ts +++ b/test/flash/unit/app/admin/send-user-notification.spec.ts @@ -389,7 +389,7 @@ describe("sendUserNotification", () => { it("logs a warning when pruning the stale tokens fails", async () => { const warn = jest.spyOn(baseLogger, "warn").mockImplementation(jest.fn() as never) - const pruneError = new Error("mongo write failed") + const pruneError = new Error("PRIVATE_STALE_DEVICE_TOKEN_AND_PROVIDER_BODY") ;(removeDeviceTokens as jest.Mock).mockResolvedValue(pruneError) sendFilteredNotification.mockResolvedValue( new DeviceTokensNotRegisteredNotificationsServiceError( @@ -402,9 +402,10 @@ describe("sendUserNotification", () => { expect(result).toBeInstanceOf(AllDeviceTokensStaleNotificationsServiceError) expect(warn).toHaveBeenCalledWith( - expect.objectContaining({ accountId, err: pruneError }), + { operation: "prune_push_tokens", outcome: "failed" }, expect.stringMatching(/failed to prune stale device tokens/i), ) + expect(JSON.stringify(warn.mock.calls)).not.toContain(pruneError.message) warn.mockRestore() }) diff --git a/test/flash/unit/app/bridge/send-deposit-notification.spec.ts b/test/flash/unit/app/bridge/send-deposit-notification.spec.ts index c7b8b1115..03196bbf8 100644 --- a/test/flash/unit/app/bridge/send-deposit-notification.spec.ts +++ b/test/flash/unit/app/bridge/send-deposit-notification.spec.ts @@ -41,6 +41,8 @@ jest.mock("@services/logger", () => { }) import { sendBridgeDepositNotificationBestEffort } from "@app/bridge/send-deposit-notification" +import { NotificationsServiceError } from "@domain/notifications" +import { baseLogger } from "@services/logger" const ACCOUNT_ID = "507f1f77bcf86cd799439011" @@ -135,3 +137,31 @@ describe("sendBridgeDepositNotificationBestEffort — payload content", () => { }) }) }) + +describe("outcome push telemetry", () => { + afterEach(() => jest.clearAllMocks()) + + it.each(["returned", "thrown"])( + "keeps private notification and provider data out of logs for %s failures", + async (mode) => { + const privateValue = "PRIVATE_DEVICE_TOKEN_AND_NOTIFICATION" + findUserById.mockResolvedValue({ deviceTokens: [privateValue], language: "en" }) + const failure = new NotificationsServiceError(privateValue) + if (mode === "returned") mockSendFiltered.mockResolvedValue(failure) + else mockSendFiltered.mockRejectedValue(failure) + await sendBridgeDepositNotificationBestEffort({ + accountId: ACCOUNT_ID, + amount: "10", + currency: "USD", + }) + expect(mockSendFiltered).toHaveBeenCalledTimes(1) + expect(baseLogger.warn).toHaveBeenCalledWith( + { operation: "send_outcome_push", outcome: "failed" }, + "Unable to send outcome push", + ) + expect(JSON.stringify(jest.mocked(baseLogger.warn).mock.calls)).not.toContain( + privateValue, + ) + }, + ) +}) diff --git a/test/flash/unit/services/tracing-input-privacy.spec.ts b/test/flash/unit/services/tracing-input-privacy.spec.ts index 8e1c30d38..a01adcd4b 100644 --- a/test/flash/unit/services/tracing-input-privacy.spec.ts +++ b/test/flash/unit/services/tracing-input-privacy.spec.ts @@ -617,8 +617,10 @@ it.each([ "BRIDGE_KYC_PENDING", "BRIDGE_INSUFFICIENT_FUNDS", "BRIDGE_INVALID_PLAID_TOKEN", + "DEVICE_TOTP_REQUIRES_PHONE_UPGRADE", + "TOTP_ENROLLMENT_UNAVAILABLE", ])( - "preserves the closed %s banking code from a real GraphQL error without retaining its private text", + "preserves the closed %s code from a real GraphQL error without retaining its private text", (code) => { const instrumentation = configuredGraphQL() Reflect.get(instrumentation, "_createExecuteSpan").call( From 86a25a84224eb151c8fa8a25943405b910fe8bb9 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:14:11 -0700 Subject: [PATCH 20/42] fix: close notification and preregistration telemetry inputs --- docs/notification-preregistration-privacy.md | 13 + .../validate-preregistration-payload.ts | 20 +- src/services/notification/index.ts | 117 +++--- .../validate-preregistration-payload.spec.ts | 50 +++ .../notification/telemetry-privacy.spec.ts | 349 ++++++++++++++++++ 5 files changed, 481 insertions(+), 68 deletions(-) create mode 100644 docs/notification-preregistration-privacy.md create mode 100644 test/flash/unit/services/notification/telemetry-privacy.spec.ts diff --git a/docs/notification-preregistration-privacy.md b/docs/notification-preregistration-privacy.md new file mode 100644 index 000000000..440c1d35a --- /dev/null +++ b/docs/notification-preregistration-privacy.md @@ -0,0 +1,13 @@ +# Multi-channel notification and pre-registration privacy + +This leaf is based on integrated backend `0d856f52fc81d49638bf3fad0cc5b6efbd4a3cbd`, after the preceding automatic argument/GraphQL/HTTP privacy boundaries and push-notification cleanup. It changes the separate email/SMS/WhatsApp notifier and the pre-registration helper only. No real credentials, provider requests, exported spans or deployments were used. + +The notifier no longer logs recipient or sender addresses, provider account/message identifiers, message lengths, SDK error objects, provider error messages/details or response fields. Initialization, delivery success/refusal/failure and unsupported-method paths record fixed operation/outcome text. The WhatsApp template indicator remains a boolean; content variables and message bodies remain private. Caught exceptions are not inspected or stringified for diagnostics, including initialization and bridge-network failures. + +Provider behavior is retained: Twilio receives the original credentials, sender/recipient, SMS content and WhatsApp prefix/template arguments; SendGrid receives the original API key and email payload; the WhatsApp bridge receives the original secret header and JSON body. Configuration selection, bridge response acceptance, timeout cleanup and true/false delivery decisions remain unchanged. This cleanup adds no retry, provider redirect policy or delivery guarantee. + +The pre-registration helper previously copied arbitrary `body.schema_id` into an application span before validating the callback secret. It now records the fixed supported schema and closed outcomes. An invalid or missing callback key is rejected without reading payload fields, including fields implemented as throwing getters. Authorized validation, phone lookup, refusal errors, repository error identity and read-only behavior remain unchanged. The span metadata does not include the submitted schema, phone or callback key. + +Validation: **68 tests across six reviewed suites** pass under Node 24 with the integration runner's fake `safeEnvironment`, network guard and Watchman disabled. Tests assert exact credential/body delivery to mocked providers, email/SMS/plain and template WhatsApp success, all provider failures, unavailable configuration, unknown methods, bridge acceptance/network/JSON failures, callback preauthorization non-inspection, schema refusal and existing Kratos route/validation behavior. All three TypeScript configurations, scoped ESLint and `git diff --check` pass. + +This is a scoped source fix. It does not sanitize all application errors or explicit identity metadata elsewhere, remove historical telemetry, or establish production collector retention policy. Internal domain errors preserve their application identity; the helper does not add raw error diagnostics. The private request values are still sent to their intended providers as required for delivery. diff --git a/src/app/authentication/validate-preregistration-payload.ts b/src/app/authentication/validate-preregistration-payload.ts index 63282c9bb..88c21c30b 100644 --- a/src/app/authentication/validate-preregistration-payload.ts +++ b/src/app/authentication/validate-preregistration-payload.ts @@ -31,26 +31,38 @@ export const validatePreRegistrationPayload = async ({ } }): Promise => { addAttributesToCurrentSpan({ - "preregistration.schema_id": body.schema_id, + "preregistration.schema_id": SchemaIdType.PhoneNoPasswordV0, + "preregistration.outcome": "received", }) const isValidKey = CallbackSecretValidator(KRATOS_CALLBACK_API_KEY).authorize(secret) if (isValidKey instanceof Error) { + addAttributesToCurrentSpan({ "preregistration.outcome": "callback_rejected" }) return isValidKey } const payload = PreRegistrationPayloadValidator( SchemaIdType.PhoneNoPasswordV0, ).validate(body) - if (payload instanceof Error) return payload + if (payload instanceof Error) { + addAttributesToCurrentSpan({ "preregistration.outcome": "invalid_payload" }) + return payload + } // The post-persist hook upserts the users document by phone, which has a // unique index. A document already holding this phone is exactly the // DuplicateKeyForPersistError that used to strand the identity; reject it // here while nothing has been committed. const existing = await UsersRepository().findByPhone(payload.phone) - if (existing instanceof CouldNotFindUserFromPhoneError) return true - if (existing instanceof Error) return existing + if (existing instanceof CouldNotFindUserFromPhoneError) { + addAttributesToCurrentSpan({ "preregistration.outcome": "eligible" }) + return true + } + if (existing instanceof Error) { + addAttributesToCurrentSpan({ "preregistration.outcome": "lookup_failed" }) + return existing + } + addAttributesToCurrentSpan({ "preregistration.outcome": "phone_registered" }) return new PhoneAlreadyRegisteredError() } diff --git a/src/services/notification/index.ts b/src/services/notification/index.ts index e44584151..6998eecaa 100644 --- a/src/services/notification/index.ts +++ b/src/services/notification/index.ts @@ -47,30 +47,24 @@ class NotificationServiceImpl implements NotificationService { private initializeTwilio() { try { if (env.TWILIO_ACCOUNT_SID && env.TWILIO_AUTH_TOKEN) { - // Never log credential material (not even a prefix/length). baseLogger.info( - { - accountSid: env.TWILIO_ACCOUNT_SID, - verifyServiceId: env.TWILIO_VERIFY_SERVICE_ID, - twilioFrom: env.TWILIO_FROM || "NOT SET", - twilioWhatsAppFrom: env.TWILIO_WHATSAPP_FROM || "NOT SET", - }, - "Initializing Twilio client with credentials", + { operation: "initialize_twilio", outcome: "started" }, + "Initializing Twilio client", ) this.twilioClient = twilio(env.TWILIO_ACCOUNT_SID, env.TWILIO_AUTH_TOKEN) baseLogger.info("Twilio client initialized successfully") } else { baseLogger.warn( - { - hasAccountSid: !!env.TWILIO_ACCOUNT_SID, - hasAuthToken: !!env.TWILIO_AUTH_TOKEN, - }, + { operation: "initialize_twilio", outcome: "unconfigured" }, "Twilio credentials not fully configured", ) } - } catch (error) { - baseLogger.error({ error }, "Failed to initialize Twilio client") + } catch { + baseLogger.error( + { operation: "initialize_twilio", outcome: "failed" }, + "Failed to initialize Twilio client", + ) } } @@ -82,8 +76,11 @@ class NotificationServiceImpl implements NotificationService { } else { baseLogger.warn("SendGrid API key not configured") } - } catch (error) { - baseLogger.error({ error }, "Failed to initialize SendGrid client") + } catch { + baseLogger.error( + { operation: "initialize_sendgrid", outcome: "failed" }, + "Failed to initialize SendGrid client", + ) } } @@ -102,11 +99,17 @@ class NotificationServiceImpl implements NotificationService { case NotificationMethod.WHATSAPP: return await this.sendWhatsApp(to, subjectOrBody) default: - baseLogger.error({ method }, "Unknown notification method") + baseLogger.error( + { operation: "send_notification", outcome: "unsupported_method" }, + "Unknown notification method", + ) return false } - } catch (error) { - baseLogger.error({ error, method, to }, "Failed to send notification") + } catch { + baseLogger.error( + { operation: "send_notification", outcome: "failed" }, + "Failed to send notification", + ) return false } } @@ -132,10 +135,16 @@ class NotificationServiceImpl implements NotificationService { html: htmlBody || subject, }) - baseLogger.info({ to }, "Email sent successfully via SendGrid") + baseLogger.info( + { operation: "send_email", outcome: "sent" }, + "Email sent successfully via SendGrid", + ) return true - } catch (error) { - baseLogger.error({ error, to }, "Failed to send email via SendGrid") + } catch { + baseLogger.error( + { operation: "send_email", outcome: "failed" }, + "Failed to send email via SendGrid", + ) return false } } @@ -157,10 +166,13 @@ class NotificationServiceImpl implements NotificationService { from: TWILIO_FROM, to, }) - baseLogger.info({ to }, "SMS sent successfully via Twilio") + baseLogger.info( + { operation: "send_sms", outcome: "sent" }, + "SMS sent successfully via Twilio", + ) return true - } catch (error) { - baseLogger.error({ error, to }, "Failed to send SMS") + } catch { + baseLogger.error({ operation: "send_sms", outcome: "failed" }, "Failed to send SMS") return false } } @@ -191,13 +203,7 @@ class NotificationServiceImpl implements NotificationService { : `whatsapp:${TWILIO_WHATSAPP_FROM}` baseLogger.info( - { - to: whatsappTo, - from: whatsappFrom, - bodyLength: body.length, - accountSid: env.TWILIO_ACCOUNT_SID, - hasAuthToken: !!env.TWILIO_AUTH_TOKEN, - }, + { operation: "send_whatsapp", outcome: "started" }, "Attempting to send WhatsApp message", ) @@ -233,45 +239,22 @@ class NotificationServiceImpl implements NotificationService { // are stored only as sha256 hashes — they must never reach the logs). baseLogger.info( { - from: messageOptions.from, - to: messageOptions.to, + operation: "send_whatsapp", usesTemplate: Boolean(messageOptions.contentSid), }, "Sending WhatsApp message with options", ) - const message = await this.twilioClient.messages.create(messageOptions) + await this.twilioClient.messages.create(messageOptions) baseLogger.info( - { - to: whatsappTo, - messageSid: message.sid, - status: message.status, - }, + { operation: "send_whatsapp", outcome: "sent" }, "WhatsApp message sent successfully via Twilio", ) return true - } catch (error) { - const err = error as { - message?: string - code?: string - status?: number - moreInfo?: string - details?: string - } + } catch { baseLogger.error( - { - error: { - message: err.message, - code: err.code, - status: err.status, - moreInfo: err.moreInfo, - details: err.details, - }, - to: whatsappTo, - from: whatsappFrom, - accountSid: env.TWILIO_ACCOUNT_SID, - }, + { operation: "send_whatsapp", outcome: "failed" }, "Failed to send WhatsApp message", ) return false @@ -300,15 +283,21 @@ class NotificationServiceImpl implements NotificationService { const ok = resp.ok && payload.ok === true if (ok) { baseLogger.info( - { to, bodyLength: body.length }, + { operation: "send_whatsapp_bridge", outcome: "sent" }, "WhatsApp message sent via wa-bridge", ) } else { - baseLogger.error({ to, status: resp.status }, "wa-bridge send failed") + baseLogger.error( + { operation: "send_whatsapp_bridge", outcome: "failed" }, + "wa-bridge send failed", + ) } return ok - } catch (error) { - baseLogger.error({ error: String(error), to }, "wa-bridge send error") + } catch { + baseLogger.error( + { operation: "send_whatsapp_bridge", outcome: "failed" }, + "wa-bridge send error", + ) return false } finally { clearTimeout(timeout) diff --git a/test/flash/unit/app/authentication/validate-preregistration-payload.spec.ts b/test/flash/unit/app/authentication/validate-preregistration-payload.spec.ts index 88c82f9c4..2326513ab 100644 --- a/test/flash/unit/app/authentication/validate-preregistration-payload.spec.ts +++ b/test/flash/unit/app/authentication/validate-preregistration-payload.spec.ts @@ -9,6 +9,7 @@ import { CouldNotFindUserFromPhoneError, UnknownRepositoryError } from "@domain/ import { InvalidCarrierTypeForPhoneMetadataError } from "@domain/users/errors" import { SchemaIdType } from "@services/kratos" import { UsersRepository } from "@services/mongoose" +import { addAttributesToCurrentSpan } from "@services/tracing" const CALLBACK_KEY = "unit-test-callback-key" @@ -58,6 +59,7 @@ const body = (overrides: Record = {}) => ({ describe("validatePreRegistrationPayload", () => { beforeEach(() => { + jest.clearAllMocks() mockFindByPhone.mockReset() mockFindById.mockReset() mockUpdate.mockReset() @@ -147,4 +149,52 @@ describe("validatePreRegistrationPayload", () => { expect(result).toBe(repoError) }) + + it("never inspects unauthenticated payload fields for span metadata", async () => { + const untrustedBody = Object.defineProperties( + {}, + { + schema_id: { + get() { + throw new Error("PRIVATE_SCHEMA_MUST_NOT_BE_READ") + }, + }, + phone: { + get() { + throw new Error("PRIVATE_PHONE_MUST_NOT_BE_READ") + }, + }, + }, + ) + expect( + await validatePreRegistrationPayload({ + secret: "PRIVATE_WRONG_SECRET", + body: untrustedBody, + }), + ).toBeInstanceOf(InvalidSecretForAuthNCallbackError) + expect(mockFindByPhone).not.toHaveBeenCalled() + expect(jest.mocked(addAttributesToCurrentSpan).mock.calls).toEqual([ + [ + { + "preregistration.schema_id": SchemaIdType.PhoneNoPasswordV0, + "preregistration.outcome": "received", + }, + ], + [{ "preregistration.outcome": "callback_rejected" }], + ]) + }) + + it("omits caller schema, phone and callback key from invalid-payload diagnostics", async () => { + const result = await validatePreRegistrationPayload({ + secret: CALLBACK_KEY, + body: body({ schema_id: "PRIVATE_SCHEMA_TEXT", phone: "+18765550999" }), + }) + expect(result).toBeInstanceOf(UnsupportedSchemaTypeError) + const attributes = JSON.stringify(jest.mocked(addAttributesToCurrentSpan).mock.calls) + expect(attributes).not.toContain("PRIVATE_SCHEMA_TEXT") + expect(attributes).not.toContain("+18765550999") + expect(attributes).not.toContain(CALLBACK_KEY) + expect(attributes).toContain("invalid_payload") + expect(mockFindByPhone).not.toHaveBeenCalled() + }) }) diff --git a/test/flash/unit/services/notification/telemetry-privacy.spec.ts b/test/flash/unit/services/notification/telemetry-privacy.spec.ts new file mode 100644 index 000000000..c745fa786 --- /dev/null +++ b/test/flash/unit/services/notification/telemetry-privacy.spec.ts @@ -0,0 +1,349 @@ +import type { NotificationMethod as NotificationMethodType } from "@services/notification" + +jest.mock("twilio", () => jest.fn()) +jest.mock("@sendgrid/mail", () => ({ setApiKey: jest.fn(), send: jest.fn() })) +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +const privateValues = { + accountSid: "PRIVATE_TWILIO_ACCOUNT_SID", + authToken: "PRIVATE_TWILIO_AUTH_TOKEN", + verifyServiceId: "PRIVATE_VERIFY_SERVICE_ID", + apiKey: "PRIVATE_SENDGRID_KEY", + recipient: "+18765550123", + email: "PRIVATE_EMAIL@example.test", + sender: "+18765550999", + subject: "PRIVATE_BANK_CUSTOMER", + body: "PRIVATE_ONE_TIME_INVITE_LINK", + html: "

PRIVATE_BANK_ACCOUNT

", + error: "PRIVATE_PROVIDER_ERROR_BODY", + messageID: "PRIVATE_PROVIDER_MESSAGE_ID", + bridgeSecret: "PRIVATE_BRIDGE_SECRET", + template: "PRIVATE_TEMPLATE_ID", + templateName: "PRIVATE_TEMPLATE_NAME", +} + +const providerError = () => + Object.assign(new Error(privateValues.error), { + code: privateValues.authToken, + status: privateValues.accountSid, + moreInfo: privateValues.body, + details: privateValues.html, + config: { headers: { authorization: privateValues.apiKey }, data: privateValues }, + response: { + body: privateValues, + request: { headers: { authorization: privateValues.authToken } }, + }, + }) + +const loadNotifier = ({ + twilioFailure = false, + sendGridFailure = false, + configured = true, +} = {}) => { + jest.resetModules() + jest.doMock("@config", () => ({ + env: configured + ? { + TWILIO_ACCOUNT_SID: privateValues.accountSid, + TWILIO_AUTH_TOKEN: privateValues.authToken, + TWILIO_VERIFY_SERVICE_ID: privateValues.verifyServiceId, + TWILIO_FROM: privateValues.sender, + TWILIO_WHATSAPP_FROM: privateValues.sender, + } + : {}, + SendGridConfig: configured ? { apiKey: privateValues.apiKey } : {}, + TWILIO_FROM: configured ? privateValues.sender : undefined, + TWILIO_WHATSAPP_FROM: configured ? privateValues.sender : undefined, + })) + const twilio = jest.requireMock("twilio") as jest.Mock + const sendGrid = jest.requireMock("@sendgrid/mail") as { + setApiKey: jest.Mock + send: jest.Mock + } + const logger = jest.requireMock("@services/logger").baseLogger as { + info: jest.Mock + warn: jest.Mock + error: jest.Mock + } + const create = jest + .fn() + .mockResolvedValue({ sid: privateValues.messageID, status: privateValues.error }) + twilio.mockImplementation(() => { + if (twilioFailure) throw providerError() + return { messages: { create } } + }) + sendGrid.setApiKey.mockImplementation(() => { + if (sendGridFailure) throw providerError() + }) + sendGrid.send.mockResolvedValue([{ body: privateValues }]) + const module = jest.requireActual( + "@services/notification", + ) as typeof import("@services/notification") + const assertPrivateTelemetryAbsent = () => { + const telemetry = JSON.stringify(Object.values(logger).flatMap((fn) => fn.mock.calls)) + for (const value of Object.values(privateValues)) + expect(telemetry).not.toContain(value) + } + return { ...module, twilio, sendGrid, create, assertPrivateTelemetryAbsent } +} + +describe("notification provider telemetry privacy", () => { + const originalFetch = global.fetch + const keys = [ + "WA_BRIDGE_URL", + "WA_BRIDGE_SECRET", + "TWILIO_WHATSAPP_TEMPLATE_SID", + "SENDGRID_FROM_EMAIL", + ] + let previous: Array + let fetchMock: jest.Mock + + beforeEach(() => { + previous = keys.map((key) => process.env[key]) + for (const key of keys) delete process.env[key] + process.env.TWILIO_WHATSAPP_TEMPLATE_SID = privateValues.template + fetchMock = jest.fn() + global.fetch = fetchMock as unknown as typeof fetch + }) + afterEach(() => { + global.fetch = originalFetch + keys.forEach((key, index) => { + if (previous[index] === undefined) delete process.env[key] + else process.env[key] = previous[index] + }) + }) + + it("preserves SDK credentials and exact email payload without logging recipients or content", async () => { + const { + notificationService, + NotificationMethod, + twilio, + sendGrid, + assertPrivateTelemetryAbsent, + } = loadNotifier() + expect(twilio).toHaveBeenCalledWith(privateValues.accountSid, privateValues.authToken) + expect(sendGrid.setApiKey).toHaveBeenCalledWith(privateValues.apiKey) + expect( + await notificationService.sendNotification( + NotificationMethod.EMAIL, + privateValues.email, + privateValues.subject, + privateValues.html, + ), + ).toBe(true) + expect(sendGrid.send).toHaveBeenCalledWith({ + to: privateValues.email, + from: "noreply@getflash.io", + subject: privateValues.subject, + text: privateValues.subject, + html: privateValues.html, + }) + assertPrivateTelemetryAbsent() + }) + + it("preserves exact SMS payload and successful delivery", async () => { + const { + notificationService, + NotificationMethod, + create, + assertPrivateTelemetryAbsent, + } = loadNotifier() + expect( + await notificationService.sendNotification( + NotificationMethod.SMS, + privateValues.recipient, + privateValues.body, + ), + ).toBe(true) + expect(create).toHaveBeenCalledWith({ + body: privateValues.body, + from: privateValues.sender, + to: privateValues.recipient, + }) + assertPrivateTelemetryAbsent() + }) + + it.each([false, true])( + "preserves WhatsApp payload, prefix and template selection (template=%s)", + async (template) => { + const { + notificationService, + NotificationMethod, + create, + assertPrivateTelemetryAbsent, + } = loadNotifier() + const body = template + ? JSON.stringify({ + templateName: privateValues.templateName, + templateVariables: { "1": privateValues.body }, + }) + : privateValues.body + expect( + await notificationService.sendNotification( + NotificationMethod.WHATSAPP, + privateValues.recipient, + body, + ), + ).toBe(true) + expect(create).toHaveBeenCalledWith( + template + ? { + from: `whatsapp:${privateValues.sender}`, + to: `whatsapp:${privateValues.recipient}`, + contentSid: privateValues.template, + contentVariables: JSON.stringify({ "1": privateValues.body }), + } + : { + from: `whatsapp:${privateValues.sender}`, + to: `whatsapp:${privateValues.recipient}`, + body: privateValues.body, + }, + ) + assertPrivateTelemetryAbsent() + }, + ) + + it.each(["EMAIL", "SMS", "WHATSAPP"])( + "keeps %s SDK failures false without serializing private SDK errors", + async (method) => { + const { + notificationService, + NotificationMethod, + create, + sendGrid, + assertPrivateTelemetryAbsent, + } = loadNotifier() + create.mockRejectedValue(providerError()) + sendGrid.send.mockRejectedValue(providerError()) + expect( + await notificationService.sendNotification( + NotificationMethod[method as keyof typeof NotificationMethod], + privateValues.recipient, + privateValues.body, + ), + ).toBe(false) + assertPrivateTelemetryAbsent() + }, + ) + + it("contains raw initialization failures while preserving unavailable delivery decisions", async () => { + const { + notificationService, + NotificationMethod, + create, + assertPrivateTelemetryAbsent, + } = loadNotifier({ twilioFailure: true, sendGridFailure: true }) + expect( + await notificationService.sendNotification( + NotificationMethod.SMS, + privateValues.recipient, + privateValues.body, + ), + ).toBe(false) + expect( + await notificationService.sendNotification( + NotificationMethod.WHATSAPP, + privateValues.recipient, + privateValues.body, + ), + ).toBe(false) + expect(create).not.toHaveBeenCalled() + assertPrivateTelemetryAbsent() + }) + + it("preserves unconfigured refusal and unknown-method behavior without logging caller text", async () => { + const { + notificationService, + NotificationMethod, + create, + sendGrid, + assertPrivateTelemetryAbsent, + } = loadNotifier({ configured: false }) + for (const method of Object.values(NotificationMethod)) + expect( + await notificationService.sendNotification( + method, + privateValues.recipient, + privateValues.body, + ), + ).toBe(false) + expect( + await notificationService.sendNotification( + privateValues.authToken as NotificationMethodType, + privateValues.email, + privateValues.body, + ), + ).toBe(false) + expect(create).not.toHaveBeenCalled() + expect(sendGrid.send).not.toHaveBeenCalled() + assertPrivateTelemetryAbsent() + }) + + it("keeps exact bridge auth/body and handles successful provider data without logging it", async () => { + const { + notificationService, + NotificationMethod, + create, + assertPrivateTelemetryAbsent, + } = loadNotifier() + process.env.WA_BRIDGE_URL = "https://bridge.example.test/send" + process.env.WA_BRIDGE_SECRET = privateValues.bridgeSecret + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ ok: true, ...privateValues }), + }) + expect( + await notificationService.sendNotification( + NotificationMethod.WHATSAPP, + privateValues.recipient, + privateValues.body, + ), + ).toBe(true) + expect(fetchMock).toHaveBeenCalledWith( + "https://bridge.example.test/send", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Send-Token": privateValues.bridgeSecret, + }, + body: JSON.stringify({ to: privateValues.recipient, text: privateValues.body }), + signal: expect.any(AbortSignal), + }), + ) + expect(create).not.toHaveBeenCalled() + assertPrivateTelemetryAbsent() + }) + + it.each(["rejected", "not-ok", "malformed-json"])( + "keeps bridge %s failure false and private", + async (failure) => { + const { notificationService, NotificationMethod, assertPrivateTelemetryAbsent } = + loadNotifier() + process.env.WA_BRIDGE_URL = "https://bridge.example.test/send" + process.env.WA_BRIDGE_SECRET = privateValues.bridgeSecret + if (failure === "rejected") fetchMock.mockRejectedValue(providerError()) + else + fetchMock.mockResolvedValue({ + ok: false, + status: privateValues.authToken, + json: + failure === "malformed-json" + ? async () => { + throw providerError() + } + : async () => ({ ok: false, ...privateValues }), + }) + expect( + await notificationService.sendNotification( + NotificationMethod.WHATSAPP, + privateValues.recipient, + privateValues.body, + ), + ).toBe(false) + assertPrivateTelemetryAbsent() + }, + ) +}) From 7a5985f54a0b5f7541769d9869d7561b24ac1dc2 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:16:09 -0700 Subject: [PATCH 21/42] Include notifier privacy regressions in guarded parity runner --- docs/native-parity-unit-tests.md | 4 ++-- scripts/native-parity-unit.cjs | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/native-parity-unit-tests.md b/docs/native-parity-unit-tests.md index 3f4f850fe..97f6ecbcb 100644 --- a/docs/native-parity-unit-tests.md +++ b/docs/native-parity-unit-tests.md @@ -7,7 +7,7 @@ node scripts/native-parity-unit.cjs node --test scripts/native-parity-unit.test.cjs ``` -The first command runs the fixed, reviewed selection of 66 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. +The first command runs the fixed, reviewed selection of 68 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. The child receives only inherited `PATH` and `TMPDIR`, plus the explicit fixture values in `scripts/native-parity-unit.cjs`. Required secrets are unmistakable dummy strings. Network is `regtest`; configured service hosts point at numeric loopback port 9. `HOME`, cloud credentials/profiles, proxy variables, Node preloads, and inherited service/provider credentials are omitted. Jest's setup selects only committed `dev/config/base-config.yaml`; no private configuration file is loaded by the runner. Its required dummy environment exists only in the child process and must never be used for a deployment. @@ -23,6 +23,6 @@ Three narrow test boundaries now avoid real service initialization: The identity presigned-PUT test still uses the real pinned AWS SDK and sends the untouched signed URL with a nonempty PNG to its local HTTP fixture. The interlock permits that fixture. It emulates checksum/body acceptance, not production signature verification, and uses dummy credentials only. All Bridge, IBEX, notification, database and cache interactions elsewhere are mocked. -Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `0d856f52f` plus the reviewed TOTP, push and notification-caller changes: all **66 suites / 955 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-postpush-privacy-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. +Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `86a25a842` plus the reviewed runner changes: all **68 suites / 978 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-post-notifier-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. These are offline unit checks. They do not establish provider access, sandbox interoperability, production configuration, bank-account provisioning, image storage acceptance by DigitalOcean, or deployment readiness. diff --git a/scripts/native-parity-unit.cjs b/scripts/native-parity-unit.cjs index 612dd6890..dcda8cbf4 100644 --- a/scripts/native-parity-unit.cjs +++ b/scripts/native-parity-unit.cjs @@ -49,6 +49,7 @@ const suites = Object.freeze({ telemetry: [ "app/admin/send-user-notification.spec.ts", "app/authentication/registration-trace-privacy.spec.ts", + "app/authentication/validate-preregistration-payload.spec.ts", "app/invite/send-referral-notifications.spec.ts", "servers/request-log.spec.ts", "services/frappe/ErpNext.bankAccountUpdate.spec.ts", @@ -60,6 +61,7 @@ const suites = Object.freeze({ "services/ibex/request-trace-privacy.spec.ts", "services/notifications/firebase-telemetry-privacy.spec.ts", "services/notifications/push-notifications.spec.ts", + "services/notification/telemetry-privacy.spec.ts", "services/tracing-input-privacy.spec.ts", "services/tracing.spec.ts", ], From d3cb94cffc247fb07e80bef9130d3ee8ab16d625 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:21:09 -0700 Subject: [PATCH 22/42] Keep payer attribution diagnostics private --- docs/native-parity-unit-tests.md | 4 ++-- scripts/native-parity-unit.cjs | 1 + src/services/fygaro/webhook-server/routes/payment.ts | 7 +++++-- .../services/fygaro/webhook-server/payment.spec.ts | 12 +++++++++++- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/native-parity-unit-tests.md b/docs/native-parity-unit-tests.md index 97f6ecbcb..907103fb8 100644 --- a/docs/native-parity-unit-tests.md +++ b/docs/native-parity-unit-tests.md @@ -7,7 +7,7 @@ node scripts/native-parity-unit.cjs node --test scripts/native-parity-unit.test.cjs ``` -The first command runs the fixed, reviewed selection of 68 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. +The first command runs the fixed, reviewed selection of 69 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. The child receives only inherited `PATH` and `TMPDIR`, plus the explicit fixture values in `scripts/native-parity-unit.cjs`. Required secrets are unmistakable dummy strings. Network is `regtest`; configured service hosts point at numeric loopback port 9. `HOME`, cloud credentials/profiles, proxy variables, Node preloads, and inherited service/provider credentials are omitted. Jest's setup selects only committed `dev/config/base-config.yaml`; no private configuration file is loaded by the runner. Its required dummy environment exists only in the child process and must never be used for a deployment. @@ -23,6 +23,6 @@ Three narrow test boundaries now avoid real service initialization: The identity presigned-PUT test still uses the real pinned AWS SDK and sends the untouched signed URL with a nonempty PNG to its local HTTP fixture. The interlock permits that fixture. It emulates checksum/body acceptance, not production signature verification, and uses dummy credentials only. All Bridge, IBEX, notification, database and cache interactions elsewhere are mocked. -Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `86a25a842` plus the reviewed runner changes: all **68 suites / 978 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-post-notifier-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. +Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `86a25a842` plus the reviewed runner changes: all **69 suites / 1096 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-post-fygaro-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. These are offline unit checks. They do not establish provider access, sandbox interoperability, production configuration, bank-account provisioning, image storage acceptance by DigitalOcean, or deployment readiness. diff --git a/scripts/native-parity-unit.cjs b/scripts/native-parity-unit.cjs index dcda8cbf4..8d1aeebe4 100644 --- a/scripts/native-parity-unit.cjs +++ b/scripts/native-parity-unit.cjs @@ -57,6 +57,7 @@ const suites = Object.freeze({ "services/frappe/ErpNext.idVerification.spec.ts", "services/frappe/ErpNext.spec.ts", "services/frappe/request-telemetry-privacy.spec.ts", + "services/fygaro/webhook-server/payment.spec.ts", "services/http-trace-privacy.spec.ts", "services/ibex/request-trace-privacy.spec.ts", "services/notifications/firebase-telemetry-privacy.spec.ts", diff --git a/src/services/fygaro/webhook-server/routes/payment.ts b/src/services/fygaro/webhook-server/routes/payment.ts index e5805ec9a..7493e8ca0 100644 --- a/src/services/fygaro/webhook-server/routes/payment.ts +++ b/src/services/fygaro/webhook-server/routes/payment.ts @@ -119,8 +119,11 @@ const resolveAccountByPayerEmail = async ( const account = await AccountsRepository().findByUserId(userId) if (account instanceof Error) return undefined return account - } catch (error) { - baseLogger.warn({ error, email: trimmed }, "Fygaro payer-email attribution failed") + } catch { + baseLogger.warn( + { operation: "fygaro_payer_email_attribution", outcome: "failed" }, + "Fygaro payer-email attribution failed", + ) return undefined } } diff --git a/test/flash/unit/services/fygaro/webhook-server/payment.spec.ts b/test/flash/unit/services/fygaro/webhook-server/payment.spec.ts index 4aa47a513..3ce284eb2 100644 --- a/test/flash/unit/services/fygaro/webhook-server/payment.spec.ts +++ b/test/flash/unit/services/fygaro/webhook-server/payment.spec.ts @@ -160,6 +160,7 @@ import { UnknownRepositoryError, } from "@domain/errors" import { ResourceAttemptsLockServiceError } from "@domain/lock" +import { baseLogger } from "@services/logger" import { paymentHandler } from "@services/fygaro/webhook-server/routes/payment" import { FygaroCreditError } from "@services/fygaro/webhook-server/credit-topup" @@ -639,7 +640,9 @@ describe("fygaro paymentHandler", () => { }) it("records unattributed when the kratos lookup throws (best-effort, never breaks the webhook)", async () => { - mockGetUserIdFromIdentifier.mockRejectedValue(new Error("kratos down")) + mockGetUserIdFromIdentifier.mockRejectedValue( + new Error("private-payer@example.com kratos down"), + ) const res = makeRes() await paymentHandler(makeReq({ ...VALID_BODY, customReference: "" }), res) @@ -648,6 +651,13 @@ describe("fygaro paymentHandler", () => { expect.objectContaining({ accountId: undefined }), ) expect(res.json).toHaveBeenCalledWith({ status: "recorded", attributed: false }) + expect(baseLogger.warn).toHaveBeenCalledWith( + { operation: "fygaro_payer_email_attribution", outcome: "failed" }, + "Fygaro payer-email attribution failed", + ) + expect(JSON.stringify(jest.mocked(baseLogger.warn).mock.calls)).not.toContain( + "private-payer@example.com", + ) }) it("skips the lookup entirely for a payload without a client email", async () => { From 0ab8c6240676cc0a57393ba80dbd54eee2ce66c9 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:21:44 -0700 Subject: [PATCH 23/42] fix: remove private auth and API key trace attributes --- docs/auth-api-key-telemetry-privacy.md | 13 + src/app/api-keys/create-api-key.ts | 6 +- src/app/authentication/login.ts | 10 +- src/app/authentication/request-code.ts | 2 +- .../unit/app/api-keys/create-api-key.spec.ts | 90 ++++ .../authentication/telemetry-privacy.spec.ts | 401 ++++++++++++++++++ 6 files changed, 510 insertions(+), 12 deletions(-) create mode 100644 docs/auth-api-key-telemetry-privacy.md create mode 100644 test/flash/unit/app/authentication/telemetry-privacy.spec.ts diff --git a/docs/auth-api-key-telemetry-privacy.md b/docs/auth-api-key-telemetry-privacy.md new file mode 100644 index 000000000..402fcd0a5 --- /dev/null +++ b/docs/auth-api-key-telemetry-privacy.md @@ -0,0 +1,13 @@ +# Authentication and API-key application telemetry privacy + +This leaf is based on integrated backend `86a25a84224eb151c8fa8a25943405b910fe8bb9`, after the preceding telemetry and notifier privacy fixes. It changes explicit telemetry in `login.ts`, `request-code.ts` and `create-api-key.ts`. No real provider calls, credentials, exported spans or deployment were used. + +Onboarding no longer JSON-serializes IP or carrier metadata into application spans. Existing validation-enabled flags remain, and successful metadata reads emit fixed boolean markers. The two explicit IP-fetch failure records now use a fresh fixed-message `IpFetcherServiceError` with the original critical severity and no attached IP. The original domain error is still returned to the caller; authentication and refusal decisions are unchanged. + +Email-code requests record only a fixed operation instead of the email address and IP. The original values still reach their exact IP/login-identifier limiter keys and the email-code provider. Limiter refusals still stop provider dispatch, and provider results/errors retain their original identity. Existing blocked-country controls, coalescing and deliberately separate operational notifications are untouched. + +API-key creation spans contain only started/success/error flags. They no longer inspect or record the caller's name, account identifier, scopes, requested expiry or generated public key ID. Name/scope/IP/rate validation, account limits, key generation, hash-only persistence and the one-time raw-key response are unchanged. The deliberate lifecycle audit remains intact: `auditApiKeyCreated` still receives the account, public key ID, validated scopes, expiry and rate limit. It receives neither the raw API key nor its hash. This distinguishes the existing security audit contract from general application tracing; the audit retention policy is outside this change. + +Validation: **136 tests across seven reviewed suites** pass under Node 24 with the integration runner's fake `safeEnvironment`, network guard and Watchman disabled. New regressions cover successful and refused phone/device onboarding, exact OTP/carrier/IP/device inputs, same-owner bootstrap order, fixed private-error diagnostics, disabled carrier-validation fallback, existing-user login, limiter ordering, exact email dispatch and API-key persistence/audit behavior. Rejected scope arrays have a throwing `join` method to prove they are not serialized for telemetry before validation. Existing destination-country, collision, key-domain and lifecycle-audit tests remain green. All three TypeScript configurations, scoped ESLint and `git diff --check` pass. + +This is a scoped application-boundary fix. The source audit also found independent adapter sinks in `services/ipfetcher/index.ts` (raw Axios error and attached IP) and `services/twilio.ts` (carrier SDK result and OTP-validation error). Those files are unchanged here and need their own provider-contract tests. The IP adapter's `UnknownIpFetcherServiceError` is also a sibling of `IpFetcherServiceError`, while login currently checks the latter with `instanceof`; this leaf preserves that existing authentication behavior. It does not claim all adapter telemetry, operational alerts, domain error propagation, historical records or collector retention are sanitized. diff --git a/src/app/api-keys/create-api-key.ts b/src/app/api-keys/create-api-key.ts index d502156cd..e3f746da0 100644 --- a/src/app/api-keys/create-api-key.ts +++ b/src/app/api-keys/create-api-key.ts @@ -22,10 +22,7 @@ export const createApiKey = async ({ expiresIn = null, }: CreateApiKeyArgs): Promise => { addAttributesToCurrentSpan({ - "app.apiKeys.create.accountId": accountId, - "app.apiKeys.create.name": name, - "app.apiKeys.create.scopes": scopes.join(","), - "app.apiKeys.create.expiresIn": expiresIn || undefined, + "app.apiKeys.create.started": true, }) const checkedName = checkedToApiKeyName(name) @@ -92,7 +89,6 @@ export const createApiKey = async ({ addAttributesToCurrentSpan({ "app.apiKeys.create.success": true, - "app.apiKeys.create.keyId": apiKey.keyId, }) auditApiKeyCreated({ diff --git a/src/app/authentication/login.ts b/src/app/authentication/login.ts index aaa38d89f..205516de3 100644 --- a/src/app/authentication/login.ts +++ b/src/app/authentication/login.ts @@ -412,9 +412,8 @@ export const loginWithDevice = async ({ if (ipFetcherInfo instanceof IpFetcherServiceError) { recordExceptionInCurrentSpan({ - error: ipFetcherInfo, + error: new IpFetcherServiceError("IP metadata lookup failed"), level: ErrorLevel.Critical, - attributes: { ip }, }) return ipFetcherInfo } @@ -493,15 +492,14 @@ const isAllowedToOnboard = async ({ if (ipFetcherInfo instanceof IpFetcherServiceError) { recordExceptionInCurrentSpan({ - error: ipFetcherInfo, + error: new IpFetcherServiceError("IP metadata lookup failed"), level: ErrorLevel.Critical, - attributes: { ip }, }) return ipFetcherInfo } addAttributesToCurrentSpan({ - "login.ipFetcherInfo": JSON.stringify(ipFetcherInfo), + "login.ipMetadataFetched": true, }) const authorizedIPMetadata = IPMetadataAuthorizer( @@ -533,7 +531,7 @@ const isAllowedToOnboard = async ({ ).authorize(phoneMetadata) addAttributesToCurrentSpan({ - "login.phoneMetadata": JSON.stringify(phoneMetadata), + "login.phoneMetadataFetched": true, }) if (authorizedPhoneMetadata instanceof Error) { diff --git a/src/app/authentication/request-code.ts b/src/app/authentication/request-code.ts index 258fec1be..689fdd122 100644 --- a/src/app/authentication/request-code.ts +++ b/src/app/authentication/request-code.ts @@ -154,7 +154,7 @@ export const requestEmailCode = async ({ email: EmailAddress ip: IpAddress }): Promise => { - baseLogger.info({ email, ip }, "RequestEmailCode called") + baseLogger.info({ operation: "request_email_code" }, "RequestEmailCode called") { const limitOk = await checkRequestCodeAttemptPerIpLimits(ip) diff --git a/test/flash/unit/app/api-keys/create-api-key.spec.ts b/test/flash/unit/app/api-keys/create-api-key.spec.ts index f005b8f68..5a9dafad5 100644 --- a/test/flash/unit/app/api-keys/create-api-key.spec.ts +++ b/test/flash/unit/app/api-keys/create-api-key.spec.ts @@ -11,11 +11,15 @@ import { } from "@domain/api-keys" import { UnknownRepositoryError } from "@domain/errors" import { ApiKeysRepository } from "@services/mongoose/api-keys" +import { auditApiKeyCreated } from "@services/api-keys-audit" +import { addAttributesToCurrentSpan } from "@services/tracing" jest.mock("@config", () => ({ getApiKeyConfig: jest.fn(() => ({ maxKeysPerAccount: 3 })), })) +jest.mock("@services/api-keys-audit", () => ({ auditApiKeyCreated: jest.fn() })) + jest.mock("@services/tracing", () => ({ addAttributesToCurrentSpan: jest.fn(), })) @@ -41,6 +45,7 @@ describe("createApiKey", () => { let findByAccountId: jest.Mock beforeEach(() => { + jest.clearAllMocks() create = jest.fn().mockImplementation(async (newKey: NewApiKey) => ({ id: toApiKeyId("record-id"), keyId: newKey.keyId, @@ -204,4 +209,89 @@ describe("createApiKey", () => { expect(result).toBeInstanceOf(UnknownRepositoryError) expect(create).not.toHaveBeenCalled() }) + + it("keeps caller metadata and generated credentials out of spans while preserving the explicit creation audit", async () => { + const owner = "PRIVATE_ACCOUNT_OWNER" as AccountId + const result = await createApiKey({ + accountId: owner, + name: "PRIVATE CUSTOMER NAME", + scopes: ["read:wallet", "write:wallet"], + ipConstraints: ["203.0.113.92"], + metadata: { note: "PRIVATE_BANK_DATA" }, + rateLimitPerMinute: 75, + expiresIn: 3600, + }) + if (result instanceof Error) throw result + const persisted = create.mock.calls[0][0] + expect(persisted).toMatchObject({ + accountId: owner, + name: "PRIVATE CUSTOMER NAME", + scopes: ["read:wallet", "write:wallet"], + ipConstraints: ["203.0.113.92"], + metadata: { note: "PRIVATE_BANK_DATA" }, + rateLimitPerMinute: 75, + }) + const spans = JSON.stringify(jest.mocked(addAttributesToCurrentSpan).mock.calls) + for (const privateValue of [ + owner, + result.name, + result.keyId, + result.apiKey, + persisted.hashedKey, + "read:wallet", + "write:wallet", + "203.0.113.92", + "PRIVATE_BANK_DATA", + ]) + expect(spans).not.toContain(privateValue) + expect(jest.mocked(addAttributesToCurrentSpan).mock.calls).toEqual([ + [{ "app.apiKeys.create.started": true }], + [{ "app.apiKeys.create.success": true }], + ]) + expect(auditApiKeyCreated).toHaveBeenCalledTimes(1) + expect(auditApiKeyCreated).toHaveBeenCalledWith({ + accountId: owner, + keyId: result.keyId, + scopes: result.scopes, + expiresAt: result.expiresAt, + rateLimitPerMinute: 75, + }) + expect(JSON.stringify(jest.mocked(auditApiKeyCreated).mock.calls)).not.toContain( + result.apiKey, + ) + expect(JSON.stringify(jest.mocked(auditApiKeyCreated).mock.calls)).not.toContain( + persisted.hashedKey, + ) + }) + + it("does not serialize rejected caller-controlled scopes before validation", async () => { + const invalidScopes = ["PRIVATE_SCOPE_WITH_OTP_482916" as ApiKeyScope] + Object.defineProperty(invalidScopes, "join", { + value: () => { + throw new Error("Scope serialization must not run") + }, + }) + const result = await createApiKey({ + accountId, + name: "valid name", + scopes: invalidScopes, + }) + expect(result).toBeInstanceOf(InvalidApiKeyScopeError) + expect(create).not.toHaveBeenCalled() + expect(auditApiKeyCreated).not.toHaveBeenCalled() + expect( + JSON.stringify(jest.mocked(addAttributesToCurrentSpan).mock.calls), + ).not.toContain(invalidScopes[0]) + }) + + it("returns the original persistence error without copying private diagnostics to spans or auditing creation", async () => { + const error = new UnknownRepositoryError("PRIVATE_PROVIDER_BANK_BODY") + create.mockResolvedValue(error) + expect(await createApiKey({ accountId, name: "private failed create" })).toBe(error) + expect(jest.mocked(addAttributesToCurrentSpan).mock.calls).toEqual([ + [{ "app.apiKeys.create.started": true }], + [{ "app.apiKeys.create.error": true }], + ]) + expect(auditApiKeyCreated).not.toHaveBeenCalled() + }) }) diff --git a/test/flash/unit/app/authentication/telemetry-privacy.spec.ts b/test/flash/unit/app/authentication/telemetry-privacy.spec.ts new file mode 100644 index 000000000..d12774505 --- /dev/null +++ b/test/flash/unit/app/authentication/telemetry-privacy.spec.ts @@ -0,0 +1,401 @@ +jest.mock("@config", () => ({ + ...jest.requireActual("@config"), + getAccountsOnboardConfig: jest.fn(), +})) +jest.mock("@app/accounts", () => ({ upgradeAccountFromDeviceToPhone: jest.fn() })) +jest.mock("@app/accounts/device-account-bootstrap", () => ({ + resumeDeviceAccountBootstrap: jest.fn(), +})) +jest.mock("@app/wallets", () => ({ getBalanceForWallet: jest.fn() })) +jest.mock("@services/kratos", () => ({ + AuthWithPhonePasswordlessService: jest.fn(), + AuthWithEmailPasswordlessService: jest.fn(), + AuthWithUsernamePasswordDeviceIdService: jest.fn(), + IdentityRepository: jest.fn(), +})) +jest.mock("@services/lock", () => ({ + redlock: jest.fn( + async ({ + asyncFn, + }: { + asyncFn: (signal: { aborted: boolean }) => Promise + }) => asyncFn({ aborted: false }), + ), +})) +jest.mock("@services/twilio", () => ({ + isPhoneCodeValid: jest.fn(), + TwilioClient: jest.fn(), +})) +jest.mock("@services/ipfetcher", () => ({ IpFetcher: jest.fn() })) +jest.mock("@services/mongoose", () => ({ WalletsRepository: jest.fn() })) +jest.mock("@services/alerts/ops-events", () => ({ + notifyOpsEvent: jest.fn(), + opsEventsSettled: jest.fn(), +})) +jest.mock("@services/notifications/firebase", () => ({ + __esModule: true, + default: { isDeviceTokenValid: jest.fn() }, +})) +jest.mock("@services/geetest", () => ({ __esModule: true, default: jest.fn() })) +jest.mock("@services/rate-limit", () => ({ + RedisRateLimitService: jest.fn(), + consumeLimiter: jest.fn(), +})) +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) +jest.mock("@app/authentication/ratelimits", () => ({ + checkFailedLoginAttemptPerIpLimits: jest.fn(), + checkFailedLoginAttemptPerLoginIdentifierLimits: jest.fn(), + rewardFailedLoginAttemptPerIpLimits: jest.fn(), + rewardFailedLoginAttemptPerLoginIdentifierLimits: jest.fn(), +})) + +import { getAccountsOnboardConfig } from "@config" +import { loginWithDevice, loginWithPhoneToken } from "@app/authentication/login" +import { requestEmailCode } from "@app/authentication/request-code" +import { + checkFailedLoginAttemptPerIpLimits, + checkFailedLoginAttemptPerLoginIdentifierLimits, + rewardFailedLoginAttemptPerIpLimits, + rewardFailedLoginAttemptPerLoginIdentifierLimits, +} from "@app/authentication/ratelimits" +import { resumeDeviceAccountBootstrap } from "@app/accounts/device-account-bootstrap" +import { IdentifierNotFoundError } from "@domain/authentication/errors" +import { + UnauthorizedIPForOnboardingError, + InvalidIpMetadataError, + CouldNotFindUserFromPhoneError, +} from "@domain/errors" +import { IpFetcherServiceError } from "@domain/ipfetcher" +import { PhoneCodeInvalidError } from "@domain/phone-provider" +import { RateLimitConfig } from "@domain/rate-limit" +import { RateLimiterExceededError } from "@domain/rate-limit/errors" +import { + InvalidPhoneForOnboardingError, + InvalidPhoneMetadataForOnboardingError, +} from "@domain/users/errors" +import { IpFetcher } from "@services/ipfetcher" +import { + AuthWithEmailPasswordlessService, + AuthWithPhonePasswordlessService, + AuthWithUsernamePasswordDeviceIdService, + IdentityRepository, +} from "@services/kratos" +import { baseLogger } from "@services/logger" +import { consumeLimiter } from "@services/rate-limit" +import { + addAttributesToCurrentSpan, + recordExceptionInCurrentSpan, +} from "@services/tracing" +import { isPhoneCodeValid, TwilioClient } from "@services/twilio" + +const privateValues = { + ip: "203.0.113.81" as IpAddress, + phone: "+18765550123" as PhoneNumber, + email: "private.customer@example.test" as EmailAddress, + code: "824691" as PhoneCode, + city: "PRIVATE_CUSTOMER_CITY", + provider: "PRIVATE_CUSTOMER_PROVIDER", + carrier: "PRIVATE_CUSTOMER_CARRIER", + token: "PRIVATE_AUTH_TOKEN", + device: "11111111-1111-4111-8111-111111111111", + password: "22222222-2222-4222-8222-222222222222", +} +const ipInfo = { + provider: privateValues.provider, + country: "PRIVATE_CUSTOMER_COUNTRY", + isoCode: "JM", + region: "PRIVATE_CUSTOMER_REGION", + city: privateValues.city, + type: "residential", + asn: "AS1234", + proxy: false, +} +const phoneInfo = { + countryCode: "JM", + carrier: { + error_code: "", + mobile_country_code: "338", + mobile_network_code: "050", + name: privateValues.carrier, + type: "mobile", + }, +} +const settings = (): AccountsOnboardConfig => ({ + ipMetadataValidationSettings: { + enabled: true, + denyCountries: [], + allowCountries: [], + denyASNs: [], + allowASNs: [], + checkProxy: true, + }, + phoneMetadataValidationSettings: { + enabled: true, + denyCountries: [], + allowCountries: [], + }, +}) +const mockIP = jest.fn(), + mockCarrier = jest.fn(), + mockCreate = jest.fn(), + mockLogin = jest.fn(), + mockIdentifier = jest.fn(), + mockDevice = jest.fn(), + mockEmail = jest.fn() +const requestPhone = () => + loginWithPhoneToken({ + phone: privateValues.phone, + code: privateValues.code, + ip: privateValues.ip, + }) +const requestDevice = () => + loginWithDevice({ + username: privateValues.device, + password: privateValues.password, + deviceId: privateValues.device, + ip: privateValues.ip, + }) +const telemetry = () => + JSON.stringify({ + spans: jest.mocked(addAttributesToCurrentSpan).mock.calls, + exceptions: jest + .mocked(recordExceptionInCurrentSpan) + .mock.calls.map(([{ error, ...args }]) => { + if (!(error instanceof Error)) throw new Error("Expected a safe Error diagnostic") + return { + ...args, + error: { ...error, message: error.message, stack: error.stack }, + } + }), + logs: [baseLogger.info, baseLogger.warn, baseLogger.error].flatMap( + (log) => jest.mocked(log).mock.calls, + ), + }) +const assertPrivateTelemetryAbsent = () => { + for (const value of [ + ...Object.values(privateValues), + ...Object.values(ipInfo).filter((value) => typeof value === "string"), + ]) + expect(telemetry()).not.toContain(value) +} + +beforeEach(() => { + jest.clearAllMocks() + jest.mocked(getAccountsOnboardConfig).mockReturnValue(settings()) + jest.mocked(IpFetcher).mockReturnValue({ fetchIPInfo: mockIP }) + jest + .mocked(TwilioClient) + .mockReturnValue({ getCarrier: mockCarrier } as unknown as ReturnType< + typeof TwilioClient + >) + jest.mocked(AuthWithPhonePasswordlessService).mockReturnValue({ + createIdentityWithSession: mockCreate, + loginToken: mockLogin, + } as unknown as ReturnType) + jest + .mocked(AuthWithUsernamePasswordDeviceIdService) + .mockReturnValue({ createIdentityWithSession: mockDevice } as unknown as ReturnType< + typeof AuthWithUsernamePasswordDeviceIdService + >) + jest + .mocked(AuthWithEmailPasswordlessService) + .mockReturnValue({ sendEmailWithCode: mockEmail } as unknown as ReturnType< + typeof AuthWithEmailPasswordlessService + >) + jest + .mocked(IdentityRepository) + .mockReturnValue({ getUserIdFromIdentifier: mockIdentifier } as unknown as ReturnType< + typeof IdentityRepository + >) + jest.mocked(isPhoneCodeValid).mockResolvedValue(true) + jest.mocked(checkFailedLoginAttemptPerIpLimits).mockResolvedValue(true) + jest.mocked(checkFailedLoginAttemptPerLoginIdentifierLimits).mockResolvedValue(true) + jest.mocked(rewardFailedLoginAttemptPerIpLimits).mockResolvedValue(true) + jest.mocked(rewardFailedLoginAttemptPerLoginIdentifierLimits).mockResolvedValue(true) + jest.mocked(resumeDeviceAccountBootstrap).mockResolvedValue(true) + jest.mocked(consumeLimiter).mockResolvedValue(true) + mockIP.mockResolvedValue(ipInfo) + mockCarrier.mockResolvedValue(phoneInfo) + mockIdentifier.mockResolvedValue(new IdentifierNotFoundError()) + mockCreate.mockResolvedValue({ + authToken: privateValues.token, + kratosUserId: "private-owner", + }) + mockLogin.mockResolvedValue({ + authToken: privateValues.token, + kratosUserId: "private-owner", + }) + mockDevice.mockResolvedValue({ + authToken: privateValues.token, + kratosUserId: "private-owner", + newEntity: false, + }) + mockEmail.mockResolvedValue("PRIVATE_EMAIL_FLOW_ID") +}) + +describe("explicit onboarding telemetry boundary", () => { + it("keeps exact phone, OTP, IP and carrier inputs for real authorization without copying metadata to spans", async () => { + expect(await requestPhone()).toEqual({ + authToken: privateValues.token, + totpRequired: false, + }) + expect(isPhoneCodeValid).toHaveBeenCalledWith({ + phone: privateValues.phone, + code: privateValues.code, + }) + expect(mockIP).toHaveBeenCalledWith(privateValues.ip) + expect(mockCarrier).toHaveBeenCalledWith(privateValues.phone) + expect(mockCreate).toHaveBeenCalledWith({ + phone: privateValues.phone, + phoneMetadata: phoneInfo, + }) + expect(telemetry()).toContain("login.ipMetadataFetched") + expect(telemetry()).toContain("login.phoneMetadataFetched") + assertPrivateTelemetryAbsent() + }) + + it.each(["phone", "device"])( + "returns the original %s IP-provider error while recording only a fixed diagnostic", + async (kind) => { + const error = Object.assign( + new IpFetcherServiceError(Object.values(privateValues).join(" ")), + { request: privateValues }, + ) + mockIP.mockResolvedValue(error) + expect(await (kind === "phone" ? requestPhone() : requestDevice())).toBe(error) + expect(recordExceptionInCurrentSpan).toHaveBeenCalledTimes(1) + expect(mockCreate).not.toHaveBeenCalled() + expect(mockDevice).not.toHaveBeenCalled() + assertPrivateTelemetryAbsent() + }, + ) + + it("retains exact owned device credentials and post-auth readiness ordering", async () => { + expect(await requestDevice()).toBe(privateValues.token) + expect(mockDevice).toHaveBeenCalledWith({ + username: privateValues.device, + password: privateValues.password, + }) + expect(resumeDeviceAccountBootstrap).toHaveBeenCalledWith({ + userId: "private-owner", + deviceId: privateValues.device, + isCancelled: expect.any(Function), + }) + expect(mockDevice.mock.invocationCallOrder[0]).toBeLessThan( + jest.mocked(resumeDeviceAccountBootstrap).mock.invocationCallOrder[0], + ) + assertPrivateTelemetryAbsent() + }) + + it.each(["phone", "device"])( + "keeps %s onboarding denied for proxy metadata", + async (kind) => { + mockIP.mockResolvedValue({ ...ipInfo, proxy: true }) + expect(await (kind === "phone" ? requestPhone() : requestDevice())).toBeInstanceOf( + UnauthorizedIPForOnboardingError, + ) + expect(mockCreate).not.toHaveBeenCalled() + expect(mockDevice).not.toHaveBeenCalled() + assertPrivateTelemetryAbsent() + }, + ) + + it("keeps missing-IP-metadata refusal distinct", async () => { + mockIP.mockResolvedValue({ ...ipInfo, asn: "" }) + expect(await requestPhone()).toBeInstanceOf(InvalidIpMetadataError) + expect(mockCreate).not.toHaveBeenCalled() + assertPrivateTelemetryAbsent() + }) + + it("still refuses forbidden phone carrier metadata without creating an identity", async () => { + mockCarrier.mockResolvedValue({ + ...phoneInfo, + carrier: { ...phoneInfo.carrier, type: "voip" }, + }) + expect(await requestPhone()).toBeInstanceOf(InvalidPhoneForOnboardingError) + expect(mockCreate).not.toHaveBeenCalled() + assertPrivateTelemetryAbsent() + }) + + it.each([true, false])( + "retains carrier lookup failure policy when validation enabled=%s", + async (enabled) => { + const config = settings() + config.phoneMetadataValidationSettings.enabled = enabled + jest.mocked(getAccountsOnboardConfig).mockReturnValue(config) + mockCarrier.mockResolvedValue( + new CouldNotFindUserFromPhoneError("PRIVATE_CUSTOMER_CARRIER"), + ) + const result = await requestPhone() + const expectedError = expect.any(InvalidPhoneMetadataForOnboardingError) + expect(result).toEqual( + enabled ? expectedError : { authToken: privateValues.token, totpRequired: false }, + ) + expect(mockCreate.mock.calls).toEqual( + enabled ? [] : [[{ phone: privateValues.phone, phoneMetadata: undefined }]], + ) + assertPrivateTelemetryAbsent() + }, + ) + + it("does not consult onboarding providers after OTP rejection or for an existing phone identity", async () => { + const otpError = new PhoneCodeInvalidError() + jest.mocked(isPhoneCodeValid).mockResolvedValueOnce(otpError) + expect(await requestPhone()).toBe(otpError) + expect(mockIP).not.toHaveBeenCalled() + mockIdentifier.mockResolvedValue("existing-owner") + expect(await requestPhone()).toEqual({ + authToken: privateValues.token, + totpRequired: false, + }) + expect(mockLogin).toHaveBeenCalledWith({ phone: privateValues.phone }) + expect(mockIP).not.toHaveBeenCalled() + expect(mockCarrier).not.toHaveBeenCalled() + assertPrivateTelemetryAbsent() + }) +}) + +describe("email code request telemetry boundary", () => { + const request = () => + requestEmailCode({ email: privateValues.email, ip: privateValues.ip }) + it("retains exact IP/email limiter keys and provider input without logging them", async () => { + expect(await request()).toBe("PRIVATE_EMAIL_FLOW_ID") + expect(consumeLimiter).toHaveBeenNthCalledWith(1, { + rateLimitConfig: RateLimitConfig.requestCodeAttemptPerIp, + keyToConsume: privateValues.ip, + }) + expect(consumeLimiter).toHaveBeenNthCalledWith(2, { + rateLimitConfig: RateLimitConfig.requestCodeAttemptPerLoginIdentifier, + keyToConsume: privateValues.email, + }) + expect(mockEmail).toHaveBeenCalledWith({ email: privateValues.email }) + assertPrivateTelemetryAbsent() + }) + it.each([1, 2])( + "keeps limiter %s refusal ahead of provider dispatch", + async (refused) => { + const error = new RateLimiterExceededError("PRIVATE_LIMITER_DIAGNOSTIC") + jest + .mocked(consumeLimiter) + .mockImplementation(async () => + jest.mocked(consumeLimiter).mock.calls.length === refused ? error : true, + ) + expect(await request()).toBe(error) + expect(mockEmail).not.toHaveBeenCalled() + expect(consumeLimiter).toHaveBeenCalledTimes(refused) + assertPrivateTelemetryAbsent() + }, + ) + it("returns the exact email-provider refusal without serializing it", async () => { + const error = new Error("PRIVATE_EMAIL_PROVIDER_ERROR") + mockEmail.mockResolvedValue(error) + expect(await request()).toBe(error) + assertPrivateTelemetryAbsent() + }) +}) From 8fc4a439ce686a3659b985b42b8846dd07720422 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:23:03 -0700 Subject: [PATCH 24/42] Select auth privacy regressions in guarded runner --- scripts/native-parity-unit.cjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/native-parity-unit.cjs b/scripts/native-parity-unit.cjs index 8d1aeebe4..661df7005 100644 --- a/scripts/native-parity-unit.cjs +++ b/scripts/native-parity-unit.cjs @@ -48,7 +48,9 @@ const suites = Object.freeze({ ], telemetry: [ "app/admin/send-user-notification.spec.ts", + "app/api-keys/create-api-key.spec.ts", "app/authentication/registration-trace-privacy.spec.ts", + "app/authentication/telemetry-privacy.spec.ts", "app/authentication/validate-preregistration-payload.spec.ts", "app/invite/send-referral-notifications.spec.ts", "servers/request-log.spec.ts", From def2da6d26d8244e0e368de51d82460222f50cb1 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:22:44 -0700 Subject: [PATCH 25/42] Keep custom IBEX failure bodies and paths out of telemetry --- docs/ibex-custom-fetch-privacy.md | 26 +++++++++ src/services/ibex/client.ts | 29 +++++++--- .../ibex/request-trace-privacy.spec.ts | 55 +++++++++++++++++++ 3 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 docs/ibex-custom-fetch-privacy.md diff --git a/docs/ibex-custom-fetch-privacy.md b/docs/ibex-custom-fetch-privacy.md new file mode 100644 index 000000000..483014522 --- /dev/null +++ b/docs/ibex-custom-fetch-privacy.md @@ -0,0 +1,26 @@ +# Private IBEX custom-fetch failures + +The custom IBEX crypto/account fetch path previously logged the full URL and +provider response body, then placed both the path and body in an `IbexError`. +Paths can contain account IDs; provider bodies can echo credentials and payment +details. The adjacent OAuth-token request also put a failed response body in +its returned error. + +Failed HTTP responses now produce fixed error text with the numeric HTTP status +only. The crypto/account path logs a fixed operation and outcome plus that +status. Neither failure path reads the response body. Success requests and +response parsing, authentication headers, provider payloads, return types and +HTTP failure classification remain unchanged. + +Mocked regressions cover an account ID in the path, a destination handle, a +private provider response, and a failed OAuth-token response. They assert that +logs, returned errors and spans contain none of those values, while the numeric +status remains available. The guarded telemetry selection passes 17 suites and +332 tests; the whole-tree TypeScript checks, scoped ESLint and diff check pass. +No live IBEX request or financial mutation was made. + +This is a privacy fix, not payment-outcome reconciliation. In the existing +Bridge withdrawal flow, a crypto-send failure response is still marked as a +failed send; the provider could have debited funds before an ambiguous reply. +That money-state boundary requires separate provider and reconciliation work +before relying on it as a definitive retry signal. diff --git a/src/services/ibex/client.ts b/src/services/ibex/client.ts index 4efd2c3da..9d2f6ade5 100644 --- a/src/services/ibex/client.ts +++ b/src/services/ibex/client.ts @@ -337,6 +337,16 @@ const payToLnurl = async ( }).then(errorHandler) } +const discardFailedResponseBody = async (response: Response): Promise => { + try { + // Do not retain the provider's untrusted body, but release its stream so + // repeated failures do not pin fetch connections waiting for consumption. + await response.body?.cancel() + } catch { + // Discarding diagnostics must not replace the original HTTP failure. + } +} + const getIbexToken = async (): Promise => { const cached = await Ibex.authentication.storage.getAccessToken() if (typeof cached === "string") return `${cached}` @@ -358,10 +368,10 @@ const getIbexToken = async (): Promise => { if (resp instanceof IbexError) return resp if (!resp.ok) { - const responseBody = await resp.text().catch(() => "") - return new IbexError( - new Error(`IBEX token request failed: ${resp.status} — ${responseBody}`), - ) + await discardFailedResponseBody(resp) + // Provider responses can echo OAuth inputs. Keep only the HTTP status in + // the error that may be logged, traced or returned by an upstream caller. + return new IbexError(new Error(`IBEX token request failed: ${resp.status}`)) } const data = (await resp.json()) as { @@ -391,9 +401,14 @@ const ibexFetch = async ( }, }) if (!resp.ok) { - const body = await resp.text().catch(() => "") - baseLogger.error({ url, status: resp.status, body }, "IBEX request failed") - return new IbexError(new Error(`IBEX ${path} failed: ${resp.status} — ${body}`)) + await discardFailedResponseBody(resp) + // Paths may hold account IDs, and provider bodies can echo credentials or + // payment details. Neither belongs in telemetry or a returned error. + baseLogger.error( + { operation: "ibex_api_request", outcome: "failed", httpStatus: resp.status }, + "IBEX request failed", + ) + return new IbexError(new Error(`IBEX request failed: ${resp.status}`)) } return resp.json() as Promise } diff --git a/test/flash/unit/services/ibex/request-trace-privacy.spec.ts b/test/flash/unit/services/ibex/request-trace-privacy.spec.ts index a3896676f..90e9e94c7 100644 --- a/test/flash/unit/services/ibex/request-trace-privacy.spec.ts +++ b/test/flash/unit/services/ibex/request-trace-privacy.spec.ts @@ -43,6 +43,8 @@ jest.mock("ibex-client", () => { }) import Ibex from "@services/ibex/client" +import { IbexError } from "@services/ibex/errors" +import { baseLogger } from "@services/logger" import { USDAmount, USDTAmount } from "@domain/shared" import { addAttributesToCurrentSpan, @@ -215,3 +217,56 @@ it("preserves crypto destination name/address metadata for IBEX without recordin }) expectNoInputTelemetry() }) + +it("keeps failed crypto-send path and provider response out of logs and returned errors", async () => { + const privateAccount = "fixture-private-account-in-path" + const privateBody = "fixture-private-provider-response-with-credentials" + const privateDestination = "fixture-private-destination-handle" + const failedResponse = new Response(JSON.stringify({ error: privateBody }), { + status: 422, + }) + const readBody = jest.spyOn(failedResponse, "text") + fetch.mockResolvedValue(failedResponse) + + const result = await Ibex.sendCrypto({ + accountId: privateAccount, + cryptoSendInfosId: privateDestination, + amount: 1.25, + }) + + expect(result).toBeInstanceOf(IbexError) + expect((result as IbexError).message).toBe("IBEX request failed: 422") + expect(readBody).not.toHaveBeenCalled() + expect(baseLogger.error).toHaveBeenCalledWith( + { operation: "ibex_api_request", outcome: "failed", httpStatus: 422 }, + "IBEX request failed", + ) + const diagnostics = JSON.stringify({ + error: result, + logCalls: jest.mocked(baseLogger.error).mock.calls, + }) + for (const privateValue of [privateAccount, privateBody, privateDestination]) { + expect(diagnostics).not.toContain(privateValue) + } + expectNoInputTelemetry() +}) + +it("keeps a failed token request's response body out of returned errors", async () => { + const privateBody = "fixture-private-oauth-provider-response-with-secret" + sdk.authentication.storage.getAccessToken.mockResolvedValue(null) + const failedResponse = new Response(privateBody, { status: 401 }) + const readBody = jest.spyOn(failedResponse, "text") + fetch.mockResolvedValue(failedResponse) + + const result = await Ibex.getCryptoSendRequirements({ + network: "ethereum", + currencyId: 29 as IbexCurrencyId, + }) + + expect(result).toBeInstanceOf(IbexError) + expect((result as IbexError).message).toBe("IBEX token request failed: 401") + expect(readBody).not.toHaveBeenCalled() + expect(JSON.stringify(result)).not.toContain(privateBody) + expect(baseLogger.error).not.toHaveBeenCalled() + expectNoInputTelemetry() +}) From 356a57e774c49928ec73018606103e2a4b464bac Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:23:23 -0700 Subject: [PATCH 26/42] Route fixed cash Lightning sends to owned settlement wallet --- .../root/mutation/ln-invoice-payment-send.ts | 67 +++++- .../graphql/ln-invoice-payment-send.spec.ts | 191 +++++++++++++++++- .../graphql/ln-send-marker-withdrawal.spec.ts | 10 + 3 files changed, 257 insertions(+), 11 deletions(-) diff --git a/src/graphql/public/root/mutation/ln-invoice-payment-send.ts b/src/graphql/public/root/mutation/ln-invoice-payment-send.ts index c6b36a044..8dc606793 100644 --- a/src/graphql/public/root/mutation/ln-invoice-payment-send.ts +++ b/src/graphql/public/root/mutation/ln-invoice-payment-send.ts @@ -9,9 +9,13 @@ import dedent from "dedent" // FLASH FORK: import ibex dependencies import { PaymentSendStatus, decodeInvoice } from "@domain/bitcoin/lightning" +import { InvalidWalletForAccountError } from "@domain/accounts" import { LnPaymentRequestNonZeroAmountRequiredError } from "@domain/payments/errors" +import { WalletCurrency } from "@domain/shared" +import { resolveCashWalletMutationWalletIdForAccount } from "@app/cash-wallet-cutover" import Ibex from "@services/ibex/client" import { IbexError, InsufficientIbexBalance } from "@services/ibex/errors" +import { WalletsRepository } from "@services/mongoose" import { paymentSendStatusOrPending } from "@services/ibex/payment-status" import { withPaymentIdempotency } from "@app/payments/idempotency" import { payMarkedBolt11 } from "@app/payments/mark-outgoing-invoice" @@ -68,7 +72,7 @@ const LnInvoicePaymentSendMutation = GT.Field< args: { input: { type: GT.NonNull(LnInvoicePaymentInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, cashWalletClientCapabilities }) => { const { walletId, paymentRequest, memo, idempotencyKey } = args.input if (walletId instanceof InputValidationError) { return { errors: [{ message: walletId.message }] } @@ -96,7 +100,56 @@ const LnInvoicePaymentSendMutation = GT.Field< if (!domainAccount) throw new Error("Authentication required") - // ENG-530: dedupe on (senderWalletId, idempotencyKey) when a key is supplied. + // The wallet middleware checks the supplied ID in production. Repeat the + // ownership check here because this is the last boundary before IBEX and + // because the cash alias can resolve to a different settlement wallet. + const suppliedWallet = await WalletsRepository().findById(walletId) + if (suppliedWallet instanceof Error) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(suppliedWallet)], + } + } + if (suppliedWallet.accountId !== domainAccount.id) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(new InvalidWalletForAccountError())], + } + } + + let senderWalletId = walletId + if ( + suppliedWallet.currency === WalletCurrency.Usd || + suppliedWallet.currency === WalletCurrency.Usdt + ) { + const routed = await resolveCashWalletMutationWalletIdForAccount({ + account: domainAccount, + walletId, + client: cashWalletClientCapabilities, + }) + if (routed instanceof Error) { + return { status: "failed", errors: [mapAndParseErrorForGqlResponse(routed)] } + } + senderWalletId = routed + if (senderWalletId !== walletId) { + const settlementWallet = await WalletsRepository().findById(senderWalletId) + if (settlementWallet instanceof Error) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(settlementWallet)], + } + } + if (settlementWallet.accountId !== domainAccount.id) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(new InvalidWalletForAccountError())], + } + } + } + } + + // ENG-530: dedupe on the wallet IBEX will actually debit. A presented USD + // alias and its USDT settlement wallet must share one key scope. // This resolver pays IBEX directly (the app-layer path is stubbed above), so the // idempotency wrapper goes around the inline call here rather than in @app. // @@ -105,7 +158,7 @@ const LnInvoicePaymentSendMutation = GT.Field< // budget or being re-judged against a moved mid price. const status = await withPaymentIdempotency({ idempotencyKey, - senderWalletId: walletId, + senderWalletId, requestFingerprint: `ln|${paymentRequest}`, authorize: async () => { // The bolt11 decode is PART of the guard, not a precondition of it: the @@ -132,7 +185,7 @@ const LnInvoicePaymentSendMutation = GT.Field< error, reason: SendRejectionReasons.undecodableInvoice, senderAccount: domainAccount, - senderWalletId: walletId, + senderWalletId, kind: "lightning", }) @@ -144,7 +197,7 @@ const LnInvoicePaymentSendMutation = GT.Field< return authorizeSend({ senderAccount: domainAccount, - senderWalletId: walletId, + senderWalletId, amount: { currency: "BTC", sats: decodedInvoice.paymentAmount.amount }, kind: "lightning", }) @@ -155,11 +208,11 @@ const LnInvoicePaymentSendMutation = GT.Field< // withdrawn when IBEX rejects the payment outright. return payMarkedBolt11({ paymentRequest, - senderWalletId: walletId, + senderWalletId, pay: async () => { const PayLightningInvoice = await Ibex.payInvoice({ invoice: paymentRequest as Bolt11, - accountId: walletId, + accountId: senderWalletId, }) if (PayLightningInvoice instanceof IbexError) { diff --git a/test/flash/unit/graphql/ln-invoice-payment-send.spec.ts b/test/flash/unit/graphql/ln-invoice-payment-send.spec.ts index 80db48972..bedc899aa 100644 --- a/test/flash/unit/graphql/ln-invoice-payment-send.spec.ts +++ b/test/flash/unit/graphql/ln-invoice-payment-send.spec.ts @@ -46,9 +46,22 @@ jest.mock("@config", () => ({ })) const mockPayInvoice = jest.fn() +const mockFindWalletById = jest.fn() +const mockResolveCashWalletMutationWalletIdForAccount = jest.fn() const mockRecordExceptionInCurrentSpan = jest.fn() const mockAddEventToCurrentSpan = jest.fn() +jest.mock("@services/mongoose", () => ({ + WalletsRepository: () => ({ + findById: (...args: unknown[]) => mockFindWalletById(...args), + }), +})) + +jest.mock("@app/cash-wallet-cutover", () => ({ + resolveCashWalletMutationWalletIdForAccount: (...args: unknown[]) => + mockResolveCashWalletMutationWalletIdForAccount(...args), +})) + jest.mock("@services/tracing", () => ({ addAttributesToCurrentSpan: jest.fn(), addEventToCurrentSpan: (...args: unknown[]) => mockAddEventToCurrentSpan(...args), @@ -106,6 +119,7 @@ import { ErrorLevel } from "@domain/shared" import { WithdrawalLimitsExceededError } from "@domain/errors" import { LnInvoiceDecodeError } from "@domain/bitcoin/lightning/errors" import { InternalTransferMarkerError } from "@domain/payment-splits" +import { CashWalletCutoverPreflightError } from "@app/cash-wallet-cutover/errors" import LnInvoicePaymentSendMutation from "@graphql/public/root/mutation/ln-invoice-payment-send" import { IbexError, @@ -124,20 +138,189 @@ type PaymentSendResult = { errors: { message: string; code?: string }[] } -const resolvePayment = async (): Promise => { +const resolvePayment = async ( + input: Record = {}, +): Promise => { const resolve = LnInvoicePaymentSendMutation.resolve as unknown as ( source: null, args: { input: Record }, - ctx: { domainAccount: Record }, + ctx: { + domainAccount: Record + cashWalletClientCapabilities: Record + }, ) => Promise return resolve( null, - { input: { walletId: "wallet-1", paymentRequest: "lnbc1" } }, - { domainAccount: { id: "account-1" } }, + { input: { walletId: "wallet-1", paymentRequest: "lnbc1", ...input } }, + { + domainAccount: { id: "account-1" }, + cashWalletClientCapabilities: { cashWalletPresentation: "usdt" }, + }, ) } +beforeEach(() => { + mockFindWalletById.mockImplementation(async (walletId: string) => ({ + id: walletId, + accountId: "account-1", + currency: walletId === "btc-wallet" ? "BTC" : "USD", + })) + mockResolveCashWalletMutationWalletIdForAccount.mockImplementation( + async ({ walletId }: { walletId: string }) => walletId, + ) +}) + +describe("fixed-invoice cash source routing", () => { + beforeEach(() => { + jest.clearAllMocks() + mockSendGuardMode.mockReturnValue("log-only") + mockDecodeInvoice.mockReturnValue({ + paymentAmount: { amount: 21_000n, currency: "BTC" }, + }) + mockPayInvoice.mockResolvedValue({ + transaction: { payment: { status: { id: 2 } } }, + }) + }) + + it("uses the settlement wallet for a presented legacy USD alias at every money boundary", async () => { + mockResolveCashWalletMutationWalletIdForAccount.mockResolvedValue("usdt-wallet") + + const result = await resolvePayment({ idempotencyKey: "attempt-1" }) + + expect(result).toEqual({ errors: [], status: "success" }) + expect(mockResolveCashWalletMutationWalletIdForAccount).toHaveBeenCalledWith({ + account: { id: "account-1" }, + walletId: "wallet-1", + client: { cashWalletPresentation: "usdt" }, + }) + expect(mockFindWalletById).toHaveBeenNthCalledWith(1, "wallet-1") + expect(mockFindWalletById).toHaveBeenNthCalledWith(2, "usdt-wallet") + expect(mockWithPaymentIdempotency).toHaveBeenCalledWith( + expect.objectContaining({ + idempotencyKey: "attempt-1", + senderWalletId: "usdt-wallet", + requestFingerprint: "ln|lnbc1", + }), + ) + expect(mockAuthorizeSend).toHaveBeenCalledWith( + expect.objectContaining({ senderWalletId: "usdt-wallet" }), + ) + expect(mockMarkOutgoingBolt11).toHaveBeenCalledWith({ + paymentRequest: "lnbc1", + senderWalletId: "usdt-wallet", + }) + expect(mockPayInvoice).toHaveBeenCalledWith({ + invoice: "lnbc1", + accountId: "usdt-wallet", + }) + }) + + it("keeps a directly selected USDT wallet as the payment source", async () => { + mockFindWalletById.mockResolvedValue({ + id: "usdt-wallet", + accountId: "account-1", + currency: "USDT", + }) + + const result = await resolvePayment({ walletId: "usdt-wallet" }) + + expect(result).toEqual({ errors: [], status: "success" }) + expect(mockFindWalletById).toHaveBeenCalledTimes(1) + expect(mockWithPaymentIdempotency).toHaveBeenCalledWith( + expect.objectContaining({ senderWalletId: "usdt-wallet" }), + ) + expect(mockPayInvoice).toHaveBeenCalledWith({ + invoice: "lnbc1", + accountId: "usdt-wallet", + }) + }) + + it("leaves an owned BTC source on the existing BTC path", async () => { + const result = await resolvePayment({ walletId: "btc-wallet" }) + + expect(result).toEqual({ errors: [], status: "success" }) + expect(mockResolveCashWalletMutationWalletIdForAccount).not.toHaveBeenCalled() + expect(mockWithPaymentIdempotency).toHaveBeenCalledWith( + expect.objectContaining({ senderWalletId: "btc-wallet" }), + ) + expect(mockPayInvoice).toHaveBeenCalledWith({ + invoice: "lnbc1", + accountId: "btc-wallet", + }) + }) + + it("fails before opening an attempt if cash source routing fails", async () => { + mockResolveCashWalletMutationWalletIdForAccount.mockResolvedValue( + new CashWalletCutoverPreflightError("cash routing unavailable"), + ) + + const result = await resolvePayment() + + expect(result.status).toBe("failed") + expect(mockWithPaymentIdempotency).not.toHaveBeenCalled() + expect(mockPayInvoice).not.toHaveBeenCalled() + }) + + it("rejects a foreign supplied wallet before routing, idempotency or IBEX", async () => { + mockFindWalletById.mockResolvedValue({ + id: "wallet-1", + accountId: "foreign-account", + currency: "USD", + }) + + const result = await resolvePayment() + + expect(result.status).toBe("failed") + expect(result.errors).toHaveLength(1) + expect(mockResolveCashWalletMutationWalletIdForAccount).not.toHaveBeenCalled() + expect(mockWithPaymentIdempotency).not.toHaveBeenCalled() + expect(mockPayInvoice).not.toHaveBeenCalled() + }) + + it("rejects a routed settlement wallet that is not owned by the caller", async () => { + mockResolveCashWalletMutationWalletIdForAccount.mockResolvedValue("foreign-wallet") + mockFindWalletById.mockImplementation(async (walletId: string) => ({ + id: walletId, + accountId: walletId === "foreign-wallet" ? "foreign-account" : "account-1", + currency: "USD", + })) + + const result = await resolvePayment() + + expect(result.status).toBe("failed") + expect(mockWithPaymentIdempotency).not.toHaveBeenCalled() + expect(mockPayInvoice).not.toHaveBeenCalled() + }) + + it("gives alias and direct settlement-wallet replays the same key scope", async () => { + mockResolveCashWalletMutationWalletIdForAccount.mockResolvedValue("usdt-wallet") + const first = await resolvePayment({ idempotencyKey: "same-attempt" }) + expect(first).toEqual({ errors: [], status: "success" }) + + // Model a cache hit: the wrapper returns the original result without + // authorizing, marking or calling IBEX again. + mockWithPaymentIdempotency.mockResolvedValueOnce({ value: "success" }) + const replay = await resolvePayment({ + walletId: "usdt-wallet", + idempotencyKey: "same-attempt", + }) + + expect(replay).toEqual({ errors: [], status: "success" }) + expect(mockWithPaymentIdempotency.mock.calls[0][0]).toMatchObject({ + senderWalletId: "usdt-wallet", + idempotencyKey: "same-attempt", + }) + expect(mockWithPaymentIdempotency.mock.calls[1][0]).toMatchObject({ + senderWalletId: "usdt-wallet", + idempotencyKey: "same-attempt", + }) + expect(mockAuthorizeSend).toHaveBeenCalledTimes(1) + expect(mockMarkOutgoingBolt11).toHaveBeenCalledTimes(1) + expect(mockPayInvoice).toHaveBeenCalledTimes(1) + }) +}) + describe("lnInvoicePaymentSend IBEX error surfacing (issue #93)", () => { beforeEach(() => { jest.clearAllMocks() diff --git a/test/flash/unit/graphql/ln-send-marker-withdrawal.spec.ts b/test/flash/unit/graphql/ln-send-marker-withdrawal.spec.ts index 4e15bd291..bfb5fd825 100644 --- a/test/flash/unit/graphql/ln-send-marker-withdrawal.spec.ts +++ b/test/flash/unit/graphql/ln-send-marker-withdrawal.spec.ts @@ -44,6 +44,16 @@ jest.mock("@services/mongoose/payment-split-executions", () => ({ PaymentSplitExecutionsRepository: jest.fn(() => mockStore.repo), })) +jest.mock("@services/mongoose", () => ({ + WalletsRepository: () => ({ + findById: async (walletId: string) => ({ + id: walletId, + accountId: "payer-account", + currency: "USD", + }), + }), +})) + jest.mock("@app/payments/authorize-send", () => ({ authorizeSend: async () => true, gateSend: async () => true, From 3028c2597b901f8c385e24c6e7fb01b4020c14af Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:24:22 -0700 Subject: [PATCH 27/42] Guard auth privacy and Lightning source routing contracts --- docs/native-parity-unit-tests.md | 4 ++-- scripts/native-parity-unit.cjs | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/native-parity-unit-tests.md b/docs/native-parity-unit-tests.md index 907103fb8..39d9cfa2f 100644 --- a/docs/native-parity-unit-tests.md +++ b/docs/native-parity-unit-tests.md @@ -7,7 +7,7 @@ node scripts/native-parity-unit.cjs node --test scripts/native-parity-unit.test.cjs ``` -The first command runs the fixed, reviewed selection of 69 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. +The first command runs the fixed, reviewed selection of 73 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. The child receives only inherited `PATH` and `TMPDIR`, plus the explicit fixture values in `scripts/native-parity-unit.cjs`. Required secrets are unmistakable dummy strings. Network is `regtest`; configured service hosts point at numeric loopback port 9. `HOME`, cloud credentials/profiles, proxy variables, Node preloads, and inherited service/provider credentials are omitted. Jest's setup selects only committed `dev/config/base-config.yaml`; no private configuration file is loaded by the runner. Its required dummy environment exists only in the child process and must never be used for a deployment. @@ -23,6 +23,6 @@ Three narrow test boundaries now avoid real service initialization: The identity presigned-PUT test still uses the real pinned AWS SDK and sends the untouched signed URL with a nonempty PNG to its local HTTP fixture. The interlock permits that fixture. It emulates checksum/body acceptance, not production signature verification, and uses dummy credentials only. All Bridge, IBEX, notification, database and cache interactions elsewhere are mocked. -Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `86a25a842` plus the reviewed runner changes: all **69 suites / 1096 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-post-fygaro-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. +Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `86a25a842` plus the reviewed runner changes: all **73 suites / 1167 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-post-ln-route-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. These are offline unit checks. They do not establish provider access, sandbox interoperability, production configuration, bank-account provisioning, image storage acceptance by DigitalOcean, or deployment readiness. diff --git a/scripts/native-parity-unit.cjs b/scripts/native-parity-unit.cjs index 661df7005..9155c029c 100644 --- a/scripts/native-parity-unit.cjs +++ b/scripts/native-parity-unit.cjs @@ -84,6 +84,8 @@ const suites = Object.freeze({ ownership: [ "app/accounts/owned-wallet-snapshot-adapters.spec.ts", "app/accounts/owned-wallet-snapshot.spec.ts", + "graphql/ln-invoice-payment-send.spec.ts", + "graphql/ln-send-marker-withdrawal.spec.ts", "graphql/owned-wallet-snapshot.spec.ts", ], }) From ba04cb045f3fa1a8e1e3fe0225f283faf9d1bdc6 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:28:02 -0700 Subject: [PATCH 28/42] Keep auth provider diagnostics private and fail closed on IP errors --- docs/auth-provider-adapter-privacy.md | 32 ++++++ src/domain/ipfetcher/errors.ts | 2 +- src/services/ipfetcher/index.ts | 11 +- src/services/twilio.ts | 74 ++++++++----- .../authentication/telemetry-privacy.spec.ts | 4 +- .../unit/services/ipfetcher-privacy.spec.ts | 96 ++++++++++++++++ test/flash/unit/services/twilio.spec.ts | 104 +++++++++++++++--- 7 files changed, 269 insertions(+), 54 deletions(-) create mode 100644 docs/auth-provider-adapter-privacy.md create mode 100644 test/flash/unit/services/ipfetcher-privacy.spec.ts diff --git a/docs/auth-provider-adapter-privacy.md b/docs/auth-provider-adapter-privacy.md new file mode 100644 index 000000000..2eccf689d --- /dev/null +++ b/docs/auth-provider-adapter-privacy.md @@ -0,0 +1,32 @@ +# Authentication provider adapter privacy and IP failure handling + +The IP metadata adapter still sends the exact IP and configured key to its +provider. Successful metadata is returned unchanged to the onboarding policy; +the exported span contains only closed booleans. On provider failure, the +adapter records and returns a fixed diagnostic. It no longer includes the +queried IP, credential-bearing request URL, response body or raw exception. + +`UnknownIpFetcherServiceError` now extends `IpFetcherServiceError`. Phone and +device onboarding already check for the latter class before authorizing IP +metadata. The corrected inheritance makes an unknown provider failure take +that fail-closed path, without changing the public error name or critical +severity. + +Twilio Verify retains exact phone/code/channel provider inputs and its +service-wide send-rate-limit key. HTTP 429 or known rate-limit code 60203 still +maps to `PhoneProviderRateLimitExceededError`, without claiming which Twilio +bucket rejected the request. Other known failures retain their mapped classes, +including invalid code, restricted region, unavailable provider and expired +verification. Returned messages and log fields are fixed; they contain no +provider error text, phone, code or carrier response object. Carrier lookup +still returns the exact mapped metadata to the caller. + +Three guarded unit suites cover 28 tests, including private provider response +and error fixtures, exact provider inputs, both onboarding paths, rate-limit +classification and fixed returned diagnostics. Whole-tree TypeScript, +no-implicit-any, scoped lint, spelling and diff checks pass. Tests use dummy +configuration and mocks, with no provider request or account mutation. + +This change does not validate production Twilio delivery or proxy-provider +accuracy. Existing historical telemetry and unrelated provider adapters need +separate operator review. diff --git a/src/domain/ipfetcher/errors.ts b/src/domain/ipfetcher/errors.ts index ffd2b87b6..8aff864bc 100644 --- a/src/domain/ipfetcher/errors.ts +++ b/src/domain/ipfetcher/errors.ts @@ -3,6 +3,6 @@ import { DomainError, ErrorLevel } from "@domain/shared" export class IpFetcherError extends DomainError {} export class IpFetcherServiceError extends IpFetcherError {} -export class UnknownIpFetcherServiceError extends IpFetcherError { +export class UnknownIpFetcherServiceError extends IpFetcherServiceError { level = ErrorLevel.Critical } diff --git a/src/services/ipfetcher/index.ts b/src/services/ipfetcher/index.ts index 93426ecc2..dab4641c6 100644 --- a/src/services/ipfetcher/index.ts +++ b/src/services/ipfetcher/index.ts @@ -41,11 +41,14 @@ export const IpFetcher = (): IIpFetcherService => { const proxy = !!(data[ip] && data[ip].proxy && data[ip].proxy === "yes") const isoCode = data[ip] && data[ip].isocode - addAttributesToCurrentSpan({ proxy, isoCode, keyIsPresent }) + // Provider metadata is untrusted and may contain the queried IP or + // arbitrary text. Only closed booleans belong in an exported span. + addAttributesToCurrentSpan({ proxy, keyIsPresent }) return { ...data[ip], isoCode, proxy, status: data.status } - } catch (error) { - recordExceptionInCurrentSpan({ error, attributes: { ip, keyIsPresent } }) - return new UnknownIpFetcherServiceError(error) + } catch { + const failure = new UnknownIpFetcherServiceError("IP metadata lookup failed") + recordExceptionInCurrentSpan({ error: failure, attributes: { keyIsPresent } }) + return failure } } return { diff --git a/src/services/twilio.ts b/src/services/twilio.ts index 99ab1d8c6..5b6015143 100644 --- a/src/services/twilio.ts +++ b/src/services/twilio.ts @@ -20,13 +20,10 @@ import { UnknownPhoneProviderServiceError, UnsubscribedRecipientPhoneProviderError, } from "@domain/phone-provider" -import { maskPhone } from "@services/alerts/ops-events" import { baseLogger } from "@services/logger" import { TestAccountsChecker } from "@domain/accounts/test-accounts-checker" import { NotImplementedError } from "@domain/errors" -import { parseErrorMessageFromUnknown } from "@domain/shared" - import { isAxiosError } from "axios" import { wrapAsyncFunctionsToRunInSpan } from "./tracing" @@ -103,7 +100,6 @@ export const TwilioClient = (): IPhoneProviderService => { // of these a probe can produce. Not an ops-feed event on purpose — a // per-rejection embed would evict the rest of the feed during exactly // the burst this exists for (see the coalescing in request-code.ts). - const { status, code } = err as { status?: unknown; code?: unknown } // No code Twilio returns (60203 included) distinguishes the // global_sends cap from its built-in per-number Verify limit, so // never claim the global_sends cause or key here — an on-call @@ -111,18 +107,16 @@ export const TwilioClient = (): IPhoneProviderService => { // throttling would otherwise misdiagnose it as the send cap // tripping. See the comment above TWILIO_RATE_LIMIT_HTTP_STATUS. baseLogger.warn( - { - to: maskPhone(to), - channel, - twilioStatus: status, - twilioCode: code, - }, + { operation: "twilio_verify_send", outcome: "rate_limited", channel }, "verify send rejected with HTTP 429 (rate limit, cause unconfirmed — not necessarily the global send cap)", ) - return new PhoneProviderRateLimitExceededError(parseErrorMessageFromUnknown(err)) + return new PhoneProviderRateLimitExceededError("Phone verification rate limited") } - baseLogger.error({ err }, "impossible to send text") + baseLogger.error( + { operation: "twilio_verify_send", outcome: "failed" }, + "Unable to send verification code", + ) return handleCommonErrors(err) } @@ -144,9 +138,14 @@ export const TwilioClient = (): IPhoneProviderService => { return true } catch (err) { - baseLogger.error({ err }, "impossible to verify phone and code") + baseLogger.error( + { operation: "twilio_verify_check", outcome: "failed" }, + "Unable to check verification code", + ) if (isAxiosError(err) && err.status === 404) { - return new ExpiredOrNonExistentPhoneNumberError(err.message) + return new ExpiredOrNonExistentPhoneNumberError( + "Verification expired or unavailable", + ) } return handleCommonErrors(err) @@ -158,7 +157,10 @@ export const TwilioClient = (): IPhoneProviderService => { const result = await client.lookups.v1 .phoneNumbers(phone) .fetch({ type: ["carrier"] }) - baseLogger.info({ result }, "result carrier info") + baseLogger.info( + { operation: "twilio_carrier_lookup", outcome: "received" }, + "Carrier lookup completed", + ) // TODO: migration to save the converted value to mongoose instead // of the one returned from twilio @@ -185,8 +187,8 @@ export const TwilioClient = (): IPhoneProviderService => { } return phoneMetadata - } catch (err) { - return new UnknownPhoneProviderServiceError(err) + } catch { + return new UnknownPhoneProviderServiceError("Carrier lookup failed") } } @@ -196,8 +198,22 @@ export const TwilioClient = (): IPhoneProviderService => { }) } -const handleCommonErrors = (err: Error | string | unknown) => { - const errMsg = parseErrorMessageFromUnknown(err) +const providerMessageForClassification = (err: unknown): string => { + try { + if (typeof err === "string") return err.slice(0, 4096) + if (err instanceof Error) return err.message.slice(0, 4096) + if (err && typeof err === "object" && "message" in err) { + const message = err.message + return typeof message === "string" ? message.slice(0, 4096) : "" + } + } catch { + // A provider error with hostile getters is still an unknown failure. + } + return "" +} + +const handleCommonErrors = (err: unknown) => { + const errMsg = providerMessageForClassification(err) const match = (knownErrDetail: RegExp): boolean => knownErrDetail.test(errMsg) @@ -205,37 +221,37 @@ const handleCommonErrors = (err: Error | string | unknown) => { case match(KnownTwilioErrorMessages.InvalidPhoneNumber): case match(KnownTwilioErrorMessages.InvalidMobileNumber): case match(KnownTwilioErrorMessages.InvalidPhoneNumberParameter): - return new InvalidPhoneNumberPhoneProviderError(errMsg) + return new InvalidPhoneNumberPhoneProviderError("Invalid phone number") case match(KnownTwilioErrorMessages.InvalidCodeParameter): - return new PhoneCodeInvalidError(errMsg) + return new PhoneCodeInvalidError("Invalid verification code") case match(KnownTwilioErrorMessages.RestrictedRegion): case match(KnownTwilioErrorMessages.CountryNeedsVetting): case match(KnownTwilioErrorMessages.BlockedRegion): - return new RestrictedRegionPhoneProviderError(errMsg) + return new RestrictedRegionPhoneProviderError("Phone region is restricted") case match(KnownTwilioErrorMessages.UnsubscribedRecipient): - return new UnsubscribedRecipientPhoneProviderError(errMsg) + return new UnsubscribedRecipientPhoneProviderError("Phone recipient unsubscribed") case match(KnownTwilioErrorMessages.BadPhoneProviderConnection): - return new PhoneProviderConnectionError(errMsg) + return new PhoneProviderConnectionError("Phone provider connection failed") case match(KnownTwilioErrorMessages.ServiceUnavailable): - return new PhoneProviderUnavailableError(errMsg) + return new PhoneProviderUnavailableError("Phone provider unavailable") case match(KnownTwilioErrorMessages.RateLimitsExceeded): case match(KnownTwilioErrorMessages.TooManyConcurrentRequests): - return new PhoneProviderRateLimitExceededError(errMsg) + return new PhoneProviderRateLimitExceededError("Phone verification rate limited") case match(KnownTwilioErrorMessages.FraudulentActivityBlock): - return new RestrictedRecipientPhoneNumberError(errMsg) + return new RestrictedRecipientPhoneNumberError("Phone recipient restricted") case match(KnownTwilioErrorMessages.InvalidOrApprovedVerification): - return new InvalidOrApprovedVerificationError(errMsg) + return new InvalidOrApprovedVerificationError("Verification unavailable") default: - return new UnknownPhoneProviderServiceError(errMsg) + return new UnknownPhoneProviderServiceError("Phone provider failed") } } export const KnownTwilioErrorMessages = { diff --git a/test/flash/unit/app/authentication/telemetry-privacy.spec.ts b/test/flash/unit/app/authentication/telemetry-privacy.spec.ts index d12774505..8979fc3c4 100644 --- a/test/flash/unit/app/authentication/telemetry-privacy.spec.ts +++ b/test/flash/unit/app/authentication/telemetry-privacy.spec.ts @@ -71,7 +71,7 @@ import { InvalidIpMetadataError, CouldNotFindUserFromPhoneError, } from "@domain/errors" -import { IpFetcherServiceError } from "@domain/ipfetcher" +import { UnknownIpFetcherServiceError } from "@domain/ipfetcher" import { PhoneCodeInvalidError } from "@domain/phone-provider" import { RateLimitConfig } from "@domain/rate-limit" import { RateLimiterExceededError } from "@domain/rate-limit/errors" @@ -264,7 +264,7 @@ describe("explicit onboarding telemetry boundary", () => { "returns the original %s IP-provider error while recording only a fixed diagnostic", async (kind) => { const error = Object.assign( - new IpFetcherServiceError(Object.values(privateValues).join(" ")), + new UnknownIpFetcherServiceError(Object.values(privateValues).join(" ")), { request: privateValues }, ) mockIP.mockResolvedValue(error) diff --git a/test/flash/unit/services/ipfetcher-privacy.spec.ts b/test/flash/unit/services/ipfetcher-privacy.spec.ts new file mode 100644 index 000000000..8db376466 --- /dev/null +++ b/test/flash/unit/services/ipfetcher-privacy.spec.ts @@ -0,0 +1,96 @@ +jest.mock("axios", () => ({ __esModule: true, default: { request: jest.fn() } })) +jest.mock("@config", () => ({ PROXY_CHECK_APIKEY: "fixture-private-proxy-key" })) +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) + +import axios from "axios" +import { UnknownIpFetcherServiceError, IpFetcherServiceError } from "@domain/ipfetcher" +import { IpFetcher } from "@services/ipfetcher" +import { + addAttributesToCurrentSpan, + recordExceptionInCurrentSpan, +} from "@services/tracing" + +const privateIp = "198.51.100.43" as IpAddress +const request = jest.mocked(axios.request) + +beforeEach(() => { + jest.clearAllMocks() +}) + +it("sends the exact IP and key to the provider while tracing only closed booleans", async () => { + const privateCarrier = "PRIVATE_PROVIDER_META" + request.mockResolvedValue({ + data: { + status: "ok", + [privateIp]: { + proxy: "yes", + isocode: privateCarrier, + country: "JM", + provider: privateCarrier, + }, + }, + }) + + const result = await IpFetcher().fetchIPInfo(privateIp) + + expect(request).toHaveBeenCalledWith({ + url: `https://proxycheck.io/v2/${privateIp}`, + params: { + vpn: "1", + asn: "1", + time: "1", + risk: "1", + key: "fixture-private-proxy-key", + }, + timeout: 500, + }) + expect(result).toEqual({ + proxy: true, + isoCode: privateCarrier, + isocode: privateCarrier, + country: "JM", + provider: privateCarrier, + status: "ok", + }) + expect(addAttributesToCurrentSpan).toHaveBeenCalledWith({ + proxy: true, + keyIsPresent: true, + }) + expect( + JSON.stringify(jest.mocked(addAttributesToCurrentSpan).mock.calls), + ).not.toContain(privateIp) + expect( + JSON.stringify(jest.mocked(addAttributesToCurrentSpan).mock.calls), + ).not.toContain(privateCarrier) +}) + +it("returns a service-class failure and records no URL, key, IP or provider body", async () => { + const privateProviderBody = "PRIVATE_PROVIDER_BODY" + request.mockRejectedValue( + Object.assign( + new Error(`https://proxycheck.io/v2/${privateIp}?key=fixture-private-proxy-key`), + { response: { data: privateProviderBody } }, + ), + ) + + const result = await IpFetcher().fetchIPInfo(privateIp) + + expect(result).toBeInstanceOf(UnknownIpFetcherServiceError) + expect(result).toBeInstanceOf(IpFetcherServiceError) + expect((result as Error).message).toBe("IP metadata lookup failed") + expect(recordExceptionInCurrentSpan).toHaveBeenCalledWith({ + error: result, + attributes: { keyIsPresent: true }, + }) + const diagnostics = JSON.stringify({ + result, + spans: jest.mocked(addAttributesToCurrentSpan).mock.calls, + exceptions: jest.mocked(recordExceptionInCurrentSpan).mock.calls, + }) + for (const secret of [privateIp, "fixture-private-proxy-key", privateProviderBody]) { + expect(diagnostics).not.toContain(secret) + } +}) diff --git a/test/flash/unit/services/twilio.spec.ts b/test/flash/unit/services/twilio.spec.ts index f94b0a2a4..3ad516583 100644 --- a/test/flash/unit/services/twilio.spec.ts +++ b/test/flash/unit/services/twilio.spec.ts @@ -7,6 +7,8 @@ */ const verificationsCreate = jest.fn() const verificationChecksCreate = jest.fn() +const lookupPhoneNumbers = jest.fn() +const lookupFetch = jest.fn() jest.mock("twilio", () => jest.fn(() => ({ @@ -18,7 +20,7 @@ jest.mock("twilio", () => })), }, }, - lookups: { v1: { phoneNumbers: jest.fn() } }, + lookups: { v1: { phoneNumbers: lookupPhoneNumbers } }, })), ) @@ -46,8 +48,10 @@ import { VERIFY_GLOBAL_SEND_CAP_VALUE, } from "@services/twilio" import { + PhoneCodeInvalidError, PhoneProviderRateLimitExceededError, RestrictedRegionPhoneProviderError, + UnknownPhoneProviderServiceError, } from "@domain/phone-provider" const phone = "+18765550100" as PhoneNumber @@ -60,8 +64,13 @@ const rateLimitRejection = () => describe("TwilioClient.initiateVerify", () => { beforeEach(() => { verificationsCreate.mockReset() + verificationChecksCreate.mockReset() + lookupPhoneNumbers.mockReset() + lookupFetch.mockReset() + lookupPhoneNumbers.mockReturnValue({ fetch: lookupFetch }) mockedLogger.warn.mockReset() mockedLogger.error.mockReset() + jest.mocked(baseLogger.info).mockReset() }) it.each(["sms", "whatsapp"] as ChannelType[])( @@ -86,19 +95,14 @@ describe("TwilioClient.initiateVerify", () => { expect(VERIFY_GLOBAL_SEND_CAP_VALUE).toBe("all") }) - it("maps an exhausted bucket (HTTP 429 / 60203) to the rate-limit error and warns with a masked number", async () => { + it("maps an exhausted bucket (HTTP 429 / 60203) to the rate-limit error without logging the number", async () => { verificationsCreate.mockRejectedValue(rateLimitRejection()) const result = await TwilioClient().initiateVerify({ to: phone, channel: "sms" }) expect(result).toBeInstanceOf(PhoneProviderRateLimitExceededError) expect(mockedLogger.warn).toHaveBeenCalledWith( - expect.objectContaining({ - to: "+1876…00", - channel: "sms", - twilioStatus: 429, - twilioCode: 60203, - }), + { operation: "twilio_verify_send", outcome: "rate_limited", channel: "sms" }, "verify send rejected with HTTP 429 (rate limit, cause unconfirmed — not necessarily the global send cap)", ) expect(mockedLogger.error).not.toHaveBeenCalled() @@ -151,14 +155,11 @@ describe("TwilioClient.initiateVerify", () => { expect(result).toBeInstanceOf(PhoneProviderRateLimitExceededError) expect(mockedLogger.warn).toHaveBeenCalledTimes(1) const [fields, message] = mockedLogger.warn.mock.calls[0] - expect(fields).toEqual( - expect.objectContaining({ - to: "+1876…00", - channel: "sms", - twilioStatus: 429, - twilioCode: 20429, - }), - ) + expect(fields).toEqual({ + operation: "twilio_verify_send", + outcome: "rate_limited", + channel: "sms", + }) expect(fields).not.toHaveProperty("rateLimitKey") expect(message).not.toBe("verify send rejected by twilio rate limit") expect(mockedLogger.error).not.toHaveBeenCalled() @@ -178,11 +179,78 @@ describe("TwilioClient.initiateVerify", () => { expect(result).toBeInstanceOf(RestrictedRegionPhoneProviderError) expect(mockedLogger.error).toHaveBeenCalledWith( - expect.objectContaining({ err: expect.any(Error) }), - "impossible to send text", + { operation: "twilio_verify_send", outcome: "failed" }, + "Unable to send verification code", ) expect( KnownTwilioErrorMessages.RateLimitsExceeded.test("Max send attempts reached"), ).toBe(true) }) + + it("classifies a private Verify send error without returning or logging provider text", async () => { + const privateError = `The destination phone number has been blocked by Verify Geo-Permissions. SN is blocked for sms channel for all services; ${phone}` + verificationsCreate.mockRejectedValue(new Error(privateError)) + + const result = await TwilioClient().initiateVerify({ to: phone, channel: "sms" }) + + expect(result).toBeInstanceOf(RestrictedRegionPhoneProviderError) + expect((result as Error).message).toBe("Phone region is restricted") + expect(JSON.stringify(mockedLogger.error.mock.calls)).not.toContain(phone) + expect(JSON.stringify(result)).not.toContain(phone) + }) + + it("classifies a private Verify check error without returning or logging the code", async () => { + const privateCode = "private-otp-602123" + verificationChecksCreate.mockRejectedValue( + new Error(`Invalid parameter: Code ${privateCode} for ${phone}`), + ) + + const result = await TwilioClient().validateVerify({ + to: phone, + code: privateCode as PhoneCode, + }) + + expect(verificationChecksCreate).toHaveBeenCalledWith({ + to: phone, + code: privateCode, + }) + expect(result).toBeInstanceOf(PhoneCodeInvalidError) + expect((result as Error).message).toBe("Invalid verification code") + const diagnostics = JSON.stringify({ result, logs: mockedLogger.error.mock.calls }) + expect(diagnostics).not.toContain(privateCode) + expect(diagnostics).not.toContain(phone) + }) + + it("returns the exact carrier metadata without logging it or a raw lookup failure", async () => { + const privateCarrier = "Private Carrier Account" + const metadata = { + countryCode: "JM", + phoneNumber: phone, + nationalFormat: "PRIVATE_NATIONAL_FORMAT", + carrier: { + error_code: null, + mobile_country_code: "338", + mobile_network_code: "050", + name: privateCarrier, + type: "mobile", + }, + } + lookupFetch.mockResolvedValueOnce(metadata) + expect(await TwilioClient().getCarrier(phone)).toEqual({ + carrier: metadata.carrier, + countryCode: "JM", + }) + expect(lookupPhoneNumbers).toHaveBeenCalledWith(phone) + expect(lookupFetch).toHaveBeenCalledWith({ type: ["carrier"] }) + expect(JSON.stringify(jest.mocked(baseLogger.info).mock.calls)).not.toContain(phone) + expect(JSON.stringify(jest.mocked(baseLogger.info).mock.calls)).not.toContain( + privateCarrier, + ) + + lookupFetch.mockRejectedValueOnce(new Error(`PRIVATE_PROVIDER_ERROR ${phone}`)) + const failure = await TwilioClient().getCarrier(phone) + expect(failure).toBeInstanceOf(UnknownPhoneProviderServiceError) + expect((failure as Error).message).toBe("Carrier lookup failed") + expect(JSON.stringify(failure)).not.toContain(phone) + }) }) From 0d7bfed1a3724d0385ef5d34625fe9c1b691c342 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:28:40 -0700 Subject: [PATCH 29/42] Select provider privacy and fail-closed auth regressions --- docs/native-parity-unit-tests.md | 4 ++-- scripts/native-parity-unit.cjs | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/native-parity-unit-tests.md b/docs/native-parity-unit-tests.md index 39d9cfa2f..0e6047869 100644 --- a/docs/native-parity-unit-tests.md +++ b/docs/native-parity-unit-tests.md @@ -7,7 +7,7 @@ node scripts/native-parity-unit.cjs node --test scripts/native-parity-unit.test.cjs ``` -The first command runs the fixed, reviewed selection of 73 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. +The first command runs the fixed, reviewed selection of 75 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. The child receives only inherited `PATH` and `TMPDIR`, plus the explicit fixture values in `scripts/native-parity-unit.cjs`. Required secrets are unmistakable dummy strings. Network is `regtest`; configured service hosts point at numeric loopback port 9. `HOME`, cloud credentials/profiles, proxy variables, Node preloads, and inherited service/provider credentials are omitted. Jest's setup selects only committed `dev/config/base-config.yaml`; no private configuration file is loaded by the runner. Its required dummy environment exists only in the child process and must never be used for a deployment. @@ -23,6 +23,6 @@ Three narrow test boundaries now avoid real service initialization: The identity presigned-PUT test still uses the real pinned AWS SDK and sends the untouched signed URL with a nonempty PNG to its local HTTP fixture. The interlock permits that fixture. It emulates checksum/body acceptance, not production signature verification, and uses dummy credentials only. All Bridge, IBEX, notification, database and cache interactions elsewhere are mocked. -Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `86a25a842` plus the reviewed runner changes: all **73 suites / 1167 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-post-ln-route-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. +Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `ba04cb045` plus the reviewed runner changes: all **75 suites / 1180 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-post-auth-provider-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. These are offline unit checks. They do not establish provider access, sandbox interoperability, production configuration, bank-account provisioning, image storage acceptance by DigitalOcean, or deployment readiness. diff --git a/scripts/native-parity-unit.cjs b/scripts/native-parity-unit.cjs index 9155c029c..ece096d96 100644 --- a/scripts/native-parity-unit.cjs +++ b/scripts/native-parity-unit.cjs @@ -62,11 +62,13 @@ const suites = Object.freeze({ "services/fygaro/webhook-server/payment.spec.ts", "services/http-trace-privacy.spec.ts", "services/ibex/request-trace-privacy.spec.ts", + "services/ipfetcher-privacy.spec.ts", "services/notifications/firebase-telemetry-privacy.spec.ts", "services/notifications/push-notifications.spec.ts", "services/notification/telemetry-privacy.spec.ts", "services/tracing-input-privacy.spec.ts", "services/tracing.spec.ts", + "services/twilio.spec.ts", ], device: [ "app/accounts/device-account-bootstrap.spec.ts", From c53f2dc70475e7454cd420c07b146180c92ead13 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:29:58 -0700 Subject: [PATCH 30/42] Use spelling-safe source reference in parity test record --- docs/native-parity-unit-tests.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/native-parity-unit-tests.md b/docs/native-parity-unit-tests.md index 0e6047869..a20e3fe0a 100644 --- a/docs/native-parity-unit-tests.md +++ b/docs/native-parity-unit-tests.md @@ -23,6 +23,6 @@ Three narrow test boundaries now avoid real service initialization: The identity presigned-PUT test still uses the real pinned AWS SDK and sends the untouched signed URL with a nonempty PNG to its local HTTP fixture. The interlock permits that fixture. It emulates checksum/body acceptance, not production signature verification, and uses dummy credentials only. All Bridge, IBEX, notification, database and cache interactions elsewhere are mocked. -Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `ba04cb045` plus the reviewed runner changes: all **75 suites / 1180 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-post-auth-provider-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. +Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `0d7bfed1a` plus the reviewed runner changes: all **75 suites / 1180 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-post-auth-provider-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. These are offline unit checks. They do not establish provider access, sandbox interoperability, production configuration, bank-account provisioning, image storage acceptance by DigitalOcean, or deployment readiness. From 7d0c5e3628c825e65ab64c98ecb71e12e22f67f1 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:54:58 -0700 Subject: [PATCH 31/42] fix: fence ambiguous Bridge withdrawal crypto sends --- docs/bridge-withdrawal-send-uncertainty.md | 29 +++ src/domain/bridge/withdrawal-outcome.ts | 8 + src/graphql/error-map.ts | 7 + src/services/bridge/errors.ts | 8 + src/services/bridge/index.ts | 156 ++++++----- src/services/bridge/reconciliation.ts | 219 ++++++++++------ .../bridge/webhook-server/routes/transfer.ts | 6 + .../bridge/withdrawal-send-uncertainty.ts | 44 ++++ src/services/graphql-trace-privacy.ts | 1 + src/services/mongoose/bridge-accounts.ts | 145 ++++++++++- src/services/mongoose/schema.ts | 29 +++ .../unit/graphql/bridge-error-map.spec.ts | 15 ++ .../root/mutation/bridge-withdrawal.spec.ts | 19 ++ test/flash/unit/services/bridge/index.spec.ts | 244 +++++++++++++----- .../services/bridge/reconciliation.spec.ts | 113 +++++++- .../services/bridge/return-shapes.spec.ts | 31 +-- .../bridge/webhook-server/transfer.spec.ts | 36 +++ .../bridge-withdrawal-send-fence.spec.ts | 238 +++++++++++++++++ .../services/tracing-input-privacy.spec.ts | 1 + 19 files changed, 1108 insertions(+), 241 deletions(-) create mode 100644 docs/bridge-withdrawal-send-uncertainty.md create mode 100644 src/domain/bridge/withdrawal-outcome.ts create mode 100644 src/services/bridge/withdrawal-send-uncertainty.ts create mode 100644 test/flash/unit/services/mongoose/bridge-withdrawal-send-fence.spec.ts diff --git a/docs/bridge-withdrawal-send-uncertainty.md b/docs/bridge-withdrawal-send-uncertainty.md new file mode 100644 index 000000000..7097a7712 --- /dev/null +++ b/docs/bridge-withdrawal-send-uncertainty.md @@ -0,0 +1,29 @@ +# Bridge withdrawal send uncertainty + +This leaf starts from integrated backend `0ab8c6240`. It addresses the debit ambiguity between IBEX crypto send and Bridge payout; it does not change provider adapters, amount conversion, withdrawal-fee consent or enable native withdrawal initiation. No real provider request, payment, credential, exported span or deployment was used. + +Previously any `sendCrypto` error made the withdrawal `send_failed`. The reconciler automatically deleted the Bridge transfer for that state. A timeout after IBEX accepted a debit could therefore cancel the destination while funds were already moving. A process crash or failed receipt write could also leave a submitted row without evidence that the send had begun. + +Before calling `sendCrypto`, the service now reserves the exact submitted row using an atomic owner/transfer/state CAS. The write requires majority acknowledgment and journaling. It stores a new attempt UUID, attempt time, source wallet, send-info ID and original major-unit amount, and sets `send_unknown`/`attempted`. There is no transition back to an eligible request on errors. An index on `accountId`, unique only for `send_unknown`, prevents concurrent distinct rows from dispatching for the same account. The repository creates/verifies that exact named index using majority commit quorum before it grants dispatch authority; index, read, CAS or write-concern errors fail closed before provider dispatch. + +Thrown or returned send errors, malformed acknowledgments, missing IDs, and receipt-persistence failures retain the fence. A usable IBEX ID can be stored against that exact attempt, but the state stays `send_unknown`. The pinned `ibex-client` `CryptoSendResponse200` has optional IDs and free-form optional status fields; it does not establish that `PENDING` means the funds were sent or settled. No positive status allowlist or local balance adjustment is invented. The service returns the closed `BRIDGE_WITHDRAWAL_SEND_OUTCOME_UNKNOWN` error with fixed instructions to check status and avoid submitting again; it does not return an error-free initiation result that RN would interpret as final success. No `usdt_sent` notification or ERP event is created from an uncertain acknowledgment. The existing completed-ERP writer can upsert from the later authoritative Bridge webhook. + +Only the exact Bridge transfer's authenticated `payment_processed` read or explicit signed webhook state can clear the new uncertainty as completed. Repository completion of `send_unknown` or legacy `send_failed` requires a closed internal proof argument. An event name such as `transfer.completed` alone cannot clear it. The webhook acknowledges a typed unresolved-state refusal as HTTP 200 `pending_review`, performs no completed audit/notification, and leaves authenticated reconciliation to establish the state. Database faults retain HTTP 500. Existing known submitted/usdt-sent behavior remains compatible. Failed/returned/cancelled Bridge states never establish that an IBEX debit did not occur and cannot clear the new fence or authorize an automatic refund/resend. + +Definitive errors before the crypto-send call remain distinguishable. Requirements/send-info failures set `send_failed` with explicit `cryptoSendState: not_dispatched` and fixed failure text. The repository cannot write this proof over an attempt fence. Automatic Bridge cancellation is permitted only for that proof with no attempt ID. Historical `send_failed` records have no such proof and are quarantined; they are not presumed safe to cancel. Account-level reads also block new requests/attempts for historical submitted rows, ambiguous send failures and sent rows missing a payout ID. This blocks a retry using a newly created row as well as reuse of the original row. + +The scanner includes cross-age unresolved rows rather than dropping them after fifteen minutes. Each run reads at most 100 records with a five-second Mongo query budget. The `(status, cryptoSendReviewedAt, _id)` index supports the active-state scan. Separate review timestamps rotate the queue fairly without changing financial status or the original update/attempt times. Submitted rows receive the existing fifteen-minute grace before review. Review-bookkeeping faults are reported with fixed diagnostics; they never authorize cancellation or dispatch. Provider lookup errors or mismatched transfer IDs preserve uncertainty. The scan performs at most one lookup/cancellation per selected row and does not automatically retry a money send. + +New uncertainty logs and operator alerts contain a closed reason and `SHA256("bridge-send:" + withdrawalRecordId)` reference. They exclude account/wallet IDs, addresses, amounts, provider errors/responses and credentials. The alert is best-effort; the Mongo fence is the durable authority. Existing unrelated Bridge logging, operational alerts and historical telemetry are outside this privacy claim. + +## Release and recovery + +Quiesce all old withdrawal writers and reconcilers before activation; old processes do not honor the new fence/proof marker. Build and verify the named unique partial index and the review-queue index before accepting traffic. MongoDB must support majority index commit quorum (4.4+ replica set), and the application must be able to verify/create the index and perform majority journaled writes. Unsupported topology, permissions, duplicate existing unknown rows, conflicting index options or unknown acknowledgments block dispatch rather than falling back to weaker writes. Runtime creation verifies the required uniqueness; no index or historical record was changed during this development work. + +Operators should list protected unresolved records, derive the alert reference from the record ID, and correlate the immutable source/send-info/attempt and any receipt ID with authoritative IBEX debit records and the owned Bridge transfer. A missing lookup result, timeout, pending response or absence of a webhook is not proof of no debit. Confirmed Bridge `payment_processed` can complete through the guarded repository path. Other outcomes require reviewed provider evidence and a separately authorized repair; this leaf adds no force-reset, blind retry, cancellation or refund endpoint. Never reset a fenced record to pending to unblock an account. Historical submitted/failed rows and a concurrent losing row may remain conservatively blocked for operator review. Ambiguity is deliberately retained when safe recovery cannot be established. + +GraphQL's withdrawal `status` remains a string, so `send_unknown` needs no schema change. Native commit `53b7de3` explicitly decodes it and shows “Transfer pending review” and “Do not start another withdrawal”, never failed or settled. Pinned RN confirmation shows the actionable mutation error and stays on screen; backend guards reject subsequent attempts even though RN has no dedicated pending-review screen. Native initiation remains unavailable independently of this backend fix. + +Validation covers thrown/returned provider outcomes, missing/failure acknowledgments, persistence uncertainty, journaled CAS ordering, index failures/results, concurrent distinct-row account fences, new-row retry blocking, historical quarantine, bounded/fair reconciliation, exact-ID completion, and event-name-only webhook refusal. These are guarded mocked tests; the real Mongo index/replica-set behavior and provider integration require the normal pre-release integration lane. No live provider calls are part of validation. + +Validation result: **255 tests across 12 reviewed suites** pass under Node 24 with fake `safeEnvironment`, the integration network guard and Watchman disabled. All three TypeScript configurations, scoped ESLint, Typos and `git diff --check` pass. The test suites cover actual repository CAS/index options and modeled Mongo uniqueness; they do not substitute for the deployment gate’s real replica-set index verification. diff --git a/src/domain/bridge/withdrawal-outcome.ts b/src/domain/bridge/withdrawal-outcome.ts new file mode 100644 index 000000000..9c5e9df8d --- /dev/null +++ b/src/domain/bridge/withdrawal-outcome.ts @@ -0,0 +1,8 @@ +import { RepositoryError } from "@domain/errors" + +// A deliberate unresolved money state, distinct from an unavailable database. +export class BridgeWithdrawalCompletionProofRequiredError extends RepositoryError { + constructor() { + super("Withdrawal completion requires confirmed provider state") + } +} diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 6650d70f3..c6b02936b 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -780,6 +780,13 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message, }) + case "BridgeWithdrawalSendOutcomeUnknownError": + return bridgeGqlError({ + code: "BRIDGE_WITHDRAWAL_SEND_OUTCOME_UNKNOWN", + message: + "Withdrawal is pending confirmation. Do not submit it again; check its status or contact support.", + }) + case "BridgeWithdrawalAlreadyInitiatedError": message = error.message || diff --git a/src/services/bridge/errors.ts b/src/services/bridge/errors.ts index 24ac356be..e1241685e 100644 --- a/src/services/bridge/errors.ts +++ b/src/services/bridge/errors.ts @@ -232,3 +232,11 @@ export const mapBridgeHttpError = ( return new BridgeApiError("Bridge API error", statusCode, response) } } + +export class BridgeWithdrawalSendOutcomeUnknownError extends BridgeError { + constructor() { + super( + "Withdrawal is pending confirmation. Do not submit it again; check its status or contact support.", + ) + } +} diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index 04d7670c5..262cedce5 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -38,7 +38,6 @@ import { WalletsRepository } from "@services/mongoose/wallets" import { IdentityRepository } from "@services/kratos" import IbexClient from "@services/ibex/client" -import { writeBridgeCashoutPending } from "@services/frappe/BridgeTransferRequestWriter" import { IbexError } from "@services/ibex/errors" import { @@ -57,6 +56,7 @@ import { BridgeInvalidPlaidTokenError, BridgeDepositInstructionsMissingError, BridgeWithdrawalNetAmountTooLowError, + BridgeWithdrawalSendOutcomeUnknownError, } from "./errors" import BridgeApiClient, { type CreateExternalAccountRequest, @@ -71,6 +71,8 @@ import { type PresentedBridgeWithdrawal, } from "./withdrawal-fees" +import { reportWithdrawalSendUncertainty } from "./withdrawal-send-uncertainty" + const asBridgeRequestWithdrawalError = (error: unknown): BridgeError => { if (error instanceof BridgeError) return error if (error instanceof IbexError) { @@ -1170,6 +1172,20 @@ const deleteExternalAccount = async ( } } +const rejectUnresolvedWithdrawalSend = async ( + accountId: string, +): Promise => { + const unresolved = + await BridgeAccountsRepo.findUnresolvedWithdrawalSendByAccountId(accountId) + if (unresolved instanceof Error) return unresolved + if (!unresolved) return undefined + reportWithdrawalSendUncertainty( + BridgeAccountsRepo.bridgeWithdrawalRecordId(unresolved), + "unresolved_prior_send", + ) + return new BridgeWithdrawalSendOutcomeUnknownError() +} + /** * Requests a withdrawal — validates everything and stores a pending record in MongoDB. * Does NOT call the Bridge API. Returns the pending withdrawal so the frontend can @@ -1197,6 +1213,8 @@ const requestWithdrawal = async ( operation: "requestWithdrawal", }) if (customerId instanceof Error) return customerId + const unresolvedSend = await rejectUnresolvedWithdrawalSend(accountId as string) + if (unresolvedSend) return unresolvedSend const ethereumAddress = account.bridgeEthereumAddress if (!ethereumAddress) { @@ -1389,12 +1407,15 @@ const initiateWithdrawal = async ( const account = await checkAccountLevel(accountId) if (account instanceof Error) return account + let dispatchFenceId: string | undefined try { const customerId = await requireApprovedBridgeCustomer({ account, operation: "initiateWithdrawal", }) if (customerId instanceof Error) return customerId + const unresolvedSend = await rejectUnresolvedWithdrawalSend(accountId as string) + if (unresolvedSend) return unresolvedSend const pendingWithdrawal = await BridgeAccountsRepo.findWithdrawalById(withdrawalId) if (pendingWithdrawal instanceof Error) { @@ -1511,7 +1532,7 @@ const initiateWithdrawal = async ( amount: transfer.amount, currency: transfer.currency, bridgeDepositAddress, - failureReason: sendRequirements.message, + failureReason: "IBEX crypto send requirements unavailable before dispatch", }) return sendRequirements } @@ -1529,7 +1550,7 @@ const initiateWithdrawal = async ( amount: transfer.amount, currency: transfer.currency, bridgeDepositAddress, - failureReason: cryptoSendInfo.message, + failureReason: "IBEX crypto send information unavailable before dispatch", }) return cryptoSendInfo } @@ -1547,89 +1568,84 @@ const initiateWithdrawal = async ( return error } + const attemptId = crypto.randomUUID() + const reserved = await BridgeAccountsRepo.reserveWithdrawalCryptoSendAttempt({ + id: pendingWithdrawal.id, + accountId: accountId as string, + bridgeTransferId: transfer.id, + attemptId, + sourceWalletId: usdtWallet.id, + cryptoSendInfoId: cryptoSendInfo.id, + amount, + }) + if ( + reserved instanceof Error || + reserved.cryptoSendAttemptId !== attemptId || + reserved.status !== "send_unknown" + ) { + reportWithdrawalSendUncertainty( + pendingWithdrawal.id, + "attempt_reservation_unconfirmed", + ) + return new BridgeWithdrawalSendOutcomeUnknownError() + } + // No provider call is permitted until the majority-journaled CAS fence + // exists. Every failure after this point keeps it; never roll it back. + dispatchFenceId = pendingWithdrawal.id const sendResult = await IbexClient.sendCrypto({ accountId: usdtWallet.id as IbexAccountId, cryptoSendInfosId: cryptoSendInfo.id, amount: sendAmount.toIbex(), }) - if (sendResult instanceof Error) { - await markWithdrawalSendFailed({ - accountId: accountId as string, - withdrawalId: pendingWithdrawal.id, - transferId: transfer.id, - amount: transfer.amount, - currency: transfer.currency, - bridgeDepositAddress, - failureReason: sendResult.message, - }) - return sendResult - } - - const ibexPayoutId = ibexPayoutIdFromSendResponse(sendResult) - if (!ibexPayoutId) { - baseLogger.error( - { - accountId, - withdrawalId: pendingWithdrawal.id, - transferId: transfer.id, - sendResult, - }, - "IBEX crypto send succeeded without transaction id; manual payout linking required", + const ibexPayoutId = + sendResult instanceof Error ? undefined : ibexPayoutIdFromSendResponse(sendResult) + const responseStatus = + typeof sendResult === "object" && sendResult !== null + ? ((sendResult as { status?: unknown; transaction?: { status?: unknown } }) + .transaction?.status ?? (sendResult as { status?: unknown }).status) + : undefined + const explicitFailure = + responseStatus !== undefined && + (typeof responseStatus !== "string" || + ["FAILED", "FAILURE", "REJECTED", "ERROR", "CANCELLED", "CANCELED"].includes( + responseStatus.toUpperCase(), + )) + if ( + explicitFailure || + typeof ibexPayoutId !== "string" || + !/^[A-Za-z0-9_-]{1,256}$/.test(ibexPayoutId) + ) { + reportWithdrawalSendUncertainty( + pendingWithdrawal.id, + "provider_outcome_unconfirmed", ) + return new BridgeWithdrawalSendOutcomeUnknownError() } - const updated = await BridgeAccountsRepo.updateWithdrawalOnchainSend( + const updated = await BridgeAccountsRepo.recordWithdrawalCryptoSendReceipt( pendingWithdrawal.id, ibexPayoutId, ibexTxHashFromSendResponse(sendResult), + attemptId, ) - if (updated instanceof Error) return updated - - await sendBridgeWithdrawalNotificationBestEffort({ - accountId: accountId as string, - amount: updated.amount, - currency: updated.currency, - outcome: "usdt_sent", - }) - - const auditResult = await writeBridgeCashoutPending({ - transferId: transfer.id, - amount: transfer.amount, - currency: transfer.currency, - accountId: accountId as string, - sourceEventId: updated.id, - sourceEventType: "bridge.withdrawal.usdt_sent", - rawPayload: { - withdrawalId: updated.id, - bridgeTransferId: transfer.id, - ibexPayoutId, - ibexTxHash: updated.ibexTxHash, - }, - }) - if (auditResult instanceof Error) { - baseLogger.warn( - { accountId, withdrawalId, transferId: transfer.id, error: auditResult }, - "Failed to write pending Bridge cashout transfer request", - ) - } - - baseLogger.info( - { - accountId, - operation: "initiateWithdrawal", - transferId: transfer.id, - ibexPayoutId, - }, - "Bridge operation completed", - ) - - return presentBridgeWithdrawal(updated) + if (updated instanceof Error) { + reportWithdrawalSendUncertainty(pendingWithdrawal.id, "send_receipt_unconfirmed") + return new BridgeWithdrawalSendOutcomeUnknownError() + } + reportWithdrawalSendUncertainty(pendingWithdrawal.id, "provider_pending_confirmation") + // RN currently treats any error-free initiate result as final success. + // Keep this an explicit pending-review error until Bridge confirms payout. + return new BridgeWithdrawalSendOutcomeUnknownError() } catch (error) { + if (dispatchFenceId) { + reportWithdrawalSendUncertainty(dispatchFenceId, "provider_outcome_unconfirmed") + return new BridgeWithdrawalSendOutcomeUnknownError() + } baseLogger.error( - { accountId, operation: "initiateWithdrawal", error }, + { operation: "initiateWithdrawal", outcome: "failed_before_dispatch" }, "Bridge operation failed", ) - return error instanceof Error ? error : new Error(String(error)) + return error instanceof Error ? error : new Error("Withdrawal failed before dispatch") } } diff --git a/src/services/bridge/reconciliation.ts b/src/services/bridge/reconciliation.ts index b5cd18d16..4f84e3434 100644 --- a/src/services/bridge/reconciliation.ts +++ b/src/services/bridge/reconciliation.ts @@ -16,6 +16,9 @@ import { PubSubDefaultTriggers } from "@domain/pubsub" import { toBridgeTransferId } from "@domain/primitives/bridge" import BridgeApiClient from "./client" +import { reportWithdrawalSendUncertainty } from "./withdrawal-send-uncertainty" + +export const WITHDRAWAL_REVIEW_BATCH_LIMIT = 100 const FIFTEEN_MIN_MS = 15 * 60 * 1000 @@ -59,7 +62,9 @@ type BridgeWithdrawalLike = { ibexPayoutId?: string amount: string currency: string - status: "usdt_sent" | "send_failed" + status: "usdt_sent" | "send_failed" | "send_unknown" | "submitted" + cryptoSendState?: string + cryptoSendAttemptId?: string failureReason?: string updatedAt: Date createdAt: Date @@ -242,6 +247,7 @@ export const reconcileBridgeAndIbexWithdrawals = async ({ finalizedCompletedTransfers: number ibexSendWithoutBridgeSettlement: number bridgeTransferWithoutIbexSend: number + unresolvedSendOutcomes: number } | Error > => { @@ -250,10 +256,14 @@ export const reconcileBridgeAndIbexWithdrawals = async ({ const since = new Date(now.getTime() - windowMs) const withdrawals = (await BridgeWithdrawal.find({ - updatedAt: { $gte: since, $lte: now }, - status: { $in: ["usdt_sent", "send_failed"] }, - bridgeTransferId: { $exists: true }, + $or: [ + { status: { $in: ["send_unknown", "send_failed", "usdt_sent"] } }, + { status: "submitted", updatedAt: { $lte: since } }, + ], }) + .sort({ cryptoSendReviewedAt: 1, _id: 1 }) + .limit(WITHDRAWAL_REVIEW_BATCH_LIMIT) + .maxTimeMS(5_000) .lean() .exec()) as BridgeWithdrawalLike[] @@ -261,91 +271,141 @@ export const reconcileBridgeAndIbexWithdrawals = async ({ let finalizedCompletedTransfers = 0 let ibexSendWithoutBridgeSettlement = 0 let bridgeTransferWithoutIbexSend = 0 + let unresolvedSendOutcomes = 0 for (const withdrawal of withdrawals) { - const transferId = withdrawal.bridgeTransferId - if (!transferId) continue - const bridgeTransferId = toBridgeTransferId(transferId) - - if (withdrawal.status === "send_failed") { - try { - await BridgeApiClient.deleteTransfer(bridgeTransferId) - cancelledSendFailedTransfers++ - } catch (error) { - bridgeTransferWithoutIbexSend++ - const reason = "Bridge transfer exists but IBEX crypto send failed" - await upsertBridgeReconciliationOrphan({ - orphanKey: toOrphanKey("withdrawal-send-failed", transferId), - orphanType: "bridge_transfer_without_ibex_send", - transferId, - amount: withdrawal.amount, - currency: withdrawal.currency, - triageContext: { - reason, - windowStart: since.toISOString(), - windowEnd: now.toISOString(), - accountId: withdrawal.accountId, - bridgeDepositAddress: withdrawal.bridgeDepositAddress, - failureReason: withdrawal.failureReason, - deleteTransferError: error instanceof Error ? error.message : String(error), - }, - }) - alertIbexReconciliationOrphan({ - orphanType: "bridge_transfer_without_ibex_send", - transferId, - reason, - context: { - account_id: withdrawal.accountId, + const recordId = String(withdrawal.id ?? withdrawal._id ?? "") + try { + if ( + withdrawal.status === "send_unknown" || + withdrawal.status === "submitted" || + (withdrawal.status === "usdt_sent" && !withdrawal.ibexPayoutId) || + (withdrawal.status === "send_failed" && + (withdrawal.cryptoSendState !== "not_dispatched" || + withdrawal.cryptoSendAttemptId !== undefined)) + ) { + // A pending receipt is not settlement. Only the exact Bridge transfer's + // authoritative processed state can resolve this durable uncertainty. + if (withdrawal.bridgeTransferId) { + try { + const transfer = await BridgeApiClient.getTransfer( + toBridgeTransferId(withdrawal.bridgeTransferId), + ) + if ( + transfer.id === withdrawal.bridgeTransferId && + transfer.state === "payment_processed" + ) { + const finalized = await BridgeAccountsRepo.updateWithdrawalStatus( + toBridgeTransferId(withdrawal.bridgeTransferId), + "completed", + undefined, + "payment_processed", + ) + if (!(finalized instanceof Error)) { + finalizedCompletedTransfers++ + continue + } + } + } catch { + // Lookup errors never prove absence of debit and must not cancel. + } + } + reportWithdrawalSendUncertainty(recordId, "legacy_send_outcome_unconfirmed") + unresolvedSendOutcomes++ + continue + } + + const transferId = withdrawal.bridgeTransferId + if (!transferId) continue + const bridgeTransferId = toBridgeTransferId(transferId) + + if (withdrawal.status === "send_failed") { + try { + await BridgeApiClient.deleteTransfer(bridgeTransferId) + cancelledSendFailedTransfers++ + } catch (error) { + bridgeTransferWithoutIbexSend++ + const reason = "Bridge transfer exists but IBEX crypto send failed" + await upsertBridgeReconciliationOrphan({ + orphanKey: toOrphanKey("withdrawal-send-failed", transferId), + orphanType: "bridge_transfer_without_ibex_send", + transferId, amount: withdrawal.amount, currency: withdrawal.currency, - }, - }) + triageContext: { + reason, + windowStart: since.toISOString(), + windowEnd: now.toISOString(), + operation: "cancel_proven_not_dispatched", + outcome: "cancel_unconfirmed", + }, + }) + alertIbexReconciliationOrphan({ + orphanType: "bridge_transfer_without_ibex_send", + transferId, + reason, + context: { + account_id: withdrawal.accountId, + amount: withdrawal.amount, + currency: withdrawal.currency, + }, + }) + } + continue } - continue - } - const transfer = await BridgeApiClient.getTransfer(bridgeTransferId) - if (transfer.state === "payment_processed") { - const finalized = await BridgeAccountsRepo.updateWithdrawalStatus( - bridgeTransferId, - "completed", - ) - if (!(finalized instanceof Error)) finalizedCompletedTransfers++ - continue - } + const transfer = await BridgeApiClient.getTransfer(bridgeTransferId) + if (transfer.state === "payment_processed") { + const finalized = await BridgeAccountsRepo.updateWithdrawalStatus( + bridgeTransferId, + "completed", + ) + if (!(finalized instanceof Error)) finalizedCompletedTransfers++ + continue + } - if (!WITHDRAWAL_TERMINAL_FAILURE_STATES.has(transfer.state)) continue + if (!WITHDRAWAL_TERMINAL_FAILURE_STATES.has(transfer.state)) continue - ibexSendWithoutBridgeSettlement++ - const reason = `IBEX crypto send succeeded but Bridge transfer is ${transfer.state}` - await upsertBridgeReconciliationOrphan({ - orphanKey: toOrphanKey("withdrawal-ibex-sent", transferId), - orphanType: "ibex_send_without_bridge_settlement", - transferId, - customerId: transfer.on_behalf_of, - amount: withdrawal.amount, - currency: withdrawal.currency, - triageContext: { - reason, - windowStart: since.toISOString(), - windowEnd: now.toISOString(), - accountId: withdrawal.accountId, - ibexPayoutId: withdrawal.ibexPayoutId, - bridgeState: transfer.state, - }, - }) - alertIbexReconciliationOrphan({ - orphanType: "ibex_send_without_bridge_settlement", - transferId, - reason, - context: { - account_id: withdrawal.accountId, - ibex_payout_id: withdrawal.ibexPayoutId, - bridge_state: transfer.state, + ibexSendWithoutBridgeSettlement++ + const reason = `IBEX crypto send succeeded but Bridge transfer is ${transfer.state}` + await upsertBridgeReconciliationOrphan({ + orphanKey: toOrphanKey("withdrawal-ibex-sent", transferId), + orphanType: "ibex_send_without_bridge_settlement", + transferId, + customerId: transfer.on_behalf_of, amount: withdrawal.amount, currency: withdrawal.currency, - }, - }) + triageContext: { + reason, + windowStart: since.toISOString(), + windowEnd: now.toISOString(), + accountId: withdrawal.accountId, + ibexPayoutId: withdrawal.ibexPayoutId, + bridgeState: transfer.state, + }, + }) + alertIbexReconciliationOrphan({ + orphanType: "ibex_send_without_bridge_settlement", + transferId, + reason, + context: { + account_id: withdrawal.accountId, + ibex_payout_id: withdrawal.ibexPayoutId, + bridge_state: transfer.state, + amount: withdrawal.amount, + currency: withdrawal.currency, + }, + }) + } finally { + // Separate review progress provides a fair bounded cross-age scan; + // never change status/updatedAt or authorize a retry as bookkeeping. + if (recordId) { + const reviewed = + await BridgeAccountsRepo.noteWithdrawalCryptoSendReviewed(recordId) + if (reviewed instanceof Error) + reportWithdrawalSendUncertainty(recordId, "review_progress_unconfirmed") + } + } } const summary = { @@ -354,6 +414,7 @@ export const reconcileBridgeAndIbexWithdrawals = async ({ finalizedCompletedTransfers, ibexSendWithoutBridgeSettlement, bridgeTransferWithoutIbexSend, + unresolvedSendOutcomes, } baseLogger.info(summary, "Bridge withdrawal reconciliation completed") diff --git a/src/services/bridge/webhook-server/routes/transfer.ts b/src/services/bridge/webhook-server/routes/transfer.ts index 44b5c1c92..31f1ea6ea 100644 --- a/src/services/bridge/webhook-server/routes/transfer.ts +++ b/src/services/bridge/webhook-server/routes/transfer.ts @@ -8,6 +8,7 @@ import { sendBridgeWithdrawalNotificationBestEffort } from "@app/bridge/send-wit import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" import { LockService } from "@services/lock" import { baseLogger } from "@services/logger" +import { BridgeWithdrawalCompletionProofRequiredError } from "@domain/bridge/withdrawal-outcome" import { toBridgeTransferId } from "@domain/primitives/bridge" import { writeBridgeCashoutCompleted, @@ -102,9 +103,14 @@ export const transferHandler = async (req: Request, res: Response) => { const result = await BridgeAccountsRepo.updateWithdrawalStatus( bridgeTransferId, "completed", + undefined, + state === "payment_processed" ? "payment_processed" : undefined, ) if (result instanceof Error) { + if (result instanceof BridgeWithdrawalCompletionProofRequiredError) { + return res.status(200).json({ status: "pending_review" }) + } if (result.message === BridgeAccountsRepo.BRIDGE_WITHDRAWAL_NOT_FOUND) { baseLogger.warn( { transfer_id }, diff --git a/src/services/bridge/withdrawal-send-uncertainty.ts b/src/services/bridge/withdrawal-send-uncertainty.ts new file mode 100644 index 000000000..9217091fd --- /dev/null +++ b/src/services/bridge/withdrawal-send-uncertainty.ts @@ -0,0 +1,44 @@ +import { createHash } from "crypto" + +import { alertBridge } from "@services/alerts" +import { baseLogger } from "@services/logger" + +type Reason = + | "unresolved_prior_send" + | "attempt_reservation_unconfirmed" + | "provider_outcome_unconfirmed" + | "provider_pending_confirmation" + | "review_progress_unconfirmed" + | "send_receipt_unconfirmed" + | "legacy_send_outcome_unconfirmed" + +export const reportWithdrawalSendUncertainty = ( + withdrawalId: string, + reason: Reason, +): void => { + // A stable one-way reference permits operator correlation without exporting + // account IDs, addresses, amounts, provider responses or credential material. + const reference = createHash("sha256") + .update(`bridge-send:${withdrawalId}`) + .digest("hex") + baseLogger.error( + { operation: "bridge_withdrawal_send", reason, reference }, + "Withdrawal send requires manual review", + ) + try { + alertBridge({ + source: "ibex", + severity: "warning", + dedupKey: `bridge-withdrawal-send:${reference}`, + title: "Bridge withdrawal send requires review", + detail: + "A withdrawal send has an unresolved outcome. Do not retry or cancel automatically.", + context: { reason, reference }, + }) + } catch { + baseLogger.error( + { operation: "bridge_withdrawal_send", outcome: "alert_unavailable" }, + "Withdrawal review alert unavailable", + ) + } +} diff --git a/src/services/graphql-trace-privacy.ts b/src/services/graphql-trace-privacy.ts index 7ef20fb18..6f0adfc0c 100644 --- a/src/services/graphql-trace-privacy.ts +++ b/src/services/graphql-trace-privacy.ts @@ -80,6 +80,7 @@ const errorCodes = new Set([ "BRIDGE_WITHDRAWAL_NET_AMOUNT_TOO_LOW", "BRIDGE_WITHDRAWAL_NOT_FOUND", "BRIDGE_WITHDRAWAL_ALREADY_INITIATED", + "BRIDGE_WITHDRAWAL_SEND_OUTCOME_UNKNOWN", "BRIDGE_RATE_LIMIT", "BRIDGE_TIMEOUT", "BRIDGE_TRANSFER_FAILED", diff --git a/src/services/mongoose/bridge-accounts.ts b/src/services/mongoose/bridge-accounts.ts index b14947777..91843c058 100644 --- a/src/services/mongoose/bridge-accounts.ts +++ b/src/services/mongoose/bridge-accounts.ts @@ -4,6 +4,7 @@ import { BridgeTransferId, } from "@domain/primitives/bridge" import { RepositoryError } from "@domain/errors" +import { BridgeWithdrawalCompletionProofRequiredError } from "@domain/bridge/withdrawal-outcome" import { BridgeVirtualAccount, BridgeExternalAccount, BridgeWithdrawal } from "./schema" @@ -384,27 +385,131 @@ export const updateWithdrawalTransferId = async ( } } -export const updateWithdrawalOnchainSend = async ( +// This primary/majority read also quarantines historical rows whose prior +// adapter error or process crash cannot prove that no debit occurred. +export const findUnresolvedWithdrawalSendByAccountId = async (accountId: string) => { + try { + return await BridgeWithdrawal.findOne( + { + accountId: { $eq: accountId }, + $or: [ + { status: { $in: ["submitted", "send_unknown"] } }, + { status: "send_failed", cryptoSendState: { $ne: "not_dispatched" } }, + { status: "usdt_sent", $or: [{ ibexPayoutId: null }, { ibexPayoutId: "" }] }, + { + cryptoSendAttemptId: { $exists: true }, + status: { $nin: ["usdt_sent", "completed"] }, + }, + ], + }, + undefined, + { readPreference: "primary", readConcern: { level: "majority" } }, + ) + } catch { + return new RepositoryError("Unable to verify unresolved withdrawal sends") + } +} + +export const reserveWithdrawalCryptoSendAttempt = async (args: { + id: string + accountId: string + bridgeTransferId: string + attemptId: string + sourceWalletId: string + cryptoSendInfoId: string + amount: string +}) => { + try { + // Fail closed if the required unique index cannot be created/verified. + const indexName = await BridgeWithdrawal.collection.createIndex( + { accountId: 1 }, + { + unique: true, + name: "bridge_withdrawal_unresolved_send", + partialFilterExpression: { status: "send_unknown" }, + commitQuorum: "majority", + }, + ) + if (indexName !== "bridge_withdrawal_unresolved_send") + return new RepositoryError("Withdrawal send fence index unavailable") + const record = await BridgeWithdrawal.findOneAndUpdate( + { + _id: { $eq: args.id }, + accountId: { $eq: args.accountId }, + bridgeTransferId: { $eq: args.bridgeTransferId }, + status: "submitted", + ibexPayoutId: { $exists: false }, + cryptoSendAttemptId: { $exists: false }, + cryptoSendState: { $exists: false }, + }, + { + $set: { + status: "send_unknown", + cryptoSendState: "attempted", + cryptoSendAttemptId: args.attemptId, + cryptoSendAttemptedAt: new Date(), + cryptoSendSourceWalletId: args.sourceWalletId, + cryptoSendInfoId: args.cryptoSendInfoId, + cryptoSendAmount: args.amount, + updatedAt: new Date(), + }, + }, + { new: true, writeConcern: { w: "majority", j: true } }, + ) + return record || new RepositoryError("Withdrawal send attempt is already reserved") + } catch { + return new RepositoryError("Unable to reserve withdrawal send attempt") + } +} + +export const recordWithdrawalCryptoSendReceipt = async ( id: string, - ibexPayoutId: string | undefined, - ibexTxHash?: string, + ibexPayoutId: string, + ibexTxHash: string | undefined, + attemptId: string, ) => { + if (!ibexPayoutId || !attemptId) + return new RepositoryError("Missing withdrawal send receipt") try { const update: Record = { - status: "usdt_sent", + // Receipt of a provider ID is not proof that the send settled. + status: "send_unknown", updatedAt: new Date(), } if (ibexPayoutId) update.ibexPayoutId = ibexPayoutId if (ibexTxHash) update.ibexTxHash = ibexTxHash const record = await BridgeWithdrawal.findOneAndUpdate( - { _id: { $eq: id }, status: "submitted", ibexPayoutId: { $exists: false } }, + { + _id: { $eq: id }, + status: "send_unknown", + cryptoSendState: "attempted", + cryptoSendAttemptId: { $eq: attemptId }, + ibexPayoutId: { $exists: false }, + }, update, { new: true }, ) - return record || new RepositoryError("Withdrawal not found or already sent") - } catch (error) { - return new RepositoryError(String(error)) + return ( + record || + new RepositoryError("Withdrawal send receipt unavailable or already recorded") + ) + } catch { + return new RepositoryError("Unable to record withdrawal send receipt") + } +} + +export const noteWithdrawalCryptoSendReviewed = async (id: string) => { + try { + await BridgeWithdrawal.updateOne( + { + _id: { $eq: id }, + status: { $in: ["submitted", "send_unknown", "send_failed", "usdt_sent"] }, + }, + { $set: { cryptoSendReviewedAt: new Date() } }, + ) + } catch { + return new RepositoryError("Unable to record withdrawal review progress") } } @@ -418,13 +523,20 @@ export const updateWithdrawalSendFailed = async ( ) => { try { const record = await BridgeWithdrawal.findOneAndUpdate( - { _id: { $eq: id }, status: "submitted", ibexPayoutId: { $exists: false } }, + { + _id: { $eq: id }, + status: "submitted", + ibexPayoutId: { $exists: false }, + cryptoSendAttemptId: { $exists: false }, + cryptoSendState: { $exists: false }, + }, { bridgeTransferId, amount, currency, bridgeDepositAddress, status: "send_failed", + cryptoSendState: "not_dispatched", failureReason: truncateBridgeFailureReason(failureReason), updatedAt: new Date(), }, @@ -451,6 +563,7 @@ export const updateWithdrawalStatus = async ( bridgeTransferId: BridgeTransferId, status: "pending" | "completed" | "failed", failureReason?: string, + completionProof?: "payment_processed", ) => { try { const truncatedReason = truncateBridgeFailureReason(failureReason) @@ -462,7 +575,12 @@ export const updateWithdrawalStatus = async ( const record = await BridgeWithdrawal.findOneAndUpdate( { bridgeTransferId: { $eq: bridgeTransferId }, - status: { $in: ["submitted", "usdt_sent"] }, + status: { + $in: + status === "completed" && completionProof === "payment_processed" + ? ["submitted", "usdt_sent", "send_unknown", "send_failed"] + : ["submitted", "usdt_sent"], + }, }, update, { new: true }, @@ -476,6 +594,13 @@ export const updateWithdrawalStatus = async ( // Idempotent: duplicate webhook after we already reached this terminal status. if (existing.status === status) return existing + if ( + status === "completed" && + completionProof !== "payment_processed" && + (existing.status === "send_unknown" || existing.status === "send_failed") + ) { + return new BridgeWithdrawalCompletionProofRequiredError() + } return new RepositoryError( `Withdrawal already ${existing.status}, cannot transition to ${status}`, diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index 60a947bd4..87779eb31 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -52,6 +52,13 @@ interface IBridgeWithdrawalRecord { bridgeDepositAddress?: string ibexPayoutId?: string ibexTxHash?: string + cryptoSendState?: "not_dispatched" | "attempted" + cryptoSendAttemptId?: string + cryptoSendAttemptedAt?: Date + cryptoSendReviewedAt?: Date + cryptoSendSourceWalletId?: string + cryptoSendInfoId?: string + cryptoSendAmount?: string amount: string currency: string status: @@ -59,6 +66,7 @@ interface IBridgeWithdrawalRecord { | "submitted" | "usdt_sent" | "send_failed" + | "send_unknown" | "completed" | "failed" | "cancelled" @@ -778,6 +786,13 @@ const BridgeWithdrawalSchema = new Schema({ bridgeDepositAddress: { type: String }, ibexPayoutId: { type: String, unique: true, sparse: true }, ibexTxHash: { type: String }, + cryptoSendState: { type: String, enum: ["not_dispatched", "attempted"] }, + cryptoSendAttemptId: { type: String }, + cryptoSendAttemptedAt: { type: Date }, + cryptoSendReviewedAt: { type: Date }, + cryptoSendSourceWalletId: { type: String }, + cryptoSendInfoId: { type: String }, + cryptoSendAmount: { type: String }, amount: { type: String, required: true }, currency: { type: String, required: true }, status: { @@ -787,6 +802,7 @@ const BridgeWithdrawalSchema = new Schema({ "submitted", "usdt_sent", "send_failed", + "send_unknown", "completed", "failed", "cancelled", @@ -821,6 +837,19 @@ BridgeWithdrawalSchema.index( }, ) +// One unresolved dispatch per account, including concurrent distinct requests. +// The repository also creates/verifies this exact index before any dispatch. +BridgeWithdrawalSchema.index( + { accountId: 1 }, + { + unique: true, + name: "bridge_withdrawal_unresolved_send", + partialFilterExpression: { status: "send_unknown" }, + }, +) + +BridgeWithdrawalSchema.index({ status: 1, cryptoSendReviewedAt: 1, _id: 1 }) + const BridgeDepositsSchema = new Schema({ eventId: { type: String, required: true, unique: true }, transferId: { type: String, required: true }, diff --git a/test/flash/unit/graphql/bridge-error-map.spec.ts b/test/flash/unit/graphql/bridge-error-map.spec.ts index 4687c0a06..5b667c82b 100644 --- a/test/flash/unit/graphql/bridge-error-map.spec.ts +++ b/test/flash/unit/graphql/bridge-error-map.spec.ts @@ -21,6 +21,7 @@ import { BridgeTransferFailedError, BridgeWebhookValidationError, BridgeWithdrawalNetAmountTooLowError, + BridgeWithdrawalSendOutcomeUnknownError, mapBridgeHttpError, } from "@services/bridge/errors" @@ -43,6 +44,10 @@ describe("error-map: Bridge errors", () => { [new BridgeCustomerNotFoundError(), "BRIDGE_CUSTOMER_NOT_FOUND"], [new BridgeInsufficientFundsError(), "BRIDGE_INSUFFICIENT_FUNDS"], [new BridgeWithdrawalNetAmountTooLowError(), "BRIDGE_WITHDRAWAL_NET_AMOUNT_TOO_LOW"], + [ + new BridgeWithdrawalSendOutcomeUnknownError(), + "BRIDGE_WITHDRAWAL_SEND_OUTCOME_UNKNOWN", + ], [new BridgeRateLimitError(), "BRIDGE_RATE_LIMIT"], [new BridgeTimeoutError(), "BRIDGE_TIMEOUT"], [new BridgeTransferFailedError(), "BRIDGE_TRANSFER_FAILED"], @@ -68,6 +73,16 @@ describe("error-map: Bridge errors", () => { expect(result.message).toBeTruthy() }) + it("keeps the pending-review message fixed even when an error carries private diagnostic text", () => { + const error = new BridgeWithdrawalSendOutcomeUnknownError() + error.message = "PRIVATE_PROVIDER_BANK_BODY" + const mapped = mapAndParseErrorForGqlResponse(error) + expect(mapped.code).toBe("BRIDGE_WITHDRAWAL_SEND_OUTCOME_UNKNOWN") + expect(mapped.message).toMatch(/pending confirmation/) + expect(mapped.message).toMatch(/Do not submit it again/) + expect(mapped.message).not.toContain("PRIVATE_PROVIDER_BANK_BODY") + }) + it("keeps the KYC gate messages verbatim for the app to show", () => { expect(mapError(new BridgeKycEmailNotVerifiedError()).message).toBe( "Verify your email address before starting identity verification.", diff --git a/test/flash/unit/graphql/public/root/mutation/bridge-withdrawal.spec.ts b/test/flash/unit/graphql/public/root/mutation/bridge-withdrawal.spec.ts index 4f802d4a5..65399db92 100644 --- a/test/flash/unit/graphql/public/root/mutation/bridge-withdrawal.spec.ts +++ b/test/flash/unit/graphql/public/root/mutation/bridge-withdrawal.spec.ts @@ -25,6 +25,7 @@ import BridgeCancelWithdrawalRequestMutation from "@graphql/public/root/mutation import { BridgeWithdrawalNotFoundError, BridgeWithdrawalAlreadyInitiatedError, + BridgeWithdrawalSendOutcomeUnknownError, } from "@services/bridge/errors" // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -150,6 +151,24 @@ describe("bridgeInitiateWithdrawal resolver", () => { expect(result?.withdrawal?.bridgeTransferId).toBe(TRANSFER_ID) }) + it("returns a stable pending-review error and no success payload for an uncertain send", async () => { + ;(BridgeService.initiateWithdrawal as jest.Mock).mockResolvedValue( + new BridgeWithdrawalSendOutcomeUnknownError(), + ) + const result = await resolveBridgeMutation( + BridgeInitiateWithdrawalMutation as BridgeWithdrawalMutation, + { withdrawalId: WITHDRAWAL_ID }, + ) + expect(result.errors).toEqual([ + { + code: "BRIDGE_WITHDRAWAL_SEND_OUTCOME_UNKNOWN", + message: + "Withdrawal is pending confirmation. Do not submit it again; check its status or contact support.", + }, + ]) + expect(result.withdrawal).toBeUndefined() + }) + it("maps BridgeWithdrawalNotFoundError to BRIDGE_WITHDRAWAL_NOT_FOUND when ID is missing or wrong-owner", async () => { ;(BridgeService.initiateWithdrawal as jest.Mock).mockResolvedValue( new BridgeWithdrawalNotFoundError(), diff --git a/test/flash/unit/services/bridge/index.spec.ts b/test/flash/unit/services/bridge/index.spec.ts index 25edf5d6c..f639ae3fa 100644 --- a/test/flash/unit/services/bridge/index.spec.ts +++ b/test/flash/unit/services/bridge/index.spec.ts @@ -1,3 +1,5 @@ +jest.mock("@services/alerts", () => ({ alertBridge: jest.fn() })) + import crypto from "crypto" jest.mock("@services/tracing", () => ({ @@ -62,10 +64,12 @@ jest.mock("@services/mongoose/bridge-accounts", () => ({ bridgeWithdrawalRecordId: jest.requireActual("@services/mongoose/bridge-accounts") .bridgeWithdrawalRecordId, updateWithdrawalTransferId: jest.fn(), + findUnresolvedWithdrawalSendByAccountId: jest.fn(), + reserveWithdrawalCryptoSendAttempt: jest.fn(), findWithdrawalById: jest.fn(), findWithdrawalsByAccountId: jest.fn(), cancelWithdrawal: jest.fn(), - updateWithdrawalOnchainSend: jest.fn(), + recordWithdrawalCryptoSendReceipt: jest.fn(), updateWithdrawalSendFailed: jest.fn(), })) @@ -151,6 +155,9 @@ jest.mock("@services/cache", () => ({ }), })) +import { alertBridge } from "@services/alerts" +import { baseLogger } from "@services/logger" + import BridgeService, { deriveWithdrawalIdempotencyKey } from "@services/bridge" import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" import BridgeClient from "@services/bridge/client" @@ -272,6 +279,16 @@ const expectSuccess = (result: T | Error): T => { /** Sets up the guards common to requestWithdrawal and initiateWithdrawal. */ const setupGuards = () => { + ;( + BridgeAccountsRepo.findUnresolvedWithdrawalSendByAccountId as jest.Mock + ).mockResolvedValue(null) + ;( + BridgeAccountsRepo.reserveWithdrawalCryptoSendAttempt as jest.Mock + ).mockImplementation(async (args) => ({ + ...makeRow(args.id), + status: "send_unknown", + cryptoSendAttemptId: args.attemptId, + })) ;(AccountsRepository as jest.Mock).mockReturnValue({ findById: jest.fn().mockResolvedValue(mockAccount), update: jest.fn(), @@ -324,7 +341,7 @@ const setupGuards = () => { subtotalAmount: "48.90", finalAmount: "48.90", }) - ;(BridgeAccountsRepo.updateWithdrawalOnchainSend as jest.Mock).mockResolvedValue({ + ;(BridgeAccountsRepo.recordWithdrawalCryptoSendReceipt as jest.Mock).mockResolvedValue({ ...makeRow(WITHDRAWAL_ID), bridgeTransferId: TRANSFER_ID, bridgeDepositAddress: BRIDGE_DEPOSIT_ADDRESS, @@ -333,7 +350,7 @@ const setupGuards = () => { subtotalAmount: "48.90", finalAmount: "48.90", ibexPayoutId: IBEX_PAYOUT_ID, - status: "usdt_sent" as const, + status: "send_unknown" as const, }) ;(BridgeClient.createTransfer as jest.Mock).mockResolvedValue(mockTransfer) ;(IbexClient.sendOnchain as jest.Mock).mockResolvedValue({ @@ -1196,68 +1213,35 @@ describe("initiateWithdrawal — takes withdrawalId (step 2A)", () => { finalAmount: "48.90", }, ) - expect(BridgeAccountsRepo.updateWithdrawalOnchainSend).toHaveBeenCalledWith( + expect(BridgeAccountsRepo.recordWithdrawalCryptoSendReceipt).toHaveBeenCalledWith( WITHDRAWAL_ID, IBEX_PAYOUT_ID, undefined, + expect.any(String), ) - expect(expectSuccess(result)).toMatchObject({ - status: "usdt_sent", - bridgeTransferId: TRANSFER_ID, - flashFeeIsEstimate: false, - bridgeDeveloperFee: "1.00", - bridgeExchangeFee: "0.10", - }) + expect((result as Error).name).toBe("BridgeWithdrawalSendOutcomeUnknownError") }) - it("preserves successful IBEX sends that do not expose a transaction id", async () => { + it("quarantines an acknowledgment without a usable payout id", async () => { ;(IbexClient.sendCrypto as jest.Mock).mockResolvedValue({ status: "PENDING", accepted: true, }) - ;(BridgeAccountsRepo.updateWithdrawalOnchainSend as jest.Mock).mockResolvedValue({ - ...makeRow(WITHDRAWAL_ID), - bridgeTransferId: TRANSFER_ID, - bridgeDepositAddress: BRIDGE_DEPOSIT_ADDRESS, - ibexPayoutId: undefined, - status: "usdt_sent" as const, - }) - const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) - + expect((result as Error).name).toBe("BridgeWithdrawalSendOutcomeUnknownError") expect(BridgeAccountsRepo.updateWithdrawalSendFailed).not.toHaveBeenCalled() - expect(BridgeAccountsRepo.updateWithdrawalOnchainSend).toHaveBeenCalledWith( - WITHDRAWAL_ID, - undefined, - undefined, - ) - expect(expectSuccess(result)).toMatchObject({ - status: "usdt_sent", - bridgeTransferId: TRANSFER_ID, - }) + expect(BridgeAccountsRepo.recordWithdrawalCryptoSendReceipt).not.toHaveBeenCalled() }) - it("marks the withdrawal send_failed when IBEX send fails after Bridge transfer creation", async () => { - const ibexError = new Error("ibex unavailable") - ;(IbexClient.sendCrypto as jest.Mock).mockResolvedValue(ibexError) - ;(BridgeAccountsRepo.updateWithdrawalSendFailed as jest.Mock).mockResolvedValue({ - ...makeRow(WITHDRAWAL_ID), - bridgeTransferId: TRANSFER_ID, - status: "send_failed" as const, - failureReason: ibexError.message, - }) - - const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) - - expect(result).toBe(ibexError) - expect(BridgeAccountsRepo.updateWithdrawalSendFailed).toHaveBeenCalledWith( - WITHDRAWAL_ID, - TRANSFER_ID, - AMOUNT, - "usd", - BRIDGE_DEPOSIT_ADDRESS, - ibexError.message, + it("keeps a returned send error uncertain after the durable fence", async () => { + ;(IbexClient.sendCrypto as jest.Mock).mockResolvedValue( + new Error("PRIVATE_PROVIDER_ERROR"), ) + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + expect((result as Error).name).toBe("BridgeWithdrawalSendOutcomeUnknownError") + expect((result as Error).message).not.toContain("PRIVATE_PROVIDER_ERROR") + expect(BridgeAccountsRepo.reserveWithdrawalCryptoSendAttempt).toHaveBeenCalledTimes(1) + expect(BridgeAccountsRepo.updateWithdrawalSendFailed).not.toHaveBeenCalled() }) it("marks the withdrawal send_failed when IBEX send info creation fails", async () => { @@ -1281,10 +1265,153 @@ describe("initiateWithdrawal — takes withdrawalId (step 2A)", () => { AMOUNT, "usd", BRIDGE_DEPOSIT_ADDRESS, - ibexError.message, + "IBEX crypto send requirements unavailable before dispatch", ) }) + it("retains the durable fence after a thrown send, without marking failed or exposing provider text", async () => { + ;(IbexClient.sendCrypto as jest.Mock).mockRejectedValue( + new Error("PRIVATE_SEND_TIMEOUT_BODY"), + ) + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + expect((result as Error).name).toBe("BridgeWithdrawalSendOutcomeUnknownError") + expect(BridgeAccountsRepo.updateWithdrawalSendFailed).not.toHaveBeenCalled() + expect(BridgeAccountsRepo.recordWithdrawalCryptoSendReceipt).not.toHaveBeenCalled() + expect(BridgeAccountsRepo.reserveWithdrawalCryptoSendAttempt).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: ACCOUNT_ID, + sourceWalletId: USDT_WALLET_ID, + cryptoSendInfoId: IBEX_CRYPTO_SEND_INFO_ID, + amount: AMOUNT, + }), + ) + expect( + jest.mocked(BridgeAccountsRepo.reserveWithdrawalCryptoSendAttempt).mock + .invocationCallOrder[0], + ).toBeLessThan(jest.mocked(IbexClient.sendCrypto).mock.invocationCallOrder[0]) + const diagnostics = JSON.stringify({ + alerts: jest.mocked(alertBridge).mock.calls, + errors: jest.mocked(baseLogger.error).mock.calls, + }) + for (const value of [ + "PRIVATE_SEND_TIMEOUT_BODY", + ACCOUNT_ID, + BRIDGE_DEPOSIT_ADDRESS, + USDT_WALLET_ID, + ]) + expect(diagnostics).not.toContain(value) + expect(alertBridge).toHaveBeenCalledWith( + expect.objectContaining({ + context: { + reason: "provider_outcome_unconfirmed", + reference: expect.stringMatching(/^[a-f0-9]{64}$/), + }, + }), + ) + }) + + it("does not dispatch if the attempt CAS/write acknowledgment fails", async () => { + ;( + BridgeAccountsRepo.reserveWithdrawalCryptoSendAttempt as jest.Mock + ).mockResolvedValue(new RepositoryError("PRIVATE_DATABASE_BODY")) + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + expect((result as Error).name).toBe("BridgeWithdrawalSendOutcomeUnknownError") + expect(IbexClient.sendCrypto).not.toHaveBeenCalled() + expect(BridgeAccountsRepo.updateWithdrawalSendFailed).not.toHaveBeenCalled() + }) + + it.each(["returned", "thrown"])( + "keeps acknowledged sends fenced after %s receipt persistence failure", + async (mode) => { + const update = BridgeAccountsRepo.recordWithdrawalCryptoSendReceipt as jest.Mock + if (mode === "returned") + update.mockResolvedValue(new RepositoryError("PRIVATE_DATABASE_BODY")) + else update.mockRejectedValue(new Error("PRIVATE_DATABASE_BODY")) + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + expect((result as Error).name).toBe("BridgeWithdrawalSendOutcomeUnknownError") + expect(IbexClient.sendCrypto).toHaveBeenCalledTimes(1) + expect(BridgeAccountsRepo.updateWithdrawalSendFailed).not.toHaveBeenCalled() + ;( + BridgeAccountsRepo.findUnresolvedWithdrawalSendByAccountId as jest.Mock + ).mockResolvedValue({ ...makeRow(WITHDRAWAL_ID), status: "send_unknown" }) + expect( + ((await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID)) as Error) + .name, + ).toBe("BridgeWithdrawalSendOutcomeUnknownError") + expect(IbexClient.sendCrypto).toHaveBeenCalledTimes(1) + }, + ) + + it.each(["submitted", "send_unknown", "send_failed"])( + "blocks new-row requests and initiation when an older %s send has no definitive outcome", + async (status) => { + ;( + BridgeAccountsRepo.findUnresolvedWithdrawalSendByAccountId as jest.Mock + ).mockResolvedValue({ ...makeRow("older-withdrawal"), status }) + const requested = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + const initiated = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + expect((requested as Error).name).toBe("BridgeWithdrawalSendOutcomeUnknownError") + expect((initiated as Error).name).toBe("BridgeWithdrawalSendOutcomeUnknownError") + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + expect(IbexClient.sendCrypto).not.toHaveBeenCalled() + }, + ) + + it("dispatches at most once for concurrent distinct rows on one account", async () => { + const heldAccounts = new Set() + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockImplementation( + async (id: string) => makeRow(id), + ) + ;( + BridgeAccountsRepo.reserveWithdrawalCryptoSendAttempt as jest.Mock + ).mockImplementation(async (args) => { + if (heldAccounts.has(args.accountId)) + return new RepositoryError("Withdrawal send attempt is already reserved") + heldAccounts.add(args.accountId) + return { status: "send_unknown", cryptoSendAttemptId: args.attemptId } + }) + const results = await Promise.all([ + BridgeService.initiateWithdrawal(ACCOUNT_ID, "distinct-one"), + BridgeService.initiateWithdrawal(ACCOUNT_ID, "distinct-two"), + ]) + expect(IbexClient.sendCrypto).toHaveBeenCalledTimes(1) + expect(heldAccounts.size).toBe(1) + expect(BridgeAccountsRepo.reserveWithdrawalCryptoSendAttempt).toHaveBeenCalledTimes(2) + expect(results.map((result) => (result as Error).name)).toEqual([ + "BridgeWithdrawalSendOutcomeUnknownError", + "BridgeWithdrawalSendOutcomeUnknownError", + ]) + }) + + it("fails closed before transfer/send when unresolved-send read fails", async () => { + ;( + BridgeAccountsRepo.findUnresolvedWithdrawalSendByAccountId as jest.Mock + ).mockResolvedValue( + new RepositoryError("Unable to verify unresolved withdrawal sends"), + ) + expect( + await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID), + ).toBeInstanceOf(RepositoryError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + expect(IbexClient.sendCrypto).not.toHaveBeenCalled() + }) + + it("quarantines explicit provider failure acknowledgments even when they contain an ID", async () => { + ;(IbexClient.sendCrypto as jest.Mock).mockResolvedValue({ + transaction: { id: IBEX_PAYOUT_ID, status: "FAILED" }, + }) + expect( + ((await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID)) as Error).name, + ).toBe("BridgeWithdrawalSendOutcomeUnknownError") + expect(BridgeAccountsRepo.recordWithdrawalCryptoSendReceipt).not.toHaveBeenCalled() + expect(BridgeAccountsRepo.updateWithdrawalSendFailed).not.toHaveBeenCalled() + }) + it("returns BridgeWithdrawalNotFoundError when the withdrawal ID does not exist", async () => { ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( new RepositoryError("Withdrawal not found"), @@ -1511,13 +1638,8 @@ describe("withdrawal request → confirm/cancel flow", () => { const pending = expectSuccess(requested) expect(pending).toMatchObject({ status: "pending", id: WITHDRAWAL_ID }) - const initiated = expectSuccess( - await BridgeService.initiateWithdrawal(ACCOUNT_ID, pending.id), - ) - expect(initiated).toMatchObject({ - status: "usdt_sent", - bridgeTransferId: TRANSFER_ID, - }) + const initiated = await BridgeService.initiateWithdrawal(ACCOUNT_ID, pending.id) + expect((initiated as Error).name).toBe("BridgeWithdrawalSendOutcomeUnknownError") expect(BridgeClient.createTransfer).toHaveBeenCalledTimes(1) expect(BridgeAccountsRepo.updateWithdrawalTransferId).toHaveBeenCalledWith( WITHDRAWAL_ID, @@ -1570,10 +1692,8 @@ describe("withdrawal request → confirm/cancel flow", () => { expect(firstPending).toMatchObject({ id: "deduped-withdrawal-001" }) expect(secondPending).toMatchObject({ id: "deduped-withdrawal-001" }) - const initiated = expectSuccess( - await BridgeService.initiateWithdrawal(ACCOUNT_ID, firstPending.id), - ) - expect(initiated.bridgeTransferId).toBe(TRANSFER_ID) + const initiated = await BridgeService.initiateWithdrawal(ACCOUNT_ID, firstPending.id) + expect((initiated as Error).name).toBe("BridgeWithdrawalSendOutcomeUnknownError") expect(BridgeAccountsRepo.updateWithdrawalTransferId).toHaveBeenCalledWith( "deduped-withdrawal-001", TRANSFER_ID, diff --git a/test/flash/unit/services/bridge/reconciliation.spec.ts b/test/flash/unit/services/bridge/reconciliation.spec.ts index d5c301772..1e15333dd 100644 --- a/test/flash/unit/services/bridge/reconciliation.spec.ts +++ b/test/flash/unit/services/bridge/reconciliation.spec.ts @@ -1,3 +1,5 @@ +jest.mock("@services/alerts", () => ({ alertBridge: jest.fn() })) + /** * Unit tests for Bridge↔IBEX reconciliation * Covers reconcileByTxHash (real-time) and reconcileBridgeAndIbexDeposits (batch) @@ -17,6 +19,7 @@ jest.mock("@services/mongoose/schema", () => ({ jest.mock("@services/mongoose/bridge-accounts", () => ({ updateWithdrawalStatus: jest.fn(), + noteWithdrawalCryptoSendReviewed: jest.fn(), })) jest.mock("@services/bridge/client", () => ({ @@ -482,13 +485,21 @@ describe("reconcileBridgeAndIbexDeposits", () => { }) describe("reconcileBridgeAndIbexWithdrawals", () => { - const makeWithdrawalFind = (withdrawals: unknown[]) => ({ - lean: () => ({ exec: () => Promise.resolve(withdrawals) }), - }) + const makeWithdrawalFind = (withdrawals: unknown[]) => { + const query = { + sort: jest.fn(() => query), + limit: jest.fn(() => query), + maxTimeMS: jest.fn(() => query), + lean: () => ({ exec: () => Promise.resolve(withdrawals) }), + } + return query + } - it("cancels Bridge transfers when IBEX crypto send failed", async () => { + it("cancels Bridge transfers only with explicit pre-dispatch failure proof", async () => { ;(BridgeWithdrawal.find as jest.Mock).mockReturnValue( - makeWithdrawalFind([BRIDGE_WITHDRAWAL_SEND_FAILED]), + makeWithdrawalFind([ + { ...BRIDGE_WITHDRAWAL_SEND_FAILED, cryptoSendState: "not_dispatched" }, + ]), ) const result = await reconcileBridgeAndIbexWithdrawals() @@ -500,6 +511,98 @@ describe("reconcileBridgeAndIbexWithdrawals", () => { expect(upsertBridgeReconciliationOrphan).not.toHaveBeenCalled() }) + it.each([ + { status: "send_failed" }, + { + status: "send_failed", + cryptoSendState: "not_dispatched", + cryptoSendAttemptId: "attempt", + }, + { status: "send_unknown", cryptoSendAttemptId: "attempt" }, + { status: "submitted" }, + ])("never cancels, retries or marks paid for uncertain row %p", async (fields) => { + ;(BridgeWithdrawal.find as jest.Mock).mockReturnValue( + makeWithdrawalFind([ + { + ...BRIDGE_WITHDRAWAL_SEND_FAILED, + ...fields, + updatedAt: new Date("2000-01-01"), + failureReason: "PRIVATE_PROVIDER_BODY", + }, + ]), + ) + const result = await reconcileBridgeAndIbexWithdrawals() + expect(result).not.toBeInstanceOf(Error) + expect(BridgeApiClient.deleteTransfer).not.toHaveBeenCalled() + expect(BridgeApiClient.getTransfer).toHaveBeenCalledTimes(1) + expect(BridgeAccountsRepo.updateWithdrawalStatus).not.toHaveBeenCalled() + expect(upsertBridgeReconciliationOrphan).not.toHaveBeenCalled() + }) + + it("does not age unresolved rows out of the scanner", async () => { + ;(BridgeWithdrawal.find as jest.Mock).mockReturnValue(makeWithdrawalFind([])) + await reconcileBridgeAndIbexWithdrawals() + const query = (BridgeWithdrawal.find as jest.Mock).mock.calls[0][0] + expect(query).toMatchObject({ + $or: expect.arrayContaining([ + { status: { $in: ["send_unknown", "send_failed", "usdt_sent"] } }, + ]), + }) + expect(query).not.toHaveProperty("updatedAt") + }) + + it("clears uncertainty only for an exact Bridge payment_processed receipt", async () => { + ;(BridgeWithdrawal.find as jest.Mock).mockReturnValue( + makeWithdrawalFind([{ ...BRIDGE_WITHDRAWAL_SEND_FAILED, status: "send_unknown" }]), + ) + ;(BridgeApiClient.getTransfer as jest.Mock).mockResolvedValue({ + id: "tr_withdrawal_002", + state: "payment_processed", + }) + const result = await reconcileBridgeAndIbexWithdrawals() + expect(result).toMatchObject({ finalizedCompletedTransfers: 1 }) + expect(BridgeAccountsRepo.updateWithdrawalStatus).toHaveBeenCalledWith( + "tr_withdrawal_002", + "completed", + undefined, + "payment_processed", + ) + expect(BridgeApiClient.deleteTransfer).not.toHaveBeenCalled() + }) + + it("never resolves an unknown send from a foreign transfer receipt", async () => { + ;(BridgeWithdrawal.find as jest.Mock).mockReturnValue( + makeWithdrawalFind([{ ...BRIDGE_WITHDRAWAL_SEND_FAILED, status: "send_unknown" }]), + ) + ;(BridgeApiClient.getTransfer as jest.Mock).mockResolvedValue({ + id: "foreign-transfer", + state: "payment_processed", + }) + expect(await reconcileBridgeAndIbexWithdrawals()).toMatchObject({ + finalizedCompletedTransfers: 0, + unresolvedSendOutcomes: 1, + }) + expect(BridgeAccountsRepo.updateWithdrawalStatus).not.toHaveBeenCalled() + expect(BridgeApiClient.deleteTransfer).not.toHaveBeenCalled() + expect(BridgeAccountsRepo.noteWithdrawalCryptoSendReviewed).toHaveBeenCalledWith( + "withdrawal_002", + ) + }) + + it("bounds the review batch and rotates review timestamps without aging uncertainty out", async () => { + const query = makeWithdrawalFind([ + { ...BRIDGE_WITHDRAWAL_SEND_FAILED, status: "send_unknown" }, + ]) + ;(BridgeWithdrawal.find as jest.Mock).mockReturnValue(query) + await reconcileBridgeAndIbexWithdrawals() + expect(query.sort).toHaveBeenCalledWith({ cryptoSendReviewedAt: 1, _id: 1 }) + expect(query.limit).toHaveBeenCalledWith(100) + expect(query.maxTimeMS).toHaveBeenCalledWith(5_000) + expect(BridgeAccountsRepo.noteWithdrawalCryptoSendReviewed).toHaveBeenCalledWith( + "withdrawal_002", + ) + }) + it("self-heals a sent withdrawal when Bridge already reports payment_processed", async () => { ;(BridgeWithdrawal.find as jest.Mock).mockReturnValue( makeWithdrawalFind([BRIDGE_WITHDRAWAL_USDT_SENT]), diff --git a/test/flash/unit/services/bridge/return-shapes.spec.ts b/test/flash/unit/services/bridge/return-shapes.spec.ts index 284669b6f..b3cb884d4 100644 --- a/test/flash/unit/services/bridge/return-shapes.spec.ts +++ b/test/flash/unit/services/bridge/return-shapes.spec.ts @@ -62,7 +62,9 @@ jest.mock("@services/mongoose/bridge-accounts", () => ({ findWithdrawalsByAccountId: jest.fn(), findWithdrawalById: jest.fn(), updateWithdrawalTransferId: jest.fn(), - updateWithdrawalOnchainSend: jest.fn(), + findUnresolvedWithdrawalSendByAccountId: jest.fn(), + reserveWithdrawalCryptoSendAttempt: jest.fn(), + recordWithdrawalCryptoSendReceipt: jest.fn(), })) jest.mock("@services/bridge/client", () => ({ @@ -237,6 +239,15 @@ const setupGuards = () => { ], has_more: false, }) + ;( + BridgeAccountsRepo.findUnresolvedWithdrawalSendByAccountId as jest.Mock + ).mockResolvedValue(null) + ;( + BridgeAccountsRepo.reserveWithdrawalCryptoSendAttempt as jest.Mock + ).mockImplementation(async (args) => ({ + status: "send_unknown", + cryptoSendAttemptId: args.attemptId, + })) ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue(makeRow()) ;(BridgeAccountsRepo.updateWithdrawalTransferId as jest.Mock).mockResolvedValue({ ...makeRow(), @@ -247,7 +258,7 @@ const setupGuards = () => { finalAmount: "48.90", status: "submitted" as const, }) - ;(BridgeAccountsRepo.updateWithdrawalOnchainSend as jest.Mock).mockResolvedValue({ + ;(BridgeAccountsRepo.recordWithdrawalCryptoSendReceipt as jest.Mock).mockResolvedValue({ ...makeRow(), bridgeTransferId: TRANSFER_ID, bridgeDeveloperFee: "1.00", @@ -283,20 +294,10 @@ describe("initiateWithdrawal — BridgeWithdrawal GraphQL contract shape", () => setupGuards() }) - it("returns every NonNull field required by the BridgeWithdrawal GraphQL type", async () => { + it("returns an explicit pending-review error instead of an error-free success for a PENDING send", async () => { const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) - - expect(result).not.toBeInstanceOf(Error) - if (result instanceof Error) return - - expect(result.id).toBe(WITHDRAWAL_ID) - expect(result.amount).toBe(AMOUNT) - expect(result.currency).toBe("usdt") - expect(result.status).toBe("usdt_sent") - expect(result.createdAt).toBe(CREATED_AT.toISOString()) - expect(result.bridgeTransferId).toBe(TRANSFER_ID) - expect((result as Record).transferId).toBeUndefined() - expect((result as Record).state).toBeUndefined() + expect((result as Error).name).toBe("BridgeWithdrawalSendOutcomeUnknownError") + expect((result as Error).message).toMatch(/pending confirmation/) }) }) diff --git a/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts b/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts index 0c6aff7c2..6062177f0 100644 --- a/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts +++ b/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts @@ -21,6 +21,7 @@ jest.mock("@services/frappe/BridgeTransferRequestWriter", () => ({ })) import { Request, Response } from "express" +import { BridgeWithdrawalCompletionProofRequiredError } from "@domain/bridge/withdrawal-outcome" import { LockService } from "@services/lock" import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" import { sendBridgeWithdrawalNotificationBestEffort } from "@app/bridge/send-withdrawal-notification" @@ -107,6 +108,41 @@ describe("transferHandler", () => { expect(res.status as jest.Mock).toHaveBeenCalledWith(200) }) + it("acknowledges an event-name-only completion as pending review without clearing an unknown send", async () => { + updateFn.mockResolvedValue(new BridgeWithdrawalCompletionProofRequiredError()) + const res = makeRes() + await transferHandler( + makeReq({ + event_type: "transfer.completed", + event_object: { id: "tr-abc", state: "pending" }, + }), + res, + ) + expect(updateFn).toHaveBeenCalledWith("tr-abc", "completed", undefined, undefined) + expect(writeBridgeCashoutCompleted).not.toHaveBeenCalled() + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + expect(lockFn).not.toHaveBeenCalled() + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + expect(res.json as jest.Mock).toHaveBeenCalledWith({ status: "pending_review" }) + }) + + it("passes explicit provider state proof for a payment_processed completion", async () => { + const res = makeRes() + await transferHandler( + makeReq({ + event_type: "transfer.completed", + event_object: { id: "tr-abc", state: "payment_processed" }, + }), + res, + ) + expect(updateFn).toHaveBeenCalledWith( + "tr-abc", + "completed", + undefined, + "payment_processed", + ) + }) + it("does not acquire lock when status update fails", async () => { const { RepositoryError } = jest.requireActual("@domain/errors") updateFn.mockResolvedValue(new RepositoryError("mongo error")) diff --git a/test/flash/unit/services/mongoose/bridge-withdrawal-send-fence.spec.ts b/test/flash/unit/services/mongoose/bridge-withdrawal-send-fence.spec.ts new file mode 100644 index 000000000..6d42a2690 --- /dev/null +++ b/test/flash/unit/services/mongoose/bridge-withdrawal-send-fence.spec.ts @@ -0,0 +1,238 @@ +jest.mock("@services/mongoose/schema", () => ({ + BridgeWithdrawal: { + collection: { createIndex: jest.fn() }, + findOneAndUpdate: jest.fn(), + findOne: jest.fn(), + updateOne: jest.fn(), + }, +})) + +import { toBridgeTransferId } from "@domain/primitives/bridge" +import { BridgeWithdrawalCompletionProofRequiredError } from "@domain/bridge/withdrawal-outcome" +import { RepositoryError } from "@domain/errors" +import { + reserveWithdrawalCryptoSendAttempt, + findUnresolvedWithdrawalSendByAccountId, + recordWithdrawalCryptoSendReceipt, + updateWithdrawalSendFailed, + updateWithdrawalStatus, + noteWithdrawalCryptoSendReviewed, +} from "@services/mongoose/bridge-accounts" + +const model = jest.requireMock("@services/mongoose/schema").BridgeWithdrawal as { + collection: { createIndex: jest.Mock } + findOneAndUpdate: jest.Mock + findOne: jest.Mock + updateOne: jest.Mock +} +const attempt = { + id: "row", + accountId: "owner", + bridgeTransferId: "transfer", + attemptId: "attempt-one", + sourceWalletId: "source-wallet", + cryptoSendInfoId: "send-info", + amount: "50.00", +} + +beforeEach(() => { + jest.resetAllMocks() + model.collection.createIndex.mockResolvedValue("bridge_withdrawal_unresolved_send") + model.findOneAndUpdate.mockResolvedValue({ + id: "row", + status: "send_unknown", + cryptoSendAttemptId: "attempt-one", + }) +}) + +it("verifies the unique account fence and atomically journals immutable attempt data before dispatch", async () => { + expect(await reserveWithdrawalCryptoSendAttempt(attempt)).not.toBeInstanceOf(Error) + expect(model.collection.createIndex).toHaveBeenCalledWith( + { accountId: 1 }, + { + unique: true, + name: "bridge_withdrawal_unresolved_send", + partialFilterExpression: { status: "send_unknown" }, + commitQuorum: "majority", + }, + ) + expect(model.findOneAndUpdate).toHaveBeenCalledWith( + { + _id: { $eq: "row" }, + accountId: { $eq: "owner" }, + bridgeTransferId: { $eq: "transfer" }, + status: "submitted", + ibexPayoutId: { $exists: false }, + cryptoSendAttemptId: { $exists: false }, + cryptoSendState: { $exists: false }, + }, + { + $set: { + status: "send_unknown", + cryptoSendState: "attempted", + cryptoSendAttemptId: "attempt-one", + cryptoSendAttemptedAt: expect.any(Date), + cryptoSendSourceWalletId: "source-wallet", + cryptoSendInfoId: "send-info", + cryptoSendAmount: "50.00", + updatedAt: expect.any(Date), + }, + }, + { new: true, writeConcern: { w: "majority", j: true } }, + ) + expect(model.collection.createIndex.mock.invocationCallOrder[0]).toBeLessThan( + model.findOneAndUpdate.mock.invocationCallOrder[0], + ) +}) + +it.each(["rejected", "wrong-result"])( + "fails closed on index %s before any fence write", + async (kind) => { + if (kind === "rejected") + model.collection.createIndex.mockRejectedValue(new Error("PRIVATE_INDEX_ERROR")) + else model.collection.createIndex.mockResolvedValue("wrong-index") + const result = await reserveWithdrawalCryptoSendAttempt(attempt) + expect(result).toBeInstanceOf(RepositoryError) + expect((result as Error).message).not.toContain("PRIVATE_INDEX_ERROR") + expect(model.findOneAndUpdate).not.toHaveBeenCalled() + }, +) + +it.each(["no-match", "write-concern-error"])( + "never grants dispatch authority after %s", + async (kind) => { + if (kind === "no-match") model.findOneAndUpdate.mockResolvedValue(null) + else + model.findOneAndUpdate.mockRejectedValue(new Error("PRIVATE_MAJORITY_WRITE_ERROR")) + expect(await reserveWithdrawalCryptoSendAttempt(attempt)).toBeInstanceOf( + RepositoryError, + ) + expect(model.findOneAndUpdate).toHaveBeenCalledTimes(1) + }, +) + +it("admits only one distinct-row attempt for the same account under the unique index", async () => { + const heldAccounts = new Set() + model.findOneAndUpdate.mockImplementation( + async ( + query: { accountId: { $eq: string } }, + update: { $set: Record }, + ) => { + // Models the Mongo unique partial index's atomic constraint, without I/O. + if (heldAccounts.has(query.accountId.$eq)) throw new Error("E11000 duplicate key") + heldAccounts.add(query.accountId.$eq) + return { ...update.$set } + }, + ) + const results = await Promise.all([ + reserveWithdrawalCryptoSendAttempt(attempt), + reserveWithdrawalCryptoSendAttempt({ + ...attempt, + id: "other-row", + attemptId: "attempt-two", + }), + ]) + expect(results.filter((result) => result instanceof Error)).toHaveLength(1) + expect(results.filter((result) => !(result instanceof Error))).toHaveLength(1) + expect(heldAccounts.size).toBe(1) +}) + +it("blocks all unresolved historical classes using a primary majority read", async () => { + model.findOne.mockResolvedValue({ status: "send_failed" }) + expect(await findUnresolvedWithdrawalSendByAccountId("owner")).toMatchObject({ + status: "send_failed", + }) + expect(model.findOne).toHaveBeenCalledWith( + { + accountId: { $eq: "owner" }, + $or: expect.arrayContaining([ + { status: { $in: ["submitted", "send_unknown"] } }, + { status: "send_failed", cryptoSendState: { $ne: "not_dispatched" } }, + { status: "usdt_sent", $or: [{ ibexPayoutId: null }, { ibexPayoutId: "" }] }, + ]), + }, + undefined, + { readPreference: "primary", readConcern: { level: "majority" } }, + ) + model.findOne.mockRejectedValue(new Error("PRIVATE_READ_ERROR")) + expect(await findUnresolvedWithdrawalSendByAccountId("owner")).toMatchObject({ + message: "Unable to verify unresolved withdrawal sends", + }) +}) + +it("records a receipt only for the same attempt and never promotes it to sent/settled", async () => { + await recordWithdrawalCryptoSendReceipt("row", "payout", undefined, "attempt-one") + expect(model.findOneAndUpdate).toHaveBeenCalledWith( + { + _id: { $eq: "row" }, + status: "send_unknown", + cryptoSendState: "attempted", + cryptoSendAttemptId: { $eq: "attempt-one" }, + ibexPayoutId: { $exists: false }, + }, + { status: "send_unknown", ibexPayoutId: "payout", updatedAt: expect.any(Date) }, + { new: true }, + ) +}) + +it("keeps private receipt persistence errors inside the repository boundary", async () => { + model.findOneAndUpdate.mockRejectedValue(new Error("PRIVATE_PROVIDER_RECEIPT_BODY")) + const result = await recordWithdrawalCryptoSendReceipt( + "row", + "payout", + undefined, + "attempt-one", + ) + expect(result).toMatchObject({ message: "Unable to record withdrawal send receipt" }) +}) + +it("cannot overwrite any attempt fence with a definitive not-dispatched failure", async () => { + await updateWithdrawalSendFailed( + "row", + "transfer", + "50", + "usd", + "address", + "Fixed preflight failure", + ) + expect(model.findOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + status: "submitted", + cryptoSendAttemptId: { $exists: false }, + cryptoSendState: { $exists: false }, + }), + expect.objectContaining({ status: "send_failed", cryptoSendState: "not_dispatched" }), + { new: true }, + ) +}) + +it("allows only authoritative completion to clear unknown and leaves failed webhooks unable to clear it", async () => { + await updateWithdrawalStatus( + toBridgeTransferId("transfer"), + "completed", + undefined, + "payment_processed", + ) + expect(model.findOneAndUpdate.mock.calls[0][0].status.$in).toContain("send_unknown") + model.findOneAndUpdate.mockClear() + await updateWithdrawalStatus(toBridgeTransferId("transfer"), "failed") + expect(model.findOneAndUpdate.mock.calls[0][0].status.$in).not.toContain("send_unknown") +}) + +it("returns a typed review refusal rather than clearing uncertainty from an event-name-only completion", async () => { + model.findOneAndUpdate.mockResolvedValue(null) + model.findOne.mockResolvedValue({ status: "send_unknown" }) + expect( + await updateWithdrawalStatus(toBridgeTransferId("transfer"), "completed"), + ).toBeInstanceOf(BridgeWithdrawalCompletionProofRequiredError) + expect(model.findOneAndUpdate.mock.calls[0][0].status.$in).not.toContain("send_unknown") + expect(model.findOneAndUpdate.mock.calls[0][0].status.$in).not.toContain("send_failed") +}) + +it("rotates review position without changing status or send timestamps", async () => { + await noteWithdrawalCryptoSendReviewed("row") + expect(model.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ _id: { $eq: "row" } }), + { $set: { cryptoSendReviewedAt: expect.any(Date) } }, + ) +}) diff --git a/test/flash/unit/services/tracing-input-privacy.spec.ts b/test/flash/unit/services/tracing-input-privacy.spec.ts index a01adcd4b..22d922564 100644 --- a/test/flash/unit/services/tracing-input-privacy.spec.ts +++ b/test/flash/unit/services/tracing-input-privacy.spec.ts @@ -615,6 +615,7 @@ it.each([ "BANK_ACCOUNT_INVALID", "BANK_ACCOUNT_NOT_FOUND", "BRIDGE_KYC_PENDING", + "BRIDGE_WITHDRAWAL_SEND_OUTCOME_UNKNOWN", "BRIDGE_INSUFFICIENT_FUNDS", "BRIDGE_INVALID_PLAID_TOKEN", "DEVICE_TOTP_REQUIRES_PHONE_UPGRADE", From 0f414b6cf8516870cc5d714de563ac27ec8ec718 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 01:56:12 -0700 Subject: [PATCH 32/42] Guard ambiguous Bridge send recovery contracts --- docs/native-parity-unit-tests.md | 4 ++-- scripts/native-parity-unit.cjs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/native-parity-unit-tests.md b/docs/native-parity-unit-tests.md index a20e3fe0a..b4167175e 100644 --- a/docs/native-parity-unit-tests.md +++ b/docs/native-parity-unit-tests.md @@ -7,7 +7,7 @@ node scripts/native-parity-unit.cjs node --test scripts/native-parity-unit.test.cjs ``` -The first command runs the fixed, reviewed selection of 75 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. +The first command runs the fixed, reviewed selection of 76 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. The child receives only inherited `PATH` and `TMPDIR`, plus the explicit fixture values in `scripts/native-parity-unit.cjs`. Required secrets are unmistakable dummy strings. Network is `regtest`; configured service hosts point at numeric loopback port 9. `HOME`, cloud credentials/profiles, proxy variables, Node preloads, and inherited service/provider credentials are omitted. Jest's setup selects only committed `dev/config/base-config.yaml`; no private configuration file is loaded by the runner. Its required dummy environment exists only in the child process and must never be used for a deployment. @@ -23,6 +23,6 @@ Three narrow test boundaries now avoid real service initialization: The identity presigned-PUT test still uses the real pinned AWS SDK and sends the untouched signed URL with a nonempty PNG to its local HTTP fixture. The interlock permits that fixture. It emulates checksum/body acceptance, not production signature verification, and uses dummy credentials only. All Bridge, IBEX, notification, database and cache interactions elsewhere are mocked. -Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `0d7bfed1a` plus the reviewed runner changes: all **75 suites / 1180 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-post-auth-provider-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. +Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `7d0c5e362` plus the reviewed runner changes: all **76 suites / 1218 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-post-bridge-fence-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. These are offline unit checks. They do not establish provider access, sandbox interoperability, production configuration, bank-account provisioning, image storage acceptance by DigitalOcean, or deployment readiness. diff --git a/scripts/native-parity-unit.cjs b/scripts/native-parity-unit.cjs index ece096d96..34c297674 100644 --- a/scripts/native-parity-unit.cjs +++ b/scripts/native-parity-unit.cjs @@ -39,6 +39,7 @@ const suites = Object.freeze({ "services/bridge/webhook-server/verify-signature.spec.ts", "services/bridge/withdrawal-fees.spec.ts", "services/mongoose/accounts-bind-bridge-customer.spec.ts", + "services/mongoose/bridge-withdrawal-send-fence.spec.ts", "services/notification/wa-bridge.spec.ts", ], earn: ["app/payments/add-earn.spec.ts", "services/mongoose/rewards.spec.ts"], From 5433ffa831bc02bb18b06eaf68985b78c7f346a1 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 02:06:52 -0700 Subject: [PATCH 33/42] Scope CSV export to requested owned wallets --- docs/csv-wallet-scope.md | 37 ++++++++ src/app/accounts/get-csv-for-account.ts | 19 ++++- .../wallets/get-transactions-for-wallet.ts | 29 ++++++- .../public/types/object/business-account.ts | 6 +- .../public/types/object/consumer-account.ts | 6 +- .../public/types/object/csv-transactions.ts | 19 +++++ src/services/ledger/csv-wallet-export.ts | 24 ++++-- .../app/accounts/csv-wallet-scope.spec.ts | 85 +++++++++++++++++++ .../unit/graphql/csv-wallet-scope.spec.ts | 44 ++++++++++ .../csv-wallet-export-completeness.spec.ts | 72 ++++++++++++++++ 10 files changed, 323 insertions(+), 18 deletions(-) create mode 100644 docs/csv-wallet-scope.md create mode 100644 src/graphql/public/types/object/csv-transactions.ts create mode 100644 test/flash/unit/app/accounts/csv-wallet-scope.spec.ts create mode 100644 test/flash/unit/graphql/csv-wallet-scope.spec.ts create mode 100644 test/flash/unit/services/ledger/csv-wallet-export-completeness.spec.ts diff --git a/docs/csv-wallet-scope.md b/docs/csv-wallet-scope.md new file mode 100644 index 000000000..02a15414c --- /dev/null +++ b/docs/csv-wallet-scope.md @@ -0,0 +1,37 @@ +# Wallet-scoped transaction CSV + +The public `ConsumerAccount.csvTransactions` and `BusinessAccount.csvTransactions` +fields require `walletIds: [WalletId!]!`. Before this change, both resolvers +ignored that argument and exported every stored wallet in the account. A +selected-wallet download could therefore contain transactions from other +wallets in the same account. + +Both fields now require the authenticated account to match the source account +and pass the exact requested list to the application export service. That +service loads only stored wallets belonging to the account, rejects the entire +request if any requested ID is absent from that owned set, and uses the same +error for missing and foreign IDs. It deduplicates IDs in caller order. An empty +required list produces a header-only CSV and makes no IBEX transaction request. +Neither resolver expands a presented cash alias into an additional wallet ID. + +The CSV export now opts into a strict transaction read: an IBEX error for any +selected wallet or a provider row that the existing transaction mapper cannot +represent aborts the download. A thrown provider error returns fixed export +failure text. Ordinary paginated history retains its prior partial-read +behavior. This removes the false success where a failed wallet could produce a +plausible but incomplete CSV. + +This does **not** prove an exhaustive historical export. The existing IBEX +request uses `page: 0, limit: 0` without a continuation loop; the meaning of +`limit: 0` and large-history behavior still need provider acceptance. The +backend CSV writer also does not escape spreadsheet formulas. Native export +currently validates and escapes rows before sharing, but other consumers must +not treat this backend CSV as spreadsheet-safe. Those are separate release +checks. No provider call, account mutation or file download was made here. + +Validation uses mocked repositories and IBEX plus the guarded Node 24 unit +runner. **14 tests in three focused suites** pass; the inherited **76 suites / 1,218 tests** +also pass. All three TypeScript configurations, ESLint, Typos and diff checks +pass. Tests cover exact owned subset/order, duplicate IDs, empty selection, +uniform missing/foreign refusal, caller/account mismatch, error propagation, +provider failure, unrepresentable rows and preservation of ordinary history. diff --git a/src/app/accounts/get-csv-for-account.ts b/src/app/accounts/get-csv-for-account.ts index 00a82bc4d..33b25f764 100644 --- a/src/app/accounts/get-csv-for-account.ts +++ b/src/app/accounts/get-csv-for-account.ts @@ -1,15 +1,32 @@ +import { InvalidWalletForAccountError } from "@domain/accounts" import { CsvWalletsExport } from "@services/ledger/csv-wallet-export" import { WalletsRepository } from "@services/mongoose" export const getCSVForAccount = async ( accountId: AccountId, + walletIds: readonly WalletId[], ): Promise => { + if (!Array.isArray(walletIds)) return new InvalidWalletForAccountError() const wallets = await WalletsRepository().listByAccountId(accountId) if (wallets instanceof Error) return wallets + const ownedById = new Map( + wallets + .filter((wallet) => wallet.accountId === accountId) + .map((wallet) => [wallet.id, wallet]), + ) + const selected: Wallet[] = [] + for (const walletId of new Set(walletIds)) { + if (typeof walletId !== "string") return new InvalidWalletForAccountError() + const wallet = ownedById.get(walletId as WalletId) + if (!wallet) return new InvalidWalletForAccountError() + selected.push(wallet) + } + const csv = new CsvWalletsExport() - await csv.addWallets(wallets) + const error = await csv.addWallets(selected) + if (error instanceof Error) return error return csv.getBase64() } diff --git a/src/app/wallets/get-transactions-for-wallet.ts b/src/app/wallets/get-transactions-for-wallet.ts index f4dfdb186..d564975d0 100644 --- a/src/app/wallets/get-transactions-for-wallet.ts +++ b/src/app/wallets/get-transactions-for-wallet.ts @@ -1,7 +1,7 @@ import { PartialResult } from "@app/partial-result" import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" import Ibex from "@services/ibex/client" -import { IbexError } from "@services/ibex/errors" +import { IbexError, UnexpectedIbexResponse } from "@services/ibex/errors" import { baseLogger } from "@services/logger" import { GResponse200 } from "ibex-client" import { ConnectionArguments } from "graphql-relay" @@ -9,9 +9,11 @@ import { ConnectionArguments } from "graphql-relay" export const getTransactionsForWallets = async ({ wallets, paginationArgs, + requireComplete = false, }: { wallets: Wallet[] paginationArgs?: PaginationArgs + requireComplete?: boolean }): Promise>> => { const walletIds = wallets.map((wallet) => wallet.id) @@ -24,11 +26,34 @@ export const getTransactionsForWallets = async ({ ), ) - const transactions = ibexCalls.flatMap((resp) => { + // A download must not present a partial set of transactions as a complete + // financial record. Ordinary history retains its existing partial behavior. + if (requireComplete) { + const failed = ibexCalls.find((resp): resp is IbexError => resp instanceof IbexError) + if (failed) return PartialResult.err(failed) + } + + const converted = ibexCalls.map((resp) => { if (resp instanceof IbexError) return [] else return toWalletTransactions(resp) }) + // The usual history view may omit an unrepresentable vendor row. A CSV + // download cannot silently label that same omission a complete export. + if ( + requireComplete && + ibexCalls.some( + (resp, index) => + !(resp instanceof IbexError) && converted[index].length !== resp.length, + ) + ) { + return PartialResult.err( + new UnexpectedIbexResponse("CSV export has unrepresentable transactions"), + ) + } + + const transactions = converted.flat() + return PartialResult.ok({ slice: transactions, total: transactions.length, diff --git a/src/graphql/public/types/object/business-account.ts b/src/graphql/public/types/object/business-account.ts index 1fd023820..99f61b88a 100644 --- a/src/graphql/public/types/object/business-account.ts +++ b/src/graphql/public/types/object/business-account.ts @@ -31,6 +31,7 @@ import { TransactionConnection } from "../../../shared/types/object/transaction" import RealtimePrice from "./realtime-price" import { NotificationSettings } from "./notification-settings" +import { exportOwnedAccountCSV } from "./csv-transactions" const BusinessAccount = GT.Object({ name: "BusinessAccount", @@ -131,9 +132,8 @@ const BusinessAccount = GT.Object({ type: GT.NonNullList(WalletId), }, }, - resolve: async (source) => { - return Accounts.getCSVForAccount(source.id) - }, + resolve: async (source, { walletIds }, context) => + exportOwnedAccountCSV(source, walletIds, context), }, transactions: { description: diff --git a/src/graphql/public/types/object/consumer-account.ts b/src/graphql/public/types/object/consumer-account.ts index b4d89da60..c58657de0 100644 --- a/src/graphql/public/types/object/consumer-account.ts +++ b/src/graphql/public/types/object/consumer-account.ts @@ -38,6 +38,7 @@ import Quiz from "./quiz" import CallbackEndpoint from "./callback-endpoint" import { NotificationSettings } from "./notification-settings" import { ownedWalletSnapshotField } from "./owned-wallet-snapshot" +import { exportOwnedAccountCSV } from "./csv-transactions" const ConsumerAccount = GT.Object({ name: "ConsumerAccount", @@ -157,9 +158,8 @@ const ConsumerAccount = GT.Object({ type: GT.NonNullList(WalletId), }, }, - resolve: async (source) => { - return Accounts.getCSVForAccount(source.id) - }, + resolve: async (source, { walletIds }, context) => + exportOwnedAccountCSV(source, walletIds, context), }, limits: { diff --git a/src/graphql/public/types/object/csv-transactions.ts b/src/graphql/public/types/object/csv-transactions.ts new file mode 100644 index 000000000..101ef14e2 --- /dev/null +++ b/src/graphql/public/types/object/csv-transactions.ts @@ -0,0 +1,19 @@ +import { Accounts } from "@app" +import { InvalidWalletForAccountError } from "@domain/accounts" +import { mapError } from "@graphql/error-map" + +// Both public account types expose this field. Bind it to the authenticated +// account before considering caller-supplied wallet IDs, then let the app +// service validate the entire requested subset against stored ownership. +export const exportOwnedAccountCSV = async ( + source: Account, + walletIds: WalletId[], + context: GraphQLPublicContextAuth, +): Promise => { + if (!context.domainAccount || context.domainAccount.id !== source.id) + throw mapError(new InvalidWalletForAccountError()) + + const result = await Accounts.getCSVForAccount(source.id, walletIds) + if (result instanceof Error) throw mapError(result) + return result +} diff --git a/src/services/ledger/csv-wallet-export.ts b/src/services/ledger/csv-wallet-export.ts index 5dde1fc5b..813be0862 100644 --- a/src/services/ledger/csv-wallet-export.ts +++ b/src/services/ledger/csv-wallet-export.ts @@ -1,5 +1,6 @@ -import { getTransactionsForWallets } from "@app/wallets" +import { getTransactionsForWallets } from "@app/wallets/get-transactions-for-wallet" import { txDirectionValues } from "@graphql/shared/types/scalar/tx-direction" +import { UnexpectedIbexResponse } from "@services/ibex/errors" import { baseLogger } from "@services/logger" import { createObjectCsvStringifier, createObjectCsvWriter } from "csv-writer" @@ -54,16 +55,21 @@ export class CsvWalletsExport { // TODO: interface could be improved by returning self, so that it's // possible to run csv.addWallet(wallet).getBase64() - const response = await getTransactionsForWallets({ - wallets, - }) + let response: Awaited> + try { + response = await getTransactionsForWallets({ wallets, requireComplete: true }) + } catch { + return new UnexpectedIbexResponse("CSV export history unavailable") + } + if (response.error) return response.error + if (!response.result?.slice) + return new UnexpectedIbexResponse("CSV export history unavailable") - const txs = await this.formatTxs(response.result?.slice) + const txs = await this.formatTxs(response.result.slice) - if (!(txs instanceof Error)) { - // @ts-expect-error no-implicit-any error - this.entries.push(...txs) - } + if (txs instanceof Error) return txs + // @ts-expect-error no-implicit-any error + this.entries.push(...txs) } async formatTxs(txs?: IbexTransaction[]) { diff --git a/test/flash/unit/app/accounts/csv-wallet-scope.spec.ts b/test/flash/unit/app/accounts/csv-wallet-scope.spec.ts new file mode 100644 index 000000000..de5347ee0 --- /dev/null +++ b/test/flash/unit/app/accounts/csv-wallet-scope.spec.ts @@ -0,0 +1,85 @@ +const listByAccountId = jest.fn() +const addWallets = jest.fn() +const getBase64 = jest.fn() + +jest.mock("@services/mongoose", () => ({ + WalletsRepository: () => ({ listByAccountId }), +})) +jest.mock("@services/ledger/csv-wallet-export", () => ({ + CsvWalletsExport: jest.fn().mockImplementation(() => ({ addWallets, getBase64 })), +})) + +import { InvalidWalletForAccountError } from "@domain/accounts" +import { getCSVForAccount } from "@app/accounts/get-csv-for-account" + +const account = "507f1f77bcf86cd799439011" as AccountId +const ownedA = { id: "owned-a", accountId: account } as Wallet +const ownedB = { id: "owned-b", accountId: account } as Wallet +const own = (id: string) => id as WalletId + +beforeEach(() => { + jest.clearAllMocks() + listByAccountId.mockResolvedValue([ownedA, ownedB]) + addWallets.mockResolvedValue(undefined) + getBase64.mockReturnValue("aGVhZGVy") +}) + +it("exports only the exact requested owned subset, in request order, without duplicate rows", async () => { + const result = await getCSVForAccount(account, [ + own("owned-b"), + own("owned-a"), + own("owned-b"), + ]) + + expect(result).toBe("aGVhZGVy") + expect(listByAccountId).toHaveBeenCalledWith(account) + expect(addWallets).toHaveBeenCalledTimes(1) + expect(addWallets).toHaveBeenCalledWith([ownedB, ownedA]) +}) + +it("treats an empty required list as a header-only export, never all wallets", async () => { + expect(await getCSVForAccount(account, [])).toBe("aGVhZGVy") + expect(addWallets).toHaveBeenCalledWith([]) +}) + +it.each(["foreign-wallet", "missing-wallet"])( + "rejects %s identically before any CSV is built", + async (id) => { + const result = await getCSVForAccount(account, [own("owned-a"), own(id)]) + + expect(result).toBeInstanceOf(InvalidWalletForAccountError) + expect(addWallets).not.toHaveBeenCalled() + expect(getBase64).not.toHaveBeenCalled() + }, +) + +it("rejects a mismatched repository row instead of trusting its list membership", async () => { + listByAccountId.mockResolvedValueOnce([ + ownedA, + { id: "foreign-wallet", accountId: "foreign-account" }, + ]) + const result = await getCSVForAccount(account, [own("foreign-wallet")]) + + expect(result).toBeInstanceOf(InvalidWalletForAccountError) + expect(addWallets).not.toHaveBeenCalled() +}) + +it("rejects missing or malformed argument arrays rather than falling back to all wallets", async () => { + for (const bad of [undefined, null, [new Error("invalid scalar")]]) { + const result = await getCSVForAccount(account, bad as unknown as WalletId[]) + expect(result).toBeInstanceOf(InvalidWalletForAccountError) + } + expect(addWallets).not.toHaveBeenCalled() +}) + +it("propagates repository and export failures without returning an incomplete download", async () => { + const repositoryError = new Error("repository unavailable") + listByAccountId.mockResolvedValueOnce(repositoryError) + expect(await getCSVForAccount(account, [own("owned-a")])).toBe(repositoryError) + expect(addWallets).not.toHaveBeenCalled() + + const exportError = new Error("provider unavailable") + addWallets.mockResolvedValueOnce(exportError) + expect(await getCSVForAccount(account, [own("owned-a")])).toBe(exportError) + expect(getBase64).not.toHaveBeenCalled() +}) diff --git a/test/flash/unit/graphql/csv-wallet-scope.spec.ts b/test/flash/unit/graphql/csv-wallet-scope.spec.ts new file mode 100644 index 000000000..62a23073f --- /dev/null +++ b/test/flash/unit/graphql/csv-wallet-scope.spec.ts @@ -0,0 +1,44 @@ +const getCSVForAccount = jest.fn() +jest.mock("@app", () => ({ + Accounts: { getCSVForAccount: (...args: unknown[]) => getCSVForAccount(...args) }, +})) +jest.mock("@graphql/error-map", () => ({ mapError: (error: Error) => error })) + +import { InvalidWalletForAccountError } from "@domain/accounts" +import { exportOwnedAccountCSV } from "@graphql/public/types/object/csv-transactions" + +const source = { id: "507f1f77bcf86cd799439011" } as Account +const owned = { id: source.id } as Account +const ids = ["owned-a" as WalletId] + +beforeEach(() => { + jest.clearAllMocks() + getCSVForAccount.mockResolvedValue("aGVhZGVy") +}) + +it("passes the requested wallet set only for the authenticated source account", async () => { + const result = await exportOwnedAccountCSV(source, ids, { + domainAccount: owned, + } as GraphQLPublicContextAuth) + + expect(result).toBe("aGVhZGVy") + expect(getCSVForAccount).toHaveBeenCalledWith(source.id, ids) +}) + +it("rejects a foreign source before reading any wallet or export data", async () => { + await expect( + exportOwnedAccountCSV(source, ids, { + domainAccount: { id: "foreign-account" }, + } as GraphQLPublicContextAuth), + ).rejects.toBeInstanceOf(InvalidWalletForAccountError) + expect(getCSVForAccount).not.toHaveBeenCalled() +}) + +it("maps an ownership or provider failure instead of coercing an error into a CSV string", async () => { + getCSVForAccount.mockResolvedValue(new InvalidWalletForAccountError()) + await expect( + exportOwnedAccountCSV(source, ids, { + domainAccount: owned, + } as GraphQLPublicContextAuth), + ).rejects.toBeInstanceOf(InvalidWalletForAccountError) +}) diff --git a/test/flash/unit/services/ledger/csv-wallet-export-completeness.spec.ts b/test/flash/unit/services/ledger/csv-wallet-export-completeness.spec.ts new file mode 100644 index 000000000..06d81a35b --- /dev/null +++ b/test/flash/unit/services/ledger/csv-wallet-export-completeness.spec.ts @@ -0,0 +1,72 @@ +const getAccountTransactions = jest.fn() +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + getAccountTransactions: (...args: unknown[]) => getAccountTransactions(...args), + }, +})) +jest.mock("@services/logger", () => ({ + baseLogger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, +})) + +import { getTransactionsForWallets } from "@app/wallets/get-transactions-for-wallet" +import { IbexError } from "@services/ibex/errors" +import { CsvWalletsExport } from "@services/ledger/csv-wallet-export" + +const wallets = [{ id: "owned-a" as WalletId }, { id: "owned-b" as WalletId }] as Wallet[] + +beforeEach(() => { + jest.clearAllMocks() + getAccountTransactions.mockResolvedValue([]) +}) + +it("keeps the existing ordinary-history partial behavior while export rejects a failed wallet", async () => { + const providerError = new IbexError(new Error("private provider detail")) + getAccountTransactions.mockImplementation( + async ({ account_id }: { account_id: string }) => + account_id === "owned-b" ? providerError : [], + ) + + const ordinary = await getTransactionsForWallets({ wallets }) + expect(ordinary.error).toBeUndefined() + expect(ordinary.result?.slice).toEqual([]) + + const csv = new CsvWalletsExport() + expect(await csv.addWallets(wallets)).toBe(providerError) + expect(csv.entries).toEqual([]) + expect(getAccountTransactions).toHaveBeenCalledWith( + expect.objectContaining({ account_id: "owned-a", page: 0, limit: 0 }), + ) + expect(getAccountTransactions).toHaveBeenCalledWith( + expect.objectContaining({ account_id: "owned-b", page: 0, limit: 0 }), + ) +}) + +it("preserves header-only output for an explicitly empty wallet set with no provider call", async () => { + const csv = new CsvWalletsExport() + expect(await csv.addWallets([])).toBeUndefined() + expect(getAccountTransactions).not.toHaveBeenCalled() + expect(Buffer.from(csv.getBase64(), "base64").toString("utf8")).toContain(" Wallet Id") +}) + +it("turns a thrown provider failure into a fixed export failure", async () => { + getAccountTransactions.mockRejectedValue(new Error("private provider detail")) + const csv = new CsvWalletsExport() + + const result = await csv.addWallets([wallets[0]]) + expect(result).toBeInstanceOf(Error) + expect(result?.message).toBe("CSV export history unavailable") + expect(csv.entries).toEqual([]) +}) + +it("refuses an export if an unrepresentable provider row would be silently omitted", async () => { + getAccountTransactions.mockResolvedValue([ + { id: "vendor-row", accountId: "owned-a", currencyId: 999 }, + ]) + const csv = new CsvWalletsExport() + + const result = await csv.addWallets([wallets[0]]) + expect(result).toBeInstanceOf(Error) + expect(result?.message).toBe("CSV export has unrepresentable transactions") + expect(csv.entries).toEqual([]) +}) From 25f4747759d232c97b81b4140fff11105bc8d4b9 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 02:07:47 -0700 Subject: [PATCH 34/42] Guard owned CSV export and completeness contracts --- docs/native-parity-unit-tests.md | 4 ++-- scripts/native-parity-unit.cjs | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/native-parity-unit-tests.md b/docs/native-parity-unit-tests.md index b4167175e..75d462661 100644 --- a/docs/native-parity-unit-tests.md +++ b/docs/native-parity-unit-tests.md @@ -7,7 +7,7 @@ node scripts/native-parity-unit.cjs node --test scripts/native-parity-unit.test.cjs ``` -The first command runs the fixed, reviewed selection of 76 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. +The first command runs the fixed, reviewed selection of 79 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. The child receives only inherited `PATH` and `TMPDIR`, plus the explicit fixture values in `scripts/native-parity-unit.cjs`. Required secrets are unmistakable dummy strings. Network is `regtest`; configured service hosts point at numeric loopback port 9. `HOME`, cloud credentials/profiles, proxy variables, Node preloads, and inherited service/provider credentials are omitted. Jest's setup selects only committed `dev/config/base-config.yaml`; no private configuration file is loaded by the runner. Its required dummy environment exists only in the child process and must never be used for a deployment. @@ -23,6 +23,6 @@ Three narrow test boundaries now avoid real service initialization: The identity presigned-PUT test still uses the real pinned AWS SDK and sends the untouched signed URL with a nonempty PNG to its local HTTP fixture. The interlock permits that fixture. It emulates checksum/body acceptance, not production signature verification, and uses dummy credentials only. All Bridge, IBEX, notification, database and cache interactions elsewhere are mocked. -Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `7d0c5e362` plus the reviewed runner changes: all **76 suites / 1218 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-post-bridge-fence-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. +Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `5433ffa83` plus the reviewed runner changes: all **79 suites / 1232 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-post-csv-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. These are offline unit checks. They do not establish provider access, sandbox interoperability, production configuration, bank-account provisioning, image storage acceptance by DigitalOcean, or deployment readiness. diff --git a/scripts/native-parity-unit.cjs b/scripts/native-parity-unit.cjs index 34c297674..959773bab 100644 --- a/scripts/native-parity-unit.cjs +++ b/scripts/native-parity-unit.cjs @@ -85,11 +85,14 @@ const suites = Object.freeze({ "services/mongoose/device-bootstrap.spec.ts", ], ownership: [ + "app/accounts/csv-wallet-scope.spec.ts", "app/accounts/owned-wallet-snapshot-adapters.spec.ts", "app/accounts/owned-wallet-snapshot.spec.ts", + "graphql/csv-wallet-scope.spec.ts", "graphql/ln-invoice-payment-send.spec.ts", "graphql/ln-send-marker-withdrawal.spec.ts", "graphql/owned-wallet-snapshot.spec.ts", + "services/ledger/csv-wallet-export-completeness.spec.ts", ], }) From 5ce406f5c2d654bb0e853c5a1fed7b957db203ce Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 02:12:59 -0700 Subject: [PATCH 35/42] Escape spreadsheet formulas in transaction CSV exports --- docs/csv-wallet-scope.md | 21 +++- src/services/ledger/csv-wallet-export.ts | 28 ++++- .../ledger/csv-formula-safety.spec.ts | 114 ++++++++++++++++++ 3 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 test/flash/unit/services/ledger/csv-formula-safety.spec.ts diff --git a/docs/csv-wallet-scope.md b/docs/csv-wallet-scope.md index 02a15414c..2aa69382e 100644 --- a/docs/csv-wallet-scope.md +++ b/docs/csv-wallet-scope.md @@ -24,14 +24,25 @@ plausible but incomplete CSV. This does **not** prove an exhaustive historical export. The existing IBEX request uses `page: 0, limit: 0` without a continuation loop; the meaning of `limit: 0` and large-history behavior still need provider acceptance. The -backend CSV writer also does not escape spreadsheet formulas. Native export -currently validates and escapes rows before sharing, but other consumers must -not treat this backend CSV as spreadsheet-safe. Those are separate release -checks. No provider call, account mutation or file download was made here. +backend CSV writer now prefixes an apostrophe to text cells that could start a +spreadsheet formula after leading whitespace, control or invisible Unicode +characters. It also detects fullwidth compatibility forms using NFKC for the +decision while preserving the original text after the prefix. The prefix is +added before the existing CSV writer quotes commas, quotes and newlines. Exact +signed numeric tokens in amount and fee columns remain unchanged, including +negative values and exponents. This necessarily changes the displayed text +of suspicious cells; the original in-memory transaction is not modified. +Native export independently validates and escapes rows. A server-prefixed +apostrophe is not prefixed again by the native rule. Provider completeness and +spreadsheet application acceptance remain separate release checks. No provider +call, account mutation or file download was made here. Validation uses mocked repositories and IBEX plus the guarded Node 24 unit -runner. **14 tests in three focused suites** pass; the inherited **76 suites / 1,218 tests** +runner. **33 tests in four focused suites** pass; the inherited **76 suites / 1,218 tests** also pass. All three TypeScript configurations, ESLint, Typos and diff checks pass. Tests cover exact owned subset/order, duplicate IDs, empty selection, uniform missing/foreign refusal, caller/account mismatch, error propagation, provider failure, unrepresentable rows and preservation of ordinary history. +Formula tests cover delimiters, embedded newlines, quotes, invisible and +compatibility Unicode, benign text, pre-prefixed text, a native-style second +pass and exact numeric cells. diff --git a/src/services/ledger/csv-wallet-export.ts b/src/services/ledger/csv-wallet-export.ts index 813be0862..104b3e1ad 100644 --- a/src/services/ledger/csv-wallet-export.ts +++ b/src/services/ledger/csv-wallet-export.ts @@ -19,16 +19,40 @@ const header = [ { id: "memo", title: " Memo" }, ] +const numericColumns = new Set(["amount", "displayAmount", "fee", "displayFee"]) +const exactNumber = /^[+-]?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$/ +// Ignore leading whitespace, controls and invisible formatting/combining +// marks when deciding whether a spreadsheet may interpret a cell as a formula. +// NFKC also detects fullwidth =, +, - and @ without altering the source text. +const formulaPrefix = /^[\p{White_Space}\p{Cc}\p{Cf}\p{M}]*(?:[=+@-]|\u2212)/u + +const safeCsvCell = (column: string, value: unknown): unknown => { + if (typeof value !== "string") return value + if (numericColumns.has(column) && exactNumber.test(value)) return value + return formulaPrefix.test(value.normalize("NFKC")) ? `'${value}` : value +} + export class CsvWalletsExport { entries: LedgerTransaction[] = [] + private safeRecords(): Record[] { + return this.entries.map((entry) => + Object.fromEntries( + Object.entries(entry).map(([column, value]) => [ + column, + safeCsvCell(column, value), + ]), + ), + ) + } + getBase64(): string { const csvWriter = createObjectCsvStringifier({ header, }) const header_stringify = csvWriter.getHeaderString() - const records = csvWriter.stringifyRecords(this.entries) + const records = csvWriter.stringifyRecords(this.safeRecords()) const str = header_stringify + records @@ -47,7 +71,7 @@ export class CsvWalletsExport { header, }) - await csvWriter.writeRecords(this.entries) + await csvWriter.writeRecords(this.safeRecords()) baseLogger.info("saving complete") } diff --git a/test/flash/unit/services/ledger/csv-formula-safety.spec.ts b/test/flash/unit/services/ledger/csv-formula-safety.spec.ts new file mode 100644 index 000000000..b942ac26b --- /dev/null +++ b/test/flash/unit/services/ledger/csv-formula-safety.spec.ts @@ -0,0 +1,114 @@ +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { getAccountTransactions: jest.fn() }, +})) +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +import { CsvWalletsExport } from "@services/ledger/csv-wallet-export" + +const row = (changes: Record = {}): LedgerTransaction => + ({ + id: "transaction-id", + walletId: "wallet-id", + type: "lightning", + direction: "SEND", + amount: -9007199254740993n, + displayAmount: "-9007199254740993.001", + fee: 0, + displayFee: "-2.50", + currency: "USDT", + timestamp: "2026-10-23T01:00:00Z", + status: "SUCCESS", + memo: "ordinary memo", + ...changes, + }) as unknown as LedgerTransaction + +const exportRows = (...entries: LedgerTransaction[]): string => { + const csv = new CsvWalletsExport() + csv.entries = entries + return Buffer.from(csv.getBase64(), "base64").toString("utf8") +} + +it("prefixes a formula before CSV quoting while retaining commas, quotes and embedded newlines", () => { + const text = ` =HYPERLINK("https://example.invalid/a,b","open")\nsecond line` + const output = exportRows(row({ memo: text })) + + expect(output).toContain( + `"' =HYPERLINK(""https://example.invalid/a,b"",""open"")\nsecond line"`, + ) + expect(output).toContain("-9007199254740993.001") + expect(output).toContain("-2.50") + expect(output).not.toContain("'-9007199254740993.001") + expect(output).not.toContain("'-2.50") + expect(output).toContain("SEND") +}) + +it.each([ + "=1+1", + "+cmd", + "-cmd", + "@SUM(1)", + "\t=1+1", + "\r\n@SUM(1)", + "\uFEFF=1+1", + "\u200B+cmd", + "\u2028=1+1", + "\u0301=1+1", + "=SUM(1)", + "+cmd", + "-cmd", + "@SUM(1)", + "−cmd", +])( + "escapes a text cell that begins with a formula after Unicode normalization: %s", + (text) => { + const output = exportRows(row({ memo: text })) + expect(output).toContain(`'${text}`) + expect(output).not.toContain(`''${text}`) + }, +) + +it("does not alter benign text, existing apostrophe guards, or exact financial numbers", () => { + const output = exportRows( + row({ memo: `normal, "quoted"\nnext`, displayAmount: "+2.50", displayFee: "-1e-6" }), + row({ memo: "'=SUM(1)", displayAmount: "-2", displayFee: 0 }), + ) + + expect(output).toContain(`"normal, ""quoted""\nnext"`) + expect(output).toContain("'=SUM(1)") + expect(output).not.toContain("''=SUM(1)") + expect(output).toContain("+2.50") + expect(output).toContain("-1e-6") + expect(output).not.toContain("'+2.50") + expect(output).not.toContain("'-1e-6") +}) + +it("leaves the native-style second pass with no formula to prefix again", () => { + const output = exportRows(row({ memo: "=SUM(1)" })) + const encodedCell = output.trimEnd().split(/\r?\n/)[1].split(",").at(-1)! + const serverCell = encodedCell.replace(/^"|"$/g, "") + const nativeStylePass = (cell: string): string => + /^[=+@-]/.test(cell.trim()) ? `'${cell}` : cell + + expect(serverCell).toBe("'=SUM(1)") + expect(nativeStylePass(serverCell)).toBe(serverCell) + expect(nativeStylePass(serverCell)).not.toBe("''=SUM(1)") +}) + +it("escapes untrusted identifier and numeric-text formulas without changing the original entries", () => { + const original = row({ + id: '=HYPERLINK("https://example.invalid")', + walletId: "\u200B+wallet", + displayAmount: "=1+1", + displayFee: "-cmd", + }) + const output = exportRows(original) + + expect(output).toContain(`'\u200B+wallet`) + expect(output).toContain(`'=1+1`) + expect(output).toContain(`'-cmd`) + expect(output).toContain(`"'=HYPERLINK(""https://example.invalid"")"`) + expect(original.id).toBe('=HYPERLINK("https://example.invalid")') +}) From 85e75b696805dc82e725bed4bbd6ae81db7b6a91 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 02:13:34 -0700 Subject: [PATCH 36/42] Guard CSV formula safety with parity runner --- docs/native-parity-unit-tests.md | 4 ++-- scripts/native-parity-unit.cjs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/native-parity-unit-tests.md b/docs/native-parity-unit-tests.md index 75d462661..3a4d4e4a3 100644 --- a/docs/native-parity-unit-tests.md +++ b/docs/native-parity-unit-tests.md @@ -7,7 +7,7 @@ node scripts/native-parity-unit.cjs node --test scripts/native-parity-unit.test.cjs ``` -The first command runs the fixed, reviewed selection of 79 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. +The first command runs the fixed, reviewed selection of 80 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. The child receives only inherited `PATH` and `TMPDIR`, plus the explicit fixture values in `scripts/native-parity-unit.cjs`. Required secrets are unmistakable dummy strings. Network is `regtest`; configured service hosts point at numeric loopback port 9. `HOME`, cloud credentials/profiles, proxy variables, Node preloads, and inherited service/provider credentials are omitted. Jest's setup selects only committed `dev/config/base-config.yaml`; no private configuration file is loaded by the runner. Its required dummy environment exists only in the child process and must never be used for a deployment. @@ -23,6 +23,6 @@ Three narrow test boundaries now avoid real service initialization: The identity presigned-PUT test still uses the real pinned AWS SDK and sends the untouched signed URL with a nonempty PNG to its local HTTP fixture. The interlock permits that fixture. It emulates checksum/body acceptance, not production signature verification, and uses dummy credentials only. All Bridge, IBEX, notification, database and cache interactions elsewhere are mocked. -Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `5433ffa83` plus the reviewed runner changes: all **79 suites / 1232 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-post-csv-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. +Validated 2026-10-23 on Node `24.22.1` against integrated backend source through `5ce406f5c` plus the reviewed runner changes: all **80 suites / 1251 tests** pass with natural status-zero termination, no blocked network attempts and no Jest open-handle warning. The separate Node runner checks pass **5 tests**, covering hostile inherited configuration, fixed suite selection, real local HTTP fixture acceptance, blocked TCP/DNS/UDP/listeners, and caught unmocked `fetch` failures. Installed lockfile versions were Jest `29.7.0`, `@swc/jest` `0.2.39`, and both S3 SDK packages `3.968.0`. Logs from this run are `/tmp/flash-native-backend-post-csv-formula-runner.log` and `/tmp/flash-native-backend-final-runner-selftest.log`; they are local artifacts, not checked-in fixtures. These are offline unit checks. They do not establish provider access, sandbox interoperability, production configuration, bank-account provisioning, image storage acceptance by DigitalOcean, or deployment readiness. diff --git a/scripts/native-parity-unit.cjs b/scripts/native-parity-unit.cjs index 959773bab..0bd54e856 100644 --- a/scripts/native-parity-unit.cjs +++ b/scripts/native-parity-unit.cjs @@ -93,6 +93,7 @@ const suites = Object.freeze({ "graphql/ln-send-marker-withdrawal.spec.ts", "graphql/owned-wallet-snapshot.spec.ts", "services/ledger/csv-wallet-export-completeness.spec.ts", + "services/ledger/csv-formula-safety.spec.ts", ], }) From 922fa315d1b94f00a5938acc703d9a57e5b729d7 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 02:37:16 -0700 Subject: [PATCH 37/42] Bind IBEX on-chain receive addresses to owned cash wallets --- scripts/native-parity-unit.cjs | 1 + .../wallets/create-owned-on-chain-address.ts | 55 +++++++ .../root/mutation/on-chain-address-create.ts | 17 ++- .../root/mutation/on-chain-address-current.ts | 32 ++-- .../on-chain-address-ownership.spec.ts | 139 ++++++++++++++++++ 5 files changed, 223 insertions(+), 21 deletions(-) create mode 100644 src/app/wallets/create-owned-on-chain-address.ts create mode 100644 test/flash/unit/graphql/public/root/mutation/on-chain-address-ownership.spec.ts diff --git a/scripts/native-parity-unit.cjs b/scripts/native-parity-unit.cjs index 0bd54e856..5ac9b6cb2 100644 --- a/scripts/native-parity-unit.cjs +++ b/scripts/native-parity-unit.cjs @@ -91,6 +91,7 @@ const suites = Object.freeze({ "graphql/csv-wallet-scope.spec.ts", "graphql/ln-invoice-payment-send.spec.ts", "graphql/ln-send-marker-withdrawal.spec.ts", + "graphql/public/root/mutation/on-chain-address-ownership.spec.ts", "graphql/owned-wallet-snapshot.spec.ts", "services/ledger/csv-wallet-export-completeness.spec.ts", "services/ledger/csv-formula-safety.spec.ts", diff --git a/src/app/wallets/create-owned-on-chain-address.ts b/src/app/wallets/create-owned-on-chain-address.ts new file mode 100644 index 000000000..a7f32cbfb --- /dev/null +++ b/src/app/wallets/create-owned-on-chain-address.ts @@ -0,0 +1,55 @@ +import { address, networks } from "bitcoinjs-lib" + +import { NETWORK } from "@config" +import { AccountValidator, InvalidWalletForAccountError } from "@domain/accounts" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" +import Ibex from "@services/ibex/client" +import { IbexError, UnexpectedIbexResponse } from "@services/ibex/errors" +import { AccountsRepository, WalletsRepository } from "@services/mongoose" + +/** IBEX on-chain receive is for an active owner's cash checking wallet. */ +export const createOwnedOnChainAddress = async ({ + accountId, + walletId, +}: { + accountId: AccountId + walletId: WalletId +}): Promise => { + const wallet = await WalletsRepository().findById(walletId) + // Use one refusal for absent, foreign, external, and wrong-currency wallets. + if ( + wallet instanceof Error || + wallet.accountId !== accountId || + wallet.type !== WalletType.Checking || + (wallet.currency !== WalletCurrency.Usd && wallet.currency !== WalletCurrency.Usdt) + ) { + return new InvalidWalletForAccountError() + } + + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return account + if (account.id !== accountId) return new InvalidWalletForAccountError() + const active = AccountValidator(account).isActive() + if (active instanceof Error) return active + + const response = await Ibex.generateBitcoinAddress(walletId) + if (response instanceof IbexError) return response + const value = response.address + if (typeof value !== "string") + return new UnexpectedIbexResponse("Bitcoin address unavailable") + try { + // bitcoinjs verifies the checksum and the configured chain, rather than + // relying on an address prefix or an untrusted provider response. + const network = + NETWORK === "mainnet" + ? networks.bitcoin + : NETWORK === "regtest" + ? networks.regtest + : networks.testnet // signet uses testnet address encoding. + address.toOutputScript(value, network) + return value as OnChainAddress + } catch { + return new UnexpectedIbexResponse("Bitcoin address unavailable") + } +} diff --git a/src/graphql/public/root/mutation/on-chain-address-create.ts b/src/graphql/public/root/mutation/on-chain-address-create.ts index 5b1ceaa22..3d446d336 100644 --- a/src/graphql/public/root/mutation/on-chain-address-create.ts +++ b/src/graphql/public/root/mutation/on-chain-address-create.ts @@ -1,5 +1,6 @@ import { GT } from "@graphql/index" -import { Wallets } from "@app" +import { createOwnedOnChainAddress } from "@app/wallets/create-owned-on-chain-address" +import { InvalidWalletForAccountError } from "@domain/accounts" import OnChainAddressPayload from "@graphql/public/types/payload/on-chain-address" import WalletId from "@graphql/shared/types/scalar/wallet-id" import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" @@ -11,7 +12,7 @@ const OnChainAddressCreateInput = GT.Input({ }), }) -const OnChainAddressCreateMutation = GT.Field({ +const OnChainAddressCreateMutation = GT.Field({ extensions: { complexity: 120, }, @@ -19,13 +20,21 @@ const OnChainAddressCreateMutation = GT.Field({ args: { input: { type: GT.NonNull(OnChainAddressCreateInput) }, }, - resolve: async (_, args) => { + resolve: async (_, args, { domainAccount }) => { const { walletId } = args.input if (walletId instanceof Error) { return { errors: [{ message: walletId.message }] } } - const address = await Wallets.createOnChainAddress({ walletId }) + if (!domainAccount) + return { + errors: [mapAndParseErrorForGqlResponse(new InvalidWalletForAccountError())], + } + + const address = await createOwnedOnChainAddress({ + accountId: domainAccount.id, + walletId, + }) if (address instanceof Error) { return { errors: [mapAndParseErrorForGqlResponse(address)] } } diff --git a/src/graphql/public/root/mutation/on-chain-address-current.ts b/src/graphql/public/root/mutation/on-chain-address-current.ts index cbfe20573..42a94793e 100644 --- a/src/graphql/public/root/mutation/on-chain-address-current.ts +++ b/src/graphql/public/root/mutation/on-chain-address-current.ts @@ -2,12 +2,8 @@ import { GT } from "@graphql/index" import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" import WalletId from "@graphql/shared/types/scalar/wallet-id" import OnChainAddressPayload from "@graphql/public/types/payload/on-chain-address" -// import { Wallets } from "@app" - -// FLASH FORK: import ibex dependencies -import Ibex from "@services/ibex/client" - -import { IbexError } from "@services/ibex/errors" +import { createOwnedOnChainAddress } from "@app/wallets/create-owned-on-chain-address" +import { InvalidWalletForAccountError } from "@domain/accounts" const OnChainAddressCurrentInput = GT.Input({ name: "OnChainAddressCurrentInput", @@ -16,7 +12,7 @@ const OnChainAddressCurrentInput = GT.Input({ }), }) -const OnChainAddressCurrentMutation = GT.Field({ +const OnChainAddressCurrentMutation = GT.Field({ extensions: { complexity: 120, }, @@ -24,25 +20,27 @@ const OnChainAddressCurrentMutation = GT.Field({ args: { input: { type: GT.NonNull(OnChainAddressCurrentInput) }, }, - resolve: async (_, args) => { + resolve: async (_, args, { domainAccount }) => { const { walletId } = args.input if (walletId instanceof Error) { return { errors: [{ message: walletId.message }] } } - // FLASH FORK: use IBEX to create on-chain address - // const address = await Wallets.getLastOnChainAddress(walletId) - // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!domainAccount) + return { + errors: [mapAndParseErrorForGqlResponse(new InvalidWalletForAccountError())], + } - const resp = await Ibex.generateBitcoinAddress(walletId) - - if (resp instanceof IbexError) { - return { errors: [mapAndParseErrorForGqlResponse(resp)] } - } + const address = await createOwnedOnChainAddress({ + accountId: domainAccount.id, + walletId, + }) + if (address instanceof Error) + return { errors: [mapAndParseErrorForGqlResponse(address)] } return { errors: [], - address: resp.address, + address, } }, }) diff --git a/test/flash/unit/graphql/public/root/mutation/on-chain-address-ownership.spec.ts b/test/flash/unit/graphql/public/root/mutation/on-chain-address-ownership.spec.ts new file mode 100644 index 000000000..08c7754ad --- /dev/null +++ b/test/flash/unit/graphql/public/root/mutation/on-chain-address-ownership.spec.ts @@ -0,0 +1,139 @@ +const mockFindWallet = jest.fn() +const mockFindAccount = jest.fn() +const mockGenerateAddress = jest.fn() + +jest.mock("@services/mongoose", () => ({ + WalletsRepository: () => ({ + findById: (...args: unknown[]) => mockFindWallet(...args), + }), + AccountsRepository: () => ({ + findById: (...args: unknown[]) => mockFindAccount(...args), + }), +})) +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + generateBitcoinAddress: (...args: unknown[]) => mockGenerateAddress(...args), + }, +})) + +import { address, networks } from "bitcoinjs-lib" + +import { createOwnedOnChainAddress } from "@app/wallets/create-owned-on-chain-address" +import { NETWORK } from "@config" +import { InvalidWalletForAccountError } from "@domain/accounts" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" +import OnChainAddressCreateMutation from "@graphql/public/root/mutation/on-chain-address-create" +import OnChainAddressCurrentMutation from "@graphql/public/root/mutation/on-chain-address-current" +import { UnexpectedIbexResponse } from "@services/ibex/errors" + +const accountId = "owner-account" as AccountId +const walletId = "11111111-1111-4111-8111-111111111111" as WalletId +const ownedWallet = { + id: walletId, + accountId, + type: WalletType.Checking, + currency: WalletCurrency.Usdt, +} +const network = + NETWORK === "mainnet" + ? networks.bitcoin + : NETWORK === "regtest" + ? networks.regtest + : networks.testnet +const ownedAddress = address.toBech32(Buffer.alloc(20, 1), 0, network.bech32) +const wrongNetworkAddress = address.toBech32( + Buffer.alloc(20, 1), + 0, + NETWORK === "mainnet" ? networks.testnet.bech32 : networks.bitcoin.bech32, +) + +type Result = { errors: { message: string }[]; address?: string } +type Mutation = { + resolve: ( + source: null, + args: { input: { walletId: WalletId } }, + context: GraphQLPublicContextAuth, + info: never, + ) => Promise +} +const mutations = [OnChainAddressCreateMutation, OnChainAddressCurrentMutation] +const resolve = (mutation: (typeof mutations)[number], owner?: string) => + (mutation as unknown as Mutation).resolve( + null, + { input: { walletId } }, + { domainAccount: owner ? { id: owner } : undefined } as GraphQLPublicContextAuth, + {} as never, + ) + +beforeEach(() => { + jest.clearAllMocks() + mockFindWallet.mockResolvedValue(ownedWallet) + mockFindAccount.mockResolvedValue({ id: accountId, status: "active" }) + mockGenerateAddress.mockResolvedValue({ address: ownedAddress }) +}) + +describe.each(mutations)("on-chain receive resolver %#", (mutation) => { + it("requires a current authenticated owner before reading a wallet", async () => { + const result = await resolve(mutation) + expect(result.errors).toHaveLength(1) + expect(mockFindWallet).not.toHaveBeenCalled() + expect(mockGenerateAddress).not.toHaveBeenCalled() + }) + + it("refuses a foreign wallet without creating a provider address", async () => { + const result = await resolve(mutation, "other-owner") + expect(result.errors).toHaveLength(1) + expect(mockGenerateAddress).not.toHaveBeenCalled() + }) + + it("creates only an address for an active owned cash checking wallet", async () => { + const result = await resolve(mutation, accountId) + expect(result).toMatchObject({ errors: [], address: ownedAddress }) + expect(mockGenerateAddress).toHaveBeenCalledTimes(1) + expect(mockGenerateAddress).toHaveBeenCalledWith(walletId) + }) + + it("does not expose a wrong-chain address from the provider", async () => { + mockGenerateAddress.mockResolvedValue({ address: wrongNetworkAddress }) + const result = await resolve(mutation, accountId) + expect(result.errors).toHaveLength(1) + expect(result.address).toBeUndefined() + }) +}) + +describe("owned on-chain receive boundary", () => { + it.each([ + { ...ownedWallet, accountId: "foreign" }, + { ...ownedWallet, type: WalletType.External }, + { ...ownedWallet, currency: WalletCurrency.Btc }, + ])( + "rejects non-owned or non-cash checking wallet before provider call", + async (wallet) => { + mockFindWallet.mockResolvedValue(wallet) + expect(await createOwnedOnChainAddress({ accountId, walletId })).toBeInstanceOf( + InvalidWalletForAccountError, + ) + expect(mockGenerateAddress).not.toHaveBeenCalled() + }, + ) + + it("refuses an inactive owner and invalid provider checksum", async () => { + mockFindAccount.mockResolvedValue({ id: accountId, status: "locked" }) + expect(await createOwnedOnChainAddress({ accountId, walletId })).toBeInstanceOf(Error) + expect(mockGenerateAddress).not.toHaveBeenCalled() + + mockFindAccount.mockResolvedValue({ id: accountId, status: "active" }) + mockGenerateAddress.mockResolvedValue({ address: `${ownedAddress.slice(0, -1)}q` }) + expect(await createOwnedOnChainAddress({ accountId, walletId })).toBeInstanceOf( + UnexpectedIbexResponse, + ) + }) + + it("permits an owned USD checking wallet", async () => { + mockFindWallet.mockResolvedValue({ ...ownedWallet, currency: WalletCurrency.Usd }) + expect(await createOwnedOnChainAddress({ accountId, walletId })).toBe(ownedAddress) + expect(mockGenerateAddress).toHaveBeenCalledWith(walletId) + }) +}) From 3e1ff83878870812d7c7b41c6a28b53390df4d31 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 02:40:15 -0700 Subject: [PATCH 38/42] Bind Bridge withdrawal deposit instructions before crypto debit --- docs/bridge-withdrawal-send-uncertainty.md | 10 +++ src/services/bridge/index.ts | 42 +++++++-- test/flash/unit/services/bridge/index.spec.ts | 88 ++++++++++++++++++- .../services/bridge/return-shapes.spec.ts | 9 +- 4 files changed, 140 insertions(+), 9 deletions(-) diff --git a/docs/bridge-withdrawal-send-uncertainty.md b/docs/bridge-withdrawal-send-uncertainty.md index 7097a7712..389f85f9e 100644 --- a/docs/bridge-withdrawal-send-uncertainty.md +++ b/docs/bridge-withdrawal-send-uncertainty.md @@ -18,6 +18,16 @@ New uncertainty logs and operator alerts contain a closed reason and `SHA256("br ## Release and recovery +Before any IBEX debit, the Bridge create-transfer response must also match the +reviewed customer, Ethereum/USDT source, ACH/USD destination external account, +and Ethereum/USDT deposit instructions. The returned transfer ID must be a +single safe identifier and its deposit address a 20-byte hex address. Missing +or mismatched receipt fields now refuse dispatch and leave the Bridge transfer +for operator review; no inferred destination or automatic retry is permitted. +Verify these returned fields with a sandbox transfer before enabling the +withdrawal flow. The response amount is not treated as a USDT debit quote; +immutable fee/source/expiry consent remains a separate release gate. + Quiesce all old withdrawal writers and reconcilers before activation; old processes do not honor the new fence/proof marker. Build and verify the named unique partial index and the review-queue index before accepting traffic. MongoDB must support majority index commit quorum (4.4+ replica set), and the application must be able to verify/create the index and perform majority journaled writes. Unsupported topology, permissions, duplicate existing unknown rows, conflicting index options or unknown acknowledgments block dispatch rather than falling back to weaker writes. Runtime creation verifies the required uniqueness; no index or historical record was changed during this development work. Operators should list protected unresolved records, derive the alert reference from the record ID, and correlate the immutable source/send-info/attempt and any receipt ID with authoritative IBEX debit records and the owned Bridge transfer. A missing lookup result, timeout, pending response or absence of a webhook is not proof of no debit. Confirmed Bridge `payment_processed` can complete through the guarded repository path. Other outcomes require reviewed provider evidence and a separately authorized repair; this leaf adds no force-reset, blind retry, cancellation or refund endpoint. Never reset a fenced record to pending to unblock an account. Historical submitted/failed rows and a concurrent losing row may remain conservatively blocked for operator review. Ambiguity is deliberately retained when safe recovery cannot be established. diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index 262cedce5..35923bbe4 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -61,6 +61,7 @@ import { import BridgeApiClient, { type CreateExternalAccountRequest, type ExternalAccount, + type Transfer, } from "./client" import { PlaidLinkTokenStore } from "./plaid-link-token-store" import { checkedOwnedKycCustomerId } from "./owned-kyc-customer" @@ -161,9 +162,34 @@ type ExternalAccountResult = { export const deriveWithdrawalIdempotencyKey = (rowId: string): string => crypto.createHash("sha256").update(`withdrawal:${rowId}`).digest("hex") -const bridgeDepositAddressFromTransfer = (transfer: { - source_deposit_instructions?: { to_address?: string } -}) => transfer.source_deposit_instructions?.to_address +const checkedWithdrawalDepositAddress = ( + transfer: Transfer, + customerId: string, + externalAccountId: string, +): string | BridgeError => { + const instructions = transfer.source_deposit_instructions + if (!instructions?.to_address) return new BridgeDepositInstructionsMissingError() + if ( + typeof transfer.id !== "string" || + !/^[A-Za-z0-9_-]{1,256}$/.test(transfer.id) || + transfer.on_behalf_of !== customerId || + transfer.source?.payment_rail !== "ethereum" || + transfer.source.currency !== "usdt" || + transfer.destination?.payment_rail !== "ach" || + transfer.destination.currency !== "usd" || + transfer.destination.external_account_id !== externalAccountId || + instructions?.payment_rail !== "ethereum" || + instructions.currency !== "usdt" || + typeof instructions.to_address !== "string" || + !/^0x[0-9a-fA-F]{40}$/.test(instructions.to_address) + ) { + // A Bridge transfer was created, but its returned payment instructions + // have not been bound to the reviewed owner, source rail and destination. + // Never send an IBEX debit using these instructions. + return new BridgeError("Bridge transfer instructions did not match the withdrawal") + } + return instructions.to_address +} const ibexPayoutIdFromSendResponse = (response: unknown): string | undefined => { if (typeof response !== "object" || response === null) return undefined @@ -1498,10 +1524,12 @@ const initiateWithdrawal = async ( idempotencyKey, ) - const bridgeDepositAddress = bridgeDepositAddressFromTransfer(transfer) - if (!bridgeDepositAddress) { - return new BridgeDepositInstructionsMissingError() - } + const bridgeDepositAddress = checkedWithdrawalDepositAddress( + transfer, + customerId, + externalAccountId, + ) + if (bridgeDepositAddress instanceof Error) return bridgeDepositAddress const submitted = await BridgeAccountsRepo.updateWithdrawalTransferId( pendingWithdrawal.id, diff --git a/test/flash/unit/services/bridge/index.spec.ts b/test/flash/unit/services/bridge/index.spec.ts index f639ae3fa..942e86bb5 100644 --- a/test/flash/unit/services/bridge/index.spec.ts +++ b/test/flash/unit/services/bridge/index.spec.ts @@ -178,7 +178,7 @@ const ETHEREUM_ADDRESS = "ETH_ADDR_001" const TRANSFER_ID = "transfer-bridge-001" const WITHDRAWAL_ID = "withdrawal-mongo-001" const USDT_WALLET_ID = "ibex-eth-usdt-wallet-001" -const BRIDGE_DEPOSIT_ADDRESS = "0xbridgeDepositAddress" +const BRIDGE_DEPOSIT_ADDRESS = "0x1111111111111111111111111111111111111111" const IBEX_PAYOUT_ID = "ibex-payout-001" const IBEX_CRYPTO_SEND_REQUIREMENTS_ID = "send-requirements-001" const IBEX_CRYPTO_SEND_INFO_ID = "send-info-001" @@ -220,6 +220,13 @@ const mockTransfer = { amount: AMOUNT, currency: "usd", state: "pending", + on_behalf_of: CUSTOMER_ID, + source: { payment_rail: "ethereum", currency: "usdt" }, + destination: { + payment_rail: "ach", + currency: "usd", + external_account_id: EXTERNAL_ACCOUNT_ID, + }, source_deposit_instructions: { payment_rail: "ethereum", currency: "usdt", @@ -1172,6 +1179,85 @@ describe("initiateWithdrawal — takes withdrawalId (step 2A)", () => { expect(IbexClient.sendOnchain).not.toHaveBeenCalled() }) + it.each([ + ["customer", { on_behalf_of: "different-customer" }], + ["missing customer", { on_behalf_of: undefined }], + ["transfer ID", { id: "../other-transfer" }], + ["source rail", { source: { payment_rail: "base", currency: "usdt" } }], + ["source currency", { source: { payment_rail: "ethereum", currency: "usdc" } }], + [ + "destination rail", + { + destination: { + payment_rail: "wire", + currency: "usd", + external_account_id: EXTERNAL_ACCOUNT_ID, + }, + }, + ], + [ + "destination currency", + { + destination: { + payment_rail: "ach", + currency: "eur", + external_account_id: EXTERNAL_ACCOUNT_ID, + }, + }, + ], + [ + "destination bank", + { + destination: { + payment_rail: "ach", + currency: "usd", + external_account_id: "different-bank", + }, + }, + ], + [ + "deposit rail", + { + source_deposit_instructions: { + payment_rail: "base", + currency: "usdt", + to_address: BRIDGE_DEPOSIT_ADDRESS, + }, + }, + ], + [ + "deposit currency", + { + source_deposit_instructions: { + payment_rail: "ethereum", + currency: "usdc", + to_address: BRIDGE_DEPOSIT_ADDRESS, + }, + }, + ], + [ + "deposit address", + { + source_deposit_instructions: { + payment_rail: "ethereum", + currency: "usdt", + to_address: "0xnot-an-address", + }, + }, + ], + ])("does not debit IBEX for mismatched Bridge %s", async (_label, override) => { + ;(BridgeClient.createTransfer as jest.Mock).mockResolvedValue({ + ...mockTransfer, + ...override, + }) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(result).toBeInstanceOf(Error) + expect(BridgeAccountsRepo.updateWithdrawalTransferId).not.toHaveBeenCalled() + expect(IbexClient.sendCrypto).not.toHaveBeenCalled() + }) + it("creates an IBEX crypto send info for Bridge's deposit address before sending", async () => { await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) diff --git a/test/flash/unit/services/bridge/return-shapes.spec.ts b/test/flash/unit/services/bridge/return-shapes.spec.ts index b3cb884d4..c8dea3241 100644 --- a/test/flash/unit/services/bridge/return-shapes.spec.ts +++ b/test/flash/unit/services/bridge/return-shapes.spec.ts @@ -139,7 +139,7 @@ const CUSTOMER_ID = "cust-001" const ETHEREUM_ADDRESS = "ETH_ADDR_001" const TRANSFER_ID = "transfer-bridge-001" const WITHDRAWAL_ID = "withdrawal-mongo-001" -const BRIDGE_DEPOSIT_ADDRESS = "0xbridgeDepositAddress" +const BRIDGE_DEPOSIT_ADDRESS = "0x1111111111111111111111111111111111111111" const IBEX_PAYOUT_ID = "ibex-payout-001" const IBEX_CRYPTO_SEND_INFO_ID = "send-info-001" const CREATED_AT = new Date("2026-06-05T00:00:00.000Z") @@ -158,6 +158,13 @@ const mockTransfer = { amount: AMOUNT, currency: "usd", state: "pending", + on_behalf_of: CUSTOMER_ID, + source: { payment_rail: "ethereum", currency: "usdt" }, + destination: { + payment_rail: "ach", + currency: "usd", + external_account_id: EXTERNAL_ACCOUNT_ID, + }, source_deposit_instructions: { payment_rail: "ethereum", currency: "usdt", From 1f89798c15dbc3a4c4a1a680e9454301e09975b4 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 02:41:28 -0700 Subject: [PATCH 39/42] Record expanded isolated backend suite selection --- docs/native-parity-unit-tests.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/native-parity-unit-tests.md b/docs/native-parity-unit-tests.md index 3a4d4e4a3..99c0923fa 100644 --- a/docs/native-parity-unit-tests.md +++ b/docs/native-parity-unit-tests.md @@ -7,7 +7,7 @@ node scripts/native-parity-unit.cjs node --test scripts/native-parity-unit.test.cjs ``` -The first command runs the fixed, reviewed selection of 80 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. +The first command runs the fixed, reviewed selection of 81 Bridge, Earn, identity-storage, telemetry, device-bootstrap and wallet-ownership unit suites. Optional group arguments are `bridge`, `earn`, `storage`, `telemetry`, `device`, and `ownership`, for example `node scripts/native-parity-unit.cjs device`. No other Jest flags, file paths, environment overrides or configuration arguments are accepted. The checked-in selection must be updated deliberately when another relevant unit suite is added. It includes owned KYC provider/link boundaries, atomic account/customer binding, Earn's atomic reward deduplication, private-log regressions, durable device provisioning, and raw owned-wallet snapshot checks. It does not claim to run the full backend test tree; offers' Redis storage tests are outside this identity-storage slice. The child receives only inherited `PATH` and `TMPDIR`, plus the explicit fixture values in `scripts/native-parity-unit.cjs`. Required secrets are unmistakable dummy strings. Network is `regtest`; configured service hosts point at numeric loopback port 9. `HOME`, cloud credentials/profiles, proxy variables, Node preloads, and inherited service/provider credentials are omitted. Jest's setup selects only committed `dev/config/base-config.yaml`; no private configuration file is loaded by the runner. Its required dummy environment exists only in the child process and must never be used for a deployment. From 4ffbafcc3bc5955441b031248097bd07293863dd Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 02:47:00 -0700 Subject: [PATCH 40/42] Route owned cash on-chain receive to settlement wallet --- docs/ibex-onchain-receive-cutover.md | 22 +++++ .../wallets/create-owned-on-chain-address.ts | 30 ++++++- .../root/mutation/on-chain-address-create.ts | 3 +- .../root/mutation/on-chain-address-current.ts | 3 +- .../on-chain-address-ownership.spec.ts | 88 +++++++++++++++++-- 5 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 docs/ibex-onchain-receive-cutover.md diff --git a/docs/ibex-onchain-receive-cutover.md b/docs/ibex-onchain-receive-cutover.md new file mode 100644 index 000000000..7415752e7 --- /dev/null +++ b/docs/ibex-onchain-receive-cutover.md @@ -0,0 +1,22 @@ +# IBEX cash on-chain receive and wallet cutover + +The RN cash receive flow calls `onChainAddressCurrent` with the selected USD or +USDT wallet ID. BTC receive uses Breez/Spark separately. A persisted checking +wallet ID is also its IBEX account ID, so address generation must use the +active settlement wallet ID after a cash-wallet cutover. + +Both address mutations first require the presented wallet to be an owned USD or +USDT checking wallet and the account to be active. They then use the same cash +wallet mutation routing as invoice creation: an owned legacy USD alias resolves +to the active USDT wallet when that is the settlement wallet. Because cutover +resolution may create a missing USDT wallet, the initial ownership and account +checks run before routing. A changed wallet ID is read back and must still be +owned USDT checking before IBEX is called. Foreign, external, BTC, inactive, +missing, and wrongly routed wallets produce no provider address request. + +The returned address is checksum and network validated against configured +Bitcoin chain. These checks do not prove that IBEX credits that address to the +selected active wallet. Before enabling native cash on-chain receive, use +controlled provider accounts to confirm pre-cutover USD, in-progress, complete, +and rolled-back account routing; address credit attribution; webhook delivery; +and restart behavior. Native BTC receive remains a separate SDK ownership task. diff --git a/src/app/wallets/create-owned-on-chain-address.ts b/src/app/wallets/create-owned-on-chain-address.ts index a7f32cbfb..468eadc53 100644 --- a/src/app/wallets/create-owned-on-chain-address.ts +++ b/src/app/wallets/create-owned-on-chain-address.ts @@ -1,5 +1,9 @@ import { address, networks } from "bitcoinjs-lib" +import { + CashWalletClientCapabilities, + resolveCashWalletMutationWalletIdForAccount, +} from "@app/cash-wallet-cutover" import { NETWORK } from "@config" import { AccountValidator, InvalidWalletForAccountError } from "@domain/accounts" import { WalletCurrency } from "@domain/shared" @@ -12,9 +16,11 @@ import { AccountsRepository, WalletsRepository } from "@services/mongoose" export const createOwnedOnChainAddress = async ({ accountId, walletId, + client, }: { accountId: AccountId walletId: WalletId + client: CashWalletClientCapabilities }): Promise => { const wallet = await WalletsRepository().findById(walletId) // Use one refusal for absent, foreign, external, and wrong-currency wallets. @@ -33,7 +39,29 @@ export const createOwnedOnChainAddress = async ({ const active = AccountValidator(account).isActive() if (active instanceof Error) return active - const response = await Ibex.generateBitcoinAddress(walletId) + // The presented USD wallet can be an alias for the active USDT settlement + // wallet after cutover. Prove ownership and active account status before + // resolving that alias, because resolution may self-heal a missing wallet. + const routedWalletId = await resolveCashWalletMutationWalletIdForAccount({ + account, + walletId, + client, + }) + if (routedWalletId instanceof Error) return routedWalletId + + if (routedWalletId !== walletId) { + const routedWallet = await WalletsRepository().findById(routedWalletId) + if ( + routedWallet instanceof Error || + routedWallet.accountId !== accountId || + routedWallet.type !== WalletType.Checking || + routedWallet.currency !== WalletCurrency.Usdt + ) { + return new InvalidWalletForAccountError() + } + } + + const response = await Ibex.generateBitcoinAddress(routedWalletId) if (response instanceof IbexError) return response const value = response.address if (typeof value !== "string") diff --git a/src/graphql/public/root/mutation/on-chain-address-create.ts b/src/graphql/public/root/mutation/on-chain-address-create.ts index 3d446d336..b953d6946 100644 --- a/src/graphql/public/root/mutation/on-chain-address-create.ts +++ b/src/graphql/public/root/mutation/on-chain-address-create.ts @@ -20,7 +20,7 @@ const OnChainAddressCreateMutation = GT.Field({ args: { input: { type: GT.NonNull(OnChainAddressCreateInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, cashWalletClientCapabilities }) => { const { walletId } = args.input if (walletId instanceof Error) { return { errors: [{ message: walletId.message }] } @@ -34,6 +34,7 @@ const OnChainAddressCreateMutation = GT.Field({ const address = await createOwnedOnChainAddress({ accountId: domainAccount.id, walletId, + client: cashWalletClientCapabilities, }) if (address instanceof Error) { return { errors: [mapAndParseErrorForGqlResponse(address)] } diff --git a/src/graphql/public/root/mutation/on-chain-address-current.ts b/src/graphql/public/root/mutation/on-chain-address-current.ts index 42a94793e..3971443d3 100644 --- a/src/graphql/public/root/mutation/on-chain-address-current.ts +++ b/src/graphql/public/root/mutation/on-chain-address-current.ts @@ -20,7 +20,7 @@ const OnChainAddressCurrentMutation = GT.Field({ args: { input: { type: GT.NonNull(OnChainAddressCurrentInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, cashWalletClientCapabilities }) => { const { walletId } = args.input if (walletId instanceof Error) { return { errors: [{ message: walletId.message }] } @@ -34,6 +34,7 @@ const OnChainAddressCurrentMutation = GT.Field({ const address = await createOwnedOnChainAddress({ accountId: domainAccount.id, walletId, + client: cashWalletClientCapabilities, }) if (address instanceof Error) return { errors: [mapAndParseErrorForGqlResponse(address)] } diff --git a/test/flash/unit/graphql/public/root/mutation/on-chain-address-ownership.spec.ts b/test/flash/unit/graphql/public/root/mutation/on-chain-address-ownership.spec.ts index 08c7754ad..c346518a6 100644 --- a/test/flash/unit/graphql/public/root/mutation/on-chain-address-ownership.spec.ts +++ b/test/flash/unit/graphql/public/root/mutation/on-chain-address-ownership.spec.ts @@ -1,6 +1,12 @@ const mockFindWallet = jest.fn() const mockFindAccount = jest.fn() const mockGenerateAddress = jest.fn() +const mockRouteCashWallet = jest.fn() + +jest.mock("@app/cash-wallet-cutover", () => ({ + resolveCashWalletMutationWalletIdForAccount: (args: unknown) => + mockRouteCashWallet(args), +})) jest.mock("@services/mongoose", () => ({ WalletsRepository: () => ({ @@ -30,6 +36,11 @@ import { UnexpectedIbexResponse } from "@services/ibex/errors" const accountId = "owner-account" as AccountId const walletId = "11111111-1111-4111-8111-111111111111" as WalletId +const settlementWalletId = "22222222-2222-4222-8222-222222222222" as WalletId +const client = { + cashWalletPresentation: "usdt" as const, + hasUsdtCashWalletSupport: true, +} const ownedWallet = { id: walletId, accountId, @@ -63,7 +74,10 @@ const resolve = (mutation: (typeof mutations)[number], owner?: string) => (mutation as unknown as Mutation).resolve( null, { input: { walletId } }, - { domainAccount: owner ? { id: owner } : undefined } as GraphQLPublicContextAuth, + { + domainAccount: owner ? { id: owner } : undefined, + cashWalletClientCapabilities: client, + } as GraphQLPublicContextAuth, {} as never, ) @@ -72,6 +86,9 @@ beforeEach(() => { mockFindWallet.mockResolvedValue(ownedWallet) mockFindAccount.mockResolvedValue({ id: accountId, status: "active" }) mockGenerateAddress.mockResolvedValue({ address: ownedAddress }) + mockRouteCashWallet.mockImplementation( + async ({ walletId }: { walletId: WalletId }) => walletId, + ) }) describe.each(mutations)("on-chain receive resolver %#", (mutation) => { @@ -79,7 +96,9 @@ describe.each(mutations)("on-chain receive resolver %#", (mutation) => { const result = await resolve(mutation) expect(result.errors).toHaveLength(1) expect(mockFindWallet).not.toHaveBeenCalled() + expect(mockRouteCashWallet).not.toHaveBeenCalled() expect(mockGenerateAddress).not.toHaveBeenCalled() + expect(mockRouteCashWallet).not.toHaveBeenCalled() }) it("refuses a foreign wallet without creating a provider address", async () => { @@ -101,6 +120,51 @@ describe.each(mutations)("on-chain receive resolver %#", (mutation) => { expect(result.errors).toHaveLength(1) expect(result.address).toBeUndefined() }) + + it("routes an owned legacy USD alias to the active USDT IBEX account", async () => { + mockRouteCashWallet.mockResolvedValue(settlementWalletId) + mockFindWallet.mockImplementation(async (id: WalletId) => + id === walletId + ? { ...ownedWallet, currency: WalletCurrency.Usd } + : { ...ownedWallet, id: settlementWalletId }, + ) + const result = await resolve(mutation, accountId) + expect(result).toMatchObject({ errors: [], address: ownedAddress }) + expect(mockRouteCashWallet).toHaveBeenCalledWith({ + account: expect.objectContaining({ id: accountId }), + walletId, + client, + }) + expect(mockFindWallet).toHaveBeenCalledWith(settlementWalletId) + expect(mockGenerateAddress).toHaveBeenCalledWith(settlementWalletId) + }) + + it("refuses a cash alias that resolves to a foreign wallet", async () => { + mockRouteCashWallet.mockResolvedValue(settlementWalletId) + mockFindWallet.mockImplementation(async (id: WalletId) => + id === walletId + ? { ...ownedWallet, currency: WalletCurrency.Usd } + : { ...ownedWallet, id: settlementWalletId, accountId: "foreign" }, + ) + const result = await resolve(mutation, accountId) + expect(result.errors).toHaveLength(1) + expect(mockGenerateAddress).not.toHaveBeenCalled() + }) + + it.each([ + { type: WalletType.External, currency: WalletCurrency.Usdt }, + { type: WalletType.Checking, currency: WalletCurrency.Usd }, + ])("refuses a routed wallet outside USDT checking", async (selection) => { + mockRouteCashWallet.mockResolvedValue(settlementWalletId) + mockFindWallet.mockImplementation(async (id: WalletId) => + id === walletId + ? { ...ownedWallet, currency: WalletCurrency.Usd } + : { ...ownedWallet, id: settlementWalletId, ...selection }, + ) + const result = await resolve(mutation, accountId) + expect(result.errors).toHaveLength(1) + expect(mockGenerateAddress).not.toHaveBeenCalled() + }) }) describe("owned on-chain receive boundary", () => { @@ -112,28 +176,34 @@ describe("owned on-chain receive boundary", () => { "rejects non-owned or non-cash checking wallet before provider call", async (wallet) => { mockFindWallet.mockResolvedValue(wallet) - expect(await createOwnedOnChainAddress({ accountId, walletId })).toBeInstanceOf( - InvalidWalletForAccountError, - ) + expect( + await createOwnedOnChainAddress({ accountId, walletId, client }), + ).toBeInstanceOf(InvalidWalletForAccountError) + expect(mockRouteCashWallet).not.toHaveBeenCalled() expect(mockGenerateAddress).not.toHaveBeenCalled() }, ) it("refuses an inactive owner and invalid provider checksum", async () => { mockFindAccount.mockResolvedValue({ id: accountId, status: "locked" }) - expect(await createOwnedOnChainAddress({ accountId, walletId })).toBeInstanceOf(Error) + expect( + await createOwnedOnChainAddress({ accountId, walletId, client }), + ).toBeInstanceOf(Error) + expect(mockRouteCashWallet).not.toHaveBeenCalled() expect(mockGenerateAddress).not.toHaveBeenCalled() mockFindAccount.mockResolvedValue({ id: accountId, status: "active" }) mockGenerateAddress.mockResolvedValue({ address: `${ownedAddress.slice(0, -1)}q` }) - expect(await createOwnedOnChainAddress({ accountId, walletId })).toBeInstanceOf( - UnexpectedIbexResponse, - ) + expect( + await createOwnedOnChainAddress({ accountId, walletId, client }), + ).toBeInstanceOf(UnexpectedIbexResponse) }) it("permits an owned USD checking wallet", async () => { mockFindWallet.mockResolvedValue({ ...ownedWallet, currency: WalletCurrency.Usd }) - expect(await createOwnedOnChainAddress({ accountId, walletId })).toBe(ownedAddress) + expect(await createOwnedOnChainAddress({ accountId, walletId, client })).toBe( + ownedAddress, + ) expect(mockGenerateAddress).toHaveBeenCalledWith(walletId) }) }) From eefb5c329a45f7c56e76f93333fd2e54ba8888c2 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 02:47:29 -0700 Subject: [PATCH 41/42] Remove duplicate on-chain refusal assertion --- .../public/root/mutation/on-chain-address-ownership.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/flash/unit/graphql/public/root/mutation/on-chain-address-ownership.spec.ts b/test/flash/unit/graphql/public/root/mutation/on-chain-address-ownership.spec.ts index c346518a6..de7c44562 100644 --- a/test/flash/unit/graphql/public/root/mutation/on-chain-address-ownership.spec.ts +++ b/test/flash/unit/graphql/public/root/mutation/on-chain-address-ownership.spec.ts @@ -98,7 +98,6 @@ describe.each(mutations)("on-chain receive resolver %#", (mutation) => { expect(mockFindWallet).not.toHaveBeenCalled() expect(mockRouteCashWallet).not.toHaveBeenCalled() expect(mockGenerateAddress).not.toHaveBeenCalled() - expect(mockRouteCashWallet).not.toHaveBeenCalled() }) it("refuses a foreign wallet without creating a provider address", async () => { From 27ceabc9aaf01b2fdd7dc1ef50b0c29a706b4688 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 23 Oct 2026 02:52:29 -0700 Subject: [PATCH 42/42] Freeze pending Bridge withdrawal fee snapshots --- docs/bridge-withdrawal-quote-gate.md | 44 +++++ src/services/bridge/index.ts | 154 +++++++++++++----- test/flash/unit/services/bridge/index.spec.ts | 124 ++++++++++++-- .../services/bridge/return-shapes.spec.ts | 6 + 4 files changed, 273 insertions(+), 55 deletions(-) create mode 100644 docs/bridge-withdrawal-quote-gate.md diff --git a/docs/bridge-withdrawal-quote-gate.md b/docs/bridge-withdrawal-quote-gate.md new file mode 100644 index 000000000..decfe47a4 --- /dev/null +++ b/docs/bridge-withdrawal-quote-gate.md @@ -0,0 +1,44 @@ +# Bridge withdrawal quote and consent gate + +The existing two-step GraphQL flow stores a pending withdrawal before calling +Bridge. RN then queries that row and confirms by ID. The pinned RN confirmation +screen calculates a fixed 0.5% fee locally from `withdrawal.amount`; the backend +stores a configured Flash developer fee plus estimated Bridge and gas fees from +a changing market snapshot. No confirmation input binds the reviewed amount, +wallet, bank, fee total, or quote expiry. This is not an immutable reviewed +payment quote, and native withdrawal initiation remains closed. + +This backend hardening keeps the first persisted fee snapshot on a pending +request. Retrying the same account/destination/amount no longer refreshes that +snapshot underneath an open confirmation screen. An old pending row missing a +complete estimate now refuses before transfer creation and can be cancelled +and requested anew. Initiation independently refuses malformed/non-USDT rows, +missing or fee-consuming estimates, and external accounts that are no longer +verified. The direct service entry point also refuses malformed amount strings. +The service parses up to six USDT decimal places into exact microunits and +refuses amounts above one billion USDT, a defensive representability bound +rather than a business transaction limit. +These checks do not prove that a client displayed or accepted the estimate. + +The Bridge transfer receipt's `amount` and `currency` can describe the payout, +while the IBEX USDT debit uses the original pending `amount`. Bridge's official +[orchestration overview](https://www.withbridge.com/product/orchestration) +describes determining the source amount needed for a destination amount; it +does not supply an executable debit cap or a frozen provider quote in this +repository. The current code still writes the returned transfer amount/currency +over the pending amount/currency after creation. Before enabling native +withdrawal, establish source and destination units with a sandbox transfer, +persist them separately, and verify the source debit against a user-approved +maximum. Also add a short server-enforced quote expiry and a confirmation +contract that echoes the exact reviewed amount, source wallet, bank and fee +bound. Do not infer fee consent from the existence of a pending row. + +A timeout during `createTransfer` can occur after Bridge created a transfer but +before Mongo recorded its ID. The same pending ID derives a stable idempotency +key, but this repository has no proven provider retention window or lookup by +that key. Do not treat a retry or a missing local transfer ID as proof no +transfer exists. Operational recovery requires authoritative Bridge evidence +or a durable creation-uncertainty fence before automatic retry. The existing +IBEX send uncertainty fence starts later, after a transfer ID is recorded. + +No live Bridge or IBEX call was used for this audit or its focused tests. diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index 35923bbe4..fb75fdb9e 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -69,6 +69,7 @@ import { presentBridgeWithdrawal, receiptFeesFromTransfer, resolveWithdrawalCustomerFeeEstimate, + type BridgeWithdrawalLike, type PresentedBridgeWithdrawal, } from "./withdrawal-fees" @@ -162,6 +163,54 @@ type ExternalAccountResult = { export const deriveWithdrawalIdempotencyKey = (rowId: string): string => crypto.createHash("sha256").update(`withdrawal:${rowId}`).digest("hex") +// The provider rail uses six decimal USDT units. Keep arithmetic within a +// deliberately generous bound whose integer microunits are exact in JS. +const MAX_WITHDRAWAL_MICROS = 1_000_000_000_000_000n +const decimalMicros = (value: string): bigint | undefined => { + const parts = /^(\d{1,10})(?:\.(\d{1,6}))?$/.exec(value) + if (!parts) return undefined + const micros = BigInt(parts[1]) * 1_000_000n + BigInt((parts[2] ?? "").padEnd(6, "0")) + return micros <= MAX_WITHDRAWAL_MICROS ? micros : undefined +} + +const validWithdrawalAmount = (amount: string): boolean => { + const micros = decimalMicros(amount) + return micros !== undefined && micros > 0n +} + +const hasFrozenWithdrawalFeeEstimate = ( + withdrawal: Pick< + BridgeWithdrawalLike, + | "flashFeePercent" + | "flashFee" + | "estimatedBridgeFeePercent" + | "estimatedBridgeFee" + | "estimatedGasBuffer" + | "estimatedCustomerFee" + >, +): boolean => { + const percentages = [withdrawal.flashFeePercent, withdrawal.estimatedBridgeFeePercent] + if ( + !percentages.every( + (value) => + typeof value === "string" && + /^\d+(?:\.\d{1,6})?$/.test(value) && + Number.isFinite(Number(value)), + ) + ) + return false + const parts = [ + withdrawal.flashFee, + withdrawal.estimatedBridgeFee, + withdrawal.estimatedGasBuffer, + withdrawal.estimatedCustomerFee, + ].map((value) => (typeof value === "string" ? decimalMicros(value) : undefined)) + return ( + parts.every((value) => value !== undefined) && + parts[0]! + parts[1]! + parts[2]! === parts[3] + ) +} + const checkedWithdrawalDepositAddress = ( transfer: Transfer, customerId: string, @@ -1229,6 +1278,9 @@ const requestWithdrawal = async ( const enabledCheck = checkBridgeEnabled() if (enabledCheck instanceof Error) return enabledCheck + if (!validWithdrawalAmount(amount)) { + return new BridgeInsufficientFundsError("Invalid withdrawal amount") + } const account = await checkAccountLevel(accountId) if (account instanceof Error) return account @@ -1275,10 +1327,7 @@ const requestWithdrawal = async ( if (!(balance instanceof USDTAmount)) { return new BridgeInsufficientFundsError("Invalid balance type") } - const withdrawalAmount = parseFloat(amount) - if (isNaN(withdrawalAmount) || withdrawalAmount <= 0) { - return new BridgeInsufficientFundsError("Invalid withdrawal amount") - } + const withdrawalAmount = Number(amount) const availableBalance = balance.toIbex() if (availableBalance < withdrawalAmount) { @@ -1309,13 +1358,6 @@ const requestWithdrawal = async ( return new BridgeError("External account is not verified") } - const feeEstimate = await resolveWithdrawalCustomerFeeEstimate(amount) - if (withdrawalAmount <= parseFloat(feeEstimate.estimatedCustomerFee)) { - return new BridgeWithdrawalNetAmountTooLowError( - `Withdrawal amount ${amount} must exceed estimated customer fees ${feeEstimate.estimatedCustomerFee}`, - ) - } - const existingWithdrawal = await BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer( accountId as string, @@ -1323,40 +1365,63 @@ const requestWithdrawal = async ( amount, ) if (existingWithdrawal instanceof Error) return existingWithdrawal - - let pendingWithdrawal if (existingWithdrawal) { - pendingWithdrawal = await BridgeAccountsRepo.updateWithdrawalFeeEstimates( - BridgeAccountsRepo.bridgeWithdrawalRecordId(existingWithdrawal), - feeEstimate, - ) - } else { - pendingWithdrawal = await BridgeAccountsRepo.createWithdrawal({ - accountId: accountId as string, - amount, - currency: "usdt", - externalAccountId, - flashFeePercent: feeEstimate.flashFeePercent, - flashFee: feeEstimate.flashFee, - estimatedBridgeFeePercent: feeEstimate.estimatedBridgeFeePercent, - estimatedBridgeFee: feeEstimate.estimatedBridgeFee, - estimatedGasBuffer: feeEstimate.estimatedGasBuffer, - estimatedCustomerFee: feeEstimate.estimatedCustomerFee, - status: "pending", - }) + // Reusing a pending request reads its original quote. No changing gas + // probe can overwrite or mask what another confirmation screen saw. + const storedFeeMicros = decimalMicros(existingWithdrawal.estimatedCustomerFee) + const requestedMicros = decimalMicros(amount) if ( - !(pendingWithdrawal instanceof Error) && - !pendingWithdrawal.estimatedCustomerFee + !hasFrozenWithdrawalFeeEstimate(existingWithdrawal) || + storedFeeMicros === undefined || + requestedMicros === undefined || + storedFeeMicros >= requestedMicros ) { - pendingWithdrawal = await BridgeAccountsRepo.updateWithdrawalFeeEstimates( - BridgeAccountsRepo.bridgeWithdrawalRecordId(pendingWithdrawal), - feeEstimate, + return new BridgeError( + "Withdrawal fee estimate is unavailable; cancel and request again", ) } + return presentBridgeWithdrawal(existingWithdrawal) + } + + const feeEstimate = await resolveWithdrawalCustomerFeeEstimate(amount) + if (!hasFrozenWithdrawalFeeEstimate(feeEstimate)) { + return new BridgeError("Withdrawal fee estimate is unavailable") + } + const feeMicros = decimalMicros(feeEstimate.estimatedCustomerFee) + const requestedMicros = decimalMicros(amount) + if (feeMicros === undefined || requestedMicros === undefined) { + return new BridgeError("Withdrawal fee estimate is unavailable") + } + if (requestedMicros <= feeMicros) { + return new BridgeWithdrawalNetAmountTooLowError( + `Withdrawal amount ${amount} must exceed estimated customer fees ${feeEstimate.estimatedCustomerFee}`, + ) } + + const pendingWithdrawal = await BridgeAccountsRepo.createWithdrawal({ + accountId: accountId as string, + amount, + currency: "usdt", + externalAccountId, + flashFeePercent: feeEstimate.flashFeePercent, + flashFee: feeEstimate.flashFee, + estimatedBridgeFeePercent: feeEstimate.estimatedBridgeFeePercent, + estimatedBridgeFee: feeEstimate.estimatedBridgeFee, + estimatedGasBuffer: feeEstimate.estimatedGasBuffer, + estimatedCustomerFee: feeEstimate.estimatedCustomerFee, + status: "pending", + }) if (pendingWithdrawal instanceof Error) { return asBridgeRequestWithdrawalError(pendingWithdrawal) } + // A retry or racing request must show the same persisted fee estimate. + // Never refresh a pending row underneath a confirmation screen. Legacy + // rows without a complete estimate require cancellation and a new request. + if (!hasFrozenWithdrawalFeeEstimate(pendingWithdrawal)) { + return new BridgeError( + "Withdrawal fee estimate is unavailable; cancel and request again", + ) + } baseLogger.info( { @@ -1367,7 +1432,7 @@ const requestWithdrawal = async ( "Bridge operation completed", ) - return presentBridgeWithdrawal(pendingWithdrawal, feeEstimate) + return presentBridgeWithdrawal(pendingWithdrawal) } catch (error) { baseLogger.error( { accountId, operation: "requestWithdrawal", error }, @@ -1459,6 +1524,18 @@ const initiateWithdrawal = async ( } const { amount, externalAccountId } = pendingWithdrawal + const amountMicros = decimalMicros(amount) + const feeMicros = decimalMicros(pendingWithdrawal.estimatedCustomerFee) + if ( + pendingWithdrawal.currency !== "usdt" || + amountMicros === undefined || + amountMicros === 0n || + !hasFrozenWithdrawalFeeEstimate(pendingWithdrawal) || + feeMicros === undefined || + feeMicros >= amountMicros + ) { + return new BridgeError("Withdrawal quote is unavailable; cancel and request again") + } const externalAccounts = await syncExternalAccountsFromBridge( accountId as string, @@ -1471,6 +1548,9 @@ const initiateWithdrawal = async ( if (!targetAccount) { return new Error("External account not found") } + if (targetAccount.status !== "verified") { + return new BridgeError("External account is not verified") + } // Re-check balance at execution time — funds may have changed since the request const wallets = await WalletsRepository().listByAccountId(accountId) diff --git a/test/flash/unit/services/bridge/index.spec.ts b/test/flash/unit/services/bridge/index.spec.ts index 942e86bb5..8e0f2d312 100644 --- a/test/flash/unit/services/bridge/index.spec.ts +++ b/test/flash/unit/services/bridge/index.spec.ts @@ -744,24 +744,38 @@ describe("requestWithdrawal", () => { expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() }) + it.each(["50.00extra", "1e309", "1000000000.000001", "0", "-10.00"])( + "refuses invalid direct-service withdrawal amount %s before persistence", + async (invalidAmount) => { + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + invalidAmount, + EXTERNAL_ACCOUNT_ID, + ) + expect(result).toBeInstanceOf(Error) + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }, + ) + it("never creates a Bridge transfer", async () => { await BridgeService.requestWithdrawal(ACCOUNT_ID, AMOUNT, EXTERNAL_ACCOUNT_ID) expect(BridgeClient.createTransfer).not.toHaveBeenCalled() }) - it("reuses an existing pending withdrawal for the same account, amount, and external account", async () => { + it("reuses the persisted fee snapshot without changing a pending confirmation", async () => { + const { resolveWithdrawalCustomerFeeEstimate } = jest.requireMock( + "@services/bridge/withdrawal-fees", + ) const existingRow = makeRow("withdrawal-existing-001", { - estimatedBridgeFeePercent: undefined, - estimatedBridgeFee: undefined, - estimatedGasBuffer: undefined, - estimatedCustomerFee: undefined, + flashFee: "0.75", + estimatedBridgeFee: "0.20", + estimatedGasBuffer: "0.55", + estimatedCustomerFee: "1.50", }) ;( BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock ).mockResolvedValue(existingRow) - ;(BridgeAccountsRepo.updateWithdrawalFeeEstimates as jest.Mock).mockResolvedValue( - makeRow("withdrawal-existing-001"), - ) const result = await BridgeService.requestWithdrawal( ACCOUNT_ID, @@ -770,22 +784,60 @@ describe("requestWithdrawal", () => { ) expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() - expect(BridgeAccountsRepo.updateWithdrawalFeeEstimates).toHaveBeenCalledWith( - "withdrawal-existing-001", - MOCK_WITHDRAWAL_FEE_ESTIMATE, - ) + expect(BridgeAccountsRepo.updateWithdrawalFeeEstimates).not.toHaveBeenCalled() + expect(resolveWithdrawalCustomerFeeEstimate).not.toHaveBeenCalled() expect(expectSuccess(result)).toMatchObject({ id: "withdrawal-existing-001", status: "pending", + flashFee: "0.75", estimatedBridgeFeePercent: "0.6", - estimatedBridgeFee: "0.30", - estimatedGasBuffer: "1.00", - estimatedCustomerFee: "2.30", - subtotalAmount: "47.70", - finalAmount: "47.70", + estimatedBridgeFee: "0.20", + estimatedGasBuffer: "0.55", + estimatedCustomerFee: "1.50", + subtotalAmount: "48.50", + finalAmount: "48.50", }) }) + it("refuses a legacy pending row with no complete fee snapshot", async () => { + ;( + BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock + ).mockResolvedValue( + makeRow("withdrawal-legacy-001", { estimatedCustomerFee: undefined }), + ) + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + expect(result).toBeInstanceOf(Error) + expect(BridgeAccountsRepo.updateWithdrawalFeeEstimates).not.toHaveBeenCalled() + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + }) + + it("presents the persisted winner's fees after a concurrent create", async () => { + ;(BridgeAccountsRepo.createWithdrawal as jest.Mock).mockResolvedValue( + makeRow("withdrawal-race-001", { + flashFee: "0.75", + estimatedBridgeFee: "0.20", + estimatedGasBuffer: "0.55", + estimatedCustomerFee: "1.50", + }), + ) + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + expect(expectSuccess(result)).toMatchObject({ + id: "withdrawal-race-001", + flashFee: "0.75", + estimatedCustomerFee: "1.50", + finalAmount: "48.50", + }) + expect(BridgeAccountsRepo.updateWithdrawalFeeEstimates).not.toHaveBeenCalled() + }) + it("returns an error when the external account does not belong to the caller (CRIT-2)", async () => { ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ { bridgeExternalAccountId: "somebody-elses-account", status: "verified" }, @@ -840,6 +892,7 @@ describe("requestWithdrawal", () => { ) resolveWithdrawalCustomerFeeEstimate.mockResolvedValue({ ...MOCK_WITHDRAWAL_FEE_ESTIMATE, + flashFee: "48.70", estimatedCustomerFee: AMOUNT, }) @@ -853,7 +906,7 @@ describe("requestWithdrawal", () => { "@services/bridge/errors", ) expect(result).toBeInstanceOf(BridgeWithdrawalNetAmountTooLowError) - expect(BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer).not.toHaveBeenCalled() + expect(BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer).toHaveBeenCalled() expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() }) @@ -1091,6 +1144,41 @@ describe("initiateWithdrawal — takes withdrawalId (step 2A)", () => { expect(IbexClient.sendOnchain).not.toHaveBeenCalled() }) + it.each([ + { amount: "50.00extra" }, + { amount: "1e309" }, + { amount: "1000000000.000001" }, + { currency: "usd" }, + { estimatedCustomerFee: undefined }, + { estimatedCustomerFee: AMOUNT }, + ])( + "refuses a pending row without a valid frozen debit and fee snapshot", + async (change) => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, change), + ) + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + expect(result).toBeInstanceOf(Error) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + expect(IbexClient.sendCrypto).not.toHaveBeenCalled() + }, + ) + + it("refuses a bank account that lost verified status before initiation", async () => { + ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ + { + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "pending", + }, + ]) + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + expect(result).toBeInstanceOf(Error) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + expect(IbexClient.sendCrypto).not.toHaveBeenCalled() + }) + it("creates a Bridge transfer that asks Bridge for source deposit instructions", async () => { await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) diff --git a/test/flash/unit/services/bridge/return-shapes.spec.ts b/test/flash/unit/services/bridge/return-shapes.spec.ts index c8dea3241..f84cdb38e 100644 --- a/test/flash/unit/services/bridge/return-shapes.spec.ts +++ b/test/flash/unit/services/bridge/return-shapes.spec.ts @@ -186,6 +186,12 @@ const makeRow = (overrides: Record = {}) => ({ currency: "usdt", externalAccountId: EXTERNAL_ACCOUNT_ID, status: "pending" as const, + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "1.00", + estimatedCustomerFee: "2.30", bridgeTransferId: undefined, failureReason: undefined, createdAt: CREATED_AT,