diff --git a/packages/core/src/billing/polar.test.ts b/packages/core/src/billing/polar.test.ts index 989460fa..0e65e27b 100644 --- a/packages/core/src/billing/polar.test.ts +++ b/packages/core/src/billing/polar.test.ts @@ -172,6 +172,36 @@ describe('Both signing schemes', () => { expect(delivery.standing).toEqual({ plan: 'paid', status: 'active' }); }); + test('a raw snake_case payload is read, not just the SDK\u2019s camelCase', async () => { + // Verifying the signature ourselves hands back exactly what was sent, + // which is snake_case; the SDK's parser renames fields on the way + // through. Reading one spelling made every delivery verify correctly and + // then apply to nobody, which looks identical to working. + const { Webhook } = await import('standardwebhooks'); + const secret = `whsec_${Buffer.from('b'.repeat(32)).toString('base64')}`; + configure({ POLAR_WEBHOOK_SECRET: secret }); + + const body = JSON.stringify({ + type: 'subscription.revoked', + data: { customer: { external_id: 'tem_snake' }, product_id: PAID } + }); + const id = 'msg_snake'; + const timestamp = new Date(); + const signature = new Webhook(secret).sign(id, timestamp, body); + + const delivery = Polar.receive({ + body, + headers: { + 'webhook-id': id, + 'webhook-timestamp': Math.floor(timestamp.getTime() / 1000).toString(), + 'webhook-signature': signature + } + }); + + expect(delivery.teamId).toBe('tem_snake'); + expect(delivery.standing).toEqual({ plan: 'free', status: 'revoked' }); + }); + test('a tampered body under a valid-looking signature is refused', () => { const secret = `whsec_${Buffer.from('a'.repeat(32)).toString('base64')}`; configure({ POLAR_WEBHOOK_SECRET: secret }); diff --git a/packages/core/src/billing/polar.ts b/packages/core/src/billing/polar.ts index 8c2e09ba..9cb06b6c 100644 --- a/packages/core/src/billing/polar.ts +++ b/packages/core/src/billing/polar.ts @@ -341,18 +341,27 @@ export namespace Polar { throw error; } + // Both spellings, for both paths. The SDK's parser renames fields to + // camelCase on the way through; verifying the signature ourselves + // hands back exactly what was sent, which is snake_case. Reading only + // one spelling makes every delivery arrive intact, verify correctly, + // and then quietly apply to nobody. const data = event.data ?? {}; - const customer = data.customer as { externalId?: string | null } | undefined; + const customer = data.customer as + | { externalId?: string | null; external_id?: string | null } + | undefined; // `externalId` is the team id we put on the customer. A delivery // without one is about a customer created some other way — by hand in // their dashboard, most likely — and there is nothing here it can // change. - const teamId = customer?.externalId ?? null; + const teamId = customer?.externalId ?? customer?.external_id ?? null; - // Both spellings, because which one a payload carries depends on - // whether the product was expanded into it. const product = data.product as { id?: string } | undefined; - const productId = (data.productId as string | undefined) ?? product?.id ?? null; + const productId = + (data.productId as string | undefined) ?? + (data.product_id as string | undefined) ?? + product?.id ?? + null; return { type: event.type, teamId, standing: standingFor(event.type, productId) }; }