Cloud code review — Salus-Website pull request 18

The web checkout and the reconciler, whole.

Pull request #18
Compared from review-base-2026-09-04-web-checkout7fc8e1744b29f541211bbf4fd805a1f0d5d39f83
Compared to review/salus-web-checkout-and-reconcilerbaaef74e02a2d475b60544c6217b3b2485102930
Commit range main at 6876fa554a3ff296699b9386a771b0e39d7e2f29
Ran 07:05 to 07:52 BST, 4 September 2026
Allowance free review 3 of 3
Reviewer's own verify pass 16 found, 16 verified, 5 refuted, deduplicated to 9

The pull request is REVIEW ONLY and is never merged. The fixes below are committed to main instead, exactly as pull request 17's were.

The verdict

The web checkout takes a caller's word for who is buying: two checkout functions read user_id straight out of an unauthenticated body, and the rest of the findings are a lifetime purchase that leaves a Stripe subscription billing, a transient lookup read as an absent account, a dedupe marker written through a swallowed insert, an attribution column renamed out from under a swallowed error, and four smaller faults of scope, bound and duplication.

The findings, money first

Ordered by what can lose money, entitlement or a buyer's identity, not by the order the reviewer wrote them. severity is the reviewer's own word mapped to the board's vocabulary: normal becomes medium, nit becomes low, and the one pre_existing finding is medium with raw_severity keeping the word. Nothing was upgraded or downgraded on the way through.

# File Line Severity What is wrong
1 stripe-checkout/index.ts, create-checkout/index.ts 287-358 medium Both accept user_id from the unauthenticated request body and attribute the purchase to it. hasUser only checks the shape of the identifier. stripe-webhook then reads that field back and, on supersession, cancels any live sub_ subscription for that user — so anybody who knows a victim's account id can start a trial in the victim's name, have the victim's paid subscription cancelled, and cancel their own trial before it charges.
2 lifetime-order/index.ts 154-170 medium cancelPriorRecurring branches on paypal_I- and on apple_/play_, and has no branch for a raw Stripe sub_. A lifetime buyer who already has a Stripe web subscription is charged for the lifetime AND keeps being billed monthly, with no cancel attempted and no alarm raised.
3 lifetime-order/index.ts 263-267 medium In grantFromCapture, admin.getUserById is wrapped in a swallowing .catch, and !u?.user sets userId = null. A transient auth-API failure therefore reads as "this account does not exist", the buyer falls into guest resolution, and the lifetime grant can land on a different account or a newly minted one.
4 create-checkout/index.ts 24-36 medium (pre-existing) Migration 20260810_stripe_provider_and_attribution.sql renamed ad_attribution.paypal_subscription_id to external_ref. create-checkout still writes the old name and still passes it as the conflict target, and the attribution block's own catch swallows the PostgREST 42703. Every Google click id captured on that path is lost without a sound.
5 stripe-webhook/index.ts 372-374 medium The welcome_email_invoked marker — the only per-subscription guard against sending the welcome twice — is written through diag(), whose insert sits inside a try/catch that only logs. A failed marker write still returns sent, so a later event for the same subscription passes both the marker check and the 24-hour rate window and mails a second welcome.
6 iap-diag/index.ts 6-44 low steps is a module-scope array reset with steps.length = 0 at the top of every request, so two overlapping callers wipe and interleave each other's diagnostic output.
7 stripe-checkout/index.ts 75 low landing_url is written raw while every neighbouring attribution field goes through shortAttr(..., 120), so an unauthenticated caller can put an unbounded value into the row.
8 stripe-checkout/index.ts 130-168 low clientIp, rateLimitHit and the two window constants are copied verbatim into three checkout functions with no shared helper.
9 stripe-webhook/index.ts 155-190 low The erased-email lookup and its SHA-256 helper are cloned into three functions, and lifetime-order's copy has already drifted: it collapses an error to unknown with no moneyAlarm.

Where the record lives

2026-09-04-pr18.json beside this file is the machine record, in the shape README.md declares, and it carries the reviewer's untouched output as raw_findings. Each finding's outcome and closed_by say what happened to it.

What was done about them

Fixed at the cause, each with a failing test written first: findings 1, 2, 3, 4, 5, 6 and 7. Deferred with a reason: findings 8 and 9 (see the outcome field in the JSON).

The failing test first. supabase/functions/_shared/review-pr18-wiring_test.ts, run against the unmodified handlers before a line was changed: 0 passed, 16 failed, at least one per finding.

Proof it can fail. supabase/functions/_shared/review-pr18-redproof.sh plants 43 faults, one at a time, and requires the suite to go red for each. 43 planted, 43 killed, 0 survived, against a healthy control that must be green before any mutation counts. Six mutations survived an earlier run and every one was a defect in the PROOF, not in the fix: an anchor that survived a rename, a name that survived inside a guard condition, the same name surviving inside an import line, a short-circuit inserted before a checked call, and a test uuid made only of digits so an upper-case mutation was the same string. All six were repaired before this was called done. The whole Deno suite is 463 green.

