diff --git a/__tests__/unit/api/standardResponse.contract.test.ts b/__tests__/unit/api/standardResponse.contract.test.ts index f96efb225..10ebb9b27 100644 --- a/__tests__/unit/api/standardResponse.contract.test.ts +++ b/__tests__/unit/api/standardResponse.contract.test.ts @@ -37,6 +37,7 @@ jest.mock('@/utils/logger', () => ({ })); import { + apiPaymentRequired, apiSuccess, apiError, apiBadRequest, @@ -222,3 +223,28 @@ describe('apiRateLimited — Retry-After header', () => { expect(headers.get('Retry-After')).toBeNull(); }); }); + +/** + * A 402 carries a bearer credential, so it must never be cached. + * + * The body embeds `token` (`.`) and WWW-Authenticate + * repeats it — that token is what a payer later exchanges for a receipt. So + * anything caching this response caches a credential for someone else's + * payment. Every other payment response sets no-store; this one did not. + * bitbaum/orangecat#563 suggestion 15. + */ +describe('apiPaymentRequired — never cached', () => { + const headersOf = (r: unknown) => (r as { headers: Headers }).headers; + + it('sets no-store', () => { + expect( + headersOf(apiPaymentRequired('Pay up', { token: 'pi-1.tok' })).get('Cache-Control') + ).toBe('no-store, must-revalidate'); + }); + + it('sets it on the challenge form, where the header repeats the token', () => { + const res = apiPaymentRequired('Pay up', { token: 'pi-1.tok' }, 'L402 token="pi-1.tok"'); + expect(headersOf(res).get('Cache-Control')).toBe('no-store, must-revalidate'); + expect(headersOf(res).get('WWW-Authenticate')).toContain('L402'); + }); +}); diff --git a/__tests__/unit/cat/forget-generic-stem-innocents.test.ts b/__tests__/unit/cat/forget-generic-stem-innocents.test.ts new file mode 100644 index 000000000..e861eae0b --- /dev/null +++ b/__tests__/unit/cat/forget-generic-stem-innocents.test.ts @@ -0,0 +1,94 @@ +/** + * Innocent memories that merely SHARE A STEM with the target must survive. + * + * INCIDENT_CORPUS was built from one real over-deletion, so every row in it is + * either a target or obviously unrelated. That shape flatters the matcher: the + * dangerous case is a memory that shares a common stem with the forget phrase + * and is nonetheless a different fact — "cooking skills" beside "photography + * skills", "documentary work" beside "weekend work". + * + * bitbaum/orangecat#563 suggestion 17: the corpus lacked these, so nothing + * proved the word-boundary fix (#831 / finding 9) held against them. + */ + +import { forgetMemoriesMatching } from '@/services/cat/memory'; +import type { AnySupabaseClient } from '@/lib/supabase/types'; + +jest.mock('@/utils/logger', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +jest.mock('@/services/ai/embeddings', () => ({ + embeddingsEnabled: () => false, + embedText: jest.fn(), + embedTexts: jest.fn(), +})); + +interface Row { + id: string; + content: string; +} + +function clientFor(corpus: Row[]) { + return { + from: () => ({ + select: () => ({ eq: async () => ({ data: corpus, error: null }) }), + delete: () => ({ eq: () => ({ in: async () => ({ error: null }) }) }), + insert: async () => ({ error: null }), + upsert: async () => ({ error: null }), + }), + rpc: async () => ({ data: [], error: null }), + } as unknown as AnySupabaseClient; +} + +/** Targets plus innocents that share a stem with each target. */ +const CORPUS: Row[] = [ + { id: 'a1', content: 'Has photography skills from years of freelance work' }, + { id: 'a2', content: 'Has strong cooking skills' }, + { id: 'a3', content: 'Can only work on weekends.' }, + { id: 'a4', content: 'Has a documentary photography background' }, + { id: 'a5', content: 'Prefers Lightning over on-chain payments' }, + { id: 'a6', content: 'Owns a drone.' }, +]; + +const forget = (facts: string[]) => forgetMemoriesMatching(clientFor(CORPUS), 'u1', facts); + +describe('forget leaves stem-sharing innocents alone', () => { + it('"cooking skills" does not take the photography skills with it', async () => { + const result = await forget(['cooking skills']); + expect(result.deleted).toEqual(['Has strong cooking skills']); + }); + + it('"photography skills" does not take the cooking skills with it', async () => { + const result = await forget(['photography skills']); + expect(result.deleted).not.toContain('Has strong cooking skills'); + // Both photography rows are legitimately about photography. + for (const kept of result.deleted) { + expect(kept.toLowerCase()).toContain('photograph'); + } + }); + + /** + * Single-word containment is DELIBERATE — memory-forget.test.ts:138 pins it + * as how a user removes a whole topic ("forget photography" clears every + * photography memory). The consequence, pinned here so it is a decision and + * not a surprise: a generic word like "skills" is treated as a topic too, so + * it clears every memory phrased with it, across unrelated subjects. + * + * That is the accepted cost of topic removal, and #838 makes it visible — + * the reply names each memory it deleted, so an over-broad word is caught by + * the user reading the receipt rather than discovered later. + */ + it('treats a generic word as a topic, clearing every memory phrased with it', async () => { + const result = await forget(['skills']); + expect(result.deleted).toEqual([ + 'Has photography skills from years of freelance work', + 'Has strong cooking skills', + ]); + }); + + it('"weekend work" does not reach "freelance work"', async () => { + const result = await forget(['weekend work']); + expect(result.deleted).not.toContain('Has photography skills from years of freelance work'); + }); +}); diff --git a/__tests__/unit/cat/forget-semantic-floor.test.ts b/__tests__/unit/cat/forget-semantic-floor.test.ts new file mode 100644 index 000000000..f47d46a06 --- /dev/null +++ b/__tests__/unit/cat/forget-semantic-floor.test.ts @@ -0,0 +1,87 @@ +/** + * The semantic fallback and its similarity floor, actually executed. + * + * Every other forget test disables embeddings, so the branch that runs when + * text containment finds NOTHING — and the 0.45 floor it passes to + * match_cat_memories — was never exercised by any test. The floor was moved + * from 0.75 to 0.45 on measured production data; a silent revert (or a typo) + * would have changed which memories a user's "forget that" reaches, and no + * test would have noticed. + * + * bitbaum/orangecat#563 suggestion 17. + */ + +import { forgetMemoriesMatching } from '@/services/cat/memory'; +import type { AnySupabaseClient } from '@/lib/supabase/types'; + +jest.mock('@/utils/logger', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const mockEmbedText = jest.fn(); +jest.mock('@/services/ai/embeddings', () => ({ + embeddingsEnabled: () => true, + embedText: (...args: unknown[]) => mockEmbedText(...args), + embedTexts: jest.fn().mockResolvedValue([]), +})); + +/** Corpus deliberately shares NO word with the phrase, forcing the RPC path. */ +const STORED = { id: 's1', content: 'Knows French' }; + +function makeClient(rpc: jest.Mock) { + return { + from: () => ({ + select: () => ({ eq: async () => ({ data: [STORED], error: null }) }), + delete: () => ({ eq: () => ({ in: async () => ({ error: null }) }) }), + insert: async () => ({ error: null }), + upsert: async () => ({ error: null }), + }), + rpc, + } as unknown as AnySupabaseClient; +} + +describe('forget semantic fallback', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockEmbedText.mockResolvedValue([0.1, 0.2, 0.3]); + }); + + it('passes the measured 0.45 floor to match_cat_memories', async () => { + const rpc = jest.fn().mockResolvedValue({ data: [], error: null }); + await forgetMemoriesMatching(makeClient(rpc), 'u1', ['speaking a second language']); + + expect(rpc).toHaveBeenCalledWith( + 'match_cat_memories', + expect.objectContaining({ p_user_id: 'u1', min_similarity: 0.45 }) + ); + }); + + it('deletes what the semantic match returns when no word matched', async () => { + const rpc = jest.fn().mockResolvedValue({ data: [STORED], error: null }); + const result = await forgetMemoriesMatching(makeClient(rpc), 'u1', [ + 'speaking a second language', + ]); + + expect(result.deleted).toEqual(['Knows French']); + expect(result.notFound).toEqual([]); + }); + + it('is a FALLBACK — a word match never reaches the RPC', async () => { + const rpc = jest.fn().mockResolvedValue({ data: [], error: null }); + await forgetMemoriesMatching(makeClient(rpc), 'u1', ['French']); + + expect(rpc).not.toHaveBeenCalled(); + }); + + it('reports no-match rather than failing when the RPC errors', async () => { + // A dead embedding path must degrade to "nothing matched", never to a + // half-truth about what was removed. + const rpc = jest.fn().mockRejectedValue(new Error('vector index offline')); + const result = await forgetMemoriesMatching(makeClient(rpc), 'u1', [ + 'speaking a second language', + ]); + + expect(result.deleted).toEqual([]); + expect(result.notFound).toEqual(['speaking a second language']); + }); +}); diff --git a/__tests__/unit/domain/payments/publicPaymentStatus.test.ts b/__tests__/unit/domain/payments/publicPaymentStatus.test.ts index ca5cb195a..8d1b603e1 100644 --- a/__tests__/unit/domain/payments/publicPaymentStatus.test.ts +++ b/__tests__/unit/domain/payments/publicPaymentStatus.test.ts @@ -138,17 +138,13 @@ describe('a claim is only accepted where no rail can answer', () => { paid_at: null, requires_recipient_confirmation: true, }); - expect(fake.updates.map(u => u.patch.status)).toEqual([ - STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED, - ]); + expect(fake.updates.map(u => u.patch.status)).toEqual([STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED]); }); it('refuses a claim when the Lightning address can be verified automatically', async () => { const fake = world(intent({ lnurl_verify_url: 'https://ln.example/verify/1' })); - await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow( - 'confirmed automatically' - ); + await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow('confirmed automatically'); expect(fake.updates).toEqual([]); expect(dispatchMock).not.toHaveBeenCalled(); }); @@ -156,18 +152,14 @@ describe('a claim is only accepted where no rail can answer', () => { it('refuses a claim on an on-chain payment — the mempool answers, not the payer', async () => { const fake = world(intent({ payment_method: 'onchain', onchain_address: 'bc1qexample' })); - await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow( - 'confirmed automatically' - ); + await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow('confirmed automatically'); expect(fake.updates).toEqual([]); }); it('refuses a claim on an NWC payment', async () => { const fake = world(intent({ payment_method: 'nwc', payment_hash: 'hash-1' })); - await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow( - 'confirmed automatically' - ); + await expect(acknowledgePublicPayment(PI_ID, TOKEN)).rejects.toThrow('confirmed automatically'); expect(fake.updates).toEqual([]); }); }); @@ -226,9 +218,7 @@ describe('expiry bounds when a payment can be MADE, not when it can be REPORTED' expect(result.status).toBe(STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED); expect(dispatchMock).toHaveBeenCalledTimes(1); - expect(fake.updates.map(u => u.patch.status)).toEqual([ - STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED, - ]); + expect(fake.updates.map(u => u.patch.status)).toEqual([STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED]); }); it('refuses a claim once the claim window has also closed, and records the expiry', async () => { @@ -255,3 +245,59 @@ describe('expiry bounds when a payment can be MADE, not when it can be REPORTED' expect(fake.updates).toEqual([]); }); }); + +/** + * A claim costs the recipient attention, so it is budgeted per recipient. + * + * The route in front of this only knows the caller's IP, and the abuse shape + * is many addresses aimed at ONE seller's confirmation queue — free intents + * fanned into plausible "someone paid you" cards, whose payoff is + * ship-the-goods-for-no-money. bitbaum/orangecat#563 finding 3. + */ +describe('claims are budgeted per recipient', () => { + const allow = jest.fn(async () => true); + const refuse = jest.fn(async () => false); + + beforeEach(() => { + allow.mockClear(); + refuse.mockClear(); + }); + + it('asks about the recipient entity, not the caller', async () => { + world(intent()); + await acknowledgePublicPayment(PI_ID, TOKEN, allow); + expect(allow).toHaveBeenCalledWith('product', 'prod-1'); + }); + + it('refuses the claim once that recipient has been flooded', async () => { + world(intent()); + await expect(acknowledgePublicPayment(PI_ID, TOKEN, refuse)).rejects.toThrow( + /Too many payment claims/ + ); + }); + + it('sends no card and moves no intent when the claim is refused', async () => { + const fake = world(intent()); + + await expect(acknowledgePublicPayment(PI_ID, TOKEN, refuse)).rejects.toThrow(); + + // The whole point: nothing reaches the seller, and the intent stays put. + expect(dispatchMock).not.toHaveBeenCalled(); + expect(fake.updates).toEqual([]); + }); + + it('does not spend the budget on an idempotent re-claim', async () => { + // Already buyer_confirmed: this returns early, costs the attacker nothing, + // and so must not consume a genuine payer's allowance either. + world(intent({ status: STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED })); + + await acknowledgePublicPayment(PI_ID, TOKEN, allow); + expect(allow).not.toHaveBeenCalled(); + }); + + it('is unbudgeted when no guard is supplied — the caller owns the policy', async () => { + const fake = world(intent()); + await acknowledgePublicPayment(PI_ID, TOKEN); + expect(fake.updates.map(u => u.patch.status)).toEqual([STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED]); + }); +}); diff --git a/__tests__/unit/domain/payments/resolveSellerWallet.test.ts b/__tests__/unit/domain/payments/resolveSellerWallet.test.ts index 5230c98cc..91923a1bc 100644 --- a/__tests__/unit/domain/payments/resolveSellerWallet.test.ts +++ b/__tests__/unit/domain/payments/resolveSellerWallet.test.ts @@ -110,7 +110,7 @@ describe('resolveSellerWallet — entity-linked wallet precedence', () => { }; getAdminClientMock.mockReturnValue(makeAdmin(fx)); - const resolved = await resolveSellerWallet({} as never, ENTITY.entity_type, ENTITY.entity_id); + const resolved = await resolveSellerWallet(ENTITY.entity_type, ENTITY.entity_id); expect(resolved).toEqual({ method: 'onchain', @@ -132,7 +132,7 @@ describe('resolveSellerWallet — entity-linked wallet precedence', () => { }; getAdminClientMock.mockReturnValue(makeAdmin(fx)); - const resolved = await resolveSellerWallet({} as never, ENTITY.entity_type, ENTITY.entity_id); + const resolved = await resolveSellerWallet(ENTITY.entity_type, ENTITY.entity_id); expect(resolved).toEqual({ method: 'lightning_address', @@ -145,7 +145,7 @@ describe('resolveSellerWallet — entity-linked wallet precedence', () => { const fx = baseFixtures(); // entityWallets empty getAdminClientMock.mockReturnValue(makeAdmin(fx)); - const resolved = await resolveSellerWallet({} as never, ENTITY.entity_type, ENTITY.entity_id); + const resolved = await resolveSellerWallet(ENTITY.entity_type, ENTITY.entity_id); expect(resolved).toEqual({ method: 'onchain', @@ -160,7 +160,7 @@ describe('resolveSellerWallet — entity-linked wallet precedence', () => { fx.walletsById = {}; // linked wallet not active → single() returns null getAdminClientMock.mockReturnValue(makeAdmin(fx)); - const resolved = await resolveSellerWallet({} as never, ENTITY.entity_type, ENTITY.entity_id); + const resolved = await resolveSellerWallet(ENTITY.entity_type, ENTITY.entity_id); expect(resolved).toEqual({ method: 'onchain', diff --git a/__tests__/unit/domain/payments/sellerIdentityAndReceiveInfo.test.ts b/__tests__/unit/domain/payments/sellerIdentityAndReceiveInfo.test.ts index 627ad86e8..f790e9475 100644 --- a/__tests__/unit/domain/payments/sellerIdentityAndReceiveInfo.test.ts +++ b/__tests__/unit/domain/payments/sellerIdentityAndReceiveInfo.test.ts @@ -70,7 +70,7 @@ describe('resolveSellerReceiveInfo — what the owner is shown', () => { const { client } = world({ wallets: [wallet({ lightning_address: 'me@orangecat.ch' })] }); getAdminClientMock.mockReturnValue(client); - expect(await resolveSellerReceiveInfo({} as never, 'product', ENTITY_ID)).toEqual({ + expect(await resolveSellerReceiveInfo('product', ENTITY_ID)).toEqual({ method: 'lightning_address', address: 'me@orangecat.ch', }); @@ -80,7 +80,7 @@ describe('resolveSellerReceiveInfo — what the owner is shown', () => { const { client } = world({ wallets: [wallet({ address_or_xpub: 'bc1qexample' })] }); getAdminClientMock.mockReturnValue(client); - expect(await resolveSellerReceiveInfo({} as never, 'product', ENTITY_ID)).toEqual({ + expect(await resolveSellerReceiveInfo('product', ENTITY_ID)).toEqual({ method: 'onchain', address: 'bc1qexample', }); @@ -92,7 +92,7 @@ describe('resolveSellerReceiveInfo — what the owner is shown', () => { const { client } = world({ wallets: [wallet({ nwc_connection_uri: secret })] }); getAdminClientMock.mockReturnValue(client); - const info = await resolveSellerReceiveInfo({} as never, 'product', ENTITY_ID); + const info = await resolveSellerReceiveInfo('product', ENTITY_ID); expect(info).toEqual({ method: 'nwc', address: null }); expect(JSON.stringify(info)).not.toContain('hunter2'); @@ -106,7 +106,7 @@ describe('resolveSellerReceiveInfo — what the owner is shown', () => { const { client } = world({ wallets: [wallet({ wallet_type: 'xpub', address_or_xpub: zpub })] }); getAdminClientMock.mockReturnValue(client); - const info = await resolveSellerReceiveInfo({} as never, 'product', ENTITY_ID); + const info = await resolveSellerReceiveInfo('product', ENTITY_ID); expect(info).toEqual({ method: 'onchain', address: null }); }); @@ -115,7 +115,7 @@ describe('resolveSellerReceiveInfo — what the owner is shown', () => { const { client } = world({ wallets: [] }); getAdminClientMock.mockReturnValue(client); - expect(await resolveSellerReceiveInfo({} as never, 'product', ENTITY_ID)).toBeNull(); + expect(await resolveSellerReceiveInfo('product', ENTITY_ID)).toBeNull(); }); }); diff --git a/__tests__/unit/domain/payments/settlementRailDispatch.test.ts b/__tests__/unit/domain/payments/settlementRailDispatch.test.ts index 4884ae190..0a960dd65 100644 --- a/__tests__/unit/domain/payments/settlementRailDispatch.test.ts +++ b/__tests__/unit/domain/payments/settlementRailDispatch.test.ts @@ -91,7 +91,8 @@ beforeEach(() => { describe('each method asks its own authority and no other', () => { it('asks the NWC relay for an NWC payment', async () => { const pi = intent(); - await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + await reconcilePaymentIntent(pi); expect(nwcMock).toHaveBeenCalledTimes(1); expect(lnurlMock).not.toHaveBeenCalled(); @@ -104,7 +105,8 @@ describe('each method asks its own authority and no other', () => { payment_hash: null, lnurl_verify_url: 'https://ln.example/verify/1', }); - await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + await reconcilePaymentIntent(pi); expect(lnurlMock).toHaveBeenCalledTimes(1); expect(nwcMock).not.toHaveBeenCalled(); @@ -117,7 +119,8 @@ describe('each method asks its own authority and no other', () => { payment_hash: null, onchain_address: 'bc1qexample', }); - await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + await reconcilePaymentIntent(pi); expect(onchainMock).toHaveBeenCalledTimes(1); expect(nwcMock).not.toHaveBeenCalled(); @@ -128,21 +131,24 @@ describe('each method asks its own authority and no other', () => { describe('a rail is never asked without what it needs to answer', () => { it('does not query the relay for an NWC intent with no payment hash', async () => { const pi = intent({ payment_hash: null }); - await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + await reconcilePaymentIntent(pi); expect(nwcMock).not.toHaveBeenCalled(); }); it('does not query the mempool for an on-chain intent with no address', async () => { const pi = intent({ payment_method: 'onchain', payment_hash: null, onchain_address: null }); - await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + await reconcilePaymentIntent(pi); expect(onchainMock).not.toHaveBeenCalled(); }); it('does not query a verify URL a bare Lightning address does not have', async () => { const pi = intent({ payment_method: 'lightning_address', payment_hash: null }); - await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + await reconcilePaymentIntent(pi); expect(lnurlMock).not.toHaveBeenCalled(); }); @@ -153,7 +159,9 @@ describe('only the authority can settle a payment', () => { nwcMock.mockResolvedValue(true); const pi = intent(); - const result = await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + + const result = await reconcilePaymentIntent(pi); expect(result.status).toBe(STATUS.PAYMENT_INTENTS.PAID); expect(result.paid_at).not.toBeNull(); @@ -167,7 +175,9 @@ describe('only the authority can settle a payment', () => { lnurl_verify_url: 'https://ln.example/verify/1', }); - const result = await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + + const result = await reconcilePaymentIntent(pi); expect(result.status).toBe(STATUS.PAYMENT_INTENTS.PAID); }); @@ -180,7 +190,9 @@ describe('only the authority can settle a payment', () => { onchain_address: 'bc1qexample', }); - const result = await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + + const result = await reconcilePaymentIntent(pi); expect(result.status).toBe(STATUS.PAYMENT_INTENTS.PAID); }); @@ -194,7 +206,7 @@ describe('only the authority can settle a payment', () => { }); const fake = world(pi); - const result = await reconcilePaymentIntent(fake.client, pi); + const result = await reconcilePaymentIntent(pi); expect(result.status).toBe(STATUS.PAYMENT_INTENTS.PENDING_CONFIRMATION); expect(result.paid_at).toBeNull(); @@ -213,7 +225,7 @@ describe('only the authority can settle a payment', () => { }); const fake = world(pi); - await reconcilePaymentIntent(fake.client, pi); + await reconcilePaymentIntent(pi); expect(fake.updates).toEqual([]); }); @@ -222,7 +234,7 @@ describe('only the authority can settle a payment', () => { const pi = intent(); const fake = world(pi); - const result = await reconcilePaymentIntent(fake.client, pi); + const result = await reconcilePaymentIntent(pi); expect(result.status).toBe(STATUS.PAYMENT_INTENTS.INVOICE_READY); expect(fake.updates).toEqual([]); @@ -236,7 +248,9 @@ describe('expiry is only the truth after the rail has said no', () => { nwcMock.mockResolvedValue(true); const pi = intent({ expires_at: DEAD }); - const result = await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + + const result = await reconcilePaymentIntent(pi); expect(result.status).toBe(STATUS.PAYMENT_INTENTS.PAID); }); @@ -245,7 +259,7 @@ describe('expiry is only the truth after the rail has said no', () => { const pi = intent({ expires_at: DEAD }); const fake = world(pi); - const result = await reconcilePaymentIntent(fake.client, pi); + const result = await reconcilePaymentIntent(pi); expect(nwcMock).toHaveBeenCalled(); expect(result.status).toBe(STATUS.PAYMENT_INTENTS.EXPIRED); @@ -262,7 +276,7 @@ describe('expiry is only the truth after the rail has said no', () => { }); const fake = world(pi); - const result = await reconcilePaymentIntent(fake.client, pi); + const result = await reconcilePaymentIntent(pi); expect(result.status).toBe(STATUS.PAYMENT_INTENTS.INVOICE_READY); expect(fake.updates).toEqual([]); @@ -271,7 +285,9 @@ describe('expiry is only the truth after the rail has said no', () => { it('returns a terminal status without asking any rail', async () => { const pi = intent({ status: STATUS.PAYMENT_INTENTS.PAID, paid_at: '2026-08-01T10:00:00Z' }); - const result = await reconcilePaymentIntent(world(pi).client, pi); + world(pi); + + const result = await reconcilePaymentIntent(pi); expect(result).toEqual({ status: STATUS.PAYMENT_INTENTS.PAID, diff --git a/__tests__/unit/domain/payments/undetectableIntentLifecycle.test.ts b/__tests__/unit/domain/payments/undetectableIntentLifecycle.test.ts index 10eeb517c..598b07c4b 100644 --- a/__tests__/unit/domain/payments/undetectableIntentLifecycle.test.ts +++ b/__tests__/unit/domain/payments/undetectableIntentLifecycle.test.ts @@ -69,7 +69,8 @@ beforeEach(() => { describe('undetectable intent around expiry', () => { it('stays OPEN after invoice expiry while the claim window runs — the payer can still say "I paid"', async () => { const expiredAnHourAgo = new Date(Date.now() - 3_600_000).toISOString(); - const res = await reconcilePaymentIntent(makeSupabase(), bareLightningIntent(expiredAnHourAgo)); + makeSupabase(); + const res = await reconcilePaymentIntent(bareLightningIntent(expiredAnHourAgo)); expect(res.status).toBe(STATUS.PAYMENT_INTENTS.INVOICE_READY); expect(updates).toHaveLength(0); @@ -77,7 +78,8 @@ describe('undetectable intent around expiry', () => { it('terminalizes honestly once the invoice is dead AND the claim window has closed', async () => { const longDead = new Date(Date.now() - BUYER_CLAIM_GRACE_MS - 3_600_000).toISOString(); - const res = await reconcilePaymentIntent(makeSupabase(), bareLightningIntent(longDead)); + makeSupabase(); + const res = await reconcilePaymentIntent(bareLightningIntent(longDead)); expect(res.status).toBe(STATUS.PAYMENT_INTENTS.EXPIRED); expect(updates.some(u => u.status === STATUS.PAYMENT_INTENTS.EXPIRED)).toBe(true); @@ -85,10 +87,8 @@ describe('undetectable intent around expiry', () => { it('never asks any rail about an undetectable intent — there is nothing to ask', async () => { const statusService = jest.requireMock('@/domain/payments/paymentStatusService'); - await reconcilePaymentIntent( - makeSupabase(), - bareLightningIntent(new Date(Date.now() + 600_000).toISOString()) - ); + makeSupabase(); + await reconcilePaymentIntent(bareLightningIntent(new Date(Date.now() + 600_000).toISOString())); expect(statusService.checkNWCPaymentStatus).not.toHaveBeenCalled(); expect(statusService.checkLnurlVerifyPaymentStatus).not.toHaveBeenCalled(); diff --git a/__tests__/unit/domain/payments/walletResolutionQueries.test.ts b/__tests__/unit/domain/payments/walletResolutionQueries.test.ts index d7b32a401..5fe7f0d22 100644 --- a/__tests__/unit/domain/payments/walletResolutionQueries.test.ts +++ b/__tests__/unit/domain/payments/walletResolutionQueries.test.ts @@ -245,7 +245,7 @@ describe('resolveSellerWallet — a wallet tied to one specific entity', () => { // Falls through to the owner's profile default rather than paying a wallet // that is no longer in use. - expect(await resolveSellerWallet({} as never, 'product', 'prod-1')).toMatchObject({ + expect(await resolveSellerWallet('product', 'prod-1')).toMatchObject({ wallet_id: 'w-default', }); }); @@ -263,7 +263,7 @@ describe('resolveSellerWallet — a wallet tied to one specific entity', () => { ] ); - expect(await resolveSellerWallet({} as never, 'product', 'prod-1')).toMatchObject({ + expect(await resolveSellerWallet('product', 'prod-1')).toMatchObject({ wallet_id: 'w-chosen', }); }); @@ -277,7 +277,7 @@ describe('resolveSellerWallet — a wallet tied to one specific entity', () => { ] ); - expect(await resolveSellerWallet({} as never, 'product', 'prod-1')).toMatchObject({ + expect(await resolveSellerWallet('product', 'prod-1')).toMatchObject({ wallet_id: 'w-default', }); }); @@ -308,7 +308,7 @@ describe('resolveSellerWallet — group entities', () => { }); getAdminClientMock.mockReturnValue(client); - expect(await resolveSellerWallet({} as never, 'group', GROUP)).toEqual({ + expect(await resolveSellerWallet('group', GROUP)).toEqual({ method: 'lightning_address', wallet_id: 'gw-ours', lightning_address: 'ours@ln', @@ -325,7 +325,7 @@ describe('resolveSellerWallet — group entities', () => { }); getAdminClientMock.mockReturnValue(client); - expect(await resolveSellerWallet({} as never, 'group', GROUP)).toEqual({ + expect(await resolveSellerWallet('group', GROUP)).toEqual({ method: 'onchain', wallet_id: 'gw-live', onchain_address: 'bc1qlive', @@ -346,11 +346,13 @@ describe('resolveSellerWallet — group entities', () => { [DATABASE_TABLES.WALLETS]: [ wallet({ id: 'w-founder', profile_id: 'founder-1', lightning_address: 'founder@ln' }), ], - [DATABASE_TABLES.GROUP_WALLETS]: [groupWallet({ id: 'gw-ours', lightning_address: 'ours@ln' })], + [DATABASE_TABLES.GROUP_WALLETS]: [ + groupWallet({ id: 'gw-ours', lightning_address: 'ours@ln' }), + ], }); getAdminClientMock.mockReturnValue(fake.client); - expect(await resolveSellerWallet({} as never, 'product', 'prod-1')).toEqual({ + expect(await resolveSellerWallet('product', 'prod-1')).toEqual({ method: 'lightning_address', wallet_id: 'gw-ours', lightning_address: 'ours@ln', @@ -370,7 +372,7 @@ describe('resolveSellerWallet — group entities', () => { }); getAdminClientMock.mockReturnValue(client); - expect(await resolveSellerWallet({} as never, 'group', GROUP)).toMatchObject({ + expect(await resolveSellerWallet('group', GROUP)).toMatchObject({ wallet_id: 'gw-old', }); }); @@ -382,6 +384,6 @@ describe('resolveSellerWallet — group entities', () => { }); getAdminClientMock.mockReturnValue(client); - expect(await resolveSellerWallet({} as never, 'group', GROUP)).toBeNull(); + expect(await resolveSellerWallet('group', GROUP)).toBeNull(); }); }); diff --git a/src/app/api/bookings/[id]/receive-info/route.ts b/src/app/api/bookings/[id]/receive-info/route.ts index d2373fe44..c3d254a48 100644 --- a/src/app/api/bookings/[id]/receive-info/route.ts +++ b/src/app/api/bookings/[id]/receive-info/route.ts @@ -56,7 +56,6 @@ export const GET = withAuth(async (request: AuthenticatedRequest, context: Route } const info = await resolveSellerReceiveInfo( - supabase, booking.bookable_type as EntityType, booking.bookable_id ); diff --git a/src/app/api/payments/receive-info/route.ts b/src/app/api/payments/receive-info/route.ts index 2f5325fb2..c7a06ee84 100644 --- a/src/app/api/payments/receive-info/route.ts +++ b/src/app/api/payments/receive-info/route.ts @@ -43,7 +43,7 @@ export const GET = withAuth(async (request: AuthenticatedRequest) => { return apiForbidden('Only the owner can view receiving info for this entity'); } - const info = await resolveSellerReceiveInfo(supabase, entityType as EntityType, entityId); + const info = await resolveSellerReceiveInfo(entityType as EntityType, entityId); return apiSuccess({ hasWallet: !!info, diff --git a/src/app/api/v1/payments/public/[id]/route.ts b/src/app/api/v1/payments/public/[id]/route.ts index 0480c6b8d..6194047d3 100644 --- a/src/app/api/v1/payments/public/[id]/route.ts +++ b/src/app/api/v1/payments/public/[id]/route.ts @@ -1,16 +1,8 @@ -import { - apiBadRequest, - apiNotFound, - apiRateLimited, - apiSuccess, -} from '@/lib/api/standardResponse'; -import { - acknowledgePublicPayment, - checkPublicPaymentStatus, -} from '@/domain/payments'; +import { apiBadRequest, apiNotFound, apiRateLimited, apiSuccess } from '@/lib/api/standardResponse'; +import { acknowledgePublicPayment, checkPublicPaymentStatus } from '@/domain/payments'; import { publicPaymentActionSchema } from '@/lib/validation/finance'; import { validateUUID, getValidationError } from '@/lib/api/validation'; -import { rateLimitWriteAsync, retryAfterSeconds } from '@/lib/rate-limit'; +import { rateLimitPaymentClaim, rateLimitWriteAsync, retryAfterSeconds } from '@/lib/rate-limit'; import { clientIpKey } from '@/lib/client-ip'; // Same per-IP keying as ../route.ts — anonymous callers, so IP is the only handle. @@ -23,10 +15,7 @@ function readToken(request: Request): string | null { return token && token.length >= 32 && token.length <= 128 ? token : null; } -export async function GET( - request: Request, - context: { params: Promise<{ id: string }> } -) { +export async function GET(request: Request, context: { params: Promise<{ id: string }> }) { const { id } = await context.params; const idError = getValidationError(validateUUID(id, 'payment ID')); if (idError) { @@ -44,10 +33,7 @@ export async function GET( } } -export async function POST( - request: Request, - context: { params: Promise<{ id: string }> } -) { +export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { const limit = await rateLimitWriteAsync(requestKey(request)); if (!limit.success) { return apiRateLimited( @@ -72,7 +58,13 @@ export async function POST( } try { - return apiSuccess(await acknowledgePublicPayment(id, token), { cache: 'NONE' }); + // The recipient-side budget lives here, in the HTTP layer that owns + // policy; the domain decides only WHEN to ask (once, on the real + // transition). See ClaimGuard in paymentStatusFlow. + const claimGuard = async (entityType: string, entityId: string) => + (await rateLimitPaymentClaim(entityType, entityId)).success; + + return apiSuccess(await acknowledgePublicPayment(id, token, claimGuard), { cache: 'NONE' }); } catch (error) { const message = error instanceof Error ? error.message : ''; if (message.includes('automatically')) { @@ -81,6 +73,11 @@ export async function POST( if (message.includes('expired')) { return apiBadRequest('This payment request has expired.'); } + if (message.includes('Too many payment claims')) { + return apiRateLimited( + 'This recipient has received too many payment claims right now. Try again shortly.' + ); + } return apiNotFound('Payment not found'); } } diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx index 55cda881a..494eff040 100644 --- a/src/app/projects/[id]/page.tsx +++ b/src/app/projects/[id]/page.tsx @@ -207,7 +207,7 @@ export default async function PublicProjectPage({ params }: PageProps) { supporters_count: fundingStats?.contributorCount ?? 0, profiles: profile ?? undefined, }; - const sellerReceive = await resolveSellerReceiveInfo(supabase, 'project', id); + const sellerReceive = await resolveSellerReceiveInfo('project', id); // A draft project is invisible to everyone but its owner (the projects_public_read // RLS policy), and nothing on the page used to say so — projects were the one diff --git a/src/components/public/PublicEntityDetailPage.tsx b/src/components/public/PublicEntityDetailPage.tsx index 7fb00affb..48cbfece4 100644 --- a/src/components/public/PublicEntityDetailPage.tsx +++ b/src/components/public/PublicEntityDetailPage.tsx @@ -151,7 +151,7 @@ export default async function PublicEntityDetailPage({ let priceAmountBtc: number | undefined; const hasPaymentSurface = config.showPaymentSection !== false || meta.canReceiveSupport; if (hasPaymentSurface) { - sellerReceive = await resolveSellerReceiveInfo(supabase, config.entityType, id); + sellerReceive = await resolveSellerReceiveInfo(config.entityType, id); // Address-reuse disclosure — only worth resolving when an address will // actually be shown (NWC reveals no static address to link). if (sellerReceive?.address) { diff --git a/src/domain/payments/paymentInitiation.ts b/src/domain/payments/paymentInitiation.ts index e2dfdaa58..76f75059d 100644 --- a/src/domain/payments/paymentInitiation.ts +++ b/src/domain/payments/paymentInitiation.ts @@ -66,7 +66,7 @@ export async function initiatePayment( } // 2. Resolve seller's wallet & payment method - const wallet = await resolveSellerWallet(supabase, entity_type, entity_id); + const wallet = await resolveSellerWallet(entity_type, entity_id); if (!wallet) { throw new Error('Seller has no wallet connected. Payment not available.'); } @@ -205,7 +205,7 @@ export async function initiatePublicSupport( throw new Error('Entity owner not found'); } - const wallet = await resolveSellerWallet(publicSupabase, entityType, entityId); + const wallet = await resolveSellerWallet(entityType, entityId); if (!wallet) { throw new Error('Seller has no wallet connected. Payment not available.'); } diff --git a/src/domain/payments/paymentStatusFlow.ts b/src/domain/payments/paymentStatusFlow.ts index fa78fd54b..3c7c5c864 100644 --- a/src/domain/payments/paymentStatusFlow.ts +++ b/src/domain/payments/paymentStatusFlow.ts @@ -51,7 +51,7 @@ export async function checkPaymentStatus( throw new Error('Access denied'); } - return refreshPaymentStatus(supabase, pi as PaymentIntent); + return refreshPaymentStatus(pi as PaymentIntent); } export async function checkPublicPaymentStatus( @@ -70,16 +70,27 @@ export async function checkPublicPaymentStatus( throw new Error('Payment not found'); } - const result = await refreshPaymentStatus(admin, pi as PaymentIntent); + const result = await refreshPaymentStatus(pi as PaymentIntent); return { ...result, requires_recipient_confirmation: result.status === STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED, }; } +/** + * Asked, once, whether this recipient may receive another claim. + * + * Injected rather than imported: rate limiting is infrastructure, and pulling + * it in here would drag the Upstash client into the payments domain — wrong + * layer, and it breaks every domain test that has no business knowing about + * Redis. The HTTP layer owns the policy; the domain owns WHEN it is asked. + */ +export type ClaimGuard = (entityType: string, entityId: string) => Promise; + export async function acknowledgePublicPayment( paymentIntentId: string, - token: string + token: string, + claimGuard?: ClaimGuard ): Promise { const admin = getAdminClient() as unknown as SupabaseClient; const { data: pi } = await admin @@ -128,6 +139,21 @@ export async function acknowledgePublicPayment( }; } + // Bound claims per RECIPIENT, not per caller — the abuse shape is many + // addresses aimed at one seller's confirmation queue, which the caller-keyed + // budget in front of this cannot see. Asked only on the real transition: the + // idempotent re-claim paths above return before here, cost an attacker + // nothing, and so must not consume a genuine payer's allowance either. + if (claimGuard) { + const allowed = await claimGuard( + (pi.entity_type as string) ?? 'unknown', + (pi.entity_id as string) ?? paymentIntentId + ); + if (!allowed) { + throw new Error('Too many payment claims for this recipient'); + } + } + await updatePaymentStatus(paymentIntentId, STATUS.PAYMENT_INTENTS.BUYER_CONFIRMED); notifyRecipientOfClaim(pi as PaymentIntent); return { @@ -159,10 +185,7 @@ function notifyRecipientOfClaim(pi: PaymentIntent): void { }); } -async function refreshPaymentStatus( - supabase: SupabaseClient, - pi: PaymentIntent -): Promise { +async function refreshPaymentStatus(pi: PaymentIntent): Promise { const terminalStatuses = new Set([ STATUS.PAYMENT_INTENTS.PAID, STATUS.PAYMENT_INTENTS.EXPIRED, @@ -242,11 +265,8 @@ async function refreshPaymentStatus( * Deliberately NOT a second implementation: a copy would drift, and then two * parts of the product would disagree about whether money arrived. */ -export async function reconcilePaymentIntent( - supabase: SupabaseClient, - pi: PaymentIntent -): Promise { - return refreshPaymentStatus(supabase, pi); +export async function reconcilePaymentIntent(pi: PaymentIntent): Promise { + return refreshPaymentStatus(pi); } /** diff --git a/src/domain/payments/walletResolutionService.ts b/src/domain/payments/walletResolutionService.ts index b7f5ec7e1..3ed260669 100644 --- a/src/domain/payments/walletResolutionService.ts +++ b/src/domain/payments/walletResolutionService.ts @@ -28,7 +28,6 @@ import { logger } from '@/utils/logger'; * Returns null if seller has no wallet connected. */ export async function resolveSellerWallet( - supabase: SupabaseClient, entityType: EntityType, entityId: string ): Promise { @@ -122,11 +121,10 @@ export interface SellerReceiveInfo { * buyers will actually pay to. Returns null when no wallet is connected. */ export async function resolveSellerReceiveInfo( - supabase: SupabaseClient, entityType: EntityType, entityId: string ): Promise { - const resolved = await resolveSellerWallet(supabase, entityType, entityId); + const resolved = await resolveSellerWallet(entityType, entityId); if (!resolved) { return null; } diff --git a/src/domain/wallets/walletUsage.ts b/src/domain/wallets/walletUsage.ts index 33e7fc5ee..321638128 100644 --- a/src/domain/wallets/walletUsage.ts +++ b/src/domain/wallets/walletUsage.ts @@ -88,7 +88,7 @@ export async function getSharedWalletUsage( entityId: string ): Promise { try { - const resolved = await resolveSellerWallet(supabase, entityType, entityId); + const resolved = await resolveSellerWallet(entityType, entityId); if (!resolved) { return null; } diff --git a/src/lib/api/standardResponse.ts b/src/lib/api/standardResponse.ts index 35b9a1170..286d8c5f8 100644 --- a/src/lib/api/standardResponse.ts +++ b/src/lib/api/standardResponse.ts @@ -195,6 +195,11 @@ export function apiPaymentRequired( }, { status: 402 } ); + // The 402 body and the WWW-Authenticate header both carry the payment + // token — a bearer credential for this intent. Every other payment response + // sets no-store; this one did not, so a shared cache or an intermediary was + // free to keep the credential and hand it to the next caller. + response.headers.set('Cache-Control', CACHE_PRESETS.NONE); if (wwwAuthenticate) { response.headers.set('WWW-Authenticate', wwwAuthenticate); } diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 1f5c8a0cd..77a419588 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -101,6 +101,7 @@ const upstashTipRecipientLimiter = createUpstashLimiter('tip-recipient', 20, '5 // Generous on purpose: a paying client polls, and refusing a real payer is // worse than the outbound traffic this bounds. const upstashL402VerifyLimiter = createUpstashLimiter('l402-verify', 60, '1 m'); +const upstashPaymentClaimLimiter = createUpstashLimiter('payment-claim', 10, '1 h'); // Public Ask-Cat / feedback endpoint: each submission costs a platform LLM // call, and the caller may be anonymous — so a tight per-IP budget on top of // the general limiter. 8 per 5 min is plenty for a real person having a @@ -173,6 +174,10 @@ const fallbackL402VerifyLimiter = new InMemoryRateLimiter({ windowMs: 60 * 1000, maxRequests: 60, }); +const fallbackPaymentClaimLimiter = new InMemoryRateLimiter({ + windowMs: 60 * 60 * 1000, + maxRequests: 10, +}); const fallbackAskCatLimiter = new InMemoryRateLimiter({ windowMs: 5 * 60 * 1000, maxRequests: 8, @@ -282,6 +287,37 @@ export async function rateLimitPaymentRecipient( return rateLimitTipRecipient(`${entityType}:${entityId}`); } +/** + * Rate limit "I paid you" claims per RECIPIENT. + * + * An acknowledge is testimony, not settlement: it flips an intent to + * buyer_confirmed and fires a "someone says they paid you" card into the + * recipient's confirmation queue. One card is a prompt to check a wallet; a + * hundred is a denial-of-attention attack, and the social-engineering primitive + * is ship-the-goods-for-no-money. + * + * The route's per-IP budget cannot bound this — the claims that matter come + * from many addresses at one seller. Creating the intents is already bounded + * per recipient (rateLimitPaymentRecipient), so this is the second half of the + * same fence: bound the claims as well as the invoices. + * + * 10 per hour per recipient. A genuine payer claims once, and retries are + * idempotent no-ops that never reach here. bitbaum/orangecat#563 finding 3. + */ +export async function rateLimitPaymentClaim( + entityType: string, + entityId: string +): Promise { + const key = `payment-claim:${entityType}:${entityId}`; + + if (upstashPaymentClaimLimiter) { + const result = await upstashPaymentClaimLimiter.limit(key); + return toRateLimitResult(result); + } + + return fallbackPaymentClaimLimiter.check(key); +} + /** * Rate limit L402 verification PER TOKEN. * diff --git a/src/services/cat/handlers/payments.ts b/src/services/cat/handlers/payments.ts index c4977f80a..935f4a057 100644 --- a/src/services/cat/handlers/payments.ts +++ b/src/services/cat/handlers/payments.ts @@ -310,11 +310,7 @@ export const paymentHandlers: Record = { } // 2. Resolve project owner's payment method (uses admin internally for cross-user lookup) - const projectWallet = await resolveSellerWallet( - supabase as unknown as SupabaseClient, - 'project', - projectId - ); + const projectWallet = await resolveSellerWallet('project', projectId); if (!projectWallet) { return { diff --git a/src/services/payments/reconcile.ts b/src/services/payments/reconcile.ts index 2c26cb430..db6934e29 100644 --- a/src/services/payments/reconcile.ts +++ b/src/services/payments/reconcile.ts @@ -249,7 +249,7 @@ export async function runPaymentReconcileSweep(): Promise // The SAME path the payer's browser runs. Settlement side-effects are // exactly-once (claimPaidTransition), so overlapping with a live poll // is safe by construction. - const result = await reconcilePaymentIntent(admin, intent); + const result = await reconcilePaymentIntent(intent); if (result.status === STATUS.PAYMENT_INTENTS.PAID) { settled += 1; logger.info(