From fc97da820d001599aeb20a9701df39f0c2507d0c Mon Sep 17 00:00:00 2001 From: Michael Ryaboy Date: Sat, 19 Sep 2026 07:33:20 -0700 Subject: [PATCH 1/2] Surface Pro and notify the owner of sign-ins and subscriptions --- BILLING.md | 63 +++++- src/auth.ts | 12 +- src/billing-notifications.ts | 233 +++++++++++++++++++++ src/billing.ts | 35 +++- src/home.ts | 7 +- src/newsletter.ts | 4 +- src/pages.ts | 8 +- test/newsletter.test.ts | 12 +- test/notifications.test.ts | 393 +++++++++++++++++++++++++++++++++++ test/workos-fixture.ts | 81 +++++++- 10 files changed, 823 insertions(+), 25 deletions(-) create mode 100644 src/billing-notifications.ts create mode 100644 test/notifications.test.ts diff --git a/BILLING.md b/BILLING.md index b4556aa..f299c5c 100644 --- a/BILLING.md +++ b/BILLING.md @@ -41,8 +41,16 @@ SETUP https://classifier.dev/v1/billing/callback; for local development add the callback under your BILLING_ORIGIN (http://localhost:8787/v1/billing/callback by default). -6. Merge/deploy the Worker with the BILLING binding and v2-billing migration +6. Configure STRIPE_WEBHOOK_SECRET and STRIPE_PRO_PRODUCT_ID using Wrangler. + The signing secret belongs to a Stripe endpoint at + https://classifier.dev/v1/billing/stripe-webhook listening to invoice.paid. + The product ID limits notifications to Pro on the shared Stripe account. + Notifications use the existing RESEND_API_KEY and private REPORT_TO setting. + No Stripe API key is needed. Missing webhook configuration returns 503; + test-mode events are accepted and ignored. +7. Merge/deploy the Worker with the BILLING binding and v2-billing migration from wrangler.example.toml. Secrets are kept outside git and survive deploys. + Notifications add no binding and no migration: they reuse BILLING. LOCAL DEVELOPMENT @@ -65,6 +73,17 @@ sends the browser back to /pro. Session cookies are HttpOnly, Secure and scoped to the billing routes; lifetime and rotation follow the backend's WorkOS session configuration. Mutations require an exact same-origin Origin header. +POST /v1/billing/stripe-webhook is Stripe's, not a browser's: it carries no +cookie and no Origin header, so it skips the session and Origin guards while +still being served only on the canonical origin. It verifies the Stripe +signature over the raw body — HMAC-SHA256 over `.`, every v1 in the +header tried so a secret rotation keeps working, a 300-second tolerance in both +directions — before parsing anything, and reads at most 256 KiB. An invalid, +missing, stale or future signature is 400; an oversized body 413; the wrong +method 405; unset configuration or a storage failure 503, which is Stripe's cue +to retry. Valid events it ignores are 200. Nothing is logged and no error says +why beyond a generic message. + Authenticated browser routes: GET /v1/billing/account email, active, plan, hasKey POST /v1/billing/checkout hosted confirmation URL for Pro @@ -92,6 +111,39 @@ email; neither receives classification content. Classification analytics continue using the existing daily caller fingerprints; no billing customer ID or email is included in analytics or the newsletter DB. +OPERATOR NOTIFICATIONS + +Completed WorkOS sign-ins and new paid Pro subscriptions email REPORT_TO. +The internal sender is classifier.dev ; replies go to +contact@classifier.dev. Messages contain billing identity and provider IDs, +plus the amount and payment time for subscriptions. Classification traffic, +labels and API keys are never included. + +A sign-in queues once per WorkOS session after the callback resolves an +account. Failed callbacks and refused first-time account links send nothing; +refreshes and account reads do not notify. The callback waits for durable +storage, never for Resend. A storage failure fails the callback so the user can +sign in again; email-provider failures are retried independently. + +Paid notifications require a live invoice.paid event, status paid, a positive +amount, billing_reason subscription_create, and a positive line for the Pro +product. Renewals, zero invoices, unpaid events and other products are ignored. +Asynchronous payments notify when the paid invoice arrives. Both modern +parent/pricing fields and legacy subscription/price fields are supported. +The payment timestamp comes from paid_at, falling back to event.created. + +One BILLING instance per session or subscription stores its outbox and alarm +atomically. The full email payload, including the private recipient, is frozen +in billing storage before delivery and discarded after Resend accepts it. +A retry alarm is persisted before each attempt, so a crash between provider +acceptance and the sent marker can retry safely. Failures, including missing +email configuration, back off from 30 seconds to one hour across restarts. +The sent marker remains to suppress later duplicate events. + +Retries use the same Resend idempotency key and body. Resend retains keys for +24 hours: an ambiguous acceptance followed by a retry after that window can +produce a duplicate. A known successful send remains deduplicated indefinitely. + Only an active, non-past-due Pro subscription grants access. Scheduled cancellation remains active through expires_at. Positive access is cached for at most 60 seconds, bounded by any known expiry; inactive access for 5 seconds. @@ -101,6 +153,15 @@ Anonymous requests continue to work during billing-provider outages. VERIFICATION Run `npm test`, `npx tsc --noEmit`, and `cd cli && node --test`. +The notification tests sign webhook bodies with real HMAC and drive the outbox +through fixture storage and alarms; they send no mail and touch no account. +Production verification on 2026-09-19 confirmed inbox receipt for a fresh +WorkOS sign-in and the existing paid $20 Pro invoice. A locally signed replay +of that real invoice received 200; repeating it sent no duplicate email. +The live Stripe invoice.paid endpoint is enabled. A Stripe-originated replay +remains unverified because the CLI key lacks webhook_write and accessing the +existing full-access key requires a fresh phone verification. No new payment +was made for this check. In sandbox, verify WorkOS sign-in, checkout for $20/month, key creation, REST and MCP headers, key replacement, portal cancellation and expiry. Confirm that repeated checkout requests reuse the pending checkout rather than creating diff --git a/src/auth.ts b/src/auth.ts index 6d5d7cc..bb6b9e4 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -107,8 +107,11 @@ async function openLoginCookie(env: AuthEnv, raw: string | undefined): Promise<{ } catch { return null; } } -/** Finishes hosted sign-in: state must match this browser's cookie, then the code and PKCE verifier are exchanged. */ -export async function completeLogin(req: Request, env: AuthEnv): Promise<{user: User; sealedSession: string}> { +/** + * Finishes hosted sign-in: state must match this browser's cookie, then the code and PKCE verifier are + * exchanged. The session ID comes from the claim just checked, so naming this sign-in costs no second call. + */ +export async function completeLogin(req: Request, env: AuthEnv): Promise<{user: User; sealedSession: string; sessionId: string}> { const {workos, clientId, cookiePassword, issuer} = config(env); const url = new URL(req.url); const code = url.searchParams.get("code"), state = url.searchParams.get("state"); @@ -119,8 +122,9 @@ export async function completeLogin(req: Request, env: AuthEnv): Promise<{user: result = await workos.userManagement.authenticateWithCode({code, codeVerifier: login.verifier, clientId, session: {sealSession: true, cookiePassword}}); } catch (error) { throw providerFailure(error); } if (!result.sealedSession || typeof result.user?.id !== "string" || typeof result.user.email !== "string") throw unavailable(); - if (!sessionClaim(result.accessToken, {issuer, clientId, userId: result.user.id})) throw unauthorized(); - return {user: result.user, sealedSession: result.sealedSession}; + const sessionId = sessionClaim(result.accessToken, {issuer, clientId, userId: result.user.id}); + if (!sessionId) throw unauthorized(); + return {user: result.user, sealedSession: result.sealedSession, sessionId}; } export type Session = {userId: string; email: string; sessionId: string}; diff --git a/src/billing-notifications.ts b/src/billing-notifications.ts new file mode 100644 index 0000000..84ae737 --- /dev/null +++ b/src/billing-notifications.ts @@ -0,0 +1,233 @@ +/** Private sign-in and paid-subscription emails, delivered by a durable outbox. */ +import { BillingError, unavailable } from "./auth"; + +export interface NotifyEnv { + RESEND_API_KEY?: string; + REPORT_TO?: string; + STRIPE_WEBHOOK_SECRET?: string; + STRIPE_PRO_PRODUCT_ID?: string; +} + +const encoder = new TextEncoder(); +const FROM = "classifier.dev "; +const REPLY_TO = "contact@classifier.dev"; +const RESEND = "https://api.resend.com/emails"; +const TIMEOUT = 10000; +const FIRST_RETRY_MS = 30_000; +const MAX_RETRY_MS = 3_600_000; +const OUTBOX = "outbox"; +const MAX_SUBJECT = 200; +const MAX_TEXT = 4000; + +export type Notification = {id: string; subject: string; text: string}; +export const NOTIFICATION_ID = /^notify:(login|pro):[A-Za-z0-9_-]{1,128}$/; +const PROVIDER_ID = /^[A-Za-z0-9_-]{1,128}$/; + +const when = (at: number) => new Date(at).toISOString(); +const address = (value: unknown) => + typeof value === "string" && value.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? value : "Email unavailable"; +const money = (amount: number, currency: string) => `${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`; + +export function loginNotification(input: {sessionId: string; email: string; at: number}): Notification { + if (!PROVIDER_ID.test(input.sessionId)) throw unavailable(); + return { + id: `notify:login:${input.sessionId}`, + subject: "classifier.dev: account signed in", + text: [ + "A Classifier Pro account signed in.", + "", + `Account: ${address(input.email)}`, + `WorkOS session: ${input.sessionId}`, + `Signed in: ${when(input.at)}`, + "", + "One message per sign-in. Session refreshes and account reads send none.", + ].join("\n"), + }; +} + +export type PaidSubscription = {subscription: string; customer: string; invoice: string; amount: number; currency: string; email: unknown; paidAt: number | null}; +export function subscriptionNotification(paid: PaidSubscription, at: number): Notification { + if (!PROVIDER_ID.test(paid.subscription)) throw unavailable(); + // Say when the invoice was actually paid; a replayed old event must not read as money arriving now. + const stamp = paid.paidAt === null ? `Queued: ${when(at)} (the event carried no paid time)` : `Paid: ${when(paid.paidAt)}`; + return { + id: `notify:pro:${paid.subscription}`, + subject: `classifier.dev: new paid Pro subscription (${money(paid.amount, paid.currency)})`, + text: [ + "A new paid Classifier Pro subscription started. This is a first payment,", + "not a renewal.", + "", + `Customer: ${address(paid.email)}`, + `Amount paid: ${money(paid.amount, paid.currency)}`, + `Stripe customer: ${paid.customer}`, + `Subscription: ${paid.subscription}`, + `Invoice: ${paid.invoice}`, + stamp, + "", + "Sent once per subscription, from the paid invoice itself.", + ].join("\n"), + }; +} + +type Mail = {from: string; to: string[]; reply_to: string; subject: string; text: string}; +type Outbox = + | {key: string; attempts: number; mail: Mail} + | {key: string; attempts: number; subject: string; text: string} + | {key: string; sent: true}; +const backoff = (attempts: number) => Math.min(MAX_RETRY_MS, FIRST_RETRY_MS * 2 ** (attempts - 1)); + +function pending(note: Notification, env: NotifyEnv, attempts: number): Outbox { + if (!env.REPORT_TO) return {key: note.id, attempts, subject: note.subject, text: note.text}; + return {key: note.id, attempts, mail: {from: FROM, to: [env.REPORT_TO], reply_to: REPLY_TO, subject: note.subject, text: note.text}}; +} + +export async function enqueue(storage: DurableObjectStorage, note: Notification, env: NotifyEnv): Promise { + if (!NOTIFICATION_ID.test(note.id) || !note.subject || !note.text || note.subject.length > MAX_SUBJECT || note.text.length > MAX_TEXT) { + throw new BillingError(400, "A valid notification is required."); + } + return storage.transaction(async tx => { + if (await tx.get(OUTBOX)) return false; + await tx.put(OUTBOX, pending(note, env, 0)); + await tx.setAlarm(Date.now()); + return true; + }); +} + +export async function flush(storage: DurableObjectStorage, env: NotifyEnv): Promise { + const record = await storage.get(OUTBOX); + if (!record || "sent" in record) return; + const attempts = record.attempts + 1; + const next: Outbox = "mail" in record + ? {...record, attempts} + : pending({id: record.key, subject: record.subject, text: record.text}, env, attempts); + // Pre-arm the retry before sending so a crash after acceptance cannot lose the job. + await storage.transaction(async tx => { + await tx.put(OUTBOX, next); + await tx.setAlarm(Date.now() + backoff(attempts)); + }); + if (!("mail" in next) || !env.RESEND_API_KEY) return; + if (!await deliver(next.mail, record.key, env.RESEND_API_KEY)) return; + await storage.transaction(async tx => { + await tx.put(OUTBOX, {key: record.key, sent: true}); + await tx.deleteAlarm(); + }); +} + +async function deliver(mail: Mail, key: string, apiKey: string): Promise { + try { + const response = await fetch(RESEND, { + method: "POST", + signal: AbortSignal.timeout(TIMEOUT), + headers: {authorization: `Bearer ${apiKey}`, "content-type": "application/json", "Idempotency-Key": key}, + body: JSON.stringify(mail), + }); + if (!response.ok) return false; + const result = await response.json() as {id?: unknown} | null; + return typeof result?.id === "string" && result.id.length > 0; + } catch { return false; } +} + +const SKEW_SECONDS = 300; +const MAX_BODY = 256 * 1024; + +const obj = (value: unknown): Record | null => + value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +function idOf(value: unknown): string | null { + if (typeof value === "string" && value) return value; + const id = obj(value)?.id; + return typeof id === "string" && id ? id : null; +} +const positive = (value: unknown) => typeof value === "number" && Number.isSafeInteger(value) && value > 0; +const millis = (value: unknown) => positive(value) && (value as number) <= 8_640_000_000_000 ? (value as number) * 1000 : null; + +async function rawBody(req: Request): Promise { + if (!req.body) throw new BillingError(400, "A request body is required."); + const reader = req.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + for (;;) { + const {value, done} = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > MAX_BODY) { await reader.cancel(); throw new BillingError(413, "Request is too large."); } + chunks.push(value); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } + return bytes; +} + +function parseSignature(header: string): {stamp: string; seconds: number; signatures: string[]} | null { + let stamp = ""; + const signatures: string[] = []; + for (const part of header.split(",")) { + const split = part.indexOf("="); + if (split < 0) continue; + const name = part.slice(0, split).trim(), value = part.slice(split + 1).trim(); + if (name === "t" && !stamp && /^\d{1,15}$/.test(value)) stamp = value; + else if (name === "v1" && /^[a-fA-F0-9]{64}$/.test(value)) signatures.push(value); + } + const seconds = stamp ? Number(stamp) : NaN; + if (!Number.isSafeInteger(seconds) || !signatures.length) return null; + return {stamp, seconds, signatures}; +} +const fromHex = (value: string) => Uint8Array.from(value.match(/../g)!.map(byte => parseInt(byte, 16))); + +async function verify(secret: string, raw: Uint8Array, header: string): Promise { + const parsed = parseSignature(header); + if (!parsed) return false; + if (Math.abs(Math.floor(Date.now() / 1000) - parsed.seconds) > SKEW_SECONDS) return false; + const prefix = encoder.encode(`${parsed.stamp}.`); + const signed = new Uint8Array(prefix.length + raw.length); + signed.set(prefix); + signed.set(raw, prefix.length); + const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {name: "HMAC", hash: "SHA-256"}, false, ["verify"]); + for (const signature of parsed.signatures) { + if (await crypto.subtle.verify("HMAC", key, fromHex(signature), signed)) return true; + } + return false; +} + +const subscriptionOf = (invoice: Record) => + idOf(obj(obj(invoice.parent)?.subscription_details)?.subscription) ?? idOf(invoice.subscription); + +function chargesProduct(invoice: Record, product: string, subscription: string): boolean { + const lines = obj(invoice.lines)?.data; + if (!Array.isArray(lines)) return false; + return lines.some(entry => { + const line = obj(entry); + if (!line || !positive(line.amount)) return false; + const owner = idOf(obj(obj(line.parent)?.subscription_item_details)?.subscription); + if (owner && owner !== subscription) return false; + const named = idOf(obj(obj(line.pricing)?.price_details)?.product) ?? idOf(obj(line.price)?.product); + return named === product; + }); +} + +export function proSubscription(event: Record, product: string): PaidSubscription | null { + if (event.livemode !== true || event.type !== "invoice.paid") return null; + const invoice = obj(obj(event.data)?.object); + if (!invoice || invoice.status !== "paid" || invoice.billing_reason !== "subscription_create") return null; + if (!positive(invoice.amount_paid)) return null; + const id = typeof invoice.id === "string" && invoice.id ? invoice.id : null; + const customer = idOf(invoice.customer); + const subscription = subscriptionOf(invoice); + const currency = typeof invoice.currency === "string" && invoice.currency ? invoice.currency : null; + if (!id || !customer || !subscription || !currency) return null; + if (!chargesProduct(invoice, product, subscription)) return null; + const paidAt = millis(obj(invoice.status_transitions)?.paid_at) ?? millis(event.created); + return {subscription, customer, invoice: id, amount: invoice.amount_paid as number, currency, email: invoice.customer_email, paidAt}; +} + +export async function stripeEvent(req: Request, env: NotifyEnv): Promise { + if (!env.STRIPE_WEBHOOK_SECRET || !env.STRIPE_PRO_PRODUCT_ID) throw unavailable(); + const header = req.headers.get("Stripe-Signature") || ""; + const raw = await rawBody(req); + if (!header || !await verify(env.STRIPE_WEBHOOK_SECRET, raw, header)) throw new BillingError(400, "The signature could not be verified."); + let event: Record | null = null; + try { event = obj(JSON.parse(new TextDecoder().decode(raw))); } catch { event = null; } + if (!event) throw new BillingError(400, "A valid JSON object is required."); + const paid = proSubscription(event, env.STRIPE_PRO_PRODUCT_ID); + return paid ? subscriptionNotification(paid, Date.now()) : null; +} diff --git a/src/billing.ts b/src/billing.ts index 509a169..8fda239 100644 --- a/src/billing.ts +++ b/src/billing.ts @@ -3,9 +3,10 @@ import { authenticateSession, beginLogin, BillingError, clearCookie, clearLoginCookie, completeLogin, endSession, SESSION_COOKIE, sessionCookie, unauthorized, unavailable, type AuthEnv, } from "./auth"; +import { enqueue, flush, loginNotification, stripeEvent, type NotifyEnv, type Notification } from "./billing-notifications"; export { BillingError } from "./auth"; -export interface BillingEnv extends PrivacyEnv, AuthEnv { +export interface BillingEnv extends PrivacyEnv, AuthEnv, NotifyEnv { BILLING?: DurableObjectNamespace; LIMITER: DurableObjectNamespace; BILLING_SIGNING_KEY?: string; @@ -88,6 +89,8 @@ async function durable(env: BillingEnv, name: string, action: string, data: Reco throw unavailable(); } } +/** Storage only: the instance named by the event writes a record, and its alarm does the sending. */ +const notify = (env: BillingEnv, note: Notification) => durable(env, note.id, "notify", note); const identityName = (userId: string) => `workos:${userId}`; /** * The customer behind a WorkOS user. A bound identity answers directly and never looks at the email again. @@ -109,6 +112,8 @@ async function resolveCustomer(env: BillingEnv, user: {id: string; email: string } const METHODS: Record = {login: "GET", callback: "GET", account: "GET", key: "POST", checkout: "POST", portal: "POST", logout: "POST"}; +/** Stripe signs its own requests and has no browser, session or Origin header to offer. */ +const WEBHOOK = "stripe-webhook"; /** Browser billing routes. Mutations are same-origin; sessions are WorkOS-sealed cookies; nothing identifying is logged. */ export async function handleBilling(req: Request, env: BillingEnv): Promise { const url = new URL(req.url); @@ -124,6 +129,13 @@ export async function handleBilling(req: Request, env: BillingEnv): Promise = Promise.resolve(); @@ -185,6 +200,12 @@ export class BillingAccount implements DurableObject { this.queue = operation.catch(() => {}); return operation; } + /** Notification instances only. Failing here keeps the record and the alarm, so nothing is lost. */ + alarm(): Promise { + const operation = this.queue.then(() => flush(this.state.storage, this.env)); + this.queue = operation.catch(() => {}); + return operation; + } private async active(customerId: string): Promise { if (this.cached && this.cached.until > Date.now()) return this.cached.active; const customer = await autumn(this.env, "customers.get", {customer_id: customerId}); @@ -227,6 +248,12 @@ export class BillingAccount implements DurableObject { const input = await body(req); const action = new URL(req.url).pathname.slice(1); if (["identity", "candidate", "bind"].includes(action)) return await this.identity(action, input); + // A notification instance holds no account, so this is answered before anything asks for one. + if (action === "notify") { + const {id, subject, text} = input; + if (typeof id !== "string" || typeof subject !== "string" || typeof text !== "string") throw new BillingError(400, "A valid notification is required."); + return json({queued: await enqueue(this.state.storage, {id, subject, text}, this.env)}); + } const {customerId, secret, userId} = input; if (typeof customerId !== "string" || !/^[a-f0-9]{64}$/.test(customerId)) throw unauthorized(); let account = await this.state.storage.get("account"); diff --git a/src/home.ts b/src/home.ts index ce170c6..0df8104 100644 --- a/src/home.ts +++ b/src/home.ts @@ -240,8 +240,8 @@ function subscribeDock() { function updatesSection() { return `