What the Council changed. The first run (WARN, 1 PROCEED / 3 WARN) agreed the fixes were right and objected to one decision: a request that NAMED an account with no bearer to prove it was being refused 401, and a ten-minute HTML cache, a stale tab or an expired access token produces exactly that for a real signed-in buyer. Three seats of four said the same thing independently. It is answered in the code: the name is now DISCARDED and the purchase proceeds as a guest, which closes the hole just as completely (nothing is attributed to the named account, so no cancel of theirs can follow) and never refuses a buyer. A mismatch is still refused 403. Three smaller objections were answered in the same pass: the owner-lookup retry is bounded by an attempt ceiling as well as by the clock, because retryCeilingReached(null) is deliberately false for ever; Stripe's own resource_missing code is preferred over its prose; and a standing schema fault is suppressed to one alarm an hour so it cannot bury every other sentinel.

The audit the panel asked for. stripe-webhook calls resolveUserId in exactly three places (lines 972, 1056 and 1134) and its uuidCandidates are only ever session.client_reference_id and sub.metadata.user_id — the two fields these checkout functions write, and both are now written from the verified id alone. No other caller-supplied identifier reaches it; its remaining fallbacks are the existing subscriptions row and the Stripe-verified email.

The second Council round, answered (4 September 2026)

salus-brain/reports/council_web-checkout-review-fixes_2026-09-04-1010.md returned WARN (4 WARN, deepseek silent in both rounds) and its synthesis named two items as blocking before deploy. Both are answered here, neither was handed to anybody.

Blocking item 1: "prove the write sites." The panel could not see, from the diff, where session.client_reference_id and sub.metadata.user_id are actually assigned, and that seam is what the whole identity fix rests on. Measured by reading the tree, not by assertion:

Blocking item 2: "bound the retry when the counter store is unreadable." This was a real defect and it is fixed at the cause, with the failing test written first. retryCeilingReached answers false with no usable clock ON PURPOSE, and rateLimitHit answers null when its own store is unreadable — so when a capture carried no usable create_time AND the counter was down, both halves of the bound were false for ever and lifetime-order would have answered 503 to the buyer's page on every attempt until a human noticed. A counter outage reopened the exact unbounded loop the attempt ceiling had just closed.

Deployment, and the first run on the live path. The fixed functions were NOT deployed when this round began: the deployed stripe-checkout was version 26 of 2026-09-04T00:47:08Z and did not carry decideCheckoutIdentity at all. Deployed through the project's own route (supabase functions deploy <fn> --project-ref egywowuyixfqytaucihf, verify_jwt taken from supabase/config.toml and no flag typed by hand — all three pinned false) and the DEPLOYED BYTES were then read back independently:

function version deployed (UTC) verify_jwt deployed bytes carry
stripe-checkout 27 2026-09-04T07:33:04Z false decideCheckoutIdentity, const buyerId: string | null = identity.userId;, client_reference_id = buyerId
create-checkout 89 2026-09-04T07:33:06Z false decideCheckoutIdentity, const buyerId: string | null = identity.userId;
lifetime-order 33 2026-09-04T07:33:08Z false retryClockUsable, attempts === null && !retryClockUsable(capturedAt)

First-run proof (live path, watched): at 2026-09-04T07:35:40Z a POST to the live stripe-checkout naming user_id 00000000-0000-4000-8000-000000000001 with no bearer token and a valid plan returned HTTP 200 with a Stripe session, not 401. That request reaches decideCheckoutIdentity (plan validation is line 311, the identity decision line 341), so the deployed code took the downgrade-to-guest branch on a live request: the exact behaviour the first Council round demanded, proved on the live function rather than in a test. No money moved; an unused Checkout Session expires on its own. An earlier probe at 07:33:58Z with an invalid plan returned 400 from both stripe-checkout and lifetime-order and proves only that the deployed bytes execute — it stops at line 311 and is recorded as such.

Named debts, declared rather than passed off.

  1. The lifetime-order bound is proved by unit test, mutation kill and deployed-byte read-back. It is UNPROVEN on a live capture: exercising it needs a real PayPal capture whose create_time is unusable while the rate-limit store is down, and no route creates that without a real payment.
  2. The Stripe session created by the live drill was not read back through the Stripe API, so "the downgraded session carries no client_reference_id" is inferred from the deployed source (line 400 is guarded by hasUser), not measured at Stripe.
  3. The Council's non-blocking recommendations remain open and are recorded here rather than silently dropped: a counter/metric for downgraded checkouts, a once-per-id/day diagnostic on Stripe resource_missing, a test for a downgraded signed-in buyer with an email mismatch, and a test that ad_attribution reattaches post-webhook in the downgrade case.