The integrations layer is built to the consent screen and stops there
Affects: backend/src/integrations/**, backend/src/controllers/integrations.controller.ts, backend/src/routes/integrations.routes.ts, backend/src/schemas/integrations.schema.ts, backend/prisma/migrations/0004_integrations/migration.sql, backend/src/app.ts, backend/test/integrations.test.ts, backend/test/support/test-env.ts, backend/.env.example
What was decided
Stripe, QuickBooks Online and Salesforce are implemented end to end — schema, migration, RLS policies, OAuth, encrypted credential storage, idempotent sync records, an approval fan-out, an inbound webhook, six HTTP routes, OpenAPI entries and seventy-nine tests — and every one of them is dark. The last step in each case is an action no repository can take on its own behalf: Stripe needs a secret key issued to an account, QuickBooks and Salesforce need a tenant's administrator to click Allow on a screen hosted by the provider.
The alternative was to stop earlier and ship nothing, or to stop later by standing up a mock provider and asserting against it. Both were rejected. The first leaves the hard parts — key custody, replay safety, tenant resolution from an inbound webhook — as an exercise for whoever is holding the credentials, which is the worst possible moment to be designing them. The second produces a suite that proves a stub was called, and passes on the day the real provider changes its response shape.
So the boundary is drawn at the exchange itself and stated in three places: on the diagram (dashed nodes that name what they are waiting for), in GET /api/v1/integrations (each provider reports the exact variables it is still missing), and at the top of the test file.
The five choices inside it that were not obvious
Credentials are sealed, not hashed, and the envelope is versioned. A refresh token has to come back out, so it is AES-256-GCM under INTEGRATIONS_ENCRYPTION_KEY, stored as v1.<iv>.<tag>.<ciphertext>. The v1 prefix is not decoration: rotating the key or the algorithm later means reading old envelopes while writing new ones, and a format with no version field forces that migration to be a guess. Without the key set, no integration can be enabled at all — a QuickBooks refresh token is a standing authorisation against a company's ledger, and one stored in plaintext is one that lives in every backup from now on.
The `state` parameter is signed, and the signature is checked before the expiry. State carries the tenant id and the provider across a redirect we do not control, so it is HMAC-SHA256 over a base64url payload with a 600-second TTL. Verifying the signature first is the whole point: if expiry were checked first, an attacker with an unsigned payload could choose which error comes back, which is a free oracle. Order of operations, one line apart, and it is the difference between a check and a formality.
The Stripe webhook is mounted before `express.json`. Signature verification is over the literal bytes Stripe sent, so express.raw({ type: "*/*" }) sits on that path alone and the JSON parser never sees it. Re-serialising a parsed body produces a different string and therefore a different HMAC — the test suite asserts exactly that, because it is the failure a reviewer will not predict and will not be able to debug from the error message.
Idempotency is enforced twice, against two different things. A unique index on (organization_id, provider, entity_type, local_id) protects our table from duplicate rows. A findSucceededSync read *before* the adapter runs protects the provider's ledger from a duplicate invoice. The index alone would let a second approval push a second invoice and then fail to record it, which is precisely backwards. Replay safety on the inbound side is the same idea in SQL: updateMany({ where: { id, paidAt: null } }) rather than update, so a redelivered invoice.paid cannot rewrite a payment timestamp.
Tenant resolution from an inbound webhook goes through a SECURITY DEFINER function. A Stripe delivery names a Stripe invoice; it does not name a tenant, and RLS means we cannot read the table that would tell us. Rather than granting the API a bypass, integration_sync_lookup(provider, external_id) returns the four columns needed to establish context and nothing else. It joins unscopedForAuthenticationOnly() and unscopedForWebhookResolutionOnly() as the third and last named hole in the boundary, and like them it is a function with a fixed result shape rather than a table grant.
What the tests do and do not prove
A hundred and thirty-two cases across two files, and the split between them is the point. The crypto primitives are tested directly, because their failure modes — a flipped bit in an authentication tag, a timestamp five minutes and one second old, two v1= signatures during a secret roll — are inputs no HTTP test would think to construct. Everything else goes over a socket into the real app.
test-env.ts sets the OAuth client ids and secrets, the encryption key and STRIPE_WEBHOOK_SECRET. It deliberately does not set STRIPE_SECRET_KEY, and the reasoning is written into the file so it survives a future edit: setting it would make stripeAdapter.isConfigured() return true, and the approval fan-out would then POST a real customer and a real invoice to api.stripe.com for every quote the suite approves. Unset, the adapter returns SKIPPED with a message the tests assert against — the safe path and the observable one being the same path.
That leaves the successful half of each token exchange and the successful half of each outbound push, which test/adapters.test.ts covers by replacing globalThis.fetch rather than by pretending to have an account. All three providers reach the network through one function — providerFetch in src/integrations/http.ts — so one substitution is the whole outbound surface. The trap records every request, and the assertions are about the request we built and the response we mapped: Stripe's four calls in the order the API requires, because an invoice item created after the invoice attaches to the *next* invoice; a distinct Idempotency-Key per object, because without one a timeout and a retry is two invoices; the HST line computed by tax.ts rather than by Stripe Tax, which returns zero until a registration is configured; a QuickBooks customer named O'Brien with the apostrophe doubled before it reaches a query language that is SQL-92 in a trench coat; a missing "HST ON" tax code producing an explicit tax line instead of an invoice that quietly under-bills by thirteen per cent; Salesforce's 401 refresh happening exactly once and carrying the old refresh token forward, because the refresh response omits it.
Two guards, deliberately independent, stop that file reaching a provider. support/adapter-env.ts sets STRIPE_SECRET_KEY for this one file to twenty-four characters of the word "fixture", which Stripe would answer 401 to — so a leak is harmless. support/fetch-trap.ts then throws on any URL the current test did not register, with no fallthrough to the real implementation — so a leak is impossible. The absence of the fallthrough is itself asserted, because a comment saying there is no fallthrough is not evidence that there isn't one.
What remains untested, and cannot be tested here, is whether Intuit, Salesforce and Stripe behave as the fixtures say they do. The fixtures are written from the published API shapes; if a provider's response differs, that file passes and production does not. That is the irreducible gap, it is exactly what a live account buys, and it is named rather than papered over.
One implementation detail worth recording because it will bite again
test/integrations.test.ts and test/adapters.test.ts load their src/ modules with await import rather than a static import. This is not style. ESM resolves and loads an entry module's whole static graph before evaluating any of it, and harness.js registers the Prisma mock during its own evaluation — far too late to intercept a load that already happened. The two stores, the fan-out and the registry all reach src/db/prisma.js transitively, so a static import at the top of the file loads the real client, whose first act is to fail looking for a query engine. api-integration.test.ts needs none of this because everything it imports from src/ is a Zod schema, and src/schemas/ is side-effect free by rule. The comment is in the file; this entry exists so the next person who "tidies up the imports" finds the reason before the failure.
What would make this wrong
A second Stripe account, or a tenant wanting to bill through its own Stripe rather than ours. Stripe is modelled as platform-wide — our account, no per-tenant connect flow, POST /integrations/stripe/connect answers 400 — which is correct for a platform that invoices its own customers and wrong for one that processes payments on their behalf. Moving to Stripe Connect changes the connection model rather than correcting it, and should be done when a tenant asks, not in anticipation.