diff --git a/apps/nesdoctor/src/ask.rs b/apps/nesdoctor/src/ask.rs index 7f845b9c..c8f176d7 100644 --- a/apps/nesdoctor/src/ask.rs +++ b/apps/nesdoctor/src/ask.rs @@ -28,11 +28,12 @@ pub struct Answers { /// currently reach are already doing for themselves. That is the /// uncomfortable possibility, which is the reason to ask rather than not. pub want: Option, - /// USERS.md 7, roughly: is this machine a host, a client, or both? + /// Roughly: is this machine a host, a client, or both? pub role: Option, - /// USERS.md 6: cash or credit. Only asked of a machine that could host. + /// Cash or credit. Only asked of a machine that could host. pub share_for: Option, - /// USERS.md 5, in its factual form: current spend, not willingness to pay. + /// The factual form of what they pay now: current spend, not willingness + /// to pay. pub pays_today: Option, /// Asked only of a non-Linux machine: is there a Linux box behind it? pub other_linux: Option, diff --git a/apps/nesdoctor/src/sys.rs b/apps/nesdoctor/src/sys.rs index 2b87fd8f..2bbd8652 100644 --- a/apps/nesdoctor/src/sys.rs +++ b/apps/nesdoctor/src/sys.rs @@ -48,8 +48,8 @@ pub struct Gpu { pub name: String, pub vendor: Option, /// The DRM render node, where one exists. Linux only, and a hard - /// requirement in `contracts/host-requirements.md`: a card without one - /// cannot host, however good it is. + /// requirement: a card without one cannot host, however good it is. + /// ref(d-0002) pub render_node: Option, } diff --git a/bun.lock b/bun.lock index 47765b8f..641be83c 100644 --- a/bun.lock +++ b/bun.lock @@ -81,6 +81,7 @@ "drizzle-orm": "^0.45.2", "postgres": "^3.4.9", "postgresql": "^0.0.1", + "standardwebhooks": "catalog:", "zod": "catalog:", "zod-openapi": "^6.0.0", }, @@ -101,6 +102,7 @@ "@types/bun": "latest", "@types/node": "^26.1.1", "hono": "^4.12.31", + "standardwebhooks": "^1.1.1", "typescript": "^7.0.1-rc", "zod": "^4.4.3", }, diff --git a/package.json b/package.json index 93f910fe..68fc58f9 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "hono": "^4.12.31", "typescript": "^7.0.1-rc", "zod": "^4.4.3", - "@polar-sh/sdk": "^0.49.0" + "@polar-sh/sdk": "^0.49.0", + "standardwebhooks": "^1.1.1" } }, "type": "module", diff --git a/packages/core/package.json b/packages/core/package.json index cbfc7f6e..18bc00ab 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -22,6 +22,7 @@ "drizzle-orm": "^0.45.2", "postgres": "^3.4.9", "postgresql": "^0.0.1", + "standardwebhooks": "catalog:", "zod": "catalog:", "zod-openapi": "^6.0.0" }, diff --git a/packages/core/scripts/polar-product.ts b/packages/core/scripts/polar-product.ts index e3412071..17b4f5ef 100644 --- a/packages/core/scripts/polar-product.ts +++ b/packages/core/scripts/polar-product.ts @@ -22,6 +22,8 @@ import { Polar } from '@polar-sh/sdk'; import type { PresentmentCurrency } from '@polar-sh/sdk/models/components/presentmentcurrency.js'; +type Price = { currency: PresentmentCurrency; amount: number }; + /** * The paid rung of the self-serve ladder. * @@ -32,16 +34,29 @@ import type { PresentmentCurrency } from '@polar-sh/sdk/models/components/presen * * Amounts are in minor units: 2000 is 20.00. */ -const PRODUCT = { - name: 'Nestri Pro', - description: 'Cloud sessions on Nestri hardware, and a larger burn allowance.', - recurringInterval: 'month' as const, - prices: [ - { currency: 'usd', amount: 2000 }, - { currency: 'eur', amount: 2000 }, - { currency: 'gbp', amount: 2000 } - ] satisfies { currency: PresentmentCurrency; amount: number }[] -}; +const PRODUCTS = [ + { + env: 'POLAR_FREE_PRODUCT_ID', + name: 'Nestri Free', + description: 'Sessions on hardware you own, and a monthly burn allowance.', + // Recurring, and priced at nothing. It has to be a *subscription* rather + // than a one-time purchase, because a team is put on it outright at + // signup and a one-time product has no subscription to create. + recurringInterval: 'month' as const, + prices: [{ currency: 'usd', amount: 0 }] satisfies Price[] + }, + { + env: 'POLAR_PRODUCT_ID', + name: 'Nestri Pro', + description: 'Cloud sessions on Nestri hardware, and a larger burn allowance.', + recurringInterval: 'month' as const, + prices: [ + { currency: 'usd', amount: 2000 }, + { currency: 'eur', amount: 2000 }, + { currency: 'gbp', amount: 2000 } + ] satisfies Price[] + } +]; const apply = process.argv.includes('--apply'); const accessToken = process.env.POLAR_ACCESS_TOKEN; @@ -80,34 +95,49 @@ console.log(`server: ${server}`); console.log(`organization: ${organization.name} (${organization.id})`); const existing = await polar.products.list({ organizationId: organization.id, limit: 100 }); -const clash = existing.result.items.find((p) => p.name === PRODUCT.name && !p.isArchived); -if (clash) { - console.log(`\nalready there: ${PRODUCT.name} (${clash.id})`); - console.log('nothing to do. Archive it first if you meant to replace it.'); - process.exit(0); -} -console.log(`\nwould create: ${PRODUCT.name}, every ${PRODUCT.recurringInterval}`); -for (const price of PRODUCT.prices) { - console.log(` ${price.currency.toUpperCase()} ${(price.amount / 100).toFixed(2)}`); +for (const product of PRODUCTS) { + const clash = existing.result.items.find((p) => p.name === product.name && !p.isArchived); + if (clash) { + // Same name is not the same product. A one-time product where a + // subscription is wanted cannot be subscribed to at all, and reporting + // it as already-there would hand back an id that fails at the first use. + if (clash.recurringInterval !== product.recurringInterval) { + console.log(`\nwrong shape: ${product.name} (${clash.id})`); + console.log( + ` wanted every ${product.recurringInterval}, found ${clash.recurringInterval ?? 'one-time'}` + ); + console.log(' archive it first, then run this again.'); + continue; + } + console.log(`\nalready there: ${product.name}`); + console.log(` ${product.env}=${clash.id}`); + continue; + } + + console.log(`\nwould create: ${product.name}, every ${product.recurringInterval}`); + for (const price of product.prices) { + console.log(` ${price.currency.toUpperCase()} ${(price.amount / 100).toFixed(2)}`); + } + + if (!apply) { + continue; + } + + const created = await polar.products.create({ + ...(scopedToOrganization ? {} : { organizationId: organization.id }), + name: product.name, + description: product.description, + recurringInterval: product.recurringInterval, + prices: product.prices.map((price) => ({ + amountType: 'fixed' as const, + priceCurrency: price.currency, + priceAmount: price.amount + })) + }); + console.log(` created: ${product.env}=${created.id}`); } if (!apply) { - console.log('\nnothing written. Re-run with --apply to create it.'); - process.exit(0); + console.log('\nnothing written. Re-run with --apply to create them.'); } - -const created = await polar.products.create({ - ...(scopedToOrganization ? {} : { organizationId: organization.id }), - name: PRODUCT.name, - description: PRODUCT.description, - recurringInterval: PRODUCT.recurringInterval, - prices: PRODUCT.prices.map((price) => ({ - amountType: 'fixed' as const, - priceCurrency: price.currency, - priceAmount: price.amount - })) -}); - -console.log(`\ncreated: ${created.id}`); -console.log(`set POLAR_PRODUCT_ID=${created.id} for the ${server} deployment.`); diff --git a/packages/core/src/billing/polar.test.ts b/packages/core/src/billing/polar.test.ts index 09a3aa8b..989460fa 100644 --- a/packages/core/src/billing/polar.test.ts +++ b/packages/core/src/billing/polar.test.ts @@ -137,3 +137,53 @@ describe('Webhooks', () => { expect(() => Polar.receive({ body: '{}', headers: {} })).toThrow(/not configured/); }); }); + +describe('Both signing schemes', () => { + // A `whsec_` secret is Standard Webhooks, where the secret is a base64 key. + // The SDK helper only implements the older scheme and base64-encodes + // whatever it is handed, so a Standard Webhooks secret verified through it + // fails every time, for a reason no error message mentions. These assert the + // prefix is what picks, so rotating a secret cannot put the two out of step. + test('a Standard Webhooks secret is verified, and a real signature passes', async () => { + const { Webhook } = await import('standardwebhooks'); + const key = Buffer.from('a'.repeat(32)).toString('base64'); + const secret = `whsec_${key}`; + configure({ POLAR_WEBHOOK_SECRET: secret }); + + const body = JSON.stringify({ + type: 'subscription.active', + data: { customer: { externalId: 'tem_x' }, productId: PAID } + }); + const id = 'msg_1'; + 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.type).toBe('subscription.active'); + expect(delivery.teamId).toBe('tem_x'); + expect(delivery.standing).toEqual({ plan: 'paid', status: 'active' }); + }); + + 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 }); + expect(() => + Polar.receive({ + body: '{"type":"subscription.revoked"}', + headers: { + 'webhook-id': 'msg_1', + 'webhook-timestamp': '1', + 'webhook-signature': 'v1,AAAA' + } + }) + ).toThrow(/Signature/); + }); +}); diff --git a/packages/core/src/billing/polar.ts b/packages/core/src/billing/polar.ts index 46b462f6..5e517115 100644 --- a/packages/core/src/billing/polar.ts +++ b/packages/core/src/billing/polar.ts @@ -1,5 +1,6 @@ import { Polar as PolarSdk } from '@polar-sh/sdk'; import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks'; +import { Webhook as StandardWebhook } from 'standardwebhooks'; import z from 'zod'; import { Env } from '../env.js'; @@ -257,11 +258,31 @@ export namespace Polar { ); } - let event; + // Two signing schemes, told apart by the secret itself. + // + // A `whsec_` prefix means Standard Webhooks, where the secret is a + // base64 key the library decodes. Anything else is the older scheme, + // where the secret is used as its own raw bytes. The SDK's helper + // only implements the older one — it base64-encodes whatever it is + // handed, which turns a Standard Webhooks secret into the literal + // bytes of the string including the prefix, and then every signature + // fails to match for a reason no error message mentions. + // + // Reading the prefix rather than configuring which scheme is in use + // means rotating a secret cannot put the two out of step. + let event: { type: string; data?: Record }; try { - event = validateEvent(input.body, input.headers, webhookSecret); + if (webhookSecret.startsWith('whsec_')) { + const verified = new StandardWebhook(webhookSecret).verify(input.body, input.headers); + event = verified as { type: string; data?: Record }; + } else { + event = validateEvent(input.body, input.headers, webhookSecret) as { + type: string; + data?: Record; + }; + } } catch (error) { - if (error instanceof WebhookVerificationError) { + if (error instanceof WebhookVerificationError || error instanceof Error) { throw new VisibleError( 'authentication', ErrorCodes.Authentication.INVALID_TOKEN, @@ -271,7 +292,7 @@ export namespace Polar { throw error; } - const data = (event as { data?: Record }).data ?? {}; + const data = event.data ?? {}; const customer = data.customer as { externalId?: 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