From 10db4d64cfd563109572f8c4d385db1c13fe9b07 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 1 Aug 2026 06:47:25 +0000 Subject: [PATCH] fix(proxy): recognise any auth scheme, not just Bearer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credentialed rate-limit bucket added in #212 only matched `Authorization: Bearer`. The wallet extension signs its requests with `Authorization: Wallet ::`, so every call it made fell through to the anonymous 60/min per-IP bucket. A bulk payout spends one prepare-tx plus one broadcast per payment, so that ceiling is reached around the thirtieth payment and the rest fail with 429 — the same wall #212 was meant to remove, still standing for the one caller that needed it most. Match any scheme and take the credential that follows. A bare `Authorization: Bearer` with no token still yields null, so the anonymous bucket keeps covering junk headers. Co-Authored-By: Claude Opus 5 (1M context) --- src/proxy.test.ts | 22 ++++++++++++++++++++++ src/proxy.ts | 9 +++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 46b46e5..8e88a83 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -52,6 +52,19 @@ describe('presentedCredential', () => { expect(presentedCredential(headersOf({ 'x-api-key': 'sk_live_xyz' }))).toBe('sk_live_xyz'); }); + it('reads the wallet extension\'s Wallet scheme, not just Bearer', () => { + // packages/extension/src/core/api.ts signs with this exact shape. + expect( + presentedCredential(headersOf({ authorization: 'Wallet wid-1:sig-abc:1234567890' })) + ).toBe('wid-1:sig-abc:1234567890'); + }); + + it('distinguishes two wallets using the same scheme', () => { + const a = presentedCredential(headersOf({ authorization: 'Wallet wid-1:sig:1' })); + const b = presentedCredential(headersOf({ authorization: 'Wallet wid-2:sig:1' })); + expect(a).not.toBe(b); + }); + it('is null when no credential is presented', () => { expect(presentedCredential(headersOf({}))).toBeNull(); }); @@ -77,6 +90,15 @@ describe('proxy rate limiting', () => { expect(allowed).toBe(200); }); + // The extension uses the `Wallet` scheme; a batch spends two API calls per + // payment, so treating it as anonymous capped a payout at ~30 payments. + it('gives the wallet extension the credentialed budget', () => { + const allowed = countUntilLimited('/api/web-wallet/w1/broadcast', '10.1.0.6', 200, { + authorization: 'Wallet wid-1:sig-abc:1234567890', + }); + expect(allowed).toBe(200); + }); + it('budgets each API key separately from the same host', () => { const headersA = { authorization: 'Bearer sk_live_a' }; const headersB = { authorization: 'Bearer sk_live_b' }; diff --git a/src/proxy.ts b/src/proxy.ts index 33d9fdf..ee492da 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -118,8 +118,13 @@ export function presentedCredential( ): string | null { const authorization = headers.get('authorization'); if (authorization) { - const match = /^bearer\s+(\S+)/i.exec(authorization.trim()); - if (match) return match[1]; + // Any auth scheme, not just Bearer. The wallet extension signs with + // `Authorization: Wallet ::` (see + // packages/extension/src/core/api.ts), and matching Bearer alone dropped it + // into the anonymous per-IP bucket — which a bulk payout exhausts in + // seconds, since every payment costs a prepare-tx plus a broadcast. + const match = /^(\S+)\s+(\S+)/.exec(authorization.trim()); + if (match) return match[2]; } const apiKey = headers.get('x-api-key')?.trim(); return apiKey ? apiKey : null;