Repairing an inherited B2B backend
Multi-tenant RFQ platform · repair · demo tenant · PDF quotes
# summary
> A multi-tenant RFQ platform for opticians whose core trading flow rejected every request — we found the cause, repaired it, then added a sandboxed demo and server-side PDF quotes.
# the problem
> Every RFQ endpoint rejected every request with BRANCH_REQUIRED, and the cause was not in the RFQ code. There was also no way to show the product to a prospect without handing out a real account on a system holding real tenants' stock, prices and contact details. And an accepted offer produced nothing a buyer could file: no document, no reference number.
# what we built
> We repaired the RFQ flow end to end — create, blast, recipient matching, offers, shortlist, close as order — and proved it by running the whole sequence over real HTTP against a scratch database cloned from the production schema, never the live one. We added a one-click demo: a separate DEMO role living in its own tenant, seeded with branches, inventory, listings, RFQs, offers and orders. The seed includes the cases a demo usually hides — a cancelled order, rejected offers, an item that is out of stock. We added branded PDF quotes, rendered server-side from an offer, attached to the offer record and downloadable by both sides of the trade.
> Along the way we found that PATCH /api/v1/users/:userId referenced an out-of-scope req and threw a ReferenceError on every call, so user administration had never worked — and neither had the "only ADMIN may change verification status" rule sitting inside that handler.


# technical decisions
The RFQ failure was a dead seed, not RFQ code.
The schema-and-seed block inside initDb() sat inside a /* ORIGINAL INIT START … END */ comment — about 1,270 lines of dead code. The live schema had been built by hand with psql, so the system ran fine and nobody noticed the seed had never executed. The result: no user had a branch_id, and every RFQ endpoint hard-requires one.
A second fault sat on top of it. issueTokensForUser() returned branch_id: user.branch_id || user.branch in the login response, while createAccessToken() wrote user.branch_id || null into the JWT — so the client believed it had a branch while req.auth.branch_id was null. Both paths now go through one resolver. We deliberately did not revive the commented block. Re-running 1,270 lines of DDL against a schema that has drifted by hand is a larger risk than the bug it would fix, so new tables arrive through a narrow idempotent step on the live boot path instead.
DEMO is a role deliberately kept out of the role-weight ladder.
Authorisation compares weight >= required against {ADMIN: 3, OWNER: 2, STAFF: 1}. Putting DEMO anywhere in that map grants it everything below its weight. The ladder is precisely why a number was the wrong answer. Instead the demo role satisfies an explicit allow-set (STAFF, OWNER) with a denylist over the destructive owner endpoints, so ADMIN gates never open no matter what weights are added later.
The feature is flag-gated by DEMO_MODE: with it off the demo endpoints return 404 and the login panel component returns null rather than being hidden with CSS. MFA is bypassed only on the demo login path and only for that role. The global DEMO_DISABLE_MFA kill switch stays false and is never read there.
Tenant isolation had to fail closed, which meant moving it out of the response and into the query.
The first version scrubbed response bodies. It classified each row by looking for a known tenant column, and a row carrying no recognised column was classified as belonging to nobody — which the filter read as allowed. The key list was missing the plain branch_id that the leaking payloads actually used. So the filter treated "I don't know" and "harmless" as the same answer.
An isolation test passed while the branch directory, the branch profile, the map pins and the message-recipient list returned real tenants' addresses, phone numbers, emails and inventory rows to anyone who clicked the demo button. An independent reviewer caught it. The lesson is structural, not a missing key: a denylist applied after an unscoped query cannot be made safe, so WHERE company_id = $n was pushed down into the tenant-bearing fetchers and an unknown row is now never selected in the first place.
Two details only surface once you do it that way. company_id needed its own key list rather than being folded in with the branch keys, or the demo user would have 404'd out of its own account. And the map cache key read:map:<lat>:<lng>:<km> was shared across tenants, so a single demo request could warm the cache and then serve sandbox pins to a real user; it is now keyed by company.
Embedding the font is a correctness requirement, not a styling choice.
The documents are Turkish and go to a customer. A missing font file therefore raises a loud error instead of falling back to Helvetica, because the silent fallback is the exact failure the document exists to prevent. The tests assert on text extracted from the produced PDF bytes with pdftotext — every Turkish character, the lira sign, and whole phrases so a mid-word glyph substitution cannot slip through — rather than on the renderer not throwing.
Output is deterministic. pdfkit stamps a wall-clock /CreationDate and derives the trailer /ID from a hash of the info dictionary, so both are pinned to the offer's own timestamp, which is what makes content-addressed storage of the files safe. Money is computed in integer kuruş with VAT rounded per rate group rather than per line. This is also the one place a new dependency was accepted: pdfkit, pinned exact, pulling 21 transitive packages. Hand-rolling TrueType subsetting, glyph widths and a ToUnicode CMap would have been a bigger and riskier change than the dependency it avoids.
# stack
├─ Node.js, Express — the whole API is one file, 143 routes ├─ PostgreSQL, Redis ├─ Next.js (App Router), TypeScript ├─ argon2id password hashing, TOTP MFA, JWT access tokens with │ rotating server-stored refresh tokens ├─ Branch-scoped RBAC; order, escrow, courier and dispute state machines ├─ Server-Sent Events for live updates ├─ pdfkit with an embedded DejaVu Sans subset ├─ Leaflet over OpenStreetMap / Overpass data for the opticians map ├─ node:test for unit and integration tests, Playwright for end-to-end └─ pm2 behind a Cloudflare Tunnel
# outcome
- ├─ The RFQ flow completes: create, blast to matched recipients, two competing offers, shortlist, close — producing a persisted order linked back to the RFQ. Executed over real HTTP against an isolated scratch database, not asserted from code reading.
- ├─ A visitor clicks one button and lands in a working tenant. Verified from the public URL as an anonymous visitor after the fix: real tenants' branch profiles return 404, the branch directory, the message-recipient list and the map's registered pins return only demo branches, and the previously leaked markers — a real address, phone number and email — appear zero times.
- └─ Quote PDFs are byte-identical across repeated downloads, paginate correctly from 1 to 100 line items with repeating table headers and un-orphaned totals, and the Turkish characters survive extraction from the produced file.