fix(payments): budget claims per recipient, stop caching the 402 credential - #847
Merged
Conversation
…ential Closes the tail of the #563 audit. FINDING 3 — an acknowledge flips an intent to buyer_confirmed and fires a "someone says they paid you" card into the seller's queue. One card is a prompt to check a wallet; a hundred is a denial-of-attention attack whose payoff is ship-the-goods-for-no-money. The budget in front of it was keyed on the caller, and the abuse shape is many addresses aimed at ONE seller. rateLimitPaymentClaim bounds it at 10/hour per recipient, asked once on the real transition — the idempotent re-claim paths return before it, cost an attacker nothing, and so must not spend a genuine payer's allowance either. The guard is INJECTED, not imported. Importing the limiter into the payments domain dragged Upstash's ESM build into eight domain test suites that have no business knowing about Redis — the layering violation announced itself. The HTTP layer owns the policy; the domain owns when it is asked. SUGGESTION 15 — the 402 body embeds `token` and WWW-Authenticate repeats it. That token is what a payer later exchanges for a receipt, so anything caching the response cached a credential for someone else's payment. Every other payment response set no-store; this one did not. SUGGESTION 14 — refreshPaymentStatus, reconcilePaymentIntent and resolveSellerWallet each took a supabase client they had stopped using; all three call getAdminClient() internally, so the signature advertised caller-scoped RLS behaviour that no longer existed. Removing it from resolveSellerWallet made resolveSellerReceiveInfo's client dead the same way, so that goes too. buyerConfirmPayment genuinely uses its client and is untouched — which is why the audit did not list it. SUGGESTION 17 — the semantic fallback and its 0.45 floor had no coverage at all: every forget test disables embeddings, so a silent revert to the old 0.75 would have changed which memories "forget that" reaches with nothing failing. Added a stem-sharing-innocents corpus too, since INCIDENT_CORPUS holds only targets and obvious strangers — a shape that flatters the matcher. One of those tests documents rather than changes behaviour: a single generic word is treated as a TOPIC, so "skills" clears every memory phrased with it. That is the deliberate containment rule (memory-forget.test.ts:138), not an over-match, and #838 makes it visible by naming each deleted memory. Refs #563 finding 3, suggestions 14, 15, 17. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018waGt1ieA9TjpscqrbrnGb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the tail of the #563 audit: finding 3 and suggestions 14, 15 and 17.
Finding 3 — a claim costs the recipient attention, so it is budgeted per recipient
acknowledgePublicPaymentflips an intent tobuyer_confirmedand fires a"someone says they paid you" card into the seller's confirmation queue. One
card is a prompt to check a wallet; a hundred is a denial-of-attention attack
whose payoff is ship-the-goods-for-no-money.
The route in front of it only knows the caller's IP, and the abuse shape is
many addresses aimed at one seller — exactly what a per-IP budget cannot
see.
rateLimitPaymentClaim(entityType, entityId)gives it a per-recipientbound of 10/hour. Creating the intents was already bounded per recipient
(finding 1); this is the second half of the same fence.
Checked in the domain function rather than the route, so every caller is
covered — and only on the real transition: the idempotent re-claim paths return
before it, cost an attacker nothing, and so must not consume a genuine payer's
allowance either.
Suggestion 15 — a 402 carried a bearer credential with no
Cache-ControlThe 402 body embeds
token(<intentId>.<statusToken>) andWWW-Authenticaterepeats it. That token is what a payer later exchanges for a receipt, so
anything that cached the response cached a credential for someone else's
payment. Every other payment response sets
no-store;apiPaymentRequiredwasthe one that did not. It does now, sharing the same
CACHE_PRESETS.NONEstringso it cannot drift from the convention.
Suggestion 14 — signatures advertising authorization that no longer exists
refreshPaymentStatus,reconcilePaymentIntentandresolveSellerWalleteachtook a
supabaseclient they had stopped using — every one callsgetAdminClient()internally. A caller could reasonably pass a user-scopedclient and believe RLS was being enforced. Parameter removed from all three.
Removing it from
resolveSellerWalletmaderesolveSellerReceiveInfo's clientdead in the same way, so that one goes too rather than leaving a fresh instance
of the smell behind.
buyerConfirmPaymentgenuinely uses its client and isuntouched — which is why the audit did not list it.
Suggestion 17 — the two real test gaps
The embeddings floor was never executed. Every forget test disables
embeddings, so the semantic fallback and the
0.45floor it passes tomatch_cat_memorieshad no coverage at all. A silent revert to the old0.75(near-identical-text territory, which never fired for real phrasings) would
have changed which memories a user's "forget that" reaches, and nothing would
have failed. Now pinned, along with the fallback ordering (a word match must
never reach the RPC) and its failure behaviour.
Stem-sharing innocents.
INCIDENT_CORPUSwas built from one realover-deletion, so every row is either a target or obviously unrelated — a shape
that flatters the matcher. Added a corpus where innocents share a stem with the
target ("cooking skills" beside "photography skills"), proving the
word-boundary fix from finding 9 holds against them.
One of those tests documents rather than changes behaviour, and is worth
flagging: a single generic word is treated as a topic, so "skills" clears
every memory phrased with it across unrelated subjects. That is the deliberate
single-word containment rule (
memory-forget.test.ts:138— "this is how a userremoves a whole topic"), not an over-match, and #838 makes it visible by naming
every deleted memory in the reply. Pinned so it stays a decision rather than a
surprise.
The audit's third 17 item — no handler-level test for
forget_memories— wasclosed by #838.
Verification
Full non-incremental
type-checkclean;__tests__/unit/domain/payments,__tests__/unit/catand__tests__/unit/apigreen. Findings 6 and 17(a) wereverified already-closed in main rather than re-done.
Refs #563 finding 3, suggestions 14, 15, 17.