## Get the updates

-

The free tier is the whole service today. What is being built on top of it, - in the order people ask for it — tick what you would use first:

+

What is being built next, in the order people ask for it — tick what you + would use first:

${roadmapPick("want", "What you would use first")}
@@ -752,7 +752,8 @@ export function homeHtml(o: { chat?: boolean } = {}): string { css: HOME_CSS, body: `

# classifier.dev

-

zero-shot text classification over plain HTTP — no API key, no account

+

zero-shot text classification over plain HTTP — no API key, no account
+ free forever — Pro · $20/mo · 10× limits

${NAV(o.chat ? "chat" : "home")}
diff --git a/src/newsletter.ts b/src/newsletter.ts index 99fbd82..762dd61 100644 --- a/src/newsletter.ts +++ b/src/newsletter.ts @@ -31,6 +31,7 @@ import type { Env } from "./index"; import { btn, esc, page } from "./ui"; +import { SITE } from "./wellknown"; /** * What an address is worth. Honest about being work in progress, not a promise @@ -171,7 +172,8 @@ export async function requestConfirmation(env: Env, email: string, wants: Readon body: JSON.stringify({ from: env.NEWSLETTER_FROM, to: [email], - ...(env.REPORT_TO ? { reply_to: env.REPORT_TO } : {}), + // Customer-facing replies use the public alias, never the private alert recipient. + reply_to: SITE.email, subject: "Confirm your classifier.dev updates subscription", text: [ "Confirm that you want classifier.dev product updates:", link, "", diff --git a/src/pages.ts b/src/pages.ts index a102d24..c5b8b50 100644 --- a/src/pages.ts +++ b/src/pages.ts @@ -521,8 +521,8 @@ SECURITY export const PRIVACY = `classifier.dev privacy -The short version: the texts you classify are not stored, and there are no -accounts to attach anything to. +The short version: the texts you classify are not stored. Pro billing accounts +are kept separate from classification traffic. WHAT IS SENT WHERE @@ -589,6 +589,10 @@ PRO BILLING newsletter database. Billing identity is never included in classification analytics; the daily caller fingerprints above continue to apply. Payment details are entered in Stripe checkout. + Account sign-ins and new paid subscriptions send an operational notification + to the person who runs the service. It carries billing identity — the account + email and the provider's own reference — and nothing you have classified; it + is never linked to API traffic. Only hashes of API keys are stored on our server. A key is shown once when created; rotating it replaces the old credential. Subscription access is checked with a cache of at most 60 seconds. diff --git a/test/newsletter.test.ts b/test/newsletter.test.ts index 494b119..50d52c9 100644 --- a/test/newsletter.test.ts +++ b/test/newsletter.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { ROADMAP, ROADMAP_KEYS, normalise, wanted, roadmapDoc, subscribe, notify, Unavailable, confirmationToken, verifyToken, requestConfirmation } from "../src/newsletter"; import worker, { type Env } from "../src/index"; import { OPENAPI } from "../src/openapi"; +import { SITE } from "../src/wellknown"; const CONN = "postgresql://writer:pw@ep-test.us-east-1.aws.neon.tech/neondb?sslmode=require"; const env = { NEWSLETTER_DATABASE_URL: CONN, NEWSLETTER_CONFIRMATION_SECRET: "test-secret-long-enough-for-confirmation", NEWSLETTER_RESEND_API_KEY: "test-resend", NEWSLETTER_FROM: "classifier.dev " } as Env; @@ -19,12 +20,13 @@ afterEach(() => void (globalThis.fetch = realFetch)); /** Records the one request subscribe() makes. */ function capture(status = 200) { - const seen: { url: string; headers: Headers; body: { query: string; params: unknown[]; to: string[]; text: string } }[] = []; + const seen: { url: string; headers: Headers; body: { query: string; params: unknown[]; to: string[]; text: string; reply_to?: string }; raw: string }[] = []; globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { seen.push({ url: String(url), headers: new Headers(init?.headers), body: JSON.parse(String(init?.body)), + raw: String(init?.body), }); return new Response(JSON.stringify({ rowCount: 1, rows: [{ added: true }] }), { status }); }) as typeof globalThis.fetch; @@ -366,6 +368,14 @@ describe("email confirmation", () => { expect(seen[0].body.text).not.toContain(env.NEWSLETTER_CONFIRMATION_SECRET!); }); + it("replies to the published address, never the owner's private REPORT_TO", async () => { + const seen = capture(); + await requestConfirmation({ ...env, REPORT_TO: "owner-private@example.com" } as Env, "agent@example.com"); + expect(seen[0].body.reply_to).toBe(SITE.email); + // The address a subscriber never asked to learn is nowhere in what Resend is sent. + expect(seen[0].raw).not.toContain("owner-private@example.com"); + }); + it("GET previews without persisting or sending mail; POST confirms", async () => { const token = await confirmationToken(env, "agent@example.com"); const seen = capture(); diff --git a/test/notifications.test.ts b/test/notifications.test.ts new file mode 100644 index 0000000..3ac2e89 --- /dev/null +++ b/test/notifications.test.ts @@ -0,0 +1,393 @@ +/** + * Operator notifications: one private email after a sign-in, one after a new paid Pro subscription. + * The Stripe endpoint is exercised with real HMAC signatures over the real body bytes, and the outbox + * through the fixture's storage and alarms, so nothing here is a stand-in for the code under test. + */ +import { afterEach, expect, mock, spyOn, test } from "bun:test"; +import { OWNER, ORIGIN, PRO_PRODUCT, WEBHOOK_SECRET, setup, stripeSignature } from "./workos-fixture"; + +afterEach(() => mock.restore()); + +test("long verified email addresses sign in without overflowing the notification subject", async () => { + const s = setup(); + s.workos.user.email = `${"a".repeat(64)}@${"b".repeat(63)}.${"c".repeat(63)}.${"d".repeat(60)}`; + expect(s.workos.user.email).toHaveLength(253); + await s.signIn(); + await s.runAlarms(); + expect(s.resend.requests[0].body.subject).toBe("classifier.dev: account signed in"); + expect(s.resend.requests[0].body.text).toContain(s.workos.user.email); +}); + +const SUBSCRIPTION = "sub_1PaidLive", CUSTOMER = "cus_PaidLive", INVOICE = "in_1PaidLive"; +type Data = Record; +/** The shape a current live invoice.paid actually has: the subscription and the product hang off `parent`/`pricing`. */ +const modernLine = (over: Data = {}) => ({ + id: "il_1", object: "line_item", amount: 2000, currency: "usd", + pricing: {type: "price_details", price_details: {product: PRO_PRODUCT, price: "price_1Pro"}}, + parent: {type: "subscription_item_details", subscription_item_details: {subscription: SUBSCRIPTION, subscription_item: "si_1"}}, + ...over, +}); +/** The older schema Stripe still sends on pinned API versions: `invoice.subscription`, `line.price.product`. */ +const legacyLine = (over: Data = {}) => ({id: "il_1", object: "line_item", amount: 2000, currency: "usd", price: {id: "price_1Pro", object: "price", product: PRO_PRODUCT}, ...over}); + +const invoice = (over: Data = {}) => ({ + id: INVOICE, object: "invoice", status: "paid", billing_reason: "subscription_create", + amount_paid: 2000, amount_due: 2000, currency: "usd", customer: CUSTOMER, customer_email: "buyer@example.com", + parent: {type: "subscription_details", subscription_details: {subscription: SUBSCRIPTION, metadata: {}}}, + lines: {object: "list", data: [modernLine()]}, + ...over, +}); +const event = (over: Data = {}, invoiceOver: Data = {}) => ({ + id: "evt_1Live", object: "event", api_version: "2026-04-22.dahlia", created: 1758300000, + livemode: true, type: "invoice.paid", data: {object: invoice(invoiceOver)}, ...over, +}); +const legacy = () => event({}, {parent: undefined, subscription: SUBSCRIPTION, lines: {object: "list", data: [legacyLine()]}}); + +const outbox = (s: ReturnType, id: string) => s.data.get(`${id}outbox`) as Data | undefined; +const sent = (s: ReturnType) => s.resend.requests.map(r => r.body); + +test("replayed invoices show the original payment time", async () => { + const s = setup(); + const paidAt = 1750000000; + await s.stripe(event({}, {status_transitions: {paid_at: paidAt}})); + await s.runAlarms(); + expect(s.resend.requests[0].body.text).toContain(`Paid: ${new Date(paidAt * 1000).toISOString()}`); +}); + +test("an accepted email survives a failed sent-marker write with one provider delivery", async () => { + const s = setup(); + await s.stripe(event()); + s.storageFailure.sent = true; + await expect(s.runAlarms()).rejects.toThrow("storage unavailable after send"); + expect(s.alarms.size).toBe(1); + expect(s.resend.accepted.size).toBe(1); + s.storageFailure.sent = false; + s.restart(); + await s.runAlarms(Date.now() + 60_000); + expect(s.resend.requests).toHaveLength(2); + expect(s.resend.accepted.size).toBe(1); + expect(s.resend.requests[1].body).toEqual(s.resend.requests[0].body); + expect(outbox(s, `notify:pro:${SUBSCRIPTION}`)).toEqual({key: `notify:pro:${SUBSCRIPTION}`, sent: true}); + expect(s.alarms.size).toBe(0); +}); + +test("missing recipient configuration keeps a durable job until repaired", async () => { + const s = setup(); + delete s.env.REPORT_TO; + await s.stripe(event()); + await s.runAlarms(); + expect(s.resend.requests).toHaveLength(0); + expect(s.alarms.size).toBe(1); + s.env.REPORT_TO = OWNER; + await s.runAlarms(Date.now() + 60_000); + expect(s.resend.requests[0].body.to).toEqual([OWNER]); + expect(s.alarms.size).toBe(0); +}); + +test("a live paid subscription invoice notifies the operator once, from the outbox rather than the request", async () => { + const s = setup(); + const response = await s.stripe(event()); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({received: true}); + // Nothing was emailed while Stripe was waiting; the record and its alarm are what the 200 promises. + expect(s.resend.requests).toHaveLength(0); + expect(outbox(s, `notify:pro:${SUBSCRIPTION}`)).toMatchObject({key: `notify:pro:${SUBSCRIPTION}`, attempts: 0}); + expect(await s.runAlarms()).toBe(1); + expect(s.resend.requests).toHaveLength(1); + const {headers, body} = s.resend.requests[0]; + expect(headers.get("Idempotency-Key")).toBe(`notify:pro:${SUBSCRIPTION}`); + expect(headers.get("authorization")).toBe("Bearer re_test_key"); + expect(body).toMatchObject({from: "classifier.dev ", to: [OWNER], reply_to: "contact@classifier.dev"}); + expect(body.subject).toBe("classifier.dev: new paid Pro subscription (20.00 USD)"); + expect(body.text).toContain("not a renewal"); + expect(body.text).toContain("buyer@example.com"); + expect(body.text).toContain("20.00 USD"); + for (const id of [SUBSCRIPTION, CUSTOMER, INVOICE]) expect(body.text).toContain(id); + // The message carries billing identity and nothing else; the delivered payload is discarded from storage. + expect(body.text).not.toContain("classifier_pro_"); + expect(body.text).not.toContain(OWNER); + expect(JSON.stringify([...s.data.values()])).not.toContain(OWNER); + expect(outbox(s, `notify:pro:${SUBSCRIPTION}`)).toEqual({key: `notify:pro:${SUBSCRIPTION}`, sent: true}); + expect(s.alarms.size).toBe(0); +}); + +test("the older invoice schema is understood too", async () => { + const s = setup(); + expect((await s.stripe(legacy())).status).toBe(200); + expect(await s.runAlarms()).toBe(1); + expect(sent(s)[0].text).toContain(SUBSCRIPTION); +}); + +test("forged, missing, malformed, stale and future signatures are refused and queue nothing", async () => { + const time = spyOn(Date, "now").mockReturnValue(1758300000000); + const s = setup(); + const seconds = Math.floor(Date.now() / 1000); + const body = JSON.stringify(event()); + const good = await stripeSignature(body, {timestamp: seconds}); + const other = await stripeSignature(body, {timestamp: seconds, secret: "whsec_someone_elses_secret"}); + const forged = await stripeSignature(JSON.stringify(event({id: "evt_other"})), {timestamp: seconds}); + const headers = [ + null, // no Stripe-Signature at all + "", // an empty one + `t=${seconds}`, // no v1 + `v1=${good.v1}`, // no timestamp + `t=not-a-number,v1=${good.v1}`, // an unusable timestamp + `t=${seconds},v1=zz${good.v1.slice(2)}`, // not hex + `t=${seconds},v1=${good.v1.slice(0, 63)}`, // truncated + `t=${seconds},v1=${other.v1}`, // another secret + `t=${seconds},v1=${forged.v1}`, // a signature for a different body + `t=${seconds - 301},v1=${(await stripeSignature(body, {timestamp: seconds - 301})).v1}`, // stale + `t=${seconds + 301},v1=${(await stripeSignature(body, {timestamp: seconds + 301})).v1}`, // future + ]; + for (const header of headers) { + const response = await s.stripe(null, {body, header}); + expect(response.status).toBe(400); + const text = await response.text(); + expect(text).not.toContain(WEBHOOK_SECRET); + expect(text).not.toContain(PRO_PRODUCT); + } + expect(s.data.size).toBe(0); + expect(s.alarms.size).toBe(0); + // Inside the tolerance, and with a rotation's second signature present, the same body is accepted. + expect((await s.stripe(null, {body, header: `t=${seconds - 299},v1=${(await stripeSignature(body, {timestamp: seconds - 299})).v1}`})).status).toBe(200); + time.mockReturnValue(1758300000000); + s.data.clear(); + expect((await s.stripe(null, {body, header: `t=${seconds},v1=${other.v1},v1=${good.v1}`})).status).toBe(200); + expect(await s.runAlarms()).toBe(1); +}); + +test("only a live, paid, first-payment invoice for the Pro product notifies", async () => { + const s = setup(); + const ignored: Data[] = [ + event({livemode: false}), // test mode + event({type: "invoice.payment_succeeded"}), // a neighbouring event + event({type: "checkout.session.completed"}), // a redirect, which never grants anything + event({}, {billing_reason: "subscription_cycle"}), // a renewal + event({}, {billing_reason: "subscription_update"}), + event({}, {status: "open"}), // not paid + event({}, {amount_paid: 0}), + event({}, {amount_paid: -2000}), + event({}, {amount_paid: 20.5}), + event({}, {amount_paid: "2000"}), + event({}, {parent: undefined}), // no subscription anywhere + event({}, {customer: undefined}), + event({}, {currency: undefined}), + event({}, {lines: {object: "list", data: []}}), + event({}, {lines: {object: "list", data: [modernLine({pricing: {type: "price_details", price_details: {product: "prod_other_thing"}}})]}}), + event({}, {lines: {object: "list", data: [legacyLine({price: {product: "prod_other_thing"}})]}}), + event({}, {lines: {object: "list", data: [modernLine({amount: 0})]}}), // a zero line for the product + event({}, {lines: {object: "list", data: [modernLine({parent: {type: "subscription_item_details", subscription_item_details: {subscription: "sub_somebody_else"}}})]}}), + ]; + for (const ignore of ignored) { + const response = await s.stripe(ignore); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({received: true}); + } + expect(s.data.size).toBe(0); + expect(await s.runAlarms()).toBe(0); + expect(s.resend.requests).toHaveLength(0); + // Expanded objects stand in for IDs wherever Stripe documents one, and are read the same way. + const expanded = event({}, { + customer: {id: CUSTOMER, object: "customer"}, + parent: {type: "subscription_details", subscription_details: {subscription: {id: SUBSCRIPTION, object: "subscription"}}}, + lines: {object: "list", data: [modernLine({pricing: {type: "price_details", price_details: {product: {id: PRO_PRODUCT, object: "product"}}}})]}, + }); + expect((await s.stripe(expanded)).status).toBe(200); + expect(await s.runAlarms()).toBe(1); + expect(sent(s)[0].text).toContain(CUSTOMER); +}); + +test("an invoice without a usable customer email says so rather than inventing one", async () => { + const s = setup(); + expect((await s.stripe(event({}, {customer_email: null}))).status).toBe(200); + await s.runAlarms(); + expect(sent(s)[0].text).toContain("Email unavailable"); +}); + +test("redelivered and concurrent copies of one event send exactly one email", async () => { + const s = setup(); + const copies = [s.stripe(event()), s.stripe(event({id: "evt_second_delivery"})), s.stripe(event({id: "evt_third_delivery"}, {id: "in_second_attempt"}))]; + for (const response of await Promise.all(copies)) expect(response.status).toBe(200); + expect(await s.runAlarms()).toBe(1); + expect(s.resend.requests).toHaveLength(1); + // A redelivery long after Resend's 24-hour idempotency window is stopped by our own sent marker. + const time = spyOn(Date, "now").mockReturnValue(Date.now() + 25 * 3600_000); + expect((await s.stripe(event({id: "evt_much_later"}))).status).toBe(200); + expect(await s.runAlarms()).toBe(0); + expect(s.resend.requests).toHaveLength(1); + time.mockRestore(); +}); + +test("a restart loses nothing: the alarm still sends, and the sent marker still holds", async () => { + const s = setup(); + expect((await s.stripe(event())).status).toBe(200); + s.restart(); + expect(await s.runAlarms()).toBe(1); + expect(s.resend.requests).toHaveLength(1); + s.restart(); + expect((await s.stripe(event({id: "evt_after_restart"}))).status).toBe(200); + expect(await s.runAlarms()).toBe(0); + expect(s.resend.requests).toHaveLength(1); + expect(outbox(s, `notify:pro:${SUBSCRIPTION}`)).toEqual({key: `notify:pro:${SUBSCRIPTION}`, sent: true}); +}); + +test("a refused or unreachable Resend is retried with a bounded backoff, never dropped", async () => { + const start = 1758300000000; + const time = spyOn(Date, "now").mockReturnValue(start); + const s = setup(); + const id = `notify:pro:${SUBSCRIPTION}`; + expect((await s.stripe(event())).status).toBe(200); + const failures: [() => void, number][] = [ + [() => {s.resend.status = 500;}, 30_000], // refused + [() => {s.resend.status = 200; s.resend.id = null;}, 60_000], // accepted, but naming no email + [() => {s.resend.id = "email_test_01"; s.resend.unreachable = true;}, 120_000], // no answer at all + [() => {s.resend.unreachable = false; delete s.env.RESEND_API_KEY;}, 240_000], // and a missing key + [() => {delete s.env.RESEND_API_KEY; delete s.env.REPORT_TO;}, 480_000], + ]; + let attempts = 0, now = start; + for (const [breakIt, delay] of failures) { + breakIt(); + time.mockReturnValue(now); + expect(await s.runAlarms()).toBe(1); + attempts++; + expect(outbox(s, id)).toMatchObject({attempts}); + expect(outbox(s, id)!.sent).toBeUndefined(); + expect(s.alarms.get(id)).toBe(now + delay); + now += delay; + } + // The delay is capped so a long outage keeps trying hourly rather than drifting away. + for (let i = 0; i < 10; i++) { + time.mockReturnValue(now); + await s.runAlarms(); + now = s.alarms.get(id)!; + } + expect(s.alarms.get(id)! - Date.now()).toBe(3_600_000); + // Repairing the environment delivers the message that was waiting all along. + s.env.REPORT_TO = OWNER; s.env.RESEND_API_KEY = "re_test_key"; + time.mockReturnValue(now); + expect(await s.runAlarms()).toBe(1); + expect(sent(s).at(-1)).toMatchObject({to: [OWNER]}); + expect(outbox(s, id)).toEqual({key: id, sent: true}); + expect(s.alarms.size).toBe(0); +}); + +test("a retry after an unknown result repeats the key and the body exactly", async () => { + const s = setup(); + expect((await s.stripe(event())).status).toBe(200); + s.resend.unreachable = true; + await s.runAlarms(); + s.resend.unreachable = false; + s.env.REPORT_TO = "changed-owner@example.com"; + await s.runAlarms(Date.now() + 60_000); + expect(s.resend.requests).toHaveLength(2); + expect(s.resend.requests[1].body).toEqual(s.resend.requests[0].body); + expect(s.resend.requests[1].headers.get("Idempotency-Key")).toBe(s.resend.requests[0].headers.get("Idempotency-Key")); + // And once accepted it stops: the third alarm finds the marker, not a message. + expect(await s.runAlarms(Date.now() + 120_000)).toBe(0); + expect(s.resend.requests).toHaveLength(2); +}); + +test("a storage failure asks Stripe to retry rather than claiming the event was handled", async () => { + const s = setup(); + const namespace = s.env.BILLING!; + s.env.BILLING = {idFromName: (x: string) => x, get: () => ({fetch: async () => {throw new Error("storage unavailable");}})} as unknown as DurableObjectNamespace; + const response = await s.stripe(event()); + expect(response.status).toBe(503); + expect(await response.text()).not.toContain("storage unavailable"); + s.env.BILLING = namespace; + expect((await s.stripe(event())).status).toBe(200); +}); + +test("the webhook needs no Origin, while browser mutations still do", async () => { + const s = setup(), cookie = await s.signIn(); + expect((await s.stripe(event())).status).toBe(200); + expect((await s.call("key", {method: "POST", cookie, body: "{}"})).status).toBe(403); + expect((await s.call("key", {method: "POST", cookie, origin: ORIGIN, body: "{}"})).status).toBe(200); + // A webhook posted at another origin is still not this site's webhook. + const foreign = await import("../src/billing").then(m => m.handleBilling(new Request("https://evil.example/v1/billing/stripe-webhook", {method: "POST", body: "{}"}), s.env)); + expect(foreign!.status).toBe(403); +}); + +test("an oversized body, a missing body, the wrong method and missing configuration are refused", async () => { + const s = setup(); + // Signed or not, a body past the bound is refused while it streams, before anything is parsed. + const oversized = JSON.stringify({padding: "x".repeat(256 * 1024)}); + expect((await s.stripe(null, {body: oversized})).status).toBe(413); + expect((await s.stripe(null, {body: oversized, header: null})).status).toBe(413); + expect((await s.stripe(null, {body: "not json"})).status).toBe(400); + expect((await s.stripe(null, {body: "[1,2,3]"})).status).toBe(400); + expect((await s.call("stripe-webhook", {method: "POST"})).status).toBe(400); + for (const method of ["GET", "HEAD"]) expect((await s.call("stripe-webhook", {method})).status).toBe(405); + for (const missing of ["STRIPE_WEBHOOK_SECRET", "STRIPE_PRO_PRODUCT_ID"] as const) { + const s2 = setup(); + delete s2.env[missing]; + const response = await s2.stripe(event()); + expect(response.status).toBe(503); + expect(await response.text()).not.toContain(missing); + } + expect(s.data.size).toBe(0); + // A large event under the bound is read whole and handled normally. + expect((await s.stripe(event({padding: "x".repeat(200_000)}))).status).toBe(200); + expect(await s.runAlarms()).toBe(1); +}); + +test("a completed sign-in emails once; refreshes, account reads and sign-outs add nothing", async () => { + const s = setup(); + const cookie = await s.signIn(); + const sessionId = s.workos.sessions[0].id; + expect(s.resend.requests).toHaveLength(0); + expect(outbox(s, `notify:login:${sessionId}`)).toMatchObject({key: `notify:login:${sessionId}`, attempts: 0}); + expect(await s.runAlarms()).toBe(1); + const {headers, body} = s.resend.requests[0]; + expect(headers.get("Idempotency-Key")).toBe(`notify:login:${sessionId}`); + expect(body.to).toEqual([OWNER]); + // The address exactly as the provider gave it, not the normalized one the billing ID is derived from. + expect(body.subject).toBe("classifier.dev: account signed in"); + expect(body.text).toContain(sessionId); + expect(body.text).toContain(new Date(Date.now()).toISOString().slice(0, 13)); + expect(body.text).not.toContain("classifier_pro_"); + for (const action of ["account", "key", "account", "logout"]) await s.request(action, cookie); + expect(await s.runAlarms()).toBe(0); + expect(s.resend.requests).toHaveLength(1); +}); + +test("a refreshed session is not a new sign-in", async () => { + const s = setup(); + s.workos.tokenTtl = -60; + const expired = await s.signIn(); + s.workos.tokenTtl = 300; + const refreshed = await s.request("account", expired); + expect(refreshed.status).toBe(200); + expect([...s.data.keys()].filter(k => k.startsWith("notify:")).length).toBe(1); + expect(await s.runAlarms()).toBe(1); + expect(s.resend.requests).toHaveLength(1); + expect(await s.runAlarms()).toBe(0); +}); + +test("a callback that does not finish in a signed-in account notifies nobody", async () => { + const s = setup(); + s.workos.user.email_verified = false; + const started = await s.login(); + expect((await s.callback(started.code, started.state, started.cookie)).status).toBe(403); + const replayed = await s.login(); + expect((await s.callback(replayed.code, "wrong-state", replayed.cookie)).status).toBe(400); + s.workos.exchangeStatus = 500; + const failed = await s.login(); + expect((await s.callback(failed.code, failed.state, failed.cookie)).status).toBe(503); + expect([...s.data.keys()].filter(k => k.startsWith("notify:"))).toEqual([]); + expect(await s.runAlarms()).toBe(0); +}); + +test("a Resend outage cannot delay or fail a sign-in", async () => { + const s = setup(); + s.resend.unreachable = true; + const started = await s.login(); + const response = await s.callback(started.code, started.state, started.cookie); + expect(response.status).toBe(303); + expect(response.headers.get("Location")).toBe(`${ORIGIN}/pro`); + // No email network was touched while the browser waited. + expect(s.resend.requests).toHaveLength(0); + expect((await s.request("account", (response.headers.getSetCookie().find(c => c.startsWith("classifier_auth="))!).split(";")[0])).status).toBe(200); + await s.runAlarms(); + expect(s.resend.requests).toHaveLength(1); +}); diff --git a/test/workos-fixture.ts b/test/workos-fixture.ts index cdf4254..fd16bb3 100644 --- a/test/workos-fixture.ts +++ b/test/workos-fixture.ts @@ -9,6 +9,11 @@ import { BillingAccount, handleBilling, type BillingEnv } from "../src/billing"; export const CLIENT_ID = "client_test_01"; export const ISSUER = `https://api.workos.com/user_management/${CLIENT_ID}`; export const ORIGIN = "https://classifier.dev"; +/** Test-only Stripe configuration. The real endpoint secret and product ID live in Wrangler secrets. */ +export const WEBHOOK_SECRET = "whsec_test_0123456789abcdef"; +export const PRO_PRODUCT = "prod_test_pro"; +/** The private operator address the notifications go to; never a real one, and never in the source. */ +export const OWNER = "owner@example.com"; const encoder = new TextEncoder(); const hex = (bytes: ArrayBuffer) => [...new Uint8Array(bytes)].map(n => n.toString(16).padStart(2, "0")).join(""); const base64url = (bytes: ArrayBuffer | Uint8Array) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); @@ -31,6 +36,16 @@ export async function legacyId(email: string, signingKey = "test-billing-secret" const key = await crypto.subtle.importKey("raw", encoder.encode(signingKey), {name: "HMAC", hash: "SHA-256"}, false, ["sign"]); return hex(await crypto.subtle.sign("HMAC", key, encoder.encode(email))); } +/** Stripe's scheme, signed for real: HMAC-SHA256 over `.` with the endpoint secret. */ +export async function stripeSignature(body: string, options: {secret?: string; timestamp?: number} = {}) { + const timestamp = options.timestamp ?? Math.floor(Date.now() / 1000); + const key = await crypto.subtle.importKey("raw", encoder.encode(options.secret ?? WEBHOOK_SECRET), {name: "HMAC", hash: "SHA-256"}, false, ["sign"]); + return {timestamp, v1: hex(await crypto.subtle.sign("HMAC", key, encoder.encode(`${timestamp}.${body}`)))}; +} +export async function stripeHeader(body: string, options: {secret?: string; timestamp?: number} = {}) { + const {timestamp, v1} = await stripeSignature(body, options); + return `t=${timestamp},v1=${v1}`; +} export const cookieOf = (response: Response, name: string) => response.headers.getSetCookie().find(c => c.startsWith(`${name}=`)); export const cookieValue = (response: Response, name: string) => cookieOf(response, name)?.split(";")[0]; @@ -52,22 +67,46 @@ export function setup() { challenges: new Map(), refreshTokens: new Map(), sessions: [] as WorkOSSession[], exchanges: [] as Record[], revoked: [] as string[], }; + /** Scheduled alarms, per instance. They outlive the instance, exactly as the real ones do. */ + const alarms = new Map(); + const resend = {requests: [] as {headers: Headers; body: Record}[], accepted: new Map(), status: 200, id: "email_test_01" as string | null, unreachable: false}; + const storageFailure = {sent: false}; const env: BillingEnv = { BILLING_SIGNING_KEY: "test-billing-secret", AUTUMN_SECRET_KEY: "test-autumn", WORKOS_API_KEY: apiKey, WORKOS_CLIENT_ID: CLIENT_ID, WORKOS_COOKIE_PASSWORD: "cookie-password-for-tests-0123456789", WORKOS_REDIRECT_URI: `${ORIGIN}/v1/billing/callback`, + RESEND_API_KEY: "re_test_key", REPORT_TO: OWNER, STRIPE_WEBHOOK_SECRET: WEBHOOK_SECRET, STRIPE_PRO_PRODUCT_ID: PRO_PRODUCT, LIMITER: {idFromName: (x: string) => x, get: () => ({fetch: async () => Response.json({limited: false})})} as unknown as DurableObjectNamespace, }; + const storage = (id: string) => ({ + get: async (key: string) => structuredClone(data.get(id + key)), + put: async (key: string, value: unknown) => { + if (storageFailure.sent && (value as {sent?: boolean})?.sent) throw new Error("storage unavailable after send"); + data.set(id + key, structuredClone(value)); + }, + delete: async (key: string) => data.delete(id + key), + getAlarm: async () => alarms.get(id) ?? null, + setAlarm: async (at: number) => {alarms.set(id, at);}, + deleteAlarm: async () => {alarms.delete(id);}, + }); env.BILLING = {idFromName: (x: string) => x, get: (id: string) => { if (!instances.has(id)) instances.set(id, new BillingAccount({storage: { - get: async (key: string) => structuredClone(data.get(id + key)), - put: async (key: string, value: unknown) => {data.set(id + key, structuredClone(value));}, - transaction: async (callback: (storage: unknown) => Promise) => callback({ - get: async (key: string) => structuredClone(data.get(id + key)), - put: async (key: string, value: unknown) => {data.set(id + key, structuredClone(value));}, - }), + ...storage(id), + transaction: async (callback: (storage: unknown) => Promise) => callback(storage(id)), }} as unknown as DurableObjectState, env)); return instances.get(id); }} as unknown as DurableObjectNamespace; + /** Fire every alarm that is due, as the runtime would: the alarm is cleared before the handler runs. */ + const runAlarms = async (at = Date.now()) => { + let fired = 0; + for (const [id, scheduled] of [...alarms]) { + if (scheduled > at) continue; + alarms.delete(id); + env.BILLING!.get(env.BILLING!.idFromName(id)); + await instances.get(id)!.alarm(); + fired++; + } + return fired; + }; const failure = (code: number) => Response.json(code === 400 ? {error: "invalid_grant", error_description: "PRIVATE PROVIDER DETAILS"} : {message: "PRIVATE PROVIDER DETAILS", code: "private"}, {status: code}); const issue = async (userId: string, sid: string) => { const refresh = `rt_${random()}`; @@ -129,6 +168,16 @@ export function setup() { const method = init?.method ?? (input instanceof Request ? input.method : "GET"); const text = typeof init?.body === "string" ? init.body : ""; if (url.hostname === "api.workos.com") return api(url, method, text, new Headers(init?.headers as HeadersInit)); + if (url.hostname === "api.resend.com") { + resend.requests.push({headers: new Headers(init?.headers as HeadersInit), body: JSON.parse(text)}); + if (resend.unreachable) throw new Error("resend unreachable"); + const key = new Headers(init?.headers as HeadersInit).get("Idempotency-Key")!; + if (resend.status === 200 && resend.id) { + if (resend.accepted.has(key) && resend.accepted.get(key) !== text) return Response.json({error: "idempotency conflict"}, {status: 409}); + resend.accepted.set(key, text); + } + return Response.json(resend.id === null ? {} : {id: resend.id}, {status: resend.status}); + } const body = JSON.parse(text); if (fail) return Response.json({error: "PRIVATE PROVIDER DETAILS"}, {status: 500}); if (url.pathname.endsWith("customers.get_or_create")) return Response.json({id: body.customer_id}); @@ -142,9 +191,18 @@ export function setup() { } return Response.json({url: "https://billing.stripe.com/session"}); }); - const call = (path: string, init: {method?: string; cookie?: string; origin?: string; body?: string} = {}) => handleBilling(new Request(`${ORIGIN}/v1/billing/${path}`, { - method: init.method ?? "GET", headers: {...(init.origin ? {Origin: init.origin} : {}), ...(init.cookie ? {Cookie: init.cookie} : {})}, ...(init.body !== undefined ? {body: init.body} : {}), + const call = (path: string, init: {method?: string; cookie?: string; origin?: string; body?: string; headers?: Record} = {}) => handleBilling(new Request(`${ORIGIN}/v1/billing/${path}`, { + method: init.method ?? "GET", headers: {...(init.origin ? {Origin: init.origin} : {}), ...(init.cookie ? {Cookie: init.cookie} : {}), ...init.headers}, ...(init.body !== undefined ? {body: init.body} : {}), }), env).then(response => response!); + /** + * POST a Stripe event as Stripe would. The body is signed for real unless the test supplies its own + * header, which is how a forged, missing, stale or future signature gets in front of the endpoint. + */ + const stripe = async (event: unknown, options: {secret?: string; timestamp?: number; header?: string | null; body?: string; method?: string} = {}) => { + const body = options.body ?? JSON.stringify(event); + const header = options.header === undefined ? await stripeHeader(body, options) : options.header; + return call("stripe-webhook", {method: options.method ?? "POST", body, headers: header === null ? {} : {"Stripe-Signature": header}}); + }; /** What the /pro page does: GET for account, an empty JSON POST for everything else. */ const request = (action: string, cookie?: string, origin = ORIGIN) => call(action, action === "account" ? {cookie} : {method: "POST", cookie, origin, body: "{}"}); /** GET /login, then play WorkOS: remember the PKCE challenge behind a fresh authorization code. */ @@ -163,5 +221,10 @@ export function setup() { expect(response.status).toBe(303); return cookieValue(response, "classifier_auth")!; }; - return {env, apiKey, workos, data, status, call, request, login, callback, signIn, get calls() {return calls;}, set fail(value: boolean) {fail = value;}}; + /** Drop the live instances: the next call rebuilds them over the same storage, as a restart does. */ + const restart = () => instances.clear(); + return { + env, apiKey, workos, data, storageFailure, status, alarms, resend, call, request, login, callback, signIn, stripe, runAlarms, restart, + get calls() {return calls;}, set fail(value: boolean) {fail = value;}, + }; } From 9342bc4fc62fb8f32fcaf3e492cd732de63f3acf Mon Sep 17 00:00:00 2001 From: Michael Ryaboy Date: Sat, 19 Sep 2026 07:36:04 -0700 Subject: [PATCH 2/2] Record final live verification limits --- BILLING.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/BILLING.md b/BILLING.md index f299c5c..8819ff3 100644 --- a/BILLING.md +++ b/BILLING.md @@ -183,3 +183,9 @@ returned 200, with policies fast 30000/minute and 200000/day and smart The live Stripe portal showed the existing Pro subscription at $20/month. Sign-out returned the browser to the signed-out page; the WorkOS CLI then confirmed that the user had no active sessions. + +The notification follow-up also confirmed that account refreshes send no extra +login email. Its saved local API credential returned 401 on a fresh API check; +the current account still showed Pro active, and key replacement was cancelled +to preserve the existing credential. A current valid key is needed to repeat +the Pro API check described above.