diff --git a/.env.example b/.env.example index 85ce4c7..c0832fa 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,14 @@ # Copy to .env for optional local OAuth. Blank credentials keep providers disabled. -# Register the exact callbacks documented in OAUTH.md. Never commit real secrets. -PORT=3000 -PUBLIC_ORIGIN=http://127.0.0.1:3000 +# Register the exact callbacks listed in examples/better-auth/README.md, on the +# origin `pnpm dev` prints for this PORT. Never commit real secrets. +PORT=8082 GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +APPLE_CLIENT_ID= +APPLE_CLIENT_SECRET= +FACEBOOK_CLIENT_ID= +FACEBOOK_CLIENT_SECRET= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= +MICROSOFT_CLIENT_ID= +MICROSOFT_CLIENT_SECRET= diff --git a/.github/audit/security.md b/.github/audit/security.md index 338a1ad..ff3bcce 100644 --- a/.github/audit/security.md +++ b/.github/audit/security.md @@ -1,16 +1,26 @@ # Domain: security -**Scope — this file, and no other:** +**Scope — these, and no others:** -- `SECURITY.md` +- `SECURITY.md`: every `FAIL IF`, and the code and tests each bullet names +- `packages/stripe/src/`: the `Billing` class in `index.ts` and `db.ts`, and + the StripeDev simulator in `testing.ts` that the billing suites run against +- `packages/stripe/migrations/`: the `pgstencil_billing` schema and its + `owner_access` view +- the billing suites: `tests/integration/billing.test.ts`, + `tests/integration/billing-security.test.ts`, + `tests/integration/billing-workers.test.ts` and + `tests/integration/billing-http.test.ts` **Output file:** `audit-report.md` This is a code-and-policy audit of what the packed packages guarantee to a consuming application. Everything `SECURITY.md` assigns to the application — TLS, the origin gate, page CSP, secret storage, database provisioning, provider -registration — is out of scope; so are `@pgstencil/stripe` and the original -code/link `Auth` exports, which carry no rules yet. +registration, and for billing the owner's authentication, CSRF on its billing +routes, raw-body webhook delivery, Stripe key storage and the Stripe account's +configuration — is out of scope; so is the rest of `@pgstencil/stripe` that +`SECURITY.md` lists under "What is not defended". ## Mechanical pass @@ -21,12 +31,18 @@ each bullet names. ```sh pnpm db:verify # migrations apply and match the committed schema pnpm test:unit # diagnostics allowlist and the other unit rules -pnpm test:better-auth # the session, CSRF, email-code, OAuth and linking suites +pnpm exec vitest run tests/integration/better-auth.test.ts \ + tests/integration/better-auth-workers.test.ts \ + tests/integration/better-auth-oauth.test.ts # the session, CSRF, email-code, OAuth and linking suites +pnpm exec vitest run tests/integration/billing.test.ts \ + tests/integration/billing-security.test.ts \ + tests/integration/billing-workers.test.ts \ + tests/integration/billing-http.test.ts # the webhook, checkout and entitlement suites pnpm packages:verify # packs, installs into a clean project, checks provenance ``` -`test:better-auth` and `packages:verify` need a working Docker daemon (see -`compose.yaml`). If the daemon is unreachable, every check those suites pin is +The auth suites, the billing suites and `packages:verify` need a working +Docker daemon (see `compose.yaml`). If the daemon is unreachable, every check those suites pin is `UNVERIFIABLE` — say so and say why. **Never record a check as PASS on the strength of a test you did not run**, and never quietly skip one. @@ -51,11 +67,19 @@ The remaining bullets are read rather than run: - The `Packed provenance` bullets are read over `scripts/build-packages.ts`, `scripts/pack-packages.ts` and `scripts/verify-packages.ts`, with `pnpm packages:verify` as the evidence. +- The `Stripe webhooks`, `Checkout` and `Entitlement` bullets are pinned by the + billing suites, and each also names the `Billing` members to read in + `packages/stripe/src/index.ts`. Read those members in full: several clauses + (the tolerance, the injected clock, the order of the ledger, resync and + `apply`, what `synchronize` checks) are statements about the code that a + passing test only samples. Compare `SUBSCRIBED` with the status list in + `007_billing_owner_access.sql` character for character. ## Qualitative pass Be adversarial, and go past the `FAIL IF` list. Read -`packages/auth/src/better-auth*.ts`, `packages/pgstencil/src/*.ts`, and the +`packages/auth/src/better-auth*.ts`, `packages/pgstencil/src/*.ts`, +`packages/stripe/src/index.ts`, `packages/stripe/migrations/*.sql`, and the suites under `tests/` — including what they do _not_ cover. Ask at least: - **Can a caller influence where an OAuth callback lands, or reuse state across @@ -93,6 +117,25 @@ suites under `tests/` — including what they do _not_ cover. Ask at least: `pgstencil.json` migrations path, and the bundled `compose.yaml` fallback all cross into a child process. Look for string-built commands, unquoted interpolation, and a path that escapes the project root. +- **Can anything from a webhook body reach a billing table, or grant access?** + Follow every read of `event` in `webhook` and `completedCheckout` in + `packages/stripe/src/index.ts`. The body may choose whose state to resync and + which row `context.subscription` reads back; anything more — a status, a + period end, a Price, an owner taken from metadata — is the finding. +- **Can one owner end up with two chargeable subscriptions, a second trial, or + another owner's Checkout?** Trace `checkout` through `account`, `customer`, + `reconcile`, the locked transaction and the Stripe call made after it + commits, and `cancelCheckout` and `replace` racing a completion in Stripe. + Check that every Stripe write is either keyed by a value committed first or + safe to repeat. +- **Does a billing error path leak?** `verifyWebhook` puts the Stripe SDK's + message into the `BillingError` an adapter may return to the sender; follow + every error `webhook` raises to `diagnostic`, and look for a path that + serializes a body, a header, a secret or a message. +- **Does a billing test pin what its bullet claims, or only what StripeDev + allows?** `packages/stripe/src/testing.ts` is a simplified Stripe; a test + whose assertion holds only because the simulator cannot produce the bad case + leaves its clause unenforced. - **What do the integration tests not cover?** Name the gaps. A guarantee whose only evidence is that nobody has attacked it is worth saying out loud. - **Have `SECURITY.md` and the code drifted?** Say which side is wrong. A bullet diff --git a/.github/renovate.json b/.github/renovate.json index 83953cd..8ecb752 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -91,13 +91,13 @@ "minimumReleaseAge": "14 days" }, { - "description": "@types/node must not expose APIs newer than the Node major in .nvmrc and CI", + "description": "@types/node must not expose APIs newer than the Node major pinned in devEngines.runtime, which CI reads", "matchManagers": ["npm"], "matchPackageNames": ["@types/node"], "allowedVersions": ">=24 <25" }, { - "description": "Node is governed by engines, .nvmrc and the CI setup-node version, and is bumped by hand", + "description": "Node is pinned in the root package.json devEngines.runtime, which pnpm and CI both read, and is bumped by hand", "matchPackageNames": ["node"], "enabled": false } diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4ed6c92..011445f 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -13,13 +13,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 + - uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 26 + # The exact pin in `devEngines.runtime`, which pnpm also enforces locally. + node-version-file: package.json cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm format:check @@ -27,7 +28,7 @@ jobs: - run: pnpm typecheck - run: pnpm test:scripts - run: pnpm test - - run: pnpm --filter @pgstencil/example-workers build + - run: pnpm --filter @pgstencil/example-better-auth build - run: pnpm packages:verify - name: Remove this job's containers and volumes if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c0cc54..3956434 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -120,7 +120,7 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 26 + node-version-file: package.json cache: pnpm registry-url: https://registry.npmjs.org diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index bba290c..ba94051 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -35,9 +35,11 @@ jobs: # on any other branch, or a dispatch from one, never receives the token. environment: name: security-audit - # Generous: the audit runs the integration suite against real Postgres - # containers as evidence, then reads the auth package adversarially. - timeout-minutes: 40 + # Generous: the audit runs the integration suites against real Postgres + # containers as evidence, then reads the auth and billing packages + # adversarially. Runs took 13 to 23 minutes before billing joined the scope. + # Keep this under the 55 minutes `release.yml` waits for the verdict. + timeout-minutes: 50 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -48,7 +50,7 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 26 + node-version-file: package.json cache: pnpm - run: pnpm install --frozen-lockfile diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index a45fd52..0000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24 diff --git a/.prettierignore b/.prettierignore index d4a0e1a..e770f0f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,6 @@ pnpm-lock.yaml **/dist/** **/snapshots/** **/db.generated.ts -examples/login/schema.sql +examples/better-auth/schema.sql .vscode/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..33910c9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +This codebase is yours: what it's for and how it's built are your call, and you'll keep building on it. + +# Working in pgstencil + +pgstencil is three npm packages (`packages/pgstencil`, `packages/auth`, `packages/stripe`), one example application (`examples/better-auth`) and the tests that pin them. README.md explains the recipe, PACKAGES.md the public API and release process, BILLING.md billing, WORKERS.md the Workers adapter, SECURITY.md every guarantee and the test that pins it, and CHANGELOG.md what changed. + +## Commands + +Docker must be running; nothing else needs preparing. + +- `pnpm check`: formatting, typecheck, script tests and the whole suite. Run it before calling work done. +- `pnpm test `: one file. The whole suite takes about 20 seconds. +- `pnpm snapshot:update `: the only way snapshots are rewritten. Read the resulting diff. +- `pnpm db:verify`: the committed schema dump and generated types still match the migrations. Rerun `pnpm db:schema` and `pnpm db:types` after touching SQL. +- `pnpm packages:verify`: packs the tarballs and exercises them from an unrelated project. It refuses a modified tree, so commit first. +- `pnpm dev`: the example with a local inbox and Stripe simulator. +- `pnpm db:migration:create --in `: a new numbered SQL file. Name the package's directory with `--in`; the default is the last source in `pgstencil.json`. + +## Rules that are easy to break + +- Applied SQL is immutable. Never edit, rename or delete a migration file that has shipped; add a new numbered file. Deployed databases validate every applied file by checksum. +- A change to anything SECURITY.md describes needs its `FAIL IF` rule and pinning test updated in the same change. Tests are cited there by name, so renaming a cited test breaks the audit. +- Time and randomness are injected (`Time`, `RandomSource`), never read from globals, in code that takes them. Better Auth cannot take them, so its tests run a bundle built with `@pgstencil/auth/better-auth-testing`; that module must never reach a production bundle. +- Tooling dependencies (`testcontainers`, `node-pg-migrate`, `cheerio`, `turndown`, `kysely-codegen`) are optional peers loaded dynamically. No runtime entry point may import them statically; `scripts/runtime-deps.test.mjs` enforces it. +- Better Auth is pinned to an exact version because the integration reads its internals. Treat every bump as a change to review, and rerun the auth suites. +- The packages are libraries for other applications. Describe consumers generically in code and docs, and record every breaking change, with how to adapt, under Unreleased in CHANGELOG.md. +- Node is pinned in the root `package.json`'s `devEngines.runtime`; pnpm and CI both read it. Change it there and nowhere else. +- A release is a pull request that raises `version` in all three packages; merging one stages a publish. Do not bump versions for any other reason. diff --git a/BILLING.md b/BILLING.md index 5093769..d356c30 100644 --- a/BILLING.md +++ b/BILLING.md @@ -2,7 +2,7 @@ A trial, when configured, requires a card. Signing in or opening Checkout grants no access. Checkout collects payment details, creates a Stripe subscription with the configured trial, and Stripe charges the selected plan's price when the trial ends unless the customer cancels. `trialDays: 0` charges at Checkout with no trial. The example sells monthly and yearly plans with a 14-day trial. The yearly discount is the amount of the yearly Price; there is no coupon system. -`pnpm dev` enables local StripeDev automatically. Sign in using `/dev/emails`, open Billing from your account, choose a plan, and confirm the simulated card collection. No actual card number or Stripe key is used. The simulator retains its state in `.pgstencil/stripe-dev.json` beside the persistent development database. Use one interactive development process per project and a stable `PORT`/`PUBLIC_ORIGIN` when retrying unfinished operations across restarts. Tests use independent databases, ports, clocks and simulator state. +`pnpm dev` enables local StripeDev automatically. Sign in with a code from `/dev/emails`, open `/billing`, choose a plan, and confirm the simulated card collection. No actual card number or Stripe key is used. The simulator retains its state in `.pgstencil/stripe-dev.json` beside the persistent development database. Use one interactive development process per project and a stable `PORT` when retrying unfinished operations across restarts. Tests use independent databases, ports, clocks and simulator state. ## Integration @@ -24,7 +24,7 @@ Construct `Billing` with a Kysely connection, a Stripe SDK instance, your `Time` } ``` -The HTTP adapter supplies the authorized billing-owner ID. An owner can be a user or an organization. Never accept a customer ID, price ID, return URL, quantity or owner ID directly from a browser without authorization. The example demonstrates session authentication, CSRF-protected forms, fixed server-side return paths, and raw signed webhook handling. `startProduction` accepts its `billing` configuration; create the SDK with your secret API key there, and inject your production email sender separately. +Your routes supply the authorized billing-owner ID; `Billing` never authenticates anyone. An owner can be a user or an organization. Never accept a customer ID, price ID, return URL, quantity or owner ID directly from a browser without authorization. [The example's routes](examples/better-auth/src/billing.ts) demonstrate it on the Better Auth integration: a live session whose user ID is the owner, POSTs that require the exact application `Origin` and a JSON body, fixed server-side return paths, and raw signed webhook handling. [SECURITY.md](SECURITY.md#stripe-webhooks) lists what `Billing` guarantees and what stays the application's. `checkout(owner, email, plan)` takes a configured plan name and returns an operation ID, hosted URL, and the `priceId` it charges; `email` may be null for an owner without one, and Stripe Checkout then collects it onto the customer (the first email recorded for an owner is the customer's); any other name throws `BillingError` with status 400, so an adapter may pass the submitted plan name through. `confirmCheckout(owner, operation)` checks ownership and retrieves authoritative Stripe state; a success URL alone never grants access. `portal(owner)` opens Stripe's hosted portal. `cancelCheckout(owner)` expires an unfinished Checkout before a different plan is selected; `checkout(..., { replace: true })` does that itself, instead of refusing another plan while one is open. `reconcile(owner)` repairs missed webhook state. Use it periodically for existing billing accounts as well as when displaying billing. `status(owner)` reads the synchronized entitlement without making network requests; its `plan` is the configured name whose Prices include the subscription's Price. `customerOf(owner)` returns the owner's Stripe customer ID, null before checkout created one, and `ownerOfCustomer(customer)` the owner of a customer ID or object, null for one pgstencil did not create; neither makes network requests, so an application never reads `pgstencil_billing.accounts` itself. @@ -48,7 +48,7 @@ A filter on `owner_id` reaches the subscriptions index through the view. The vie `reconcileStale({ limit })` repairs missed renewals from a scheduled job: it reconciles up to `limit` (1–1000) owners, in a random order, that hold an `active` or `trialing` subscription yet no access by `owner_access` at the injected clock — a renewal or trial conversion whose webhook never arrived, or more than one current subscription. Every chosen owner is tried; it returns `{ reconciled, failed }`, the owner IDs reconciled and each failure as `{ ownerId, error }`. An owner Stripe has not moved on yet stays stale and is chosen again on a later run. It never chooses an owner whose row is `paused`, `past_due` or `unpaid`, so a missed resumption or recovered payment waits for that owner's next event or `reconcile(owner)`; nor one whose row still grants access, so a missed cancellation keeps access until the period end, when the owner becomes stale. -`reconcileAll({ dryRun, pageSize })` reconciles every owner holding a Stripe customer, one at a time in owner ID order, reading `pageSize` (1–1000, default 100) owners per query, for a one-off pass such as after [moving the API version](#moving-to-a-new-stripe-api-version). Each owner costs one `subscriptions.list` request, sequentially, and one short transaction holding that owner's lock. It returns `{ checked, changed, failed }`: how many owners it tried, each owner whose subscription rows changed as `{ ownerId, before, after }` (the rows ordered by ID, compared without `updated_at`), and each failure as `{ ownerId, error }`, without stopping. Every changed owner is an event the webhook never applied. `dryRun: true` reconciles inside a transaction it rolls back, so it reports what would change and writes nothing. +`reconcileAll({ dryRun, pageSize })` reconciles every owner holding a Stripe customer, one at a time in owner ID order, reading `pageSize` (1–1000, default 100) owners per query, for a one-off pass such as after [moving the API version](#moving-to-a-new-stripe-api-version). Each owner costs one `subscriptions.list` request per 100 of its subscriptions, sequentially, and one short transaction holding that owner's lock. It returns `{ checked, changed, failed }`: how many owners it tried, each owner whose subscription rows changed as `{ ownerId, before, after }` (the rows ordered by ID, compared without `updated_at`), and each failure as `{ ownerId, error }`, without stopping. Every changed owner is an event the webhook never applied. `dryRun: true` reconciles inside a transaction it rolls back, so it reports what would change and writes nothing. ## Plans and prices @@ -101,13 +101,15 @@ pgstencil reads subscriptions, not invoices, charges or refunds. A refund alone Create the product and one recurring Price per configured Price ID in the appropriate Stripe account. Configure the customer portal for payment-method updates, invoices and cancellation at period end. If you enable plan switching in the portal, explicitly choose and test its proration/effective-date behavior in your Stripe sandbox; the package does not silently choose that policy for you. Restrict the portal to the application's allowlisted prices. -Configure a webhook endpoint with the exact `STRIPE_API_VERSION` exported by the installed SDK: `2026-10-28.endive` for `stripe` 23.1. In the Dashboard choose that API version when adding the endpoint, or create it with `stripe.webhookEndpoints.create({ url, enabled_events, api_version: STRIPE_API_VERSION })`. Stripe cannot change an existing endpoint's version, so another version means another endpoint, with its own signing secret; see [Moving to a new Stripe API version](#moving-to-a-new-stripe-api-version). Send `customer.subscription.*`, `checkout.session.*`, and `invoice.*` events to your adapter, and `charge.dispute.*` when the application acts on disputes. The example endpoint is `POST /webhooks/stripe`. Preserve the raw body, forward `Stripe-Signature`, enforce a body limit, and return 2xx only after `billing.webhook()` completes. The SDK checks signatures and a 300-second tolerance between the delivery's signed timestamp and the injected clock. Stripe signs each delivery attempt when it sends it, so a retry hours later verifies, and the event ledger makes it a no-op once processed; a captured delivery replayed later does not verify; the service also rejects unexpected API versions, test/live modes and Connect account events. Stripe Connect is outside this package's scope. +Configure a webhook endpoint with the exact `STRIPE_API_VERSION` exported by the installed SDK: `2026-10-28.endive` for `stripe` 23.1. In the Dashboard choose that API version when adding the endpoint, or create it with `stripe.webhookEndpoints.create({ url, enabled_events, api_version: STRIPE_API_VERSION })`. Stripe cannot change an existing endpoint's version, so another version means another endpoint, with its own signing secret; see [Moving to a new Stripe API version](#moving-to-a-new-stripe-api-version). Send `customer.subscription.*`, `checkout.session.*`, and `invoice.*` events to your adapter, and `charge.dispute.*` when the application acts on disputes. The example endpoint is `POST /api/stripe/webhook`. Preserve the raw body, forward `Stripe-Signature`, enforce a body limit, and return 2xx only after `billing.webhook()` completes. The SDK checks signatures and a 300-second tolerance between the delivery's signed timestamp and the injected clock. Stripe signs each delivery attempt when it sends it, so a retry hours later verifies, and the event ledger makes it a no-op once processed; a captured delivery replayed later does not verify; the service also rejects unexpected API versions, test/live modes and Connect account events. Stripe Connect is outside this package's scope. An application that reads an event before processing it calls `verifyWebhook(body, signature)` once and passes the returned event to `webhook(event, apply)`; `webhook` accepts only an event that `verifyWebhook` on the same `Billing` returned, never a parsed body. -Events are processed under database locks and a durable event ledger. Duplicate and reordered notifications retrieve the current subscription list after acquiring the owner lock; event timestamps are not used for ordering. An upstream error rolls back synchronization and leaves a retryable failed ledger entry. Alert on failed events and reconciliation errors. Reconciliation currently rejects customers with more than 100 subscriptions rather than silently processing an incomplete list. +Events are processed under database locks and a durable event ledger. Duplicate and reordered notifications retrieve the current subscription list after acquiring the owner lock; event timestamps are not used for ordering. An upstream error rolls back synchronization and leaves a retryable failed ledger entry. Alert on failed events and reconciliation errors. Reconciliation reads every page of a customer's subscriptions, ended ones included, so the list it applies is always complete. -Customer and Checkout creation save their idempotency keys before calling Stripe. Repeated requests use the same parameters. Ambiguous creations older than 23 hours fail closed: inspect the stored key/customer/operation metadata in Stripe, restore the confirmed customer/session mapping, then retry. Do not erase the key and create another chargeable operation blindly. Retain the event and operation records for audit; this initial implementation has no automated retention job. +Customer and Checkout creation save their idempotency keys before calling Stripe. Repeated requests use the same parameters. Ambiguous creations older than 23 hours fail closed: inspect the stored key/customer/operation metadata in Stripe, restore the confirmed customer/session mapping, then retry. Do not erase the key and create another chargeable operation blindly. Retain the operation records for audit. + +`pruneEvents({ olderThanDays })` trims the event ledger from a scheduled job: it deletes events processed more than `olderThanDays` days (an integer from 30 to 3650) before the injected clock and returns how many it deleted. An event that was never processed, a failed one included, is always kept. The ledger is what makes a redelivery a no-op, so a pruned event that reaches `webhook` again is processed again, its resync and `apply` included. Stripe retries a delivery for up to three days, which the 30-day floor covers with a wide margin; an event resent by hand later, from the Dashboard or the CLI, would apply again, so keep `apply` idempotent if you resend old events. `webhook(body, signature, apply)` optionally joins application database fulfillment to the same event transaction. The callback receives the verified event, the Kysely transaction, and a context read after the event's resync: `ownerId`, the billing owner the event names, else null — through its `customer`, or for a `charge.dispute.*` event, which names only its charge, through that charge's customer, retrieved from Stripe inside the transaction — and for a `customer.subscription.*` event `subscription`, that subscription's synchronized row with its `end_reason`, else null. Use explicit application schemas. A failed callback rolls back both billing and application writes, and remains retryable. Keep external side effects out of this callback or persist an outbox record instead. StripeDev also supports one-time payment completion for consumer purchase tests. @@ -127,6 +129,6 @@ A Checkout creation whose response was lost just before the deploy is retried wi ## Testing -`pnpm test tests/integration/billing.test.ts tests/integration/billing-http.test.ts` uses real Postgres, the real Stripe SDK, and a local HTTP simulator. It covers required-card trial enrollment, exact expiry, paid renewal, failed payment/recovery, cancellation, no repeated trial, monthly/yearly price selection, a founding ladder with no trial, retired Prices, plan-name validation, purchase counts, concurrent creation, lost responses, expired Checkout, owner authorization, CSRF, signatures, duplicates, ordering and webhook retries. `tests/integration/billing-workers.test.ts` runs checkout, Web Crypto webhook verification, status and purchase counts inside workerd. Snapshots cover database state, outgoing Stripe requests, response headers, HTML and Markdown; existing auth tests cover deterministic cookies and email. +`pnpm test tests/integration/billing.test.ts tests/integration/billing-security.test.ts tests/integration/billing-http.test.ts` uses real Postgres, the real Stripe SDK, and a local HTTP simulator. It covers required-card trial enrollment, exact expiry, paid renewal, failed payment/recovery, cancellation, no repeated trial, monthly/yearly price selection, a founding ladder with no trial, retired Prices, plan-name validation, purchase counts, concurrent creation, lost responses, expired Checkout, owner authorization, exact-origin checks, signatures, duplicates, ordering, webhook retries, paginated reconciliation and ledger pruning. `tests/integration/billing-workers.test.ts` runs checkout, Web Crypto webhook verification, status and purchase counts inside workerd. Snapshots cover database state, the status JSON and outgoing Stripe requests; the auth tests cover deterministic cookies and email. -`createStripeDev(time, random, statePath?, { recurring })` provides `prices` (`price_dev_monthly` and `price_dev_yearly`); `recurring` adds Price IDs with a `'month'` or `'year'` interval, and subscription Checkout rejects any other Price. It also provides `completeCheckout`, `transition` (`renew`, `payment-failed`, `cancel`, `cancel-at-period-end`, `portal-cancel`, `pause`, `resume`), `dispute(customer, status?)` (a charge and its `charge.dispute.created` event), `signed`, `deliver`, and `failNext`. Advancing DevTime alone does not run Stripe jobs: explicitly trigger the relevant transition, then deliver its signed event. The `cancel` transition, like the SDK's `subscriptions.cancel` that `end` calls, ends a subscription immediately and emits `customer.subscription.deleted`; `pause` sets `status: 'paused'` with `status_details.paused` (reason `pause_requested`) and `resume` makes it `active` with a new period starting now, each emitting `customer.subscription.updated` and then `.paused` or `.resumed`. StripeDev's objects carry the fields pgstencil reads and the fields the endive API versions added to them, not Stripe's full objects: no invoices, payments, refunds, `previous_attributes`, or pause endpoint, and Checkout creation refuses `payment_method_types` as the endive versions do. The hosted Checkout and portal UI in production, real card validation, Stripe's own trial jobs, taxes and portal configuration still require an opt-in Stripe sandbox smoke test with real test credentials. No such credentials are required by the ordinary suite, and no live billing has been changed. +`createStripeDev(time, random, statePath?, { recurring })` provides `prices` (`price_dev_monthly` and `price_dev_yearly`); `recurring` adds Price IDs with a `'month'` or `'year'` interval, and subscription Checkout rejects any other Price. It also provides `completeCheckout`, `transition` (`renew`, `payment-failed`, `cancel`, `cancel-at-period-end`, `portal-cancel`, `pause`, `resume`), `dispute(customer, status?)` (a charge and its `charge.dispute.created` event), `signed`, `deliver`, and `failNext`. Advancing DevTime alone does not run Stripe jobs: explicitly trigger the relevant transition, then deliver its signed event. The `cancel` transition, like the SDK's `subscriptions.cancel` that `end` calls, ends a subscription immediately and emits `customer.subscription.deleted`; `pause` sets `status: 'paused'` with `status_details.paused` (reason `pause_requested`) and `resume` makes it `active` with a new period starting now, each emitting `customer.subscription.updated` and then `.paused` or `.resumed`. Listing subscriptions honours `limit` and `starting_after` and reports `has_more` as Stripe does. StripeDev's objects carry the fields pgstencil reads and the fields the endive API versions added to them, not Stripe's full objects: no invoices, payments, refunds, `previous_attributes`, or pause endpoint, and Checkout creation refuses `payment_method_types` as the endive versions do. The hosted Checkout and portal UI in production, real card validation, Stripe's own trial jobs, taxes and portal configuration still require an opt-in Stripe sandbox smoke test with real test credentials. No such credentials are required by the ordinary suite, and no live billing has been changed. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4546b3c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +The three packages (`pgstencil`, `@pgstencil/auth`, `@pgstencil/stripe`) share one version. They are 0.x releases: a minor version may break compatibility, and each breaking change below says how an application adapts. Releases before 0.5.0 are recorded only in the Git history. + +## Unreleased + +These changes are breaking, so they release as 0.5.0. + +### Removed: the original code/link authentication + +`@pgstencil/auth` is now only the Better Auth integration. The original implementation, which the earlier releases kept beside it, is gone: + +- The subpath exports `@pgstencil/auth/auth`, `/oauth`, `/oauth-providers`, `/security`, `/email`, `/http`, `/fetch`, `/hono`, `/workers`, `/migrations` and `/db.generated`, with everything they exported (`Auth`, `OAuth`, `OAuthProviders`, `createAuthHttp`, `createAuthFetch`, `createAuthHono`, `createAuthWorker`, `authMigrations`, `digest`, `keyed`, `escape`, `loginEmail` and the rest). +- The `migrations/` directory in the package (`001_auth.sql`, `002_oauth.sql`, `004_oauth_workers.sql`). +- The `openid-client` dependency. +- The `examples/login` and `examples/workers` applications. `examples/better-auth` is the one example; it now carries the billing demo and the deployable Workers entry. + +The package root changed meaning: `import … from '@pgstencil/auth'` now gives `createAuthApp`, `authOptions` and their types, the same module as `@pgstencil/auth/better-auth`. The `./better-auth*` and `./postmark` subpaths are unchanged. + +How to adapt: + +- **A database that applied the old migrations.** Applied SQL stays under checksum validation, so the three files must still be among your sources. Copy them, byte for byte and with the same names, from `@pgstencil/auth` 0.4.3 into a directory of their own in your repository, and list that directory where you listed `authMigrations`. Keep it a separate source rather than merging it into your application's directory: each source's applied history must be a prefix of that source's files, which a merged directory may not satisfy. A later migration of your own can drop the old tables. +- **`escape` from `@pgstencil/auth/email`.** Use `escapeHtml` from `pgstencil`; it behaves the same. +- **`digest` from `@pgstencil/auth/security`.** It was `createHash('sha256').update(value).digest('hex')` from `node:crypto`; inline that. +- **The `OAuthFetch` type from `@pgstencil/auth/oauth-providers`.** It was a test-fixture type. `tests/support/oauth-server.ts` now declares it locally; a copy of that fixture should take the updated file. +- **Anything else from the removed modules.** Move to the Better Auth integration ([PACKAGES.md](PACKAGES.md#better-auth-integration)); its sessions and accounts are independent of the old tables. + +### Changed + +- **`pgstencil/database`: `defaultMigrations` is always a list.** `pgstencil.json`'s `migrations` accepts one directory or an ordered list, and `defaultMigrations` is the resolved `readonly string[]` either way. Spread it where you built a list around it: `[...defaultMigrations, billingMigrations]`. `createTestContext()`, `allocateDatabase()`, `prepareTemplate()` and `developmentDatabase()` without a source argument use the whole configured list. +- **`pgstencil.json` is validated strictly** when `pgstencil/database` is imported. Its only keys are `migrations`, `schema` and `types`; a file with any other key, `$schema` included, used to have it ignored and now fails on import. +- **`@pgstencil/auth`: the default sign-in email's sender** is `signin@` instead of `signin@example.test`. `postmarkEmail(token, from)` still sends from its own `from`, so a Postmark deployment sends as before; an inbox fixture or another transport sees the new address. Set `emailFrom` to choose it. +- **`@pgstencil/auth`: a code type other than sign-in** fails with `Email codes are not supported for `. +- **`@pgstencil/stripe`: reconciliation paginates.** A customer with more than 100 subscriptions, ended ones included, used to fail every reconciliation and webhook with `Subscription reconciliation requires pagination`. Every page is now read under the same owner lock. +- **`@pgstencil/stripe`: `BillingDB` is checked against the migrations.** It is still hand-written, because it is narrower than what a generator can infer for the `owner_access` view, but the billing schema's declarations are now also generated to `db.generated.ts` and the two are compared column by column at compile time. `pnpm db:verify` fails when a billing migration changes without regenerating, and `pnpm typecheck` fails when `BillingDB` disagrees. The type itself is unchanged. + +### Added + +- **`pgstencil` command.** The core package ships the database commands this repository's `pnpm db:*` scripts ran privately: `status`, `validate`, `migrate`, `create`, `schema`, `types`, `verify`, `reset`, `gc` and `stop`, configured by `pgstencil.json` (`migrations`, `schema`, `types`). `types` needs the new optional peer `kysely-codegen`, run through its API, so a project's own kysely-codegen configuration file is not read. An application can replace its own migration script with `pnpm exec pgstencil `; see [README.md](README.md#change-the-schema). `pgstencil/database` also exports `projectConfig`, `readConfig`, `parseConfig` and the `ProjectConfig` and `TypesTarget` types. +- **`pgstencil`: `escapeHtml(value)`** for values interpolated into an email's HTML. +- **`@pgstencil/auth`: `emailFrom` and `renderEmail`** on `createAuthApp` and `createBetterAuthWorker` (where `emailFrom` may be `(env) => string`), with the exported `defaultSignInEmail` and `SignInEmailInput`. A rendered message must be addressed to the requested recipient alone; anything else, or a renderer that throws, delivers nothing. +- **`@pgstencil/auth/postmark`:** a failed delivery's `EmailDeliveryError` carries Postmark's numeric `ErrorCode` as `providerCode` for HTTP errors too, and it reaches diagnostics. Never the message text. +- **`@pgstencil/stripe`: `Billing.pruneEvents({ olderThanDays })`** trims processed events from the webhook ledger, for a scheduled job. The retention is 30 to 3650 days; unprocessed and failed events are always kept. A pruned event that is delivered again is processed again, `apply` included. +- **StripeDev** paginates `subscriptions.list` (`limit`, `starting_after`, `has_more`) and records a GET's query parameters in `requests`. +- **[SECURITY.md](SECURITY.md) covers billing.** Sixteen `FAIL IF` rules for webhooks, checkout and entitlement, each pinned by a test, are now part of the audit that gates a release. + +### Repository + +- Node is pinned once, in the root `package.json`'s `devEngines.runtime` (24.22.2). pnpm downloads and uses that version for every script, and all three workflows read it, so checks, audits and releases run on Node 24, the declared minimum, where they used to run on 26. `.nvmrc` is gone. Every action in `check.yml` is pinned to a commit. +- `PLAN.md`, `DISTRIBUTION_AND_BILLING_PLAN.md`, `LOGIN_FLOW.md` and `OAUTH.md` are deleted: the first two described finished work, the others the removed implementation. Provider registration notes moved to [the example](examples/better-auth/README.md#provider-registration). diff --git a/DISTRIBUTION_AND_BILLING_PLAN.md b/DISTRIBUTION_AND_BILLING_PLAN.md deleted file mode 100644 index 116dad2..0000000 --- a/DISTRIBUTION_AND_BILLING_PLAN.md +++ /dev/null @@ -1,68 +0,0 @@ -# Public package and SaaS billing plan - -Status: proposal for the next implementation. The repository will be public on GitHub. Two separate pnpm applications will consume the shared code; each needs authentication and SaaS subscriptions with a free trial, monthly billing, and discounted annual billing. Trial length and prices are application configuration. The trial requires a card, collected by Stripe Checkout before access starts. Local package consumption is the initial delivery mechanism; public npm setup is deferred. - -## Distribution - -Publish ordinary public npm packages from this repository. Use an npm scope owned by the maintainer; the proposed core/auth/stripe package names are placeholders until the scope is chosen. Keep the existing workspace for development and release the three packages together with one version and a short changelog. Consumers pin exact package versions and commit their pnpm lockfiles, upgrading independently. - -Public npm packages can be installed by anyone; a user or organization scope avoids depending on availability of a global name. The initial setup is an npm account with publishing authentication, an owned scope, package metadata, and a chosen license. Establish the first public release, then configure the package's trusted publisher to identify this repository's GitHub Actions release workflow. Build and test with pnpm; the final publishing step can use a supported npm CLI with OIDC, avoiding a stored long-lived publishing token. See [public packages](https://docs.npmjs.com/about-public-packages/), [scoped publishing](https://docs.npmjs.com/creating-and-publishing-scoped-public-packages/), and [trusted publishing](https://docs.npmjs.com/trusted-publishers/). - -GitHub Release assets are a viable fallback: attach built `pnpm pack` tarballs and depend on their versioned HTTPS URLs. Multiple related packages make this less convenient because their internal dependency references also need a resolvable distribution source. GitHub Packages adds npm registry authentication. GitHub Actions artifacts require signed-in download access and expire, making them unsuitable as permanent consumer dependencies. See [GitHub Packages](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry), [release assets](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases), and [workflow artifacts](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/download-workflow-artifacts). - -No release management framework is needed initially. A small release script can check aligned versions, build, pack, and verify the artifacts. Publishing is an explicit release operation, not a side effect of every commit. - -## Package boundaries - -| Package | Shared responsibility | Consumer responsibility | -| ------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| core | Postgres/template setup, SQL migrations, test contexts, time/randomness/email interfaces, snapshots | Project root, application migrations, configuration, production email sender | -| auth | Email/OAuth/session behavior, owned SQL tables and migrations, test helpers | Rendering, route integration, application profiles and permissions | -| stripe | Stripe subscription integration, billing persistence, webhook processing, deterministic fixtures | Prices, trial and access policies, billing owner, product-specific features and UI | - -Keep production entry points free of Docker startup and test fixture initialization. Test and database-tool exports remain explicit. Auth and billing compose through an application-supplied billing-owner identifier; billing must not assume that the signed-in individual is always the paying entity. The first example can use one user as that owner, without implementing teams or seats. - -Build ESM JavaScript and TypeScript declarations using the existing TypeScript toolchain. Package the required SQL, Compose defaults, and other runtime assets with an explicit files allowlist. Exports must work without the repository's tsconfig aliases or source loaders. Project roots, state directories, migration locations, and code-generation output belong to the consumer; no installed code should walk upward into this repository's `examples/login` directory. - -Auth, billing, and application SQL are explicitly named migration sets with stable ordering and checksums. Give shared tables a deliberate namespace so they cannot collide with application tables. Include every selected migration set in the template fingerprint. Consumers apply migrations in an explicit deployment step, never during dependency installation. Prove both clean initialization and upgrades preserving existing accounts, sessions, subscriptions, and application data. - -CI packs the packages and installs them into an unrelated temporary pnpm project with no workspace links or source aliases. It checks public imports, declarations, assets, migration composition, generated query types, and a real login/billing flow. A second consumer fixture uses a different schema and UI to expose assumptions specific to the example. Local `file:` overrides support rapid iteration; packed-artifact testing remains the release gate. - -## SaaS billing scope - -Use the official Stripe Node SDK, hosted Checkout, and the Billing customer portal. One Stripe product has an allowlisted monthly recurring Price and an annual recurring Price. The annual discount is represented by the annual amount, rather than a coupon engine. Each consuming application supplies its own product/prices, branding, credentials, and trial duration. See the [Stripe SDK](https://github.com/stripe/stripe-node) and [customer portal](https://docs.stripe.com/customer-management). - -The example adds a billing page showing trial/access state, current plan, next renewal or access-end date, monthly/annual choices, and a Manage billing action. Checkout and portal creation require authentication, CSRF protection, and permission to manage the relevant billing owner. The server chooses the Stripe customer, prices, quantity, metadata, and return URLs; those are not trusted from the submitted form. - -The trial requires a card. Use Checkout's subscription trial with `payment_method_collection: 'always'` and let Stripe own the deadline and first charge. Persist trial eligibility so cancellation and repeated Checkout cannot restart it. Sign-in and an open Checkout grant no access. See [Checkout trials](https://docs.stripe.com/payments/checkout/free-trials?payment-ui=stripe-hosted) and [the implemented billing guide](BILLING.md). - -The paid path creates/reuses a Stripe Customer, opens a subscription Checkout Session, synchronizes verified billing state, and provides authenticated customer portal access for payment details, invoices, and cancellation. Start with cancellation at the end of the paid period. Monthly/annual switching and its billing effective date must be explicit and tested; do not inherit an accidental proration policy. The consumer owns access rules, including any grace period after failed payment, while billing preserves Stripe's actual status and relevant dates. See [subscription lifecycle](https://docs.stripe.com/billing/subscriptions/overview). - -## Reliability and persistence - -Persist the owner-to-customer mapping, checkout operations, subscription IDs/prices/status/dates, and webhook processing state. Allow only one intended SaaS subscription per billing owner. Concurrent checkout requests must converge on the same operation; abandoned sessions can expire before replacement. Use durable operation IDs as Stripe idempotency keys and recover after a response is lost. Do not generate a new key for every retry or blindly retry old ambiguous operations beyond Stripe's idempotency retention. See [Stripe idempotency](https://docs.stripe.com/api/idempotent_requests). - -Verify webhook signatures using the untouched request body and the official SDK. Check expected Stripe mode/account and configured API version. Acknowledge only after either durable receipt or committed processing. A failed handler must remain retryable; inserting an event ID cannot by itself mark work complete. Postgres uniqueness and transactional writes prevent duplicate database effects. Any email or other external side effect requires its own durable retry/deduplication strategy. - -Handle duplicate and out-of-order events. Refresh authoritative subscription state under a per-subscription synchronization strategy so an older event cannot restore canceled access. Event timestamps alone cannot establish order. Provide an explicit reconciliation operation to repair stale state after missed events or downtime. The browser's success redirect does not grant paid access; it can request an authenticated synchronization of the saved checkout operation. See [Stripe webhook delivery](https://docs.stripe.com/webhooks). - -## Deterministic tests - -Add a small `StripeDev` helper, following EmailDev, implementing only the endpoints and scenarios used by this package. The production adapter still uses the real Stripe SDK. Local HTTP fixtures record outgoing requests and idempotency keys, supply controlled responses, and deliver signed webhook bodies through the application's real HTTP route. Unexpected API operations fail the test. Instances have isolated state and dynamic ports. - -Use the injected application clock and randomness for local billing deadlines, operation IDs, and snapshots. Scenario methods explicitly simulate payment success/failure, renewal, cancellation, and webhook delivery; advancing DevTime does not implicitly run jobs or pretend to move Stripe's real clock. Webhook signature verification keeps its real timestamp-tolerance check in production, with a controlled receipt timestamp in tests using the SDK's supported seam. - -Required cases: trial expiry exactly at its boundary; no repeated trial; monthly/annual price selection; concurrent checkout and API retries; checkout abandonment; paid access without a browser return; forged return URLs; invalid signatures; duplicate/concurrent/reordered webhooks; rollback followed by successful retry; renewal success and failure; cancellation at period end; one user's inability to open another customer's portal; and two applications running independently in parallel. Snapshot queries, headers/cookies, HTML/Markdown, outgoing Stripe requests, and any application-generated emails. - -Do not add stripe-mock as a default dependency: its maintainers explicitly describe it as stateless and unsuitable for behavioral regression tests. See [stripe-mock's limitations](https://github.com/stripe/stripe-mock). Add a separate opt-in suite against a real Stripe sandbox with test clocks for trial/renewal transitions and a manual hosted Checkout/portal smoke test. These require test credentials; ordinary tests remain offline apart from local Docker. Stripe test clocks and application DevTime are separate clocks. See [Stripe test clocks](https://docs.stripe.com/billing/testing/test-clocks). - -## Implementation order - -1. Finish and preserve the current Claude simplify pass. -2. Extract core/auth package boundaries and consumer configuration; keep existing auth tests and snapshots passing. -3. Build, pack, and test installation in independent consumer fixtures, including shared/app migration upgrades. -4. Implement the chosen SaaS trial and monthly/annual Checkout flow with the official Stripe SDK and local fixtures. -5. Add webhook recovery, renewal/cancellation, portal, reconciliation, concurrency tests, and readable billing snapshots. -6. Prepare the public release workflow and first release artifacts. Confirm the npm namespace and license, then complete account/trusted-publisher setup and the explicitly requested publication. - -The existing user instruction to commit incrementally applies to each implementation milestone. This plan does not create Stripe products, change real billing, or publish a package. diff --git a/LOGIN_FLOW.md b/LOGIN_FLOW.md deleted file mode 100644 index 77e7ce2..0000000 --- a/LOGIN_FLOW.md +++ /dev/null @@ -1,85 +0,0 @@ -# First example: email code and link login - -Status: implemented example behavior; the integration tests cover the core success, expiry, replay, concurrency, rate-limit, CSRF, cookie and delivery-failure scenarios. This example exercises pgstencil's database, HTTP, email, snapshot, time, and randomness infrastructure. Pages are server-rendered forms and work without client JavaScript. - -The example also supports optional Google and GitHub login, sharing these application sessions. [OAUTH.md](OAUTH.md) defines provider setup, browser-bound attempts, explicit linking, and local protocol tests. This document describes the email path. - -## User flow - -1. **Enter an email.** `/login` shows an email field and “Send sign-in code”. The example supports both new and returning users: create an account only after verifying mailbox access. A submitted address gets the same response shape regardless of whether an account already exists. -2. **Check email.** Show “Check your email” with one code input, “Verify code”, “Send a new code”, and “Use another email”. The input accepts an eight-digit code, including pasted spaces, and uses `autocomplete="one-time-code"` and a numeric input hint. Keep leading zeroes. State that the code expires in 10 minutes. -3. **Receive one email with two options.** Include the code (visually grouped, e.g. `1234 5678`), a “Sign in to pgstencil” link, the expiry time, and brief copy explaining that an unrequested email can be ignored. HTML and plaintext are both supplied by application templates and captured by EmailDev in tests. -4. **Enter the code or use the link.** Entering the code submits the form. Clicking the email link opens a “Confirm sign-in” page; its button submits the confirmation. Both paths verify the same login challenge, and successful use of either invalidates both. -5. **See the account page.** Redirect to `/account`, showing the verified email, sign-in time, session expiry, and a “Sign out” button. No notes CRUD is needed for the first example; the authentication flow supplies meaningful database queries and HTML snapshots. -6. **Expire or sign out.** The session lasts a fixed 24 hours without sliding renewal. Expiry redirects protected page requests to `/login` and clears the session cookie. Signing out revokes the database session immediately and clears the cookie. - -The proposed 10-minute challenge, eight-digit code, five-attempt limit, and 24-hour session are configurable example defaults, not universal security requirements. This proves mailbox access; it is a single-factor login flow. - -## Challenge and browser binding - -Bind the challenge to a random, HttpOnly pending-login cookie in the browser that requested it. Both code verification and link confirmation require that binding. Use an independent CSRF token bound to the pending flow for form submissions, including initial login submission through an anonymous form context. Validate allowed request origins as additional protection. - -The link works in the requesting browser. If opened on a different device/browser, display instructions to enter the emailed code in the original browser, or start a new login in this browser. Do not consume the challenge on a browser mismatch. This is a deliberate initial UX choice to prevent an emailed link from silently logging a different browser into an unintended account. - -Give the code and link independent secrets under one challenge. Generate the code without modulo bias using the injected random-byte source; give the link a 32-byte random bearer token. A challenge identifier alone grants no access. Persist a keyed digest of the low-entropy code (with the key held outside the database), and a cryptographic hash of the high-entropy link token. Include challenge identity/purpose in the code digest. Use standard Node crypto primitives and constant-time digest comparison, not a custom cryptographic protocol. - -These choices apply the general emailed-secret guidance on expiry, one-time use, secure generation/storage, and guessing limits from [OWASP's token/code guidance](https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html). - -## Requests and persistence - -| Request | Behavior | -| --------------------- | -------------------------------------------------------------------------------------------------------------- | -| `GET /login` | Render the email form and establish an anonymous form/CSRF context | -| `POST /login` | Validate form, check send limits, create a pending challenge and send/capture email; redirect to `/login/code` | -| `GET /login/code` | Render code entry for the pending flow | -| `POST /login/code` | Verify pending-browser binding, CSRF token, challenge, attempt budget and code; establish session on success | -| `POST /login/resend` | Rate-limit, replace this pending flow's challenge, invalidate its previous code/link, and send a new email | -| `GET /login/link?...` | Render confirmation only; never consume a token or create an authenticated session | -| `POST /login/link` | Verify browser binding, CSRF token, and link token; establish session on success | -| `GET /account` | Validate the session using server time and render account details, or redirect to login | -| `POST /logout` | Check CSRF, revoke the current session and clear the cookie | - -Start with SQL migrations for `users`, `login_challenges`, `sessions`, and shared rate-limit state. Keep the pending-browser/CSRF context small and separate from authenticated sessions. Use Kysely for every application query. Apply the same documented email-normalization policy during lookup, uniqueness checks and rate limiting; do not invent provider-specific alias equivalence. - -Challenge consumption and session creation happen in one database transaction. Lock or conditionally update the challenge while validating consumption, expiry and attempts; concurrent code/link requests must produce exactly one successful redemption. Only then create/find the verified user, create a fresh session, and return its cookie. Failure must not partially consume a challenge while leaving a usable session. Ensure failed-attempt counters commit on rejected verification, rather than rolling back with an exception. - -Store session-token hashes with `user_id`, `created_at`, `expires_at`, and `revoked_at`; the cookie carries a fresh 32-byte opaque random token. Check validity on every authenticated request. Fresh authentication replaces the browser's prior session and pending state. Account creation races for the same email resolve through a unique database constraint. - -## Request handling and abuse limits - -Use the same login page/messages/status patterns for new and existing email addresses, and avoid account-dependent early exits or send behavior. Throttle sends by normalized email and source address, and verification by challenge, normalized email, and source address. Limits are shared in Postgres so multiple application instances cannot bypass them. Obtain the source address through explicitly configured proxy trust. [OWASP authentication guidance](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html) covers generic responses and login throttling. - -Initial send policy: a 60-second resend cooldown, at most five sends per email per 15 minutes, and an independent configurable source-address ceiling. Allow at most five incorrect submissions per challenge, with additional email/source verification budgets that survive resend. Exceeding the challenge budget invalidates that challenge, not the user's account or existing sessions. All rate-limit windows and challenge deadlines use injected server time. Serialize competing resend/verification operations so replaced secrets cannot remain valid by racing an update. - -Use a generic “That code is invalid or has expired” error with a way to request another. Email delivery failures leave no authenticated session; mark the undelivered challenge unusable and offer retry without revealing account existence. The first implementation directly awaits the injected sender; a durable mail queue is a separate requirement. - -Build email links from a configured public origin and redirect only to fixed local destinations in this example. Keep secrets out of logs, redact link query parameters in access logging, disable caching on auth responses, set `Referrer-Policy: strict-origin` (native form POSTs need their Origin header, while paths and tokens must not appear in referrers), and keep token-bearing pages free of third-party content. Successful verification redirects to a clean URL. - -GET requests never authenticate or consume challenges. The confirmation POST protects against ordinary link-preview requests consuming the token: [Supabase documents email security scanners prefetching login links](https://supabase.com/docs/guides/auth/auth-email-templates#email-prefetching). Browser binding provides an additional check even if a scanner interacts with the form. - -Production uses HTTPS with a host-only session cookie: `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`, no `Domain`, and the `__Host-` prefix. Assert exact expiry and clearing attributes. Local HTTP uses explicitly configured development cookie settings and a different unprefixed name; test production cookie serialization separately. CSRF checks remain enabled. These follow [OWASP session-cookie guidance](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html). - -## Deterministic acceptance scenarios - -Use a fresh IntegreSQL database, an application-local DevTime, a freshly seeded random source, an in-memory EmailDev, and Supertest clients. Fix test digest/configuration keys. Ordinary session tests use `agent()`; historical-time tests explicitly replay the captured cookie to verify server validation independently of client eviction. - -| Scenario | Required evidence | -| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| New user enters code | User created only after verification; one session; email, challenge/user/session queries, cookie headers, page HTML and derived Markdown snapshots | -| Returning user enters code | Existing user reused; new session token; same outward login flow | -| Click link | Confirmation GET leaves challenge/session state unchanged; confirmation POST authenticates | -| Other browser opens link | Helpful fallback instructions; no consumption or session creation | -| Invalid code | Rejected, no session, failed-attempt count increases | -| Challenge expiry | Valid immediately before 10 minutes; invalid exactly at 10 minutes, for both code and link on separate fresh fixtures | -| Replay | After code success, neither code nor link can authenticate again; same after link success | -| Concurrent redemption | Parallel code/link submissions for the same challenge yield exactly one successful authentication | -| Resend | Previous code/link invalidated; new secret works; cooldown enforced; aggregate guessing budget persists | -| Throttling | Send and verification limits hold across multiple apps deliberately sharing a test database; advancing DevTime opens the next window | -| Session expiry | At Jan 1 2020, authenticate and capture the cookie; after 23 hours it works; after one more hour replay is rejected and the cookie is cleared | -| Logout | POST revokes session; replay cannot authenticate; missing/invalid CSRF rejected | -| Request forgery | Missing/wrong pending binding, invalid CSRF and untrusted origins cannot complete login | -| Secret handling | Persisted challenge/session rows contain digests, not plaintext secrets; production cookie flags and cache/referrer headers verified | -| Delivery failure | Failed sender produces no usable undelivered challenge or authenticated session; retry behavior clear | -| Parallel isolation | Twenty independent apps can use identical seeds and dates with identical snapshots and no shared mail, random state, sessions or database writes | - -Use focused ordinary assertions for invariants and race outcomes; snapshots show readable state and responses. Keep email code/link contents visible in test artifacts because they come from deterministic test inputs. Snapshot security-relevant attributes rather than normalizing them away. Boundary tests compare against the injected time without sleeping or simulating timers. diff --git a/OAUTH.md b/OAUTH.md deleted file mode 100644 index 2023871..0000000 --- a/OAUTH.md +++ /dev/null @@ -1,93 +0,0 @@ -# OAuth login - -The login example supports email, Google and Apple OpenID Connect, and Facebook and GitHub OAuth. Each configured provider adds a native **Continue with…** form to `/login`. Successful authentication creates the same fixed 24-hour application session as email login. Providers are disabled until configured; the normal test suite needs no credentials or provider network access. - -## Local setup - -```sh -cp .env.example .env -# Fill in credentials for the providers you want to enable. -pnpm dev -``` - -`pnpm dev` loads the optional `.env` using Node's built-in environment-file support. The example file selects `PORT=3000` and `PUBLIC_ORIGIN=http://127.0.0.1:3000`. Keep these consistent and register the exact callback URL, including hostname and port. `localhost` and `127.0.0.1` are different origins. With no credentials, email login and the local inbox continue to work normally. - -| Provider | Environment variables | Local callback | -| -------- | ------------------------------------------ | --------------------------------------------- | -| Google | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | `http://127.0.0.1:3000/oauth/google/callback` | -| GitHub | `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | `http://127.0.0.1:3000/oauth/github/callback` | - -For Google, create a **Web application** OAuth client, configure the consent screen and any required test users, and add the callback under authorized redirect URIs. The application requests `openid email`; it does not request offline access or keep refresh tokens. See [Google's OpenID Connect setup](https://developers.google.com/identity/openid-connect/openid-connect). - -For GitHub, create an **OAuth App**, set its homepage to the public origin, and set its authorization callback URL to the GitHub callback above. The application requests `read:user user:email`, including permission to read a private verified email address. Use separate registrations for local and deployed environments. See [GitHub's OAuth web application flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps) and [authenticated email API](https://docs.github.com/en/rest/users/emails). - -For Apple, configure a web Services ID and register `https://your-app.example/oauth/apple/callback` as its return URL. Set `APPLE_CLIENT_ID` to that Services ID and `APPLE_CLIENT_SECRET` to an ES256 JWT signed with your Apple key (Team ID as issuer, Services ID as subject, `https://appleid.apple.com` as audience, key ID in the header). Apple client secrets expire; generate and replace them before their deadline (maximum six months). Apple's real web flow requires a registered HTTPS domain, not a localhost URL. Register your sending domain/address with Apple's private email relay when supporting Hide My Email. See [Apple client-secret setup](https://developer.apple.com/documentation/signinwithapplerestapi/creating-a-client-secret). - -For Facebook, enable Facebook Login for your Meta app, request `email`, and register `https://your-app.example/oauth/facebook/callback`. Set `FACEBOOK_CLIENT_ID` and `FACEBOOK_CLIENT_SECRET`. Review the app's configured Graph API version; our endpoints use that app version. App development mode restricts login to permitted test accounts/roles. Facebook's authenticated primary email is trusted, matching [Supabase's Facebook provider](https://github.com/supabase/auth/blob/master/internal/api/provider/facebook.go); an absent email or declined permission fails closed. We do not interpret a profile's `verified` field as email verification. No matching-email account merging occurs. - -Both credentials are required for each enabled provider. `.env` files are ignored by Git; `.env.example` contains no credentials. A configured development server also requires `PUBLIC_ORIGIN`. Configuration errors fail startup without printing secrets. - -## Accounts and linking - -A provider identity is `(provider, subject)`: the OIDC `sub` or the provider's stable user ID. Usernames and email addresses are not identity keys. A changed provider email continues to sign into the same linked account and does not silently change its local recovery email. - -A new, verified provider email creates an account. If the email already belongs to a local account, sign-in stops with instructions to use an existing method, then connect the provider from `/account`. Matching emails never automatically merge accounts. - -Connecting requires a valid session created within the last five minutes, a session-bound CSRF token, and the provider verifying the same email as the local account. The same session must remain valid through the callback. Connecting rotates the session and revokes its predecessor. A provider identity cannot move between accounts, and an account cannot replace an already connected identity from the same provider. Unlinking and account merging are outside this example. - -Email sign-in remains available for the account's stored address, so account security also depends on control of that mailbox. Provider login does not make this an MFA flow. - -## Protocol and storage - -[`openid-client`](https://github.com/panva/openid-client) owns code exchange and protocol validation. Google uses discovery and signed ID tokens, with signature, issuer, audience, expiry, nonce, state, and PKCE validation. GitHub uses fixed OAuth endpoints, PKCE S256, and authenticated `/user` and `/user/emails` requests; the public profile email is ignored. Its verified primary email can be private or on a later page. - -Apple validates signed ID tokens, issuer, audience, expiry and nonce, accepting the provider's boolean or string `email_verified` claim. It does not advertise PKCE. Its cross-site `form_post` callback is relayed with 303 to the same callback's GET so the browser sends its original SameSite=Lax binding cookie. The POST neither consumes state nor creates a session, and callback responses use `no-store` / `no-referrer`. Only state, code and error survive the relay; unsigned user data is ignored. Facebook uses state plus the same independent browser cookie, server-side code exchange and an authenticated `/me` request with HMAC `appsecret_proof`; it does not claim PKCE support. - -An Apple private relay address may differ from an existing account's address. Under the current same-email linking policy, use a matching address to connect, or sign up with Apple first and use that stored relay address for email login. Account merging is not implemented. - -Starting or connecting is a same-origin POST with CSRF protection. Each attempt has independent random state and a browser-binding HttpOnly/SameSite=Lax cookie. The database stores their hashes; purpose-separated HMAC derivation supplies the PKCE verifier and OIDC nonce from state and the application secret. Servers sharing the database, public origin, credentials, and secret can finish each other's attempts. - -Attempts expire after ten minutes, or sooner when a connecting session reaches five minutes of age. A callback atomically consumes the attempt before contacting the provider. Cancellation, exchange failure, or replay requires a new attempt. Missing or mismatched state, browser cookie, provider, or callback origin fails before exchange. Deadlines and connecting sessions are checked again after network I/O. Starting another attempt in the same browser replaces its binding cookie, so the latest attempt is the usable one. - -Application session tokens remain opaque, hashed in Postgres, and fixed at 24 hours. Access, refresh, and ID tokens are used only during the callback and never persisted or rendered. Provider error descriptions are not reflected into pages. The example limits OAuth starts to 30 per direct peer IP per 15-minute application-time window; see the proxy limitation below. - -`002_oauth.sql` adds identities and attempts without changing existing email accounts or sessions. The migration upgrade test proves existing data survives. Expired/consumed attempt retention is not automated; any production cleanup must remove old OAuth attempts before referenced sessions. - -## Tests without real secrets - -```sh -pnpm test tests/integration/oauth-providers.test.ts tests/integration/oauth.test.ts -pnpm snapshot:update tests/integration/oauth.test.ts -pnpm check -pnpm db:verify -``` - -The test provider is a local HTTP server with dummy clients, real RSA-signed JWTs, discovery/JWKS, one-use authorization codes, PKCE verification, and GitHub profile/email endpoints. An injected transport maps only the known provider URLs to this server and rejects unknown network destinations. No test contacts a real OAuth provider. The fixture also serves Apple discovery/JWKS and Facebook profile endpoints. [Workers tests](tests/integration/workers.test.ts) exercise all three target providers in workerd with real Postgres. - -Coverage includes invalid signatures/claims/nonces, missing or unverified email, PKCE mismatch, paginated private email, cancellation, forged callbacks, exact expiry boundaries, expiry during exchange, replay and concurrent redemption, provider outages, rate limits, session rotation/logout, linking conflicts and freshness, disabled configuration, HTTPS cookie attributes, and callbacks reaching a second application instance. Real Postgres backs every application scenario. - -[OAuth snapshots](tests/integration/snapshots/oauth) cover query results, cookies and redirect headers, full HTML, and derived Markdown. Application state, nonce derivation, cookies, and timestamps use injected randomness/time. The upstream JWT protocol uses wall time, just like a real external provider; its keys/tokens are not snapshot inputs. Only the fixture's known origin, including its URL-encoded representation inside `redirect_uri`, is normalized. - -These tests cannot verify registered callback URLs, real consent screens, provider account restrictions, or deployed browser/TLS/proxy behavior. With real credentials, manually complete each provider's login, cancellation, existing-account connection, and logout using your registered origin before deployment. - -## Production composition - -```ts -import { startProduction } from './examples/login/src/production.ts'; -import { oauthFromEnvironment } from './examples/login/src/oauth-providers.ts'; - -await startProduction({ - databaseUrl, - publicOrigin: 'https://your-app.example', - secret: sharedHighEntropySecret, - email: yourEmailSender, - oauth: oauthFromEnvironment(process.env), - port: 3000, -}); -``` - -Register `https://your-app.example/oauth/google/callback` and `https://your-app.example/oauth/github/callback` with their respective providers. Apply migrations before starting the deployment. `startProduction` uses real time, secure randomness, HTTPS origin validation, Secure `__Host-` cookies, and no inbox routes. The transport seam is available only on the lower-level application factory, not the production wrapper. - -Use a same-host HTTPS reverse proxy and keep the public origin fixed; request Host/forwarded headers never choose callback destinations. The example sees the proxy's IP, so configure internet-facing rate limits at the trusted proxy rather than forwarding untrusted client IP headers. Exclude token-bearing callback/link query strings from access logs. Rotating the shared application secret invalidates in-flight OAuth proofs and existing CSRF derivations; coordinate it across instances. - -For Hono, Fetch handlers, or Cloudflare Workers, see [WORKERS.md](WORKERS.md). diff --git a/PACKAGES.md b/PACKAGES.md index 5561b98..93a6580 100644 --- a/PACKAGES.md +++ b/PACKAGES.md @@ -1,6 +1,6 @@ # Consuming pgstencil -Three packages share version 0.4.2: `pgstencil` (infrastructure and test primitives), `@pgstencil/auth` (email/OAuth/session services and a JSON HTTP adapter), and `@pgstencil/stripe` (SaaS billing and StripeDev). They are published to npm as 0.x releases, so a minor release may break compatibility. +Three packages share one version, the `version` in each `packages/*/package.json`: `pgstencil` (infrastructure, test primitives and the database CLI), `@pgstencil/auth` (sign-in on Better Auth: email codes, OAuth providers, sessions and a Workers adapter), and `@pgstencil/stripe` (SaaS billing and StripeDev). They are published to npm as 0.x releases, so a minor release may break compatibility; [CHANGELOG.md](CHANGELOG.md) says how an application adapts. Run `pnpm packages:pack` to produce the three archives in `dist/packages`. They contain ESM JavaScript, TypeScript declarations, the MIT license, and required SQL/Compose assets. Workspace development uses source exports; packing switches the exports to compiled files. Nothing runs migrations during install. @@ -8,7 +8,7 @@ Copy the tarballs to a consumer's `vendor/` directory, depend on them using `fil Every archive carries `package/dist/provenance.json`, holding the 40-character `commit` it was packed from plus `"dirty": true` when `--allow-dirty` packed a modified tree. Read it without unpacking the archive: `tar -xOf vendor/pgstencil-0.1.0.tgz package/dist/provenance.json`. That path is the contract; nothing else in the archive identifies its source, since npm's `gitHead` is absent from a `pnpm pack` of a private package. A consumer should record the commit it vendored, re-derive it from the archive on every build, and refuse an archive whose commit differs or that is marked dirty. `pnpm packages:verify` makes the same assertion against this checkout's `HEAD`, so a stale archive fails CI here. Having recovered the commit, a consumer confirms it was audited by reading its check run — `gh api repos/diffplug/pgstencil/commits//check-runs` lists `security-audit`, which succeeds only when that commit's audit against [SECURITY.md](SECURITY.md) reached `VERDICT: PASS`. -`pnpm packages:verify` builds, packs, installs into an unrelated temporary pnpm project, checks TypeScript declarations, and runs an email login and required-card trial against a real cloned database. The project declares each peer at the version this workspace tests, and fails on an unmet peer. It shares this repository's Docker service state, but loads all code and SQL from installed archives. The consumer directory is printed for inspection. +`pnpm packages:verify` builds, packs, installs into an unrelated temporary pnpm project, checks TypeScript declarations, and runs a Better Auth email sign-in, a required-card trial and the `pgstencil` CLI against a real cloned database. The project declares each peer at the version this workspace tests, and fails on an unmet peer. It shares this repository's Docker service state, but loads all code and SQL from installed archives. The consumer directory is printed for inspection. ## Dependencies @@ -18,24 +18,25 @@ Every other dependency is private: pgstencil owns its version and applications d ### Tooling peers -pgstencil's test, migration and snapshot tooling loads optional peers, so a production install leaves them out. No entry point imports them statically, and only three load them; `scripts/runtime-deps.test.mjs` and `pnpm packages:verify` fail otherwise. An application using the tooling adds the peers it needs, usually as devDependencies: +pgstencil's test, migration, snapshot and code-generation tooling loads optional peers, so a production install leaves them out. No entry point or bin imports them statically, and only the ones below load them; `scripts/runtime-deps.test.mjs` and `pnpm packages:verify` fail otherwise. An application using the tooling adds the peers it needs, usually as devDependencies: -| Entry point | Optional peer | Loaded when | -| --------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `pgstencil/database` | `testcontainers` | Local services start or reattach: `ensureServices`, `prepareTemplate`, `allocateDatabase`, `developmentDatabase` | -| `pgstencil/database` | `node-pg-migrate` | `migrate` runs, including inside `allocateDatabase` and `developmentDatabase` | -| `pgstencil/testing` | both of the above | `createTestContext` | -| `pgstencil/snapshots` | `cheerio`, `turndown` | The module is imported | +| Entry point | Optional peer | Loaded when | +| --------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `pgstencil/database` | `testcontainers` | Local services start or reattach: `ensureServices`, `prepareTemplate`, `allocateDatabase`, `developmentDatabase` | +| `pgstencil/database` | `node-pg-migrate` | `migrate` runs, including inside `allocateDatabase` and `developmentDatabase` | +| `pgstencil/testing` | both of the above | `createTestContext` | +| `pgstencil/snapshots` | `cheerio`, `turndown` | The module is imported | +| `pgstencil` command | `kysely-codegen` | `pgstencil types` or `pgstencil verify` runs with a `types` entry configured; its other commands need the `pgstencil/database` peers | ```sh -pnpm add -D testcontainers node-pg-migrate cheerio turndown +pnpm add -D testcontainers node-pg-migrate cheerio turndown kysely-codegen ``` Reading migration sources (`defaultMigrations`, `readMigrations`, `validateMigrations`, `appliedMigrations`) needs none of them. A release step that migrates a persistent database needs `node-pg-migrate`, which peers on `pg`, but not `testcontainers`; make it a regular dependency if that step runs from a production install. A missing peer fails with an error naming the package and the entry point that needs it. The peer ranges start at the versions pgstencil's CI tests, and pgstencil widens them as for any peer. ## Releasing -The three packages share one version. A release is a pull request that raises `version` in all three `package.json` files: a patch for a fix or compatible addition, a minor for a breaking change, since these are 0.x. Nothing else triggers one. +The three packages share one version. A release is a pull request that raises `version` in all three `package.json` files and moves [CHANGELOG.md](CHANGELOG.md)'s Unreleased entries under that version: a patch for a fix or compatible addition, a minor for a breaking change, since these are 0.x. Nothing else triggers one. After that merges, [`release.yml`](.github/workflows/release.yml) finds any version npm does not have, waits for `security-audit` to pass on that commit, and **stages** the packages from the `publish` environment, which admits only `main`. A staged package is not public. Approve each one, core first because auth and billing peer on it: @@ -45,7 +46,7 @@ npm stage download # `dist/provenance.json` must name the audited c npm stage approve # asks for 2FA ``` -Each package's trusted publisher on npmjs.com names this repository, `release.yml` and the `publish` environment, and leaves "can also publish directly with `npm publish`" unchecked, so the workflow can stage but never publish. Package access is set to require 2FA and disallow tokens. Applications receive an approved release as an ordinary Renovate update. Changes that are not released yet reach an application on a branch through `pnpm pgstencil:sync`. +Each package's trusted publisher on npmjs.com names this repository, `release.yml` and the `publish` environment, and leaves "can also publish directly with `npm publish`" unchecked, so the workflow can stage but never publish. Package access is set to require 2FA and disallow tokens. Applications receive an approved release as an ordinary Renovate update. Changes that are not released yet reach an application through the packed archives described above. Version 0.2.0 was published by hand from commit `e79cc4d`, after its audit passed. @@ -53,27 +54,30 @@ Version 0.2.0 was published by hand from commit `e79cc4d`, after its audit passe Production imports use `pgstencil`, `pgstencil/postgres`, `pgstencil/diagnostics`, `@pgstencil/auth` and `@pgstencil/stripe`. Docker startup and test fixtures are opt-in imports through `pgstencil/database`, `pgstencil/testing`, and `@pgstencil/stripe/testing`; the first two need the [tooling peers](#tooling-peers). -The database tooling uses the current working directory as the project root, or `PGSTENCIL_PROJECT_ROOT` when explicitly set. State goes in that project's `.pgstencil`; its Docker Compose project name derives from the project path. A project can supply `compose.yaml`, otherwise the package's bundled default is used. The default SQL directory is `migrations`, overridable by a `pgstencil.json` file containing a `migrations` directory path inside the project root; a path resolving outside it is refused. Applications composing packages should pass their complete source list explicitly: +The database tooling uses the current working directory as the project root, or `PGSTENCIL_PROJECT_ROOT` when explicitly set. State goes in that project's `.pgstencil`; its Docker Compose project name derives from the project path. A project can supply `compose.yaml`, otherwise the package's bundled default is used. `pgstencil.json` in the project root lists the SQL sources as `migrations`: one directory or an ordered list, each inside the project root (a path resolving outside it is refused), defaulting to `migrations`. The file is validated when `pgstencil/database` is imported, and an unknown key is an error. The [CLI](README.md#change-the-schema), `defaultMigrations`, and `createTestContext()`, `allocateDatabase()` and `developmentDatabase()` without a source argument all use that list. A package's source is named by its installed path in `pgstencil.json`, or by its export in code: ```ts -import { authMigrations } from '@pgstencil/auth/migrations'; +import { betterAuthMigrations } from '@pgstencil/auth/better-auth-migrations'; import { billingMigrations } from '@pgstencil/stripe/migrations'; -const sources = [authMigrations, billingMigrations, applicationMigrations]; +const sources = [ + betterAuthMigrations, + billingMigrations, + applicationMigrations, +]; ``` -Every source is an append-only ordered directory of numbered SQL files. Filenames must be globally unique; include a package/application label, such as `100_ttr_purchases.sql`. Each source's applied history must remain a prefix of that source's current files. A package can therefore introduce a new migration with a lower number than another source's already-applied migration. Pending files execute in filename order, and every applied file retains checksum protection. Test package upgrades against existing application data as well as clean initialization. +Every source is an append-only ordered directory of numbered SQL files. Filenames must be globally unique; include a package/application label, such as `100_shop_purchases.sql`. Each source's applied history must remain a prefix of that source's current files. A package can therefore introduce a new migration with a lower number than another source's already-applied migration. Pending files execute in filename order, and every applied file retains checksum protection. Test package upgrades against existing application data as well as clean initialization. -Auth reserves the existing `public.users`, `login_flows`, `login_challenges`, `sessions`, `rate_limits`, `oauth_identities`, and `oauth_flows` tables. Their original SQL bytes and names are preserved for upgrades. Billing reserves the `pgstencil_billing` schema. Application SQL must avoid those names. - -`Auth` accepts a `renderEmail` function for branding. `createAuthHttp` from `@pgstencil/auth/http` supplies JSON routes under `/api/auth/`, native OAuth callbacks under `/oauth/`, and a non-consuming email-link redirect under `/login/link`. The SPA confirms the link with an authenticated browser-flow POST. Session tokens stay in HttpOnly cookies; the JSON state contains the CSRF token, public session fields, and configured provider names. See the adopter's backend spec for a complete React integration. +Auth reserves `public.user`, `session`, `account`, `verification`, `rateLimit`, `pgstencil_auth_limits` and `pgstencil_oauth_claims`. Billing reserves the `pgstencil_billing` schema. Application SQL must avoid those names. The project is MIT licensed and hosted at [diffplug/pgstencil](https://github.com/diffplug/pgstencil). Releases reach npm through trusted publishing, as described in [Releasing](#releasing). The local archives remain for trying unreleased changes; they are ordinary npm package artifacts, so moving between them and a released version needs no submodules or source-loader integration. ## Better Auth integration -New applications can use `@pgstencil/auth/better-auth` and the request-scoped -`@pgstencil/auth/better-auth-workers` adapter. The old exports remain available -so adoption can be staged without changing already deployed auth code. +`@pgstencil/auth` is a fixed sign-in policy on Better Auth. Node hosts import +`createAuthApp` from `@pgstencil/auth` (or `@pgstencil/auth/better-auth`, the +same module); Workers use the request-scoped +`@pgstencil/auth/better-auth-workers` adapter: ```ts import { @@ -87,14 +91,15 @@ type Env = BetterAuthWorkerBindings & { EMAIL_FROM: string; }; const auth = createBetterAuthWorker({ - appName: 'Type The Rhythm', - sessionPolicy: 'single', // Dormouse uses 'multiple'. + appName: 'Your App', + sessionPolicy: 'single', // Default: 'multiple'. accountLinking: 'same-email', // Default: 'explicit'. trustedEmailProviders: ['google', 'apple', 'facebook'], // Skip the extra mailbox code. allowMissingEmail: true, // Optional: provider-only accounts have public email: null. successPath: '/profile', errorPath: '/login', email: (env) => postmarkEmail(env.POSTMARK_SERVER_TOKEN, env.EMAIL_FROM), + emailFrom: (env) => env.EMAIL_FROM, }); ``` @@ -104,11 +109,31 @@ bindings used by your application. Forward `/api/auth/*` and `/api/providers` to `AUTH_SECRET`; paired provider credentials enable OAuth. A consumer can strip provider bindings in its preview entry to guarantee email-only previews. -Use `betterAuthMigrations` from `@pgstencil/auth/better-auth-migrations`. These -reserve `public.user`, `session`, `account`, `verification`, `rateLimit`, -`pgstencil_auth_limits`, and `pgstencil_oauth_claims`. An existing deployment -retains its old migration source and adds this one; it must not drop checksum -history. Old and new auth tables coexist, but sessions/accounts are independent. +Use `betterAuthMigrations` from `@pgstencil/auth/better-auth-migrations`. A +database that applied the code/link auth migrations of releases before 0.5.0 +(`001_auth.sql`, `002_oauth.sql`, `004_oauth_workers.sql`) must keep those three +files as a source of its own, byte for byte, because the package no longer ships +them and applied SQL stays under checksum validation; [CHANGELOG.md](CHANGELOG.md) +has the steps. + +### Sign-in email + +The email-code message is sent through the `email` sender you inject. +`emailFrom` sets its sender; without it the sender is +`signin@`, so a deployment that forgets it is refused by its +mail provider instead of sending as another domain. On Workers, `emailFrom` may +be `(env) => env.EMAIL_FROM`. `postmarkEmail(token, from)` always sends from its +own `from` argument, whatever the message says. + +`renderEmail` replaces the wording. It receives +`{ email, otp, expiresInMinutes, appName, from }` and returns an `EmailMessage`, +or a promise of one; `defaultSignInEmail` is the built-in renderer, which a +custom one can call and adjust. The message must be addressed to `email` alone, +with no `cc` or `bcc`: any other recipient, or a renderer that throws, delivers +nothing and records an `email.delivery.failed` diagnostic. Escape every value +you interpolate into `html` with `escapeHtml` from `pgstencil`. Better Auth +answers a send request 200 whether or not delivery succeeded, so watch that +diagnostic rather than the response. Node hosts use `createAuthApp({databaseUrl, origin, secret, email, ...})` and call `close()` before returning their database lease. Serve `app.fetch` through @@ -127,8 +152,8 @@ for app creation and requests. Never inject it into production builds. The [working example](examples/better-auth/README.md) documents the HTTP protocol, security choices, native session-token storage tradeoff, test coverage and -provider callback registration. `packages:verify` also installs and exercises -this integration from the tarball alongside the legacy auth and Stripe packages. +provider callback registration. `packages:verify` installs and exercises this +integration from the tarball alongside the Stripe package. ### Account linking policies diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 23018ce..0000000 --- a/PLAN.md +++ /dev/null @@ -1,205 +0,0 @@ -# pgstencil implementation plan - -Status: the first working recipe and login example are implemented. See README.md for available commands and exact behavior. The architecture below records the intended direction; extension items called out here are not all shipped. - -Delivered: SQL migrations and checksum validation; reusable Docker/IntegreSQL templates and isolated leases; Kysely types/schema dump; real-port application fixtures; per-app time, randomness and email; readable file snapshots with explicit updates; the email login example; concurrency/upgrade/failure tests; and Linux CI configuration. - -Remaining expansion work: automatic codegen during watch, opt-in query traces, attachment captures, a generalized snapshot identity/duplicate-path API, explicit nontransactional migrations, template seed hooks, and standalone published-package configuration/second consumer. The current package is a private workspace toolkit whose service configuration comes from this checkout. Linux CI is configured; the initial local validation ran on macOS. - -Build a TypeScript/pnpm toolkit for Postgres-backed applications: version the schema, automatically prepare a reusable local database template, start isolated test applications concurrently, control server time and randomness deterministically, capture outgoing email in memory, and review their behavior through readable snapshots. - -## What to carry forward - -The neighboring repositories establish the intended workflow: - -- `../webtools/src/main/java/com/diffplug/webtools/flywayjooq/SetupCleanupDockerFlyway.java` starts Docker, migrates `template1`, and writes a schema dump. Its configuration participates in the build lifecycle. -- `../diffplugdotcom/server/src/test/java/common/CleanPostgresModule.kt` creates a database per application from that template and drops it on application shutdown. -- `../diffplugdotcom/server/src/test/java/selfie/SelfieSettings.kt` captures multiple facets of a response: HTML, headers/cookies, Markdown, and forms. Lenses normalize local origins and select meaningful HTML before Markdown conversion. -- `../diffplugdotcom/server/src/test/java/common/EmailDev.kt` substitutes an email backend that stores messages per application, supports waiting for unread messages, and provides local message/template previews. -- `../diffplugdotcom/server/src/test/java/common/DevTime.kt` provides a mutable server-time dependency with explicit advancement. `DevNoDB.kt` injects a freshly seeded `SecureRandom` per application, and `DevRandomIsDeterministicTest.java` checks its repeatable byte sequence. - -Preserve that developer experience. Use dedicated, immutable application templates instead of modifying Postgres's built-in templates. Keep application state, clocks, HTTP clients, and database pools local to each test application. - -## Recommended building blocks - -Dependency policy: adopt libraries that replace substantial machinery or difficult protocol semantics. Write small pgstencil-specific helpers locally using Node built-ins and the libraries already selected. Do not add dependencies merely for hashing, IDs, port selection, formatting a table, maintaining an outbox, composing lenses, or parsing a handful of CLI flags. Assess direct dependencies case by case; this is not a promise to eliminate transitive utilities used by the major libraries. - -| Concern | Initial choice | What pgstencil adds | -| ------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| Runtime/tooling | Node LTS, strict TypeScript, ESM, pnpm workspace | Pinned compatible versions, repeatable scripts | -| Postgres access | [`pg`](https://node-postgres.com/features/queries) | Administrative connections and application pool lifecycle | -| Migrations | [`node-pg-migrate`](https://salsita.github.io/node-pg-migrate/api), SQL migration files | Configuration, template preparation, validation commands | -| Database templates/clones | [IntegreSQL](https://github.com/allaboutapps/integresql) | Migration/seed initialization, application database lifetime and configuration | -| Docker | [Testcontainers](https://node.testcontainers.org/features/containers/) Compose support | Automatically start/reuse Postgres and IntegreSQL on a shared network | -| Tests/snapshot storage | [Vitest file snapshots](https://vitest.dev/guide/snapshot.html#file-snapshots) | Local capture functions, lenses, stable names and readable facets | -| HTML selection | [Cheerio](https://cheerio.js.org/docs/intro/) | Explicit content selection and exclusions | -| HTML to Markdown | [Turndown](https://github.com/mixmark-io/turndown) | Rules for application content that generic conversion loses | -| HTTP test client | [Supertest](https://github.com/forwardemail/supertest) | Per-client fixtures, response capture, and clock integration; preserve its fluent request/assertion API | -| Cookies | Supertest's session agent by default | Capture actual response cookies and exercise server-side validity using the injected time | -| Server time | Small local `Time` / `DevTime` implementations | `now()`, `set(instant)`, and `advanceHours(n)` per application | -| Randomness | Small injected byte-provider interface backed by Node crypto in production and a seeded implementation in tests | Repeatable tokens, IDs, and cookie values per application | -| Email | Small local sender interface and in-memory `EmailDev` implementation | Per-application outbox, wait helper, snapshots and local previews; provider API adapter in production | -| Application queries | [Kysely](https://kysely.dev/) + [`kysely-codegen`](https://github.com/RobinBlomberg/kysely-codegen), using `pg` | Generate types from the migrated schema before typechecking | - -Use Kysely for application queries from the first example and `pg` for the driver/administrative connections. Integrate code generation once a migrated clone is available; keep raw SQL available. SQL migrations remain the schema authority. Do not build an ORM, migration executor, HTML parser, or general snapshot runner. - -Use `node-pg-migrate` to apply an ordered directory of `.sql` files. Kysely is the query layer; IntegreSQL manages template preparation and allocation and calls our initializer, which runs those migrations. No Kysely migration DSL or conversion of SQL migrations into TypeScript is needed. Pin compatible stable package versions and use their documented SQL-file format. - -Start with one library package, with database, application-fixture, and snapshot modules, plus one small example application in the pnpm workspace. Keep runtime database imports separate from test-only dependencies. The example can use Node's HTTP server so that selecting a website framework does not block the infrastructure. - -The first example is a server-rendered email login flow supporting typed codes and clickable links, specified in [LOGIN_FLOW.md](LOGIN_FLOW.md). It includes an account page and logout, and exercises deterministic challenge/session expiry and randomness. This replaces the previously proposed notes application. - -### HTTP client selection - -[Supertest](https://github.com/forwardemail/supertest) is the selected RestAssured equivalent: fluent requests/assertions, form submissions, response access, and `request.agent(origin)` for automatic cookie persistence. Keep its API available; implement only application fixtures and snapshot adapters. Use `.redirects(0)` when capturing an intermediate response and explicitly opt into redirect following. Use explicit status assertions for expected non-2xx responses. - -The core time-travel test checks the server's acceptance of a login cookie at controlled times. Capture the original cookie after login and explicitly replay it to prove acceptance before expiry and rejection at expiry, even if a client still sends it. This also avoids confusing missing-cookie rejection with actual expiration validation. - -Supertest's cookie store uses the machine clock. An absolute `Expires` date in 2020 may be discarded by an ordinary client today; for historical-date tests, retain the actual `Set-Cookie` snapshot and explicitly send the captured cookie name/value on subsequent Supertest requests. Normal session tests can use `agent()`. Use the package's parsed cookie values or a small extraction helper for the application's known cookie format; this helper does not implement a cookie store. Preserve deterministic expiry attributes in snapshots. Browser-side cookie eviction is outside this server-time workflow; no simulated browser clock or new cookie engine is required. - -## Database lifecycle - -### Container ownership - -`pnpm test` and `pnpm dev` automatically find or start the project's Postgres and IntegreSQL containers. Wait for a successful database query and IntegreSQL readiness. Docker must already be available. Use a shared Docker network for communication between the services, dynamic host ports for Node clients, and discovered addresses. Keep container-internal connection addresses distinct from the host-side addresses used by tests. - -Reuse both services across local command invocations. Use project labels and stable container/network configuration; isolate separate checkouts by default. [Testcontainers supports reuse by configuration](https://node.testcontainers.org/features/containers/#reusing-a-container), but verify cross-process startup and stopped-container/network behavior in the first milestone. Prefer its existing coordination facilities. Add local coordination only if required. Do not build a Docker manager or a second template coordinator. - -CI gets a fresh pair of services per job, shared by that job's workers, then tears them down. A test run must never stop reusable local infrastructure that another run is using. Local cache loss or service/container removal may require initialization again; ordinary warm runs must not. - -Pin a concrete Postgres image version, initially in the same major version as the reference application (17), and make it configurable. Include relevant image/configuration identity in cache keys. Validate macOS Docker and Linux CI during implementation. - -### Template preparation - -Compute a fingerprint from ordered migration paths and bytes, deterministic template seed inputs, and schema-affecting configuration, including a pgstencil template-format version. This fingerprint identifies a template within its Postgres cluster. - -Integrate with IntegreSQL's initialize/finalize and database-allocation APIs. Its [TypeScript client](https://github.com/devoxa/integresql-client) demonstrates the initializer callback and `getTestDatabase` flow. Use the client if it provides useful lifecycle/error handling; a few direct API calls can use Node fetch if that keeps the integration smaller. - -Preparation protocol: - -1. Ask IntegreSQL to initialize the template for this fingerprint. Only the process selected to initialize it runs the next steps; other processes wait for the ready template through IntegreSQL. -2. Connect to the assigned template database and run the ordered SQL migrations through `node-pg-migrate`. -3. Insert deterministic shared reference data, then close every initializer connection. -4. Finalize the template on success; discard a failed initialization through IntegreSQL's lifecycle API. Bound waits and test recovery from an initializer process that exits unexpectedly. -5. Allocate a separate test database from IntegreSQL for each application or database-only fixture. - -Migration files used by a build must match the fingerprint even if files change during watch mode: prepare from captured inputs or detect changes and retry. Run schema dumping and type generation on allocated disposable databases, leaving the ready template available for cloning. - -Each schema fingerprint is initialized once successfully per retained cache. A changed migration or seed input creates a different template; existing applications keep their allocated databases. Let IntegreSQL own pooling and cloning. Verify its reclamation behavior under long-lived applications, including intervals when application pools have no active connections; keep a fixture-lifetime connection if needed to prevent premature reclamation. Close it only when the application is finished, and use the supported recreate/release operation for dirty databases. Never return a modified database as an unchanged template clone. - -### Migration discipline - -Use ordered SQL migrations and the package's migration history, locking, and transaction facilities. Follow forward-only changes for shared/deployed databases. Expose migration creation, status, validation, and application through commands that use the same migration runner as template preparation. - -Check whether the selected package version verifies applied migration contents. If it does not, add a small checksum manifest/validation layer for persistent databases; template fingerprinting alone is not migration-history validation. Reject changed or missing applied migrations. Support explicit nontransactional migrations through the runner's documented mechanism and test failure recovery. - -Keep development databases separate from disposable test databases. Provide an explicit development reset command; schema changes must not silently delete a developer's data. Produce a stable, reviewable schema-only dump using the container's matching `pg_dump`. - -## Application and test lifecycle - -Define an application factory that receives its database connection, configuration, clock, and ID/randomness providers, and returns a running application handle with an asynchronous close method. - -The fixture owns a database clone, an application instance, its pool, its email outbox, and independent HTTP clients. The server binds directly to `127.0.0.1:0`, then exposes its actual origin; [Node supports OS-assigned ports](https://nodejs.org/api/net.html#serverlistenport-host-backlog-callback). Avoid reserving a port and releasing it before application startup. - -Default to one fresh database and application per test. Allow a test to start multiple applications, each isolated by default. A deliberately shared database for a multi-instance scenario must be explicit. Support database-only fixtures for query tests. - -Register cleanup as resources are acquired, so partial startup failures also clean up. Shutdown stops requests and background work, closes pools and lifetime connections, then releases the database through IntegreSQL's supported lifecycle. Use unique run/fixture IDs for diagnostics and scope service/cache cleanup to the project. Reuse IntegreSQL's reclamation behavior; verify aborted-run recovery without deleting another active application's database. Bound pool sizes and worker concurrency against the server connection budget, including any lifetime connections. - -The test client handles cookies and redirects explicitly. A cookie jar is browser-like cookie storage: it remembers cookies received in responses and sends the applicable cookies with later requests. Each client represents an independent browser session, so tests can model multiple logged-in users. Capture intermediate redirect responses and their `Set-Cookie` headers before following them. Each client has its own cookie storage: cookies are not isolated by port, so sharing storage between localhost applications would leak sessions. - -## Deterministic time - -Follow `DevTime`: inject a small `Time` interface with `now()` into each application. The production implementation returns real UTC time; the test implementation stores a mutable instant and exposes `set(instant)` and `advanceHours(n)` (plus simple duration/day helpers as needed). Return immutable values or defensive Date copies. Advancing time is synchronous and changes only the value returned by `now()`. No Sinon dependency, replacement of global Date/timers, or simulated timer queue. - -Application timestamps, session/token creation and expiry checks, business deadlines, email timestamps, and rendered dates must use this injected time. Configure any token/session library to validate against it. Use UTC and an explicit locale by default; allow explicit time zones for calendar tests. Keep timestamps visible in snapshots so tests verify the actual values. Two concurrent applications can be set to different dates and advanced independently. - -Coordinate all layers that interpret time: - -- **Database:** Prefer supplying application timestamps and expiry cutoffs as SQL parameters from the clock. PostgreSQL's [`now()` and related functions](https://www.postgresql.org/docs/17/functions-datetime.html#FUNCTIONS-DATETIME-CURRENT) use database-server time; freezing JavaScript time does not control them. Any defaults, triggers, or queries that must obtain application time inside SQL should use one explicit application-time function with a scoped test override. Prove override isolation and reset across pooled connections before relying on it. Do not attempt to change the shared container's system clock. -- **Cookies:** Creation and server-side expiration validation use the application clock. Assert exact cookie timestamps and lifetime attributes, and explicitly replay captured login cookies in historical-date expiry tests as described above. The server must reject a replay at or after the expiry boundary even if the client sends it. -- **Time-dependent jobs:** If an example needs a maintenance action, expose an explicit callable operation and await it after advancing time, following DevTime's explicit job-triggering approach. Advancing the clock does not run jobs implicitly. A general scheduler is outside the initial scope. -- **Infrastructure:** Docker readiness, network timeouts, resource leases, and test-runner deadlines continue to use real elapsed time. Frozen application time must not hang setup or cleanup. Automatically generated transport timestamps, such as HTTP `Date`, need an explicit capture policy; omit them from default relevant-header snapshots while preserving application timestamps. -- **Seeds and cache:** Shared template data uses fixed explicit timestamps. Time-relative scenario data is inserted into the clone using its application's clock, so changing test time does not require rebuilding the template. - -Acceptance: set time to `2020-01-01T00:00:00Z`, log in with a fixed 24-hour session lifetime, and snapshot deterministic cookie values/timestamps plus database/rendered dates. Advance 23 hours and verify the captured login cookie still authenticates; advance one more hour and verify it no longer authenticates. Use a fixed expiry with no sliding renewal for this scenario. Run beside another application at a different date and confirm isolation. Repeat on cold and warm template runs with identical snapshots and no real waiting. - -## Deterministic randomness - -Follow `DevNoDB`'s injected SecureRandom model with a small `RandomSource` interface, starting with `bytes(length)`. Route application-generated tokens, IDs, nonces, and other random values through it. Production uses Node's cryptographic random-byte implementation. Tests instantiate a fresh seeded byte source per application, with an optional seed override. - -Implement the test source locally using a fixed, documented deterministic byte stream, for example SHA-256 blocks over a seed and counter using Node crypto. Buffer unused bytes so output is repeatable regardless of read chunk sizes. The test implementation is explicitly wired by the test/dev application factory; production construction always uses the cryptographic source. No global Math.random/crypto monkeypatch and no extra random-number package. - -Use deterministic test signing/configuration keys where needed as well as deterministic inputs. SQL defaults that generate random IDs must receive explicit test values or have an explicit deterministic strategy. Infrastructure resource names use separate collision-resistant IDs, so identically seeded applications cannot collide or consume each other's random sequence. Concurrent operations whose assignment order matters should use distinct explicitly seeded sources or controlled ordering. - -Acceptance: equal seeds and the same scenario yield identical tokens/cookies/email links on repeated runs; advancing time changes the intended timestamps while randomness remains reproducible; consuming bytes in one application does not change another's output. Check the byte stream with a known vector. Do not require byte-for-byte compatibility with Java's SHA1PRNG. - -## Email capture and local previews - -Follow EmailDev's model: inject an `EmailSender` interface into each application and use a small, entirely in-memory implementation in tests and local development. Preserve the application's normal rendering/composition path and copy the resulting message into a per-application outbox. Production implements the same interface with an email provider's HTTP API, such as Postmark; provider selection and credentials are later application configuration. No Nodemailer, SMTP server, or message-delivery dependency is needed for capture. - -Expose a small API: list captured messages, consume the next unread message or batch, wait for messages with a useful real-time timeout, and assert no unread messages after expected work completes. Notify waiters when capture finishes instead of polling. Dispose pending waiters during fixture cleanup. Outboxes and read cursors must not be global. - -Capture recipient fields (to/cc/bcc), sender, reply-to, subject, application headers, HTML, plaintext, and attachments when used. Reuse HTML/Markdown lenses for email snapshots. Capture time comes from the application's clock, and application-generated IDs/tokens come from its random provider; preserve meaningful link values. Provider-generated delivery IDs and MIME encoding are outside the in-memory capture contract. - -Provide a local development inbox with message HTML/plaintext views and registered template previews using fixed example data, matching EmailDev's useful workflow. Mount these routes only in development/test configurations. An external SMTP catcher is unnecessary for the initial injected sender. - -Acceptance: request a login email containing both a code and a link; await its capture and snapshot recipients/subject/HTML/plaintext/Markdown. Exercise code entry and link confirmation in separate fixtures, then advance time in fresh fixtures to verify expiry. Verify two parallel applications cannot see each other's mail; checking for unexpected extra messages and wait timeout errors must work. No test/development message is delivered externally. - -## Local snapshots and lenses - -Build a small local API that captures values into named text facets, applies pure transformations, and passes the resulting text to Vitest. No dependency on completing Selfie's JavaScript implementation. Keep capture and lens functions independent of Vitest so a future Selfie adapter is possible. - -| Capture | Facets and behavior | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| Query result | Readable rows with column names, explicit nulls, and stable representations for timestamps, decimals, bigint, JSON and binary values | -| Query trace, opt-in | SQL and parameters captured separately, with ordering semantics declared; query timings excluded | -| HTTP response | Status, relevant headers, redirect location, and every `Set-Cookie` header | -| Cookies | Names, normalized values where requested, domain/path, expiry, HttpOnly, Secure and SameSite; optionally jar state after the response | -| Email | Recipients/headers, subject, plaintext, HTML, derived Markdown, and relevant attachment metadata | -| HTML | Original response body by default; readable formatting as an explicit lens with whitespace-sensitive cases covered | -| Markdown | Selected HTML converted to readable content, preserving links, tables, images and meaningful form/iframe information through custom rules | - -Preserve query row order. Tests should use `ORDER BY` or explicitly request an unordered comparison. Normalize only declared unstable fields. Prefer an injected clock and deterministic IDs over broad replacement patterns. Map only the fixture's known origin to a stable test origin, preserving meaningful URL differences. Cookie normalization must preserve security attributes and expiry behavior. - -Support selector-based inclusion/exclusion and a composed Markdown lens. Use the existing project's `.selfie` and `.selfie-exclude` conventions as reference behavior; decide the new default selectors in the example. Keep full HTML alongside the reduced Markdown view so conversion does not conceal structural changes. Add a forms facet only if Markdown rules are insufficient for readable assertions. - -Store snapshots beside tests, under a dedicated directory such as `snapshots//.md`, with related HTML or JSON files where useful. A Markdown report may group small facets under headings. Start with Vitest's file matcher and update/diff behavior; evaluate file count/readability using the example before inventing a grouped storage format. - -Snapshot identity must derive from the test path, full test name, explicit case ID where needed, and checkpoint name, never from ports, workers, or invocation order. Use [the test-local `expect` in concurrent Vitest tests](https://vitest.dev/api/test.html). Reject duplicate output paths. Normal test runs only compare; an explicit update command writes accepted baselines. CI rejects missing or changed snapshots. Prevent simultaneous update processes from writing the same files. - -## Implementation sequence and acceptance checks - -1. **Foundation and IntegreSQL integration.** Scaffold the workspace and one SQL migration. Start Postgres and IntegreSQL automatically through Testcontainers, run `node-pg-migrate` inside template initialization, and allocate two independently writable databases. Acceptance: a second invocation reuses both services/template without rerunning migrations; simultaneous cold invocations converge on one ready template; a migration change produces a new template. Verify host/container address mapping and database lifetime during idle connection-pool periods. - -2. **Database lifecycle and migration workflow.** Add fixture teardown, interrupted-build recovery, persistent-database validation, schema dump, development database commands, and ownership-aware cleanup. Integrate Kysely/codegen using a disposable clone, with driver-aligned date/numeric mappings; commit generated declarations and generate before typechecking. Acceptance: failed migration preparation cannot poison the cache; applied migration edits are rejected for persistent databases; upgrading a database with representative existing data preserves that data and reaches the expected schema; a renamed/dropped column produces a compile error in a typed query after regeneration. Fresh-schema testing alone is insufficient. - -3. **Parallel fixtures, deterministic time/randomness, and email.** Add the application factory, real HTTP listener, Supertest clients, local DevTime and seeded random providers, in-memory email outboxes, and Vitest fixtures. Acceptance: pass the Jan 1 / 23-hour / 24-hour login test and deterministic-randomness checks above; run at least 20 lightweight applications simultaneously with unique ports/databases and isolated writes, cookies, time, random state, and captured email; run two independent test processes simultaneously; verify normal and partial-failure cleanup. Record resource limits and provisioning timings. - -4. **Email login example and snapshot vertical slice.** Implement [LOGIN_FLOW.md](LOGIN_FLOW.md): emailed code/link, confirmation, account page, logout, short-lived challenges, and fixed 24-hour sessions. Snapshot query results, cookies, HTML, derived Markdown, and captured email at controlled times. Add local inbox/template previews. Acceptance: pass the flow's functional/security scenarios; intentional changes to each facet fail the corresponding assertion; cold and warm repeats are identical; explicit updates create clear diffs; meaningful timestamps/form/link/cookie attributes survive lenses. Review the actual artifacts before expanding the snapshot API. - -5. **CI and developer workflow.** Wire frozen-lockfile installation, formatting/linting, typechecking, unit tests, migration upgrade checks, and Docker integration tests. Add focused/watch test commands, diagnostics, cache status, and stale-resource cleanup. Acceptance: clean Linux CI and local macOS runs pass without manual database preparation; watch mode notices migration changes; aborted runs are recoverable; snapshot mismatches fail without rewriting files. Report cold setup, warm setup, clone, and suite timings before setting performance thresholds. - -6. **Query workflow polish.** Verify generated declaration freshness in CI, watch-mode regeneration, and ordinary editor/typechecking operation without Docker using committed declarations. Demonstrate joins and raw SQL escape hatches through Kysely in the example, and document the limits of generated types. Core query/type generation is already part of milestone 2. - -7. **Package and document the reusable recipe.** Exercise pgstencil from a second minimal consumer, finalize exports/configuration, and document the migration and snapshot review workflow, resource lifecycle, and extension points. Only split packages if the consumer exposes a concrete need. Publishing and the choice to replace the local snapshot layer with Selfie remain later decisions. - -## Intended commands - -These commands are available; README.md documents their exact current behavior. - -| Command | Intended result | -| ------------------------------------- | ------------------------------------------------------------------------- | -| `pnpm test` | Prepare infrastructure automatically and run the suite | -| `pnpm test:watch` | Reuse infrastructure and rerun affected tests | -| `pnpm snapshot:update` | Explicitly update reviewed snapshot baselines | -| `pnpm db:migration:create ` | Create the next SQL migration | -| `pnpm db:status` / `pnpm db:validate` | Inspect migration history and validate applied contents | -| `pnpm db:migrate` | Apply pending migrations to the explicitly configured persistent database | -| `pnpm db:schema` | Regenerate the reviewable schema dump | -| `pnpm db:types` | Regenerate Kysely query types | -| `pnpm dev` | Start the example using its persistent development database | -| `pnpm db:reset` | Explicitly recreate the development database | -| `pnpm db:gc` / `pnpm db:stop` | Reclaim stale owned resources / stop an idle local container | -| `pnpm check` | Run the repository's required verification | - -The first useful deliverable is one test that starts from Docker availability, prepares the schema through SQL migrations and IntegreSQL, serves a real request on an assigned port with controlled time/randomness, and produces all four original snapshot views plus captured email. The must-have milestone is complete when that workflow remains deterministic under parallel tests with independent time, random sources and outboxes, explicit time advancement, separate test processes, repeated warm runs, migration changes, and failure recovery. diff --git a/PRODUCT.md b/PRODUCT.md index eae6256..5fa549c 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -8,20 +8,20 @@ web ## Stack -TypeScript/pnpm; server-rendered HTML forms, Node HTTP, Postgres, IntegreSQL, Kysely, Supertest. Approved in PLAN.md and LOGIN_FLOW.md. +TypeScript/pnpm libraries published to npm: Postgres, IntegreSQL, Kysely, Better Auth, Stripe, Hono, Vitest and Supertest. The example application is a JSON API with a minimal page, on Node or Cloudflare Workers. ## Users -Developers building Postgres websites and users signing in to the example by email code/link, Google, or GitHub. +Developers building Postgres websites who want sign-in and subscriptions they can test deterministically, and the people who sign in to and pay for those websites. ## Product Purpose -Make real-database tests parallel, readable and deterministic through isolated databases, server time, seeded randomness and captured email. +Make real-database tests parallel, readable and deterministic through isolated databases, server time, seeded randomness and captured email, and supply audited sign-in and billing built the same way. ## Capabilities and Constraints -The approved login flows in LOGIN_FLOW.md and OAUTH.md are the product brief. Works without client JavaScript. Providers are optional and existing accounts explicitly connect new sign-in methods. The user has explicitly approved implementation; focus on this small functional example rather than introducing a brand-design project. +README.md, PACKAGES.md, BILLING.md and SECURITY.md are the product brief. The packages are libraries: applications own their pages, branding and routes. The example exists to exercise and demonstrate the packages, not as a designed product. ## Brand Commitments -Name: pgstencil. No existing visual assets or commercial claims. +Name: pgstencil. No visual assets or commercial claims. diff --git a/README.md b/README.md index 74b5fbb..e9d1f35 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,20 @@ # pgstencil -A TypeScript/pnpm starting point for Postgres websites with fast, isolated, deterministic tests. SQL files own the schema; IntegreSQL clones migrated templates; Kysely supplies typed queries. Supertest exercises real HTTP servers, and local capture functions produce readable Vitest snapshots. +A TypeScript/pnpm toolkit for Postgres websites with fast, isolated, deterministic tests. SQL files own the schema; IntegreSQL clones migrated templates; Kysely supplies typed queries. Supertest exercises real HTTP servers, and local capture functions produce readable Vitest snapshots. -The first application signs users in with an emailed eight-digit code, a one-use link, Google, Apple, Facebook, or GitHub. Email stays in an in-memory inbox during development and testing. OAuth providers are optional; [OAUTH.md](OAUTH.md) covers credentials, callback URLs, account linking, and tests that use local provider endpoints. +Three packages are published to npm as 0.x releases: + +| Package | What it is | +| ------------------- | ---------------------------------------------------------------------------------------------------- | +| `pgstencil` | Migrations, isolated test databases, injected time/randomness/email, snapshots, and the database CLI | +| `@pgstencil/auth` | Sign-in built on [Better Auth](https://better-auth.com): email codes, five OAuth providers, sessions | +| `@pgstencil/stripe` | SaaS subscriptions on Stripe Checkout, with a local Stripe simulator for tests | + +[PACKAGES.md](PACKAGES.md) covers installing them, their options and their dependencies. [CHANGELOG.md](CHANGELOG.md) lists what each release changed and how an application adapts. ## Start -Install Node 24 or newer, pnpm 10.30.1, and Docker with Compose. Start Docker, then: +Install the pnpm version named in `package.json`'s `packageManager`, and Docker with Compose. pnpm downloads the Node version pinned in `devEngines.runtime` and runs every script with it; the published packages need Node 24 or newer. Start Docker, then: ```sh pnpm install --frozen-lockfile @@ -14,33 +22,31 @@ pnpm test pnpm dev ``` -No database preparation is necessary. The first command that needs Postgres starts this checkout's containers and prepares a template. `pnpm dev` prints the application's assigned URL and local inbox URL. Request a code using any example address, open **Local inbox**, then type the code or click the email's link and confirm. No external email is sent. +No database preparation is necessary. The first command that needs Postgres starts this checkout's containers and prepares a template. `pnpm dev` runs [the example application](examples/better-auth/README.md) and prints its URL, its local inbox and its billing page. Request a code using any example address, open **Local inbox**, and type the code. No external email is sent, and billing runs against a local Stripe simulator. -Use `PORT=3000 pnpm dev` for a fixed port. Development uses real time so cooldowns elapse naturally, and chooses a fresh random seed because its database survives restarts. Tests instead start at January 1, 2020 with a repeatable seed. `PGSTENCIL_TIME` explicitly freezes development time and `PGSTENCIL_SEED` overrides its random seed; reset the development database before deliberately replaying an old seed. +Use `PORT=3000 pnpm dev` for a fixed port. Development uses real time and the persistent `pgstencil_dev` database, and chooses a fresh random seed because that database survives restarts; `PGSTENCIL_SEED` overrides the seed. Tests instead start at January 1, 2020 with a repeatable seed. ## The recipe -| Piece | Implementation | -| ------------------ | ----------------------------------------------------------------------------------------------------- | -| Schema versioning | `packages/auth/migrations/*.sql`, node-pg-migrate, applied-file SHA-256 validation | -| Database isolation | IntegreSQL template per migration/configuration fingerprint; writable database lease per application | -| Local services | Testcontainers starts pinned Postgres/IntegreSQL Compose services with dynamic loopback ports | -| Queries | Kysely over `pg`; committed declarations generated by kysely-codegen | -| HTTP tests | Supertest agents against listeners bound to `127.0.0.1:0` | -| OAuth | openid-client for Google/Apple OIDC and Facebook/GitHub OAuth; local HTTP provider fixtures for tests | -| Time / randomness | Injected `DevTime` and `DevRandom`; production uses `SystemTime` and Node crypto | -| Email | Injected `EmailSender`, with `EmailDev` capture, waiting, unread checks and preview routes | -| Snapshots | Vitest file snapshots plus local JSON, response, HTML, Markdown and email captures | - -The workspace contains `pgstencil`, `@pgstencil/auth`, and `@pgstencil/stripe`. They are published to npm as 0.x releases; [PACKAGES.md](PACKAGES.md) explains installing them and consuming unreleased changes through compiled local tarballs. The core package's `pgstencil/postgres` export is the runtime connection layer; `pgstencil/database` and `pgstencil/testing` include local Docker infrastructure, and with `pgstencil/snapshots` they load [optional peers](PACKAGES.md#tooling-peers) that a production install leaves out. `examples/login` is a complete consumer, using Node's HTTP server and native HTML forms. No frontend framework is required. - -Hono and Cloudflare Workers are supported through the shared Fetch adapter and request-scoped Hyperdrive connections. See [WORKERS.md](WORKERS.md) for the deployable example, local runtime tests, and deployment preparation. - -The [Better Auth integration](examples/better-auth/README.md) is the path for new applications: email codes, Google/Apple/Facebook/GitHub login, explicit account linking, and configurable single or multiple sessions. It preserves deterministic parallel tests on Node and Workers. Run `pnpm dev:better-auth` for the local demo or `pnpm test:better-auth` for its tests. [PACKAGES.md](PACKAGES.md#better-auth-integration) shows the public package API. The original code/link and billing example remains available during the staged migration. +| Piece | Implementation | +| ------------------ | ---------------------------------------------------------------------------------------------------- | +| Schema versioning | Ordered `.sql` files per source, node-pg-migrate, applied-file SHA-256 validation | +| Database isolation | IntegreSQL template per migration/configuration fingerprint; writable database lease per application | +| Local services | Testcontainers starts pinned Postgres/IntegreSQL Compose services with dynamic loopback ports | +| Queries | Kysely over `pg`; committed declarations generated by kysely-codegen | +| HTTP tests | Supertest against listeners bound to `127.0.0.1:0` | +| Time / randomness | Injected `DevTime` and `DevRandom`; production uses `SystemTime` and Node crypto | +| Email | Injected `EmailSender`, with `EmailDev` capture, waiting and unread checks | +| Snapshots | Vitest file snapshots plus local JSON, response, HTML, Markdown and email captures | +| Authentication | Better Auth, with pgstencil's SQL, security policy and a deterministic test bundle around it | +| Billing | The official Stripe SDK, hosted Checkout and portal, a webhook ledger, and the `StripeDev` simulator | +| Workers | Hono and Cloudflare Workers through request-scoped Hyperdrive connections ([WORKERS.md](WORKERS.md)) | + +The core package's `pgstencil/postgres` export is the runtime connection layer. `pgstencil/database` and `pgstencil/testing` include local Docker infrastructure, and with `pgstencil/snapshots` they load [optional peers](PACKAGES.md#tooling-peers) that a production install leaves out. ## Write a test -The complete application fixture is in [tests/integration/helpers.ts](tests/integration/helpers.ts). A smaller consumer can use the infrastructure directly: +A test asks for a context and gets its own database, clock, random stream and outbox: ```ts import { test, expect } from 'vitest'; @@ -59,30 +65,34 @@ test('empty users', async ({ onTestFinished }) => { }); ``` -Pass `{ migrations: '/absolute/path/to/migrations', seed: 'scenario', now: '2020-01-01T00:00:00Z' }` for another schema or scenario. Pass the returned database URL, clock, randomness and email sender into your application factory. Close HTTP servers and query pools **before** closing the context. The lease holds a database connection throughout its lifetime so IntegreSQL cannot reclaim an idle test's database. +Without arguments the context uses the migration sources in `pgstencil.json`. Pass `{ migrations: ['/absolute/path/to/migrations'], seed: 'scenario', now: '2020-01-01T00:00:00Z' }` for another schema or scenario. Pass the returned database URL, clock, randomness and email sender into your application factory; [tests/support/fixture-app.ts](tests/support/fixture-app.ts) is the smallest application that does. Close HTTP servers and query pools **before** closing the context. The lease holds a database connection throughout its lifetime so IntegreSQL cannot reclaim an idle test's database. + +Each context owns its clock, random stream and outbox. Advance time with `context.time.advanceHours(23)` and then `advanceHours(1)`; there is no sleeping or timer monkey-patching. Applications must use the injected clock for deadlines and timestamps. PostgreSQL `now()` and browser clocks still use real time, so application SQL explicitly supplies relevant timestamps. -Each context owns its clock, random stream and outbox. Advance time with `context.time.advanceHours(23)` and then `advanceHours(1)`; there is no sleeping or timer monkey-patching. Applications must use the injected clock for authentication deadlines and timestamps. PostgreSQL `now()` and browser clocks still use real time, so application SQL explicitly supplies relevant timestamps. +Supertest's `request.agent(origin)` manages ordinary cookies. For historical tests, capture the actual `Set-Cookie` headers and explicitly replay their name/value pairs: the client's cookie store uses today's machine clock. The [24-hour session test](tests/integration/better-auth.test.ts) keeps the original cookie and proves that the server accepts it at 23 hours and rejects it once 24 hours have passed. Cookie expiry attributes remain visible in the snapshot. -Supertest's `request.agent(origin)` manages ordinary cookies. For historical tests, capture the actual `Set-Cookie` headers and explicitly replay their name/value pairs: the client's cookie store uses today's machine clock. The [24-hour session test](tests/integration/login.test.ts) keeps the original cookie and proves that the server accepts it at 23 hours and rejects it exactly at 24 hours. Cookie expiry attributes remain visible in the snapshot. +A library that reads the global clock and `crypto` itself, as Better Auth does, cannot take an injected `Time`. Its tests bundle the application with a module that scopes `Date`, Web Crypto and outbound `fetch` to each application through `AsyncLocalStorage`; see [Determinism and Workers](examples/better-auth/README.md#determinism-and-workers). -Run a focused test with `pnpm test tests/integration/login.test.ts`, or `pnpm test:watch`. Changes to the example's SQL files or Compose configuration trigger a full watch rerun. Type generation is explicit: rerun `pnpm db:types` after editing migrations. +Run a focused test with `pnpm test tests/integration/database.test.ts`, or `pnpm test:watch`. Changes to migration SQL or the Compose configuration trigger a full watch rerun. ## Review snapshots ```sh pnpm test -pnpm snapshot:update tests/integration/login.test.ts +pnpm snapshot:update tests/integration/better-auth.test.ts git diff -- tests/integration/snapshots ``` Ordinary runs fail on missing or changed baselines and do not rewrite them. The update command serializes update processes for this checkout. Use unique, explicit paths for each test/case/checkpoint and the test-local `expect` in concurrent tests. -See [the code-login snapshots](tests/integration/snapshots/code) and [OAuth snapshots](tests/integration/snapshots/oauth): original HTML, derived Markdown, response headers including every cookie, ordered database rows, and grouped email metadata/plaintext/Markdown/HTML. Capture functions preserve meaningful timestamps and tokens. They replace only the supplied application's exact origin (including its encoded form inside OAuth redirects) and omit transport-generated HTTP fields such as `Date` and `Content-Length`. +See [the snapshots](tests/integration/snapshots): original HTML, derived Markdown, response cookies, ordered database rows, outgoing Stripe requests, and grouped email metadata/plaintext/Markdown/HTML. Capture functions preserve meaningful timestamps and tokens. They replace only the supplied application's exact origin (including its encoded form inside redirects) and omit transport-generated HTTP fields such as `Date` and `Content-Length`. The Markdown lens selects the last `.selfie` element, otherwise `main`, otherwise `body`; it excludes `.selfie-exclude`, scripts, styles and hidden inputs. It renders fields/buttons as readable text and retains links, images, tables, embedded-content links and preformatted text. Full HTML remains a separate facet. Query capture preserves row order: use `ORDER BY` when order matters. `pg` keeps numeric/decimal values as strings; bigint values are labeled rather than coerced to imprecise numbers. ## Change the schema +The `pgstencil` command ships in the core package. In this repository the `pnpm db:*` scripts run it from source; an application runs `pnpm exec pgstencil `. + ```sh pnpm db:migration:create add_profile # Edit the new SQL file's -- Up Migration section. @@ -93,26 +103,42 @@ pnpm test pnpm db:migrate ``` -The schema dump and generated declarations are committed review artifacts, not schema inputs. Each new migration fingerprint gets its own template; unchanged fingerprints reuse a ready template without rerunning initialization. Failed initializers are discarded, and interrupted initializers without a ready marker are rebuilt. Template cache identity includes SQL filenames/content, the Compose configuration and a format version. +`pgstencil.json` at the project root configures it: + +```json +{ + "migrations": [ + "node_modules/@pgstencil/auth/better-auth-migrations", + "node_modules/@pgstencil/stripe/migrations", + "migrations" + ], + "schema": "schema.sql", + "types": [{ "out": "src/db.generated.ts" }] +} +``` + +`migrations` is one directory or an ordered list, each inside the project root; it defaults to `migrations`. List a package's source by its installed path and your own directory last, since that is where `create` writes. `schema` names the schema dump. Each `types` entry generates Kysely declarations to `out`, for the tables matching `include` (default `public.*`), minus `exclude`, with `defaultSchema` set when the tables live outside `public`; `types` needs the optional peer `kysely-codegen`. No other key is accepted. The project root is the working directory, or `PGSTENCIL_PROJECT_ROOT`. + +The schema dump and generated declarations are committed review artifacts, not schema inputs, so editor typechecking works without Docker. Each new migration fingerprint gets its own template; unchanged fingerprints reuse a ready template without rerunning initialization. Failed initializers are discarded, and interrupted initializers without a ready marker are rebuilt. Template cache identity includes SQL filenames/content, the Compose configuration and a format version. -Persistent development/production databases record applied-file checksums. Editing or deleting applied SQL fails validation; add a new migration instead. Pending migrations run in one transaction through node-pg-migrate. Nontransactional changes such as `CREATE INDEX CONCURRENTLY` are intentionally outside this first runner's contract. SQL upgrade tests exercise existing data and rollback, as well as fresh initialization. +Persistent development/production databases record applied-file checksums. Editing or deleting applied SQL fails validation; add a new migration instead. Pending migrations run in one transaction through node-pg-migrate. Nontransactional changes such as `CREATE INDEX CONCURRENTLY` are intentionally outside this runner's contract. [PACKAGES.md](PACKAGES.md#application-composition) explains how several sources compose. -| Command | Result | -| --------------------------- | ------------------------------------------------------------------- | -| `pnpm db:status` | List applied and pending SQL without applying it | -| `pnpm db:validate` | Verify applied SQL contents | -| `pnpm db:migrate` | Apply pending SQL | -| `pnpm db:types [--verify]` | Generate or verify Kysely declarations from a disposable clone | -| `pnpm db:schema [--verify]` | Generate or verify the schema dump from a disposable clone | -| `pnpm db:verify` | Verify both generated artifacts using one disposable clone | -| `pnpm db:reset` | Explicitly recreate the development database | -| `pnpm db:gc` | Clear cached test templates/databases when idle | -| `pnpm db:stop` | Stop this checkout's services when idle; retain its Postgres volume | -| `pnpm check` | Formatting, typechecking and all tests | +| Command | `pnpm` script here | Result | +| ----------------------------- | --------------------- | ------------------------------------------------------------------- | +| `pgstencil status` | `db:status` | List applied and pending SQL without applying it | +| `pgstencil validate` | `db:validate` | Verify applied SQL contents | +| `pgstencil migrate` | `db:migrate` | Apply pending SQL | +| `pgstencil create ` | `db:migration:create` | Create the next numbered SQL file; `--in ` picks the source | +| `pgstencil types [--verify]` | `db:types` | Generate or verify Kysely declarations from a disposable clone | +| `pgstencil schema [--verify]` | `db:schema` | Generate or verify the schema dump from a disposable clone | +| `pgstencil verify` | `db:verify` | Verify both generated artifacts using one disposable clone | +| `pgstencil reset` | `db:reset` | Explicitly recreate the development database | +| `pgstencil gc` | `db:gc` | Clear cached test templates/databases when idle | +| `pgstencil stop` | `db:stop` | Stop this checkout's services when idle; retain its Postgres volume | -`DATABASE_URL` directs status/validate/migrate at an explicitly supplied persistent database. Otherwise they use `pgstencil_dev`. Schema/type commands always use a disposable migrated clone. Never point template management at a production cluster. +`DATABASE_URL` directs `status`, `validate` and `migrate` at an explicitly supplied persistent database. Otherwise they use `pgstencil_dev`. Schema/type commands always use a disposable migrated clone. Never point template management at a production cluster. `pnpm check` runs formatting, typechecking and all tests. -Local services run PostgreSQL 18. The 18 image keeps its data under `/var/lib/postgresql/18/docker` and refuses a volume holding a 17 cluster, so the volume is named `postgres-18-data` and an existing checkout starts a fresh development database. The old volume stays until removed with `docker volume rm _postgres-data`; `docker volume ls` lists it. +Local services run PostgreSQL 18. The 18 image keeps its data under `/var/lib/postgresql/18/docker` and refuses a volume holding a 17 cluster, so the volume is named `postgres-18-data`. A checkout that last ran PostgreSQL 17 starts a fresh development database; its old volume stays until removed with `docker volume rm _postgres-data`, and `docker volume ls` lists it. ## Resource lifecycle @@ -120,33 +146,25 @@ Each checkout has a Compose project name derived from its absolute path, its own Ordinary test runs retain the services and templates. Closing a fixture tells IntegreSQL to recreate its dirty clone. IntegreSQL's coordinator cache is in memory, so a coordinator restart rebuilds the template cache; the persistent development database survives `db:stop`. Cleanup commands refuse when they observe live application database connections. Run cleanup while test runners and development servers are stopped, never alongside a new allocation. -The configured pool supports 40 test databases per fingerprint, with Postgres capped at 200 connections and each Kysely pool capped at two. The suite exercises 20 simultaneous applications plus two independent Node processes. Tests assert unique ports/databases, deterministic identical outputs, isolated writes/time/randomness/email, and correct cleanup. `pnpm test --reporter=verbose --disableConsoleIntercept` exposes the concurrent fixture timing. +The configured pool supports 40 test databases per fingerprint, with Postgres capped at 200 connections and each Kysely pool capped at two. [The suite](tests/integration/parallel.test.ts) exercises 20 simultaneous applications plus two independent Node processes. Tests assert unique ports/databases, deterministic identical outputs, isolated writes/time/randomness/email, and correct cleanup. `pnpm test --reporter=verbose --disableConsoleIntercept` exposes the concurrent fixture timing. Docker must be running; pgstencil does not install or launch Docker Desktop. If initialization is interrupted, rerun the command. A stale process lock is removed when its owning PID is no longer alive. Do not delete `.pgstencil` while another process uses it. -## Original login example and production boundaries +## Authentication -The example implements browser-bound code/link challenges, confirmation POSTs that do not consume links during GET previews, 10-minute deadlines, five attempts per challenge, resend cooldowns, database-backed email/IP rate limits shared across instances, one-time atomic redemption, normalized email uniqueness, fixed 24-hour opaque sessions, session rotation/revocation, CSRF tokens, origin checks, and escaped HTML. +`@pgstencil/auth` wraps Better Auth with a fixed policy: eight-digit email codes, Google, Apple, Facebook, GitHub and Microsoft sign-in, 24-hour database sessions, explicit CSRF tokens, database-backed rate limits shared across instances, and explicit or same-email account linking. [PACKAGES.md](PACKAGES.md#better-auth-integration) documents the options, [the example](examples/better-auth/README.md) the HTTP protocol and provider registration, and [SECURITY.md](SECURITY.md) every guarantee with the test that pins it. -`examples/login/src/production.ts` composes `SystemTime` and `SecureRandom`, requires a public HTTPS origin and secret, uses `__Host-` Secure/HttpOnly/SameSite=Lax cookies, and omits inbox routes. It accepts an `EmailSender` implemented using a provider API such as Postmark; no real provider adapter or credentials are included. Deploy behind an HTTPS reverse proxy on the same host, apply migrations as a separate release step, and supply a high-entropy shared auth secret. The sample sender address is `signin@example.test`; configure your verified sender before real delivery. +Email is delivered through an injected `EmailSender`; `@pgstencil/auth/postmark` is the provided production adapter, and `emailFrom` and `renderEmail` set the sender and wording. Passkeys, MFA, account recovery beyond email sign-in, and retention of expired auth rows are the application's. -Native forms require an actual `Origin`; `strict-origin` referrer policy preserves it while omitting URL paths and link tokens. `no-referrer` can make form POSTs send `Origin: null`, as [MDN explains](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy). Keep access logs from recording token-bearing query strings. Forwarded IP headers are deliberately not trusted; configure rate limiting at a trusted proxy before internet deployment, since the sample sees its direct peer's address. - -Optional Google/GitHub login uses PKCE, browser-bound one-time attempts, verified provider identities, and explicit account linking. Pass `oauth: oauthFromEnvironment(process.env)` to the production wrapper to enable configured providers. [OAUTH.md](OAUTH.md) documents its security contract and remaining live-provider checks. +## Billing -This is an example authentication application; email sign-in remains an available recovery method for the stored address. Provider delivery retries, expired-row retention, account recovery policy, passkeys/MFA, distributed deployment configuration and npm release automation remain extension work. Test helpers and deterministic random sources are not production dependencies to inject. +`@pgstencil/stripe` provides a card-required SaaS trial, named plans, entitlement as a SQL view, a webhook ledger and reconciliation. The example mounts it behind its sign-in. See [BILLING.md](BILLING.md) for setup, entitlement rules, recovery and production configuration. ## Verification -GitHub Actions installs with the frozen lockfile on Node 24/Linux, verifies formatting, generated schema/types, typechecks, and runs the complete Docker suite and verifies an independent consumer of the packed packages before removing that job's containers and volumes. Local editor typechecking works without Docker because generated types are committed. - -[SECURITY.md](SECURITY.md) states what the packages guarantee, the test that pins each rule, and how to report a vulnerability privately. - -See [PLAN.md](PLAN.md) for architectural decisions and remaining expansion work, and [LOGIN_FLOW.md](LOGIN_FLOW.md) for the login contract. - -## Billing +GitHub Actions installs with the frozen lockfile on Linux under the pinned Node, verifies formatting and the generated schema and types, typechecks, runs the complete Docker suite, builds the Workers example, and verifies an independent consumer of the packed packages before removing that job's containers and volumes. -The example includes a card-required SaaS trial, monthly/yearly plans, and a local Stripe simulator. See [BILLING.md](BILLING.md) for setup, entitlement rules, recovery and production configuration. +[SECURITY.md](SECURITY.md) states what the packages guarantee, the test that pins each rule, and how to report a vulnerability privately. An automated audit runs those rules on every push to `main` and gates each npm release. ## License diff --git a/SECURITY.md b/SECURITY.md index 4061fbc..510d9d7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,8 @@ # pgstencil security -pgstencil ships `pgstencil`, `@pgstencil/auth` and `@pgstencil/stripe` as packed tarballs ([PACKAGES.md](PACKAGES.md)). This file states what the Better Auth integration in `@pgstencil/auth` and `pgstencil` itself guarantee to a consuming application, as conditions an auditor can check at one commit; each section ends with the tests that pin them. Bare names are under `packages/auth/src/`, `packages/auth/better-auth-migrations/` or `tests/`. +pgstencil ships `pgstencil`, `@pgstencil/auth` and `@pgstencil/stripe` as packed tarballs ([PACKAGES.md](PACKAGES.md)). This file states what the Better Auth integration in `@pgstencil/auth`, the `Billing` class in `@pgstencil/stripe`, and `pgstencil` itself guarantee to a consuming application, as conditions an auditor can check at one commit; each section ends with the tests that pin them. Bare names are under `packages/auth/src/`, `packages/auth/better-auth-migrations/`, `packages/stripe/migrations/` or `tests/`; in the billing sections a bare method or constant is a member of `Billing`, or an export, in `packages/stripe/src/index.ts`. -The application owns everything outside the packages: TLS and its origin gate, the CSP of its own pages, secret and credential storage, database provisioning, provider registration, and — only when it opts in with `ipAddressHeaders` — a proxy that overwrites those headers on every request. `@pgstencil/stripe` and the original code/link `Auth` exports carry no rules here yet. +The application owns everything outside the packages: TLS and its origin gate, the CSP of its own pages, secret and credential storage, database provisioning, provider registration, and — only when it opts in with `ipAddressHeaders` — a proxy that overwrites those headers on every request. For billing it also owns: authenticating the owner before every `Billing` call that takes an owner ID, and never taking that ID, a customer, Price, quantity or URL from the browser; CSRF and exact-origin checks on its billing routes; handing each webhook's raw body and `Stripe-Signature` header to `webhook` or `verifyWebhook` unaltered, under a body limit, and answering 2xx only after `webhook` resolves; storing the Stripe secret key and the webhook signing secret; and the Stripe account itself — its Prices, a customer portal that offers no Price outside `prices`, no quantity change and no second subscription, and a webhook endpoint created at `STRIPE_API_VERSION`. The example in `examples/better-auth/` shows one way to do this, exercised by `integration/billing-http.test.ts`. ## Sessions and cookies @@ -67,6 +67,38 @@ Pinned by `unit/diagnostics.test.ts`: `diagnostics allowlist drops secrets even Pinned by `integration/better-auth-workers.test.ts`: `normal Workers build uses real time and randomness and contains no test clock controls`; `integration/better-auth.test.ts`: `Better Auth email: repeatable cookies, database and email snapshots across parallel apps`, `Better Auth time: 23 hours, expiration boundary, isolated async contexts and unchanged host clock`. +## Stripe webhooks + +- **FAIL IF** `verifyWebhook` parses the body before `constructEventAsync` checks its signature against `webhookSecret`, passes a tolerance other than 300 seconds or a clock other than the injected `Time`, or returns an event whose `livemode` differs from `live`, whose `api_version` is not `STRIPE_API_VERSION`, or that carries a Connect `account`; or a refusal is anything but a `BillingError` with status 400, raised before `webhook` touches the ledger; inspect `verifyWebhook` and `webhook`. +- **FAIL IF** `webhook(event)` accepts an object that this same `Billing` instance's `verifyWebhook` did not return — a parsed body, a copy, or an event another instance verified; inspect `verified` in `webhook`. +- **FAIL IF** the ledger row, the owner's resync and `apply` do not share one transaction that marks the event processed only after `apply` returns; an event whose ledger row is already processed reaches the resync or `apply` again; concurrent deliveries of one event are not serialized on that row; or a failure — upstream, database or `apply` — commits any billing or application write instead of leaving the event recorded `failed` and unprocessed for a retry; inspect `webhook`. +- **FAIL IF** `pruneEvents` deletes an event that was never processed or is marked `failed`, deletes one processed within the retention it was given, measures that retention by anything but the injected `Time`, or accepts a retention under 30 days; inspect `pruneEvents`. +- **FAIL IF** anything from an event's body is written to `subscriptions`, `checkouts` or `accounts`, or its `created` time or arrival order decides a write. The body may only choose whose state to resync — the owner of the customer it names, or for a `charge.dispute.*` event the customer Stripe returns for its charge — and which row `context.subscription` reads back, filtered to that owner; the state comes from `synchronize`, which lists the customer's subscriptions from Stripe only after locking the owner's `accounts` row. An event naming a customer pgstencil did not create resyncs nobody; inspect `webhook`, `synchronize` and `ownerOfCustomer`. +- **FAIL IF** `completedCheckout` reports a Checkout other than a stored operation whose ID and session ID both match the event's and whose owner is the session's `client_reference_id`, or reports fields other than that stored operation's; inspect `completedCheckout`. +- **FAIL IF** a `billing.webhook.*` diagnostic names a `stripeEventId` from a delivery that failed verification, or carries anything beyond what `diagnostic` and `diagnosticError` in `packages/pgstencil/src/diagnostics.ts` allow — never the body, the `Stripe-Signature` header, the webhook secret, or the message of a Stripe, database or `apply` error; inspect `webhook`. + +Pinned by `integration/billing.test.ts`: `checkout ownership, card policy, signatures, mode and API version are enforced`, `pruneEvents deletes only events processed before the retention window`, `the five-minute tolerance reads the delivery signature: a retry signed hours later applies once, an old signature never`, `application fulfillment and the webhook ledger share one transaction`, `duplicate and out-of-order webhooks reconcile authoritative state exactly once`, `an upstream outage rolls back entitlement changes and a failed event can retry`, `a deleted webhook racing end() waits on the owner lock and reads its reason`, `owner and customer lookups, dispute owners through their charge, and an already-verified event`, `a checkout ref is validated, kept across retries, and read back from the completion event`, `payment diagnostics keep verified event IDs and report failure before recovery without payloads`; `integration/billing-security.test.ts`: `webhook(event) takes only an event this Billing verified, and a rejected delivery reaches neither the ledger nor apply`, `concurrent deliveries of one event run apply once and record one attempt`, `event content never grants access: the resync reads Stripe, and context and completions are matched to the owner`, `webhook diagnostics name an event only once it verifies, and never carry a body, signature, secret or message`; `integration/billing-workers.test.ts`: `Billing in workerd: fetch client, Web Crypto webhooks, founding offer and purchase counts`. + +## Checkout + +- **FAIL IF** a Checkout Session is created with a customer other than the owner's stored `customer_id`, a Price other than the one `offeredPrice` resolved for a configured plan name and stored on the operation, more than one line item, a quantity other than 1, a `success_url` other than `returnUrl` with `checkout=`, a `cancel_url` other than `returnUrl`, or caller-supplied metadata other than a `ref` matching `^[a-z0-9:-]{1,40}$` under `pgstencil_ref`; or for a Price `expectPrice` refused; or `portal` sends a `return_url` other than `returnUrl`; inspect `checkout`, `offeredPrice` and `portal`. +- **FAIL IF** the constructor accepts an `origin` that is not a bare origin, a `returnPath` that resolves off that origin, a `redirectOrigins` entry that is not a bare origin, an empty `webhookSecret`, or one Price under two plans; inspect the `Billing` constructor. +- **FAIL IF** a Checkout Session is created without `payment_method_collection: 'always'` and `allowed_payment_method_types: ['card']`, or with a trial but without `trial_settings.end_behavior.missing_payment_method: 'cancel'`; inspect `checkout`. +- **FAIL IF** an operation is stored with trial days while the owner's `trial_used_at` is set, `synchronize` lists a subscription carrying the owner's metadata without setting it, or anything clears it; inspect `checkout` and `synchronize`. +- **FAIL IF** an owner can hold two `pending` or `open` checkouts (`billing_one_checkout` in `003_billing.sql`), racing `checkout` calls open more than one Checkout Session, or `checkout` opens one while — after its own resync, under the owner's `accounts` row lock — the owner holds a subscription in a `SUBSCRIBED` status; inspect `checkout`. +- **FAIL IF** a customer or Checkout Session creation reaches Stripe before its idempotency key — the account's `customer_key`, or the operation's ID — is committed, a retry sends another key or a Price, trial, `ref` or expiry other than the stored operation's, or a creation first attempted 23 hours or more earlier is retried instead of refused; inspect `customer`, `checkout` and `assertRetryable`. +- **FAIL IF** `confirmCheckout` acts on an operation not stored for the calling owner, or on a Stripe session whose `client_reference_id`, customer or mode is not that owner's subscription Checkout, or grants anything except through `reconcile`; inspect `confirmCheckout`. +- **FAIL IF** `end` cancels a subscription that is not synchronized to the calling owner, or records a reason outside `^[a-z0-9:-]{1,40}$`; inspect `end` and `008_billing_end_reason.sql`. + +Pinned by `integration/billing.test.ts`: `the browser chooses only a configured plan name, never a price`, `yearly discount uses the configured price; canceled accounts cannot repeat a trial`, `checkout charges only a Price the application expects, and replace gives way to another plan`, `a checkout ref is validated, kept across retries, and read back from the completion event`, `yes-card trial: signup and an open checkout grant nothing; confirmed trial ends exactly on time`, `expired and canceled checkouts can be replaced without spending the trial`, `lost creation responses and concurrent clicks reuse durable idempotency keys`, `ambiguous customer creation beyond the retry window fails closed`, `checkout ownership, card policy, signatures, mode and API version are enforced`, `failed first payment revokes access and a successful retry restores it`, `a paused subscription grants nothing and blocks another checkout; resuming restores access`, `end() cancels at once without proration and records its reason, which the deleted webhook reads`; `integration/billing-security.test.ts`: `the server picks customer, price, quantity and return URLs, and the constructor refuses off-origin configuration`, `racing checkouts open one Checkout Session per owner, and none opens beside a held subscription`, `creation keys are committed before Stripe sees them, retries resend them, and an ambiguous Checkout past 23 hours fails closed`, `confirmCheckout acts only on the calling owner's own subscription Checkout`, `a subscription counts only with the owner's metadata, its customer, one item, a recognized price, quantity 1 and the configured livemode`. + +## Entitlement + +- **FAIL IF** `synchronize` writes a row for a subscription whose `pgstencil_owner` metadata is not the owner's ID; writes any row for an owner when one of its subscriptions carrying that metadata has another customer, other than exactly one item, a Price `prices` does not recognize, a quantity other than 1 or a `livemode` other than `live` — that must fail the whole resync; or leaves a row Stripe no longer lists for the owner in any status but `missing`; inspect `synchronize`. +- **FAIL IF** `status().access` comes from anything but an `owner_access` row whose `access_until` is later than the injected clock; `owner_access` yields a row for an owner holding no subscription, or more than one, in a `SUBSCRIBED` status, filters on statuses other than `SUBSCRIBED`, or gives an `access_until` to any status but `active` (its `period_end`) and `trialing` (its `trial_end`); or signing up, opening Checkout or a success URL grants access; inspect `status`, `SUBSCRIBED` and `007_billing_owner_access.sql`. + +Pinned by `integration/billing.test.ts`: `owner_access is the access rule status() applies, for application SQL under a read-only grant`, `yes-card trial: signup and an open checkout grant nothing; confirmed trial ends exactly on time`, `a retired price keeps granting its plan and is never offered`, `failed first payment revokes access and a successful retry restores it`, `a paused subscription grants nothing and blocks another checkout; resuming restores access`; `integration/billing-security.test.ts`: `a subscription counts only with the owner's metadata, its customer, one item, a recognized price, quantity 1 and the configured livemode`, `event content never grants access: the resync reads Stripe, and context and completions are matched to the owner`, `confirmCheckout acts only on the calling owner's own subscription Checkout`. + ## Packed provenance - **FAIL IF** `packages:pack` builds from a modified `packages`, `scripts`, `tsconfig.json`, `tsconfig.build.json`, `pnpm-lock.yaml`, `compose.yaml`, `LICENSE` or `package.json` without `--allow-dirty`; inspect `scripts/build-packages.ts`. @@ -99,4 +131,9 @@ Report privately through GitHub's [advisory form for diffplug/pgstencil](https:/ - A provider that asserts an email it did not verify, and account recovery after a lost provider account or mailbox. Facebook sends no verification claim, so pgstencil treats any address Facebook returns as verified. - Two applications sharing both a database and `AUTH_SECRET`: they share OAuth state, sessions and limits, and count as one application here. - Multi-factor authentication and passkeys, and abuse beyond the budgets above. -- `@pgstencil/stripe`, the original code/link `Auth` exports, and the example applications; see [BILLING.md](BILLING.md), [OAUTH.md](OAUTH.md) and [LOGIN_FLOW.md](LOGIN_FLOW.md). +- Stripe itself: its hosted Checkout and portal, its trial, renewal and dunning jobs, and the hosted URLs it returns, which `checkout` and `portal` hand back without comparing them with `redirectOrigins`. pgstencil reads subscriptions, not charges, so a refund or dispute changes no access until the subscription ends. +- Trials per person: eligibility is per billing owner, so one person holding several owners can start several trials. +- A subscription changed in Stripe outside the Entitlement rules — a second item, a quantity other than 1, a Price removed from `prices` — fails every resync for its owner. That keeps the owner's last synchronized rows, and whatever access they grant until their own `period_end` or `trial_end`, until the subscription or the configuration is repaired; alert on failed events. +- An attacker holding the webhook signing secret: billing state still comes from the resync, but `apply` runs on an event they wrote, so an `apply` that trusts the body beyond `context` and `completedCheckout` is the application's. Whoever holds the Stripe secret key controls the Stripe account. +- A delivery of an event whose ledger row has been deleted, as `pruneEvents` deletes old processed rows: it is processed again, `apply` included. +- The rest of `@pgstencil/stripe` — purchase counts, the `reconcileStale` and `reconcileAll` jobs, and the StripeDev simulator in `packages/stripe/src/testing.ts`, a test double — and the example applications; see [BILLING.md](BILLING.md). diff --git a/WORKERS.md b/WORKERS.md index f0dbd93..d9d19e1 100644 --- a/WORKERS.md +++ b/WORKERS.md @@ -1,73 +1,71 @@ # Hono and Cloudflare Workers -For new authentication integrations, use `@pgstencil/auth/better-auth-workers`. -[The package recipe](PACKAGES.md#better-auth-integration) covers Hono composition, -Postmark, SQL migrations, session policies and deterministic test bundles. -[The Better Auth example](examples/better-auth/README.md) documents the email and -OAuth protocol plus production acceptance checks. +`@pgstencil/auth/better-auth-workers` exports `createBetterAuthWorker`, a Hono +application that builds the auth app for each request from that request's +bindings: a Kysely/`pg` pool over the `HYPERDRIVE` connection string, opened and +closed inside the request, including on errors. No socket, clock or test control +crosses invocations. [The package recipe](PACKAGES.md#better-auth-integration) +covers its options, and [the example](examples/better-auth/README.md) the HTTP +protocol and provider registration. Docker, migrations, the CLI and snapshot +lenses remain Node-side tools and are excluded from a Worker bundle. -The adapter below is the original implementation, retained for staged upgrades. - -`@pgstencil/auth/fetch` owns the JSON routes, cookies and CSRF checks. -`@pgstencil/auth/hono` mounts that adapter in Hono; the existing `/http` export -bridges the same adapter to Node. `@pgstencil/auth/workers` composes a Hono app -with a request-scoped Kysely/pg connection through Hyperdrive. Connections close -in `finally`, including errors. Docker, migrations and snapshot lenses remain -Node-side tools and are excluded from the Worker bundle. - -See [the deployable example](examples/workers/src/index.ts) and its -[Wrangler configuration](examples/workers/wrangler.jsonc). Build without uploading: +See [the deployable entry](examples/better-auth/src/postmark-worker.ts) and its +[Wrangler configuration](examples/better-auth/wrangler.jsonc). Build without +uploading, and run the workerd tests: ```sh -pnpm --filter @pgstencil/example-workers build -pnpm test tests/integration/workers.test.ts +pnpm --filter @pgstencil/example-better-auth build +pnpm test tests/integration/better-auth-workers.test.ts tests/integration/billing-workers.test.ts ``` -The runtime tests execute bundled Hono/auth code inside workerd, with real -Postgres clones via local Hyperdrive bindings. EmailDev and OAuth HTTP fixtures -replace external services. They cover email login, exact 23/24-hour session -boundaries, Secure cookies, CSRF, Google/Apple/Facebook callbacks, browser binding -and replay. Node provider tests additionally verify signatures, claims, nonces, -PKCE where supported, provider failures and missing email. Test clock controls -exist only in the test entrypoint, never the deployable Worker. +The runtime tests execute the bundled application inside workerd, with real +Postgres clones through local Hyperdrive bindings. EmailDev and local OAuth +provider fixtures replace external services. They cover email sign-in, +deterministic replay, separate clocks per application, rate limits shared through +the database, safe 503s when setup fails, and every OAuth provider. Test clock +controls exist only in the test entrypoint; a test proves the normal build uses +real time and randomness and contains none. ## Deployment preparation 1. Provision an empty hosted Postgres database. Hyperdrive supplies pooling, - not database storage. Apply the auth SQL migrations from Node using the direct - database URL; migrations must finish before the new Worker serves traffic. + not database storage. Apply the SQL migrations from Node with + `DATABASE_URL= pnpm exec pgstencil migrate`; migrations must + finish before the new Worker serves traffic. 2. Create Hyperdrive with **query caching disabled** and replace the placeholder - ID. Auth reads must immediately observe challenges, sessions and revocations. + ID. Auth reads must immediately observe codes, sessions and revocations. Local Hyperdrive tests don't reproduce Cloudflare's remote pool or cache. 3. Set `APP_ORIGIN` to the exact HTTPS frontend origin and `EMAIL_FROM` to a - verified sending address. Mount the Worker on that same origin's `/api/auth/*`, - `/oauth/*` and `/login/link` paths; serve the SPA elsewhere. No cross-origin + verified sending address. Route that same origin's `/api/auth/*` and + `/api/providers` to the Worker; serve the frontend elsewhere. No cross-origin auth or CORS credentials are needed. 4. Store `AUTH_SECRET` (at least 32 random characters), `POSTMARK_SERVER_TOKEN`, and each provider's `*_CLIENT_ID` / `*_CLIENT_SECRET` as Wrangler secrets. - Apple needs a signed client-secret JWT, not a plain password. See [OAuth setup](OAUTH.md). + Apple needs a signed client-secret JWT, not a plain password; see + [provider registration](examples/better-auth/README.md#provider-registration). 5. Register the exact HTTPS callbacks, deploy, then verify real email delivery - and each provider's consent/callback flow in a browser. Automated mocks cannot - establish that dashboard configuration or actual delivery is correct. + and each provider's consent/callback flow in a browser. Automated fixtures + cannot establish that dashboard configuration or actual delivery is correct. The example deliberately contains placeholder domain and Hyperdrive settings; building it does not deploy anything. Secret files (`.env*`, `.dev.vars*`) and Wrangler state are ignored. Avoid logging callback queries, cookies, email codes or provider tokens. Cloudflare supplies the trusted `CF-Connecting-IP` header -used by the shared database rate limiter. +that the adapter always counts for rate limits. Hyperdrive [does not support advisory locks](https://developers.cloudflare.com/hyperdrive/reference/supported-databases-and-features/). -Runtime auth and Stripe serialization uses row locks; SQL migration tooling runs -directly against Postgres. OAuth first-use identity creation locks one of 64 -fixed rows, so arbitrary identities cannot grow a lock table without bound. +Runtime auth and Stripe serialization uses row locks and atomic upserts; SQL +migration tooling, which does take advisory locks, runs directly against +Postgres. + +## Billing on Workers Stripe webhook verification is asynchronous and uses Web Crypto. On Workers, construct the Stripe SDK with `httpClient: Stripe.createFetchHttpClient()` and -`await billing.webhook(rawBody, signature)`. `verifyWebhook()` is now asynchronous -too. The runnable Workers example covers authentication; -`tests/integration/billing-workers.test.ts` runs billing in workerd with the fetch -client, Web Crypto webhook verification and Hyperdrive. No billing code needs a -Node-only API at runtime; `@pgstencil/stripe/testing` and `/migrations` are Node-side. +`await billing.webhook(rawBody, signature)`. `tests/integration/billing-workers.test.ts` +runs billing in workerd with the fetch client, Web Crypto webhook verification +and Hyperdrive. No billing code needs a Node-only API at runtime; +`@pgstencil/stripe/testing` and `/migrations` are Node-side. For deployment details, use Cloudflare's [Hyperdrive setup](https://developers.cloudflare.com/hyperdrive/get-started/) and [query caching documentation](https://developers.cloudflare.com/hyperdrive/concepts/query-caching/). diff --git a/examples/better-auth/README.md b/examples/better-auth/README.md index 402776c..98b0ecf 100644 --- a/examples/better-auth/README.md +++ b/examples/better-auth/README.md @@ -1,23 +1,50 @@ # Better Auth with pgstencil -Better Auth 1.7.3 handles email-code login and Google, Apple, Facebook, GitHub and Microsoft +[Better Auth](https://better-auth.com), pinned by `@pgstencil/auth` to the exact release its CI tested, handles email-code login and Google, Apple, Facebook, GitHub and Microsoft OAuth. pgstencil owns SQL migrations, Docker/IntegreSQL clones, email capture, -security policy and deterministic tests. The reusable exports live in `@pgstencil/auth`; see [package consumption](../../PACKAGES.md#better-auth-integration). This example is isolated from the old -auth implementation and from TTR production. +security policy and deterministic tests. The reusable exports live in `@pgstencil/auth`; see [package consumption](../../PACKAGES.md#better-auth-integration). With Docker running: ```sh -pnpm dev:better-auth -pnpm test:better-auth +pnpm dev +pnpm test ``` Open `http://127.0.0.1:8082`, enter any test email and read its eight-digit code at -`/dev/emails`. Development uses real time/randomness and a disposable database. +`/dev/emails`. Development uses real time, a fresh random seed (override it with +`PGSTENCIL_SEED`) and the persistent `pgstencil_dev` database, migrated from +`src/migrations.ts` on start; `pnpm db:reset` recreates it. `PORT=0` chooses a free port. Stop with Ctrl-C. OAuth buttons appear only for configured providers; set paired `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`, -`APPLE_CLIENT_ID`/`APPLE_CLIENT_SECRET`, `FACEBOOK_CLIENT_ID`/`FACEBOOK_CLIENT_SECRET` -and/or `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in the process environment. +`APPLE_CLIENT_ID`/`APPLE_CLIENT_SECRET`, `FACEBOOK_CLIENT_ID`/`FACEBOOK_CLIENT_SECRET`, +`GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` and/or `MICROSOFT_CLIENT_ID`/`MICROSOFT_CLIENT_SECRET` +in the process environment or `.env`. + +## Billing demo + +`createEmailApp({ ..., billing: { stripe, config, time, random } })` adds a +card-required trial on `@pgstencil/stripe`; without `billing` the example only +signs in. The billing owner is the signed-in user's ID. `pnpm dev` runs it +against the local StripeDev simulator, whose state persists in +`.pgstencil/stripe-dev.json` and whose webhooks go to this server. `/billing` is +a small page over these JSON routes: + +| Route | Result | +| ----------------------------------- | --------------------------------------------------------------------------- | +| `GET /api/billing` | Ensures the account, confirms `?checkout=` or reconciles; status | +| `POST /api/billing/checkout` | `{ "plan": "monthly" }` → `{ "url" }` of a Checkout Session | +| `POST /api/billing/portal` | `{ "url" }` of a billing portal session | +| `POST /api/billing/cancel-checkout` | Expires the open Checkout; status | +| `POST /api/stripe/webhook` | Raw signed body and `stripe-signature`; 200, or a `BillingError`'s status | + +Every route except the webhook needs a live session (401 otherwise). The POSTs +also need `Origin` to equal the application origin exactly (403) and +`content-type: application/json` (415). An unknown plan is 400 and a checkout +operation the user does not own is 404. The server chooses the customer, prices +and return URLs; only the plan name is read from a request body. Checkout gets +the user's email only when it is verified, so a provider-only account's +internal address never reaches Stripe. ## Security policy @@ -33,8 +60,8 @@ and/or `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in the process environment. - Only the implemented API operations are exposed. Responses omit upstream session tokens, use no-store, and have security headers. CSP permits the local external script; it does not require unsafe-inline. -- `sessionPolicy: 'single'` signs out all other devices on successful login (TTR). - `'multiple'` is the default and retains independent sessions (Dormouse). +- `sessionPolicy: 'single'` signs out all other devices on successful login. + `'multiple'` is the default and retains independent sessions. A Postgres trigger serializes session creation per user. Logout revokes the current session. Sessions last 24 hours with refresh and cookie caching disabled. - Better Auth stores native session tokens in the database. A token alone cannot @@ -80,6 +107,12 @@ Better Auth accepts a replayed session cookie at exactly 24 hours and rejects it one millisecond later. Tests explicitly replay historical cookies independently of the browser clock. +`src/worker.ts` sends email through an `EMAIL` service binding, which the Workers +tests replace. `src/postmark-worker.ts` is the deployable entry in `wrangler.jsonc`, +sending through Postmark with `POSTMARK_SERVER_TOKEN` and `EMAIL_FROM`; +`pnpm --filter @pgstencil/example-better-auth build` bundles it with +`wrangler deploy --dry-run`. Replace the placeholder origin and Hyperdrive ID before deploying. + SQL is generated for review and committed, never migrated during requests. Background runtime schema inspection is disabled because it races request-scoped Worker pool teardown. Tests verify the committed schema against Better Auth's @@ -87,25 +120,51 @@ migration plan. The Worker uses a Hyperdrive binding and an EMAIL service bindin tests replace EMAIL with in-memory capture. Hosted Hyperdrive/Neon and real provider configuration still need the first candidate-deployment smoke test. -## First production smoke test - -Register these callback URLs with each enabled provider, using the candidate's -stable public origin: +## Provider registration + +Each provider needs both of its `*_CLIENT_ID` and `*_CLIENT_SECRET` values; a +provider with neither is disabled, and one with only half fails startup without +printing secrets. Register the exact callback, including scheme, hostname and +port: `localhost` and `127.0.0.1` are different origins. Use separate +registrations for local and deployed environments. + +| Provider | Callback | +| --------- | ---------------------------------------------- | +| Google | `https:///api/auth/callback/google` | +| Apple | `https:///api/auth/callback/apple` | +| Facebook | `https:///api/auth/callback/facebook` | +| GitHub | `https:///api/auth/callback/github` | +| Microsoft | `https:///api/auth/callback/microsoft` | + +- **Google**: create a **Web application** OAuth client, configure the consent + screen and any test users, and add the callback under authorized redirect URIs. +- **GitHub**: create an **OAuth App** with the callback as its authorization + callback URL. The verified primary email is read from the authenticated + email API, so it can be private. +- **Apple**: configure a web Services ID with the callback as its return URL. + `APPLE_CLIENT_ID` is that Services ID; `APPLE_CLIENT_SECRET` is an ES256 JWT + signed with your Apple key, which expires after at most six months and must be + replaced before then. Apple's web flow needs a registered HTTPS domain, not a + localhost URL. Register your sending domain with Apple's private email relay + if users may choose Hide My Email. +- **Facebook**: enable Facebook Login for the Meta app and request `email`. An + app in development mode only admits its own test accounts and roles. +- **Microsoft**: see [Microsoft](#microsoft) below. + +For local OAuth, copy `.env.example` to `.env` at the repository root, fill in +the providers you want, and keep `PORT` fixed so the origin you registered is +the one `pnpm dev` binds. -| Provider | Callback | -| -------- | --------------------------------------------- | -| Google | `https:///api/auth/callback/google` | -| Apple | `https:///api/auth/callback/apple` | -| Facebook | `https:///api/auth/callback/facebook` | -| GitHub | `https:///api/auth/callback/github` | +## First production smoke test -Existing Supabase client IDs/secrets can be reused when the provider configuration -allows the new callback. Apple uses a Services ID and an unexpired client-secret -JWT; its private relay also requires the mail sender to be registered with Apple. -Enter secrets directly into deployment tooling, not chat, source files or logs. -Do not delete Supabase/Pages until email and each required provider pass in a real -browser. The TTR test must also confirm that a second device's login signs out -the first, and that its chosen linking policy preserves account IDs across login methods. +Automated tests use local provider fixtures. They cannot establish that a +dashboard registration, a consent screen, real email delivery or a deployed +TLS/proxy path is correct. Before relying on a deployment, complete email +sign-in and each enabled provider's sign-in, cancellation, account connection +and sign-out in a real browser against the registered origin. Check that the +session policy you chose behaves as intended across two devices, and that the +linking policy keeps one account ID across sign-in methods. Enter secrets +directly into deployment tooling, never into chat, source files or logs. ## Microsoft @@ -129,8 +188,8 @@ See [Microsoft's claim reference](https://learn.microsoft.com/en-us/entra/identi and [Better Auth's provider setup](https://better-auth.com/docs/authentication/microsoft). Microsoft signing keys are loaded with JOSE's remote JWKS resolver because -Microsoft's public RSA keys omit the optional `alg` member. Better Auth 1.7.3's -Microsoft key importer requires that member and otherwise throws. pgstencil pins +Microsoft's public RSA keys omit the optional `alg` member. Better Auth's +Microsoft key importer required that member and otherwise threw when this was written. pgstencil pins RS256 and the Microsoft key endpoint, retaining signature, nonce, audience, issuer, expiry and tenant/object identity checks. Local Microsoft fixtures also omit `alg` so this production key shape is exercised in Node and Workers tests. diff --git a/examples/better-auth/package.json b/examples/better-auth/package.json index 7398766..e89dfbb 100644 --- a/examples/better-auth/package.json +++ b/examples/better-auth/package.json @@ -2,14 +2,19 @@ "name": "@pgstencil/example-better-auth", "private": true, "type": "module", + "scripts": { + "build": "wrangler deploy --dry-run --outdir dist" + }, "dependencies": { "@hono/node-server": "2.1.6", + "@pgstencil/auth": "workspace:*", + "@pgstencil/stripe": "workspace:*", "better-auth": "1.7.18", "hono": "^4.13.7", "kysely": "^0.29.5", "pg": "^8.16.0", "pgstencil": "workspace:*", - "@pgstencil/auth": "workspace:*" + "stripe": "^23.1.0" }, "devDependencies": { "node-pg-migrate": "^9.0.0", diff --git a/examples/login/schema.sql b/examples/better-auth/schema.sql similarity index 57% rename from examples/login/schema.sql rename to examples/better-auth/schema.sql index 21c7057..4c14aad 100644 --- a/examples/login/schema.sql +++ b/examples/better-auth/schema.sql @@ -24,6 +24,23 @@ SET row_security = off; CREATE SCHEMA pgstencil_billing; +-- +-- Name: pgstencil_session_policy(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.pgstencil_session_policy() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + PERFORM id FROM "user" WHERE id = NEW."userId" FOR UPDATE; + IF NEW."singleSession" THEN + DELETE FROM "session" WHERE "userId" = NEW."userId"; + END IF; + RETURN NEW; +END; +$$; + + SET default_tablespace = ''; SET default_table_access_method = heap; @@ -128,78 +145,23 @@ CREATE VIEW pgstencil_billing.owner_access AS -- --- Name: login_challenges; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.login_challenges ( - id text NOT NULL, - flow_id text NOT NULL, - email text NOT NULL, - code_digest text NOT NULL, - link_hash text NOT NULL, - created_at timestamp with time zone NOT NULL, - expires_at timestamp with time zone NOT NULL, - consumed_at timestamp with time zone, - invalidated_at timestamp with time zone, - delivered_at timestamp with time zone, - attempts integer DEFAULT 0 NOT NULL, - CONSTRAINT login_challenges_attempts_check CHECK ((attempts >= 0)) -); - - --- --- Name: login_flows; Type: TABLE; Schema: public; Owner: - +-- Name: account; Type: TABLE; Schema: public; Owner: - -- -CREATE TABLE public.login_flows ( +CREATE TABLE public.account ( id text NOT NULL, - binding_hash text NOT NULL, - csrf_hash text NOT NULL, - email text, - created_at timestamp with time zone NOT NULL, - expires_at timestamp with time zone NOT NULL -); - - --- --- Name: oauth_flows; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.oauth_flows ( - state_hash text NOT NULL, - provider text NOT NULL, - browser_hash text NOT NULL, - redirect_uri text NOT NULL, - link_user_id text, - link_session_hash text, - created_at timestamp with time zone NOT NULL, - expires_at timestamp with time zone NOT NULL, - consumed_at timestamp with time zone, - CONSTRAINT oauth_flows_check CHECK (((link_user_id IS NULL) = (link_session_hash IS NULL))), - CONSTRAINT oauth_flows_provider_check CHECK ((provider = ANY (ARRAY['google'::text, 'github'::text, 'apple'::text, 'facebook'::text]))) -); - - --- --- Name: oauth_identities; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.oauth_identities ( - provider text NOT NULL, - subject text NOT NULL, - user_id text NOT NULL, - created_at timestamp with time zone NOT NULL, - CONSTRAINT oauth_identities_provider_check CHECK ((provider = ANY (ARRAY['google'::text, 'github'::text, 'apple'::text, 'facebook'::text]))) -); - - --- --- Name: oauth_locks; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.oauth_locks ( - id integer NOT NULL, - CONSTRAINT oauth_locks_id_check CHECK (((id >= 0) AND (id < 64))) + "accountId" text NOT NULL, + "providerId" text NOT NULL, + "userId" text NOT NULL, + "accessToken" text, + "refreshToken" text, + "idToken" text, + "accessTokenExpiresAt" timestamp with time zone, + "refreshTokenExpiresAt" timestamp with time zone, + scope text, + password text, + "createdAt" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp with time zone NOT NULL ); @@ -234,6 +196,17 @@ CREATE SEQUENCE public.pgmigrations_id_seq ALTER SEQUENCE public.pgmigrations_id_seq OWNED BY public.pgmigrations.id; +-- +-- Name: pgstencil_auth_limits; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.pgstencil_auth_limits ( + key text NOT NULL, + count integer NOT NULL, + started_at timestamp with time zone NOT NULL +); + + -- -- Name: pgstencil_migration_files; Type: TABLE; Schema: public; Owner: - -- @@ -245,39 +218,71 @@ CREATE TABLE public.pgstencil_migration_files ( -- --- Name: rate_limits; Type: TABLE; Schema: public; Owner: - +-- Name: pgstencil_oauth_claims; Type: TABLE; Schema: public; Owner: - -- -CREATE TABLE public.rate_limits ( +CREATE TABLE public.pgstencil_oauth_claims ( + key text NOT NULL, + expires_at timestamp with time zone NOT NULL +); + + +-- +-- Name: rateLimit; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."rateLimit" ( + id text NOT NULL, key text NOT NULL, - window_start timestamp with time zone NOT NULL, count integer NOT NULL, - CONSTRAINT rate_limits_count_check CHECK ((count >= 0)) + "lastRequest" bigint NOT NULL ); -- --- Name: sessions; Type: TABLE; Schema: public; Owner: - +-- Name: session; Type: TABLE; Schema: public; Owner: - -- -CREATE TABLE public.sessions ( - token_hash text NOT NULL, - user_id text NOT NULL, - csrf_hash text NOT NULL, - created_at timestamp with time zone NOT NULL, - expires_at timestamp with time zone NOT NULL, - revoked_at timestamp with time zone +CREATE TABLE public.session ( + id text NOT NULL, + "expiresAt" timestamp with time zone NOT NULL, + token text NOT NULL, + "createdAt" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp with time zone NOT NULL, + "ipAddress" text, + "userAgent" text, + "userId" text NOT NULL, + "singleSession" boolean DEFAULT false NOT NULL, + "emailAuthenticated" boolean DEFAULT false NOT NULL ); -- --- Name: users; Type: TABLE; Schema: public; Owner: - +-- Name: user; Type: TABLE; Schema: public; Owner: - -- -CREATE TABLE public.users ( +CREATE TABLE public."user" ( id text NOT NULL, + name text NOT NULL, email text NOT NULL, - created_at timestamp with time zone NOT NULL + "emailVerified" boolean NOT NULL, + image text, + "createdAt" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: verification; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.verification ( + id text NOT NULL, + identifier text NOT NULL, + value text NOT NULL, + "expiresAt" timestamp with time zone NOT NULL, + "createdAt" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL ); @@ -337,99 +342,99 @@ ALTER TABLE ONLY pgstencil_billing.subscriptions -- --- Name: login_challenges login_challenges_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- Name: account account_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.login_challenges - ADD CONSTRAINT login_challenges_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public.account + ADD CONSTRAINT account_pkey PRIMARY KEY (id); -- --- Name: login_flows login_flows_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- Name: pgmigrations pgmigrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.login_flows - ADD CONSTRAINT login_flows_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public.pgmigrations + ADD CONSTRAINT pgmigrations_pkey PRIMARY KEY (id); -- --- Name: oauth_flows oauth_flows_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- Name: pgstencil_auth_limits pgstencil_auth_limits_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.oauth_flows - ADD CONSTRAINT oauth_flows_pkey PRIMARY KEY (state_hash); +ALTER TABLE ONLY public.pgstencil_auth_limits + ADD CONSTRAINT pgstencil_auth_limits_pkey PRIMARY KEY (key); -- --- Name: oauth_identities oauth_identities_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- Name: pgstencil_migration_files pgstencil_migration_files_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.oauth_identities - ADD CONSTRAINT oauth_identities_pkey PRIMARY KEY (provider, subject); +ALTER TABLE ONLY public.pgstencil_migration_files + ADD CONSTRAINT pgstencil_migration_files_pkey PRIMARY KEY (name); -- --- Name: oauth_identities oauth_identities_user_id_provider_key; Type: CONSTRAINT; Schema: public; Owner: - +-- Name: pgstencil_oauth_claims pgstencil_oauth_claims_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.oauth_identities - ADD CONSTRAINT oauth_identities_user_id_provider_key UNIQUE (user_id, provider); +ALTER TABLE ONLY public.pgstencil_oauth_claims + ADD CONSTRAINT pgstencil_oauth_claims_pkey PRIMARY KEY (key); -- --- Name: oauth_locks oauth_locks_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- Name: rateLimit rateLimit_key_key; Type: CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.oauth_locks - ADD CONSTRAINT oauth_locks_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public."rateLimit" + ADD CONSTRAINT "rateLimit_key_key" UNIQUE (key); -- --- Name: pgmigrations pgmigrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- Name: rateLimit rateLimit_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.pgmigrations - ADD CONSTRAINT pgmigrations_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public."rateLimit" + ADD CONSTRAINT "rateLimit_pkey" PRIMARY KEY (id); -- --- Name: pgstencil_migration_files pgstencil_migration_files_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- Name: session session_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.pgstencil_migration_files - ADD CONSTRAINT pgstencil_migration_files_pkey PRIMARY KEY (name); +ALTER TABLE ONLY public.session + ADD CONSTRAINT session_pkey PRIMARY KEY (id); -- --- Name: rate_limits rate_limits_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- Name: session session_token_key; Type: CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.rate_limits - ADD CONSTRAINT rate_limits_pkey PRIMARY KEY (key); +ALTER TABLE ONLY public.session + ADD CONSTRAINT session_token_key UNIQUE (token); -- --- Name: sessions sessions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- Name: user user_email_key; Type: CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.sessions - ADD CONSTRAINT sessions_pkey PRIMARY KEY (token_hash); +ALTER TABLE ONLY public."user" + ADD CONSTRAINT user_email_key UNIQUE (email); -- --- Name: users users_email_key; Type: CONSTRAINT; Schema: public; Owner: - +-- Name: user user_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.users - ADD CONSTRAINT users_email_key UNIQUE (email); +ALTER TABLE ONLY public."user" + ADD CONSTRAINT user_pkey PRIMARY KEY (id); -- --- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- Name: verification verification_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.users - ADD CONSTRAINT users_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public.verification + ADD CONSTRAINT verification_pkey PRIMARY KEY (id); -- @@ -454,73 +459,84 @@ CREATE INDEX billing_subscription_price ON pgstencil_billing.subscriptions USING -- --- Name: login_challenges_flow; Type: INDEX; Schema: public; Owner: - +-- Name: account_provider_identity; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX login_challenges_flow ON public.login_challenges USING btree (flow_id, created_at); +CREATE UNIQUE INDEX account_provider_identity ON public.account USING btree ("providerId", "accountId"); -- --- Name: oauth_flows_expiry; Type: INDEX; Schema: public; Owner: - +-- Name: account_userId_idx; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX oauth_flows_expiry ON public.oauth_flows USING btree (expires_at); +CREATE INDEX "account_userId_idx" ON public.account USING btree ("userId"); -- --- Name: checkouts checkouts_owner_id_fkey; Type: FK CONSTRAINT; Schema: pgstencil_billing; Owner: - +-- Name: pgstencil_auth_limit_expiry; Type: INDEX; Schema: public; Owner: - -- -ALTER TABLE ONLY pgstencil_billing.checkouts - ADD CONSTRAINT checkouts_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES pgstencil_billing.accounts(owner_id); +CREATE INDEX pgstencil_auth_limit_expiry ON public.pgstencil_auth_limits USING btree (started_at); -- --- Name: subscriptions subscriptions_owner_id_fkey; Type: FK CONSTRAINT; Schema: pgstencil_billing; Owner: - +-- Name: pgstencil_oauth_claim_expiry; Type: INDEX; Schema: public; Owner: - -- -ALTER TABLE ONLY pgstencil_billing.subscriptions - ADD CONSTRAINT subscriptions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES pgstencil_billing.accounts(owner_id); +CREATE INDEX pgstencil_oauth_claim_expiry ON public.pgstencil_oauth_claims USING btree (expires_at); -- --- Name: login_challenges login_challenges_flow_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- Name: session_userId_idx; Type: INDEX; Schema: public; Owner: - -- -ALTER TABLE ONLY public.login_challenges - ADD CONSTRAINT login_challenges_flow_id_fkey FOREIGN KEY (flow_id) REFERENCES public.login_flows(id) ON DELETE CASCADE; +CREATE INDEX "session_userId_idx" ON public.session USING btree ("userId"); -- --- Name: oauth_flows oauth_flows_link_session_hash_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- Name: verification_identifier_idx; Type: INDEX; Schema: public; Owner: - -- -ALTER TABLE ONLY public.oauth_flows - ADD CONSTRAINT oauth_flows_link_session_hash_fkey FOREIGN KEY (link_session_hash) REFERENCES public.sessions(token_hash); +CREATE INDEX verification_identifier_idx ON public.verification USING btree (identifier); -- --- Name: oauth_flows oauth_flows_link_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- Name: session pgstencil_session_policy; Type: TRIGGER; Schema: public; Owner: - -- -ALTER TABLE ONLY public.oauth_flows - ADD CONSTRAINT oauth_flows_link_user_id_fkey FOREIGN KEY (link_user_id) REFERENCES public.users(id); +CREATE TRIGGER pgstencil_session_policy BEFORE INSERT ON public.session FOR EACH ROW EXECUTE FUNCTION public.pgstencil_session_policy(); + + +-- +-- Name: checkouts checkouts_owner_id_fkey; Type: FK CONSTRAINT; Schema: pgstencil_billing; Owner: - +-- + +ALTER TABLE ONLY pgstencil_billing.checkouts + ADD CONSTRAINT checkouts_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES pgstencil_billing.accounts(owner_id); + + +-- +-- Name: subscriptions subscriptions_owner_id_fkey; Type: FK CONSTRAINT; Schema: pgstencil_billing; Owner: - +-- + +ALTER TABLE ONLY pgstencil_billing.subscriptions + ADD CONSTRAINT subscriptions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES pgstencil_billing.accounts(owner_id); -- --- Name: oauth_identities oauth_identities_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- Name: account account_userId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.oauth_identities - ADD CONSTRAINT oauth_identities_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); +ALTER TABLE ONLY public.account + ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES public."user"(id) ON DELETE CASCADE; -- --- Name: sessions sessions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- Name: session session_userId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.sessions - ADD CONSTRAINT sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); +ALTER TABLE ONLY public.session + ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES public."user"(id) ON DELETE CASCADE; -- diff --git a/examples/better-auth/src/auth.ts b/examples/better-auth/src/auth.ts index d43333e..f59c131 100644 --- a/examples/better-auth/src/auth.ts +++ b/examples/better-auth/src/auth.ts @@ -1,7 +1,13 @@ -import { createAuthApp } from '@pgstencil/auth/better-auth'; -export { authOptions } from '@pgstencil/auth/better-auth'; -export function createEmailApp(options: Parameters[0]) { +import { createAuthApp, type AuthAppOptions } from '@pgstencil/auth'; +import { addBillingRoutes, type BillingOptions } from './billing.ts'; +export { authOptions } from '@pgstencil/auth'; +export type ExampleOptions = AuthAppOptions & { + /** Adds the billing demo; without it the example only signs in. */ + billing?: BillingOptions; +}; +export function createEmailApp({ billing, ...options }: ExampleOptions) { const result = createAuthApp(options); + if (billing) addBillingRoutes(result, options.origin, billing); result.app.get('/auth.js', (c) => c.body(loginScript, 200, { 'content-type': 'text/javascript; charset=utf-8', diff --git a/examples/better-auth/src/billing.ts b/examples/better-auth/src/billing.ts new file mode 100644 index 0000000..3de7991 --- /dev/null +++ b/examples/better-auth/src/billing.ts @@ -0,0 +1,187 @@ +import type { Context, Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import type { Kysely } from 'kysely'; +import type { RandomSource, Time } from 'pgstencil'; +import { + Billing, + BillingError, + type BillingConfig, + type BillingDB, + type Stripe, +} from '@pgstencil/stripe'; +import type { createAuthApp } from '@pgstencil/auth'; + +/** Optional billing for the example: the server owns customer, prices and return URLs. */ +export interface BillingOptions { + stripe: Stripe; + config: Omit; + time: Time; + random: RandomSource; +} + +const JSON_BODY_BYTES = 8192; +const WEBHOOK_BODY_BYTES = 1024 * 1024; + +/** + * Billing routes on top of `createAuthApp`, keyed by the signed-in user's ID. + * Every route except the Stripe webhook needs a live session; the POSTs also + * need this exact Origin and a JSON body, so a cross-site form cannot reach them. + */ +export function addBillingRoutes( + auth: ReturnType, + origin: string, + options: BillingOptions, +) { + // Billing qualifies every table with its own schema, so it shares the pool. + const billing = new Billing( + auth.db as unknown as Kysely, + options.stripe, + options.time, + options.random, + { ...options.config, origin }, + ); + const app: Hono = auth.app; + const failure = (c: Context, error: unknown) => + error instanceof BillingError + ? c.json({ message: error.message }, error.status as 400) + : c.json( + { message: 'Billing unavailable. Please try again shortly.' }, + 503, + ); + /** The billing owner and the email Checkout should prefill, or null when signed out. */ + const owner = async (c: Context) => { + const session = await auth.auth.api.getSession({ + headers: c.req.raw.headers, + }); + if (!session) return null; + const { id, email, emailVerified } = session.user; + // A provider-only account's internal address is unverified and never shared. + return { id, email: emailVerified ? email : null }; + }; + app.get('/billing', (c) => c.html(billingHtml)); + app.get('/billing.js', (c) => + c.body(billingScript, 200, { + 'content-type': 'text/javascript; charset=utf-8', + }), + ); + app.get('/api/billing', async (c) => { + const user = await owner(c); + if (!user) return c.json({ message: 'Sign in first' }, 401); + try { + await billing.account(user.id, user.email); + const operation = c.req.query('checkout'); + if (operation) await billing.confirmCheckout(user.id, operation); + else await billing.reconcile(user.id); + return c.json(await billing.status(user.id)); + } catch (error) { + return failure(c, error); + } + }); + app.use('/api/billing/*', bodyLimit({ maxSize: JSON_BODY_BYTES })); + app.use('/api/billing/*', async (c, next) => { + if (c.req.method !== 'POST') return next(); + if (c.req.header('origin') !== origin) + return c.json({ message: 'Request not accepted' }, 403); + if ( + c.req.header('content-type')?.split(';')[0]?.trim().toLowerCase() !== + 'application/json' + ) + return c.json({ message: 'JSON required' }, 415); + return next(); + }); + app.post('/api/billing/checkout', async (c) => { + const user = await owner(c); + if (!user) return c.json({ message: 'Sign in first' }, 401); + const body: unknown = await c.req.json().catch(() => null); + const plan = (body as { plan?: unknown } | null)?.plan; + if (typeof plan !== 'string') + return c.json({ message: 'Choose a plan' }, 400); + try { + return c.json({ + url: (await billing.checkout(user.id, user.email, plan)).url, + }); + } catch (error) { + return failure(c, error); + } + }); + app.post('/api/billing/portal', async (c) => { + const user = await owner(c); + if (!user) return c.json({ message: 'Sign in first' }, 401); + try { + return c.json({ url: await billing.portal(user.id) }); + } catch (error) { + return failure(c, error); + } + }); + app.post('/api/billing/cancel-checkout', async (c) => { + const user = await owner(c); + if (!user) return c.json({ message: 'Sign in first' }, 401); + try { + await billing.cancelCheckout(user.id); + return c.json(await billing.status(user.id)); + } catch (error) { + return failure(c, error); + } + }); + app.post( + '/api/stripe/webhook', + bodyLimit({ + maxSize: WEBHOOK_BODY_BYTES, + onError: (c) => c.body(null, 413), + }), + async (c) => { + // Reject before reading: an unsigned request never reaches the parser. + const signature = c.req.header('stripe-signature'); + if (!signature) return c.body(null, 400); + try { + // The raw bytes: a re-serialized body would no longer match its signature. + await billing.webhook( + Buffer.from(await c.req.arrayBuffer()), + signature, + ); + return c.body(null, 200); + } catch (error) { + return c.body( + null, + error instanceof BillingError ? (error.status as 400) : 503, + ); + } + }, + ); + return billing; +} + +const billingHtml = ` +Billing +

Billing

Loading…

+ + +

Back to sign in

+`; + +const billingScript = ` +const status = document.querySelector('#status'), plans = document.querySelector('#plans'); +const portal = document.querySelector('#portal'), cancel = document.querySelector('#cancel'); +const post = async (path, body = {}) => { + const response = await fetch('/api/billing/' + path, {method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify(body)}); + const data = await response.json(); + if (!response.ok) throw new Error(data.message || 'Request failed'); + return data; +}; +const show = (data) => { + const sub = data.subscription; + status.textContent = data.access + ? 'Plan: ' + data.plan + ' (' + sub.status + ') until ' + new Date(data.accessUntil).toLocaleDateString() + : data.trialDays ? 'Start a ' + data.trialDays + '-day trial; provide a card at checkout.' : 'Choose a plan.'; + plans.hidden = data.access; portal.hidden = !sub; cancel.hidden = data.access; +}; +const act = (action) => async (event) => { try { await action(event); } catch (error) { status.textContent = error.message; } }; +for (const button of plans.querySelectorAll('button')) + button.onclick = act(async () => location.assign((await post('checkout', {plan: button.dataset.plan})).url)); +portal.onclick = act(async () => location.assign((await post('portal')).url)); +cancel.onclick = act(async () => show(await post('cancel-checkout'))); +const response = await fetch('/api/billing' + location.search); +const data = await response.json(); +if (response.status === 401) status.textContent = 'Sign in first.'; +else if (!response.ok) status.textContent = data.message; +else { show(data); if (location.search) history.replaceState(null, '', '/billing'); }`; diff --git a/examples/better-auth/src/dev.ts b/examples/better-auth/src/dev.ts index 716e801..ba8e2f8 100644 --- a/examples/better-auth/src/dev.ts +++ b/examples/better-auth/src/dev.ts @@ -1,70 +1,98 @@ -import { betterAuthMigrations } from '@pgstencil/auth/better-auth-migrations'; -import { EmailDev, SystemTime } from 'pgstencil'; -import { allocateDatabase } from 'pgstencil/database'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { DevRandom, EmailDev, SystemTime } from 'pgstencil'; +import { developmentDatabase, stateDirectory } from 'pgstencil/database'; +import { createStripeDev } from '@pgstencil/stripe/testing'; +import { html } from 'hono/html'; import { createEmailApp } from './auth.ts'; +import { appMigrations } from './migrations.ts'; import { listen } from './node.ts'; -import { html } from 'hono/html'; import { oauthFromEnvironment } from './oauth.ts'; -// A disposable lease keeps this experiment separate from the existing dev database. -const database = await allocateDatabase(betterAuthMigrations); -const email = new EmailDev(new SystemTime()); +const port = Number(process.env.PORT ?? 8082); +if (!Number.isInteger(port) || port < 0 || port > 65535) + throw new Error('PORT must be an integer from 0 to 65535'); +// Better Auth reads the real clock, so billing and the inbox use it too. +const time = new SystemTime(); +// The development database survives restarts; a fresh seed avoids reusing IDs. +const random = new DevRandom(process.env.PGSTENCIL_SEED ?? randomUUID()); +const databaseUrl = await developmentDatabase(true, appMigrations); +const email = new EmailDev(time); +const stripeDev = await createStripeDev( + time, + random, + join(stateDirectory, 'stripe-dev.json'), +); let app: ReturnType | undefined; -const server = await listen( - async (request, env) => { - if (new URL(request.url).pathname === '/dev/emails') - return new Response( - await html` - - Local inbox -
-

Local inbox

- ${email.all().map( - (mail) => - html`
-

${mail.subject}

-

${mail.to.join(', ')}

-
${mail.text}
-
`, - )}Back to sign in -
- `, - { - headers: { - 'content-type': 'text/html; charset=utf-8', - 'cache-control': 'no-store', - 'content-security-policy': - "default-src 'none'; frame-ancestors 'none'; base-uri 'none'", - }, +const server = await listen(async (request, env) => { + if (new URL(request.url).pathname === '/dev/emails') + return new Response( + await html` + + Local inbox +
+

Local inbox

+ ${email.all().map( + (mail) => + html`
+

${mail.subject}

+

${mail.to.join(', ')}

+
${mail.text}
+
`, + )}Back to sign in +
+ `, + { + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + 'content-security-policy': + "default-src 'none'; frame-ancestors 'none'; base-uri 'none'", }, - ); - return app - ? app.app.fetch(request, env) - : new Response('Starting', { status: 503 }); - }, - Number(process.env.PORT ?? 8082), -); + }, + ); + return app + ? app.app.fetch(request, env) + : new Response('Starting', { status: 503 }); +}, port); app = createEmailApp({ - databaseUrl: database.url, + databaseUrl, email, oauth: oauthFromEnvironment(process.env), sessionPolicy: process.env.SESSION_POLICY === 'single' ? 'single' : 'multiple', origin: server.origin, secret: 'better-auth-local-development-secret-only', + billing: { + stripe: stripeDev.stripe, + time, + random, + config: { + prices: stripeDev.prices, + trialDays: 14, + webhookSecret: stripeDev.webhookSecret, + live: false, + // Checkout is served by the local simulator. + redirectOrigins: [stripeDev.origin], + }, + }, }); +stripeDev.setWebhookTarget(`${server.origin}/api/stripe/webhook`); console.log( - `Better Auth: ${server.origin}\nLocal email: ${server.origin}/dev/emails\nDisposable database; restarting starts fresh.`, + `pgstencil: ${server.origin}\nLocal inbox: ${server.origin}/dev/emails\nBilling: ${server.origin}/billing`, ); for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => { void (async () => { await server.close(); await app?.close(); + await stripeDev.close(); email.close(); - await database.close(); - })().catch((error: unknown) => { - console.error(error); - process.exitCode = 1; - }); + })().then( + () => process.exit(0), + (error: unknown) => { + console.error(error); + process.exit(1); + }, + ); }); diff --git a/examples/better-auth/src/migrations.ts b/examples/better-auth/src/migrations.ts new file mode 100644 index 0000000..c4772eb --- /dev/null +++ b/examples/better-auth/src/migrations.ts @@ -0,0 +1,4 @@ +import { betterAuthMigrations } from '@pgstencil/auth/better-auth-migrations'; +import { billingMigrations } from '@pgstencil/stripe/migrations'; +/** Every SQL source this example needs, in dependency order. */ +export const appMigrations = [betterAuthMigrations, billingMigrations]; diff --git a/examples/better-auth/src/postmark-worker.ts b/examples/better-auth/src/postmark-worker.ts new file mode 100644 index 0000000..83db194 --- /dev/null +++ b/examples/better-auth/src/postmark-worker.ts @@ -0,0 +1,20 @@ +import { + createBetterAuthWorker, + type BetterAuthWorkerBindings, +} from '@pgstencil/auth/better-auth-workers'; +import { postmarkEmail } from '@pgstencil/auth/postmark'; + +/** + * The deployable Workers entry (`wrangler.jsonc`). `worker.ts` is the same + * application with an EMAIL service binding, which the Workers tests replace. + */ +interface Env extends BetterAuthWorkerBindings { + POSTMARK_SERVER_TOKEN: string; + EMAIL_FROM: string; +} +export default createBetterAuthWorker({ + email: (env) => postmarkEmail(env.POSTMARK_SERVER_TOKEN, env.EMAIL_FROM), + // Postmark sends from its own `from` argument; this keeps a renderer's input + // and any other transport on the same address. + emailFrom: (env) => env.EMAIL_FROM, +}); diff --git a/examples/workers/wrangler.jsonc b/examples/better-auth/wrangler.jsonc similarity index 83% rename from examples/workers/wrangler.jsonc rename to examples/better-auth/wrangler.jsonc index a7c01da..64439bc 100644 --- a/examples/workers/wrangler.jsonc +++ b/examples/better-auth/wrangler.jsonc @@ -1,7 +1,7 @@ { "$schema": "../../node_modules/wrangler/config-schema.json", - "name": "pgstencil-auth-example", - "main": "src/index.ts", + "name": "pgstencil-better-auth-example", + "main": "src/postmark-worker.ts", "compatibility_date": "2026-09-08", "compatibility_flags": ["nodejs_compat"], "workers_dev": false, diff --git a/examples/login/.impeccable/design.json b/examples/login/.impeccable/design.json deleted file mode 100644 index 458f671..0000000 --- a/examples/login/.impeccable/design.json +++ /dev/null @@ -1,337 +0,0 @@ -{ - "schemaVersion": 2, - "generatedAt": "2026-09-06T19:09:06.641438+00:00", - "title": "Design System: pgstencil login example", - "extensions": { - "colorMeta": { - "paper": { - "role": "neutral", - "displayName": "Paper", - "tonalRamp": [ - "oklch(15.00% 0.00671 97.35)", - "oklch(26.43% 0.00671 97.35)", - "oklch(37.86% 0.00671 97.35)", - "oklch(49.29% 0.00671 97.35)", - "oklch(60.71% 0.00671 97.35)", - "oklch(72.14% 0.00671 97.35)", - "oklch(83.57% 0.00671 97.35)", - "oklch(95.00% 0.00671 97.35)" - ] - }, - "ink": { - "role": "neutral", - "displayName": "Ink", - "tonalRamp": [ - "oklch(15.00% 0.03974 164.07)", - "oklch(26.43% 0.03974 164.07)", - "oklch(37.86% 0.03974 164.07)", - "oklch(49.29% 0.03974 164.07)", - "oklch(60.71% 0.03974 164.07)", - "oklch(72.14% 0.03974 164.07)", - "oklch(83.57% 0.03974 164.07)", - "oklch(95.00% 0.03974 164.07)" - ] - }, - "muted": { - "role": "neutral", - "displayName": "Muted", - "tonalRamp": [ - "oklch(15.00% 0.02903 155.98)", - "oklch(26.43% 0.02903 155.98)", - "oklch(37.86% 0.02903 155.98)", - "oklch(49.29% 0.02903 155.98)", - "oklch(60.71% 0.02903 155.98)", - "oklch(72.14% 0.02903 155.98)", - "oklch(83.57% 0.02903 155.98)", - "oklch(95.00% 0.02903 155.98)" - ] - }, - "line": { - "role": "neutral", - "displayName": "Line", - "tonalRamp": [ - "oklch(15.00% 0.01680 142.55)", - "oklch(26.43% 0.01680 142.55)", - "oklch(37.86% 0.01680 142.55)", - "oklch(49.29% 0.01680 142.55)", - "oklch(60.71% 0.01680 142.55)", - "oklch(72.14% 0.01680 142.55)", - "oklch(83.57% 0.01680 142.55)", - "oklch(95.00% 0.01680 142.55)" - ] - }, - "accent": { - "role": "primary", - "displayName": "Accent", - "tonalRamp": [ - "oklch(15.00% 0.07591 160.49)", - "oklch(26.43% 0.07591 160.49)", - "oklch(37.86% 0.07591 160.49)", - "oklch(49.29% 0.07591 160.49)", - "oklch(60.71% 0.07591 160.49)", - "oklch(72.14% 0.07591 160.49)", - "oklch(83.57% 0.07591 160.49)", - "oklch(95.00% 0.07591 160.49)" - ] - }, - "control-surface": { - "role": "neutral", - "displayName": "Control Surface", - "tonalRamp": [ - "oklch(15.00% 0.00537 95.10)", - "oklch(26.43% 0.00537 95.10)", - "oklch(37.86% 0.00537 95.10)", - "oklch(49.29% 0.00537 95.10)", - "oklch(60.71% 0.00537 95.10)", - "oklch(72.14% 0.00537 95.10)", - "oklch(83.57% 0.00537 95.10)", - "oklch(95.00% 0.00537 95.10)" - ] - }, - "control-border": { - "role": "neutral", - "displayName": "Control Border", - "tonalRamp": [ - "oklch(15.00% 0.02707 156.40)", - "oklch(26.43% 0.02707 156.40)", - "oklch(37.86% 0.02707 156.40)", - "oklch(49.29% 0.02707 156.40)", - "oklch(60.71% 0.02707 156.40)", - "oklch(72.14% 0.02707 156.40)", - "oklch(83.57% 0.02707 156.40)", - "oklch(95.00% 0.02707 156.40)" - ] - }, - "placeholder": { - "role": "neutral", - "displayName": "Placeholder", - "tonalRamp": [ - "oklch(15.00% 0.02267 154.51)", - "oklch(26.43% 0.02267 154.51)", - "oklch(37.86% 0.02267 154.51)", - "oklch(49.29% 0.02267 154.51)", - "oklch(60.71% 0.02267 154.51)", - "oklch(72.14% 0.02267 154.51)", - "oklch(83.57% 0.02267 154.51)", - "oklch(95.00% 0.02267 154.51)" - ] - }, - "focus": { - "role": "neutral", - "displayName": "Focus", - "tonalRamp": [ - "oklch(15.00% 0.07680 156.74)", - "oklch(26.43% 0.07680 156.74)", - "oklch(37.86% 0.07680 156.74)", - "oklch(49.29% 0.07680 156.74)", - "oklch(60.71% 0.07680 156.74)", - "oklch(72.14% 0.07680 156.74)", - "oklch(83.57% 0.07680 156.74)", - "oklch(95.00% 0.07680 156.74)" - ] - }, - "on-accent": { - "role": "neutral", - "displayName": "On Accent", - "tonalRamp": [ - "oklch(15.00% 0.00000 89.88)", - "oklch(26.43% 0.00000 89.88)", - "oklch(37.86% 0.00000 89.88)", - "oklch(49.29% 0.00000 89.88)", - "oklch(60.71% 0.00000 89.88)", - "oklch(72.14% 0.00000 89.88)", - "oklch(83.57% 0.00000 89.88)", - "oklch(95.00% 0.00000 89.88)" - ] - }, - "accent-hover": { - "role": "primary", - "displayName": "Accent Hover", - "tonalRamp": [ - "oklch(15.00% 0.06754 158.95)", - "oklch(26.43% 0.06754 158.95)", - "oklch(37.86% 0.06754 158.95)", - "oklch(49.29% 0.06754 158.95)", - "oklch(60.71% 0.06754 158.95)", - "oklch(72.14% 0.06754 158.95)", - "oklch(83.57% 0.06754 158.95)", - "oklch(95.00% 0.06754 158.95)" - ] - }, - "accent-active": { - "role": "primary", - "displayName": "Accent Active", - "tonalRamp": [ - "oklch(15.00% 0.06242 158.09)", - "oklch(26.43% 0.06242 158.09)", - "oklch(37.86% 0.06242 158.09)", - "oklch(49.29% 0.06242 158.09)", - "oklch(60.71% 0.06242 158.09)", - "oklch(72.14% 0.06242 158.09)", - "oklch(83.57% 0.06242 158.09)", - "oklch(95.00% 0.06242 158.09)" - ] - }, - "disabled": { - "role": "neutral", - "displayName": "Disabled", - "tonalRamp": [ - "oklch(15.00% 0.03349 157.24)", - "oklch(26.43% 0.03349 157.24)", - "oklch(37.86% 0.03349 157.24)", - "oklch(49.29% 0.03349 157.24)", - "oklch(60.71% 0.03349 157.24)", - "oklch(72.14% 0.03349 157.24)", - "oklch(83.57% 0.03349 157.24)", - "oklch(95.00% 0.03349 157.24)" - ] - }, - "error-surface": { - "role": "neutral", - "displayName": "Error Surface", - "tonalRamp": [ - "oklch(15.00% 0.01763 39.40)", - "oklch(26.43% 0.01763 39.40)", - "oklch(37.86% 0.01763 39.40)", - "oklch(49.29% 0.01763 39.40)", - "oklch(60.71% 0.01763 39.40)", - "oklch(72.14% 0.01763 39.40)", - "oklch(83.57% 0.01763 39.40)", - "oklch(95.00% 0.01763 39.40)" - ] - }, - "error-border": { - "role": "neutral", - "displayName": "Error Border", - "tonalRamp": [ - "oklch(15.00% 0.09568 33.10)", - "oklch(26.43% 0.09568 33.10)", - "oklch(37.86% 0.09568 33.10)", - "oklch(49.29% 0.09568 33.10)", - "oklch(60.71% 0.09568 33.10)", - "oklch(72.14% 0.09568 33.10)", - "oklch(83.57% 0.09568 33.10)", - "oklch(95.00% 0.09568 33.10)" - ] - }, - "error-ink": { - "role": "neutral", - "displayName": "Error Ink", - "tonalRamp": [ - "oklch(15.00% 0.10827 32.52)", - "oklch(26.43% 0.10827 32.52)", - "oklch(37.86% 0.10827 32.52)", - "oklch(49.29% 0.10827 32.52)", - "oklch(60.71% 0.10827 32.52)", - "oklch(72.14% 0.10827 32.52)", - "oklch(83.57% 0.10827 32.52)", - "oklch(95.00% 0.10827 32.52)" - ] - } - }, - "typographyMeta": { - "body": { - "displayName": "Body", - "purpose": "Instructions and field text." - }, - "label": { - "displayName": "Label", - "purpose": "Persistent field labels." - }, - "button": { - "displayName": "Button", - "purpose": "Primary form actions." - }, - "note": { - "displayName": "Note", - "purpose": "Recovery, expiry and supporting copy." - } - }, - "shadows": [], - "motion": [], - "breakpoints": [ - { - "name": "compact", - "value": "560px" - } - ] - }, - "components": [ - { - "name": "Primary button", - "kind": "button", - "refersTo": "button-primary", - "description": "Full-width form submission with keyboard focus and interaction states.", - "html": "", - "css": ".ds-primary{display:flex;align-items:center;justify-content:space-between;width:100%;margin-top:20px;padding:14px 18px;border:0;border-radius:6px;color:#fff;background:var(--accent,#245e43);font:600 16px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;cursor:pointer}.ds-primary:hover{background:#194a32}.ds-primary:active{background:#123e28}.ds-primary:disabled{background:#667c6e;cursor:wait}.ds-primary:focus-visible{outline:3px solid #4a8060;outline-offset:4px;}" - }, - { - "name": "Recovery button", - "kind": "button", - "refersTo": "button-text", - "description": "Underlined secondary action used for resending a code.", - "html": "", - "css": ".ds-text{display:flex;align-items:center;justify-content:space-between;width:100%;padding:0;margin:0;border:0;border-radius:0;background:none;color:var(--accent,#245e43);font:400 14px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;text-decoration:underline;text-underline-offset:4px;cursor:pointer}.ds-text:hover{background:none;color:#123e28}.ds-text:active{background:#123e28}.ds-text:focus-visible{outline:3px solid #4a8060;outline-offset:4px;}" - }, - { - "name": "Email field", - "kind": "input", - "refersTo": "input", - "description": "Persistent label and native email input.", - "html": "", - "css": ".ds-label{display:block;color:var(--ink,#203b2f);font:600 17px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;margin-bottom:8px}.ds-input{width:100%;background:#fffefa;color:var(--ink,#203b2f);font:400 17px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;padding:14px 16px;border:1px solid #84968a;border-radius:6px}.ds-input::placeholder{color:#68766c}.ds-input:focus-visible{outline:3px solid #4a8060;outline-offset:4px;}" - }, - { - "name": "Recovery navigation", - "kind": "nav", - "description": "Wrapping row of resend and alternate-email actions.", - "html": "
Use another email
", - "css": ".ds-recovery{margin-top:28px;display:flex;flex-wrap:wrap;justify-content:space-between;gap:16px;font:400 14px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif}.ds-resend{padding:0;margin:0;border:0;border-radius:0;background:none;font:inherit;cursor:pointer}.ds-resend,.ds-link{color:var(--accent,#245e43);text-decoration:underline;text-underline-offset:4px}.ds-resend:hover,.ds-link:hover{color:#123e28}.ds-resend:focus-visible,.ds-link:focus-visible{outline:3px solid #4a8060;outline-offset:4px;}" - }, - { - "name": "Error message", - "kind": "custom", - "refersTo": "error", - "description": "Explicit form failure announced as an alert.", - "html": "

That code is invalid or has expired.

", - "css": ".ds-error{padding:14px 16px;border:1px solid #bf7666;border-radius:6px;background:#fff0eb;color:#792e20;font:400 17px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif}" - } - ], - "narrative": { - "northStar": "Warm paper and forest native forms", - "overview": "This small operational interface uses quiet surfaces, readable labels and familiar HTML controls. Forest green identifies actions; warm paper carries the page. Instructions and recovery links remain close to the task.\n\nThis record describes the implemented example in `src/style.css`, `src/views.ts` and `src/app.ts`, bounded to `examples/login`. Its direction contract is identified by seed `93f1c640`. The finish reviewer returned **ship**; mobile screenshots were unavailable, so responsive behavior below is verified from source only.", - "keyCharacteristics": [ - "Restrained forest accent on warm paper.", - "A narrow, single-column task area.", - "Persistent labels, explicit errors and visible recovery.", - "Native forms that work without client JavaScript." - ], - "rules": [ - { - "name": "The Action Accent Rule", - "body": "Use forest green for actionable controls and links; keep supporting copy in muted ink.", - "section": "colors" - }, - { - "name": "The Visible Label Rule", - "body": "Keep a persistent label above each field; placeholder text supplies an example only.", - "section": "typography" - }, - { - "name": "The Flat Surface Rule", - "body": "Use surface tone and borders for separation, preserving the existing flat form language.", - "section": "elevation" - } - ], - "dos": [ - "Do preserve native form submission, explicit field labels and visible keyboard focus.", - "Do keep recovery actions close to the failed or pending task.", - "Do allow long email addresses and recovery actions to wrap." - ], - "donts": [ - "Don't replace visible labels with placeholders.", - "Don't use color alone to communicate errors.", - "Don't require client JavaScript for the sign-in flow." - ] - } -} diff --git a/examples/login/DESIGN.md b/examples/login/DESIGN.md deleted file mode 100644 index c3af5f1..0000000 --- a/examples/login/DESIGN.md +++ /dev/null @@ -1,171 +0,0 @@ ---- -name: pgstencil login example -description: Native email sign-in forms on warm paper with forest controls. -colors: - paper: '#f6f5f0' - ink: '#203b2f' - muted: '#526458' - line: '#c7d0c6' - accent: '#245e43' - control-surface: '#fffefa' - control-border: '#84968a' - placeholder: '#68766c' - focus: '#4a8060' - on-accent: '#ffffff' - accent-hover: '#194a32' - accent-active: '#123e28' - disabled: '#667c6e' - error-surface: '#fff0eb' - error-border: '#bf7666' - error-ink: '#792e20' -typography: - body: - fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" - fontSize: '17px' - fontWeight: 400 - lineHeight: 1.6 - label: - fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" - fontSize: '17px' - fontWeight: 600 - lineHeight: 1.6 - button: - fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" - fontSize: '16px' - fontWeight: 600 - lineHeight: 1.5 - note: - fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" - fontSize: '14px' - fontWeight: 400 - lineHeight: 1.6 -rounded: - control: '6px' -spacing: - label-gap: '8px' - control-block: '14px' - inline-gap: '16px' - action-gap: '20px' - section-gap: '24px' - outer-gap: '36px' -components: - button-primary: - backgroundColor: '{colors.accent}' - textColor: '{colors.on-accent}' - typography: '{typography.button}' - rounded: '{rounded.control}' - padding: '14px 18px' - width: '100%' - button-primary-hover: - backgroundColor: '{colors.accent-hover}' - button-primary-active: - backgroundColor: '{colors.accent-active}' - button-primary-disabled: - backgroundColor: '{colors.disabled}' - button-text: - backgroundColor: 'transparent' - textColor: '{colors.accent}' - padding: '0' - input: - backgroundColor: '{colors.control-surface}' - textColor: '{colors.ink}' - typography: '{typography.body}' - rounded: '{rounded.control}' - padding: '14px 16px' - width: '100%' - error: - backgroundColor: '{colors.error-surface}' - textColor: '{colors.error-ink}' - rounded: '{rounded.control}' - padding: '14px 16px' ---- - -# Design System: pgstencil login example - -## Overview - -**Creative North Star: "Warm paper and forest native forms"** - -This small operational interface uses quiet surfaces, readable labels and familiar HTML controls. Forest green identifies actions; warm paper carries the page. Instructions and recovery links remain close to the task. - -This record describes the implemented example in `src/style.css`, `src/views.ts` and `src/app.ts`, bounded to `examples/login`. Its direction contract is identified by seed `93f1c640`. The finish reviewer returned **ship**; mobile screenshots were unavailable, so responsive behavior below is verified from source only. - -**Key Characteristics:** - -- Restrained forest accent on warm paper. -- A narrow, single-column task area. -- Persistent labels, explicit errors and visible recovery. -- Native forms that work without client JavaScript. - -## Colors - -The restrained palette combines green-tinted neutrals with a single forest action accent. - -### Primary - -- **Forest accent:** primary buttons, links and the wordmark's punctuation; darker states distinguish interaction. -- **Focus green:** a shared outline for keyboard interaction. - -### Neutral - -- **Warm paper:** page background; **control surface:** slightly lighter input fill. -- **Forest ink:** main text; **muted green:** supporting copy and metadata. -- **Soft green line:** header, footer and inbox dividers; **control border:** stronger field boundary. -- **On-accent white:** primary button text; **placeholder:** subdued input examples. - -Error surface, border and ink form a separate semantic treatment used with explicit error copy. Disabled controls use a muted green state. - -**The Action Accent Rule.** Use forest green for actionable controls and links; keep supporting copy in muted ink. - -## Typography - -Body, labels and controls share the platform UI sans-serif stack. The compact hierarchy moves from supporting copy to body text and semibold labels; buttons use a slightly smaller semibold size. No external fonts are loaded. - -The current page heading uses Georgia at `clamp(38px, 6vw, 54px)`, regular weight, with `1.12` line height. This installed display face is an observed craft-floor exception, deliberately excluded from normative typography tokens. The code input uses spaced monospace digits as a specialized entry treatment. - -**The Visible Label Rule.** Keep a persistent label above each field; placeholder text supplies an example only. - -## Layout - -The task column is centered, capped at 480px, with 24px side clearance. Header and footer share a 1120px maximum width. Main content begins with a heading and instruction, followed by the form and relevant recovery actions. Repeated gaps separate labels, controls, actions and notes. - -At widths up to 560px, header and footer padding reduces to 20px by 24px; main margins reduce to 54px above and 64px below. The footer stacks its text, and recovery actions wrap. Long email addresses wrap anywhere. These are source-defined behaviors, not a claim of mobile screenshot verification. - -## Elevation & Depth - -There are no shadows, gradients or animated transitions. Tonal input fills and thin dividers separate regions. Keyboard focus uses a 3px solid outline with a 4px offset. - -**The Flat Surface Rule.** Use surface tone and borders for separation, preserving the existing flat form language. - -## Shapes - -Fields, primary buttons and error messages share softly rounded control corners. Inputs and errors have a 1px border; primary buttons have no border. Recovery buttons retain flat, underlined text treatment. - -## Components - -### Buttons - -Primary actions span the form width, with semibold white text on forest green. Hover and active states darken the background; disabled styling changes the fill and cursor. Text buttons use underlined accent text and share link hover behavior. All actions expose keyboard focus. - -Optional Google/GitHub actions use full-width bordered controls with a light surface and forest ink below the email form. A fieldset legend labels the alternatives. Account pages use the same controls for connecting providers and plain text for already connected methods. These are native POST forms with shared focus styling; no external logos or scripts are loaded. - -### Inputs / Fields - -Visible labels precede full-width light fields. Email input uses native email semantics and autocomplete. Code entry remains a single text field with numeric input hints, one-time-code autocomplete and an associated expiry note. Preserve leading zeroes and pasted spacing. - -### Navigation - -The header links the pgstencil wordmark to sign-in. A smaller Local inbox link appears only in development. Recovery links remain underlined and wrap alongside the resend form when space narrows. - -### Errors and account details - -Errors use a bordered warm red message with `role="alert"`, ahead of the relevant form. Recovery remains available after failed verification or delivery. Account metadata uses a definition list with muted terms and wrapping values. - -## Do's and Don'ts - -- **Do** preserve native form submission, explicit field labels and visible keyboard focus. -- **Do** keep recovery actions close to the failed or pending task. -- **Do** allow long email addresses and recovery actions to wrap. -- **Don't** replace visible labels with placeholders. -- **Don't** use color alone to communicate errors. -- **Don't** require client JavaScript for the sign-in flow. diff --git a/examples/login/package.json b/examples/login/package.json deleted file mode 100644 index 8d1cac9..0000000 --- a/examples/login/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@pgstencil/login-example", - "version": "0.1.0", - "private": true, - "type": "module", - "dependencies": { - "@pgstencil/stripe": "workspace:*", - "kysely": "^0.29.5", - "pg": "^8.16.0", - "pgstencil": "workspace:*", - "@pgstencil/auth": "workspace:*", - "hono": "^4.13.7", - "stripe": "^23.1.0" - }, - "devDependencies": { - "node-pg-migrate": "^9.0.0", - "testcontainers": "^12.2.1" - } -} diff --git a/examples/login/src/app.ts b/examples/login/src/app.ts deleted file mode 100644 index d4827ce..0000000 --- a/examples/login/src/app.ts +++ /dev/null @@ -1,659 +0,0 @@ -import { appleCallbackLocation } from '@pgstencil/auth/fetch'; -import { - createServer, - type IncomingMessage, - type ServerResponse, -} from 'node:http'; -import { readFile } from 'node:fs/promises'; -import { once } from 'node:events'; -import { connectDatabase } from 'pgstencil/postgres'; -import { - type Time, - type RandomSource, - type EmailSender, - type EmailDev, -} from 'pgstencil'; -import { Auth, SESSION_MS } from './auth.ts'; -import type { DB } from './db.generated.ts'; -import { - cookieValues, - sessionCookie, - normalizeEmail, - digest, - equalDigest, -} from './security.ts'; -import { - page, - loginPage, - codePage, - confirmPage, - accountPage, - sendFailurePage, - errorMessage, -} from './views.ts'; -import { devInboxRoutes } from './dev-inbox.ts'; -import { - Billing, - BillingError, - type BillingConfig, - type BillingDB, - type Stripe, -} from '@pgstencil/stripe'; -import type { Kysely } from 'kysely'; -import { billingPage } from './billing-view.ts'; -import { OAuth } from './oauth.ts'; -import { - OAuthProviders, - PROVIDER_LABELS, - PROVIDER_ORIGINS, - type OAuthSettings, - type OAuthFetch, -} from './oauth-providers.ts'; -export interface AppConfig { - databaseUrl: string; - time: Time; - random: RandomSource; - email: EmailSender; - secret: string; - development?: boolean; - publicOrigin?: string; - secureCookies?: boolean; - port?: number; - /** Supplying the capture inbox mounts /dev/emails; production simply cannot. */ - devInbox?: EmailDev; - oauth?: OAuthSettings; - /** Optional transport seam for local protocol tests; no provider credentials are faked by default. */ - oauthFetch?: OAuthFetch; - billing?: { - stripe: Stripe; - config: Omit; - }; -} -const MAX_BODY_BYTES = 8192; -const WEBHOOK_BODY_BYTES = 1024 * 1024; -/** Collects a capped request body, or undefined when the cap is exceeded. */ -async function readBody( - req: IncomingMessage, - limit: number, -): Promise { - const chunks: Buffer[] = []; - let size = 0; - for await (const chunk of req) { - size += (chunk as Buffer).length; - if (size > limit) return undefined; - chunks.push(Buffer.from(chunk)); - } - return Buffer.concat(chunks); -} -const OAUTH_ROUTE = /^\/oauth\/([^/]+)\/(start|connect|callback)$/; -type OAuthAction = 'start' | 'connect' | 'callback'; -// Every instance serves the same stylesheet: read it once per process. -let stylesheet: Promise | undefined; -export async function startApp(config: AppConfig) { - const development = config.development ?? false; - const secure = config.secureCookies ?? !development; - if ( - config.publicOrigin && - new URL(config.publicOrigin).origin !== config.publicOrigin - ) - throw new Error( - 'publicOrigin must contain only scheme, host and optional port', - ); - if (!development && (!secure || !config.publicOrigin?.startsWith('https://'))) - throw new Error( - 'Production requires HTTPS publicOrigin and Secure cookies', - ); - const css = await (stylesheet ??= readFile( - new URL('./style.css', import.meta.url), - 'utf8', - )); - const db = connectDatabase(config.databaseUrl); - const server = createServer(); - // Bind before building anything that needs the origin, so no component has - // to observe a half-built app through a late-filled thunk. - try { - server.listen(config.port ?? 0, '127.0.0.1'); - await once(server, 'listening'); - } catch (error) { - await db.destroy(); - throw error; - } - try { - const address = server.address(); - if (!address || typeof address === 'string') - throw new Error('No server address'); - const localOrigin = `http://127.0.0.1:${address.port}`; - const origin = config.publicOrigin ?? localOrigin; - const auth = new Auth({ - db, - time: config.time, - random: config.random, - email: config.email, - secret: config.secret, - origin, - }); - // Both services share the pool; Billing qualifies all its tables with its schema. - const billing = config.billing - ? new Billing( - db as unknown as Kysely, - config.billing.stripe, - config.time, - config.random, - { ...config.billing.config, origin }, - ) - : undefined; - const inbox = config.devInbox - ? devInboxRoutes(config.devInbox, config.time, origin) - : undefined; - const showInbox = inbox !== undefined; - const oauth = new OAuth( - auth, - new OAuthProviders(config.oauth ?? {}, config.oauthFetch), - ); - const methods = oauth.providers.enabled.map((id) => ({ - id, - label: PROVIDER_LABELS[id], - })); - const oauthName = secure ? '__Host-pgstencil-oauth' : 'pgstencil_oauth'; - const contentSecurityPolicy = `default-src 'none'; style-src 'self'; form-action 'self'${oauth.providers.enabled - .map((provider) => ` ${PROVIDER_ORIGINS[provider]}`) - .join( - '', - )}${billing ? billing.redirectOrigins.map((value) => ` ${value}`).join('') : ''}; base-uri 'none'; frame-ancestors 'none'`; - const sessionName = secure ? '__Host-pgstencil' : 'pgstencil_dev'; - const pendingName = secure - ? '__Host-pgstencil-pending' - : 'pgstencil_pending'; - function setCookie( - res: ServerResponse, - name: string, - value: string, - seconds: number, - ) { - // setCookie is the only writer, so the header is always an array. - const existing = (res.getHeader('set-cookie') as string[]) ?? []; - res.setHeader('set-cookie', [ - ...existing, - sessionCookie(name, value, config.time.now(), seconds, secure), - ]); - } - function send(res: ServerResponse, status: number, html: string) { - res.statusCode = status; - res.setHeader('content-type', 'text/html; charset=utf-8'); - res.end(html); - } - function fail( - res: ServerResponse, - status: number, - title: string, - body: string, - ) { - send(res, status, page(title, body, showInbox)); - } - function denyCsrf(res: ServerResponse) { - fail( - res, - 403, - 'Request not accepted.', - '

Return to your account and try again.

', - ); - } - function billingFailure( - res: ServerResponse, - error: unknown, - recovery = '', - ) { - const known = error instanceof BillingError; - fail( - res, - known ? error.status : 503, - 'Billing unavailable.', - `${errorMessage(known ? error.message : 'Please try again shortly.')}${recovery}`, - ); - } - function redirect(res: ServerResponse, path: string) { - res.statusCode = 303; - res.setHeader('location', path); - res.end(); - } - async function handle(req: IncomingMessage, res: ServerResponse) { - res.setHeader('cache-control', 'no-store'); - // no-referrer also nulls Origin on native form POSTs. strict-origin - // preserves CSRF origin checks while never disclosing paths/link tokens. - res.setHeader('referrer-policy', 'strict-origin'); - res.setHeader('x-content-type-options', 'nosniff'); - res.setHeader('content-security-policy', contentSecurityPolicy); - const url = new URL(req.url ?? '/', origin); - if ( - billing && - req.method === 'POST' && - url.pathname === '/webhooks/stripe' - ) { - // Reject before buffering: an unsigned request never reaches the parser. - const signature = req.headers['stripe-signature']; - if (typeof signature !== 'string') { - res.writeHead(400).end(); - return; - } - const body = await readBody(req, WEBHOOK_BODY_BYTES); - if (body === undefined) { - res.writeHead(413).end(); - return; - } - try { - // A bad signature is a typed 400; anything else is an outage. - await billing.webhook(body, signature); - res.writeHead(200).end(); - } catch (error) { - res - .writeHead(error instanceof BillingError ? error.status : 503) - .end(); - } - return; - } - // The static asset answers before any cookie or route parsing. - if (req.method === 'GET' && url.pathname === '/style.css') { - res.setHeader('content-type', 'text/css; charset=utf-8'); - res.end(css); - return; - } - const cookies = cookieValues(req.headers.cookie); - const rawSession = cookies[sessionName]; - if (billing && req.method === 'GET' && url.pathname === '/billing') { - const session = await auth.session(rawSession); - if (!session) { - redirect(res, '/login'); - return; - } - await billing.account(session.user_id, session.email); - const operation = url.searchParams.get('checkout'); - try { - if (operation) - await billing.confirmCheckout(session.user_id, operation); - else await billing.reconcile(session.user_id); - send( - res, - 200, - billingPage( - await billing.status(session.user_id), - auth.sessionCsrf(rawSession!), - showInbox, - ), - ); - } catch (error) { - billingFailure(res, error); - } - return; - } - const oauthMatch = OAUTH_ROUTE.exec(url.pathname); - const provider = oauthMatch - ? oauth.providers.enabled.find((id) => id === oauthMatch[1]) - : undefined; - const action = oauthMatch?.[2] as OAuthAction | undefined; - if (oauthMatch && !provider) { - fail( - res, - 404, - 'Sign-in method unavailable.', - 'Return to sign-in', - ); - return; - } - if ( - req.method === 'POST' && - provider === 'apple' && - action === 'callback' - ) { - if ( - req.headers['content-type']?.split(';')[0]?.trim() !== - 'application/x-www-form-urlencoded' - ) { - res.writeHead(415).end(); - return; - } - const body = await readBody(req, MAX_BODY_BYTES); - if (!body) { - res.writeHead(413).end(); - return; - } - const location = appleCallbackLocation( - new URLSearchParams(body.toString()), - url.pathname, - ); - res - .writeHead(303, { - location, - 'cache-control': 'no-store', - 'referrer-policy': 'no-referrer', - }) - .end(); - return; - } - if (req.method === 'GET' && provider && action === 'callback') { - const { result, clearCookie } = await oauth.complete( - provider, - url, - cookies[oauthName], - rawSession, - ); - if (clearCookie) setCookie(res, oauthName, '', 0); - if (result.ok) { - setCookie(res, sessionName, result.session, SESSION_MS / 1000); - setCookie(res, pendingName, '', 0); - redirect(res, '/account'); - } else - fail( - res, - result.status, - 'Sign-in not completed.', - `${errorMessage(result.message)}`, - ); - return; - } - // Only the routes below that need a flow pay for the lookup, and only once. - let pendingFlow: ReturnType | undefined; - const getPending = () => - (pendingFlow ??= auth.pending(cookies[pendingName])); - if (req.method === 'GET' && url.pathname === '/') { - redirect(res, '/login'); - return; - } - if (req.method === 'GET' && url.pathname === '/login') { - const flow = await auth.newFlow(); - setCookie(res, pendingName, flow.cookie, 30 * 60); - send(res, 200, loginPage(flow.csrf, showInbox, methods)); - return; - } - if (req.method === 'GET' && url.pathname === '/account') { - const session = await auth.session(rawSession); - if (!session) { - setCookie(res, sessionName, '', 0); - redirect(res, '/login'); - return; - } - const connected = methods.length - ? await db - .selectFrom('oauth_identities') - .select('provider') - .where('user_id', '=', session.user_id) - .execute() - : []; - send( - res, - 200, - accountPage( - session, - auth.sessionCsrf(rawSession!), - showInbox, - methods.map((method) => ({ - ...method, - connected: connected.some((row) => row.provider === method.id), - })), - !!billing, - ), - ); - return; - } - if (inbox && req.method === 'GET' && inbox(res, url)) return; - if (req.method === 'GET' && url.pathname === '/login/code') { - const pending = await getPending(); - if (!pending?.flow.email) { - redirect(res, '/login'); - return; - } - send(res, 200, codePage(pending.flow.email, pending.csrf, showInbox)); - return; - } - if (req.method === 'GET' && url.pathname === '/login/link') { - const pending = await getPending(); - const id = url.searchParams.get('id') ?? ''; - const link = url.searchParams.get('token') ?? ''; - const challenge = pending - ? await db - .selectFrom('login_challenges') - .select(['email', 'flow_id']) - .where('id', '=', id) - .executeTakeFirst() - : undefined; - if (!pending || challenge?.flow_id !== pending.flow.id) { - fail( - res, - 200, - 'Open your original browser.', - '

Enter the code from your email in the browser where you requested it, or start a new sign-in here.

Start a new sign-in', - ); - return; - } - send( - res, - 200, - confirmPage(challenge.email, pending.csrf, id, link, showInbox), - ); - return; - } - if (req.method === 'POST') { - if (req.headers.origin !== origin) { - fail( - res, - 403, - 'Request not accepted.', - '

Please return to the sign-in page and try again.

', - ); - return; - } - if ( - !req.headers['content-type']?.startsWith( - 'application/x-www-form-urlencoded', - ) - ) { - fail( - res, - 415, - 'Form required.', - '

Please submit the form on this site.

', - ); - return; - } - const raw = await readBody(req, MAX_BODY_BYTES); - if (raw === undefined) { - fail(res, 413, 'Request too large.', '

Please try again.

'); - return; - } - const form = new URLSearchParams(raw.toString()); - const csrf = form.get('csrf') ?? ''; - if (billing && url.pathname.startsWith('/billing/')) { - const session = await auth.session(rawSession); - if (!auth.validSessionCsrf(session, csrf)) { - denyCsrf(res); - return; - } - try { - if (url.pathname === '/billing/checkout') { - redirect( - res, - ( - await billing.checkout( - session.user_id, - session.email, - form.get('plan') ?? '', - ) - ).url, - ); - } else if (url.pathname === '/billing/portal') - redirect(res, await billing.portal(session.user_id)); - else if (url.pathname === '/billing/cancel-checkout') { - await billing.cancelCheckout(session.user_id); - redirect(res, '/billing'); - } else - fail( - res, - 404, - 'Page not found.', - 'Return to billing', - ); - } catch (error) { - billingFailure( - res, - error, - 'Return to billing', - ); - } - return; - } - const source = req.socket.remoteAddress ?? 'unknown'; - if (provider && (action === 'start' || action === 'connect')) { - const linking = action === 'connect'; - const session = linking ? await auth.session(rawSession) : undefined; - const pending = linking ? undefined : await getPending(); - const valid = linking - ? auth.validSessionCsrf(session, csrf) - : pending && auth.validCsrf(pending, csrf); - if (!valid) { - fail( - res, - 403, - 'Request not accepted.', - '

Return to sign-in or your account and try again.

', - ); - return; - } - try { - const result = await oauth.begin(provider, source, session); - if (result.ok) { - setCookie(res, oauthName, result.cookie, result.seconds); - redirect(res, result.url); - } else - fail( - res, - result.status, - 'Sign-in not started.', - `${errorMessage(result.message)}Return to sign-in`, - ); - } catch { - fail( - res, - 503, - 'Sign-in temporarily unavailable.', - '

Please try again, or sign in with email.

Return to sign-in', - ); - } - return; - } - if (url.pathname === '/logout') { - const session = await auth.session(rawSession); - if (!auth.validSessionCsrf(session, csrf)) { - denyCsrf(res); - return; - } - await auth.logout(rawSession!); - setCookie(res, sessionName, '', 0); - redirect(res, '/login'); - return; - } - const pending = await getPending(); - if (!pending || !auth.validCsrf(pending, csrf)) { - fail( - res, - 403, - 'Start a new sign-in.', - '

This sign-in request is no longer available.

Return to sign-in', - ); - return; - } - if (url.pathname === '/login' || url.pathname === '/login/resend') { - const email = normalizeEmail( - url.pathname === '/login/resend' - ? (pending.flow.email ?? '') - : (form.get('email') ?? ''), - ); - if (!email) { - send( - res, - 400, - loginPage( - pending.csrf, - showInbox, - methods, - 'Enter a valid email address.', - ), - ); - return; - } - const result = await auth.send(pending, email, source); - if (result.ok) redirect(res, '/login/code'); - else - send( - res, - result.status, - sendFailurePage(email, pending.csrf, showInbox, result.message), - ); - return; - } - if (url.pathname === '/login/code' || url.pathname === '/login/link') { - const method = url.pathname === '/login/code' ? 'code' : 'link'; - const result = await auth.verify( - pending, - method, - form.get(method === 'code' ? 'code' : 'token') ?? '', - form.get('id') ?? undefined, - source, - rawSession, - ); - if (result.ok) { - setCookie(res, sessionName, result.session, SESSION_MS / 1000); - setCookie(res, pendingName, '', 0); - redirect(res, '/account'); - } else - send( - res, - result.status, - codePage( - pending.flow.email ?? '', - pending.csrf, - showInbox, - result.message, - ), - ); - return; - } - } - fail( - res, - 404, - 'Page not found.', - 'Return to sign-in', - ); - } - server.on('request', (req, res) => { - void handle(req, res).catch(() => { - if (!res.headersSent) - fail(res, 500, 'Something went wrong.', '

Please try again.

'); - else res.end(); - }); - }); - let closed = false; - return { - origin: localOrigin, - publicOrigin: origin, - db, - auth, - billing, - sessionName, - pendingName, - oauthName, - async close() { - if (closed) return; - closed = true; - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); - await db.destroy(); - }, - }; - } catch (error) { - server.close(); - await db.destroy(); - throw error; - } -} -export type LoginApp = Awaited>; diff --git a/examples/login/src/auth.ts b/examples/login/src/auth.ts deleted file mode 100644 index 1819820..0000000 --- a/examples/login/src/auth.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@pgstencil/auth/auth'; diff --git a/examples/login/src/billing-view.ts b/examples/login/src/billing-view.ts deleted file mode 100644 index 557e68d..0000000 --- a/examples/login/src/billing-view.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Billing } from '@pgstencil/stripe'; -import { date, escape, hidden, page } from './views.ts'; - -export function billingPage( - state: Awaited>, - csrf: string, - showInbox: boolean, -) { - const current = state.subscription; - const deadline = state.accessUntil ? date(state.accessUntil) : null; - const status = current - ? `

${escape(state.plan)} · ${escape(current.status)}

${state.access ? 'Access available' : 'Access unavailable'}${deadline ? ` · Current access period ends ${escape(deadline)}` : ''}.

${current.cancel_at_period_end ? '

Your subscription will cancel at the end of this period.

' : ''}` - : '

Choose a plan to start your subscription.

Signing in does not start the trial.

'; - const offer = - current?.status === 'trialing' - ? '

Your card will be charged when the trial ends unless you cancel first.

' - : state.trialDays > 0 - ? `

Your ${state.trialDays}-day free trial starts after you provide a card in Checkout. Stripe will charge the selected plan when the trial ends unless you cancel first.

` - : '

Payment is due when you subscribe. Your free trial has already been used.

'; - const form = (path: string, label: string, extra = '') => - `
${hidden('csrf', csrf)}${extra}
`; - return page( - 'Billing', - `${status}${offer}

Monthly billing or a discounted yearly plan. Checkout shows the exact price and renewal terms before you confirm.

-
${form('/billing/checkout', 'Choose monthly', hidden('plan', 'monthly'))}${form('/billing/checkout', 'Choose yearly', hidden('plan', 'yearly'))}
-${current ? form('/billing/portal', 'Manage billing') : ''} -${form('/billing/cancel-checkout', 'Cancel an unfinished checkout')} -

Return to account

`, - showInbox, - ); -} diff --git a/examples/login/src/db.generated.ts b/examples/login/src/db.generated.ts deleted file mode 100644 index f05eecd..0000000 --- a/examples/login/src/db.generated.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@pgstencil/auth/db.generated'; diff --git a/examples/login/src/dev-inbox.ts b/examples/login/src/dev-inbox.ts deleted file mode 100644 index 989fb4f..0000000 --- a/examples/login/src/dev-inbox.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { ServerResponse } from 'node:http'; -import type { EmailDev, Time } from 'pgstencil'; -import { escape, page, loginEmail } from './views.ts'; -/** - * The local inbox is mounted only where an EmailDev exists, so a production - * composition cannot reach it — no runtime mode check guards these routes. - */ -export function devInboxRoutes(email: EmailDev, time: Time, origin: string) { - return (res: ServerResponse, url: URL): boolean => { - if (!url.pathname.startsWith('/dev/emails')) return false; - const messages = email.all(); - if (url.pathname === '/dev/emails') { - res.statusCode = 200; - res.setHeader('content-type', 'text/html; charset=utf-8'); - res.end( - page( - 'Local inbox.', - `

Email sent by this application appears here.

    ${messages.map((m, i) => `
  • ${escape(m.subject)}
    ${escape(m.to.join(', '))}
    ${escape(m.capturedAt)}
  • `).join('') || '
  • No messages yet. Request a sign-in code to get started.
  • '}

Templates

Sign-in email`, - true, - ), - ); - return true; - } - const key = url.pathname.split('/')[3]; - const message = - key === 'template' - ? loginEmail( - 'you@example.com', - '12345678', - `${origin}/login`, - new Date(time.now().getTime() + 600000), - ) - : messages[Number(key)]; - if (!message) return false; - if (url.searchParams.get('view') === 'text') { - res.setHeader('content-type', 'text/plain; charset=utf-8'); - res.end(message.text); - } else { - res.statusCode = 200; - res.setHeader('content-type', 'text/html; charset=utf-8'); - res.end(message.html); - } - return true; - }; -} diff --git a/examples/login/src/dev.ts b/examples/login/src/dev.ts deleted file mode 100644 index 4edb8e5..0000000 --- a/examples/login/src/dev.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { DevTime, SystemTime, DevRandom, EmailDev } from 'pgstencil'; -import { randomUUID } from 'node:crypto'; -import { developmentDatabase, stateDirectory } from 'pgstencil/database'; -import { join } from 'node:path'; -import { createStripeDev } from '@pgstencil/stripe/testing'; -import { startApp } from './app.ts'; -import { oauthFromEnvironment } from './oauth-providers.ts'; -import { appMigrations } from './migrations.ts'; -const oauth = oauthFromEnvironment(process.env); -const publicOrigin = process.env.PUBLIC_ORIGIN; -const port = Number(process.env.PORT ?? 0); -if (!Number.isInteger(port) || port < 0 || port > 65535) - throw new Error('PORT must be an integer from 0 to 65535'); -if (Object.keys(oauth).length && (!publicOrigin || port === 0)) - throw new Error( - 'Set PUBLIC_ORIGIN and PORT to the origin registered with your OAuth providers', - ); -// Browsers use their own clock for cookies. Historical dates belong in the -// tests, which explicitly replay cookies to exercise server-side expiration. -const time = process.env.PGSTENCIL_TIME - ? new DevTime(process.env.PGSTENCIL_TIME) - : new SystemTime(); -// The development database survives restarts; a fresh seed avoids reusing IDs. -const random = new DevRandom(process.env.PGSTENCIL_SEED ?? randomUUID()); -const email = new EmailDev(time); -const stripeDev = await createStripeDev( - time, - random, - join(stateDirectory, 'stripe-dev.json'), -); -const app = await startApp({ - databaseUrl: await developmentDatabase(true, [...appMigrations]), - time, - random, - email, - devInbox: email, - secret: 'pgstencil-local-development-secret-only', - development: true, - port, - oauth, - billing: { - stripe: stripeDev.stripe, - config: { - prices: stripeDev.prices, - trialDays: 14, - webhookSecret: stripeDev.webhookSecret, - live: false, - // Checkout is served by the local simulator, so it owns the CSP entry. - redirectOrigins: [stripeDev.origin], - }, - }, - ...(publicOrigin ? { publicOrigin } : {}), -}); -stripeDev.setWebhookTarget(`${app.origin}/webhooks/stripe`); -console.log( - `pgstencil: ${app.origin}\nLocal inbox: ${app.origin}/dev/emails\nServer time: ${time.now().toISOString()}`, -); -for (const signal of ['SIGINT', 'SIGTERM'] as const) - process.once(signal, () => { - void app.close().then(async () => { - await stripeDev.close(); - email.close(); - process.exit(0); - }); - }); diff --git a/examples/login/src/migrations.ts b/examples/login/src/migrations.ts deleted file mode 100644 index 7833637..0000000 --- a/examples/login/src/migrations.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { defaultMigrations } from 'pgstencil/database'; -import { billingMigrations } from '@pgstencil/stripe/migrations'; -/** Every SQL source this example needs, in dependency order. */ -export const appMigrations = [defaultMigrations, billingMigrations]; diff --git a/examples/login/src/oauth-providers.ts b/examples/login/src/oauth-providers.ts deleted file mode 100644 index 38d1e14..0000000 --- a/examples/login/src/oauth-providers.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@pgstencil/auth/oauth-providers'; diff --git a/examples/login/src/oauth.ts b/examples/login/src/oauth.ts deleted file mode 100644 index ec4883f..0000000 --- a/examples/login/src/oauth.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@pgstencil/auth/oauth'; diff --git a/examples/login/src/production.ts b/examples/login/src/production.ts deleted file mode 100644 index ac97750..0000000 --- a/examples/login/src/production.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { SecureRandom, SystemTime, type EmailSender } from 'pgstencil'; -import { startApp, type AppConfig } from './app.ts'; -import type { OAuthSettings } from './oauth-providers.ts'; - -/** Compose a real deployment behind an HTTPS reverse proxy. */ -export function startProduction(config: { - databaseUrl: string; - publicOrigin: string; - secret: string; - email: EmailSender; - port?: number; - oauth?: OAuthSettings; - billing?: NonNullable; -}) { - return startApp({ - ...config, - time: new SystemTime(), - random: new SecureRandom(), - development: false, - secureCookies: true, - }); -} diff --git a/examples/login/src/security.ts b/examples/login/src/security.ts deleted file mode 100644 index 09bf769..0000000 --- a/examples/login/src/security.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@pgstencil/auth/security'; diff --git a/examples/login/src/style.css b/examples/login/src/style.css deleted file mode 100644 index 0f8950b..0000000 --- a/examples/login/src/style.css +++ /dev/null @@ -1,247 +0,0 @@ -:root { - color-scheme: light; - --paper: #f6f5f0; - --ink: #203b2f; - --muted: #526458; - --line: #c7d0c6; - --accent: #245e43; -} -* { - box-sizing: border-box; -} -body { - margin: 0; - background: var(--paper); - color: var(--ink); - font: - 17px/1.6 -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - sans-serif; -} -header, -footer { - max-width: 1120px; - margin: 0 auto; - padding: 28px 36px; - display: flex; - justify-content: space-between; - align-items: center; - gap: 20px; -} -header { - border-bottom: 1px solid var(--line); -} -a { - color: var(--accent); - text-underline-offset: 4px; -} -a:hover { - color: #123e28; -} -.wordmark { - font-size: 25px; - font-weight: 700; - letter-spacing: -0.035em; - text-decoration: none; - color: var(--ink); -} -.wordmark span { - color: var(--accent); -} -.dev-link { - font-size: 14px; -} -main { - width: min(100% - 48px, 480px); - margin: 84px auto 110px; - min-height: 460px; -} -h1 { - font: - 400 clamp(38px, 6vw, 54px)/1.12 Georgia, - serif; - letter-spacing: -0.025em; - margin: 0 0 24px; - text-wrap: balance; -} -h2 { - font-size: 22px; - margin-top: 36px; -} -.intro { - color: var(--muted); - margin: 0 0 34px; -} -.intro strong { - color: var(--ink); - overflow-wrap: anywhere; -} -label { - display: block; - font-weight: 600; - margin-bottom: 8px; -} -input { - width: 100%; - background: #fffefa; - color: var(--ink); - font: inherit; - padding: 14px 16px; - border: 1px solid #84968a; - border-radius: 6px; -} -input::placeholder { - color: #68766c; -} -input:focus-visible, -button:focus-visible, -a:focus-visible { - outline: 3px solid #4a8060; - outline-offset: 4px; -} -button { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - margin-top: 20px; - padding: 14px 18px; - border: 0; - border-radius: 6px; - color: #fff; - background: var(--accent); - font: - 600 16px/1.5 -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - sans-serif; - cursor: pointer; -} -button:hover { - background: #194a32; -} -button:active { - background: #123e28; -} -button:disabled { - background: #667c6e; - cursor: wait; -} -.note, -.field-note { - color: var(--muted); - font-size: 14px; -} -.providers { - padding: 20px 0 0; - margin: 36px 0 0; - border: 0; - border-top: 1px solid var(--line); -} -.providers legend { - padding-right: 12px; - font-size: 14px; - color: var(--muted); -} -.providers .provider-button { - margin-top: 12px; - background: #fffefa; - color: var(--ink); - border: 1px solid #84968a; - justify-content: center; -} -.providers .provider-button:hover { - background: #e8ede6; -} -.note { - margin-top: 24px; -} -.field-note { - margin: 8px 0 0; -} -.code { - font-family: ui-monospace, monospace; - font-size: 26px; - letter-spacing: 0.14em; -} -.recovery { - margin-top: 28px; - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: 16px; - font-size: 14px; -} -.text-button { - padding: 0; - margin: 0; - background: none; - color: var(--accent); - font: inherit; - text-decoration: underline; - text-underline-offset: 4px; - border-radius: 0; -} -.text-button:hover { - background: none; - color: #123e28; -} -.error { - padding: 14px 16px; - border: 1px solid #bf7666; - border-radius: 6px; - background: #fff0eb; - color: #792e20; -} -dl { - margin: 36px 0; -} -dt { - color: var(--muted); - font-size: 14px; - margin-top: 20px; -} -dd { - margin: 4px 0 0; - overflow-wrap: anywhere; -} -footer { - border-top: 1px solid var(--line); - color: var(--muted); - font-size: 13px; -} -ul.messages { - padding: 0; - list-style: none; -} -.messages li { - padding: 18px 0; - border-bottom: 1px solid var(--line); -} -.messages a { - overflow-wrap: anywhere; -} -pre { - white-space: pre-wrap; - overflow-wrap: anywhere; - font-size: 14px; -} -@media (max-width: 560px) { - header, - footer { - padding: 20px 24px; - } - main { - margin-top: 54px; - margin-bottom: 64px; - min-height: 410px; - } - footer { - display: block; - } - footer span { - display: block; - margin-top: 6px; - } -} diff --git a/examples/login/src/views.ts b/examples/login/src/views.ts deleted file mode 100644 index 4038a52..0000000 --- a/examples/login/src/views.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Operate: a quiet, server-rendered sign-in form. The approved login flow owns - * the layout: one heading, one task, clear errors and a visible recovery path. - * Palette: warm paper, dark forest ink, restrained green controls. - */ -const ESCAPES: Record = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', -}; -export function escape(value: unknown): string { - return String(value).replace(/[&<>"']/g, (ch) => ESCAPES[ch]!); -} -// Locale resolution is the expensive part; build the formatter once. -const UTC_FORMAT = new Intl.DateTimeFormat('en-US', { - dateStyle: 'medium', - timeStyle: 'short', - timeZone: 'UTC', -}); -export function date(value: Date): string { - return UTC_FORMAT.format(value) + ' UTC'; -} -export function hidden(name: string, value: string): string { - return ``; -} -export function page(title: string, body: string, showInbox: boolean): string { - return ` - -${escape(title)} · pgstencil - - -
pgstencil${showInbox ? 'Local inbox' : ''}
-

${escape(title)}

${body}
-
pgstencil Simple sign-in. Your email is your key.
-`; -} -export function errorMessage(message?: string): string { - return message ? `` : ''; -} -export interface SignInMethod { - id: string; - label: string; - connected?: boolean; -} -function providerForms( - methods: readonly SignInMethod[], - csrf: string, - connecting: boolean, -): string { - if (!methods.length) return ''; - return `
${connecting ? 'Connected sign-in methods' : 'Or continue with'}${methods - .map((method) => - method.connected - ? `

${escape(method.label)} is connected.

` - : `
${hidden('csrf', csrf)}
`, - ) - .join( - '', - )}${connecting ? '

To connect a provider, sign in within the last five minutes and use the same verified email address.

' : ''}
`; -} -export function loginPage( - csrf: string, - showInbox: boolean, - providers: readonly SignInMethod[], - message?: string, -): string { - return page( - 'Welcome in.', - `

Sign in with your email. We’ll send you a code and a link—use whichever you prefer.

${errorMessage(message)} -
${hidden('csrf', csrf)}
-

New here? Your account is created when you verify your email.

${providerForms(providers, csrf, false)}`, - showInbox, - ); -} -export function codePage( - email: string, - csrf: string, - showInbox: boolean, - message?: string, -): string { - return page( - 'Check your email.', - `

Enter the code we sent to ${escape(email)}, or open the sign-in link in that email.

${errorMessage(message)} -
${hidden('csrf', csrf)}

Your code expires in 10 minutes.

-
${hidden('csrf', csrf)}
Use another email

You can request another code after one minute. Only the newest code will work.

`, - showInbox, - ); -} -export function confirmPage( - email: string, - csrf: string, - id: string, - linkToken: string, - showInbox: boolean, -): string { - return page( - 'Ready to sign in?', - `

Continue as ${escape(email)}.

${hidden('csrf', csrf)}${hidden('id', id)}${hidden('token', linkToken)}

This link works once, in the browser where you requested it.

Use another email`, - showInbox, - ); -} -export function sendFailurePage( - email: string, - csrf: string, - showInbox: boolean, - message: string, -): string { - return page( - 'No new email sent.', - `

We couldn’t send a new sign-in email to ${escape(email)}.

${errorMessage(message)}
${hidden('csrf', csrf)}${hidden('email', email)}
`, - showInbox, - ); -} -export function accountPage( - account: { email: string; created_at: Date; expires_at: Date }, - csrf: string, - showInbox: boolean, - providers: readonly SignInMethod[], - billing = false, -): string { - return page( - 'You’re signed in.', - `

Welcome, ${escape(account.email)}.

Signed in
${escape(date(account.created_at))}
Session expires
${escape(date(account.expires_at))}
${hidden('csrf', csrf)}
${providerForms(providers, csrf, true)}${billing ? '

Billing and subscription

' : ''}`, - showInbox, - ); -} -export { loginEmail } from '@pgstencil/auth/email'; diff --git a/examples/workers/package.json b/examples/workers/package.json deleted file mode 100644 index a9781ce..0000000 --- a/examples/workers/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@pgstencil/example-workers", - "private": true, - "type": "module", - "dependencies": { - "@pgstencil/auth": "workspace:*", - "hono": "^4.13.7", - "kysely": "^0.29.5", - "pgstencil": "workspace:*" - }, - "scripts": { - "build": "wrangler deploy --dry-run --outdir dist" - } -} diff --git a/examples/workers/src/index.ts b/examples/workers/src/index.ts deleted file mode 100644 index 592f1c0..0000000 --- a/examples/workers/src/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { - createAuthWorker, - type AuthWorkerBindings, -} from '@pgstencil/auth/workers'; -import { postmarkEmail } from '@pgstencil/auth/postmark'; - -interface Env extends AuthWorkerBindings { - POSTMARK_SERVER_TOKEN: string; - EMAIL_FROM: string; -} -export default createAuthWorker({ - email: (env) => postmarkEmail(env.POSTMARK_SERVER_TOKEN, env.EMAIL_FROM), -}); diff --git a/package.json b/package.json index 989f138..52b1cfc 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,18 @@ "private": true, "type": "module", "packageManager": "pnpm@12.18.0", + "devEngines": { + "runtime": { + "name": "node", + "version": "24.22.2", + "onFail": "download" + } + }, "engines": { "node": ">=24" }, "scripts": { - "dev": "node --env-file-if-exists=.env --import tsx examples/login/src/dev.ts", - "dev:better-auth": "node --import tsx examples/better-auth/src/dev.ts", - "test:better-auth": "vitest run tests/integration/better-auth.test.ts tests/integration/better-auth-workers.test.ts tests/integration/better-auth-oauth.test.ts", + "dev": "node --env-file-if-exists=.env --import tsx examples/better-auth/src/dev.ts", "test": "vitest run", "test:watch": "vitest", "test:unit": "vitest run tests/unit", @@ -19,16 +24,16 @@ "format": "prettier --write .", "format:check": "prettier --check .", "check": "pnpm format:check && pnpm typecheck && pnpm test:scripts && pnpm test", - "db:status": "tsx scripts/db.ts status", - "db:validate": "tsx scripts/db.ts validate", - "db:migrate": "tsx scripts/db.ts migrate", - "db:migration:create": "tsx scripts/db.ts create", - "db:schema": "tsx scripts/db.ts schema", - "db:types": "tsx scripts/db.ts types", - "db:verify": "tsx scripts/db.ts verify", - "db:reset": "tsx scripts/db.ts reset", - "db:stop": "tsx scripts/db.ts stop", - "db:gc": "tsx scripts/db.ts gc", + "db:status": "tsx packages/pgstencil/src/cli.ts status", + "db:validate": "tsx packages/pgstencil/src/cli.ts validate", + "db:migrate": "tsx packages/pgstencil/src/cli.ts migrate", + "db:migration:create": "tsx packages/pgstencil/src/cli.ts create", + "db:schema": "tsx packages/pgstencil/src/cli.ts schema", + "db:types": "tsx packages/pgstencil/src/cli.ts types", + "db:verify": "tsx packages/pgstencil/src/cli.ts verify", + "db:reset": "tsx packages/pgstencil/src/cli.ts reset", + "db:stop": "tsx packages/pgstencil/src/cli.ts stop", + "db:gc": "tsx packages/pgstencil/src/cli.ts gc", "packages:build": "node --import tsx scripts/build-packages.ts", "packages:pack": "node --import tsx scripts/pack-packages.ts", "packages:verify": "pnpm packages:pack && node --import tsx scripts/verify-packages.ts" @@ -39,7 +44,6 @@ "@types/supertest": "^7.0.0", "@types/turndown": "^5.0.0", "esbuild": "^0.28.2", - "kysely-codegen": "^0.20.0", "miniflare": "5.20261104.0-beta", "prettier": "^3.6.0", "supertest": "^7.2.0", diff --git a/packages/auth/migrations/001_auth.sql b/packages/auth/migrations/001_auth.sql deleted file mode 100644 index 9f354dc..0000000 --- a/packages/auth/migrations/001_auth.sql +++ /dev/null @@ -1,43 +0,0 @@ --- Up Migration -CREATE TABLE users ( - id text PRIMARY KEY, - email text NOT NULL UNIQUE, - created_at timestamptz NOT NULL -); -CREATE TABLE login_flows ( - id text PRIMARY KEY, - binding_hash text NOT NULL, - csrf_hash text NOT NULL, - email text, - created_at timestamptz NOT NULL, - expires_at timestamptz NOT NULL -); -CREATE TABLE login_challenges ( - id text PRIMARY KEY, - flow_id text NOT NULL REFERENCES login_flows(id) ON DELETE CASCADE, - email text NOT NULL, - code_digest text NOT NULL, - link_hash text NOT NULL, - created_at timestamptz NOT NULL, - expires_at timestamptz NOT NULL, - consumed_at timestamptz, - invalidated_at timestamptz, - delivered_at timestamptz, - attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0) -); -CREATE INDEX login_challenges_flow ON login_challenges(flow_id, created_at); -CREATE TABLE sessions ( - token_hash text PRIMARY KEY, - user_id text NOT NULL REFERENCES users(id), - csrf_hash text NOT NULL, - created_at timestamptz NOT NULL, - expires_at timestamptz NOT NULL, - revoked_at timestamptz -); -CREATE TABLE rate_limits ( - key text PRIMARY KEY, - window_start timestamptz NOT NULL, - count integer NOT NULL CHECK (count >= 0) -); --- Down Migration -DROP TABLE rate_limits, sessions, login_challenges, login_flows, users; diff --git a/packages/auth/migrations/002_oauth.sql b/packages/auth/migrations/002_oauth.sql deleted file mode 100644 index 980e993..0000000 --- a/packages/auth/migrations/002_oauth.sql +++ /dev/null @@ -1,28 +0,0 @@ --- Up Migration -CREATE TABLE oauth_identities ( - provider text NOT NULL CHECK (provider IN ('google', 'github')), - subject text NOT NULL, - user_id text NOT NULL REFERENCES users(id), - created_at timestamptz NOT NULL, - PRIMARY KEY (provider, subject), - UNIQUE (user_id, provider) -); - --- Short-lived browser-bound authorization attempts. Raw state, PKCE verifiers, --- nonces, authorization codes and provider tokens are never stored here. -CREATE TABLE oauth_flows ( - state_hash text PRIMARY KEY, - provider text NOT NULL CHECK (provider IN ('google', 'github')), - browser_hash text NOT NULL, - redirect_uri text NOT NULL, - link_user_id text REFERENCES users(id), - link_session_hash text REFERENCES sessions(token_hash), - created_at timestamptz NOT NULL, - expires_at timestamptz NOT NULL, - consumed_at timestamptz, - CHECK ((link_user_id IS NULL) = (link_session_hash IS NULL)) -); -CREATE INDEX oauth_flows_expiry ON oauth_flows(expires_at); - --- Down Migration -DROP TABLE oauth_flows, oauth_identities; diff --git a/packages/auth/migrations/004_oauth_workers.sql b/packages/auth/migrations/004_oauth_workers.sql deleted file mode 100644 index eb10e87..0000000 --- a/packages/auth/migrations/004_oauth_workers.sql +++ /dev/null @@ -1,18 +0,0 @@ --- Up Migration -ALTER TABLE oauth_identities DROP CONSTRAINT oauth_identities_provider_check; -ALTER TABLE oauth_identities ADD CHECK (provider IN ('google', 'github', 'apple', 'facebook')); -ALTER TABLE oauth_flows DROP CONSTRAINT oauth_flows_provider_check; -ALTER TABLE oauth_flows ADD CHECK (provider IN ('google', 'github', 'apple', 'facebook')); - --- Fixed-size transaction locks for first-time identity creation, including --- concurrent callbacks. Supported by Hyperdrive, unlike advisory locks. -CREATE TABLE oauth_locks (id integer PRIMARY KEY CHECK (id >= 0 AND id < 64)); -INSERT INTO oauth_locks SELECT generate_series(0, 63); - --- Down Migration --- Refuse rollback while identities/flows belonging to the new providers exist. -ALTER TABLE oauth_identities DROP CONSTRAINT oauth_identities_provider_check; -ALTER TABLE oauth_identities ADD CHECK (provider IN ('google', 'github')); -ALTER TABLE oauth_flows DROP CONSTRAINT oauth_flows_provider_check; -ALTER TABLE oauth_flows ADD CHECK (provider IN ('google', 'github')); -DROP TABLE oauth_locks; diff --git a/packages/auth/package.json b/packages/auth/package.json index 47951fe..0dee52e 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -4,17 +4,6 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./migrations": "./src/migrations.ts", - "./auth": "./src/auth.ts", - "./security": "./src/security.ts", - "./oauth": "./src/oauth.ts", - "./oauth-providers": "./src/oauth-providers.ts", - "./db.generated": "./src/db.generated.ts", - "./email": "./src/email.ts", - "./http": "./src/http.ts", - "./fetch": "./src/fetch.ts", - "./hono": "./src/hono.ts", - "./workers": "./src/workers.ts", "./postmark": "./src/postmark.ts", "./better-auth": "./src/better-auth.ts", "./better-auth-oauth": "./src/better-auth-oauth.ts", @@ -24,8 +13,7 @@ }, "dependencies": { "better-auth": "1.7.18", - "jose": "^6.2.15", - "openid-client": "^6.8.9" + "jose": "^6.2.15" }, "peerDependencies": { "hono": "^4.13.7", @@ -42,7 +30,6 @@ }, "files": [ "dist", - "migrations", "LICENSE", "better-auth-migrations" ], @@ -53,50 +40,6 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, - "./migrations": { - "types": "./dist/migrations.d.ts", - "import": "./dist/migrations.js" - }, - "./auth": { - "types": "./dist/auth.d.ts", - "import": "./dist/auth.js" - }, - "./security": { - "types": "./dist/security.d.ts", - "import": "./dist/security.js" - }, - "./oauth": { - "types": "./dist/oauth.d.ts", - "import": "./dist/oauth.js" - }, - "./oauth-providers": { - "types": "./dist/oauth-providers.d.ts", - "import": "./dist/oauth-providers.js" - }, - "./db.generated": { - "types": "./dist/db.generated.d.ts", - "import": "./dist/db.generated.js" - }, - "./email": { - "types": "./dist/email.d.ts", - "import": "./dist/email.js" - }, - "./http": { - "types": "./dist/http.d.ts", - "import": "./dist/http.js" - }, - "./fetch": { - "types": "./dist/fetch.d.ts", - "import": "./dist/fetch.js" - }, - "./hono": { - "types": "./dist/hono.d.ts", - "import": "./dist/hono.js" - }, - "./workers": { - "types": "./dist/workers.d.ts", - "import": "./dist/workers.js" - }, "./postmark": { "types": "./dist/postmark.d.ts", "import": "./dist/postmark.js" diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts deleted file mode 100644 index c402090..0000000 --- a/packages/auth/src/auth.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { sql, type Kysely, type Transaction, type Selectable } from 'kysely'; -import { - token, - decimalCode, - type RandomSource, - type Time, - type EmailSender, -} from 'pgstencil'; -import type { DB, LoginFlows, Sessions } from './db.generated.ts'; -import { digest, keyed, equalDigest } from './security.ts'; -import { loginEmail } from './email.ts'; -export const CHALLENGE_MS = 10 * 60_000; -export const SESSION_MS = 24 * 3_600_000; -export interface AuthDependencies { - db: Kysely; - time: Time; - random: RandomSource; - email: EmailSender; - secret: string; - origin: string; - renderEmail?: typeof loginEmail; -} -export type SessionRow = Selectable & { email: string }; -export interface Pending { - flow: Selectable; - csrf: string; - cookie: string; -} -export interface AuthFailure { - ok: false; - status: number; - message: string; -} -export type AuthResult = { ok: true; session: string } | AuthFailure; -export class Auth { - constructor(readonly deps: AuthDependencies) { - if (deps.secret.length < 32) - throw new Error('Auth secret must contain at least 32 characters'); - } - /** Secret-derived, purpose-separated value. Keeps the raw secret in Auth. */ - derive(purpose: string, value: string): string { - return keyed(this.deps.secret, purpose, value); - } - sessionCsrf(raw: string): string { - return this.derive('session-csrf', raw); - } - async newFlow(): Promise { - const { db, random, time } = this.deps; - const id = token(random, 16); - const binding = token(random); - const csrf = this.derive('flow-csrf', binding); - const now = time.now(); - const flow = await db - .insertInto('login_flows') - .values({ - id, - binding_hash: digest(binding), - csrf_hash: digest(csrf), - email: null, - created_at: now, - expires_at: new Date(now.getTime() + 30 * 60_000), - }) - .returningAll() - .executeTakeFirstOrThrow(); - return { flow, csrf, cookie: `${id}.${binding}` }; - } - async pending(cookie: string | undefined): Promise { - if (!cookie) return; - const parts = cookie.split('.'); - if (parts.length !== 2) return; - const [id, binding] = parts as [string, string]; - const flow = await this.deps.db - .selectFrom('login_flows') - .selectAll() - .where('id', '=', id) - .executeTakeFirst(); - if ( - !flow || - flow.expires_at <= this.deps.time.now() || - !equalDigest(flow.binding_hash, digest(binding)) - ) - return; - return { flow, csrf: this.derive('flow-csrf', binding), cookie }; - } - validCsrf(pending: Pending, csrf: string): boolean { - return equalDigest(pending.flow.csrf_hash, digest(csrf)); - } - /** The session-scoped counterpart of validCsrf, for signed-in form posts. */ - validSessionCsrf( - session: SessionRow | undefined, - csrf: string, - ): session is SessionRow { - return !!session && equalDigest(session.csrf_hash, digest(csrf)); - } - private async rate( - trx: Transaction, - keys: { key: string; limit: number }[], - ): Promise { - const now = this.deps.time.now(); - let allowed = true; - for (const { key, limit } of [...keys].sort((a, b) => - a.key.localeCompare(b.key), - )) { - // Inserting first also serializes the very first use of a key. Unlike - // advisory locks, row locks work through Hyperdrive transaction pooling. - await trx - .insertInto('rate_limits') - .values({ key, count: 0, window_start: now }) - .onConflict((c) => c.column('key').doNothing()) - .execute(); - const previous = await trx - .selectFrom('rate_limits') - .selectAll() - .where('key', '=', key) - .forUpdate() - .executeTakeFirst(); - const fresh = - !previous || - now.getTime() - previous.window_start.getTime() >= 15 * 60_000; - const count = fresh ? 1 : Math.min(previous.count + 1, limit + 1); - allowed &&= count <= limit; - await trx - .insertInto('rate_limits') - .values({ - key, - count, - window_start: fresh ? now : previous.window_start, - }) - .onConflict((c) => - c.column('key').doUpdateSet({ - count, - window_start: fresh ? now : previous.window_start, - }), - ) - .execute(); - } - return allowed; - } - async send( - pending: Pending, - email: string, - source: string, - ): Promise<{ ok: true } | AuthFailure> { - const { db, time, random } = this.deps; - const now = time.now(); - // One value for both the stored challenge and the email that announces it. - const expiresAt = new Date( - Math.min(now.getTime() + CHALLENGE_MS, pending.flow.expires_at.getTime()), - ); - const prepared = await db.transaction().execute(async (trx) => { - await trx - .selectFrom('login_flows') - .select('id') - .where('id', '=', pending.flow.id) - .forUpdate() - .executeTakeFirstOrThrow(); - const recent = await trx - .selectFrom('login_challenges') - .select('created_at') - .where('flow_id', '=', pending.flow.id) - .orderBy('created_at', 'desc') - .executeTakeFirst(); - if (recent && now.getTime() - recent.created_at.getTime() < 60_000) - return; - if ( - !(await this.rate(trx, [ - { key: `send:email:${email}`, limit: 5 }, - { key: `send:ip:${source}`, limit: 30 }, - ])) - ) - return; - await trx - .updateTable('login_challenges') - .set({ invalidated_at: now }) - .where('flow_id', '=', pending.flow.id) - .where('consumed_at', 'is', null) - .where('invalidated_at', 'is', null) - .execute(); - await trx - .updateTable('login_flows') - .set({ email }) - .where('id', '=', pending.flow.id) - .execute(); - const id = token(random, 16); - const code = decimalCode(random); - const link = token(random); - await trx - .insertInto('login_challenges') - .values({ - id, - flow_id: pending.flow.id, - email, - code_digest: keyed(this.deps.secret, 'login-code', `${id}:${code}`), - link_hash: digest(link), - created_at: now, - expires_at: expiresAt, - consumed_at: null, - invalidated_at: null, - delivered_at: null, - }) - .execute(); - return { id, code, link }; - }); - if (!prepared) - return { - ok: false, - status: 429, - message: 'Please wait before requesting another code.', - }; - const link = `${this.deps.origin}/login/link?id=${prepared.id}&token=${prepared.link}`; - try { - await this.deps.email.send( - (this.deps.renderEmail ?? loginEmail)( - email, - prepared.code, - link, - expiresAt, - ), - ); - await db - .updateTable('login_challenges') - .set({ delivered_at: time.now() }) - .where('id', '=', prepared.id) - .execute(); - return { ok: true }; - } catch { - await db - .updateTable('login_challenges') - .set({ invalidated_at: time.now() }) - .where('id', '=', prepared.id) - .execute(); - return { - ok: false, - status: 503, - message: 'We could not send your email. Please try again in a minute.', - }; - } - } - async verify( - pending: Pending, - method: 'code' | 'link', - value: string, - challengeId: string | undefined, - source: string, - priorSession?: string, - ): Promise { - const { db, time, random } = this.deps; - const now = time.now(); - return db.transaction().execute(async (trx) => { - // Same lock as resend: replacing and redeeming a challenge cannot race. - await trx - .selectFrom('login_flows') - .select('id') - .where('id', '=', pending.flow.id) - .forUpdate() - .executeTakeFirstOrThrow(); - const invalid: AuthResult = { - ok: false, - status: 400, - message: - 'That code or link is invalid or has expired. Request a new code to try again.', - }; - const challenge = await trx - .selectFrom('login_challenges') - .selectAll() - .where('flow_id', '=', pending.flow.id) - .orderBy('created_at', 'desc') - .orderBy('id', 'desc') - .executeTakeFirst(); - if ( - !challenge || - !challenge.delivered_at || - challenge.consumed_at || - challenge.invalidated_at || - challenge.expires_at <= now || - pending.flow.expires_at <= now || - challenge.attempts >= 5 - ) - return invalid; - if ( - !(await this.rate(trx, [ - { key: `verify:email:${challenge.email}`, limit: 15 }, - { key: `verify:ip:${source}`, limit: 100 }, - ])) - ) - return { - ok: false, - status: 429, - message: 'Too many attempts. Please try again later.', - }; - const code = value.replace(/\s/g, ''); - const matched = - method === 'code' - ? /^\d{8}$/.test(code) && - equalDigest( - challenge.code_digest, - keyed(this.deps.secret, 'login-code', `${challenge.id}:${code}`), - ) - : challenge.id === challengeId && - equalDigest(challenge.link_hash, digest(value)); - if (!matched) { - const attempts = challenge.attempts + 1; - await trx - .updateTable('login_challenges') - .set({ attempts, ...(attempts >= 5 ? { invalidated_at: now } : {}) }) - .where('id', '=', challenge.id) - .execute(); - return invalid; - } - await trx - .updateTable('login_challenges') - .set({ consumed_at: now }) - .where('id', '=', challenge.id) - .execute(); - // DO UPDATE (unlike DO NOTHING) returns the existing row, so the id - // comes back without a second round trip. - const user = await trx - .insertInto('users') - .values({ - id: token(random, 16), - email: challenge.email, - created_at: now, - }) - .onConflict((c) => - c.column('email').doUpdateSet({ email: sql`excluded.email` }), - ) - .returning('id') - .executeTakeFirstOrThrow(); - const session = await this.issueSession(trx, user.id, priorSession, now); - await trx - .updateTable('login_flows') - .set({ expires_at: now }) - .where('id', '=', pending.flow.id) - .execute(); - return { ok: true, session }; - }); - } - /** Shared session issuance after an email or OAuth proof has been verified. */ - async issueSession( - trx: Transaction, - userId: string, - priorSession: string | undefined, - now = this.deps.time.now(), - ): Promise { - if (priorSession) - await trx - .updateTable('sessions') - .set({ revoked_at: now }) - .where('token_hash', '=', digest(priorSession)) - .execute(); - const session = token(this.deps.random); - await trx - .insertInto('sessions') - .values({ - token_hash: digest(session), - user_id: userId, - csrf_hash: digest(this.sessionCsrf(session)), - created_at: now, - expires_at: new Date(now.getTime() + SESSION_MS), - revoked_at: null, - }) - .execute(); - return session; - } - async allowOAuthStart(source: string): Promise { - return this.deps.db - .transaction() - .execute((trx) => - this.rate(trx, [{ key: `oauth:ip:${source}`, limit: 30 }]), - ); - } - private sessionQuery( - executor: Kysely | Transaction, - tokenHash: string, - ) { - return executor - .selectFrom('sessions') - .innerJoin('users', 'users.id', 'sessions.user_id') - .selectAll('sessions') - .select('users.email') - .where('token_hash', '=', tokenHash); - } - /** True while a session may still authenticate a request. */ - live(session: Selectable): boolean { - return !session.revoked_at && session.expires_at > this.deps.time.now(); - } - async session(raw: string | undefined): Promise { - if (!raw) return; - return this.sessionQuery(this.deps.db, digest(raw)) - .where('revoked_at', 'is', null) - .where('expires_at', '>', this.deps.time.now()) - .executeTakeFirst(); - } - /** Row-locked read for callers that must decide and write in one transaction. */ - async lockedSession( - trx: Transaction, - tokenHash: string, - ): Promise { - return this.sessionQuery(trx, tokenHash) - .forUpdate('sessions') - .executeTakeFirst(); - } - async logout(raw: string): Promise { - await this.deps.db - .updateTable('sessions') - .set({ revoked_at: this.deps.time.now() }) - .where('token_hash', '=', digest(raw)) - .execute(); - } -} diff --git a/packages/auth/src/better-auth-oauth.ts b/packages/auth/src/better-auth-oauth.ts index dc2601d..8dcb89b 100644 --- a/packages/auth/src/better-auth-oauth.ts +++ b/packages/auth/src/better-auth-oauth.ts @@ -30,9 +30,10 @@ export type OAuthSettings = Partial< Record >; -/** Pin the redirect flow's OIDC checks explicitly on Better Auth 1.7.3. - * Its Google/Apple/Microsoft provider metadata supports verification, but their default - * redirect getUserInfo only decodes claims. Use the library's actual verifier. +/** Pin the redirect flow's OIDC checks explicitly. Better Auth's + * Google/Apple/Microsoft provider metadata supports verification, but their default + * redirect getUserInfo only decoded claims when this was written (1.7.3). Use the + * library's actual verifier whatever the pinned release does by default. */ export const verifiedOidc: BetterAuthPlugin = { id: 'pgstencil-verified-oidc', @@ -68,8 +69,8 @@ export const verifiedOidc: BetterAuthPlugin = { if (provider.idToken && 'jwks' in provider.idToken) { provider.idToken.algorithms = ['RS256']; if (provider.id === 'microsoft') { - // Microsoft omits JWK.alg. Better Auth 1.7.3 passes that missing value - // to importJWK, which throws. JOSE selects/imports the key using the + // Microsoft omits JWK.alg. Better Auth passed that missing value to + // importJWK, which throws (seen in 1.7.3). JOSE selects/imports the key using the // protected header; the verifier still permits only RS256 above. provider.idToken.jwks = createRemoteJWKSet( new URL( diff --git a/packages/auth/src/better-auth-workers.ts b/packages/auth/src/better-auth-workers.ts index db80a6e..f3db560 100644 --- a/packages/auth/src/better-auth-workers.ts +++ b/packages/auth/src/better-auth-workers.ts @@ -39,10 +39,17 @@ export function createBetterAuthWorker( | 'allowMissingEmail' | 'rememberLoginMethod' | 'appName' + | 'renderEmail' | 'successPath' | 'errorPath' > & { email: (env: E) => EmailSender; + /** + * The default sign-in email's sender, usually read from a binding such as + * `(env) => env.EMAIL_FROM`. When omitted, `undefined` or blank, it is + * `signin@`. `renderEmail` receives it as `from`. + */ + emailFrom?: string | ((env: E) => string); /** Builds this request's hooks; their pending work is kept alive with `waitUntil`. */ hooks?: (env: E) => AuthHooks; }, @@ -78,9 +85,14 @@ export function createBetterAuthWorker( ...diagnosticError(error), }); } - const { hooks: _factory, ...rest } = options; + const { hooks: _factory, emailFrom: _from, ...rest } = options; + const emailFrom = + typeof options.emailFrom === 'function' + ? options.emailFrom(c.env) + : options.emailFrom; const auth = createAuthApp({ ...rest, + ...(emailFrom === undefined ? {} : { emailFrom }), ...(hooks ? { hooks } : {}), ...(waitUntil ? { waitUntil } : {}), databaseUrl: c.env.HYPERDRIVE.connectionString, diff --git a/packages/auth/src/better-auth.ts b/packages/auth/src/better-auth.ts index b730c36..500eb14 100644 --- a/packages/auth/src/better-auth.ts +++ b/packages/auth/src/better-auth.ts @@ -12,7 +12,7 @@ import { emailOTP } from 'better-auth/plugins/email-otp'; import { Hono } from 'hono'; import { sql } from 'kysely'; import { connectDatabase } from 'pgstencil/postgres'; -import type { EmailSender } from 'pgstencil'; +import { escapeHtml, type EmailMessage, type EmailSender } from 'pgstencil'; import { clientIpHeader, keyed, @@ -52,6 +52,91 @@ export interface AuthHooks { onError?: (error: unknown) => void; } +/** What a sign-in email renderer receives for one email code. */ +export interface SignInEmailInput { + /** The requested recipient. The message must be addressed to exactly this address. */ + email: string; + /** The one-time sign-in code. */ + otp: string; + /** Minutes until the code expires. */ + expiresInMinutes: number; + /** `AuthOptions.appName`, when set. Escape it with `escapeHtml` before placing it in HTML. */ + appName?: string; + /** The resolved sender: `emailFrom`, or `signin@`. */ + from: string; +} + +/** + * The sign-in email pgstencil sends when no `renderEmail` is configured: sent + * from `input.from` to `input.email`, with `appName` in the subject only. A + * custom renderer can call it and adjust the result. + */ +export function defaultSignInEmail(input: SignInEmailInput): EmailMessage { + return { + from: input.from, + to: [input.email], + subject: input.appName + ? `Your ${input.appName} sign-in code` + : 'Your sign-in code', + text: `Your sign-in code is ${input.otp}. It expires in ${input.expiresInMinutes} minutes.`, + html: `

Your sign-in code is ${escapeHtml(input.otp)}.

It expires in ${escapeHtml(input.expiresInMinutes)} minutes.

`, + }; +} + +const otpExpiresInSeconds = 600; + +/** A rendered message reaches exactly the address that requested the code, or nobody. */ +function addressedOnlyTo(message: EmailMessage, email: string) { + return ( + !!message && + typeof message === 'object' && + Array.isArray(message.to) && + message.to.length === 1 && + message.to[0] === email && + !message.cc?.length && + !message.bcc?.length + ); +} + +async function sendSignInEmail( + options: AuthOptions, + email: string, + otp: string, +) { + let message: EmailMessage; + try { + const input: SignInEmailInput = { + email, + otp, + expiresInMinutes: otpExpiresInSeconds / 60, + ...(options.appName ? { appName: options.appName } : {}), + from: options.emailFrom?.trim() + ? options.emailFrom + : `signin@${new URL(options.origin).hostname}`, + }; + message = await (options.renderEmail ?? defaultSignInEmail)(input); + if (!addressedOnlyTo(message, email)) + throw new Error('The rendered sign-in email has the wrong recipients'); + } catch (error) { + diagnostic('email.delivery.failed', { + reason: 'unexpected_error', + ...diagnosticError(error), + }); + throw error; + } + try { + await options.email.send(message); + diagnostic('email.delivery.succeeded'); + } catch (error) { + diagnostic('email.delivery.failed', { + stage: 'transport', + reason: 'upstream_failure', + ...diagnosticError(error), + }); + throw error; + } +} + export interface AuthOptions { database: ReturnType; diagnostics?: DiagnosticOptions; @@ -77,6 +162,25 @@ export interface AuthOptions { /** Keeps a hook's pending promise alive past the response; the Workers adapter passes `ExecutionContext.waitUntil`. */ waitUntil?: (promise: Promise) => void; appName?: string; + /** + * The sender of the default sign-in email, and `from` in `renderEmail`'s + * input. Defaults to `signin@`, so a deployment that + * forgets it is refused by its mail provider rather than sending as another + * domain. A blank string counts as omitted, so a preview that blanks its + * mail bindings keeps working. `postmarkEmail`'s own `from` argument + * overrides any message sender. + */ + emailFrom?: string; + /** + * Composes the sign-in email; defaults to `defaultSignInEmail`. The message + * must be addressed only to `input.email` (no other `to`, `cc` or `bcc`): any + * other recipients, or a throw, fail the send with an + * `email.delivery.failed` diagnostic and nothing is delivered. Escape + * interpolated values in `html` with `escapeHtml` from `pgstencil`. + */ + renderEmail?: ( + input: SignInEmailInput, + ) => EmailMessage | Promise; successPath?: string; errorPath?: string; } @@ -366,34 +470,17 @@ export function authOptions(options: AuthOptions): BetterAuthOptions { : []), emailOTP({ otpLength: 8, - expiresIn: 600, + expiresIn: otpExpiresInSeconds, allowedAttempts: 3, storeOTP: { hash: async (otp) => keyed(options.secret, 'email-otp', otp), }, async sendVerificationOTP({ email, otp, type }) { if (isIdentityEmail(email)) throw new Error('Not a delivery address'); + // Only sign-in is enabled; Better Auth's other email-code flows stay off. if (type !== 'sign-in') - throw new Error('This example only supports sign-in email'); - try { - await options.email.send({ - from: 'signin@example.test', - to: [email], - subject: options.appName - ? `Your ${options.appName} sign-in code` - : 'Your sign-in code', - text: `Your sign-in code is ${otp}. It expires in 10 minutes.`, - html: `

Your sign-in code is ${otp}.

It expires in 10 minutes.

`, - }); - diagnostic('email.delivery.succeeded'); - } catch (error) { - diagnostic('email.delivery.failed', { - stage: 'transport', - reason: 'upstream_failure', - ...diagnosticError(error), - }); - throw error; - } + throw new Error(`Email codes are not supported for ${type}`); + await sendSignInEmail(options, email, otp); }, }), ], diff --git a/packages/auth/src/db.generated.ts b/packages/auth/src/db.generated.ts deleted file mode 100644 index 619a745..0000000 --- a/packages/auth/src/db.generated.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * This file was generated by kysely-codegen. - * Please do not edit it manually. - */ - -import type { ColumnType } from "kysely"; - -export type Generated = T extends ColumnType - ? ColumnType - : ColumnType; - -export type Timestamp = ColumnType; - -export interface LoginChallenges { - attempts: Generated; - code_digest: string; - consumed_at: Timestamp | null; - created_at: Timestamp; - delivered_at: Timestamp | null; - email: string; - expires_at: Timestamp; - flow_id: string; - id: string; - invalidated_at: Timestamp | null; - link_hash: string; -} - -export interface LoginFlows { - binding_hash: string; - created_at: Timestamp; - csrf_hash: string; - email: string | null; - expires_at: Timestamp; - id: string; -} - -export interface OauthFlows { - browser_hash: string; - consumed_at: Timestamp | null; - created_at: Timestamp; - expires_at: Timestamp; - link_session_hash: string | null; - link_user_id: string | null; - provider: string; - redirect_uri: string; - state_hash: string; -} - -export interface OauthIdentities { - created_at: Timestamp; - provider: string; - subject: string; - user_id: string; -} - -export interface OauthLocks { - id: number; -} - -export interface RateLimits { - count: number; - key: string; - window_start: Timestamp; -} - -export interface Sessions { - created_at: Timestamp; - csrf_hash: string; - expires_at: Timestamp; - revoked_at: Timestamp | null; - token_hash: string; - user_id: string; -} - -export interface Users { - created_at: Timestamp; - email: string; - id: string; -} - -export interface DB { - login_challenges: LoginChallenges; - login_flows: LoginFlows; - oauth_flows: OauthFlows; - oauth_identities: OauthIdentities; - oauth_locks: OauthLocks; - rate_limits: RateLimits; - sessions: Sessions; - users: Users; -} diff --git a/packages/auth/src/email.ts b/packages/auth/src/email.ts deleted file mode 100644 index 1491d3d..0000000 --- a/packages/auth/src/email.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { EmailMessage } from 'pgstencil'; -const ESCAPES: Record = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', -}; -export function escape(value: unknown): string { - return String(value).replace(/[&<>"']/g, (ch) => ESCAPES[ch]!); -} -// Locale resolution is the expensive part; build the formatter once. -const UTC_FORMAT = new Intl.DateTimeFormat('en-US', { - dateStyle: 'medium', - timeStyle: 'short', - timeZone: 'UTC', -}); -export function date(value: Date): string { - return UTC_FORMAT.format(value) + ' UTC'; -} -export function loginEmail( - email: string, - code: string, - link: string, - expiresAt: Date, -): EmailMessage { - const display = `${code.slice(0, 4)} ${code.slice(4)}`; - return { - from: 'pgstencil ', - to: [email], - subject: 'Your pgstencil sign-in code', - text: `Your sign-in code is ${display}.\n\nOr sign in using this link: ${link}\n\nExpires ${date(expiresAt)}. Open the link in the browser where you requested it, or enter this code there.\n\nIf you didn’t request this email, you can ignore it.`, - html: `

Your sign-in code

Enter this code in the browser where you requested it:

${display}

Or sign in to pgstencil.

Expires ${escape(date(expiresAt))}. Your code and link can only be used once.

If you didn’t request this email, you can ignore it.

`, - }; -} diff --git a/packages/auth/src/fetch.ts b/packages/auth/src/fetch.ts deleted file mode 100644 index d80f95f..0000000 --- a/packages/auth/src/fetch.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { Auth, SESSION_MS } from './auth.ts'; -import { OAuth } from './oauth.ts'; -import type { Provider } from './oauth-providers.ts'; -import { cookieValues, normalizeEmail, sessionCookie } from './security.ts'; -export { HttpError } from './http-error.ts'; -import { HttpError } from './http-error.ts'; - -/** Read incrementally so a missing or forged Content-Length cannot bypass the limit. */ -export async function readRequestBody( - req: Request, - limit = 8192, -): Promise { - const reader = req.body?.getReader(); - if (!reader) return ''; - const decoder = new TextDecoder(); - let size = 0, - body = ''; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > limit) { - await reader.cancel(); - throw new HttpError(413, 'Request too large.'); - } - body += decoder.decode(value, { stream: true }); - } - return body + decoder.decode(); - } finally { - reader.releaseLock(); - } -} -export async function readJsonRequest( - req: Request, -): Promise> { - if ( - req.headers.get('content-type')?.split(';')[0]?.trim() !== - 'application/json' - ) - throw new HttpError(415, 'Send JSON.'); - try { - const value: unknown = JSON.parse(await readRequestBody(req)); - if (!value || typeof value !== 'object' || Array.isArray(value)) - throw new Error(); - return value as Record; - } catch (error) { - if (error instanceof HttpError) throw error; - throw new HttpError(400, 'Invalid JSON.'); - } -} -export interface SessionView { - user: { id: string; email: string }; - createdAt: string; - expiresAt: string; -} -export interface AuthState { - session: SessionView | null; - csrf: string; - pendingEmail: string | null; - providers: Provider[]; -} - -/** JSON adapter for SPA shells; cookies and all security decisions stay server-side. */ -export function createAuthFetch(options: { - auth: Auth; - oauth: OAuth; - secure: boolean; - loginPath?: string; - accountPath?: string; - /** Only use a forwarded address after your trusted proxy overwrites that header. */ - clientAddress?: (req: Request) => string; -}) { - const { auth, oauth, secure } = options; - const origin = auth.deps.origin; - if (new URL(origin).origin !== origin) - throw new Error('Auth requires an origin only'); - const loginPath = options.loginPath ?? '/login'; - const accountPath = options.accountPath ?? '/profile'; - for (const path of [loginPath, accountPath]) - if (!path.startsWith('/') || new URL(path, origin).origin !== origin) - throw new Error('Auth redirects must be same-origin'); - const names = { - session: secure ? '__Host-pgstencil' : 'pgstencil_dev', - pending: secure ? '__Host-pgstencil-pending' : 'pgstencil_pending', - oauth: secure ? '__Host-pgstencil-oauth' : 'pgstencil_oauth', - }; - const source = options.clientAddress ?? (() => 'unknown'); - const rawSession = (req: Request) => - cookieValues(req.headers.get('cookie') ?? undefined)[names.session]; - const session = (req: Request) => auth.session(rawSession(req)); - const csrfHeader = (req: Request) => req.headers.get('x-csrf-token') ?? ''; - function setCookie( - res: Headers, - name: string, - value: string, - seconds: number, - ) { - res.append( - 'set-cookie', - sessionCookie(name, value, auth.deps.time.now(), seconds, secure), - ); - } - function redirect(headers: Headers, path: string) { - headers.set('location', path); - return new Response(null, { status: 303, headers }); - } - function json(headers: Headers, value: unknown) { - headers.set('content-type', 'application/json; charset=utf-8'); - headers.set('x-content-type-options', 'nosniff'); - return new Response(JSON.stringify(value), { headers }); - } - function checkOrigin(req: Request) { - if (req.headers.get('origin') !== origin) - throw new HttpError(403, 'Return to this site and try again.'); - } - async function pending(req: Request) { - return auth.pending( - cookieValues(req.headers.get('cookie') ?? undefined)[names.pending], - ); - } - async function authorize(req: Request, allowPending = false) { - checkOrigin(req); - const current = await session(req); - if (current && auth.validSessionCsrf(current, csrfHeader(req))) - return current; - const flow = allowPending ? await pending(req) : undefined; - if (flow && auth.validCsrf(flow, csrfHeader(req))) return undefined; - throw new HttpError( - current || allowPending ? 403 : 401, - current || allowPending - ? 'Refresh this page and try again.' - : 'Sign in to continue.', - ); - } - function signedIn(res: Headers, value: string) { - setCookie(res, names.session, value, SESSION_MS / 1000); - setCookie(res, names.pending, '', 0); - } - async function handle(req: Request): Promise { - const incoming = new URL(req.url); - const url = new URL(incoming.pathname + incoming.search, origin); - const callback = /^\/oauth\/(google|github|apple|facebook)\/callback$/.exec( - url.pathname, - ); - if ( - !url.pathname.startsWith('/api/auth/') && - url.pathname !== '/login/link' && - !callback - ) - return undefined; - const res = new Headers({ - 'cache-control': 'no-store', - 'referrer-policy': 'no-referrer', - }); - if (req.method === 'POST' && callback?.[1] === 'apple') { - if (!oauth.providers.enabled.includes('apple')) - throw new HttpError(404, 'Sign-in method unavailable.'); - if ( - req.headers.get('content-type')?.split(';')[0]?.trim() !== - 'application/x-www-form-urlencoded' - ) - throw new HttpError(415, 'Send form data.'); - const form = new URLSearchParams(await readRequestBody(req)); - return redirect(res, appleCallbackLocation(form, url.pathname)); - } - if (req.method === 'GET' && callback) { - const provider = oauth.providers.enabled.find((id) => id === callback[1]); - if (!provider) throw new HttpError(404, 'Sign-in method unavailable.'); - const result = await oauth.complete( - provider, - url, - cookieValues(req.headers.get('cookie') ?? undefined)[names.oauth], - rawSession(req), - ); - if (result.clearCookie) setCookie(res, names.oauth, '', 0); - if (result.result.ok) { - signedIn(res, result.result.session); - return redirect(res, accountPath); - } else - return redirect( - res, - `${loginPath}?error=${encodeURIComponent(result.result.message)}`, - ); - } - if (req.method === 'GET' && url.pathname === '/login/link') { - // A link preview never consumes a challenge. The SPA presents a confirm button. - return redirect( - res, - `${loginPath}#${new URLSearchParams({ id: url.searchParams.get('id') ?? '', token: url.searchParams.get('token') ?? '' })}`, - ); - } - if (req.method === 'GET' && url.pathname === '/api/auth/session') { - const current = await session(req); - let flow = await pending(req); - if (!current && !flow) { - flow = await auth.newFlow(); - setCookie(res, names.pending, flow.cookie, 30 * 60); - } - return json(res, { - session: current - ? { - user: { id: current.user_id, email: current.email }, - createdAt: current.created_at.toISOString(), - expiresAt: current.expires_at.toISOString(), - } - : null, - csrf: current ? auth.sessionCsrf(rawSession(req)!) : flow!.csrf, - pendingEmail: flow?.flow.email ?? null, - providers: [...oauth.providers.enabled], - } satisfies AuthState); - } - if (req.method === 'GET' && url.pathname === '/api/auth/providers') { - const current = await session(req); - if (!current) throw new HttpError(401, 'Sign in to continue.'); - const identities = await auth.deps.db - .selectFrom('oauth_identities') - .select('provider') - .where('user_id', '=', current.user_id) - .orderBy('provider') - .execute(); - return json( - res, - identities.map((identity) => identity.provider), - ); - } - if (req.method !== 'POST') throw new HttpError(404, 'Route not found.'); - checkOrigin(req); - const body = await readJsonRequest(req); - const text = (key: string) => - typeof body[key] === 'string' ? (body[key] as string) : ''; - if (url.pathname === '/api/auth/logout') { - await authorize(req); - await auth.logout(rawSession(req)!); - setCookie(res, names.session, '', 0); - return json(res, { ok: true }); - } - const oauthRoute = - /^\/api\/auth\/oauth\/(google|github|apple|facebook)\/(start|connect)$/.exec( - url.pathname, - ); - if (oauthRoute?.[2] === 'connect') { - const current = await authorize(req); - const provider = oauth.providers.enabled.find( - (id) => id === oauthRoute[1], - ); - if (!provider) throw new HttpError(404, 'Sign-in method unavailable.'); - const result = await oauth.begin(provider, source(req), current); - if (!result.ok) throw new HttpError(result.status, result.message); - setCookie(res, names.oauth, result.cookie, result.seconds); - return json(res, { url: result.url }); - } - const flow = await pending(req); - if (!flow || !auth.validCsrf(flow, csrfHeader(req))) - throw new HttpError(403, 'Start a new sign-in.'); - if (oauthRoute) { - const provider = oauth.providers.enabled.find( - (id) => id === oauthRoute[1], - ); - if (!provider) throw new HttpError(404, 'Sign-in method unavailable.'); - const result = await oauth.begin(provider, source(req)); - if (!result.ok) throw new HttpError(result.status, result.message); - setCookie(res, names.oauth, result.cookie, result.seconds); - return json(res, { url: result.url }); - } - if ( - url.pathname === '/api/auth/email' || - url.pathname === '/api/auth/resend' - ) { - const email = normalizeEmail( - url.pathname.endsWith('/resend') - ? (flow.flow.email ?? '') - : text('email'), - ); - if (!email) throw new HttpError(400, 'Enter a valid email address.'); - const result = await auth.send(flow, email, source(req)); - if (!result.ok) throw new HttpError(result.status, result.message); - return json(res, { ok: true }); - } - if (url.pathname === '/api/auth/verify') { - const method = text('method'); - if (method !== 'code' && method !== 'link') - throw new HttpError(400, 'Choose code or link verification.'); - const result = await auth.verify( - flow, - method, - text('value'), - text('id') || undefined, - source(req), - rawSession(req), - ); - if (!result.ok) throw new HttpError(result.status, result.message); - signedIn(res, result.session); - return json(res, { ok: true }); - } - throw new HttpError(404, 'Route not found.'); - } - return { handle, session, authorize, names, checkOrigin }; -} - -/** Relay Apple's cross-site POST to a GET that carries the browser's Lax cookies. */ -export function appleCallbackLocation( - form: URLSearchParams, - pathname: string, -): string { - const query = new URLSearchParams(); - for (const key of ['state', 'code', 'error']) { - const values = form.getAll(key); - if (values.length > 1) throw new HttpError(400, 'Invalid callback.'); - if (values[0]) query.set(key, values[0]); - } - // No tokens are exchanged until the GET checks state AND the browser cookie. - return `${pathname}?${query}`; -} diff --git a/packages/auth/src/hono.ts b/packages/auth/src/hono.ts deleted file mode 100644 index 8698d42..0000000 --- a/packages/auth/src/hono.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Hono, type Context, type Env } from 'hono'; -import { createAuthFetch, HttpError } from './fetch.ts'; - -export type AuthFetch = ReturnType; -/** The factory may acquire request-scoped services from Hono context variables. */ -export function createAuthHono( - factory: AuthFetch | ((context: Context) => AuthFetch), -) { - const app = new Hono(); - app.onError( - (error) => - new Response( - JSON.stringify({ - error: - error instanceof HttpError - ? error.message - : 'Sign-in is temporarily unavailable. Please try again.', - }), - { - status: error instanceof HttpError ? error.status : 503, - headers: { - 'content-type': 'application/json', - 'cache-control': 'no-store', - 'referrer-policy': 'no-referrer', - }, - }, - ), - ); - app.all('*', async (c) => { - const api = typeof factory === 'function' ? factory(c) : factory; - return (await api.handle(c.req.raw)) ?? c.notFound(); - }); - return app; -} diff --git a/packages/auth/src/http-error.ts b/packages/auth/src/http-error.ts deleted file mode 100644 index 9cd99ef..0000000 --- a/packages/auth/src/http-error.ts +++ /dev/null @@ -1,8 +0,0 @@ -export class HttpError extends Error { - constructor( - readonly status: number, - message: string, - ) { - super(message); - } -} diff --git a/packages/auth/src/http.ts b/packages/auth/src/http.ts deleted file mode 100644 index a142bc9..0000000 --- a/packages/auth/src/http.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'node:http'; -import { createAuthFetch } from './fetch.ts'; -import { HttpError } from './http-error.ts'; -export { HttpError } from './http-error.ts'; -export type { SessionView, AuthState } from './fetch.ts'; -export async function readBody( - req: IncomingMessage, - limit = 8192, -): Promise { - const chunks: Buffer[] = []; - let size = 0; - for await (const chunk of req) { - size += (chunk as Buffer).length; - if (size > limit) throw new HttpError(413, 'Request too large.'); - chunks.push(Buffer.from(chunk)); - } - return Buffer.concat(chunks); -} -export async function readJson( - req: IncomingMessage, -): Promise> { - if (req.headers['content-type']?.split(';')[0]?.trim() !== 'application/json') - throw new HttpError(415, 'Send JSON.'); - try { - const value: unknown = JSON.parse((await readBody(req)).toString()); - if (!value || typeof value !== 'object' || Array.isArray(value)) - throw new Error(); - return value as Record; - } catch (error) { - if (error instanceof HttpError) throw error; - throw new HttpError(400, 'Invalid JSON.'); - } -} -export function sendJson(res: ServerResponse, value: unknown, status = 200) { - res - .writeHead(status, { - 'content-type': 'application/json; charset=utf-8', - 'cache-control': 'no-store', - 'x-content-type-options': 'nosniff', - }) - .end(JSON.stringify(value)); -} - -/** Node bridge; Hono/Workers use the same security decisions through the Fetch adapter. */ -export function createAuthHttp( - options: Omit[0], 'clientAddress'> & { - clientAddress?: (req: IncomingMessage) => string; - }, -) { - const sources = new WeakMap(); - const api = createAuthFetch({ - ...options, - clientAddress: (req) => sources.get(req) ?? 'unknown', - }); - function request(req: IncomingMessage, body?: Buffer) { - const headers = new Headers(); - for (const [key, value] of Object.entries(req.headers)) { - if (Array.isArray(value)) - for (const item of value) headers.append(key, item); - else if (value !== undefined) headers.set(key, value); - } - const value = new Request( - new URL(req.url ?? '/', options.auth.deps.origin), - { - method: req.method ?? 'GET', - headers, - ...(body ? { body: new Uint8Array(body) } : {}), - }, - ); - sources.set( - value, - options.clientAddress?.(req) ?? req.socket.remoteAddress ?? 'unknown', - ); - return value; - } - return { - names: api.names, - session: (req: IncomingMessage) => api.session(request(req)), - authorize: (req: IncomingMessage, allowPending = false) => - api.authorize(request(req), allowPending), - checkOrigin: (req: IncomingMessage) => api.checkOrigin(request(req)), - async handle(req: IncomingMessage, res: ServerResponse): Promise { - const path = new URL(req.url ?? '/', options.auth.deps.origin).pathname; - if ( - !path.startsWith('/api/auth/') && - path !== '/login/link' && - !/^\/oauth\/[^/]+\/callback$/.test(path) - ) - return false; - const response = await api.handle( - request(req, req.method === 'POST' ? await readBody(req) : undefined), - ); - if (!response) return false; - res.statusCode = response.status; - response.headers.forEach((value, key) => { - if (key !== 'set-cookie') res.setHeader(key, value); - }); - const cookies = response.headers.getSetCookie(); - if (cookies.length) res.setHeader('set-cookie', cookies); - res.end(Buffer.from(await response.arrayBuffer())); - return true; - }, - }; -} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 030b6a6..c04ba30 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,5 +1 @@ -export * from './auth.ts'; -export * from './oauth.ts'; -export * from './oauth-providers.ts'; -export * from './security.ts'; -export type { DB as AuthDB } from './db.generated.ts'; +export * from './better-auth.ts'; diff --git a/packages/auth/src/migrations.ts b/packages/auth/src/migrations.ts deleted file mode 100644 index 0a0ebfa..0000000 --- a/packages/auth/src/migrations.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { fileURLToPath } from 'node:url'; -export const authMigrations = fileURLToPath( - new URL('../migrations/', import.meta.url), -); diff --git a/packages/auth/src/oauth-providers.ts b/packages/auth/src/oauth-providers.ts deleted file mode 100644 index f59952e..0000000 --- a/packages/auth/src/oauth-providers.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { createHmac } from 'node:crypto'; -import * as client from 'openid-client'; -import { normalizeEmail } from './security.ts'; - -export const PROVIDERS = ['google', 'github', 'apple', 'facebook'] as const; -export type Provider = (typeof PROVIDERS)[number]; -export const PROVIDER_LABELS: Record = { - google: 'Google', - github: 'GitHub', - apple: 'Apple', - facebook: 'Facebook', -}; -export const PROVIDER_ORIGINS: Record = { - google: 'https://accounts.google.com', - github: 'https://github.com', - apple: 'https://appleid.apple.com', - facebook: 'https://www.facebook.com', -}; -export interface OAuthCredentials { - clientId: string; - clientSecret: string; -} -export type OAuthSettings = Partial>; -export type OAuthFetch = client.CustomFetch; -export interface OAuthProof { - state: string; - verifier: string; - nonce: string; - redirectUri: string; -} -export interface ProviderIdentity { - subject: string; - email: string; -} -export class IdentityError extends Error {} -const GITHUB_HEADERS = new Headers({ - accept: 'application/vnd.github+json', - 'x-github-api-version': '2026-03-10', - 'user-agent': 'pgstencil', -}); -function object(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) - throw new IdentityError('Invalid provider response'); - return value as Record; -} -function verifiedEmail(value: unknown): string { - const email = typeof value === 'string' ? normalizeEmail(value) : undefined; - if (!email) throw new IdentityError('A verified email address is required.'); - return email; -} - -/** The OAuth/OIDC protocol belongs to openid-client; provider-specific identity policy lives here. */ -export class OAuthProviders { - readonly enabled: Provider[]; - private configurations = new Map>(); - constructor( - private readonly settings: OAuthSettings, - private readonly transport?: OAuthFetch, - ) { - this.enabled = PROVIDERS.filter( - (provider) => settings[provider] !== undefined, - ); - for (const provider of this.enabled) { - const credentials = settings[provider]!; - if (!credentials.clientId.trim() || !credentials.clientSecret.trim()) - throw new Error( - `${PROVIDER_LABELS[provider]} requires a client ID and client secret`, - ); - } - } - private configuration(provider: Provider): Promise { - if (!this.enabled.includes(provider)) - throw new Error('OAuth provider is not configured'); - let configuration = this.configurations.get(provider); - if (!configuration) { - configuration = this.configure(provider).catch((error) => { - this.configurations.delete(provider); // Discovery failures must be retryable. - throw error; - }); - this.configurations.set(provider, configuration); - } - return configuration; - } - private async configure(provider: Provider): Promise { - const { clientId, clientSecret } = this.settings[provider]!; - if (provider === 'google' || provider === 'apple') { - return client.discovery( - new URL(PROVIDER_ORIGINS[provider]), - clientId, - { client_secret: clientSecret, id_token_signed_response_alg: 'RS256' }, - client.ClientSecretPost(clientSecret), - { - timeout: 10, - execute: [client.enableNonRepudiationChecks], - ...(this.transport ? { [client.customFetch]: this.transport } : {}), - }, - ); - } - if (provider === 'facebook') { - // Meta's unversioned endpoints follow the application's configured API version. - const config = new client.Configuration( - { - issuer: PROVIDER_ORIGINS.facebook, - authorization_endpoint: 'https://www.facebook.com/dialog/oauth', - token_endpoint: 'https://graph.facebook.com/oauth/access_token', - response_types_supported: ['code'], - }, - clientId, - clientSecret, - client.ClientSecretPost(clientSecret), - ); - config.timeout = 10; - if (this.transport) config[client.customFetch] = this.transport; - return config; - } - // GitHub implements OAuth 2, but does not publish OIDC discovery metadata. - const config = new client.Configuration( - { - issuer: 'https://github.com', - authorization_endpoint: 'https://github.com/login/oauth/authorize', - token_endpoint: 'https://github.com/login/oauth/access_token', - response_types_supported: ['code'], - code_challenge_methods_supported: ['S256'], - }, - clientId, - clientSecret, - client.ClientSecretPost(clientSecret), - ); - config.timeout = 10; - if (this.transport) config[client.customFetch] = this.transport; - return config; - } - async authorizationUrl( - provider: Provider, - proof: OAuthProof, - connecting: boolean, - ): Promise { - const config = await this.configuration(provider); - const parameters: Record = { - redirect_uri: proof.redirectUri, - scope: - provider === 'github' - ? 'read:user user:email' - : provider === 'facebook' - ? 'email' - : 'openid email', - state: proof.state, - }; - if (provider === 'google' || provider === 'github') { - parameters.code_challenge = await client.calculatePKCECodeChallenge( - proof.verifier, - ); - parameters.code_challenge_method = 'S256'; - } - if (provider === 'google' || provider === 'apple') - parameters.nonce = proof.nonce; - if (provider === 'apple') parameters.response_mode = 'form_post'; - if (connecting && provider === 'google') - parameters.prompt = 'select_account'; - if (connecting && provider === 'facebook') - parameters.auth_type = 'reauthenticate'; - return client.buildAuthorizationUrl(config, parameters); - } - async identity( - provider: Provider, - callback: URL, - proof: OAuthProof, - ): Promise { - const config = await this.configuration(provider); - const tokens = await client.authorizationCodeGrant(config, callback, { - expectedState: proof.state, - ...(provider === 'google' || provider === 'github' - ? { pkceCodeVerifier: proof.verifier } - : {}), - ...(provider === 'google' || provider === 'apple' - ? { expectedNonce: proof.nonce, idTokenExpected: true } - : {}), - }); - // Tokens are used only during this call. Never persist or log them. - if (provider === 'google' || provider === 'apple') { - const claims = tokens.claims(); - if ( - !claims || - !( - claims.email_verified === true || - (provider === 'apple' && claims.email_verified === 'true') - ) - ) - throw new IdentityError( - `${PROVIDER_LABELS[provider]} must provide a verified email address.`, - ); - if ( - typeof claims.sub !== 'string' || - !/^[\x21-\x7e]{1,255}$/.test(claims.sub) - ) - throw new IdentityError( - `Invalid ${PROVIDER_LABELS[provider]} identity`, - ); - return { subject: claims.sub, email: verifiedEmail(claims.email) }; - } - if (provider === 'facebook') { - const url = new URL('https://graph.facebook.com/me'); - url.searchParams.set('fields', 'id,email'); - url.searchParams.set( - 'appsecret_proof', - createHmac('sha256', this.settings.facebook!.clientSecret) - .update(tokens.access_token) - .digest('hex'), - ); - const response = await client.fetchProtectedResource( - config, - tokens.access_token, - url, - 'GET', - ); - if (!response.ok) throw new Error('Facebook identity request failed'); - const profile = object(await response.json()); - if (typeof profile.id !== 'string' || !/^[0-9]{1,255}$/.test(profile.id)) - throw new IdentityError('Invalid Facebook identity'); - // Facebook's authenticated primary email is trusted as in Supabase's - // Facebook adapter. Missing email/denied permission cannot create an account. - // Matching emails never silently link two provider identities. - return { subject: profile.id, email: verifiedEmail(profile.email) }; - } - const resource = async (url: string) => { - const response = await client.fetchProtectedResource( - config, - tokens.access_token, - new URL(url), - 'GET', - undefined, - GITHUB_HEADERS, - ); - if (!response.ok) throw new Error('GitHub identity request failed'); - return (await response.json()) as unknown; - }; - const emailPage = (page: number) => - resource(`https://api.github.com/user/emails?per_page=100&page=${page}`); - // The profile and the first page of addresses depend only on the access - // token, so one round trip serves both. - const [profileResponse, firstPage] = await Promise.all([ - resource('https://api.github.com/user'), - emailPage(1), - ]); - const profile = object(profileResponse); - if ( - typeof profile.id !== 'number' || - !Number.isSafeInteger(profile.id) || - profile.id <= 0 - ) - throw new IdentityError('Invalid GitHub identity'); - // /user.email may be null, public, or stale. The authenticated email list - // explicitly identifies the verified primary address, including private ones. - for (let page = 1; page <= 10; page++) { - const addresses = page === 1 ? firstPage : await emailPage(page); - if (!Array.isArray(addresses)) - throw new IdentityError('Invalid GitHub email response'); - for (const value of addresses) { - const address = object(value); - if (address.primary === true && address.verified === true) - return { - subject: String(profile.id), - email: verifiedEmail(address.email), - }; - } - if (addresses.length < 100) break; - } - throw new IdentityError( - 'GitHub must provide a verified primary email address.', - ); - } -} - -export function oauthFromEnvironment( - env: Record, -): OAuthSettings { - const settings: OAuthSettings = {}; - for (const provider of PROVIDERS) { - const prefix = provider.toUpperCase(); - const clientId = env[`${prefix}_CLIENT_ID`]; - const clientSecret = env[`${prefix}_CLIENT_SECRET`]; - if (!clientId && !clientSecret) continue; - if (!clientId?.trim() || !clientSecret?.trim()) - throw new Error( - `Set both ${prefix}_CLIENT_ID and ${prefix}_CLIENT_SECRET`, - ); - settings[provider] = { clientId, clientSecret }; - } - return settings; -} diff --git a/packages/auth/src/oauth.ts b/packages/auth/src/oauth.ts deleted file mode 100644 index ba9afb0..0000000 --- a/packages/auth/src/oauth.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { sql, type Transaction } from 'kysely'; -import { token } from 'pgstencil'; -import { - Auth, - type AuthResult, - type AuthFailure, - type SessionRow, -} from './auth.ts'; -import type { DB, OauthFlows } from './db.generated.ts'; -import type { Selectable } from 'kysely'; -import { digest } from './security.ts'; -import { - OAuthProviders, - PROVIDER_LABELS, - IdentityError, - type Provider, - type OAuthProof, - type ProviderIdentity, -} from './oauth-providers.ts'; - -export const OAUTH_MS = 10 * 60_000; -export const CONNECT_FRESH_MS = 5 * 60_000; -type Session = SessionRow; -const failure = (message: string, status = 400): AuthFailure => ({ - ok: false, - status, - message, -}); -const unavailable = () => - failure('This sign-in attempt is invalid or has expired. Start again.'); -const reconnect = () => - failure('Sign in again before connecting another sign-in method.', 403); - -export class OAuth { - constructor( - readonly auth: Auth, - readonly providers: OAuthProviders, - ) {} - private proof(state: string, redirectUri: string): OAuthProof { - // Derivation keeps verifiers/nonces out of storage and works across server - // instances sharing the application secret. Each state is fresh randomness. - return { - state, - redirectUri, - verifier: this.auth.derive('oauth-pkce', state), - nonce: this.auth.derive('oauth-nonce', state), - }; - } - private fresh(session: Session): boolean { - const age = - this.auth.deps.time.now().getTime() - session.created_at.getTime(); - return this.auth.live(session) && age >= 0 && age < CONNECT_FRESH_MS; - } - async begin( - provider: Provider, - source: string, - linking?: Session, - ): Promise< - AuthFailure | { ok: true; url: string; cookie: string; seconds: number } - > { - if (linking && !this.fresh(linking)) return reconnect(); - if (!(await this.auth.allowOAuthStart(source))) - return failure('Too many sign-in attempts. Please try again later.', 429); - const { random, time, db, origin } = this.auth.deps; - const state = token(random); - const browser = token(random); - const redirectUri = `${origin}/oauth/${provider}/callback`; - const url = await this.providers.authorizationUrl( - provider, - this.proof(state, redirectUri), - linking !== undefined, - ); - const now = time.now(); - const expires = new Date( - Math.min( - now.getTime() + OAUTH_MS, - linking ? linking.created_at.getTime() + CONNECT_FRESH_MS : Infinity, - ), - ); - await db - .insertInto('oauth_flows') - .values({ - state_hash: digest(state), - provider, - browser_hash: digest(browser), - redirect_uri: redirectUri, - link_user_id: linking?.user_id ?? null, - link_session_hash: linking?.token_hash ?? null, - created_at: now, - expires_at: expires, - consumed_at: null, - }) - .execute(); - return { - ok: true, - url: url.href, - cookie: browser, - seconds: Math.max( - 0, - Math.floor((expires.getTime() - now.getTime()) / 1000), - ), - }; - } - /** Maps a verified provider identity to the account it may sign into. */ - private async resolveUser( - trx: Transaction, - provider: Provider, - flow: Selectable, - identity: ProviderIdentity, - now: Date, - ): Promise<{ userId: string } | AuthFailure> { - const { random } = this.auth.deps; - const existing = await trx - .selectFrom('oauth_identities') - .select('user_id') - .where('provider', '=', provider) - .where('subject', '=', identity.subject) - .executeTakeFirst(); - if (flow.link_user_id && flow.link_session_hash) { - const session = await this.auth.lockedSession( - trx, - flow.link_session_hash, - ); - if ( - !session || - session.user_id !== flow.link_user_id || - !this.fresh(session) - ) - return reconnect(); - if (session.email !== identity.email) - return failure( - 'The provider must verify the same email address as your account.', - 403, - ); - if (existing && existing.user_id !== session.user_id) - return failure( - 'This provider account is already connected to another account.', - 409, - ); - if (!existing) { - const connected = await trx - .insertInto('oauth_identities') - .values({ - provider, - subject: identity.subject, - user_id: session.user_id, - created_at: now, - }) - .onConflict((c) => c.doNothing()) - .returning('user_id') - .executeTakeFirst(); - if (!connected) - return failure( - 'A different account from this provider is already connected.', - 409, - ); - } - return { userId: session.user_id }; - } - // A changed email/handle must never change which account a stable - // provider identity signs into, or silently update its recovery email. - if (existing) return { userId: existing.user_id }; - const user = await trx - .insertInto('users') - .values({ id: token(random, 16), email: identity.email, created_at: now }) - .onConflict((c) => c.column('email').doNothing()) - .returning('id') - .executeTakeFirst(); - if (!user) - return failure( - 'This email already has an account. Sign in with email or an existing method, then connect this provider from your account.', - 409, - ); - await trx - .insertInto('oauth_identities') - .values({ - provider, - subject: identity.subject, - user_id: user.id, - created_at: now, - }) - .execute(); - return { userId: user.id }; - } - async complete( - provider: Provider, - callback: URL, - browser: string | undefined, - rawSession: string | undefined, - ): Promise<{ result: AuthResult; clearCookie: boolean }> { - const state = callback.searchParams.get('state'); - if ( - !state || - callback.searchParams.getAll('state').length !== 1 || - !/^[\w-]{43}$/.test(state) || - !browser || - !/^[\w-]{43}$/.test(browser) - ) - return { result: unavailable(), clearCookie: false }; - const { db, time, random } = this.auth.deps; - // Claim once before network I/O. Concurrent callbacks cannot both exchange - // the code, and failures/cancellation cannot be replayed into a session. - const flow = await db - .updateTable('oauth_flows') - .set({ consumed_at: time.now() }) - .where('state_hash', '=', digest(state)) - .where('provider', '=', provider) - .where('browser_hash', '=', digest(browser)) - .where('redirect_uri', '=', callback.origin + callback.pathname) - .where('expires_at', '>', time.now()) - .where('consumed_at', 'is', null) - .returningAll() - .executeTakeFirst(); - if (!flow) return { result: unavailable(), clearCookie: false }; - const done = (result: AuthResult) => ({ result, clearCookie: true }); - if (callback.searchParams.has('error')) - return done(failure('Sign-in was not completed. You can try again.')); - if ( - flow.link_session_hash && - (!rawSession || digest(rawSession) !== flow.link_session_hash) - ) - return done(reconnect()); - try { - const identity = await this.providers.identity( - provider, - callback, - this.proof(state, flow.redirect_uri), - ); - const result = await db - .transaction() - .execute(async (trx): Promise => { - // A bounded set of lock rows also covers identities not inserted yet. - // Hash collisions only serialize unrelated logins; they cannot merge them. - const bucket = - parseInt( - digest(provider + ':' + identity.subject).slice(0, 8), - 16, - ) % 64; - await sql`SELECT id FROM oauth_locks WHERE id = ${bucket} FOR UPDATE`.execute( - trx, - ); - const now = time.now(); - if (flow.expires_at <= now) return unavailable(); - const resolved = await this.resolveUser( - trx, - provider, - flow, - identity, - now, - ); - if ('ok' in resolved) return resolved; - return { - ok: true, - session: await this.auth.issueSession( - trx, - resolved.userId, - rawSession, - now, - ), - }; - }); - return done(result); - } catch (error) { - return done( - failure( - error instanceof IdentityError - ? error.message - : `Could not complete sign-in with ${PROVIDER_LABELS[provider]}. Please try again.`, - error instanceof IdentityError ? 403 : 502, - ), - ); - } - } -} diff --git a/packages/auth/src/postmark.ts b/packages/auth/src/postmark.ts index f269ba9..16afcbb 100644 --- a/packages/auth/src/postmark.ts +++ b/packages/auth/src/postmark.ts @@ -9,7 +9,25 @@ class EmailDeliveryError extends Error { } } -/** Production transport; tests inject EmailDev instead. Provider errors contain no message content. */ +/** Postmark's numeric `ErrorCode`, never its message text, which may echo addresses. */ +async function errorCode(response: Response) { + try { + const code = ((await response.json()) as { ErrorCode?: unknown }) + ?.ErrorCode; + return typeof code === 'number' && Number.isFinite(code) ? code : undefined; + } catch { + return undefined; + } +} + +/** + * Production transport; tests inject EmailDev instead. Every message is sent + * from this adapter's `from` argument, which wins over `message.from` + * (including `emailFrom` and a `renderEmail` sender), so one verified sender + * signature covers the deployment. A failure throws an error carrying only + * `httpStatus` and Postmark's numeric `ErrorCode` as `providerCode`, never + * message content or Postmark's error text. + */ export function postmarkEmail(token: string, from: string): EmailSender { if (!token || !from) throw new Error('Configure Postmark and the sender address'); @@ -41,13 +59,9 @@ export function postmarkEmail(token: string, from: string): EmailSender { : {}), }), }); - if (!response.ok) throw new EmailDeliveryError(response.status); - const result = (await response.json()) as { ErrorCode?: number }; - if (result.ErrorCode !== 0) - throw new EmailDeliveryError( - response.status, - typeof result.ErrorCode === 'number' ? result.ErrorCode : undefined, - ); + const code = await errorCode(response); + if (!response.ok || code !== 0) + throw new EmailDeliveryError(response.status, code); }, }; } diff --git a/packages/auth/src/security.ts b/packages/auth/src/security.ts deleted file mode 100644 index fb46faa..0000000 --- a/packages/auth/src/security.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; -export function digest(value: string): string { - return createHash('sha256').update(value).digest('hex'); -} -export function keyed(secret: string, purpose: string, value: string): string { - return createHmac('sha256', secret) - .update(purpose) - .update('\0') - .update(value) - .digest('hex'); -} -export function equalDigest(a: string, b: string): boolean { - const left = Buffer.from(a, 'hex'); - const right = Buffer.from(b, 'hex'); - return ( - left.length === 32 && right.length === 32 && timingSafeEqual(left, right) - ); -} -export function cookieValues( - header: string | undefined, -): Record { - const values = Object.create(null) as Record; - for (const part of (header ?? '').split(';')) { - const position = part.indexOf('='); - if (position > 0) - values[part.slice(0, position).trim()] = part.slice(position + 1).trim(); - } - return values; -} -export function sessionCookie( - name: string, - value: string, - now: Date, - lifetimeSeconds: number, - secure: boolean, -): string { - return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${lifetimeSeconds}; Expires=${new Date(lifetimeSeconds === 0 ? 0 : now.getTime() + lifetimeSeconds * 1000).toUTCString()}${secure ? '; Secure' : ''}`; -} -export function normalizeEmail(input: string): string | undefined { - const email = input.trim().toLowerCase(); - return email.length <= 254 && /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(email) - ? email - : undefined; -} diff --git a/packages/auth/src/workers.ts b/packages/auth/src/workers.ts deleted file mode 100644 index 9a240cc..0000000 --- a/packages/auth/src/workers.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Hono } from 'hono'; -import { - SecureRandom, - SystemTime, - type EmailSender, - type Time, - type RandomSource, -} from 'pgstencil'; -import { connectDatabase } from 'pgstencil/postgres'; -import { Auth } from './auth.ts'; -import type { loginEmail } from './email.ts'; -import type { DB } from './db.generated.ts'; -import { OAuth } from './oauth.ts'; -import { - OAuthProviders, - PROVIDERS, - oauthFromEnvironment, - type Provider, - type OAuthFetch, -} from './oauth-providers.ts'; -import { createAuthFetch } from './fetch.ts'; -import { createAuthHono, type AuthFetch } from './hono.ts'; - -export type AuthWorkerBindings = { - HYPERDRIVE: { connectionString: string }; - APP_ORIGIN: string; - AUTH_SECRET: string; -} & Partial< - Record< - `${Uppercase}_CLIENT_ID` | `${Uppercase}_CLIENT_SECRET`, - string - > ->; - -/** Request-scoped database connections: never share sockets across Worker invocations. */ -export function createAuthWorker(options: { - email: (env: E) => EmailSender; - loginPath?: string; - accountPath?: string; - renderEmail?: typeof loginEmail; - /** Injection points belong in a separate test entrypoint, never public HTTP controls. */ - time?: Time; - random?: RandomSource; - oauthFetch?: OAuthFetch; -}) { - type Environment = { Bindings: E; Variables: { auth: AuthFetch } }; - const app = new Hono(); - app.use('*', async (c, next) => { - const env = c.env; - if (new URL(env.APP_ORIGIN).protocol !== 'https:') - throw new Error('Workers auth requires an HTTPS application origin'); - const db = connectDatabase(env.HYPERDRIVE.connectionString); - try { - const auth = new Auth({ - db, - origin: env.APP_ORIGIN, - secret: env.AUTH_SECRET, - time: options.time ?? new SystemTime(), - random: options.random ?? new SecureRandom(), - email: options.email(env), - ...(options.renderEmail ? { renderEmail: options.renderEmail } : {}), - }); - const credentials: Record = {}; - for (const provider of PROVIDERS) - for (const suffix of ['CLIENT_ID', 'CLIENT_SECRET'] as const) { - const key = - `${provider.toUpperCase()}_${suffix}` as keyof AuthWorkerBindings; - const value = env[key]; - if (typeof value === 'string') credentials[key] = value; - } - c.set( - 'auth', - createAuthFetch({ - auth, - oauth: new OAuth( - auth, - new OAuthProviders( - oauthFromEnvironment(credentials), - options.oauthFetch, - ), - ), - secure: true, - ...(options.loginPath ? { loginPath: options.loginPath } : {}), - ...(options.accountPath ? { accountPath: options.accountPath } : {}), - // Cloudflare overwrites this header on inbound requests. - clientAddress: (req) => - req.headers.get('cf-connecting-ip') ?? 'unknown', - }), - ); - await next(); - } finally { - await db.destroy(); - } - }); - app.route( - '/', - createAuthHono((c) => c.get('auth')), - ); - app.onError( - () => - new Response( - JSON.stringify({ - error: 'Sign-in is temporarily unavailable. Please try again.', - }), - { - status: 503, - headers: { - 'content-type': 'application/json', - 'cache-control': 'no-store', - }, - }, - ), - ); - return app; -} diff --git a/packages/pgstencil/package.json b/packages/pgstencil/package.json index 4bbafcf..2bb58a7 100644 --- a/packages/pgstencil/package.json +++ b/packages/pgstencil/package.json @@ -10,6 +10,9 @@ "./postgres": "./src/postgres.ts", "./diagnostics": "./src/diagnostics.ts" }, + "bin": { + "pgstencil": "./src/cli.ts" + }, "dependencies": { "pg": "^8.23.1" }, @@ -18,7 +21,8 @@ "node-pg-migrate": "^9.0.0", "testcontainers": "^12.2.1", "cheerio": "^1.2.0", - "turndown": "^7.2.4" + "turndown": "^7.2.4", + "kysely-codegen": "^0.20.0" }, "peerDependenciesMeta": { "node-pg-migrate": { @@ -32,6 +36,9 @@ }, "turndown": { "optional": true + }, + "kysely-codegen": { + "optional": true } }, "devDependencies": { @@ -39,7 +46,8 @@ "node-pg-migrate": "^9.0.0", "testcontainers": "^12.2.1", "cheerio": "^1.2.0", - "turndown": "^7.2.4" + "turndown": "^7.2.4", + "kysely-codegen": "^0.20.0" }, "engines": { "node": ">=24" @@ -76,6 +84,9 @@ "types": "./dist/diagnostics.d.ts", "import": "./dist/diagnostics.js" } + }, + "bin": { + "pgstencil": "./dist/cli.js" } }, "description": "Postgres schema migrations, isolated test databases, and deterministic snapshot testing for TypeScript.", diff --git a/packages/pgstencil/src/arguments.ts b/packages/pgstencil/src/arguments.ts new file mode 100644 index 0000000..5368757 --- /dev/null +++ b/packages/pgstencil/src/arguments.ts @@ -0,0 +1,103 @@ +/** The `pgstencil` command line, parsed without touching configuration or Docker. */ +export type Command = + | { + name: + 'status' | 'validate' | 'migrate' | 'verify' | 'reset' | 'gc' | 'stop'; + } + | { name: 'create'; label: string; in?: string } + | { name: 'schema' | 'types'; verify: boolean } + | { name: 'help' }; + +export const usage = `Usage: pgstencil [options] + +Commands: + status List applied and pending migrations + validate Check applied migrations still match their SQL files + migrate Apply pending migrations + create [--in ] Add an empty numbered migration to , by default + the last configured source + schema [--verify] Write, or verify, the schema dump + types [--verify] Write, or verify, the Kysely declarations + verify Verify the schema dump and declarations together + reset Recreate the development database + gc Clear cached test templates when no database is in use + stop Stop this project's services when no database is in use + help Show this message + +status, validate and migrate use DATABASE_URL when it is set, otherwise the +development database. schema, types and verify use a disposable database built +from the configured migrations. Configuration is read from pgstencil.json in +PGSTENCIL_PROJECT_ROOT, or the current directory.`; + +/** A command line pgstencil cannot run; the CLI prints it with the usage text. */ +export class UsageError extends Error { + override name = 'UsageError'; +} + +const PLAIN = new Set([ + 'status', + 'validate', + 'migrate', + 'verify', + 'reset', + 'gc', + 'stop', +]); +const LABEL = /^[a-z][a-z0-9_]*$/; + +function noArguments(name: string, rest: readonly string[]): void { + if (rest.length) + throw new UsageError( + `${name} takes no arguments, got ${rest.map((arg) => JSON.stringify(arg)).join(' ')}`, + ); +} + +/** Parse `argv` (without the node and script paths) into a command. */ +export function parseArguments(argv: readonly string[]): Command { + const [name, ...rest] = argv; + if (name === undefined) throw new UsageError('Missing command'); + if (name === 'help' || name === '--help' || name === '-h') { + noArguments(name, rest); + return { name: 'help' }; + } + if (PLAIN.has(name)) { + noArguments(name, rest); + return { name: name as 'status' }; + } + if (name === 'schema' || name === 'types') { + const flags = new Set(rest); + const unknown = rest.filter((arg) => arg !== '--verify'); + if (unknown.length || flags.size !== rest.length) + throw new UsageError( + `${name} accepts only --verify, got ${rest.map((arg) => JSON.stringify(arg)).join(' ')}`, + ); + return { name, verify: flags.has('--verify') }; + } + if (name === 'create') { + let label: string | undefined; + let directory: string | undefined; + for (let index = 0; index < rest.length; index++) { + const arg = rest[index]!; + if (arg === '--in' || arg.startsWith('--in=')) { + if (directory !== undefined) throw new UsageError('--in given twice'); + directory = arg === '--in' ? rest[++index] : arg.slice('--in='.length); + if (!directory || directory.startsWith('-')) + throw new UsageError('--in needs a directory'); + } else if (arg.startsWith('-')) + throw new UsageError(`create does not accept ${arg}`); + else if (label !== undefined) + throw new UsageError(`create takes one name, got ${label} and ${arg}`); + else label = arg; + } + if (label === undefined) + throw new UsageError('create needs a snake_case migration name'); + if (!LABEL.test(label)) + throw new UsageError( + `Migration names are snake_case (a lowercase letter, then lowercase letters, digits or _): ${label}`, + ); + return directory === undefined + ? { name, label } + : { name, label, in: directory }; + } + throw new UsageError(`Unknown command: ${name}`); +} diff --git a/packages/pgstencil/src/cli.ts b/packages/pgstencil/src/cli.ts new file mode 100644 index 0000000..1a7f9d1 --- /dev/null +++ b/packages/pgstencil/src/cli.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env node +/** + * The `pgstencil` command. Arguments are parsed before pgstencil.json or any + * tooling peer loads, so `pgstencil help` and usage errors work in a project + * whose configuration is broken. + */ +import { parseArguments, usage, UsageError } from './arguments.ts'; + +/** Runs the command line `argv` and returns the process exit code. */ +export async function main(argv: readonly string[]): Promise { + let command; + try { + command = parseArguments(argv); + } catch (error) { + if (!(error instanceof UsageError)) throw error; + console.error(`pgstencil: ${error.message}\n\n${usage}`); + return 2; + } + if (command.name === 'help') { + console.log(usage); + return 0; + } + try { + const { runCommand } = await import('./commands.ts'); + await runCommand(command); + return 0; + } catch (error) { + console.error(`pgstencil ${command.name}: ${(error as Error).message}`); + return 1; + } +} + +process.exitCode = await main(process.argv.slice(2)); diff --git a/packages/pgstencil/src/codegen.ts b/packages/pgstencil/src/codegen.ts new file mode 100644 index 0000000..82e5bcc --- /dev/null +++ b/packages/pgstencil/src/codegen.ts @@ -0,0 +1,73 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, relative } from 'node:path'; +import { importPeer } from './peer.ts'; +import { projectRoot } from './paths.ts'; +import type { TypesTarget } from './config.ts'; + +/** pgstencil's own bookkeeping tables, never part of an application's types. */ +const BOOKKEEPING = new Set(['pgmigrations', 'pgstencil_migration_files']); + +const loadCodegen = (entry: string) => + importPeer('kysely-codegen', entry, () => import('kysely-codegen')); + +/** Fails, naming `entry` as the command that needs it, when kysely-codegen is absent. */ +export async function requireCodegen(entry: string): Promise { + await loadCodegen(entry); +} + +/** Kysely declarations for one `types` target, generated from the database at `url`. */ +export async function renderTypes( + url: string, + target: Omit, +): Promise { + const { Cli } = await loadCodegen('pgstencil types'); + return new Cli().generate({ + url, + dialect: 'postgres', + logLevel: 'silent', + // Rendered in memory; generateTypes compares and writes the file itself. + outFile: null, + includePattern: target.include, + excludePattern: target.exclude ?? null, + ...(target.defaultSchema ? { defaultSchemas: [target.defaultSchema] } : {}), + async postprocess({ metadata }) { + metadata.tables = metadata.tables.filter( + (table) => + !( + (table.schema ?? 'public') === 'public' && + BOOKKEEPING.has(table.name) + ), + ); + return metadata; + }, + }); +} + +/** + * Writes `target.out`, or with `verify` throws when it differs from what the + * database at `url` generates. Returns whether the file was (or would be) changed. + */ +export async function generateTypes( + url: string, + target: TypesTarget, + verify = false, +): Promise { + const output = await renderTypes(url, target); + const current = await readFile(target.out, 'utf8').catch( + (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return undefined; + throw error; + }, + ); + if (current === output) return false; + const name = relative(projectRoot, target.out); + if (verify) + throw new Error( + current === undefined + ? `${name} is missing; run pgstencil types` + : `${name} is stale; run pgstencil types`, + ); + await mkdir(dirname(target.out), { recursive: true }); + await writeFile(target.out, output); + return true; +} diff --git a/packages/pgstencil/src/commands.ts b/packages/pgstencil/src/commands.ts new file mode 100644 index 0000000..e52ff31 --- /dev/null +++ b/packages/pgstencil/src/commands.ts @@ -0,0 +1,258 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, relative } from 'node:path'; +import { + activeDatabases, + allocateDatabase, + appliedMigrations, + clearTemplates, + composeFile, + developmentDatabase, + developmentDatabaseName, + ensureServices, + migrate, + projectConfig, + projectName, + projectRoot, + queryDatabase, + readMigrations, + stateDirectory, + validateMigrations, + type DatabaseLease, + type Services, +} from './database.ts'; +import { insideRoot } from './config.ts'; +import { generateTypes, requireCodegen } from './codegen.ts'; +import type { Command } from './arguments.ts'; + +const exec = promisify(execFile); +const statePath = join(stateDirectory, 'services.json'); +const sources = projectConfig.migrations; +const shown = (path: string) => relative(projectRoot, path) || '.'; + +/** + * `unreachableIsIdle` is only for `stop`, which reads a recorded stack that may + * already be down. gc/reset run against a stack we just started, so there a + * failed query is a real fault and must surface. + */ +async function requireIdle( + services: Services, + unreachableIsIdle = false, +): Promise { + const active = unreachableIsIdle + ? await activeDatabases(services).catch(() => []) + : await activeDatabases(services); + if (active.length) + throw new Error( + 'Applications/databases are still in use; stop them before cleanup', + ); +} + +/** `fallback` when the path does not exist; any other failure surfaces. */ +const absent = + (fallback: T) => + (error: NodeJS.ErrnoException): T => { + if (error.code === 'ENOENT') return fallback; + throw error; + }; + +/** Numbers of the SQL files in `directory`; an absent directory has none. */ +async function numbers(directory: string): Promise { + const names = await readdir(directory).catch(absent([])); + return names.flatMap((name) => /^(\d+)_.*\.sql$/.exec(name)?.[1] ?? []); +} + +async function create(label: string, directory?: string): Promise { + const target = + directory === undefined + ? sources[sources.length - 1]! + : insideRoot(projectRoot, directory, '--in'); + if (!sources.includes(target)) + console.error( + `Note: ${shown(target)} is not a migration source in pgstencil.json; add it there to apply this migration.`, + ); + // One sequence across every source, so a filename never collides. + const taken = ( + await Promise.all([...new Set([...sources, target])].map(numbers)) + ).flat(); + const next = String( + Math.max(0, ...taken.map((number) => Number(number))) + 1, + ).padStart(Math.max(3, ...taken.map((number) => number.length)), '0'); + await mkdir(target, { recursive: true }); + const path = join(target, `${next}_${label}.sql`); + await writeFile(path, '-- Up Migration\n\n-- Down Migration\n', { + flag: 'wx', + }); + console.log(path); +} + +async function dumpSchema(lease: DatabaseLease): Promise { + const services = await ensureServices(); + const result = await exec( + 'docker', + [ + 'exec', + services.postgresContainer, + 'pg_dump', + '-U', + 'pgstencil', + '-d', + lease.name, + '--schema-only', + '--no-owner', + '--no-privileges', + '--restrict-key=pgstencil', + ], + { maxBuffer: 16_000_000 }, + ); + return result.stdout.replace(/^-- Dumped .*\n/gm, ''); +} + +/** Writes or verifies the schema dump; returns a problem instead of throwing under `verify`. */ +async function schema( + lease: DatabaseLease, + file: string, + verify: boolean, +): Promise { + const dump = await dumpSchema(lease); + const current = await readFile(file, 'utf8').catch(absent(undefined)); + if (current === dump) { + console.log(`${shown(file)} is up to date`); + return undefined; + } + if (verify) + return `${shown(file)} is ${current === undefined ? 'missing' : 'stale'}; run pgstencil schema`; + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, dump); + console.log(`Wrote ${shown(file)}`); + return undefined; +} + +async function types(lease: DatabaseLease, verify: boolean): Promise { + const problems: string[] = []; + for (const target of projectConfig.types) { + try { + const changed = await generateTypes(lease.url, target, verify); + console.log( + changed + ? `Wrote ${shown(target.out)}` + : `${shown(target.out)} is up to date`, + ); + } catch (error) { + if (!verify) throw error; + problems.push((error as Error).message); + } + } + return problems; +} + +/** schema, types and verify: each generator against one disposable database. */ +async function generate( + command: 'schema' | 'types' | 'verify', + verify: boolean, +): Promise { + const file = projectConfig.schema; + const wantSchema = command !== 'types'; + const wantTypes = command !== 'schema'; + if (command === 'schema' && file === undefined) + throw new Error('pgstencil.json has no "schema" path to write the dump to'); + if (command === 'types' && projectConfig.types.length === 0) + throw new Error('pgstencil.json has no "types" entries to generate'); + if ( + command === 'verify' && + file === undefined && + projectConfig.types.length === 0 + ) + throw new Error( + 'pgstencil.json has neither a "schema" path nor "types" entries to verify', + ); + // Fail on a missing peer before building a database. + if (wantTypes && projectConfig.types.length) + await requireCodegen(`pgstencil ${command}`); + const lease = await allocateDatabase(sources); + const problems: string[] = []; + try { + if (wantSchema && file !== undefined) { + const problem = await schema(lease, file, verify); + if (problem) problems.push(problem); + } + if (wantTypes) problems.push(...(await types(lease, verify))); + } finally { + await lease.close(); + } + if (problems.length) throw new Error(problems.join('\n')); +} + +async function inspect(command: 'status' | 'validate' | 'migrate') { + const url = process.env.DATABASE_URL ?? (await developmentDatabase(false)); + const files = await readMigrations(sources); + if (command === 'validate') { + await validateMigrations(url, files); + console.log('Applied SQL migration contents match.'); + return; + } + const applied = new Set(await appliedMigrations(url)); + if (command === 'status') { + console.table( + files.map((file) => ({ + name: file.name, + source: shown(file.source), + status: applied.has(file.name) ? 'applied' : 'pending', + })), + ); + return; + } + await migrate(url, files); + const pending = files.filter((file) => !applied.has(file.name)); + console.log( + pending.length + ? `Applied ${pending.map((file) => file.name).join(', ')}` + : 'No pending migrations.', + ); +} + +/** Runs one parsed command; failures throw. */ +export async function runCommand(command: Command): Promise { + switch (command.name) { + case 'help': + return; + case 'create': + return create(command.label, command.in); + case 'schema': + case 'types': + return generate(command.name, command.verify); + case 'verify': + return generate('verify', true); + case 'status': + case 'validate': + case 'migrate': + return inspect(command.name); + case 'stop': { + // Read the recorded stack rather than starting one just to shut it down. + const state: Services | undefined = await readFile(statePath, 'utf8') + .then((text) => JSON.parse(text) as Services) + .catch(() => undefined); + if (state) await requireIdle(state, true); + await exec( + 'docker', + ['compose', '-p', projectName, '-f', composeFile, 'down'], + { cwd: projectRoot }, + ); + await rm(statePath, { force: true }); + return; + } + case 'gc': + case 'reset': { + const services = await ensureServices(); + await requireIdle(services); + if (command.name === 'gc') return clearTemplates(services); + await queryDatabase( + services.postgresUrl, + `DROP DATABASE IF EXISTS ${developmentDatabaseName}`, + ); + await developmentDatabase(true, sources); + return; + } + } +} diff --git a/packages/pgstencil/src/config.ts b/packages/pgstencil/src/config.ts new file mode 100644 index 0000000..7276c28 --- /dev/null +++ b/packages/pgstencil/src/config.ts @@ -0,0 +1,172 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +/** The configuration file's name, read from the project root. */ +export const configFileName = 'pgstencil.json'; + +/** One `types` entry of `pgstencil.json`, with `out` resolved to an absolute path. */ +export interface TypesTarget { + /** Absolute path of the generated declarations file. */ + out: string; + /** kysely-codegen `--include-pattern`; `public.*` when omitted. */ + include: string; + /** kysely-codegen `--exclude-pattern`, if any. pgstencil's own tables are always left out. */ + exclude?: string; + /** kysely-codegen `--default-schema`: tables in it are named without a schema prefix. */ + defaultSchema?: string; +} + +/** `pgstencil.json`, validated and resolved against the project root. */ +export interface ProjectConfig { + /** Absolute migration source directories, in configured order. */ + migrations: readonly string[]; + /** Absolute path of the schema dump, if configured. */ + schema?: string; + types: readonly TypesTarget[]; +} + +const TOP_LEVEL = ['migrations', 'schema', 'types'] as const; +const TYPES_KEYS = ['out', 'include', 'exclude', 'defaultSchema'] as const; + +/** + * Resolve `configured` against `root`, refusing a path that leaves it + * lexically. `what` names the setting in the error, e.g. `pgstencil.json schema`. + */ +export function insideRoot( + root: string, + configured: string, + what: string, +): string { + const path = resolve(root, configured); + const fromRoot = relative(root, path); + if ( + fromRoot === '..' || + fromRoot.startsWith('..' + sep) || + isAbsolute(fromRoot) + ) + throw new Error(`${what} must stay inside the project root: ${configured}`); + return path; +} + +const describe = (value: unknown): string => + value === null ? 'null' : Array.isArray(value) ? 'an array' : typeof value; + +function text(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim() === '') + throw new Error( + `${configFileName} ${field} must be a non-empty string, not ${describe(value)}`, + ); + return value; +} + +function object(value: unknown, field: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new Error( + `${configFileName} ${field} must be an object, not ${describe(value)}`, + ); + return value as Record; +} + +function onlyKeys( + value: Record, + allowed: readonly string[], + field: string, +): void { + const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); + if (unknown.length) + throw new Error( + `${configFileName} ${field} has unknown ${unknown.length === 1 ? 'key' : 'keys'} ${unknown.map((key) => JSON.stringify(key)).join(', ')}; expected ${allowed.join(', ')}`, + ); +} + +function unique(paths: readonly string[], root: string, field: string): void { + const seen = new Set(); + for (const path of paths) { + if (seen.has(path)) + throw new Error( + `${configFileName} ${field} lists ${relative(root, path)} more than once`, + ); + seen.add(path); + } +} + +/** Validate a parsed `pgstencil.json` and resolve its paths against `root`. */ +export function parseConfig(root: string, value: unknown): ProjectConfig { + const config = object(value, 'top level'); + onlyKeys(config, TOP_LEVEL, 'top level'); + const sources = + config.migrations === undefined ? ['migrations'] : config.migrations; + const list = typeof sources === 'string' ? [sources] : sources; + if (!Array.isArray(list) || list.length === 0) + throw new Error( + `${configFileName} migrations must be a directory or a non-empty list of directories, not ${Array.isArray(list) ? 'an empty list' : describe(list)}`, + ); + const migrations = list.map((entry: unknown, index) => { + const field = + typeof sources === 'string' ? 'migrations' : `migrations[${index}]`; + return insideRoot(root, text(entry, field), `${configFileName} ${field}`); + }); + unique(migrations, root, 'migrations'); + const types = config.types === undefined ? [] : config.types; + if (!Array.isArray(types)) + throw new Error( + `${configFileName} types must be a list of { out, include?, exclude?, defaultSchema? }, not ${describe(types)}`, + ); + const targets = types.map((entry: unknown, index): TypesTarget => { + const field = `types[${index}]`; + const target = object(entry, field); + onlyKeys(target, TYPES_KEYS, field); + if (target.out === undefined) + throw new Error(`${configFileName} ${field} needs an "out" path`); + const resolved: TypesTarget = { + out: insideRoot( + root, + text(target.out, `${field}.out`), + `${configFileName} ${field}.out`, + ), + include: + target.include === undefined + ? 'public.*' + : text(target.include, `${field}.include`), + }; + if (target.exclude !== undefined) + resolved.exclude = text(target.exclude, `${field}.exclude`); + if (target.defaultSchema !== undefined) + resolved.defaultSchema = text( + target.defaultSchema, + `${field}.defaultSchema`, + ); + return resolved; + }); + unique( + targets.map((target) => target.out), + root, + 'types', + ); + const parsed: ProjectConfig = { + migrations: Object.freeze(migrations), + types: Object.freeze(targets), + }; + if (config.schema !== undefined) + parsed.schema = insideRoot( + root, + text(config.schema, 'schema'), + `${configFileName} schema`, + ); + return parsed; +} + +/** Read `pgstencil.json` from `root`; an absent file means every default. */ +export function readConfig(root: string): ProjectConfig { + const file = join(root, configFileName); + if (!existsSync(file)) return parseConfig(root, {}); + let value: unknown; + try { + value = JSON.parse(readFileSync(file, 'utf8')); + } catch (error) { + throw new Error(`${file} is not valid JSON: ${(error as Error).message}`, { + cause: error, + }); + } + return parseConfig(root, value); +} diff --git a/packages/pgstencil/src/database.ts b/packages/pgstencil/src/database.ts index da5d2f8..91e4f50 100644 --- a/packages/pgstencil/src/database.ts +++ b/packages/pgstencil/src/database.ts @@ -27,6 +27,12 @@ export { projectRoot, stateDirectory, defaultMigrations, + projectConfig, + parseConfig, + readConfig, + configFileName, + type ProjectConfig, + type TypesTarget, } from './paths.ts'; export interface Services { project: string; diff --git a/packages/pgstencil/src/email.ts b/packages/pgstencil/src/email.ts index 274d852..5269450 100644 --- a/packages/pgstencil/src/email.ts +++ b/packages/pgstencil/src/email.ts @@ -16,6 +16,21 @@ export interface CapturedEmail extends EmailMessage { export interface EmailSender { send(message: EmailMessage): Promise; } +const HTML_ESCAPES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; +/** + * Converts any value to a string safe inside HTML text and quoted attribute + * values, escaping `&`, `<`, `>`, `"` and `'`. Use it for every + * application-supplied value interpolated into an email's `html`. + */ +export function escapeHtml(value: unknown): string { + return String(value).replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]!); +} export class EmailDev implements EmailSender { private messages: CapturedEmail[] = []; private cursor = 0; diff --git a/packages/pgstencil/src/paths.ts b/packages/pgstencil/src/paths.ts index 80b08ea..1e08495 100644 --- a/packages/pgstencil/src/paths.ts +++ b/packages/pgstencil/src/paths.ts @@ -1,29 +1,27 @@ import { createHash } from 'node:crypto'; -import { resolve, join, relative, isAbsolute, sep } from 'node:path'; -import { existsSync, readFileSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { configFileName, insideRoot, readConfig } from './config.ts'; +export { + configFileName, + parseConfig, + readConfig, + type ProjectConfig, + type TypesTarget, +} from './config.ts'; /** Workspace paths, kept free of heavy imports so light scripts can use them. */ export const projectRoot = resolve( process.env.PGSTENCIL_PROJECT_ROOT ?? process.cwd(), ); -const configFile = join(projectRoot, 'pgstencil.json'); -const config = existsSync(configFile) - ? (JSON.parse(readFileSync(configFile, 'utf8')) as { migrations?: string }) - : {}; /** Resolve a configured migrations directory, refusing one outside the project root. */ export function migrationsDirectory(root: string, configured: string) { - const directory = resolve(root, configured); - const path = relative(root, directory); - if (path === '..' || path.startsWith('..' + sep) || isAbsolute(path)) - throw new Error( - `pgstencil.json migrations must stay inside the project root: ${configured}`, - ); - return directory; + return insideRoot(root, configured, `${configFileName} migrations`); } -export const defaultMigrations = migrationsDirectory( - projectRoot, - config.migrations ?? 'migrations', -); +/** The project's `pgstencil.json`, or every default when it has none. */ +export const projectConfig = readConfig(projectRoot); +/** The configured migration sources, in order; `["migrations"]` by default. */ +export const defaultMigrations: readonly string[] = projectConfig.migrations; export const stateDirectory = join(projectRoot, '.pgstencil'); export const composeFile = existsSync(join(projectRoot, 'compose.yaml')) ? join(projectRoot, 'compose.yaml') diff --git a/packages/stripe/src/db.generated.ts b/packages/stripe/src/db.generated.ts new file mode 100644 index 0000000..49b8609 --- /dev/null +++ b/packages/stripe/src/db.generated.ts @@ -0,0 +1,75 @@ +/** + * This file was generated by kysely-codegen. + * Please do not edit it manually. + */ + +import type { ColumnType } from "kysely"; + +export type Generated = T extends ColumnType + ? ColumnType + : ColumnType; + +export type Timestamp = ColumnType; + +export interface Accounts { + created_at: Timestamp; + customer_id: string | null; + customer_key: string | null; + customer_started_at: Timestamp | null; + email: string | null; + owner_id: string; + trial_used_at: Timestamp | null; +} + +export interface Checkouts { + created_at: Timestamp; + expires_at: Timestamp; + id: string; + owner_id: string; + plan: string; + price_id: string; + ref: string | null; + session_id: string | null; + status: string; + trial_days: number; + url: string | null; +} + +export interface Events { + attempts: Generated; + failed: Generated; + id: string; + processed_at: Timestamp | null; + received_at: Timestamp; + type: string; +} + +export interface OwnerAccess { + access_until: Timestamp | null; + owner_id: string | null; + price_id: string | null; + status: string | null; + subscription_id: string | null; +} + +export interface Subscriptions { + cancel_at_period_end: boolean; + end_reason: string | null; + ended_at: Timestamp | null; + id: string; + owner_id: string; + period_end: Timestamp; + price_id: string; + started_at: Timestamp | null; + status: string; + trial_end: Timestamp | null; + updated_at: Timestamp; +} + +export interface DB { + accounts: Accounts; + checkouts: Checkouts; + events: Events; + owner_access: OwnerAccess; + subscriptions: Subscriptions; +} diff --git a/packages/stripe/src/db.ts b/packages/stripe/src/db.ts index c4ef31b..17938b3 100644 --- a/packages/stripe/src/db.ts +++ b/packages/stripe/src/db.ts @@ -1,5 +1,19 @@ -import type { ColumnType, Generated } from 'kysely'; +import type { + ColumnType, + Generated, + InsertType, + SelectType, + UpdateType, +} from 'kysely'; +import type { DB as Migrated } from './db.generated.ts'; type Timestamp = ColumnType; +/** + * The billing schema as consumers see it. Written by hand so its columns carry + * documentation and the `owner_access` view keeps the non-null columns + * Postgres cannot declare for a view. `db.generated.ts`, generated from the + * migrations by `pgstencil types` and checked by `pgstencil verify`, keeps it + * honest: `BillingDBMatchesMigrations` below fails typechecking on any drift. + */ export interface BillingDB { accounts: { owner_id: string; @@ -61,3 +75,39 @@ export interface BillingDB { failed: Generated; }; } + +type Same = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; +/** Views whose hand-written columns may be narrower than what Postgres reports. */ +type Views = 'owner_access'; +/** A column as Kysely uses it: what a select returns, an insert takes, an update takes. */ +type Facets = [SelectType, InsertType, UpdateType]; +/** `true` where a column agrees; otherwise both types, so the error names it. */ +type Agreement = ( + Table extends Views + ? [SelectType] extends [SelectType] + ? true + : false + : Same, Facets> +) extends true + ? true + : { BillingDB: Hand; 'db.generated.ts': Generated }; +type Columns = { + [Column in keyof Hand | keyof Generated]: Column extends keyof Hand + ? Column extends keyof Generated + ? Agreement + : 'column missing from db.generated.ts' + : 'column missing from BillingDB'; +}; +type Drift = { + [Table in keyof BillingDB | keyof Migrated]: Table extends keyof BillingDB + ? Table extends keyof Migrated + ? Columns + : 'table missing from db.generated.ts' + : 'table missing from BillingDB'; +}; +type Agrees>> = T; +// Never used: checking the constraint is the point. `pnpm typecheck` runs it. +type BillingDBMatchesMigrations = Agrees; diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts index 18ea74b..636f504 100644 --- a/packages/stripe/src/index.ts +++ b/packages/stripe/src/index.ts @@ -677,14 +677,15 @@ export class Billing

{ .executeTakeFirstOrThrow(); if (!account.customer_id) return; // Fetch after the owner lock: competing webhook reads cannot overwrite newer state. - const subscriptions = await this.stripe.subscriptions.list({ + // Every page, ended ones included: only the complete list is authoritative. + const subscriptions: Stripe.Subscription[] = []; + for await (const sub of this.stripe.subscriptions.list({ customer: account.customer_id, status: 'all', limit: 100, - }); - if (subscriptions.has_more) - throw new Error('Subscription reconciliation requires pagination'); - const recognized = subscriptions.data.filter( + })) + subscriptions.push(sub); + const recognized = subscriptions.filter( (sub) => sub.metadata.pgstencil_owner === ownerId, ); const now = this.time.now(); @@ -861,11 +862,12 @@ export class Billing

{ /** * Reconciles every owner holding a Stripe customer, one at a time in owner * ID order, reading `pageSize` (1-1000, default 100) owners per query: one - * `subscriptions.list` request each, for a one-off pass such as after a - * Stripe API version move. Returns how many owners it checked, each owner - * whose subscription rows changed, with the rows before and after, and each - * failure, without stopping. `dryRun` reconciles inside a transaction it - * rolls back, so it reports what would change and writes nothing. + * `subscriptions.list` request each per 100 of its subscriptions, for a + * one-off pass such as after a Stripe API version move. Returns how many + * owners it checked, each owner whose subscription rows changed, with the + * rows before and after, and each failure, without stopping. `dryRun` + * reconciles inside a transaction it rolls back, so it reports what would + * change and writes nothing. */ async reconcileAll( options: { dryRun?: boolean; pageSize?: number } = {}, @@ -1216,4 +1218,36 @@ export class Billing

{ throw error; } } + /** + * Deletes the webhook ledger's events processed more than `olderThanDays` + * days (an integer from 30 to 3650; a day is 24 hours) before the injected + * clock, and returns how many it deleted. For a scheduled job. An event + * that was never processed, a failed one included, is always kept. + * + * The ledger is what makes a redelivery a no-op: a pruned event that + * reaches `webhook` again is processed again, its resync and `apply` + * included, so an `apply` that is not idempotent repeats its effects. + * Stripe retries a delivery for up to three days, so the 30-day floor + * leaves a wide margin; an event resent by hand later, from the Dashboard + * or the CLI, would still apply again. + */ + async pruneEvents(options: { olderThanDays: number }): Promise { + const { olderThanDays } = options; + if ( + !Number.isInteger(olderThanDays) || + olderThanDays < 30 || + olderThanDays > 3650 + ) + throw new Error('olderThanDays must be an integer between 30 and 3650'); + const cutoff = new Date( + this.time.now().getTime() - olderThanDays * 86_400_000, + ); + const result = await this.db + .deleteFrom('events') + .where('processed_at', '<', cutoff) + // A processed event is never failed; this keeps the promise explicit. + .where('failed', '=', false) + .executeTakeFirst(); + return Number(result.numDeletedRows); + } } diff --git a/packages/stripe/src/testing.ts b/packages/stripe/src/testing.ts index ce83ca6..56d87a6 100644 --- a/packages/stripe/src/testing.ts +++ b/packages/stripe/src/testing.ts @@ -334,9 +334,11 @@ export async function createStripeDev( requests.push({ method: req.method!, path, - // The SDK sends a DELETE's parameters in its query string. + // The SDK sends a GET's or DELETE's parameters in its query string. body: Object.fromEntries( - req.method === 'DELETE' ? url.searchParams : body, + req.method === 'GET' || req.method === 'DELETE' + ? url.searchParams + : body, ), ...(key ? { key } : {}), }); @@ -451,12 +453,28 @@ export async function createStripeDev( session.url = `${origin}/checkout/${session.id}`; result = session; } else if (path === '/v1/subscriptions' && req.method === 'GET') { + // As Stripe lists: newest first, `limit` (1-100, default 10) at a + // time, resuming after the `starting_after` ID, with `has_more` true + // while any remain. The `status` filter is not modeled. + const query = url.searchParams; + const limit = Number(query.get('limit') ?? 10); + if (!Number.isInteger(limit) || limit < 1 || limit > 100) + throw new Error('limit must be an integer between 1 and 100'); + if (query.has('ending_before')) + throw new Error('StripeDev does not support ending_before'); + const listed = [...subscriptions.values()] + .filter((s) => s.customer === query.get('customer')) + .reverse(); + const after = query.get('starting_after'); + const start = + after === null ? 0 : listed.findIndex((s) => s.id === after) + 1; + if (after !== null && !start) + throw new Error(`No such subscription: '${after}'`); result = { object: 'list', - has_more: false, - data: [...subscriptions.values()].filter( - (s) => s.customer === url.searchParams.get('customer'), - ), + url: '/v1/subscriptions', + has_more: start + limit < listed.length, + data: listed.slice(start, start + limit), }; } else if (path.startsWith('/v1/charges/') && req.method === 'GET') { const charge = charges.get(path.split('/')[3]!); diff --git a/pgstencil.json b/pgstencil.json index 7750495..0867f4b 100644 --- a/pgstencil.json +++ b/pgstencil.json @@ -1,3 +1,14 @@ { - "migrations": "packages/auth/migrations" + "migrations": [ + "packages/auth/better-auth-migrations", + "packages/stripe/migrations" + ], + "schema": "examples/better-auth/schema.sql", + "types": [ + { + "out": "packages/stripe/src/db.generated.ts", + "include": "pgstencil_billing.*", + "defaultSchema": "pgstencil_billing" + } + ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ba6d00..d211986 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,12 +181,12 @@ importers: esbuild: specifier: ^0.28.2 version: 0.28.2 - kysely-codegen: - specifier: ^0.20.0 - version: 0.20.0(kysely@0.29.8)(pg@8.23.1)(typescript@7.0.2) miniflare: specifier: 5.20261104.0-beta version: 5.20261104.0-beta(@types/node@24.19.2) + node: + specifier: runtime:24.22.2 + version: runtime:24.22.2 prettier: specifier: ^3.6.0 version: 3.9.10 @@ -211,40 +211,15 @@ importers: '@hono/node-server': specifier: 2.1.6 version: 2.1.6(hono@4.13.16) - '@pgstencil/auth': - specifier: workspace:* - version: link:../../packages/auth - better-auth: - specifier: 1.7.18 - version: 1.7.18(pg@8.23.1)(vitest@5.1.0(@types/node@24.19.2)(vite@8.2.2(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0))) - hono: - specifier: ^4.13.7 - version: 4.13.16 - kysely: - specifier: ^0.29.5 - version: 0.29.8 - pg: - specifier: ^8.16.0 - version: 8.23.1 - pgstencil: - specifier: workspace:* - version: link:../../packages/pgstencil - devDependencies: - node-pg-migrate: - specifier: ^9.0.0 - version: 9.0.0(@types/pg@8.23.1)(pg@8.23.1) - testcontainers: - specifier: ^12.2.1 - version: 12.2.1(supports-color@10.2.2) - - examples/login: - dependencies: '@pgstencil/auth': specifier: workspace:* version: link:../../packages/auth '@pgstencil/stripe': specifier: workspace:* version: link:../../packages/stripe + better-auth: + specifier: 1.7.18 + version: 1.7.18(pg@8.23.1)(vitest@5.1.0(@types/node@24.19.2)(vite@8.2.2(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0))) hono: specifier: ^4.13.7 version: 4.13.16 @@ -268,21 +243,6 @@ importers: specifier: ^12.2.1 version: 12.2.1(supports-color@10.2.2) - examples/workers: - dependencies: - '@pgstencil/auth': - specifier: workspace:* - version: link:../../packages/auth - hono: - specifier: ^4.13.7 - version: 4.13.16 - kysely: - specifier: ^0.29.5 - version: 0.29.8 - pgstencil: - specifier: workspace:* - version: link:../../packages/pgstencil - packages/auth: dependencies: better-auth: @@ -291,9 +251,6 @@ importers: jose: specifier: ^6.2.15 version: 6.2.15 - openid-client: - specifier: ^6.8.9 - version: 6.8.9 devDependencies: hono: specifier: ^4.13.7 @@ -317,6 +274,9 @@ importers: kysely: specifier: ^0.29.5 version: 0.29.8 + kysely-codegen: + specifier: ^0.20.0 + version: 0.20.0(kysely@0.29.8)(pg@8.23.1)(typescript@7.0.2) node-pg-migrate: specifier: ^9.0.0 version: 9.0.0(@types/pg@8.23.1)(pg@8.23.1) @@ -2042,6 +2002,138 @@ packages: '@types/pg': optional: true + node@runtime:24.22.2: + resolution: + type: variations + variants: + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-s9D3zDDSjkR0j4TdskyXqHNZJ0QPrv/JbjRFSRQWenk= + type: binary + url: https://nodejs.org/download/release/v24.22.2/node-v24.22.2-aix-ppc64.tar.gz + targets: + - cpu: ppc64 + os: aix + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-qpVJNJ5H3/qkLc+V1uBq3oxL2vyYCXmIO4h2dnDfOFo= + type: binary + url: https://nodejs.org/download/release/v24.22.2/node-v24.22.2-darwin-arm64.tar.gz + targets: + - cpu: arm64 + os: darwin + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-1eM21YjEW+DDfb1SzCmJVZMAnKSZeneh/QKBUTHtDlI= + type: binary + url: https://nodejs.org/download/release/v24.22.2/node-v24.22.2-darwin-x64.tar.gz + targets: + - cpu: x64 + os: darwin + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-lcJkFQrOt/NDein9aYSST1+NFRHqPL1sUIuD9sVV2uI= + type: binary + url: https://nodejs.org/download/release/v24.22.2/node-v24.22.2-linux-arm64.tar.gz + targets: + - cpu: arm64 + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-9WQOG2VrZ/YA44z5xfJ460KMlJiZT1ykHWQG+6BgqfM= + type: binary + url: https://nodejs.org/download/release/v24.22.2/node-v24.22.2-linux-ppc64le.tar.gz + targets: + - cpu: ppc64le + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-Nyhvy37wtDG3X/nc7VF/1zstSg6cRgJC1Ml5i4ubQkc= + type: binary + url: https://nodejs.org/download/release/v24.22.2/node-v24.22.2-linux-s390x.tar.gz + targets: + - cpu: s390x + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-EevRbI14GcBnUhEiVQUnPXrqMFqN2FqcCusfvsI0MyA= + type: binary + url: https://nodejs.org/download/release/v24.22.2/node-v24.22.2-linux-x64-musl.tar.gz + targets: + - cpu: x64 + os: linux + libc: musl + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-NhcYxETkmvLKWhEMg7pqiQEUPyOrKF677wNB85H0gqk= + type: binary + url: https://nodejs.org/download/release/v24.22.2/node-v24.22.2-linux-x64.tar.gz + targets: + - cpu: x64 + os: linux + - resolution: + archive: zip + bin: + node: node.exe + integrity: sha256-9HlowhMQUHf2TGUQ8AdjLAyiV0gCoLUGfDJ8SavhxGg= + prefix: node-v24.22.2-win-arm64 + type: binary + url: https://nodejs.org/download/release/v24.22.2/node-v24.22.2-win-arm64.zip + targets: + - cpu: arm64 + os: win32 + - resolution: + archive: zip + bin: + node: node.exe + integrity: sha256-yhv/XY6tE8ny6kBKF220a9QwcE1bURNzQEn6Nl53meI= + prefix: node-v24.22.2-win-x64 + type: binary + url: https://nodejs.org/download/release/v24.22.2/node-v24.22.2-win-x64.zip + targets: + - cpu: x64 + os: win32 + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-cK/SUKkn+8MvPZWlhZfv3xSqitGU6JQmj5uDCaLauE4= + type: binary + url: https://unofficial-builds.nodejs.org/download/release/v24.22.2/node-v24.22.2-linux-arm64-musl.tar.gz + targets: + - cpu: arm64 + os: linux + libc: musl + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-hn4yLWcpTpxAXxDK5dcO0uSUC7Nn+kfby1FRyYmdfAQ= + type: binary + url: https://unofficial-builds.nodejs.org/download/release/v24.22.2/node-v24.22.2-linux-x64-musl.tar.gz + targets: + - cpu: x64 + os: linux + libc: musl + version: 24.22.2 + hasBin: true + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -2049,9 +2141,6 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - oauth4webapi@3.8.8: - resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -2063,9 +2152,6 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - openid-client@6.8.9: - resolution: {integrity: sha512-6mXMRNJxDOf6BflEWLXul1UwzLFz++9Ofz1PYwPwwsJsyXBsbfkO+Jo255SW1qnFNLYz4EJWHz42X4OJJrya/g==} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -3979,14 +4065,14 @@ snapshots: optionalDependencies: '@types/pg': 8.23.1 + node@runtime:24.22.2: {} + normalize-path@3.0.0: {} nth-check@2.1.1: dependencies: boolbase: 1.0.0 - oauth4webapi@3.8.8: {} - object-inspect@1.13.4: {} obug@3.0.0: {} @@ -3995,11 +4081,6 @@ snapshots: dependencies: wrappy: 1.0.2 - openid-client@6.8.9: - dependencies: - jose: 6.2.15 - oauth4webapi: 3.8.8 - package-json-from-dist@1.0.1: {} parent-module@1.0.1: diff --git a/scripts/db.ts b/scripts/db.ts deleted file mode 100644 index 7cef72f..0000000 --- a/scripts/db.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import { writeFile, readFile, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { generateTypes } from './generate-types.ts'; -import { - activeDatabases, - allocateDatabase, - appliedMigrations, - clearTemplates, - ensureServices, - developmentDatabase, - developmentDatabaseName, - queryDatabase, - migrate, - readMigrations, - validateMigrations, - defaultMigrations, - projectRoot, - projectName, - stateDirectory, - type Services, -} from '../packages/pgstencil/src/database.ts'; -import { appMigrations as migrations } from '../examples/login/src/migrations.ts'; -const exec = promisify(execFile); -const command = process.argv[2]; -const statePath = join(stateDirectory, 'services.json'); -const typesFile = 'packages/auth/src/db.generated.ts'; -const schemaFile = join(projectRoot, 'examples/login/schema.sql'); -/** - * `unreachableIsIdle` is only for `stop`, which reads a recorded stack that may - * already be down. gc/reset run against a stack we just started, so there a - * failed query is a real fault and must surface. - */ -async function requireIdle( - services: Services, - unreachableIsIdle = false, -): Promise { - const active = unreachableIsIdle - ? await activeDatabases(services).catch(() => []) - : await activeDatabases(services); - if (active.length) - throw new Error( - 'Applications/databases are still in use; stop them before cleanup', - ); -} -if (command === 'create') { - const label = process.argv[3]; - if (!label || !/^[a-z][a-z0-9_]*$/.test(label)) - throw new Error('Supply a snake_case migration name'); - const files = await readMigrations(migrations); - const next = String( - Math.max(...files.map((f) => Number(f.name.split('_')[0]))) + 1, - ).padStart(3, '0'); - const path = join(defaultMigrations, `${next}_${label}.sql`); - await writeFile(path, '-- Up Migration\n\n-- Down Migration\n', { - flag: 'wx', - }); - console.log(path); -} else if ( - command === 'types' || - command === 'schema' || - command === 'verify' -) { - // One lease covers both generators; `verify` checks each is up to date. - const verify = command === 'verify' || process.argv.includes('--verify'); - const lease = await allocateDatabase(migrations); - try { - if (command !== 'schema') - console.log(await generateTypes(lease.url, typesFile, verify)); - if (command !== 'types') { - const services = await ensureServices(); - const result = await exec( - 'docker', - [ - 'exec', - services.postgresContainer, - 'pg_dump', - '-U', - 'pgstencil', - '-d', - lease.name, - '--schema-only', - '--no-owner', - '--no-privileges', - '--restrict-key=pgstencil', - ], - { maxBuffer: 4_000_000 }, - ); - const schema = result.stdout.replace(/^-- Dumped .*\n/gm, ''); - if (verify) { - if ((await readFile(schemaFile, 'utf8')) !== schema) - throw new Error('Schema dump is stale; run pnpm db:schema'); - } else await writeFile(schemaFile, schema); - } - } finally { - await lease.close(); - } -} else if (command === 'stop') { - // Read the recorded stack rather than starting one just to shut it down. - const state: Services | undefined = await readFile(statePath, 'utf8') - .then((text) => JSON.parse(text) as Services) - .catch(() => undefined); - if (state) await requireIdle(state, true); - await exec('docker', ['compose', '-p', projectName, 'down'], { - cwd: projectRoot, - }); - await rm(statePath, { force: true }); -} else if (command === 'gc' || command === 'reset') { - const services = await ensureServices(); - await requireIdle(services); - if (command === 'reset') { - await queryDatabase( - services.postgresUrl, - `DROP DATABASE IF EXISTS ${developmentDatabaseName}`, - ); - await developmentDatabase(true, migrations); - } else await clearTemplates(services); -} else if ( - command === 'status' || - command === 'migrate' || - command === 'validate' -) { - const url = process.env.DATABASE_URL ?? (await developmentDatabase(false)); - const files = await readMigrations(migrations); - if (command === 'migrate') await migrate(url, files); - else if (command === 'validate') { - await validateMigrations(url, files); - console.log('Applied SQL migration contents match.'); - } else { - const applied = new Set(await appliedMigrations(url)); - console.table( - files.map((file) => ({ - name: file.name, - status: applied.has(file.name) ? 'applied' : 'pending', - })), - ); - } -} else throw new Error('Unknown database command'); diff --git a/scripts/generate-types.ts b/scripts/generate-types.ts deleted file mode 100644 index 2866a42..0000000 --- a/scripts/generate-types.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import { join } from 'node:path'; -import { projectRoot } from '../packages/pgstencil/src/paths.ts'; -const exec = promisify(execFile); -export async function generateTypes( - url: string, - outfile: string, - verify = false, -): Promise { - const args = [ - // Running the bin directly skips a pnpm process launch (~280ms per call). - join(projectRoot, 'node_modules/kysely-codegen/dist/cli/bin.js'), - '--url', - 'env(DATABASE_URL)', - '--out-file', - outfile, - '--dialect', - 'postgres', - '--include-pattern', - 'public.*', - '--exclude-pattern', - 'public.(pgmigrations|pgstencil_migration_files)', - ]; - if (verify) args.push('--verify'); - const result = await exec(process.execPath, args, { - cwd: projectRoot, - env: { ...process.env, DATABASE_URL: url }, - }); - return result.stdout.trim(); -} diff --git a/scripts/runtime-deps.test.mjs b/scripts/runtime-deps.test.mjs index 40201b9..5d8ab22 100644 --- a/scripts/runtime-deps.test.mjs +++ b/scripts/runtime-deps.test.mjs @@ -13,8 +13,9 @@ import { build } from 'esbuild'; const repo = fileURLToPath(new URL('..', import.meta.url)); /** - * Only `pgstencil/database`, `pgstencil/testing` and `pgstencil/snapshots` use - * these. `packages:verify` reads the same list for its runtime-only install. + * Only `pgstencil/database`, `pgstencil/testing`, `pgstencil/snapshots` and the + * `pgstencil` command use these. `packages:verify` reads the same list for its + * runtime-only install. */ const TOOLING = JSON.parse( readFileSync(join(repo, 'scripts/tooling-peers.json'), 'utf8'), @@ -98,12 +99,18 @@ async function tooling(entry) { return seen; } -const entries = packages.flatMap(({ dir, manifest }) => - Object.entries(manifest.exports).map(([subpath, file]) => ({ +// Commands count as entry points too: `pgstencil types` is the only one that +// loads kysely-codegen, and it must still do so through importPeer. +const entries = packages.flatMap(({ dir, manifest }) => [ + ...Object.entries(manifest.exports).map(([subpath, file]) => ({ name: join(manifest.name, subpath), file: join(dir, file), })), -); + ...Object.entries(manifest.bin ?? {}).map(([command, file]) => ({ + name: `${command} (bin)`, + file: join(dir, file), + })), +]); test('no entry point statically reaches a tooling package', async () => { const reached = await Promise.all( diff --git a/scripts/tooling-peers.json b/scripts/tooling-peers.json index 9c238dd..7c96fed 100644 --- a/scripts/tooling-peers.json +++ b/scripts/tooling-peers.json @@ -1 +1 @@ -["testcontainers", "node-pg-migrate", "cheerio", "turndown"] +["testcontainers", "node-pg-migrate", "cheerio", "turndown", "kysely-codegen"] diff --git a/scripts/verify-packages.ts b/scripts/verify-packages.ts index c7f87fb..737e56a 100644 --- a/scripts/verify-packages.ts +++ b/scripts/verify-packages.ts @@ -68,6 +68,9 @@ for (const name of ['pgstencil', '@pgstencil/auth', '@pgstencil/stripe']) { ).version; } } +// A tooling peer's own peer that is also a shared library (kysely-codegen's +// kysely) is the application's runtime copy, not a second devDependency. +for (const name of Object.keys(peers)) delete toolingPeers[name]; // An application that imports only runtime entry points, installed the way it // deploys: none of the tooling may land, even with peers auto-installed. const runtime = await mkdtemp(join(tmpdir(), 'pgstencil-runtime-')); @@ -137,20 +140,31 @@ await writeFile( 2, ), ); +// The installed `pgstencil` command reads this, naming package sources by +// their installed paths the way an application does. +await writeFile( + join(directory, 'pgstencil.json'), + JSON.stringify({ + migrations: [ + 'node_modules/@pgstencil/auth/better-auth-migrations', + 'node_modules/@pgstencil/stripe/migrations', + ], + }), +); await writeFile( join(directory, 'verify.ts'), `import { DevTime, DevRandom, EmailDev } from 'pgstencil'; import { allocateDatabase, connectDatabase, readMigrations, migrate } from 'pgstencil/database'; -import { Auth, type AuthDB } from '@pgstencil/auth'; -import { authMigrations } from '@pgstencil/auth/migrations'; +import { createAuthApp } from '@pgstencil/auth'; import { betterAuthMigrations } from '@pgstencil/auth/better-auth-migrations'; -import { createAuthApp } from '@pgstencil/auth/better-auth'; +import { createBetterAuthWorker } from '@pgstencil/auth/better-auth-workers'; import { deterministicScope } from '@pgstencil/auth/better-auth-testing'; -import { createAuthHttp } from '@pgstencil/auth/http'; +import { postmarkEmail } from '@pgstencil/auth/postmark'; import { Billing, type BillingDB } from '@pgstencil/stripe'; import { billingMigrations } from '@pgstencil/stripe/migrations'; import { createStripeDev } from '@pgstencil/stripe/testing'; import { strict as assert } from 'node:assert'; +import { execFileSync, spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; const pgstencilVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.resolve('pgstencil')), 'utf8')).version; for (const name of ['pgstencil', '@pgstencil/auth', '@pgstencil/stripe']) { @@ -163,42 +177,51 @@ for (const name of ['pgstencil', '@pgstencil/auth', '@pgstencil/stripe']) { assert.ok(readFileSync(new URL('../LICENSE', entry), 'utf8').startsWith('MIT License')); } assert.ok(readFileSync(new URL('compose.yaml', import.meta.resolve('pgstencil/database')), 'utf8').includes('integresql')); -const lease = await allocateDatabase([authMigrations, billingMigrations, betterAuthMigrations]); -const db = connectDatabase(lease.url); +const sources = [betterAuthMigrations, billingMigrations]; +const lease = await allocateDatabase(sources); const billingDb = connectDatabase(lease.url); const time = new DevTime(); const random = new DevRandom(); const email = new EmailDev(time); const dev = await createStripeDev(time, random); try { - const auth = new Auth({ db, time, random, email, origin: 'https://consumer.test', secret: 'packed-consumer-secret-at-least-32' }); - const flow = await auth.newFlow(); - assert.equal((await auth.send(flow, 'packed@example.test', 'local')).ok, true); - const message = await email.next(); const code = message.text.match(/code is (\\d{4}) (\\d{4})/)!.slice(1).join(''); - const result = await auth.verify((await auth.pending(flow.cookie))!, 'code', code, undefined, 'local'); - assert.ok(result.ok); const session = await auth.session(result.session); assert.ok(session); - const billing = new Billing(billingDb, dev.stripe, time, random, { prices: dev.prices, trialDays: 14, webhookSecret: dev.webhookSecret, live: false, origin: 'https://consumer.test' }); - await billing.checkout(session.user_id, session.email, 'monthly'); - assert.equal((await billing.status(session.user_id)).access, false); - dev.completeCheckout([...dev.checkouts.keys()][0]!); - for (const event of dev.events) { const signed = dev.signed(event); await billing.webhook(signed.body, signed.signature); } - assert.equal((await billing.status(session.user_id)).access, true); - assert.equal(typeof createAuthHttp, 'function'); - await migrate(lease.url, await readMigrations([authMigrations, billingMigrations, betterAuthMigrations])); - const modern = createAuthApp({databaseUrl:lease.url,origin:'https://consumer.test',secret:'packed-consumer-secret-at-least-32',email,sessionPolicy:'single'}); + await migrate(lease.url, await readMigrations(sources)); + // The installed bin, run by node from dist without tsx, against this project's + // own pgstencil.json and the leased database. + const cliEnv: NodeJS.ProcessEnv = { ...process.env, DATABASE_URL: lease.url }; + delete cliEnv.PGSTENCIL_PROJECT_ROOT; + const pgstencil = (...args: string[]) => execFileSync('node_modules/.bin/pgstencil', args, { env: cliEnv, encoding: 'utf8' }); + const status = pgstencil('status'); + const rows = status.split('\\n'); + for (const file of await readMigrations(sources)) assert.ok(rows.some((row) => row.includes(file.name) && row.includes("'applied'")), status); + assert.doesNotMatch(status, /pending/); + assert.equal(pgstencil('validate').trim(), 'Applied SQL migration contents match.'); + const unknown = spawnSync('node_modules/.bin/pgstencil', ['up'], { env: cliEnv, encoding: 'utf8' }); + assert.equal(unknown.status, 2); assert.match(unknown.stderr, /Usage: pgstencil /); + const auth = createAuthApp({databaseUrl:lease.url,origin:'https://consumer.test',secret:'packed-consumer-secret-at-least-32',email,sessionPolicy:'single'}); try { - const csrfResponse = await modern.app.fetch(new Request('https://consumer.test/api/auth/csrf')); + const csrfResponse = await auth.app.fetch(new Request('https://consumer.test/api/auth/csrf')); const csrf = (await csrfResponse.json()).csrf; - const cookie = csrfResponse.headers.getSetCookie().map((v) => v.split(';')[0]).join('; '); - const post = (path: string, body: object) => modern.app.fetch(new Request('https://consumer.test/api/auth/' + path, {method:'POST',headers:{origin:'https://consumer.test',cookie,'x-csrf-token':csrf,'content-type':'application/json'},body:JSON.stringify(body)}),{incoming:{socket:{remoteAddress:'127.0.0.1'}}}); - assert.equal((await post('email-otp/send-verification-otp',{email:'modern@example.test',type:'sign-in'})).status,200); + const csrfCookie = csrfResponse.headers.getSetCookie().map((v) => v.split(';')[0]).join('; '); + const post = (path: string, body: object) => auth.app.fetch(new Request('https://consumer.test/api/auth/' + path, {method:'POST',headers:{origin:'https://consumer.test',cookie:csrfCookie,'x-csrf-token':csrf,'content-type':'application/json'},body:JSON.stringify(body)}),{incoming:{socket:{remoteAddress:'127.0.0.1'}}}); + assert.equal((await post('email-otp/send-verification-otp',{email:'packed@example.test',type:'sign-in'})).status,200); const otp = (await email.next()).text.match(/\\b\\d{8}\\b/)![0]; - const signedIn = await post('sign-in/email-otp',{email:'modern@example.test',otp}); + const signedIn = await post('sign-in/email-otp',{email:'packed@example.test',otp}); assert.equal(signedIn.status,200); - assert.ok(signedIn.headers.getSetCookie()[0].startsWith('__Host-pgstencil.session_token=')); + const sessionCookie = signedIn.headers.getSetCookie()[0]!.split(';')[0]!; + assert.ok(sessionCookie.startsWith('__Host-pgstencil.session_token=')); assert.equal((await signedIn.json()).token,undefined); - assert.equal(typeof deterministicScope.run,'function'); - } finally { await modern.close(); } - console.log('Packed imports, declarations, SQL assets, legacy/Better Auth login and card-required trial passed.'); -} finally { await db.destroy(); await billingDb.destroy(); await dev.close(); await lease.close(); email.close(); } + const session = await auth.auth.api.getSession({headers:new Headers({cookie:sessionCookie})}); + assert.ok(session); assert.equal(session.user.email,'packed@example.test'); + const billing = new Billing(billingDb, dev.stripe, time, random, { prices: dev.prices, trialDays: 14, webhookSecret: dev.webhookSecret, live: false, origin: 'https://consumer.test' }); + await billing.checkout(session.user.id, session.user.email, 'monthly'); + assert.equal((await billing.status(session.user.id)).access, false); + dev.completeCheckout([...dev.checkouts.keys()][0]!); + for (const event of dev.events) { const signed = dev.signed(event); await billing.webhook(signed.body, signed.signature); } + assert.equal((await billing.status(session.user.id)).access, true); + } finally { await auth.close(); } + assert.equal(typeof createBetterAuthWorker({email:() => postmarkEmail('token','signin@consumer.test')}).fetch,'function'); + assert.equal(typeof deterministicScope.run,'function'); + console.log('Packed imports, declarations, SQL assets, the pgstencil command, Better Auth login and card-required trial passed.'); +} finally { await billingDb.destroy(); await dev.close(); await lease.close(); email.close(); } `, ); await writeFile( @@ -258,4 +281,11 @@ execFileSync(process.execPath, ['out/verify.js'], { stdio: 'inherit', env: { ...process.env, PGSTENCIL_PROJECT_ROOT: projectRoot }, }); +// The packed generators, with the consumer's kysely-codegen, must reproduce +// this workspace's committed schema dump and declarations byte for byte. +execFileSync(join(directory, 'node_modules/.bin/pgstencil'), ['verify'], { + cwd: directory, + stdio: 'inherit', + env: { ...process.env, PGSTENCIL_PROJECT_ROOT: projectRoot }, +}); console.log(`Independent installed consumer: ${directory}`); diff --git a/tests/fixtures/migrations/001_users.sql b/tests/fixtures/migrations/001_users.sql new file mode 100644 index 0000000..167f963 --- /dev/null +++ b/tests/fixtures/migrations/001_users.sql @@ -0,0 +1,8 @@ +-- Up Migration +CREATE TABLE users ( + id text PRIMARY KEY, + email text NOT NULL UNIQUE, + created_at timestamptz NOT NULL +); +-- Down Migration +DROP TABLE users; diff --git a/tests/integration/app-test.ts b/tests/integration/app-test.ts deleted file mode 100644 index 6537b75..0000000 --- a/tests/integration/app-test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { test } from 'vitest'; -import { fixture, type Fixture } from './helpers.ts'; -/** - * The common case: one app, closed after the test. Kept out of helpers.ts so - * that plain Node processes (tests/support/process-app.ts) can reuse the - * helpers without importing the vitest runner. - */ -export const appTest = test.extend<{ f: Fixture }>({ - f: async ({}, use) => { - const f = await fixture(); - await use(f); - await f.close(); - }, -}); diff --git a/tests/integration/auth-http.test.ts b/tests/integration/auth-http.test.ts deleted file mode 100644 index 3ecabf5..0000000 --- a/tests/integration/auth-http.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { createServer } from 'node:http'; -import { once } from 'node:events'; -import { test, expect } from 'vitest'; -import request from 'supertest'; -import { - Auth, - OAuth, - OAuthProviders, - type AuthDB, -} from '../../packages/auth/src/index.ts'; -import { - createAuthHttp, - HttpError, - sendJson, -} from '../../packages/auth/src/http.ts'; -import { connectDatabase } from '../../packages/pgstencil/src/postgres.ts'; -import { createTestContext } from '../../packages/pgstencil/src/testing.ts'; -import { cookies, codeFrom } from './helpers.ts'; -import { - mockOAuthServer, - allOAuthCredentials, -} from '../support/oauth-server.ts'; - -async function fixture() { - const context = await createTestContext(); - const db = connectDatabase(context.database.url); - const provider = await mockOAuthServer(); - const server = createServer(); - server.listen(0, '127.0.0.1'); - await once(server, 'listening'); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error(); - const origin = `http://127.0.0.1:${address.port}`; - const auth = new Auth({ - db, - origin, - ...context, - secret: 'pgstencil-http-test-secret-at-least-32', - }); - const http = createAuthHttp({ - auth, - oauth: new OAuth( - auth, - new OAuthProviders(allOAuthCredentials, provider.transport), - ), - secure: false, - }); - server.on('request', (req, res) => { - void http - .handle(req, res) - .then((handled) => { - if (!handled) sendJson(res, {}, 404); - }) - .catch((error) => - sendJson( - res, - { error: error.message }, - error instanceof HttpError ? error.status : 503, - ), - ); - }); - return { - ...context, - db, - provider, - origin, - http, - post( - path: string, - csrf: string, - cookie: string, - body: Record = {}, - ) { - return request(origin) - .post(`/api/auth/${path}`) - .set('Origin', origin) - .set('X-CSRF-Token', csrf) - .set('Cookie', cookie) - .send(body); - }, - async close() { - server.closeAllConnections(); - await new Promise((resolve) => server.close(() => resolve())); - await provider.close(); - await db.destroy(); - await context.close(); - }, - }; -} -const httpTest = test.extend<{ f: Awaited> }>({ - f: async ({}, use) => { - const f = await fixture(); - try { - await use(f); - } finally { - await f.close(); - } - }, -}); - -httpTest( - 'JSON login keeps tokens in HttpOnly cookies and enforces exact expiry and logout CSRF', - async ({ f }) => { - const state = await request(f.origin).get('/api/auth/session').expect(200); - expect(state.body.session).toBeNull(); - const pending = cookies(state); - await f - .post('email', 'forged', pending, { email: 'alice@example.test' }) - .expect(403); - await f - .post('email', state.body.csrf, pending, { email: 'alice@example.test' }) - .expect(200); - const email = await f.email.next(); - const result = await f - .post('verify', state.body.csrf, pending, { - method: 'code', - value: codeFrom(email), - }) - .expect(200); - expect(result.body).toEqual({ ok: true }); - expect(String(result.headers['set-cookie']?.[0])).toContain( - 'HttpOnly; SameSite=Lax', - ); - const cookie = cookies(result); - const session = await request(f.origin) - .get('/api/auth/session') - .set('Cookie', cookie) - .expect(200); - expect(session.body.session.user.email).toBe('alice@example.test'); - await f.post('logout', 'forged', cookie).expect(403); - f.time.advanceHours(24); - expect( - (await request(f.origin).get('/api/auth/session').set('Cookie', cookie)) - .body.session, - ).toBeNull(); - }, -); - -httpTest( - 'link GET never consumes a challenge; only the requesting browser can confirm', - async ({ f }) => { - const state = await request(f.origin).get('/api/auth/session').expect(200); - const cookie = cookies(state); - await f - .post('email', state.body.csrf, cookie, { email: 'alice@example.test' }) - .expect(200); - const email = await f.email.next(); - const link = new URL(email.text.match(/http[^\s]+/)![0]); - const preview = await request(f.origin) - .get(link.pathname + link.search) - .expect(303); - expect(preview.headers.location).toContain('/login#id='); - expect(await f.db.selectFrom('sessions').selectAll().execute()).toEqual([]); - const other = await request(f.origin).get('/api/auth/session'); - const body = { - method: 'link', - id: link.searchParams.get('id'), - value: link.searchParams.get('token'), - }; - await f.post('verify', other.body.csrf, cookies(other), body).expect(400); - await f.post('verify', state.body.csrf, cookie, body).expect(200); - }, -); - -httpTest( - 'JSON OAuth start and native callback preserve browser binding for all providers', - async ({ f }) => { - for (const provider of ['google', 'github', 'apple', 'facebook'] as const) { - const state = await request(f.origin) - .get('/api/auth/session') - .expect(200); - const started = await f - .post(`oauth/${provider}/start`, state.body.csrf, cookies(state)) - .expect(200); - const callback = f.provider.authorize( - provider, - new URL(started.body.url), - { - email: `${provider}@example.test`, - }, - ); - if (provider === 'apple') { - const relay = await request(f.origin) - .post(callback.pathname) - .type('form') - .send(callback.searchParams.toString()) - .expect(303); - expect(relay.headers.location).toBe( - callback.pathname + callback.search, - ); - expect(relay.headers['set-cookie']).toBeUndefined(); - } - const unbound = await request(f.origin) - .get(callback.pathname + callback.search) - .expect(303); - expect(unbound.headers.location).toContain('/login?error='); - const result = await request(f.origin) - .get(callback.pathname + callback.search) - .set('Cookie', cookies(started)) - .expect(303); - expect(result.headers.location).toBe('/profile'); - const loggedIn = await request(f.origin) - .get('/api/auth/session') - .set('Cookie', cookies(result)); - expect(loggedIn.body.session.user.email).toBe(`${provider}@example.test`); - await f.post('logout', loggedIn.body.csrf, cookies(result)).expect(200); - } - }, -); diff --git a/tests/integration/better-auth-oauth.test.ts b/tests/integration/better-auth-oauth.test.ts index 2195389..5f3c777 100644 --- a/tests/integration/better-auth-oauth.test.ts +++ b/tests/integration/better-auth-oauth.test.ts @@ -1,10 +1,6 @@ import type { DiagnosticRecord } from '../../packages/pgstencil/src/diagnostics.ts'; import { test, expect } from 'vitest'; -import { build } from 'esbuild'; -import { builtinModules } from 'node:module'; -import { mkdir, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; import { createTestContext } from '../../packages/pgstencil/src/testing.ts'; import { queryDatabase } from '../../packages/pgstencil/src/postgres.ts'; import { @@ -13,35 +9,16 @@ import { betterAuthCredentials as allOAuthCredentials, type GrantOptions, } from '../support/oauth-server.ts'; +import { bundleDeterministicApp } from '../support/better-auth-bundle.ts'; import type { AuthOptions } from '../../packages/auth/src/better-auth.ts'; import { identityEmail } from '../../packages/auth/src/better-auth-email.ts'; -import type { createDeterministicApp } from '../support/better-auth-entry.ts'; import { providers, type Provider, } from '../../examples/better-auth/src/oauth.ts'; const origin = 'https://auth.example.test'; -const built = (async () => { - const result = await build({ - entryPoints: ['tests/support/better-auth-entry.ts'], - bundle: true, - write: false, - platform: 'node', - format: 'esm', - external: [...builtinModules, 'node:*', 'pg-native'], - inject: [resolve('packages/auth/src/better-auth-testing.ts')], - banner: { - js: "import {createRequire} from 'node:module'; const require = createRequire(import.meta.url);", - }, - }); - await mkdir('.build', { recursive: true }); - const path = resolve('.build/better-auth-oauth-test.mjs'); - await writeFile(path, result.outputFiles[0]!.text); - return (await import(pathToFileURL(path).href)) as { - createDeterministicApp: typeof createDeterministicApp; - }; -})(); +const built = bundleDeterministicApp('better-auth-oauth-test'); async function fixture( policy: 'single' | 'multiple' = 'multiple', accountLinking: 'explicit' | 'same-email' = 'explicit', diff --git a/tests/integration/better-auth.test.ts b/tests/integration/better-auth.test.ts index f0d591d..48f39f0 100644 --- a/tests/integration/better-auth.test.ts +++ b/tests/integration/better-auth.test.ts @@ -1,9 +1,5 @@ import { test, expect } from 'vitest'; -import { build } from 'esbuild'; -import { builtinModules } from 'node:module'; -import { mkdir, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; import request from 'supertest'; import { createTestContext } from '../../packages/pgstencil/src/testing.ts'; import { queryDatabase } from '../../packages/pgstencil/src/postgres.ts'; @@ -17,38 +13,26 @@ import { authOptions } from '../../examples/better-auth/src/auth.ts'; import { connectDatabase } from '../../packages/pgstencil/src/postgres.ts'; import { listen } from '../../examples/better-auth/src/node.ts'; import type { createDeterministicApp } from '../support/better-auth-entry.ts'; +import { bundleDeterministicApp } from '../support/better-auth-bundle.ts'; +import type { DiagnosticRecord } from '../../packages/pgstencil/src/diagnostics.ts'; +import type { SignInEmailInput } from '../../packages/auth/src/better-auth.ts'; const origin = 'https://better-auth.example.test'; const migrations = resolve('packages/auth/better-auth-migrations'); -const built = (async () => { - const result = await build({ - entryPoints: ['tests/support/better-auth-entry.ts'], - bundle: true, - write: false, - format: 'esm', - platform: 'node', - packages: 'bundle', - external: [ - ...builtinModules, - ...builtinModules.map((name) => `node:${name}`), - 'pg-native', - ], - inject: [resolve('packages/auth/src/better-auth-testing.ts')], - banner: { - js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);", - }, - }); - await mkdir('.build', { recursive: true }); - const file = resolve('.build/better-auth-test.mjs'); - await writeFile(file, result.outputFiles[0]!.text); - return (await import(pathToFileURL(file).href)) as { - createDeterministicApp: typeof createDeterministicApp; - }; -})(); +const built = bundleDeterministicApp('better-auth-test'); async function fixture( now = '2020-01-01T00:00:00.000Z', sessionPolicy: 'single' | 'multiple' = 'multiple', - extra: { ipAddressHeaders?: string[] } = {}, + extra: Partial< + Pick< + Parameters[0], + | 'ipAddressHeaders' + | 'appName' + | 'emailFrom' + | 'renderEmail' + | 'diagnostics' + > + > = {}, ) { const context = await createTestContext({ migrations, @@ -233,7 +217,7 @@ test('Better Auth time: 23 hours, expiration boundary, isolated async contexts a expect(two.before).toBe(b.time.now().getTime()); expect(two.after).toBe(two.before); a.time.advanceHours(1); - // Better Auth 1.7.3 compares expiresAt < now: equality remains valid for one millisecond. + // Better Auth compares expiresAt < now: equality remains valid for one millisecond. expect((await a.get(alice.cookie)).body.user.email).toBe( 'alice@example.test', ); @@ -821,3 +805,160 @@ for (const policy of ['single', 'multiple'] as const) policy === 'multiple', ); }); + +test('sign-in email: the default sender is signin@ the origin hostname, and emailFrom overrides it', async ({ + onTestFinished, +}) => { + const derived = await fixture(); + onTestFinished(() => derived.close()); + expect((await login(derived)).email.from).toBe( + 'signin@better-auth.example.test', + ); + // A blanked binding counts as omitted. + const blank = await fixture(undefined, undefined, { emailFrom: ' ' }); + onTestFinished(() => blank.close()); + expect((await login(blank)).email.from).toBe( + 'signin@better-auth.example.test', + ); + const explicit = await fixture(undefined, undefined, { + emailFrom: 'Example ', + }); + onTestFinished(() => explicit.close()); + const { email } = await login(explicit); + expect(email.from).toBe('Example '); + expect(email.subject).toBe('Your sign-in code'); +}); + +test('sign-in email: the default renderer keeps appName out of the HTML body', async ({ + onTestFinished, +}) => { + const appName = `Tom & "Jerry's"`; + const f = await fixture(undefined, undefined, { appName }); + onTestFinished(() => f.close()); + const { email, otp } = await login(f); + expect(email.subject).toBe(`Your ${appName} sign-in code`); + expect(email.text).toBe( + `Your sign-in code is ${otp}. It expires in 10 minutes.`, + ); + expect(email.html).toBe( + `

Your sign-in code is ${otp}.

It expires in 10 minutes.

`, + ); +}); + +test('sign-in email: a custom renderEmail composes the message and its code signs in', async ({ + onTestFinished, +}) => { + const inputs: SignInEmailInput[] = []; + const f = await fixture(undefined, undefined, { + appName: 'Rhythm', + emailFrom: 'Rhythm ', + renderEmail: async (input) => { + inputs.push(input); + return { + from: input.from, + to: [input.email], + replyTo: 'help@rhythm.example.test', + subject: `Code ${input.otp}`, + text: `Bonjour ! Code : ${input.otp} (${input.expiresInMinutes} min)`, + html: `

Bonjour ! ${input.otp}

`, + }; + }, + }); + onTestFinished(() => f.close()); + const { email, otp } = await login(f); + expect(inputs).toEqual([ + { + email: 'alice@example.test', + otp, + expiresInMinutes: 10, + appName: 'Rhythm', + from: 'Rhythm ', + }, + ]); + expect(email).toEqual({ + capturedAt: '2020-01-01T00:00:00.000Z', + from: 'Rhythm ', + to: ['alice@example.test'], + replyTo: 'help@rhythm.example.test', + subject: `Code ${otp}`, + text: `Bonjour ! Code : ${otp} (10 min)`, + html: `

Bonjour ! ${otp}

`, + }); +}); + +for (const [name, renderEmail] of [ + [ + 'throws', + () => { + throw new Error('private-render alice@example.test'); + }, + ], + [ + 'rejects', + async () => { + throw new Error('private-render alice@example.test'); + }, + ], + [ + 'addresses someone else', + (input: SignInEmailInput) => ({ + from: input.from, + to: ['attacker@example.test'], + subject: 'code', + text: input.otp, + html: input.otp, + }), + ], + [ + 'adds a recipient', + (input: SignInEmailInput) => ({ + from: input.from, + to: [input.email, 'attacker@example.test'], + subject: 'code', + text: input.otp, + html: input.otp, + }), + ], + [ + 'adds a bcc', + (input: SignInEmailInput) => ({ + from: input.from, + to: [input.email], + bcc: ['attacker@example.test'], + subject: 'code', + text: input.otp, + html: input.otp, + }), + ], + ['returns nothing', () => undefined as never], +] as const) + test(`sign-in email: a renderer that ${name} fails the send and delivers nothing`, async ({ + onTestFinished, + }) => { + const records: DiagnosticRecord[] = []; + const f = await fixture(undefined, undefined, { + renderEmail, + diagnostics: { sink: (record) => records.push(record) }, + }); + onTestFinished(() => f.close()); + const response = await f.post('email-otp/send-verification-otp', { + email: 'alice@example.test', + type: 'sign-in', + }); + // Better Auth answers a failed send like a successful one; nothing reveals the failure. + expect(response.status).toBe(200); + expect(JSON.stringify(response.body)).not.toContain('private-render'); + expect(f.email.all()).toEqual([]); + const failed = records.filter( + (record) => record.event === 'email.delivery.failed', + ); + expect(failed).toHaveLength(1); + expect(failed[0]).toMatchObject({ reason: 'unexpected_error' }); + expect(failed[0]).not.toHaveProperty('stage'); + expect(records.map((record) => record.event)).not.toContain( + 'email.delivery.succeeded', + ); + const logged = JSON.stringify(records); + expect(logged).not.toContain('alice@example.test'); + expect(logged).not.toContain('attacker@example.test'); + }); diff --git a/tests/integration/billing-http.test.ts b/tests/integration/billing-http.test.ts index aa23181..3f0e322 100644 --- a/tests/integration/billing-http.test.ts +++ b/tests/integration/billing-http.test.ts @@ -1,29 +1,34 @@ import { test, expect } from 'vitest'; import request from 'supertest'; import { createTestContext } from '../../packages/pgstencil/src/testing.ts'; -import { - captureResponse, - stableJson, -} from '../../packages/pgstencil/src/snapshots.ts'; -import { appMigrations } from '../../examples/login/src/migrations.ts'; +import { stableJson } from '../../packages/pgstencil/src/snapshots.ts'; import { createStripeDev } from '../../packages/stripe/src/testing.ts'; -import { startApp } from '../../examples/login/src/app.ts'; -import { begin, cookies, post, field } from './helpers.ts'; +import { appMigrations } from '../../examples/better-auth/src/migrations.ts'; +import { listen } from '../../examples/better-auth/src/node.ts'; +import { bundleDeterministicApp } from '../support/better-auth-bundle.ts'; + +const origin = 'https://billing.example.test'; +const built = bundleDeterministicApp('billing-http-test'); +const cookieFrom = (response: request.Response) => + ((response.headers['set-cookie'] as unknown as string[] | undefined) ?? []) + .map((value) => value.split(';')[0]) + .join('; '); async function fixture() { const context = await createTestContext({ migrations: appMigrations, + seed: 'billing-http', }); const dev = await createStripeDev(context.time, context.random); - const app = await startApp({ + const app = (await built).createDeterministicApp({ + ...context, databaseUrl: context.database.url, - time: context.time, - random: context.random, - email: context.email, - development: true, + origin, secret: 'pgstencil-billing-test-secret-at-least-32', billing: { stripe: dev.stripe, + time: context.time, + random: context.random, config: { prices: dev.prices, trialDays: 14, @@ -33,31 +38,46 @@ async function fixture() { }, }, }); - const target = { - client: request.agent(app.origin), - origin: app.origin, - email: context.email, - }; - const flow = await begin(target); - const signedIn = await post( - target, - '/login/code', - { csrf: flow.csrf, code: flow.code }, - flow.pendingCookie, - ).expect(303); - const cookie = cookies(signedIn); - const account = await target.client - .get('/account') - .set('Cookie', cookie) - .expect(200); + const server = await listen(app.fetch); + const client = request(server.origin); + // Sign in by email code, as a browser on this origin would. + const csrfResponse = await client.get('/api/auth/csrf').expect(200); + const csrfCookie = cookieFrom(csrfResponse); + const signIn = (path: string, body: object) => + client + .post('/api/auth/' + path) + .set('Origin', origin) + .set('Cookie', csrfCookie) + .set('X-CSRF-Token', csrfResponse.body.csrf as string) + .send(body); + await signIn('email-otp/send-verification-otp', { + email: 'alice@example.test', + type: 'sign-in', + }).expect(200); + const otp = (await context.email.next()).text.match(/\b\d{8}\b/)![0]; + const signedIn = await signIn('sign-in/email-otp', { + email: 'alice@example.test', + otp, + }).expect(200); + const cookie = cookieFrom(signedIn); + const post = (path: string, body: object = {}, from = origin) => + client + .post(path) + .set('Origin', from) + .set('Cookie', cookie) + .set('content-type', 'application/json') + .send(JSON.stringify(body)); return { ...context, dev, - app, - target, + server, + client, cookie, - csrf: field(account.text, 'csrf'), + post, + status: (query = '') => + client.get('/api/billing' + query).set('Cookie', cookie), async close() { + await server.close(); await app.close(); await dev.close(); await context.close(); @@ -76,37 +96,29 @@ const httpTest = test.extend<{ f: Awaited> }>({ }); httpTest( - 'browser billing flow uses authenticated forms and raw signed webhook delivery', + 'billing API: session-owned checkout, raw signed webhook delivery and confirmed return', async ({ f }) => { - const page = await request(f.app.origin) - .get('/billing') - .set('Cookie', f.cookie) + const before = (await f.status().expect(200)).body; + expect(before).toMatchObject({ + access: false, + trialEligible: true, + trialDays: 14, + }); + const started = await f + .post('/api/billing/checkout', { plan: 'yearly' }) .expect(200); - expect(page.text).toContain('provide a card'); - const started = await post( - f.target, - '/billing/checkout', - { csrf: f.csrf, plan: 'yearly' }, - f.cookie, - ).expect(303); const session = [...f.dev.checkouts.values()][0]!; - expect(started.headers.location).toBe(session.url); + expect(started.body).toEqual({ url: session.url }); f.dev.completeCheckout(session.id); - await f.dev.deliver(`${f.app.origin}/webhooks/stripe`); - const trial = await request(f.app.origin) - .get('/billing') - .set('Cookie', f.cookie) - .expect(200); - const captured = captureResponse(trial, f.app.origin); - await expect(captured.html).toMatchFileSnapshot( - './snapshots/billing/page.html', + await f.dev.deliver(`${f.server.origin}/api/stripe/webhook`); + const trial = await f + .status(`?checkout=${session.metadata!.pgstencil_operation}`) + .expect(200) + .expect('cache-control', 'no-store'); + expect(trial.body).toMatchObject({ access: true, plan: 'yearly' }); + await expect(stableJson(trial.body)).toMatchFileSnapshot( + './snapshots/billing/status.json', ); - await expect(captured.markdown).toMatchFileSnapshot( - './snapshots/billing/page.md', - ); - await expect( - captured.http.replaceAll(f.dev.origin, 'https://stripe.test'), - ).toMatchFileSnapshot('./snapshots/billing/page-http.json'); await expect( stableJson( f.dev.requests @@ -116,62 +128,67 @@ httpTest( body: Object.fromEntries( Object.entries(r.body).map(([key, value]) => [ key, - value.replaceAll(f.app.origin, ''), + value.replaceAll(origin, ''), ]), ), })), ), ).toMatchFileSnapshot('./snapshots/billing/stripe-requests.json'); - const portal = await post( - f.target, - '/billing/portal', - { csrf: f.csrf }, - f.cookie, - ).expect(303); - expect(portal.headers.location).toContain(f.dev.origin); + const portal = await f.post('/api/billing/portal').expect(200); + expect(portal.body.url).toContain(f.dev.origin); f.email.assertNoUnread(); }, ); httpTest( - 'billing HTTP rejects CSRF, anonymous requests, forged returns and malformed webhooks', + 'billing API rejects anonymous, cross-origin, non-JSON, unknown-plan, forged-return and forged-webhook requests', async ({ f }) => { - await request(f.app.origin) - .get('/billing') - .expect(303) - .expect('location', '/login'); - await post( - f.target, - '/billing/checkout', - { csrf: 'wrong', plan: 'monthly' }, - f.cookie, - ).expect(403); - await request(f.app.origin) - .post('/billing/checkout') + await f.client.get('/api/billing').expect(401); + for (const path of [ + '/api/billing/checkout', + '/api/billing/portal', + '/api/billing/cancel-checkout', + ]) { + await f.client + .post(path) + .set('Origin', origin) + .set('content-type', 'application/json') + .send(JSON.stringify({ plan: 'monthly' })) + .expect(401); + await f.post(path, { plan: 'monthly' }, 'https://evil.test').expect(403); + await f + .post(path, { plan: 'monthly' }, `${origin}.evil.test`) + .expect(403); + await f.client + .post(path) + .set('Cookie', f.cookie) + .set('Origin', origin) + .type('form') + .send({ plan: 'monthly' }) + .expect(415); + } + // No Origin at all, as a non-browser client sends it. + await f.client + .post('/api/billing/checkout') .set('Cookie', f.cookie) - .set('Origin', 'https://evil.test') - .type('form') - .send({ csrf: f.csrf, plan: 'monthly' }) + .set('content-type', 'application/json') + .send(JSON.stringify({ plan: 'monthly' })) .expect(403); - await post( - f.target, - '/billing/checkout', - { csrf: f.csrf, plan: 'price_attacker' }, - f.cookie, - ).expect(400); - await request(f.app.origin) - .get('/billing?checkout=forged') - .set('Cookie', f.cookie) - .expect(404); - await request(f.app.origin) - .post('/webhooks/stripe') + await f + .post('/api/billing/checkout', { plan: 'price_attacker' }) + .expect(400); + await f.post('/api/billing/checkout', {}).expect(400); + await f.status('?checkout=forged').expect(404); + await f.client + .post('/api/stripe/webhook') .send({ id: 'evt_forged' }) .expect(400); - await request(f.app.origin) - .post('/webhooks/stripe') + await f.client + .post('/api/stripe/webhook') .set('stripe-signature', 'forged') .send('{}') .expect(400); expect(f.dev.checkouts.size).toBe(0); + expect(f.dev.requests.filter((r) => r.method === 'POST')).toEqual([]); }, ); diff --git a/tests/integration/billing-security.test.ts b/tests/integration/billing-security.test.ts new file mode 100644 index 0000000..6f0a391 --- /dev/null +++ b/tests/integration/billing-security.test.ts @@ -0,0 +1,652 @@ +/** + * Pins the `SECURITY.md` billing rules that `billing.test.ts` does not already + * pin. Every test drives the real `Billing` against real Postgres and the + * StripeDev simulator through the real Stripe SDK. + */ +import { test, expect } from 'vitest'; +import { + withDiagnostics, + type DiagnosticRecord, +} from '../../packages/pgstencil/src/diagnostics.ts'; +import { createTestContext } from '../../packages/pgstencil/src/testing.ts'; +import { connectDatabase } from '../../packages/pgstencil/src/database.ts'; +import { + Billing, + BillingError, + Stripe, + STRIPE_API_VERSION, + type BillingConfig, + type BillingDB, +} from '../../packages/stripe/src/index.ts'; +import { billingMigrations } from '../../packages/stripe/src/migrations.ts'; +import { createStripeDev } from '../../packages/stripe/src/testing.ts'; + +async function fixture(config: Partial = {}) { + const context = await createTestContext({ migrations: billingMigrations }); + const dev = await createStripeDev(context.time, context.random); + const db = connectDatabase(context.database.url); + const base: BillingConfig = { + prices: dev.prices, + trialDays: 14, + webhookSecret: dev.webhookSecret, + live: false, + origin: 'https://example.test', + ...config, + }; + /** Another `Billing` over the same database, optionally on another Stripe client. */ + const make = ( + overrides: Partial = {}, + stripe: Stripe = dev.stripe, + ) => + new Billing(db, stripe, context.time, context.random, { + ...base, + ...overrides, + }); + const billing = make(); + return { + ...context, + dev, + db, + billing, + make, + async start(plan = 'monthly', owner = 'alice') { + const checkout = await billing.checkout( + owner, + `${owner}@example.test`, + plan, + ); + const session = [...dev.checkouts.values()].find( + (s) => s.url === checkout.url, + )!; + return { checkout, session }; + }, + async deliver() { + while (dev.events.length) { + const { body, signature } = dev.signed(dev.events[0]!); + await billing.webhook(body, signature); + dev.events.shift(); + } + }, + /** A signed-able event around `object`, as Stripe would send it. */ + event(id: string, type: string, object: unknown, created?: number) { + return { + id, + object: 'event', + api_version: STRIPE_API_VERSION, + created: created ?? Math.floor(context.time.now().getTime() / 1000), + livemode: false, + type, + data: { object }, + pending_webhooks: 1, + request: null, + } as unknown as Stripe.Event; + }, + requests(method: string, path: string) { + return dev.requests.filter((r) => r.method === method && r.path === path); + }, + async close() { + await db.destroy(); + await dev.close(); + await context.close(); + }, + }; +} +type Fixture = Awaited>; +const billingTest = test.extend<{ f: Fixture }>({ + f: async ({}, use) => { + const f = await fixture(); + try { + await use(f); + } finally { + await f.close(); + } + }, +}); + +/** The fields of a listed subscription that `synchronize` checks. */ +interface Listed { + customer: unknown; + livemode: boolean; + metadata: Record; + items: { data: { id: string; quantity: number; price: { id: string } }[] }; +} +/** + * A Stripe client on StripeDev whose subscription lists pass through `tamper` + * on the way back, as a Stripe account changed outside pgstencil would answer. + * It rewrites responses rather than StripeDev's state, so it holds however + * `synchronize` pages through the list. + */ +function tamperedStripe(origin: string, tamper: (sub: Listed) => void) { + const { hostname, port } = new URL(origin); + return new Stripe('sk_test_pgstencil_local_only', { + host: hostname, + port: Number(port), + protocol: 'http', + maxNetworkRetries: 0, + timeout: 5000, + httpClient: Stripe.createFetchHttpClient(async (input, init) => { + const response = await fetch(input, init); + if ( + init?.method !== 'GET' || + new URL(String(input)).pathname !== '/v1/subscriptions' + ) + return response; + const list = (await response.json()) as { data: Listed[] }; + list.data.forEach(tamper); + return Response.json(list, { status: response.status }); + }), + }); +} + +billingTest( + 'webhook(event) takes only an event this Billing verified, and a rejected delivery reaches neither the ledger nor apply', + async ({ f }) => { + const { session } = await f.start(); + f.dev.completeCheckout(session.id); + const event = f.dev.events[0]!; + const signed = f.dev.signed(event); + let calls = 0; + const apply = async () => { + calls++; + }; + // The signature verifies against the injected clock, years behind the host's. + expect(f.time.now().getTime()).toBeLessThan(Date.now() - 86_400_000); + const verified = await f.billing.verifyWebhook( + signed.body, + signed.signature, + ); + // Another instance, even with the same secret, never verified this object. + await expect(f.make().webhook(verified, apply)).rejects.toThrow( + 'verifyWebhook', + ); + // Nor is a copy the object verifyWebhook returned. + await expect( + f.billing.webhook(structuredClone(verified), apply), + ).rejects.toThrow('verifyWebhook'); + // Another secret, and a re-serialized body, fail the signature. + const attacker = f.dev.stripe.webhooks.generateTestHeaderString({ + payload: signed.body, + secret: 'whsec_not_the_endpoint_secret', + timestamp: Math.floor(f.time.now().getTime() / 1000), + }); + await expect( + f.billing.webhook(signed.body, attacker, apply), + ).rejects.toMatchObject({ status: 400 }); + await expect( + f.billing.webhook( + JSON.stringify(JSON.parse(signed.body), null, 2), + signed.signature, + apply, + ), + ).rejects.toMatchObject({ status: 400 }); + // Correctly signed, but not this endpoint's mode, version or account. + for (const invalid of [ + { ...event, livemode: true }, + { ...event, api_version: '2000-01-01' }, + { ...event, account: 'acct_other' }, + ]) { + const s = f.dev.signed(invalid as typeof event); + const error = await f.billing + .verifyWebhook(s.body, s.signature) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(BillingError); + expect(error).toMatchObject({ status: 400 }); + } + expect(calls).toBe(0); + expect( + await f.billing.db.selectFrom('events').selectAll().execute(), + ).toEqual([]); + expect((await f.billing.status('alice')).access).toBe(false); + // The verified object itself is processed, once. + await f.billing.webhook(verified, apply); + await f.billing.webhook(verified, apply); + expect(calls).toBe(1); + expect((await f.billing.status('alice')).access).toBe(true); + }, +); + +billingTest( + 'concurrent deliveries of one event run apply once and record one attempt', + async ({ f }) => { + const { session } = await f.start(); + f.dev.completeCheckout(session.id); + const signed = f.dev.signed(f.dev.events[0]!); + let calls = 0; + await Promise.all( + Array.from({ length: 5 }, () => + f.billing.webhook(signed.body, signed.signature, async () => { + calls++; + }), + ), + ); + expect(calls).toBe(1); + expect( + await f.billing.db + .selectFrom('events') + .select(['attempts', 'failed']) + .execute(), + ).toEqual([{ attempts: 1, failed: false }]); + }, +); + +billingTest( + 'event content never grants access: the resync reads Stripe, and context and completions are matched to the owner', + async ({ f }) => { + const alice = await f.start(); + const sub = f.dev.completeCheckout(alice.session.id); + const bob = await f.start('monthly', 'bob'); + const bobSub = f.dev.completeCheckout(bob.session.id); + await f.deliver(); + f.dev.transition(sub.id, 'cancel'); + await f.deliver(); + expect((await f.billing.status('alice')).access).toBe(false); + const aliceCustomer = (await f.billing.customerOf('alice'))!; + const now = Math.floor(f.time.now().getTime() / 1000); + const contexts: unknown[] = []; + const completions: unknown[] = []; + const deliver = async (event: Stripe.Event) => { + const { body, signature } = f.dev.signed(event); + await f.billing.webhook(body, signature, async (e, trx, context) => { + contexts.push(context); + completions.push(await f.billing.completedCheckout(e, trx)); + }); + }; + // A correctly signed event, newer than the cancellation, whose body still + // claims an active subscription a year long. + const claimed = structuredClone(sub) as unknown as Record & + Listed; + Object.assign(claimed, { status: 'active', ended_at: null }); + Object.assign(claimed.items.data[0]!, { current_period_end: now + 31e6 }); + await deliver( + f.event( + 'evt_claimsactive', + 'customer.subscription.updated', + claimed, + now + 3600, + ), + ); + expect((await f.billing.status('alice')).access).toBe(false); + expect(contexts.at(-1)).toEqual({ + ownerId: 'alice', + subscription: expect.objectContaining({ id: sub.id, status: 'canceled' }), + }); + // An event naming alice's customer and bob's subscription reads back no + // row, and leaves bob's row his. + const crossed = { ...structuredClone(bobSub), customer: aliceCustomer }; + await deliver( + f.event('evt_crossed', 'customer.subscription.updated', crossed), + ); + expect(contexts.at(-1)).toEqual({ ownerId: 'alice', subscription: null }); + expect((await f.billing.status('bob')).subscription).toMatchObject({ + id: bobSub.id, + owner_id: 'bob', + }); + expect((await f.billing.status('alice')).access).toBe(false); + // A customer pgstencil did not create resyncs nobody, whatever owner its + // metadata names. + const listsBefore = f.requests('GET', '/v1/subscriptions').length; + const stranger = { + ...claimed, + id: 'sub_stranger', + customer: 'cus_stranger', + metadata: { pgstencil_owner: 'alice' }, + }; + await deliver( + f.event('evt_stranger', 'customer.subscription.created', stranger), + ); + expect(contexts.at(-1)).toEqual({ ownerId: null, subscription: null }); + expect(f.requests('GET', '/v1/subscriptions')).toHaveLength(listsBefore); + expect( + await f.billing.db + .selectFrom('subscriptions') + .select('id') + .where('id', '=', 'sub_stranger') + .execute(), + ).toEqual([]); + // A completion naming alice's operation on another session is not hers. + const forged = { ...structuredClone(alice.session), id: 'cs_forged' }; + expect(forged.metadata?.pgstencil_operation).toBe(alice.checkout.id); + await deliver( + f.event('evt_forgedcompletion', 'checkout.session.completed', forged), + ); + expect(completions).toEqual([null, null, null, null]); + expect((await f.billing.status('alice')).access).toBe(false); + }, +); + +billingTest( + "a subscription counts only with the owner's metadata, its customer, one item, a recognized price, quantity 1 and the configured livemode", + async ({ f }) => { + const { session } = await f.start(); + const sub = f.dev.completeCheckout(session.id); + await f.deliver(); + const row = () => + f.billing.db + .selectFrom('subscriptions') + .selectAll() + .where('id', '=', sub.id) + .executeTakeFirstOrThrow(); + const synced = await row(); + expect(synced.status).toBe('trialing'); + let tamper: ((s: Listed) => void) | null = null; + const tampered = f.make( + {}, + tamperedStripe(f.dev.origin, (s) => tamper?.(s)), + ); + const mismatches: Record void> = { + customer: (s) => { + s.customer = 'cus_someone_else'; + }, + 'two items': (s) => { + s.items.data.push({ ...s.items.data[0]!, id: 'si_second' }); + }, + 'no item': (s) => { + s.items.data = []; + }, + 'unrecognized price': (s) => { + s.items.data[0]!.price.id = 'price_not_configured'; + }, + 'quantity 2': (s) => { + s.items.data[0]!.quantity = 2; + }, + livemode: (s) => { + s.livemode = true; + }, + }; + for (const [name, change] of Object.entries(mismatches)) { + tamper = change; + await expect(tampered.reconcile('alice'), name).rejects.toThrow( + 'mismatch', + ); + // A webhook for the owner fails the same way, writes nothing, and stays retryable. + const id = `evt_mismatch${name.replace(/\W/g, '')}`; + const { body, signature } = f.dev.signed( + f.event(id, 'customer.subscription.updated', sub), + ); + await expect(tampered.webhook(body, signature), name).rejects.toThrow( + 'mismatch', + ); + expect(await row(), name).toEqual(synced); + expect( + await f.billing.db + .selectFrom('events') + .select(['failed', 'processed_at']) + .where('id', '=', id) + .executeTakeFirstOrThrow(), + name, + ).toEqual({ failed: true, processed_at: null }); + } + // Another owner's metadata, or none, is not this owner's subscription: + // its row stops counting, and the trial it used stays used. + for (const change of [ + (s: Listed) => { + s.metadata.pgstencil_owner = 'mallory'; + }, + (s: Listed) => { + delete s.metadata.pgstencil_owner; + }, + ]) { + tamper = change; + await tampered.reconcile('alice'); + expect((await row()).status).toBe('missing'); + expect(await tampered.status('alice')).toMatchObject({ + access: false, + trialEligible: false, + }); + } + await tampered.checkout('alice', 'alice@example.test', 'yearly'); + const reopened = f.requests('POST', '/v1/checkout/sessions').at(-1)!.body; + expect(reopened['line_items[0][price]']).toBe(f.dev.prices.yearly); + expect(reopened['subscription_data[trial_period_days]']).toBeUndefined(); + // Once Stripe answers consistently again, the same event applies. + tamper = null; + const { body, signature } = f.dev.signed( + f.event('evt_mismatchlivemode', 'customer.subscription.updated', sub), + ); + await tampered.webhook(body, signature); + expect((await row()).status).toBe('trialing'); + expect((await f.billing.status('alice')).access).toBe(true); + }, +); + +billingTest( + 'racing checkouts open one Checkout Session per owner, and none opens beside a held subscription', + async ({ f }) => { + const race = () => + Promise.allSettled( + ['monthly', 'yearly', 'monthly', 'yearly', 'yearly'].map((plan) => + f.billing.checkout('alice', 'alice@example.test', plan), + ), + ); + const first = await race(); + const opened = first.flatMap((r) => + r.status === 'fulfilled' ? [r.value.id] : [], + ); + expect(opened.length).toBeGreaterThan(0); + expect(new Set(opened).size).toBe(1); + for (const r of first) + if (r.status === 'rejected') { + expect(r.reason).toBeInstanceOf(BillingError); + expect(r.reason.message).toContain('Cancel the open checkout'); + } + // Racers reusing the one pending operation may each send its creation, + // always under that operation's key, so Stripe creates one session. + const keys = f.requests('POST', '/v1/checkout/sessions').map((r) => r.key); + expect(new Set(keys)).toEqual(new Set(opened.slice(0, 1))); + expect(f.dev.checkouts.size).toBe(1); + expect(f.dev.customers.size).toBe(1); + expect( + await f.billing.db + .selectFrom('checkouts') + .select('id') + .where('status', 'in', ['pending', 'open']) + .execute(), + ).toEqual([{ id: opened[0] }]); + f.dev.completeCheckout([...f.dev.checkouts.keys()][0]!); + await f.deliver(); + const second = await race(); + for (const r of second) { + expect(r.status).toBe('rejected'); + if (r.status === 'rejected') + expect(r.reason.message).toContain('Manage billing'); + } + expect(f.requests('POST', '/v1/checkout/sessions')).toHaveLength( + keys.length, + ); + expect(f.dev.checkouts.size).toBe(1); + }, +); + +billingTest( + 'creation keys are committed before Stripe sees them, retries resend them, and an ambiguous Checkout past 23 hours fails closed', + async ({ f }) => { + f.dev.failNext('/v1/checkout/sessions', true); + await expect(f.start()).rejects.toThrow(); + const pending = await f.billing.db + .selectFrom('checkouts') + .selectAll() + .executeTakeFirstOrThrow(); + expect(pending).toMatchObject({ status: 'pending', session_id: null }); + const account = await f.billing.db + .selectFrom('accounts') + .selectAll() + .executeTakeFirstOrThrow(); + expect(f.requests('POST', '/v1/customers').map((r) => r.key)).toEqual([ + account.customer_key, + ]); + const { checkout } = await f.start(); + expect(checkout.id).toBe(pending.id); + const creations = f.requests('POST', '/v1/checkout/sessions'); + expect(creations.map((r) => r.key)).toEqual([pending.id, pending.id]); + expect(creations[1]!.body).toEqual(creations[0]!.body); + expect(f.dev.checkouts.size).toBe(1); + // Bob's response is lost too, and he returns a day later. + f.dev.failNext('/v1/checkout/sessions', true); + await expect(f.start('monthly', 'bob')).rejects.toThrow(); + f.time.advanceHours(23); + await expect(f.start('monthly', 'bob')).rejects.toThrow( + 'Checkout creation needs reconciliation', + ); + expect(f.requests('POST', '/v1/checkout/sessions')).toHaveLength(3); + // Nothing else opens a chargeable Checkout for him in the meantime. + await expect(f.start('yearly', 'bob')).rejects.toThrow( + 'Cancel the open checkout', + ); + await expect(f.billing.cancelCheckout('bob')).rejects.toThrow( + 'still being created', + ); + expect(f.requests('POST', '/v1/checkout/sessions')).toHaveLength(3); + }, +); + +test('the server picks customer, price, quantity and return URLs, and the constructor refuses off-origin configuration', async ({ + onTestFinished, +}) => { + const f = await fixture({ + returnPath: '/account/billing', + portalConfiguration: 'bpc_pgstencil', + }); + onTestFinished(() => f.close()); + const returnUrl = 'https://example.test/account/billing'; + expect(f.billing.returnUrl).toBe(returnUrl); + expect(f.billing.redirectOrigins).toEqual([ + 'https://checkout.stripe.com', + 'https://billing.stripe.com', + ]); + const { checkout } = await f.start('yearly'); + const customer = await f.billing.customerOf('alice'); + // Exactly these parameters: one line item, quantity 1, and no key a caller supplied. + expect(f.requests('POST', '/v1/checkout/sessions')[0]!.body).toEqual({ + mode: 'subscription', + customer, + client_reference_id: 'alice', + 'line_items[0][price]': f.dev.prices.yearly, + 'line_items[0][quantity]': '1', + payment_method_collection: 'always', + 'allowed_payment_method_types[0]': 'card', + 'subscription_data[metadata][pgstencil_owner]': 'alice', + 'subscription_data[metadata][pgstencil_operation]': checkout.id, + 'subscription_data[trial_period_days]': '14', + 'subscription_data[trial_settings][end_behavior][missing_payment_method]': + 'cancel', + 'metadata[pgstencil_operation]': checkout.id, + success_url: `${returnUrl}?checkout=${checkout.id}`, + cancel_url: returnUrl, + expires_at: String(Math.floor(f.time.now().getTime() / 1000) + 3600), + }); + await f.billing.portal('alice'); + expect(f.requests('POST', '/v1/billing_portal/sessions')[0]!.body).toEqual({ + customer, + return_url: returnUrl, + configuration: 'bpc_pgstencil', + }); + const build = (config: Partial) => () => f.make(config); + expect(build({ origin: 'https://example.test/' })).toThrow('origin only'); + expect(build({ origin: 'https://example.test/app' })).toThrow('origin only'); + expect(build({ returnPath: '//evil.example/billing' })).toThrow( + 'same-origin', + ); + expect(build({ returnPath: 'https://evil.example/billing' })).toThrow( + 'same-origin', + ); + expect( + build({ redirectOrigins: ['https://checkout.stripe.com/c/pay'] }), + ).toThrow('origins only'); + expect(build({ webhookSecret: '' })).toThrow('webhook secret'); +}); + +billingTest( + "confirmCheckout acts only on the calling owner's own subscription Checkout", + async ({ f }) => { + const alice = await f.start(); + await f.start('monthly', 'bob'); + for (const owner of ['bob', 'mallory']) + await expect( + f.billing.confirmCheckout(owner, alice.checkout.id), + ).rejects.toMatchObject({ status: 404 }); + // The stored operation is alice's, but Stripe's session must be too. + const session = alice.session as unknown as Record; + const bobCustomer = await f.billing.customerOf('bob'); + for (const [field, value] of [ + ['client_reference_id', 'bob'], + ['customer', bobCustomer], + ['mode', 'payment'], + ] as const) { + const original = session[field]; + session[field] = value; + await expect( + f.billing.confirmCheckout('alice', alice.checkout.id), + field, + ).rejects.toThrow('owner mismatch'); + session[field] = original; + } + // An open session confirms nothing; a completed one grants through the resync. + await f.billing.confirmCheckout('alice', alice.checkout.id); + expect((await f.billing.status('alice')).access).toBe(false); + f.dev.completeCheckout(alice.session.id); + await f.billing.confirmCheckout('alice', alice.checkout.id); + expect((await f.billing.status('alice')).access).toBe(true); + expect((await f.billing.status('bob')).access).toBe(false); + }, +); + +billingTest( + 'webhook diagnostics name an event only once it verifies, and never carry a body, signature, secret or message', + async ({ f }) => { + const { session } = await f.start(); + f.dev.completeCheckout(session.id); + const event = f.dev.events[0]!; + const signed = f.dev.signed(event); + const records: DiagnosticRecord[] = []; + const observe = (run: () => Promise) => + withDiagnostics( + { sink: (record) => records.push(record), time: f.time }, + run, + ); + const tampered = signed.body.replace('"alice"', '"mallory"'); + expect(tampered).not.toBe(signed.body); + await expect( + observe(() => f.billing.webhook(tampered, signed.signature)), + ).rejects.toMatchObject({ status: 400 }); + const live = f.dev.signed({ ...event, livemode: true }); + await expect( + observe(() => f.billing.webhook(live.body, live.signature)), + ).rejects.toThrow('configuration'); + expect(records).toHaveLength(2); + for (const record of records) { + expect(record).toMatchObject({ + event: 'billing.webhook.failed', + stage: 'transport', + reason: 'request_rejected', + }); + expect(record).not.toHaveProperty('stripeEventId'); + } + f.dev.failNext('/v1/subscriptions'); + await expect( + observe(() => + f.billing.webhook(signed.body, signed.signature, async () => {}), + ), + ).rejects.toThrow(); + expect(records.slice(2)).toMatchObject([ + { event: 'billing.webhook.received', stripeEventId: event.id }, + { + event: 'billing.webhook.failed', + stage: 'database', + stripeEventId: event.id, + }, + ]); + const text = JSON.stringify(records); + for (const secret of [ + f.dev.webhookSecret, + signed.signature, + live.signature, + session.id, + (await f.billing.customerOf('alice'))!, + 'alice', + 'mallory', + 'No signatures found', + 'injected outage', + ]) + expect(text).not.toContain(secret); + }, +); diff --git a/tests/integration/billing-workers.test.ts b/tests/integration/billing-workers.test.ts index 4793e2d..453f5e6 100644 --- a/tests/integration/billing-workers.test.ts +++ b/tests/integration/billing-workers.test.ts @@ -8,7 +8,7 @@ import { } from 'miniflare'; import { createTestContext } from '../../packages/pgstencil/src/testing.ts'; import { createStripeDev } from '../../packages/stripe/src/testing.ts'; -import { appMigrations } from '../../examples/login/src/migrations.ts'; +import { billingMigrations } from '../../packages/stripe/src/migrations.ts'; const origin = 'https://worker.example.test'; const bundle = build({ @@ -30,7 +30,7 @@ const bundle = build({ }); test('Billing in workerd: fetch client, Web Crypto webhooks, founding offer and purchase counts', async () => { - const context = await createTestContext({ migrations: appMigrations }); + const context = await createTestContext({ migrations: billingMigrations }); const dev = await createStripeDev(context.time, context.random, undefined, { recurring: { price_founding_50: 'year', price_founding_60: 'year' }, }); diff --git a/tests/integration/billing.test.ts b/tests/integration/billing.test.ts index 919f8e5..5625704 100644 --- a/tests/integration/billing.test.ts +++ b/tests/integration/billing.test.ts @@ -13,8 +13,9 @@ import { SUBSCRIBED, type BillingConfig, type BillingDB, + type WebhookApply, } from '../../packages/stripe/src/index.ts'; -import { appMigrations } from '../../examples/login/src/migrations.ts'; +import { billingMigrations } from '../../packages/stripe/src/migrations.ts'; import { createStripeDev, type StripeDevOptions, @@ -34,7 +35,7 @@ async function fixture( } = {}, ) { const context = await createTestContext({ - migrations: appMigrations, + migrations: billingMigrations, }); const dev = await createStripeDev( context.time, @@ -458,6 +459,165 @@ billingTest( }, ); +billingTest( + 'reconciliation reads every page of a customer with more than 100 subscriptions', + async ({ f }) => { + const { session } = await f.start(); + // The oldest subscription, so Stripe lists it last, on the final page. + const kept = f.dev.completeCheckout(session.id); + await f.deliver(); + const customer = (await f.billing.customerOf('alice'))!; + const ended: string[] = []; + for (let i = 0; i < 205; i++) { + const created = await f.dev.stripe.checkout.sessions.create({ + mode: 'subscription', + customer, + line_items: [{ price: f.dev.prices.monthly, quantity: 1 }], + payment_method_collection: 'always', + subscription_data: { metadata: { pgstencil_owner: 'alice' } }, + expires_at: Math.floor(f.time.now().getTime() / 1000) + 3600, + success_url: 'https://example.test/billing', + }); + const sub = f.dev.completeCheckout(created.id); + f.dev.transition(sub.id, 'cancel'); + ended.push(sub.id); + } + f.dev.events.length = 0; + // A row Stripe no longer lists: a second current subscription, which + // withholds access until the complete list marks it missing. + await f.billing.db + .insertInto('subscriptions') + .values({ + id: 'sub_unlisted', + owner_id: 'alice', + price_id: f.dev.prices.monthly, + status: 'active', + started_at: f.time.now(), + ended_at: null, + period_end: new Date(f.time.now().getTime() + 86_400_000), + trial_end: null, + cancel_at_period_end: false, + updated_at: f.time.now(), + }) + .execute(); + expect((await f.billing.status('alice')).access).toBe(false); + const before = f.dev.requests.length; + await f.billing.reconcile('alice'); + // Newest first, 100 a page: the cursor is each page's last ID. + expect( + f.dev.requests + .slice(before) + .filter((r) => r.method === 'GET' && r.path === '/v1/subscriptions') + .map((r) => [r.body.limit, r.body.starting_after ?? null]), + ).toEqual([ + ['100', null], + ['100', ended[105]], + ['100', ended[5]], + ]); + const rows = async () => + Object.fromEntries( + ( + await f.billing.db + .selectFrom('subscriptions') + .select(['id', 'status']) + .where('owner_id', '=', 'alice') + .execute() + ).map((row) => [row.id, row.status]), + ); + expect(await rows()).toEqual({ + [kept.id]: 'trialing', + sub_unlisted: 'missing', + ...Object.fromEntries(ended.map((id) => [id, 'canceled'])), + }); + expect(await f.billing.status('alice')).toMatchObject({ + access: true, + subscription: { id: kept.id, status: 'trialing' }, + }); + // A webhook for this owner resyncs every page too. + f.dev.transition(kept.id, 'cancel'); + await f.deliver(); + expect((await rows())[kept.id]).toBe('canceled'); + expect(Object.keys(await rows())).toHaveLength(207); + expect((await f.billing.status('alice')).access).toBe(false); + }, +); + +billingTest( + 'pruneEvents deletes only events processed before the retention window', + async ({ f }) => { + const { session } = await f.start(); + const sub = f.dev.completeCheckout(session.id); + const [completed, created] = f.dev.events.splice(0); + const deliver = (event: typeof completed, apply?: WebhookApply) => { + const { body, signature } = f.dev.signed(event!); + return f.billing.webhook(body, signature, apply); + }; + // Processed now, so 40 days from now it is past a 30-day window. + await deliver(completed); + // Failed, and never processed. + await expect( + deliver(created, async () => { + throw new Error('fulfillment failed'); + }), + ).rejects.toThrow('fulfillment failed'); + // Received but never processed, without a recorded failure. + await f.billing.db + .insertInto('events') + .values({ + id: 'evt_unprocessed', + type: 'customer.subscription.updated', + received_at: f.time.now(), + processed_at: null, + }) + .execute(); + f.time.advanceDays(10); + const renewed = f.dev.transition(sub.id, 'renew'); + f.dev.events.length = 0; + await deliver(renewed); + const ledger = async () => + ( + await f.billing.db + .selectFrom('events') + .select('id') + .orderBy('id') + .execute() + ).map((row) => row.id); + const all = [completed!.id, created!.id, 'evt_unprocessed', renewed.id]; + expect(await ledger()).toEqual([...all].sort()); + for (const olderThanDays of [29, 0, -30, 30.5, NaN, 3651]) + await expect(f.billing.pruneEvents({ olderThanDays })).rejects.toThrow( + 'olderThanDays must be an integer between 30 and 3650', + ); + expect(await ledger()).toEqual([...all].sort()); + f.time.advanceDays(30); + // 40 days after the first and exactly 30 after the renewal: only the + // first is older than the window. + expect(await f.billing.pruneEvents({ olderThanDays: 30 })).toBe(1); + expect(await ledger()).toEqual( + [created!.id, 'evt_unprocessed', renewed.id].sort(), + ); + f.time.advanceMilliseconds(1); + expect(await f.billing.pruneEvents({ olderThanDays: 30 })).toBe(1); + // Unprocessed and failed events stay, however old. + f.time.advanceDays(3650); + expect(await f.billing.pruneEvents({ olderThanDays: 30 })).toBe(0); + expect(await ledger()).toEqual([created!.id, 'evt_unprocessed'].sort()); + expect( + await f.billing.db + .selectFrom('events') + .select(['id', 'failed', 'processed_at']) + .where('id', '=', created!.id) + .executeTakeFirstOrThrow(), + ).toMatchObject({ failed: true, processed_at: null }); + // The documented trade-off: a pruned event delivered again applies again. + let calls = 0; + await deliver(completed, async () => { + calls++; + }); + expect(calls).toBe(1); + }, +); + billingTest( 'expired and canceled checkouts can be replaced without spending the trial', async ({ f }) => { diff --git a/tests/integration/database.test.ts b/tests/integration/database.test.ts index ff0cf0f..4c2546a 100644 --- a/tests/integration/database.test.ts +++ b/tests/integration/database.test.ts @@ -6,20 +6,20 @@ import { allocateDatabase, queryDatabase, readMigrations, - defaultMigrations, validateMigrations, migrate, prepareTemplate, } from '../../packages/pgstencil/src/database.ts'; +import { fixtureMigrations } from '../support/fixture-app.ts'; test('shared SQL sources upgrade independently while each history stays append-only', async ({ onTestFinished, }) => { const directory = await mkdtemp(join(tmpdir(), 'pgstencil-sources-')); onTestFinished(() => rm(directory, { recursive: true, force: true })); - const shared = join(directory, 'auth'); + const shared = join(directory, 'package'); const application = join(directory, 'application'); - await cp(defaultMigrations, shared, { recursive: true }); + await cp(fixtureMigrations, shared, { recursive: true }); await mkdir(application); await writeFile( join(application, '100_domain.sql'), @@ -39,7 +39,7 @@ test('shared SQL sources upgrade independently while each history stays append-o ), ) + 1; await writeFile( - join(shared, `${String(next).padStart(3, '0')}_auth_display.sql`), + join(shared, `${String(next).padStart(3, '0')}_package_display.sql`), '-- Up Migration\nALTER TABLE users ADD COLUMN display_name text;\n-- Down Migration\nALTER TABLE users DROP COLUMN display_name;', ); await migrate(lease.url, await readMigrations(sources)); @@ -61,8 +61,8 @@ test('shared SQL sources upgrade independently while each history stays append-o ); }); test('warm templates produce isolated writable databases', async () => { - const a = await allocateDatabase(); - const b = await allocateDatabase(); + const a = await allocateDatabase(fixtureMigrations); + const b = await allocateDatabase(fixtureMigrations); try { expect(a.name).not.toBe(b.name); expect(a.hash).toBe(b.hash); @@ -71,8 +71,8 @@ test('warm templates produce isolated writable databases', async () => { "INSERT INTO users VALUES ('a','alice@example.test','2020-01-01')", ); expect(await queryDatabase(b.url, 'SELECT * FROM users')).toEqual([]); - expect((await prepareTemplate()).hash).toBe(a.hash); - const files = await readMigrations(defaultMigrations); + expect((await prepareTemplate(fixtureMigrations)).hash).toBe(a.hash); + const files = await readMigrations(fixtureMigrations); await validateMigrations(a.url, files); const changed = files.map((f) => ({ ...f, hash: 'changed' })); await expect(validateMigrations(a.url, changed)).rejects.toThrow( @@ -93,8 +93,8 @@ test('SQL upgrades preserve data and failed template initialization is recoverab const directory = await mkdtemp(join(tmpdir(), 'pgstencil-upgrade-')); onTestFinished(() => rm(directory, { recursive: true, force: true })); await writeFile( - join(directory, '001_auth.sql'), - await readFile(join(defaultMigrations, '001_auth.sql')), + join(directory, '001_users.sql'), + await readFile(join(fixtureMigrations, '001_users.sql')), ); const existing = await allocateDatabase(directory); onTestFinished(() => existing.close()); @@ -141,41 +141,3 @@ test('SQL upgrades preserve data and failed template initialization is recoverab { email: 'returning@example.test' }, ]); }); - -test('OAuth migration preserves existing email accounts and sessions', async ({ - onTestFinished, -}) => { - const directory = await mkdtemp(join(tmpdir(), 'pgstencil-oauth-upgrade-')); - onTestFinished(() => rm(directory, { recursive: true, force: true })); - await writeFile( - join(directory, '001_auth.sql'), - await readFile(join(defaultMigrations, '001_auth.sql')), - ); - const database = await allocateDatabase(directory); - onTestFinished(() => database.close()); - await queryDatabase( - database.url, - "INSERT INTO users VALUES ('existing', 'returning@example.test', '2020-01-01')", - ); - await queryDatabase( - database.url, - "INSERT INTO sessions VALUES ('existing-token-hash', 'existing', 'csrf-hash', '2020-01-01', '2020-01-02', NULL)", - ); - const before = await queryDatabase( - database.url, - 'SELECT * FROM sessions JOIN users ON users.id = sessions.user_id', - ); - await migrate(database.url, await readMigrations(defaultMigrations)); - expect( - await queryDatabase( - database.url, - 'SELECT * FROM sessions JOIN users ON users.id = sessions.user_id', - ), - ).toEqual(before); - expect( - await queryDatabase(database.url, 'SELECT * FROM oauth_identities'), - ).toEqual([]); - expect( - await queryDatabase(database.url, 'SELECT * FROM oauth_flows'), - ).toEqual([]); -}); diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts deleted file mode 100644 index 65c59c7..0000000 --- a/tests/integration/helpers.ts +++ /dev/null @@ -1,150 +0,0 @@ -import request from 'supertest'; -import { - DevRandom, - DevTime, - EmailDev, -} from '../../packages/pgstencil/src/index.ts'; -import { createTestContext } from '../../packages/pgstencil/src/testing.ts'; -import { startApp, type AppConfig } from '../../examples/login/src/app.ts'; -import type { - CapturedEmail, - EmailSender, -} from '../../packages/pgstencil/src/email.ts'; -const SECRET = 'pgstencil-test-secret-at-least-32-characters'; -/** - * What driving a login flow actually needs: somewhere to send requests, the - * Origin those requests claim, and the inbox that receives the mail. Both a - * full fixture and a bare second app instance satisfy it. - */ -export interface LoginTarget { - client: request.Agent; - origin: string; - email: { next(timeoutMs?: number): Promise }; -} -type AppOptions = Pick< - AppConfig, - 'oauth' | 'oauthFetch' | 'publicOrigin' | 'secureCookies' ->; -export async function fixture({ - seed, - email, - ...options -}: { seed?: string; email?: EmailSender } & AppOptions = {}) { - const context = await createTestContext(seed ? { seed } : {}); - try { - const app = await startApp({ - databaseUrl: context.database.url, - time: context.time, - random: context.random, - email: email ?? context.email, - devInbox: context.email, - secret: SECRET, - development: true, - ...options, - }); - return { - ...context, - app, - client: request.agent(app.origin), - origin: app.publicOrigin, - async close() { - await app.close(); - await context.close(); - }, - }; - } catch (error) { - await context.close(); - throw error; - } -} -export type Fixture = Awaited>; -/** - * A second server on an existing database, for proving that state shared - * through Postgres is shared. It borrows the database, so it leases nothing. - */ -export async function secondApp( - f: Fixture, - seed: string, - options: AppOptions = {}, -) { - const time = new DevTime(); - const random = new DevRandom(seed); - const email = new EmailDev(time); - const app = await startApp({ - databaseUrl: f.database.url, - time, - random, - email, - devInbox: email, - secret: SECRET, - development: true, - ...options, - }); - return { - app, - time, - random, - email, - client: request.agent(app.origin), - origin: app.publicOrigin, - async close() { - await app.close(); - email.close(); - }, - }; -} -export function field(html: string, name: string): string { - const match = html.match(new RegExp(`name="${name}" value="([^"]*)"`)); - if (!match) throw new Error(`Missing field ${name}`); - return match[1]!; -} -export function cookies(response: request.Response): string { - const header = response.headers['set-cookie'] as string[] | undefined; - return (header ?? []).map((value) => value.split(';')[0]).join('; '); -} -/** The sign-in email prints the code in two groups; tests want the digits. */ -export function codeFrom(message: { text: string }): string { - const match = message.text.match(/code is (\d{4}) (\d{4})/); - if (!match) throw new Error('No sign-in code in email'); - return match.slice(1).join(''); -} -export function sessionCookie(f: Fixture, response: request.Response): string { - return cookies(response) - .split('; ') - .find((c) => c.startsWith(`${f.app.sessionName}=`))!; -} -export function post( - target: LoginTarget, - path: string, - body: Record, - cookie?: string, -) { - const req = target.client - .post(path) - .set('origin', target.origin) - .type('form') - .send(body); - if (cookie) req.set('Cookie', cookie); - return req; -} -export async function begin(target: LoginTarget, email = 'alice@example.test') { - const login = await target.client.get('/login').expect(200); - const csrf = field(login.text, 'csrf'); - const pendingCookie = cookies(login); - await post(target, '/login', { csrf, email }, pendingCookie).expect(303); - const message = await target.email.next(); - const link = new URL( - message.html.match(/href="([^"]+)"/)![1]!.replaceAll('&', '&'), - ); - return { csrf, pendingCookie, message, code: codeFrom(message), link, login }; -} -export async function login(f: Fixture) { - const flow = await begin(f); - const response = await post( - f, - '/login/code', - { csrf: flow.csrf, code: flow.code }, - flow.pendingCookie, - ).expect(303); - return { ...flow, response, sessionCookie: sessionCookie(f, response) }; -} diff --git a/tests/integration/login.test.ts b/tests/integration/login.test.ts deleted file mode 100644 index 399fbcd..0000000 --- a/tests/integration/login.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { test, expect } from 'vitest'; -import request from 'supertest'; -import { appTest } from './app-test.ts'; -import { - fixture, - begin, - login, - post, - cookies, - field, - codeFrom, - secondApp, -} from './helpers.ts'; -import { - captureResponse, - captureEmail, - stableJson, -} from '../../packages/pgstencil/src/snapshots.ts'; - -appTest( - 'code login snapshots and fixed 24-hour session expiry', - async ({ f }) => { - const flow = await login(f); - const account = await request(f.app.origin) - .get('/account') - .set('Cookie', flow.sessionCookie) - .expect(200); - const captured = captureResponse(account, f.app.origin); - await expect(captured.html).toMatchFileSnapshot( - './snapshots/code/account.html', - ); - await expect(captured.markdown).toMatchFileSnapshot( - './snapshots/code/account.md', - ); - await expect( - captureResponse(flow.response, f.app.origin).http, - ).toMatchFileSnapshot('./snapshots/code/login-http.json'); - await expect(captureEmail(flow.message, f.app.origin)).toMatchFileSnapshot( - './snapshots/code/email.md', - ); - const users = await f.app.db - .selectFrom('users') - .selectAll() - .orderBy('email') - .execute(); - const sessions = await f.app.db - .selectFrom('sessions') - .selectAll() - .orderBy('created_at') - .execute(); - await expect(stableJson({ users, sessions })).toMatchFileSnapshot( - './snapshots/code/database.json', - ); - f.time.advanceHours(23); - await request(f.app.origin) - .get('/account') - .set('Cookie', flow.sessionCookie) - .expect(200); - f.time.advanceHours(1); - const expired = await request(f.app.origin) - .get('/account') - .set('Cookie', flow.sessionCookie) - .expect(303); - expect(expired.headers.location).toBe('/login'); - expect(expired.headers['set-cookie']?.[0]).toContain('Max-Age=0'); - f.email.assertNoUnread(); - }, -); - -appTest( - 'link preview does not consume; only requesting browser can confirm', - async ({ f }) => { - const flow = await begin(f); - const path = flow.link.pathname + flow.link.search; - const other = await request(f.app.origin).get(path).expect(200); - expect(other.text).toContain('Open your original browser'); - const preview = await f.client - .get(path) - .set('Cookie', flow.pendingCookie) - .expect(200); - expect(preview.text).toContain('Confirm sign-in'); - expect(await f.app.db.selectFrom('sessions').selectAll().execute()).toEqual( - [], - ); - await post( - f, - '/login/link', - { - csrf: flow.csrf, - id: flow.link.searchParams.get('id')!, - token: flow.link.searchParams.get('token')!, - }, - flow.pendingCookie, - ).expect(303); - await post( - f, - '/login/code', - { csrf: flow.csrf, code: flow.code }, - flow.pendingCookie, - ).expect(403); - }, -); - -appTest( - 'wrong codes commit attempt counts and exhaust the challenge', - async ({ f }) => { - const flow = await begin(f); - for (let i = 0; i < 5; i++) - await post( - f, - '/login/code', - { csrf: flow.csrf, code: 'wrong' }, - flow.pendingCookie, - ).expect(400); - const challenge = await f.app.db - .selectFrom('login_challenges') - .selectAll() - .executeTakeFirstOrThrow(); - expect(challenge.attempts).toBe(5); - expect(challenge.invalidated_at).not.toBeNull(); - await post( - f, - '/login/code', - { csrf: flow.csrf, code: flow.code }, - flow.pendingCookie, - ).expect(400); - expect(await f.app.db.selectFrom('sessions').selectAll().execute()).toEqual( - [], - ); - }, -); - -appTest.for(['code', 'link'] as const)( - '%s expires exactly at ten minutes', - async (method, { f }) => { - const flow = await begin(f); - f.time.advanceMilliseconds(600000); - await post( - f, - `/login/${method}`, - { - csrf: flow.csrf, - code: flow.code, - id: flow.link.searchParams.get('id')!, - token: flow.link.searchParams.get('token')!, - }, - flow.pendingCookie, - ).expect(400); - }, -); - -appTest( - 'concurrent code and link redemption produces exactly one session', - async ({ f }) => { - const flow = await begin(f); - const results = await Promise.all([ - post( - f, - '/login/code', - { csrf: flow.csrf, code: flow.code }, - flow.pendingCookie, - ), - post( - f, - '/login/link', - { - csrf: flow.csrf, - id: flow.link.searchParams.get('id')!, - token: flow.link.searchParams.get('token')!, - }, - flow.pendingCookie, - ), - ]); - expect(results.filter((r) => r.status === 303)).toHaveLength(1); - expect( - await f.app.db.selectFrom('sessions').selectAll().execute(), - ).toHaveLength(1); - }, -); - -appTest( - 'resend cooldown and replacement invalidate old secrets', - async ({ f }) => { - const flow = await begin(f); - await post( - f, - '/login/resend', - { csrf: flow.csrf }, - flow.pendingCookie, - ).expect(429); - f.time.advanceMilliseconds(60000); - await post( - f, - '/login/resend', - { csrf: flow.csrf }, - flow.pendingCookie, - ).expect(303); - const mail = await f.email.next(); - await post( - f, - '/login/code', - { csrf: flow.csrf, code: flow.code }, - flow.pendingCookie, - ).expect(400); - const code = codeFrom(mail); - await post( - f, - '/login/code', - { csrf: flow.csrf, code }, - flow.pendingCookie, - ).expect(303); - }, -); - -appTest('logout revokes replay and rejects missing CSRF', async ({ f }) => { - const flow = await login(f); - await post(f, '/logout', {}, flow.sessionCookie).expect(403); - const account = await request(f.app.origin) - .get('/account') - .set('Cookie', flow.sessionCookie) - .expect(200); - await post( - f, - '/logout', - { csrf: field(account.text, 'csrf') }, - flow.sessionCookie, - ).expect(303); - await request(f.app.origin) - .get('/account') - .set('Cookie', flow.sessionCookie) - .expect(303); -}); - -appTest( - 'origin, CSRF and browser binding reject forged requests', - async ({ f }) => { - const flow = await begin(f); - await f.client - .post('/login/code') - .set('Origin', 'https://evil.example') - .type('form') - .send({ csrf: flow.csrf, code: flow.code }) - .expect(403); - await post( - f, - '/login/code', - { csrf: 'wrong', code: flow.code }, - flow.pendingCookie, - ).expect(403); - await request(f.app.origin) - .post('/login/code') - .set('Origin', f.app.origin) - .type('form') - .send({ csrf: flow.csrf, code: flow.code }) - .expect(403); - }, -); - -test('failed email delivery leaves an unusable challenge', async ({ - onTestFinished, -}) => { - const f = await fixture({ - email: { - async send() { - throw new Error('provider unavailable'); - }, - }, - }); - onTestFinished(() => f.close()); - const start = await f.client.get('/login'); - const failure = await post( - f, - '/login', - { csrf: field(start.text, 'csrf'), email: 'alice@example.test' }, - cookies(start), - ).expect(503); - expect(failure.text).toContain('No new email sent.'); - expect(failure.text).not.toContain('we sent'); - const challenge = await f.app.db - .selectFrom('login_challenges') - .selectAll() - .executeTakeFirstOrThrow(); - expect(challenge.delivered_at).toBeNull(); - expect(challenge.invalidated_at).not.toBeNull(); -}); - -appTest( - 'a returning user gets a fresh session and the previous token is revoked', - async ({ f }) => { - const first = await login(f); - const second = await begin(f, ' ALICE@EXAMPLE.TEST '); - const response = await post( - f, - '/login/code', - { csrf: second.csrf, code: second.code }, - `${second.pendingCookie}; ${first.sessionCookie}`, - ).expect(303); - expect(cookies(response)).not.toContain(first.sessionCookie); - expect( - await f.app.db.selectFrom('users').selectAll().execute(), - ).toHaveLength(1); - await request(f.app.origin) - .get('/account') - .set('Cookie', first.sessionCookie) - .expect(303); - const sessions = await f.app.db - .selectFrom('sessions') - .selectAll() - .execute(); - expect(sessions).toHaveLength(2); - expect(sessions.filter((s) => s.revoked_at !== null)).toHaveLength(1); - }, -); - -test('send limits aggregate across browsers and server instances', async ({ - onTestFinished, -}) => { - const a = await fixture({ seed: 'instance-a' }); - const b = await secondApp(a, 'instance-b'); - onTestFinished(async () => { - await b.close(); - await a.close(); - }); - for (let i = 0; i < 5; i++) await begin(i % 2 ? a : b); - const page = await a.client.get('/login').expect(200); - await post( - a, - '/login', - { csrf: field(page.text, 'csrf'), email: 'alice@example.test' }, - cookies(page), - ).expect(429); - expect(a.email.all().length + b.email.all().length).toBe(5); - a.time.advanceMilliseconds(15 * 60000); - await begin(a); -}); - -appTest.for(['code', 'link'] as const)( - '%s works just before expiry and cannot replay', - async (method, { f }) => { - const flow = await begin(f); - f.time.advanceMilliseconds(599999); - const body = { - csrf: flow.csrf, - code: flow.code, - id: flow.link.searchParams.get('id')!, - token: flow.link.searchParams.get('token')!, - }; - await post(f, `/login/${method}`, body, flow.pendingCookie).expect(303); - await post(f, `/login/${method}`, body, flow.pendingCookie).expect(403); - }, -); diff --git a/tests/integration/oauth-providers.test.ts b/tests/integration/oauth-providers.test.ts deleted file mode 100644 index ab29983..0000000 --- a/tests/integration/oauth-providers.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { test, expect } from 'vitest'; -import { - OAuthProviders, - type OAuthProof, -} from '../../examples/login/src/oauth-providers.ts'; -import { - mockOAuthServer, - allOAuthCredentials, -} from '../support/oauth-server.ts'; - -const proof: OAuthProof = { - state: 'state-from-browser', - verifier: 'a'.repeat(64), - nonce: 'nonce-from-browser', - redirectUri: 'http://127.0.0.1:9876/oauth/callback', -}; -type Mock = Awaited>; -/** Every test here drives real clients against a local provider server. */ -const providerTest = test.extend<{ server: Mock; clients: OAuthProviders }>({ - server: async ({}, use) => { - const server = await mockOAuthServer(); - try { - await use(server); - } finally { - await server.close(); - } - }, - clients: async ({ server }, use) => - use(new OAuthProviders(allOAuthCredentials, server.transport)), -}); -providerTest.for(['google', 'github'] as const)( - '%s exchanges a code using PKCE and returns a verified stable identity', - async (provider, { server, clients }) => { - const url = await clients.authorizationUrl(provider, proof, false); - expect(url.searchParams.get('state')).toBe(proof.state); - expect(url.searchParams.get('code_challenge_method')).toBe('S256'); - const identity = await clients.identity( - provider, - server.authorize(provider, url), - proof, - ); - expect(identity).toEqual({ - subject: provider === 'google' ? 'google-person-1' : '12345', - email: 'oauth@example.test', - }); - expect( - server.requests.some( - (r) => r.url.endsWith('/token') || r.url.endsWith('/access_token'), - ), - ).toBe(true); - if (provider === 'google') - expect(server.requests.some((r) => r.url.endsWith('/certs'))).toBe(true); - }, -); - -providerTest.for([ - { badSignature: true }, - { claims: { iss: 'https://attacker.example' } }, - { claims: { aud: 'other-client' } }, - { claims: { nonce: 'other-nonce' } }, - { claims: { exp: 0 } }, - { missingIdToken: true }, - { verified: false }, -])( - 'Google rejects invalid identity proof: %j', - async (options, { server, clients }) => { - const url = await clients.authorizationUrl('google', proof, false); - await expect( - clients.identity( - 'google', - server.authorize('google', url, options), - proof, - ), - ).rejects.toThrow(); - }, -); - -providerTest( - 'GitHub selects a verified private primary address across pages', - async ({ server, clients }) => { - const url = await clients.authorizationUrl('github', proof, false); - const addresses = Array.from({ length: 100 }, () => ({ - email: 'secondary@example.test', - primary: false, - verified: true, - })); - addresses.push({ - email: 'primary@example.test', - primary: true, - verified: true, - }); - expect( - await clients.identity( - 'github', - server.authorize('github', url, { githubEmails: addresses }), - proof, - ), - ).toEqual({ subject: '12345', email: 'primary@example.test' }); - }, -); - -providerTest.for(['google', 'github'] as const)( - '%s rejects a mismatched PKCE verifier', - async (provider, { server, clients }) => { - const url = await clients.authorizationUrl(provider, proof, false); - await expect( - clients.identity(provider, server.authorize(provider, url), { - ...proof, - verifier: 'b'.repeat(64), - }), - ).rejects.toThrow(); - }, -); - -providerTest('a discovery outage is retryable', async ({ server, clients }) => { - server.failDiscovery(true); - await expect( - clients.authorizationUrl('google', proof, false), - ).rejects.toThrow(); - server.failDiscovery(false); - expect( - (await clients.authorizationUrl('google', proof, false)).hostname, - ).toBe('accounts.google.com'); -}); - -providerTest.for(['apple', 'facebook'] as const)( - '%s exchanges a browser-bound code without claiming unsupported PKCE', - async (provider, { server, clients }) => { - const url = await clients.authorizationUrl(provider, proof, false); - expect(url.searchParams.get('state')).toBe(proof.state); - expect(url.searchParams.has('code_challenge')).toBe(false); - if (provider === 'apple') { - expect(url.searchParams.get('nonce')).toBe(proof.nonce); - expect(url.searchParams.get('response_mode')).toBe('form_post'); - } - expect( - await clients.identity(provider, server.authorize(provider, url), proof), - ).toEqual({ - subject: provider === 'apple' ? 'apple-person-1' : '12345', - email: 'oauth@example.test', - }); - }, -); -providerTest.for([ - { badSignature: true }, - { claims: { iss: 'https://attacker.example' } }, - { claims: { aud: 'other-client' } }, - { claims: { nonce: 'other-nonce' } }, - { claims: { exp: 0 } }, - { missingIdToken: true }, - { verified: false }, - { claims: { email_verified: 'false' } }, -])( - 'Apple rejects invalid identity proof: %j', - async (options, { server, clients }) => { - const url = await clients.authorizationUrl('apple', proof, false); - await expect( - clients.identity('apple', server.authorize('apple', url, options), proof), - ).rejects.toThrow(); - }, -); -providerTest( - 'Apple accepts a verified private relay email with a string verification claim', - async ({ server, clients }) => { - const url = await clients.authorizationUrl('apple', proof, false); - expect( - await clients.identity( - 'apple', - server.authorize('apple', url, { - email: 'private@privaterelay.appleid.com', - claims: { email_verified: 'true' }, - }), - proof, - ), - ).toEqual({ - subject: 'apple-person-1', - email: 'private@privaterelay.appleid.com', - }); - }, -); -providerTest.for([ - { email: '' }, - { subject: 'invalid' }, - { profileFailure: true }, - { tokenFailure: true }, -])( - 'Facebook fails closed on missing identity or upstream failure: %j', - async (options, { server, clients }) => { - const url = await clients.authorizationUrl('facebook', proof, false); - await expect( - clients.identity( - 'facebook', - server.authorize('facebook', url, options), - proof, - ), - ).rejects.toThrow(); - }, -); diff --git a/tests/integration/oauth.test.ts b/tests/integration/oauth.test.ts deleted file mode 100644 index 82a1dcc..0000000 --- a/tests/integration/oauth.test.ts +++ /dev/null @@ -1,552 +0,0 @@ -import { test, expect } from 'vitest'; -import request from 'supertest'; -import { - fixture, - secondApp, - login, - post, - cookies, - field, - sessionCookie, - type Fixture, - type LoginTarget, -} from './helpers.ts'; -import { - mockOAuthServer, - oauthCredentials, - type GrantOptions, -} from '../support/oauth-server.ts'; -import type { Provider } from '../../examples/login/src/oauth-providers.ts'; -import { CONNECT_FRESH_MS, OAUTH_MS } from '../../examples/login/src/oauth.ts'; -import { - captureResponse, - stableJson, -} from '../../packages/pgstencil/src/snapshots.ts'; - -type Mock = Awaited>; -const oauthTest = test.extend<{ f: Fixture; providerServer: Mock }>({ - providerServer: async ({}, use) => { - const server = await mockOAuthServer(); - try { - await use(server); - } finally { - await server.close(); - } - }, - f: async ({ providerServer }, use) => { - const f = await fixture({ - oauth: oauthCredentials, - oauthFetch: providerServer.transport, - }); - try { - await use(f); - } finally { - await f.close(); - } - }, -}); -async function start( - f: LoginTarget, - provider: Provider = 'google', - session?: string, -) { - const page = session - ? await f.client.get('/account').set('Cookie', session).expect(200) - : await f.client.get('/login').expect(200); - const response = await post( - f, - `/oauth/${provider}/${session ? 'connect' : 'start'}`, - { csrf: field(page.text, 'csrf') }, - session ?? cookies(page), - ).expect(303); - return { - page, - response, - authorization: new URL(response.headers.location as string), - cookie: [cookies(response), session].filter(Boolean).join('; '), - }; -} -function callback(f: { app: { origin: string } }, url: URL, cookie?: string) { - // A fresh client prevents its real-clock cookie jar from obscuring DevTime checks. - const req = request(f.app.origin).get(url.pathname + url.search); - return cookie ? req.set('Cookie', cookie) : req; -} -async function signIn( - f: Fixture, - server: Mock, - provider: Provider = 'google', - options: GrantOptions = {}, -) { - const flow = await start(f, provider); - const url = server.authorize(provider, flow.authorization, options); - const response = await callback(f, url, flow.cookie).expect(303); - return { ...flow, url, response, session: sessionCookie(f, response) }; -} - -oauthTest.for(['google', 'github'] as const)( - '%s login snapshots and deterministic session expiry', - async (provider, { f, providerServer }) => { - const flow = await start(f, provider); - const callbackUrl = providerServer.authorize(provider, flow.authorization); - const response = await callback(f, callbackUrl, flow.cookie).expect(303); - expect(response.headers.location).toBe('/account'); - const session = sessionCookie(f, response); - const account = await f.client - .get('/account') - .set('Cookie', session) - .expect(200); - const loginCapture = captureResponse(flow.page, f.origin); - const accountCapture = captureResponse(account, f.origin); - const snapshots = { - 'login.html': loginCapture.html, - 'login.md': loginCapture.markdown, - 'start-http.json': captureResponse(flow.response, f.origin).http, - 'callback-http.json': captureResponse(response, f.origin).http, - 'account.html': accountCapture.html, - 'account.md': accountCapture.markdown, - 'database.json': stableJson({ - users: await f.app.db.selectFrom('users').selectAll().execute(), - identities: await f.app.db - .selectFrom('oauth_identities') - .selectAll() - .execute(), - sessions: await f.app.db.selectFrom('sessions').selectAll().execute(), - }), - }; - for (const [name, value] of Object.entries(snapshots)) { - expect(value).not.toContain(encodeURIComponent(f.origin)); - await expect(value).toMatchFileSnapshot( - `./snapshots/oauth/${provider}/${name}`, - ); - } - const stored = await f.app.db - .selectFrom('oauth_flows') - .selectAll() - .executeTakeFirstOrThrow(); - const storedJson = stableJson(stored); - expect(stored.consumed_at).toEqual(f.time.now()); - expect(storedJson).not.toContain( - flow.authorization.searchParams.get('state'), - ); - expect(storedJson).not.toContain(flow.cookie.split('=')[1]); - expect(stableJson(snapshots)).not.toMatch( - /test-google-secret|test-github-secret|mock-access-/, - ); - f.time.advanceHours(23); - await f.client.get('/account').set('Cookie', session).expect(200); - f.time.advanceHours(1); - await f.client.get('/account').set('Cookie', session).expect(303); - expect(f.email.all()).toEqual([]); - }, -); - -oauthTest( - 'start requires the original browser, CSRF, and same-origin POST', - async ({ f, providerServer }) => { - const page = await f.client.get('/login').expect(200); - const body = { csrf: field(page.text, 'csrf') }; - await post(f, '/oauth/google/start', {}, cookies(page)).expect(403); - await post(f, '/oauth/google/start', body).expect(403); - await f.client - .post('/oauth/google/start') - .set('Origin', 'https://attacker.test') - .set('Cookie', cookies(page)) - .type('form') - .send(body) - .expect(403); - await f.client.get('/oauth/google/start').expect(404); - await post(f, '/oauth/google/connect', body, cookies(page)).expect(403); - expect(providerServer.requests).toEqual([]); - expect( - await f.app.db.selectFrom('oauth_flows').selectAll().execute(), - ).toEqual([]); - }, -); - -oauthTest( - 'state, browser binding and provider mismatch fail before token exchange; valid callback claims once', - async ({ f, providerServer }) => { - const flow = await start(f); - const url = providerServer.authorize('google', flow.authorization); - const wrongState = new URL(url); - wrongState.searchParams.set('state', 'a'.repeat(43)); - const duplicate = new URL(url); - duplicate.searchParams.append('state', url.searchParams.get('state')!); - const wrongProvider = new URL(url); - wrongProvider.pathname = '/oauth/github/callback'; - for (const invalid of [wrongState, duplicate, wrongProvider]) - await callback(f, invalid, flow.cookie).expect(400); - await callback(f, url).expect(400); - await callback(f, url, `${f.app.oauthName}=${'a'.repeat(43)}`).expect(400); - expect( - providerServer.requests.filter((r) => r.method === 'POST'), - ).toHaveLength(0); - const results = await Promise.all([ - callback(f, url, flow.cookie), - callback(f, url, flow.cookie), - ]); - expect(results.map((r) => r.status).sort()).toEqual([303, 400]); - await callback(f, url, flow.cookie).expect(400); - expect( - providerServer.requests.filter((r) => r.method === 'POST'), - ).toHaveLength(1); - expect( - await f.app.db.selectFrom('sessions').selectAll().execute(), - ).toHaveLength(1); - }, -); - -oauthTest.for([OAUTH_MS - 1, OAUTH_MS])( - 'attempt boundary at %i milliseconds', - async (milliseconds, { f, providerServer }) => { - const flow = await start(f); - f.time.advanceMilliseconds(milliseconds); - await callback( - f, - providerServer.authorize('google', flow.authorization), - flow.cookie, - ).expect(milliseconds < OAUTH_MS ? 303 : 400); - }, -); - -oauthTest( - 'cancellation is consumed and never reflects provider error text', - async ({ f, providerServer }) => { - const flow = await start(f); - const url = providerServer.authorize('google', flow.authorization); - url.searchParams.delete('code'); - url.searchParams.set('error', 'access_denied'); - url.searchParams.set( - 'error_description', - '', - ); - const response = await callback(f, url, flow.cookie).expect(400); - expect(response.text).toContain('Sign-in was not completed'); - expect(response.text).not.toContain('provider-secret'); - expect(response.headers['set-cookie']?.[0]).toContain('Max-Age=0'); - url.searchParams.delete('error'); - url.searchParams.set('code', 'retry'); - await callback(f, url, flow.cookie).expect(400); - expect( - providerServer.requests.filter((r) => r.method === 'POST'), - ).toHaveLength(0); - }, -); - -oauthTest.for([ - { provider: 'google', options: { tokenFailure: true }, status: 502 }, - { provider: 'google', options: { badSignature: true }, status: 502 }, - { provider: 'google', options: { verified: false }, status: 403 }, - { provider: 'github', options: { profileFailure: true }, status: 502 }, - { provider: 'github', options: { verified: false }, status: 403 }, -] as const)( - 'provider failure creates no user or session: %j', - async ({ provider, options, status }, { f, providerServer }) => { - const flow = await start(f, provider); - const url = providerServer.authorize(provider, flow.authorization, options); - const response = await callback(f, url, flow.cookie).expect(status); - expect(response.text).not.toMatch( - /synthetic-secret|test-google-secret|test-github-secret|mock-access-/, - ); - await callback(f, url, flow.cookie).expect(400); - expect(await f.app.db.selectFrom('users').selectAll().execute()).toEqual( - [], - ); - expect(await f.app.db.selectFrom('sessions').selectAll().execute()).toEqual( - [], - ); - }, -); - -oauthTest( - 'email collision requires explicit linking, which rotates the session and enables later provider login', - async ({ f, providerServer }) => { - const email = await login(f); - const flow = await start(f); - const rejected = await callback( - f, - providerServer.authorize('google', flow.authorization, { - email: 'alice@example.test', - }), - flow.cookie, - ).expect(409); - expect(rejected.text).toContain('Sign in with email or an existing method'); - expect( - await f.app.db.selectFrom('oauth_identities').selectAll().execute(), - ).toEqual([]); - const connect = await start(f, 'google', email.sessionCookie); - const response = await callback( - f, - providerServer.authorize('google', connect.authorization, { - email: 'alice@example.test', - }), - connect.cookie, - ).expect(303); - await f.client - .get('/account') - .set('Cookie', email.sessionCookie) - .expect(303); - const account = await f.client - .get('/account') - .set('Cookie', sessionCookie(f, response)) - .expect(200); - expect(account.text).toContain('Google is connected'); - // Subject remains authoritative if a provider changes the person's email. - const returning = await signIn(f, providerServer, 'google', { - email: 'changed@example.test', - }); - const users = await f.app.db.selectFrom('users').selectAll().execute(); - expect(users).toHaveLength(1); - expect(users[0]?.email).toBe('alice@example.test'); - const loggedIn = await f.client - .get('/account') - .set('Cookie', returning.session) - .expect(200); - await post( - f, - '/logout', - { csrf: field(loggedIn.text, 'csrf') }, - returning.session, - ).expect(303); - await f.client.get('/account').set('Cookie', returning.session).expect(303); - }, -); - -oauthTest.for([ - 'wrong-email', - 'missing-session', - 'revoked-session', - 'stale-session', -] as const)('connecting rejects %s', async (reason, { f, providerServer }) => { - const email = await login(f); - const flow = await start(f, 'github', email.sessionCookie); - if (reason === 'revoked-session') - await f.app.auth.logout(email.sessionCookie.split('=')[1]!); - if (reason === 'stale-session') f.time.advanceMilliseconds(CONNECT_FRESH_MS); - const cookie = - reason === 'missing-session' ? flow.cookie.split('; ')[0] : flow.cookie; - await callback( - f, - providerServer.authorize('github', flow.authorization, { - email: - reason === 'wrong-email' ? 'other@example.test' : 'alice@example.test', - }), - cookie, - ).expect(reason === 'stale-session' ? 400 : 403); - expect( - await f.app.db.selectFrom('oauth_identities').selectAll().execute(), - ).toEqual([]); - expect( - await f.app.db.selectFrom('sessions').selectAll().execute(), - ).toHaveLength(1); -}); - -oauthTest( - 'connecting requires recent authentication before leaving the app', - async ({ f, providerServer }) => { - const email = await login(f); - const account = await f.client - .get('/account') - .set('Cookie', email.sessionCookie) - .expect(200); - f.time.advanceMilliseconds(CONNECT_FRESH_MS); - await post( - f, - '/oauth/google/connect', - { csrf: field(account.text, 'csrf') }, - email.sessionCookie, - ).expect(403); - expect(providerServer.requests).toEqual([]); - }, -); - -oauthTest( - 'a linked provider identity cannot move between accounts or replace another identity', - async ({ f, providerServer }) => { - await signIn(f, providerServer); - const email = await login(f); - const conflict = await start(f, 'google', email.sessionCookie); - await callback( - f, - providerServer.authorize('google', conflict.authorization, { - email: 'alice@example.test', - }), - conflict.cookie, - ).expect(409); - const link = await start(f, 'github', email.sessionCookie); - const linked = await callback( - f, - providerServer.authorize('github', link.authorization, { - email: 'alice@example.test', - }), - link.cookie, - ).expect(303); - const replacement = await start(f, 'github', sessionCookie(f, linked)); - await callback( - f, - providerServer.authorize('github', replacement.authorization, { - email: 'alice@example.test', - subject: '999', - }), - replacement.cookie, - ).expect(409); - const identities = await f.app.db - .selectFrom('oauth_identities') - .selectAll() - .execute(); - expect(identities).toHaveLength(2); - expect(new Set(identities.map((i) => i.user_id)).size).toBe(2); - }, -); - -test('callback can reach a different server instance; HTTPS cookies use production attributes', async ({ - onTestFinished, -}) => { - const providerServer = await mockOAuthServer(); - onTestFinished(() => providerServer.close()); - const options = { - oauth: oauthCredentials, - oauthFetch: providerServer.transport, - publicOrigin: 'https://login.example.test', - secureCookies: true, - }; - const a = await fixture({ ...options, seed: 'oauth-instance-a' }); - onTestFinished(() => a.close()); - const b = await secondApp(a, 'oauth-instance-b', options); - onTestFinished(() => b.close()); - const flow = await start(a); - expect(flow.authorization.searchParams.get('redirect_uri')).toBe( - 'https://login.example.test/oauth/google/callback', - ); - expect(flow.response.headers['set-cookie']?.[0]).toMatch( - /^__Host-pgstencil-oauth=.*; Path=\/; HttpOnly; SameSite=Lax; Max-Age=600; Expires=.*; Secure$/, - ); - const response = await callback( - b, - providerServer.authorize('google', flow.authorization), - flow.cookie, - ).expect(303); - const cookie = sessionCookie(a, response); - await a.client.get('/account').set('Cookie', cookie).expect(200); - await b.client.get('/account').set('Cookie', cookie).expect(200); -}); - -oauthTest( - 'provider discovery failure leaves no attempt and can be retried', - async ({ f, providerServer }) => { - providerServer.failDiscovery(true); - const page = await f.client.get('/login').expect(200); - const response = await post( - f, - '/oauth/google/start', - { csrf: field(page.text, 'csrf') }, - cookies(page), - ).expect(503); - expect(response.text).toContain('sign in with email'); - expect( - await f.app.db.selectFrom('oauth_flows').selectAll().execute(), - ).toEqual([]); - providerServer.failDiscovery(false); - await signIn(f, providerServer); - }, -); - -oauthTest( - 'OAuth start rate limits expire using application time', - async ({ f }) => { - const page = await f.client.get('/login').expect(200); - for (let i = 0; i < 30; i++) - await post( - f, - '/oauth/github/start', - { csrf: field(page.text, 'csrf') }, - cookies(page), - ).expect(303); - await post( - f, - '/oauth/github/start', - { csrf: field(page.text, 'csrf') }, - cookies(page), - ).expect(429); - f.time.advanceMilliseconds(15 * 60000); - await start(f, 'github'); - }, -); - -test('an attempt that expires during token exchange cannot create a session', async ({ - onTestFinished, -}) => { - const server = await mockOAuthServer(); - onTestFinished(() => server.close()); - const f = await fixture({ - oauth: oauthCredentials, - oauthFetch: async (input, init) => { - const response = await server.transport(input, init); - if (init.method === 'POST') f.time.advanceMilliseconds(OAUTH_MS); - return response; - }, - }); - onTestFinished(() => f.close()); - const flow = await start(f); - await callback( - f, - server.authorize('google', flow.authorization), - flow.cookie, - ).expect(400); - expect(await f.app.db.selectFrom('users').selectAll().execute()).toEqual([]); - expect(await f.app.db.selectFrom('sessions').selectAll().execute()).toEqual( - [], - ); -}); - -oauthTest( - 'OAuth login rotates a previous session and concurrent new logins share one identity', - async ({ f, providerServer }) => { - const first = await signIn(f, providerServer); - const a = await start(f); - const b = await start(f); - const responses = await Promise.all( - [a, b].map((flow) => - callback( - f, - providerServer.authorize('google', flow.authorization), - `${flow.cookie}; ${first.session}`, - ).expect(303), - ), - ); - expect(sessionCookie(f, responses[0]!)).not.toBe( - sessionCookie(f, responses[1]!), - ); - await f.client.get('/account').set('Cookie', first.session).expect(303); - expect( - await f.app.db.selectFrom('users').selectAll().execute(), - ).toHaveLength(1); - expect( - await f.app.db.selectFrom('oauth_identities').selectAll().execute(), - ).toHaveLength(1); - }, -); - -test('providers are opt-in and disabled routes perform no network requests', async ({ - onTestFinished, -}) => { - const providerServer = await mockOAuthServer(); - onTestFinished(() => providerServer.close()); - const f = await fixture({ oauthFetch: providerServer.transport }); - onTestFinished(() => f.close()); - const page = await f.client.get('/login').expect(200); - expect(page.text).not.toContain('Continue with Google'); - expect(page.text).not.toContain('Continue with GitHub'); - for (const provider of ['google', 'github', 'unknown']) { - await f.client - .get(`/oauth/${provider}/callback?code=unused&state=unused`) - .expect(404); - await post( - f, - `/oauth/${provider}/start`, - { csrf: field(page.text, 'csrf') }, - cookies(page), - ).expect(404); - } - expect(providerServer.requests).toEqual([]); -}); diff --git a/tests/integration/parallel.test.ts b/tests/integration/parallel.test.ts index 4c3952d..efacf56 100644 --- a/tests/integration/parallel.test.ts +++ b/tests/integration/parallel.test.ts @@ -1,7 +1,7 @@ import { fork } from 'node:child_process'; import { once } from 'node:events'; import { test, expect } from 'vitest'; -import { fixture, begin, post, type Fixture } from './helpers.ts'; +import { fixture, signUp, type Fixture } from '../support/fixture-app.ts'; import { captureEmail } from '../../packages/pgstencil/src/snapshots.ts'; test('twenty live applications isolate ports, databases, time, random state and email', async ({ @@ -20,13 +20,16 @@ test('twenty live applications isolate ports, databases, time, random state and expect(new Set(apps.map((f) => f.app.origin)).size).toBe(20); expect(new Set(apps.map((f) => f.database.name)).size).toBe(20); expect(new Set(apps.map((f) => f.database.hash)).size).toBe(1); - const flows = await Promise.all(apps.map((f) => begin(f))); - // Same inputs produce the same email and tokens regardless of scheduling. + const signups = await Promise.all(apps.map((f) => signUp(f))); + // Same inputs produce the same email and IDs regardless of scheduling. expect( new Set( - flows.map((flow, i) => captureEmail(flow.message, apps[i]!.app.origin)), + signups.map((signup, i) => + captureEmail(signup.message, apps[i]!.app.origin), + ), ).size, ).toBe(1); + expect(new Set(signups.map((signup) => signup.user.id)).size).toBe(1); apps[0]!.time.advanceHours(24); apps[0]!.random.bytes(100); expect(apps[1]!.time.now().toISOString()).toBe('2020-01-01T00:00:00.000Z'); @@ -42,22 +45,33 @@ test('twenty live applications isolate ports, databases, time, random state and }) .execute(); expect( - await f.app.db.selectFrom('users').select('email').execute(), - ).toEqual([{ email: `user${i}@example.test` }]); + await f.app.db + .selectFrom('users') + .select('email') + .orderBy('email') + .execute(), + ).toEqual([ + { email: 'alice@example.test' }, + { email: `user${i}@example.test` }, + ]); f.email.assertNoUnread(); expect(f.email.all()).toHaveLength(1); }), ); - const flow = flows[1]!; - await post( - apps[1]!, - '/login/code', - { csrf: flow.csrf, code: flow.code }, - flow.pendingCookie, - ).expect(303); + await signUp(apps[1]!, 'bob@example.test'); expect( - await apps[0]!.app.db.selectFrom('sessions').selectAll().execute(), + (await apps[1]!.client.get('/users').expect(200)).body.map( + (user: { email: string }) => user.email, + ), + ).toEqual(['alice@example.test', 'bob@example.test', 'user1@example.test']); + expect( + await apps[0]!.app.db + .selectFrom('users') + .select('email') + .where('email', '=', 'bob@example.test') + .execute(), ).toEqual([]); + expect(apps[0]!.email.all()).toHaveLength(1); console.info( `20 concurrent apps, isolated writes and email: ${Math.round(performance.now() - started)}ms`, ); diff --git a/tests/integration/production.test.ts b/tests/integration/production.test.ts deleted file mode 100644 index 563483d..0000000 --- a/tests/integration/production.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { test, expect } from 'vitest'; -import request from 'supertest'; -import { createTestContext } from '../../packages/pgstencil/src/testing.ts'; -import { startProduction } from '../../examples/login/src/production.ts'; -import { begin, cookies, post } from './helpers.ts'; - -test('production composition sets hardened cookies, uses real time, and hides local inbox', async ({ - onTestFinished, -}) => { - const context = await createTestContext(); - onTestFinished(() => context.close()); - const app = await startProduction({ - databaseUrl: context.database.url, - publicOrigin: 'https://login.example.test', - secret: 'production-composition-test-secret-only', - email: context.email, - }); - onTestFinished(() => app.close()); - await request(app.origin).get('/dev/emails').expect(404); - // Requests reach the loopback port but must claim the public origin. - const target = { - client: request.agent(app.origin), - origin: app.publicOrigin, - email: context.email, - }; - const flow = await begin(target, 'production@example.test'); - const page = flow.login; - expect(page.text).not.toContain('Local inbox'); - expect(page.headers['set-cookie']![0]).toMatch(/^__Host-pgstencil-pending=/); - expect(page.headers['set-cookie']![0]).toContain('; Secure'); - expect(page.headers['set-cookie']![0]).not.toContain('Domain='); - expect(page.headers['referrer-policy']).toBe('strict-origin'); - expect(page.headers['cache-control']).toBe('no-store'); - expect(page.headers['content-security-policy']).toContain( - "form-action 'self'", - ); - expect(cookies(page)).toBe(flow.pendingCookie); - expect(flow.message.html).toContain('https://login.example.test/login/link'); - const dbFlow = await app.db - .selectFrom('login_flows') - .selectAll() - .executeTakeFirstOrThrow(); - expect(Math.abs(dbFlow.created_at.getTime() - Date.now())).toBeLessThan( - 10000, - ); - const response = await post( - target, - '/login/code', - { csrf: flow.csrf, code: flow.code }, - flow.pendingCookie, - ).expect(303); - const cookie = response.headers['set-cookie']![0]!; - expect(cookie).toMatch(/^__Host-pgstencil=/); - expect(cookie).toContain('HttpOnly; SameSite=Lax; Max-Age=86400;'); - expect(cookie).toContain('; Secure'); - const challenge = await app.db - .selectFrom('login_challenges') - .selectAll() - .executeTakeFirstOrThrow(); - const session = await app.db - .selectFrom('sessions') - .selectAll() - .executeTakeFirstOrThrow(); - expect(JSON.stringify({ challenge, session })).not.toContain(flow.code); - expect(session.token_hash).not.toBe(cookie.split(';')[0]!.split('=')[1]); -}); diff --git a/tests/integration/query-types.test.ts b/tests/integration/query-types.test.ts index db16d22..9253e11 100644 --- a/tests/integration/query-types.test.ts +++ b/tests/integration/query-types.test.ts @@ -7,18 +7,21 @@ import { projectRoot, queryDatabase, } from '../../packages/pgstencil/src/database.ts'; -import { generateTypes } from '../../scripts/generate-types.ts'; +import { generateTypes } from '../../packages/pgstencil/src/codegen.ts'; +import { fixtureMigrations } from '../support/fixture-app.ts'; test('regenerated types include new tables and make renamed-column queries fail compilation', async ({ onTestFinished, }) => { - const lease = await allocateDatabase(); + const lease = await allocateDatabase(fixtureMigrations); onTestFinished(() => lease.close()); - const consumerState = join(projectRoot, 'examples/login/.pgstencil'); + // A workspace package directory, so the generated file resolves kysely. + const consumerState = join(projectRoot, 'examples/better-auth/.pgstencil'); await mkdir(consumerState, { recursive: true }); const directory = await mkdtemp(join(consumerState, 'types-')); onTestFinished(() => rm(directory, { recursive: true, force: true })); const definitions = join(directory, 'db.ts'); + const target = { out: definitions, include: 'public.*' }; const query = join(directory, 'query.ts'); await writeFile( query, @@ -55,14 +58,22 @@ test('regenerated types include new tables and make renamed-column queries fail throw new Error(`TypeScript exited with ${result.status}: ${output}`); return errors; }; - await generateTypes(lease.url, definitions); + await generateTypes(lease.url, target); expect(diagnostics()).toEqual([]); + // pgstencil's bookkeeping tables are never an application's types. + expect(await readFile(definitions, 'utf8')).not.toMatch( + /pgmigrations|pgstencil_migration_files/, + ); await queryDatabase( lease.url, 'ALTER TABLE users RENAME COLUMN email TO primary_email; CREATE TABLE profiles (id text PRIMARY KEY)', ); - await generateTypes(lease.url, definitions); + await expect(generateTypes(lease.url, target, true)).rejects.toThrow( + 'is stale; run pgstencil types', + ); + await generateTypes(lease.url, target); expect(await readFile(definitions, 'utf8')).toContain('profiles: Profiles'); + await expect(generateTypes(lease.url, target, true)).resolves.toBe(false); const errors = diagnostics(); expect(errors).toHaveLength(1); expect(errors[0]).toContain('"email"'); diff --git a/tests/integration/snapshots/better-auth-email.md b/tests/integration/snapshots/better-auth-email.md index 361cb7b..e7fd90a 100644 --- a/tests/integration/snapshots/better-auth-email.md +++ b/tests/integration/snapshots/better-auth-email.md @@ -2,7 +2,7 @@ { "capturedAt": "2020-01-01T00:00:00.000Z", - "from": "signin@example.test", + "from": "signin@better-auth.example.test", "to": [ "alice@example.test" ] diff --git a/tests/integration/snapshots/billing/page-http.json b/tests/integration/snapshots/billing/page-http.json deleted file mode 100644 index fd968f7..0000000 --- a/tests/integration/snapshots/billing/page-http.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "headers": { - "cache-control": "no-store", - "content-security-policy": "default-src 'none'; style-src 'self'; form-action 'self' https://stripe.test; base-uri 'none'; frame-ancestors 'none'", - "content-type": "text/html; charset=utf-8", - "referrer-policy": "strict-origin", - "x-content-type-options": "nosniff" - }, - "status": 200 -} diff --git a/tests/integration/snapshots/billing/page.html b/tests/integration/snapshots/billing/page.html deleted file mode 100644 index 7af3211..0000000 --- a/tests/integration/snapshots/billing/page.html +++ /dev/null @@ -1,13 +0,0 @@ - - -Billing · pgstencil - - -
pgstencil
-

Billing

yearly · trialing

Access available · Current access period ends Jan 15, 2020, 12:00 AM UTC.

Your card will be charged when the trial ends unless you cancel first.

Monthly billing or a discounted yearly plan. Checkout shows the exact price and renewal terms before you confirm.

-
-
-
-

Return to account

-
pgstencil Simple sign-in. Your email is your key.
- \ No newline at end of file diff --git a/tests/integration/snapshots/billing/page.md b/tests/integration/snapshots/billing/page.md deleted file mode 100644 index ee52e04..0000000 --- a/tests/integration/snapshots/billing/page.md +++ /dev/null @@ -1,19 +0,0 @@ -# Billing - -yearly · trialing - -Access available · Current access period ends Jan 15, 2020, 12:00 AM UTC. - -Your card will be charged when the trial ends unless you cancel first. - -Monthly billing or a discounted yearly plan. Checkout shows the exact price and renewal terms before you confirm. - -\[Choose monthly\] - -\[Choose yearly\] - -\[Manage billing\] - -\[Cancel an unfinished checkout\] - -[Return to account](/account) diff --git a/tests/integration/snapshots/billing/status.json b/tests/integration/snapshots/billing/status.json new file mode 100644 index 0000000..bbd7d93 --- /dev/null +++ b/tests/integration/snapshots/billing/status.json @@ -0,0 +1,20 @@ +{ + "access": true, + "accessUntil": "2020-01-15T00:00:00.000Z", + "plan": "yearly", + "subscription": { + "cancel_at_period_end": false, + "end_reason": null, + "ended_at": null, + "id": "sub_w7C3rfCJSeBMAUHt", + "owner_id": "jqA6VlbZ1K4GrKS9aUQe7FgrckZrZA0M", + "period_end": "2020-01-15T00:00:00.000Z", + "price_id": "price_dev_yearly", + "started_at": "2020-01-01T00:00:00.000Z", + "status": "trialing", + "trial_end": "2020-01-15T00:00:00.000Z", + "updated_at": "2020-01-01T00:00:00.000Z" + }, + "trialDays": 0, + "trialEligible": false +} diff --git a/tests/integration/snapshots/billing/stripe-requests.json b/tests/integration/snapshots/billing/stripe-requests.json index 627756d..c4286a7 100644 --- a/tests/integration/snapshots/billing/stripe-requests.json +++ b/tests/integration/snapshots/billing/stripe-requests.json @@ -2,9 +2,9 @@ { "body": { "email": "alice@example.test", - "metadata[pgstencil_owner]": "Va8eN8Q3_3xqMF9Im1yUvg" + "metadata[pgstencil_owner]": "jqA6VlbZ1K4GrKS9aUQe7FgrckZrZA0M" }, - "key": "BIZx0gY6Ur5oTY5h3enuOI3gzKoI6dvASZhrrIYx7zA", + "key": "D_OgPdV19cIvkrF1ykesk_OrFCxiC7p0MCk-RqE3hPY", "method": "POST", "path": "/v1/customers" }, @@ -12,21 +12,21 @@ "body": { "allowed_payment_method_types[0]": "card", "cancel_url": "/billing", - "client_reference_id": "Va8eN8Q3_3xqMF9Im1yUvg", - "customer": "cus_coH1oXmUHUAvwrZs", + "client_reference_id": "jqA6VlbZ1K4GrKS9aUQe7FgrckZrZA0M", + "customer": "cus_HgmvnMSvObCu4iIM", "expires_at": "1577840400", "line_items[0][price]": "price_dev_yearly", "line_items[0][quantity]": "1", - "metadata[pgstencil_operation]": "nh6LvhIPrrm-MNZdruKyJF6UKMs6w2sRh2V87wsKWt8", + "metadata[pgstencil_operation]": "R8dOwvjxBK18jKWxM-4hcWYzE7JQT1pi-ds2_vipnBg", "mode": "subscription", "payment_method_collection": "always", - "subscription_data[metadata][pgstencil_operation]": "nh6LvhIPrrm-MNZdruKyJF6UKMs6w2sRh2V87wsKWt8", - "subscription_data[metadata][pgstencil_owner]": "Va8eN8Q3_3xqMF9Im1yUvg", + "subscription_data[metadata][pgstencil_operation]": "R8dOwvjxBK18jKWxM-4hcWYzE7JQT1pi-ds2_vipnBg", + "subscription_data[metadata][pgstencil_owner]": "jqA6VlbZ1K4GrKS9aUQe7FgrckZrZA0M", "subscription_data[trial_period_days]": "14", "subscription_data[trial_settings][end_behavior][missing_payment_method]": "cancel", - "success_url": "/billing?checkout=nh6LvhIPrrm-MNZdruKyJF6UKMs6w2sRh2V87wsKWt8" + "success_url": "/billing?checkout=R8dOwvjxBK18jKWxM-4hcWYzE7JQT1pi-ds2_vipnBg" }, - "key": "nh6LvhIPrrm-MNZdruKyJF6UKMs6w2sRh2V87wsKWt8", + "key": "R8dOwvjxBK18jKWxM-4hcWYzE7JQT1pi-ds2_vipnBg", "method": "POST", "path": "/v1/checkout/sessions" } diff --git a/tests/integration/snapshots/code/account.html b/tests/integration/snapshots/code/account.html deleted file mode 100644 index af40f8f..0000000 --- a/tests/integration/snapshots/code/account.html +++ /dev/null @@ -1,9 +0,0 @@ - - -You’re signed in. · pgstencil - - -
pgstencilLocal inbox
-

You’re signed in.

Welcome, alice@example.test.

Signed in
Jan 1, 2020, 12:00 AM UTC
Session expires
Jan 2, 2020, 12:00 AM UTC
-
pgstencil Simple sign-in. Your email is your key.
- \ No newline at end of file diff --git a/tests/integration/snapshots/code/account.md b/tests/integration/snapshots/code/account.md deleted file mode 100644 index d8d47bb..0000000 --- a/tests/integration/snapshots/code/account.md +++ /dev/null @@ -1,13 +0,0 @@ -# You’re signed in. - -Welcome, **alice@example.test**. - -Signed in - -Jan 1, 2020, 12:00 AM UTC - -Session expires - -Jan 2, 2020, 12:00 AM UTC - -\[Sign out\] diff --git a/tests/integration/snapshots/code/database.json b/tests/integration/snapshots/code/database.json deleted file mode 100644 index 75be53a..0000000 --- a/tests/integration/snapshots/code/database.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "sessions": [ - { - "created_at": "2020-01-01T00:00:00.000Z", - "csrf_hash": "8f78244e71717c54f09469f215a1a0987de1a0dfb78539dadc5fbcb643e65fcd", - "expires_at": "2020-01-02T00:00:00.000Z", - "revoked_at": null, - "token_hash": "8bd56f10b6f3285fcd2e2326eb162c96329b7d43f9fcbc3c41b136f111e468d5", - "user_id": "Va8eN8Q3_3xqMF9Im1yUvg" - } - ], - "users": [ - { - "created_at": "2020-01-01T00:00:00.000Z", - "email": "alice@example.test", - "id": "Va8eN8Q3_3xqMF9Im1yUvg" - } - ] -} diff --git a/tests/integration/snapshots/code/email.md b/tests/integration/snapshots/code/email.md deleted file mode 100644 index 9cc4b19..0000000 --- a/tests/integration/snapshots/code/email.md +++ /dev/null @@ -1,37 +0,0 @@ -# Your pgstencil sign-in code - -{ - "capturedAt": "2020-01-01T00:00:00.000Z", - "from": "pgstencil ", - "to": [ - "alice@example.test" - ] -} - -## Plaintext - -Your sign-in code is 2739 9986. - -Or sign in using this link: https://pgstencil.test/login/link?id=BgBD22MBxvwpI_IaX7FoWw&token=dSiHIX52pV8IvPE8QOIELQO8vJx3RD1LFMLzICWGlOA - -Expires Jan 1, 2020, 12:10 AM UTC. Open the link in the browser where you requested it, or enter this code there. - -If you didn’t request this email, you can ignore it. - -## Markdown - -# Your sign-in code - -Enter this code in the browser where you requested it: - -**2739 9986** - -Or [sign in to pgstencil](https://pgstencil.test/login/link?id=BgBD22MBxvwpI_IaX7FoWw&token=dSiHIX52pV8IvPE8QOIELQO8vJx3RD1LFMLzICWGlOA). - -Expires Jan 1, 2020, 12:10 AM UTC. Your code and link can only be used once. - -If you didn’t request this email, you can ignore it. - -## HTML - -

Your sign-in code

Enter this code in the browser where you requested it:

2739 9986

Or sign in to pgstencil.

Expires Jan 1, 2020, 12:10 AM UTC. Your code and link can only be used once.

If you didn’t request this email, you can ignore it.

diff --git a/tests/integration/snapshots/code/login-http.json b/tests/integration/snapshots/code/login-http.json deleted file mode 100644 index a6cbb0a..0000000 --- a/tests/integration/snapshots/code/login-http.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "headers": { - "cache-control": "no-store", - "content-security-policy": "default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", - "location": "/account", - "referrer-policy": "strict-origin", - "set-cookie": [ - "pgstencil_dev=3y4wQIhhO3kYhCYSR3zHV1n2dF9NwPNFWAXLMzpsysk; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400; Expires=Thu, 02 Jan 2020 00:00:00 GMT", - "pgstencil_pending=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT" - ], - "x-content-type-options": "nosniff" - }, - "status": 303 -} diff --git a/tests/integration/snapshots/oauth/github/account.html b/tests/integration/snapshots/oauth/github/account.html deleted file mode 100644 index b183f3a..0000000 --- a/tests/integration/snapshots/oauth/github/account.html +++ /dev/null @@ -1,9 +0,0 @@ - - -You’re signed in. · pgstencil - - -
pgstencilLocal inbox
-

You’re signed in.

Welcome, oauth@example.test.

Signed in
Jan 1, 2020, 12:00 AM UTC
Session expires
Jan 2, 2020, 12:00 AM UTC
Connected sign-in methods

GitHub is connected.

To connect a provider, sign in within the last five minutes and use the same verified email address.

-
pgstencil Simple sign-in. Your email is your key.
- \ No newline at end of file diff --git a/tests/integration/snapshots/oauth/github/account.md b/tests/integration/snapshots/oauth/github/account.md deleted file mode 100644 index 21370fb..0000000 --- a/tests/integration/snapshots/oauth/github/account.md +++ /dev/null @@ -1,21 +0,0 @@ -# You’re signed in. - -Welcome, **oauth@example.test**. - -Signed in - -Jan 1, 2020, 12:00 AM UTC - -Session expires - -Jan 2, 2020, 12:00 AM UTC - -\[Sign out\] - -Connected sign-in methods - -\[Connect Google\] - -GitHub is connected. - -To connect a provider, sign in within the last five minutes and use the same verified email address. diff --git a/tests/integration/snapshots/oauth/github/callback-http.json b/tests/integration/snapshots/oauth/github/callback-http.json deleted file mode 100644 index 20bf72a..0000000 --- a/tests/integration/snapshots/oauth/github/callback-http.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "headers": { - "cache-control": "no-store", - "content-security-policy": "default-src 'none'; style-src 'self'; form-action 'self' https://accounts.google.com https://github.com; base-uri 'none'; frame-ancestors 'none'", - "location": "/account", - "referrer-policy": "strict-origin", - "set-cookie": [ - "pgstencil_oauth=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT", - "pgstencil_dev=GIQmEkd8x1dZ9nRfTcDzRVgFyzM6bMrJBIZx0gY6Ur4; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400; Expires=Thu, 02 Jan 2020 00:00:00 GMT", - "pgstencil_pending=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT" - ], - "x-content-type-options": "nosniff" - }, - "status": 303 -} diff --git a/tests/integration/snapshots/oauth/github/database.json b/tests/integration/snapshots/oauth/github/database.json deleted file mode 100644 index 89cfb0a..0000000 --- a/tests/integration/snapshots/oauth/github/database.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "identities": [ - { - "created_at": "2020-01-01T00:00:00.000Z", - "provider": "github", - "subject": "12345", - "user_id": "ajBfSJtclL7fLjBAiGE7eQ" - } - ], - "sessions": [ - { - "created_at": "2020-01-01T00:00:00.000Z", - "csrf_hash": "a0a8273dc60f7b575dd50bf60a70fdb6a4eed4ae96c74ef2cbba416d764a0ebf", - "expires_at": "2020-01-02T00:00:00.000Z", - "revoked_at": null, - "token_hash": "1c26fdb662187cd7e6968e7c423fc5dd9bc85908eb3fc03e311ded72053033a9", - "user_id": "ajBfSJtclL7fLjBAiGE7eQ" - } - ], - "users": [ - { - "created_at": "2020-01-01T00:00:00.000Z", - "email": "oauth@example.test", - "id": "ajBfSJtclL7fLjBAiGE7eQ" - } - ] -} diff --git a/tests/integration/snapshots/oauth/github/login.html b/tests/integration/snapshots/oauth/github/login.html deleted file mode 100644 index ecce52c..0000000 --- a/tests/integration/snapshots/oauth/github/login.html +++ /dev/null @@ -1,11 +0,0 @@ - - -Welcome in. · pgstencil - - -
pgstencilLocal inbox
-

Welcome in.

Sign in with your email. We’ll send you a code and a link—use whichever you prefer.

-
-

New here? Your account is created when you verify your email.

Or continue with
-
pgstencil Simple sign-in. Your email is your key.
- \ No newline at end of file diff --git a/tests/integration/snapshots/oauth/github/login.md b/tests/integration/snapshots/oauth/github/login.md deleted file mode 100644 index 12823cd..0000000 --- a/tests/integration/snapshots/oauth/github/login.md +++ /dev/null @@ -1,17 +0,0 @@ -# Welcome in. - -Sign in with your email. We’ll send you a code and a link—use whichever you prefer. - -Email address - -\[email: you@example.com\] - -\[Send sign-in code\] - -New here? Your account is created when you verify your email. - -Or continue with - -\[Continue with Google\] - -\[Continue with GitHub\] diff --git a/tests/integration/snapshots/oauth/github/start-http.json b/tests/integration/snapshots/oauth/github/start-http.json deleted file mode 100644 index 4674661..0000000 --- a/tests/integration/snapshots/oauth/github/start-http.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "headers": { - "cache-control": "no-store", - "content-security-policy": "default-src 'none'; style-src 'self'; form-action 'self' https://accounts.google.com https://github.com; base-uri 'none'; frame-ancestors 'none'", - "location": "https://github.com/login/oauth/authorize?redirect_uri=https%3A%2F%2Fpgstencil.test%2Foauth%2Fgithub%2Fcallback&scope=read%3Auser+user%3Aemail&state=BgBD22MBxvwpI_IaX7FoWyrPy_ljqdCwdSiHIX52pV8&code_challenge=dvHL-azHXLjh-XzxiE7KKpehjsXphvVacOBRNP_MVfk&code_challenge_method=S256&client_id=test-github-client&response_type=code", - "referrer-policy": "strict-origin", - "set-cookie": [ - "pgstencil_oauth=CLzxPEDiBC0DvLycd0Q9SxTC8yAlhpTgVa8eN8Q3_3w; Path=/; HttpOnly; SameSite=Lax; Max-Age=600; Expires=Wed, 01 Jan 2020 00:10:00 GMT" - ], - "x-content-type-options": "nosniff" - }, - "status": 303 -} diff --git a/tests/integration/snapshots/oauth/google/account.html b/tests/integration/snapshots/oauth/google/account.html deleted file mode 100644 index b46105b..0000000 --- a/tests/integration/snapshots/oauth/google/account.html +++ /dev/null @@ -1,9 +0,0 @@ - - -You’re signed in. · pgstencil - - -
pgstencilLocal inbox
-

You’re signed in.

Welcome, oauth@example.test.

Signed in
Jan 1, 2020, 12:00 AM UTC
Session expires
Jan 2, 2020, 12:00 AM UTC
Connected sign-in methods

Google is connected.

To connect a provider, sign in within the last five minutes and use the same verified email address.

-
pgstencil Simple sign-in. Your email is your key.
- \ No newline at end of file diff --git a/tests/integration/snapshots/oauth/google/account.md b/tests/integration/snapshots/oauth/google/account.md deleted file mode 100644 index a30dda7..0000000 --- a/tests/integration/snapshots/oauth/google/account.md +++ /dev/null @@ -1,21 +0,0 @@ -# You’re signed in. - -Welcome, **oauth@example.test**. - -Signed in - -Jan 1, 2020, 12:00 AM UTC - -Session expires - -Jan 2, 2020, 12:00 AM UTC - -\[Sign out\] - -Connected sign-in methods - -Google is connected. - -\[Connect GitHub\] - -To connect a provider, sign in within the last five minutes and use the same verified email address. diff --git a/tests/integration/snapshots/oauth/google/callback-http.json b/tests/integration/snapshots/oauth/google/callback-http.json deleted file mode 100644 index 20bf72a..0000000 --- a/tests/integration/snapshots/oauth/google/callback-http.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "headers": { - "cache-control": "no-store", - "content-security-policy": "default-src 'none'; style-src 'self'; form-action 'self' https://accounts.google.com https://github.com; base-uri 'none'; frame-ancestors 'none'", - "location": "/account", - "referrer-policy": "strict-origin", - "set-cookie": [ - "pgstencil_oauth=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT", - "pgstencil_dev=GIQmEkd8x1dZ9nRfTcDzRVgFyzM6bMrJBIZx0gY6Ur4; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400; Expires=Thu, 02 Jan 2020 00:00:00 GMT", - "pgstencil_pending=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT" - ], - "x-content-type-options": "nosniff" - }, - "status": 303 -} diff --git a/tests/integration/snapshots/oauth/google/database.json b/tests/integration/snapshots/oauth/google/database.json deleted file mode 100644 index 2ea2b3f..0000000 --- a/tests/integration/snapshots/oauth/google/database.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "identities": [ - { - "created_at": "2020-01-01T00:00:00.000Z", - "provider": "google", - "subject": "google-person-1", - "user_id": "ajBfSJtclL7fLjBAiGE7eQ" - } - ], - "sessions": [ - { - "created_at": "2020-01-01T00:00:00.000Z", - "csrf_hash": "a0a8273dc60f7b575dd50bf60a70fdb6a4eed4ae96c74ef2cbba416d764a0ebf", - "expires_at": "2020-01-02T00:00:00.000Z", - "revoked_at": null, - "token_hash": "1c26fdb662187cd7e6968e7c423fc5dd9bc85908eb3fc03e311ded72053033a9", - "user_id": "ajBfSJtclL7fLjBAiGE7eQ" - } - ], - "users": [ - { - "created_at": "2020-01-01T00:00:00.000Z", - "email": "oauth@example.test", - "id": "ajBfSJtclL7fLjBAiGE7eQ" - } - ] -} diff --git a/tests/integration/snapshots/oauth/google/login.html b/tests/integration/snapshots/oauth/google/login.html deleted file mode 100644 index ecce52c..0000000 --- a/tests/integration/snapshots/oauth/google/login.html +++ /dev/null @@ -1,11 +0,0 @@ - - -Welcome in. · pgstencil - - -
pgstencilLocal inbox
-

Welcome in.

Sign in with your email. We’ll send you a code and a link—use whichever you prefer.

-
-

New here? Your account is created when you verify your email.

Or continue with
-
pgstencil Simple sign-in. Your email is your key.
- \ No newline at end of file diff --git a/tests/integration/snapshots/oauth/google/login.md b/tests/integration/snapshots/oauth/google/login.md deleted file mode 100644 index 12823cd..0000000 --- a/tests/integration/snapshots/oauth/google/login.md +++ /dev/null @@ -1,17 +0,0 @@ -# Welcome in. - -Sign in with your email. We’ll send you a code and a link—use whichever you prefer. - -Email address - -\[email: you@example.com\] - -\[Send sign-in code\] - -New here? Your account is created when you verify your email. - -Or continue with - -\[Continue with Google\] - -\[Continue with GitHub\] diff --git a/tests/integration/snapshots/oauth/google/start-http.json b/tests/integration/snapshots/oauth/google/start-http.json deleted file mode 100644 index 4bb6a25..0000000 --- a/tests/integration/snapshots/oauth/google/start-http.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "headers": { - "cache-control": "no-store", - "content-security-policy": "default-src 'none'; style-src 'self'; form-action 'self' https://accounts.google.com https://github.com; base-uri 'none'; frame-ancestors 'none'", - "location": "https://accounts.google.com/o/oauth2/v2/auth?redirect_uri=https%3A%2F%2Fpgstencil.test%2Foauth%2Fgoogle%2Fcallback&scope=openid+email&state=BgBD22MBxvwpI_IaX7FoWyrPy_ljqdCwdSiHIX52pV8&code_challenge=dvHL-azHXLjh-XzxiE7KKpehjsXphvVacOBRNP_MVfk&code_challenge_method=S256&nonce=483a337daeb23c2ebcca2efb55fa2ca1707e40f7a4df6d00e5c4ffcfe869855f&client_id=test-google-client&response_type=code", - "referrer-policy": "strict-origin", - "set-cookie": [ - "pgstencil_oauth=CLzxPEDiBC0DvLycd0Q9SxTC8yAlhpTgVa8eN8Q3_3w; Path=/; HttpOnly; SameSite=Lax; Max-Age=600; Expires=Wed, 01 Jan 2020 00:10:00 GMT" - ], - "x-content-type-options": "nosniff" - }, - "status": 303 -} diff --git a/tests/integration/workers.test.ts b/tests/integration/workers.test.ts deleted file mode 100644 index fb37be5..0000000 --- a/tests/integration/workers.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { test, expect } from 'vitest'; -import { build } from 'esbuild'; -import { builtinModules } from 'node:module'; -import { - Miniflare, - convertV4MiniflareOptions, - Response as MiniflareResponse, -} from 'miniflare'; -import { createTestContext } from '../../packages/pgstencil/src/testing.ts'; -import type { EmailMessage } from '../../packages/pgstencil/src/email.ts'; -import { - allOAuthCredentials, - endpointPaths, - mockOAuthServer, -} from '../support/oauth-server.ts'; -import { codeFrom } from './helpers.ts'; -import type { AuthState } from '../../packages/auth/src/fetch.ts'; - -const origin = 'https://worker.example.test'; -const bundle = build({ - entryPoints: ['tests/support/worker.ts'], - bundle: true, - write: false, - format: 'esm', - platform: 'node', - conditions: ['workerd', 'worker'], - external: ['node:*', 'cloudflare:*'], - alias: Object.fromEntries( - builtinModules - .filter((name) => !name.startsWith('node:')) - .map((name) => [name, `node:${name}`]), - ), - banner: { - js: "import { createRequire } from 'node:module'; const require = createRequire('/worker.js');", - }, -}); -type WorkerResponse = Awaited>; -const cookie = (response: WorkerResponse) => - response.headers - .getSetCookie() - .map((v) => v.split(';')[0]) - .join('; '); -const workerTest = test.extend<{ f: Awaited> }>({ - f: async ({}, use) => { - const f = await fixture(); - try { - await use(f); - } finally { - await f.close(); - } - }, -}); -async function fixture() { - const context = await createTestContext(); - const provider = await mockOAuthServer(); - const bindings: Record = { - APP_ORIGIN: origin, - AUTH_SECRET: 'workers-test-secret-at-least-32-characters', - }; - for (const [name, credentials] of Object.entries(allOAuthCredentials)) { - bindings[`${name.toUpperCase()}_CLIENT_ID`] = credentials.clientId; - bindings[`${name.toUpperCase()}_CLIENT_SECRET`] = credentials.clientSecret; - } - const worker = new Miniflare( - convertV4MiniflareOptions({ - modules: true, - script: (await bundle).outputFiles![0]!.text, - compatibilityDate: '2026-09-08', - compatibilityFlags: ['nodejs_compat'], - bindings, - hyperdrives: { HYPERDRIVE: context.database.url }, - async outboundService(req) { - const url = new URL(req.url); - if (url.origin === 'https://inbox.test') { - await context.email.send((await req.json()) as EmailMessage); - return new MiniflareResponse('ok'); - } - const path = endpointPaths[url.origin + url.pathname]; - if (!path) - throw new Error( - `Unexpected outbound request: ${url.origin}${url.pathname}`, - ); - const response = await fetch(provider.origin + path + url.search, { - method: req.method, - headers: Object.fromEntries(req.headers), - ...(req.method === 'POST' ? { body: await req.text() } : {}), - }); - return new MiniflareResponse(await response.arrayBuffer(), { - status: response.status, - headers: { - 'content-type': - response.headers.get('content-type') ?? 'application/json', - }, - }); - }, - }), - ); - try { - await worker.ready; - } catch (error) { - await worker.dispose(); - await provider.close(); - await context.close(); - throw error; - } - const get = (path: string, cookies = '') => - worker.dispatchFetch(origin + path, { - headers: { cookie: cookies }, - redirect: 'manual', - }); - const post = ( - path: string, - csrf: string, - cookies: string, - body: unknown = {}, - requestOrigin = origin, - ) => - worker.dispatchFetch(origin + path, { - method: 'POST', - headers: { - origin: requestOrigin, - cookie: cookies, - 'x-csrf-token': csrf, - 'content-type': 'application/json', - }, - body: JSON.stringify(body), - redirect: 'manual', - }); - return { - ...context, - worker, - provider, - get, - post, - async close() { - await worker.dispose(); - await provider.close(); - await context.close(); - }, - }; -} -workerTest( - 'real workerd + Postgres: email login, secure cookies, CSRF, exact 24-hour expiry', - async ({ f }) => { - const start = await f.get('/api/auth/session'); - expect(start.status).toBe(200); - const state = (await start.json()) as AuthState; - const pending = cookie(start); - expect( - ( - await f.post( - '/api/auth/email', - state.csrf, - pending, - { email: 'worker@example.test' }, - 'https://attacker.test', - ) - ).status, - ).toBe(403); - expect( - ( - await f.post('/api/auth/email', state.csrf, pending, { - email: 'worker@example.test', - }) - ).status, - ).toBe(200); - const email = await f.email.next(); - const verified = await f.post('/api/auth/verify', state.csrf, pending, { - method: 'code', - value: codeFrom(email), - }); - expect(verified.status).toBe(200); - expect(verified.headers.getSetCookie()[0]).toContain( - 'HttpOnly; SameSite=Lax; Max-Age=86400; Expires=Thu, 02 Jan 2020 00:00:00 GMT; Secure', - ); - const sessionCookie = cookie(verified); - const session = (await ( - await f.get('/api/auth/session', sessionCookie) - ).json()) as AuthState; - expect(session.session?.user.email).toBe('worker@example.test'); - expect( - (await f.post('/api/auth/logout', 'forged', sessionCookie)).status, - ).toBe(403); - await f.worker.dispatchFetch(origin + '/__test/time', { - method: 'POST', - body: '2020-01-01T23:00:00Z', - }); - expect( - ( - (await ( - await f.get('/api/auth/session', sessionCookie) - ).json()) as AuthState - ).session, - ).not.toBeNull(); - await f.worker.dispatchFetch(origin + '/__test/time', { - method: 'POST', - body: '2020-01-02T00:00:00Z', - }); - expect( - ( - (await ( - await f.get('/api/auth/session', sessionCookie) - ).json()) as AuthState - ).session, - ).toBeNull(); - }, -); -workerTest.for(['google', 'apple', 'facebook'] as const)( - 'real workerd: %s callback, browser binding and replay protection', - async (provider, { f }) => { - const start = await f.get('/api/auth/session'); - const state = (await start.json()) as AuthState; - const started = await f.post( - `/api/auth/oauth/${provider}/start`, - state.csrf, - cookie(start), - ); - expect(started.status).toBe(200); - const callback = f.provider.authorize( - provider, - new URL(((await started.json()) as { url: string }).url), - ); - let path = callback.pathname + callback.search; - if (provider === 'apple') { - const relay = await f.worker.dispatchFetch(origin + callback.pathname, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - origin: 'https://appleid.apple.com', - }, - body: callback.searchParams.toString(), - redirect: 'manual', - }); - expect(relay.status).toBe(303); - expect(relay.headers.get('referrer-policy')).toBe('no-referrer'); - expect(relay.headers.getSetCookie()).toEqual([]); - path = relay.headers.get('location')!; - } - const unbound = await f.get(path); - expect(unbound.headers.get('location')).toContain('/login?error='); - const result = await f.get(path, cookie(started)); - expect(result.headers.get('location')).toBe('/profile'); - expect( - ( - (await ( - await f.get('/api/auth/session', cookie(result)) - ).json()) as AuthState - ).session?.user.email, - ).toBe('oauth@example.test'); - expect( - (await f.get(path, cookie(started))).headers.get('location'), - ).toContain('/login?error='); - }, -); diff --git a/tests/support/better-auth-bundle.ts b/tests/support/better-auth-bundle.ts new file mode 100644 index 0000000..dc9fb57 --- /dev/null +++ b/tests/support/better-auth-bundle.ts @@ -0,0 +1,34 @@ +import { build } from 'esbuild'; +import { builtinModules } from 'node:module'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { createDeterministicApp } from './better-auth-entry.ts'; + +/** + * Bundles `better-auth-entry.ts` with esbuild injecting the actual + * `better-auth-testing` module file, so Date, Web Crypto and fetch inside the + * bundle follow each app's own clock, random stream and outbound fetch. + * `name` keeps concurrent test files from overwriting each other's output. + */ +export async function bundleDeterministicApp(name: string) { + const result = await build({ + entryPoints: [resolve('tests/support/better-auth-entry.ts')], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + packages: 'bundle', + external: [...builtinModules, 'node:*', 'pg-native'], + inject: [resolve('packages/auth/src/better-auth-testing.ts')], + banner: { + js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);", + }, + }); + await mkdir('.build', { recursive: true }); + const file = resolve(`.build/${name}.mjs`); + await writeFile(file, result.outputFiles[0]!.text); + return (await import(pathToFileURL(file).href)) as { + createDeterministicApp: typeof createDeterministicApp; + }; +} diff --git a/tests/support/fixture-app.ts b/tests/support/fixture-app.ts new file mode 100644 index 0000000..2fd8a2f --- /dev/null +++ b/tests/support/fixture-app.ts @@ -0,0 +1,132 @@ +import { createServer, type IncomingMessage } from 'node:http'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; +import request from 'supertest'; +import { + decimalCode, + token, + type EmailSender, + type RandomSource, + type Time, +} from '../../packages/pgstencil/src/index.ts'; +import { connectDatabase } from '../../packages/pgstencil/src/postgres.ts'; +import { createTestContext } from '../../packages/pgstencil/src/testing.ts'; + +/** The infrastructure tests' own schema, independent of any auth package. */ +export const fixtureMigrations = fileURLToPath( + new URL('../fixtures/migrations/', import.meta.url), +); +export interface FixtureDB { + users: { id: string; email: string; created_at: Date }; +} + +/** + * The smallest application that consumes everything a test context injects: + * its database URL, clock, random source and email sender. `POST /users` + * stores a user and emails a welcome code; `GET /users` lists the emails. + */ +export async function startFixtureApp(options: { + databaseUrl: string; + time: Time; + random: RandomSource; + email: EmailSender; +}) { + const db = connectDatabase(options.databaseUrl); + const server = createServer((req, res) => { + void handle(req).then( + ({ status, body }) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }, + () => { + res.writeHead(500).end(); + }, + ); + }); + async function handle( + req: IncomingMessage, + ): Promise<{ status: number; body: unknown }> { + if (req.url === '/users' && req.method === 'GET') + return { + status: 200, + body: await db + .selectFrom('users') + .select(['id', 'email', 'created_at']) + .orderBy('email') + .execute(), + }; + if (req.url === '/users' && req.method === 'POST') { + let raw = ''; + for await (const chunk of req) raw += String(chunk); + const { email } = JSON.parse(raw) as { email: string }; + const user = { + id: token(options.random, 16), + email, + created_at: options.time.now(), + }; + await db.insertInto('users').values(user).execute(); + const code = decimalCode(options.random); + await options.email.send({ + to: [email], + from: 'fixture@example.test', + subject: 'Welcome', + text: `Your code is ${code}. Joined ${user.created_at.toISOString()}.`, + html: `

Your code is ${code}.

Your profile

`, + }); + return { status: 201, body: user }; + } + return { status: 404, body: { message: 'Not found' } }; + } + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('Expected a TCP address'); + const origin = `http://127.0.0.1:${address.port}`; + return { + origin, + db, + async close() { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await db.destroy(); + }, + }; +} + +/** A leased database, injected clock/random/outbox, and a live fixture app on them. */ +export async function fixture() { + const context = await createTestContext({ migrations: fixtureMigrations }); + try { + const app = await startFixtureApp({ + databaseUrl: context.database.url, + time: context.time, + random: context.random, + email: context.email, + }); + return { + ...context, + app, + client: request(app.origin), + async close() { + await app.close(); + await context.close(); + }, + }; + } catch (error) { + await context.close(); + throw error; + } +} +export type Fixture = Awaited>; + +/** Signs up `email` and reads the welcome message it sent. */ +export async function signUp(f: Fixture, email = 'alice@example.test') { + const response = await f.client.post('/users').send({ email }).expect(201); + return { + user: response.body as { id: string }, + message: await f.email.next(), + }; +} diff --git a/tests/support/oauth-server.ts b/tests/support/oauth-server.ts index a4082bf..c8b913f 100644 --- a/tests/support/oauth-server.ts +++ b/tests/support/oauth-server.ts @@ -7,12 +7,30 @@ import { generateKeyPairSync, sign, } from 'node:crypto'; -import type { OAuthFetch } from '../../examples/login/src/oauth-providers.ts'; import type { Provider, OAuthSettings, } from '../../packages/auth/src/better-auth-oauth.ts'; +/** A fetch-shaped OAuth client transport: a string URL and complete options. */ +type OAuthFetch = ( + url: string, + options: { + body: + | ArrayBuffer + | null + | ReadableStream + | string + | Uint8Array + | undefined + | URLSearchParams; + duplex?: 'half'; + headers: Record; + method: string; + redirect: 'manual'; + signal?: AbortSignal; + }, +) => Promise; export const allOAuthCredentials = { google: { clientId: 'test-google-client', diff --git a/tests/support/process-app.ts b/tests/support/process-app.ts index 048ea1f..4ae0dfa 100644 --- a/tests/support/process-app.ts +++ b/tests/support/process-app.ts @@ -1,7 +1,7 @@ -import { fixture, login } from '../integration/helpers.ts'; +import { fixture, signUp } from './fixture-app.ts'; const f = await fixture(); try { - await login(f); + await signUp(f); process.send!({ origin: f.app.origin, name: f.database.name, diff --git a/tests/support/worker.ts b/tests/support/worker.ts deleted file mode 100644 index 03bc2b3..0000000 --- a/tests/support/worker.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { DevTime, DevRandom } from '../../packages/pgstencil/src/index.ts'; -import { createAuthWorker } from '../../packages/auth/src/workers.ts'; -import type { EmailMessage } from '../../packages/pgstencil/src/email.ts'; -const time = new DevTime(); -const app = createAuthWorker({ - time, - random: new DevRandom('worker-test'), - email: () => ({ - async send(message: EmailMessage) { - const response = await fetch('https://inbox.test/send', { - method: 'POST', - body: JSON.stringify(message), - }); - if (!response.ok) throw new Error('Test email delivery failed'); - }, - }), -}); -// Control routes exist only in this test bundle; the production entry has none. -export default { - async fetch(request: Request, env: Parameters[1]) { - if (new URL(request.url).pathname === '/__test/time') { - time.set(await request.text()); - return new Response('ok'); - } - return app.fetch(request, env); - }, -}; diff --git a/tests/unit/better-auth-sign-in-email.test.ts b/tests/unit/better-auth-sign-in-email.test.ts new file mode 100644 index 0000000..ba423a1 --- /dev/null +++ b/tests/unit/better-auth-sign-in-email.test.ts @@ -0,0 +1,50 @@ +import { test, expect } from 'vitest'; +import { escapeHtml } from '../../packages/pgstencil/src/index.ts'; +import { defaultSignInEmail } from '../../packages/auth/src/better-auth.ts'; + +test('escapeHtml escapes the five HTML metacharacters and stringifies anything', () => { + expect(escapeHtml(`Tom & Jerry's`)).toBe( + '<a href="x">Tom & Jerry's</a>', + ); + expect(escapeHtml('&')).toBe('&amp;'); + expect(escapeHtml(10)).toBe('10'); + expect(escapeHtml(null)).toBe('null'); + expect(escapeHtml(undefined)).toBe('undefined'); +}); + +const input = { + email: 'alice@example.test', + otp: '12345678', + expiresInMinutes: 10, + from: 'signin@app.example.test', +}; + +test('the default sign-in email keeps its wording and sender', () => { + expect(defaultSignInEmail(input)).toEqual({ + from: 'signin@app.example.test', + to: ['alice@example.test'], + subject: 'Your sign-in code', + text: 'Your sign-in code is 12345678. It expires in 10 minutes.', + html: '

Your sign-in code is 12345678.

It expires in 10 minutes.

', + }); +}); + +test('the default sign-in email puts appName in the subject only, never raw in HTML', () => { + const appName = `&`; + const message = defaultSignInEmail({ ...input, appName }); + expect(message.subject).toBe(`Your ${appName} sign-in code`); + expect(message.text).toBe( + 'Your sign-in code is 12345678. It expires in 10 minutes.', + ); + expect(message.html).toBe( + '

Your sign-in code is 12345678.

It expires in 10 minutes.

', + ); + expect(message.html).not.toContain(' { + const message = defaultSignInEmail({ ...input, otp: '&"\'' }); + expect(message.html).toBe( + '

Your sign-in code is <b>&"'</b>.

It expires in 10 minutes.

', + ); +}); diff --git a/tests/unit/better-auth-workers.test.ts b/tests/unit/better-auth-workers.test.ts index 9da1fca..9aed19f 100644 --- a/tests/unit/better-auth-workers.test.ts +++ b/tests/unit/better-auth-workers.test.ts @@ -73,3 +73,33 @@ test('a Workers hook factory that throws leaves auth requests working', async () expect(records.map((record) => record.event)).toContain('auth.hook.failed'); expect(JSON.stringify(records)).not.toContain('private-hook@example.test'); }); + +test('Workers emailFrom may be read from the request environment', async () => { + const bindings = { + APP_ORIGIN: 'https://auth.example.test', + AUTH_SECRET: 'worker-test-secret-at-least-32-characters', + HYPERDRIVE: { connectionString: 'postgres://unused' }, + EMAIL_FROM: 'App ', + }; + const seen: unknown[] = []; + const app = createBetterAuthWorker({ + email: () => ({ send: async () => {} }), + emailFrom: (env) => { + seen.push(env); + return env.EMAIL_FROM; + }, + }); + const response = await app.fetch( + new Request('https://auth.example.test/api/providers'), + bindings, + ); + expect(response.status).toBe(200); + expect(seen).toEqual([bindings]); + // A preview that blanks its mail bindings still answers. + const empty = await app.fetch( + new Request('https://auth.example.test/api/providers'), + { ...bindings, EMAIL_FROM: '' }, + ); + expect(empty.status).toBe(200); + expect(seen).toHaveLength(2); +}); diff --git a/tests/unit/cli.test.ts b/tests/unit/cli.test.ts new file mode 100644 index 0000000..b8f1a85 --- /dev/null +++ b/tests/unit/cli.test.ts @@ -0,0 +1,147 @@ +import { test, expect } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + parseArguments, + usage, +} from '../../packages/pgstencil/src/arguments.ts'; +import { projectRoot } from '../../packages/pgstencil/src/paths.ts'; + +const cli = join(projectRoot, 'packages/pgstencil/src/cli.ts'); + +/** Runs the command as the root scripts do, against a project of its own. */ +function pgstencil(root: string | undefined, ...argv: string[]) { + const env = { ...process.env }; + delete env.DATABASE_URL; + if (root) env.PGSTENCIL_PROJECT_ROOT = root; + const result = spawnSync( + process.execPath, + ['--import', 'tsx', cli, ...argv], + { + cwd: projectRoot, + env, + encoding: 'utf8', + }, + ); + if (result.error) throw result.error; + return result; +} + +test('commands parse into what they run', () => { + for (const name of [ + 'status', + 'validate', + 'migrate', + 'verify', + 'reset', + 'gc', + 'stop', + ]) + expect(parseArguments([name])).toEqual({ name }); + for (const help of ['help', '--help', '-h']) + expect(parseArguments([help])).toEqual({ name: 'help' }); + expect(parseArguments(['schema'])).toEqual({ name: 'schema', verify: false }); + expect(parseArguments(['types', '--verify'])).toEqual({ + name: 'types', + verify: true, + }); + expect(parseArguments(['create', 'add_profile'])).toEqual({ + name: 'create', + label: 'add_profile', + }); + expect( + parseArguments(['create', '--in', 'server/sql', 'add_profile']), + ).toEqual({ name: 'create', label: 'add_profile', in: 'server/sql' }); + expect(parseArguments(['create', 'add_profile', '--in=server/sql'])).toEqual({ + name: 'create', + label: 'add_profile', + in: 'server/sql', + }); +}); + +test('bad command lines are usage errors', () => { + for (const [argv, message] of [ + [[], 'Missing command'], + [['up'], 'Unknown command: up'], + [['--verify'], 'Unknown command: --verify'], + [['status', 'now'], 'status takes no arguments, got "now"'], + [['verify', '--verify'], 'verify takes no arguments'], + [['schema', '--write'], 'schema accepts only --verify, got "--write"'], + [['types', '--verify', '--verify'], 'types accepts only --verify'], + [['create'], 'create needs a snake_case migration name'], + [['create', 'AddProfile'], 'Migration names are snake_case'], + [['create', '001_profile'], 'Migration names are snake_case'], + [['create', 'a', 'b'], 'create takes one name, got a and b'], + [['create', 'a', '--in'], '--in needs a directory'], + [['create', 'a', '--in', '--force'], '--in needs a directory'], + [['create', 'a', '--in', 'x', '--in', 'y'], '--in given twice'], + [['create', 'a', '--force'], 'create does not accept --force'], + ] as const) + expect(() => parseArguments(argv), argv.join(' ')).toThrow(message); +}); + +test('the command prints usage and exits non-zero on a bad command line', () => { + for (const argv of [[], ['up'], ['create', 'Bad'], ['status', 'x']]) { + const result = pgstencil(undefined, ...argv); + expect(result.status, argv.join(' ')).toBe(2); + expect(result.stderr).toContain(usage); + expect(result.stdout).toBe(''); + } + const help = pgstencil(undefined, 'help'); + expect(help.status).toBe(0); + expect(help.stdout.trim()).toBe(usage); +}); + +test('a broken pgstencil.json fails the command with its message, but not help', async ({ + onTestFinished, +}) => { + const root = await mkdtemp(join(tmpdir(), 'pgstencil-cli-')); + onTestFinished(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, 'pgstencil.json'), '{"migration": "sql"}'); + const result = pgstencil(root, 'create', 'add_profile'); + expect(result.status).toBe(1); + expect(result.stderr.trim()).toBe( + 'pgstencil create: pgstencil.json top level has unknown key "migration"; expected migrations, schema, types', + ); + expect(pgstencil(root, 'help').status).toBe(0); +}); + +test('create numbers one above every source and writes into the last by default', async ({ + onTestFinished, +}) => { + const root = await mkdtemp(join(tmpdir(), 'pgstencil-cli-')); + onTestFinished(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, 'vendor/sql'), { recursive: true }); + await writeFile(join(root, 'vendor/sql/007_vendor.sql'), ''); + await writeFile( + join(root, 'pgstencil.json'), + JSON.stringify({ migrations: ['vendor/sql', 'app/sql'] }), + ); + // The application's own source need not exist yet. + const first = pgstencil(root, 'create', 'add_profile'); + expect(first.stderr).toBe(''); + expect(first.status).toBe(0); + expect(first.stdout.trim()).toBe(join(root, 'app/sql/008_add_profile.sql')); + expect( + await readFile(join(root, 'app/sql/008_add_profile.sql'), 'utf8'), + ).toBe('-- Up Migration\n\n-- Down Migration\n'); + const into = pgstencil(root, 'create', 'scratch', '--in', 'scratch'); + expect(into.status).toBe(0); + expect(into.stdout.trim()).toBe(join(root, 'scratch/009_scratch.sql')); + expect(into.stderr).toContain('scratch is not a migration source'); + const outside = pgstencil(root, 'create', 'escape', '--in', '../elsewhere'); + expect(outside.status).toBe(1); + expect(outside.stderr.trim()).toBe( + 'pgstencil create: --in must stay inside the project root: ../elsewhere', + ); + expect(await readdir(join(root, 'app/sql'))).toEqual(['008_add_profile.sql']); +}); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts new file mode 100644 index 0000000..2092579 --- /dev/null +++ b/tests/unit/config.test.ts @@ -0,0 +1,186 @@ +import { test, expect } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { + parseConfig, + readConfig, +} from '../../packages/pgstencil/src/config.ts'; +import { + defaultMigrations, + projectRoot, +} from '../../packages/pgstencil/src/paths.ts'; + +const root = resolve('/work/app'); +const parse = (value: unknown) => parseConfig(root, value); + +test('an empty configuration uses the migrations directory and generates nothing', () => { + expect(parse({})).toEqual({ + migrations: [join(root, 'migrations')], + types: [], + }); +}); + +test('migrations is a directory or an ordered list of directories', () => { + expect(parse({ migrations: 'db/sql' }).migrations).toEqual([ + join(root, 'db/sql'), + ]); + expect( + parse({ + migrations: [ + 'node_modules/@pgstencil/auth/better-auth-migrations', + 'node_modules/@pgstencil/stripe/migrations', + './server/migrations', + ], + }).migrations, + ).toEqual([ + join(root, 'node_modules/@pgstencil/auth/better-auth-migrations'), + join(root, 'node_modules/@pgstencil/stripe/migrations'), + join(root, 'server/migrations'), + ]); + expect(Object.isFrozen(parse({}).migrations)).toBe(true); +}); + +test('this repository configures the example sources, its schema dump and billing types', () => { + expect(defaultMigrations).toEqual([ + join(projectRoot, 'packages/auth/better-auth-migrations'), + join(projectRoot, 'packages/stripe/migrations'), + ]); + expect(readConfig(projectRoot)).toEqual({ + migrations: defaultMigrations, + schema: join(projectRoot, 'examples/better-auth/schema.sql'), + types: [ + { + out: join(projectRoot, 'packages/stripe/src/db.generated.ts'), + include: 'pgstencil_billing.*', + defaultSchema: 'pgstencil_billing', + }, + ], + }); +}); + +test('malformed migrations are refused with the offending entry named', () => { + expect(() => parse({ migrations: [] })).toThrow( + 'pgstencil.json migrations must be a directory or a non-empty list of directories, not an empty list', + ); + expect(() => parse({ migrations: 3 })).toThrow('not number'); + expect(() => parse({ migrations: null })).toThrow('not null'); + expect(() => parse({ migrations: ['a', ''] })).toThrow( + 'pgstencil.json migrations[1] must be a non-empty string', + ); + expect(() => parse({ migrations: ['a', { dir: 'b' }] })).toThrow( + 'pgstencil.json migrations[1] must be a non-empty string, not object', + ); + expect(() => parse({ migrations: ['sql', './sql'] })).toThrow( + 'pgstencil.json migrations lists sql more than once', + ); +}); + +test('every configured path must stay inside the project root', () => { + for (const escape of ['..', '../shared', 'a/../../b', resolve('/tmp/sql')]) { + expect(() => parse({ migrations: escape }), escape).toThrow( + `pgstencil.json migrations must stay inside the project root: ${escape}`, + ); + expect(() => parse({ migrations: ['sql', escape] }), escape).toThrow( + 'pgstencil.json migrations[1] must stay inside the project root', + ); + expect(() => parse({ schema: escape }), escape).toThrow( + 'pgstencil.json schema must stay inside the project root', + ); + expect(() => parse({ types: [{ out: escape }] }), escape).toThrow( + 'pgstencil.json types[0].out must stay inside the project root', + ); + } +}); + +test('unknown keys are refused, at the top level and in types entries', () => { + expect(() => parse({ migration: 'sql' })).toThrow( + 'pgstencil.json top level has unknown key "migration"; expected migrations, schema, types', + ); + expect(() => parse({ a: 1, b: 2 })).toThrow('unknown keys "a", "b"'); + expect(() => + parse({ types: [{ out: 'db.ts', outFile: 'db.ts', schema: 'x' }] }), + ).toThrow( + 'pgstencil.json types[0] has unknown keys "outFile", "schema"; expected out, include, exclude, defaultSchema', + ); +}); + +test('the configuration itself must be an object', () => { + for (const [value, kind] of [ + [null, 'null'], + [[], 'an array'], + ['sql', 'string'], + ] as const) + expect(() => parse(value)).toThrow( + `pgstencil.json top level must be an object, not ${kind}`, + ); +}); + +test('types entries default include to public.* and keep only what was given', () => { + expect( + parse({ + schema: 'schema.sql', + types: [ + { out: 'src/db.generated.ts' }, + { + out: 'src/billing.generated.ts', + include: 'pgstencil_billing.*', + exclude: 'pgstencil_billing.events', + defaultSchema: 'pgstencil_billing', + }, + ], + }), + ).toEqual({ + migrations: [join(root, 'migrations')], + schema: join(root, 'schema.sql'), + types: [ + { out: join(root, 'src/db.generated.ts'), include: 'public.*' }, + { + out: join(root, 'src/billing.generated.ts'), + include: 'pgstencil_billing.*', + exclude: 'pgstencil_billing.events', + defaultSchema: 'pgstencil_billing', + }, + ], + }); +}); + +test('malformed types entries are refused with the offending field named', () => { + expect(() => parse({ types: { out: 'db.ts' } })).toThrow( + 'pgstencil.json types must be a list of { out, include?, exclude?, defaultSchema? }, not object', + ); + expect(() => parse({ types: null })).toThrow('not null'); + expect(() => parse({ types: ['db.ts'] })).toThrow( + 'pgstencil.json types[0] must be an object, not string', + ); + expect(() => parse({ types: [{ include: 'public.*' }] })).toThrow( + 'pgstencil.json types[0] needs an "out" path', + ); + expect(() => parse({ types: [{ out: 'db.ts', include: '' }] })).toThrow( + 'pgstencil.json types[0].include must be a non-empty string', + ); + expect(() => + parse({ types: [{ out: 'db.ts' }, { out: 'db.ts', defaultSchema: 1 }] }), + ).toThrow('pgstencil.json types[1].defaultSchema must be a non-empty string'); + expect(() => + parse({ types: [{ out: 'db.ts' }, { out: './db.ts' }] }), + ).toThrow('pgstencil.json types lists db.ts more than once'); + expect(() => parse({ schema: '' })).toThrow( + 'pgstencil.json schema must be a non-empty string', + ); +}); + +test('readConfig uses every default without a file and names a file that is not JSON', async ({ + onTestFinished, +}) => { + const directory = await mkdtemp(join(tmpdir(), 'pgstencil-config-')); + onTestFinished(() => rm(directory, { recursive: true, force: true })); + expect(readConfig(directory)).toEqual({ + migrations: [join(directory, 'migrations')], + types: [], + }); + await writeFile(join(directory, 'pgstencil.json'), '{ "migrations": '); + expect(() => readConfig(directory)).toThrow( + `${join(directory, 'pgstencil.json')} is not valid JSON`, + ); +}); diff --git a/tests/unit/oauth-config.test.ts b/tests/unit/oauth-config.test.ts index e578a54..e1ad77b 100644 --- a/tests/unit/oauth-config.test.ts +++ b/tests/unit/oauth-config.test.ts @@ -1,8 +1,5 @@ import { expect, test } from 'vitest'; -import { - oauthFromEnvironment, - OAuthProviders, -} from '../../examples/login/src/oauth-providers.ts'; +import { oauthFromEnvironment } from '../../packages/auth/src/better-auth-oauth.ts'; test('OAuth configuration is optional and requires complete credentials for each enabled provider', () => { expect(oauthFromEnvironment({})).toEqual({}); @@ -13,8 +10,13 @@ test('OAuth configuration is optional and requires complete credentials for each oauthFromEnvironment({ GOOGLE_CLIENT_ID: 'client', GOOGLE_CLIENT_SECRET: 'secret', + MICROSOFT_CLIENT_ID: 'ms-client', + MICROSOFT_CLIENT_SECRET: 'ms-secret', }), - ).toEqual({ google: { clientId: 'client', clientSecret: 'secret' } }); + ).toEqual({ + google: { clientId: 'client', clientSecret: 'secret' }, + microsoft: { clientId: 'ms-client', clientSecret: 'ms-secret' }, + }); expect(() => oauthFromEnvironment({ GITHUB_CLIENT_ID: 'client' })).toThrow( 'Set both GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET', ); @@ -27,8 +29,4 @@ test('OAuth configuration is optional and requires complete credentials for each GOOGLE_CLIENT_SECRET: 'secret', }), ).toThrow(); - expect( - () => - new OAuthProviders({ github: { clientId: 'client', clientSecret: '' } }), - ).toThrow('GitHub requires'); }); diff --git a/tests/unit/paths.test.ts b/tests/unit/paths.test.ts index 46e8a20..7bf6635 100644 --- a/tests/unit/paths.test.ts +++ b/tests/unit/paths.test.ts @@ -7,9 +7,9 @@ test('pgstencil.json migrations path must stay inside the project root', () => { expect(migrationsDirectory(root, 'migrations')).toBe( join(root, 'migrations'), ); - expect(migrationsDirectory(root, 'packages/auth/migrations')).toBe( - join(root, 'packages/auth/migrations'), - ); + expect( + migrationsDirectory(root, 'packages/auth/better-auth-migrations'), + ).toBe(join(root, 'packages/auth/better-auth-migrations')); expect(migrationsDirectory(root, './db/../sql')).toBe(join(root, 'sql')); expect(migrationsDirectory(root, '..migrations')).toBe( join(root, '..migrations'), diff --git a/tests/unit/postmark.test.ts b/tests/unit/postmark.test.ts new file mode 100644 index 0000000..8796f11 --- /dev/null +++ b/tests/unit/postmark.test.ts @@ -0,0 +1,89 @@ +import { test, expect, vi, afterEach } from 'vitest'; +import { postmarkEmail } from '../../packages/auth/src/postmark.ts'; +import { diagnosticError } from '../../packages/pgstencil/src/diagnostics.ts'; + +afterEach(() => vi.unstubAllGlobals()); + +const message = { + from: 'Someone Else ', + to: ['alice@example.test'], + subject: 'Your sign-in code', + text: 'Your sign-in code is 12345678.', + html: '

12345678

', +}; + +function stub(status: number, body: string) { + const requests: Request[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init: RequestInit) => { + requests.push(new Request(url, init)); + return new Response(body, { + status, + headers: { 'content-type': 'application/json' }, + }); + }), + ); + return requests; +} + +async function failure(status: number, body: string) { + stub(status, body); + const error = await postmarkEmail('token', 'App ') + .send(message) + .then( + () => undefined, + (error: unknown) => error, + ); + expect(error).toBeInstanceOf(Error); + return error as Error & { httpStatus: number; providerCode?: number }; +} + +test("postmark sends from the adapter's own sender, overriding message.from", async () => { + const requests = stub(200, JSON.stringify({ ErrorCode: 0, Message: 'OK' })); + await postmarkEmail('token', 'App ').send(message); + expect(requests).toHaveLength(1); + expect(requests[0]!.headers.get('x-postmark-server-token')).toBe('token'); + expect(await requests[0]!.json()).toMatchObject({ + From: 'App ', + To: 'alice@example.test', + }); +}); + +test('postmark failures carry the numeric ErrorCode as providerCode, never the message text', async () => { + const error = await failure( + 422, + JSON.stringify({ + ErrorCode: 406, + Message: 'You tried to send to recipient alice@example.test (inactive)', + }), + ); + expect(error.message).toBe('Email delivery failed'); + expect(error.httpStatus).toBe(422); + expect(error.providerCode).toBe(406); + expect(JSON.stringify(error)).not.toContain('alice@example.test'); + expect(diagnosticError(error)).toMatchObject({ + httpStatus: 422, + providerCode: 406, + }); +}); + +test('postmark failures omit providerCode when the body has no numeric ErrorCode', async () => { + for (const body of [ + 'upstream unavailable', + JSON.stringify({ ErrorCode: '406' }), + JSON.stringify({ Message: 'no code' }), + 'null', + ]) { + const error = await failure(503, body); + expect(error.httpStatus, body).toBe(503); + expect(error.providerCode, body).toBeUndefined(); + } +}); + +test('postmark treats a 2xx with a nonzero or missing ErrorCode as a failure', async () => { + expect( + (await failure(200, JSON.stringify({ ErrorCode: 300 }))).providerCode, + ).toBe(300); + expect((await failure(200, '{}')).providerCode).toBeUndefined(); +}); diff --git a/tests/unit/stripe-dev.test.ts b/tests/unit/stripe-dev.test.ts index e53d22f..aac073a 100644 --- a/tests/unit/stripe-dev.test.ts +++ b/tests/unit/stripe-dev.test.ts @@ -77,3 +77,64 @@ test('Checkout refuses payment_method_types, which the endive API versions remov await dev.close(); } }); + +test('subscriptions.list pages newest first with limit, starting_after and has_more, as Stripe does', async () => { + const time = new DevTime('2020-01-01'); + const dev = await createStripeDev(time, new DevRandom('list-pages')); + try { + const subscribe = async (customer: string) => { + const session = await dev.stripe.checkout.sessions.create({ + mode: 'subscription', + customer, + line_items: [{ price: dev.prices.monthly, quantity: 1 }], + payment_method_collection: 'always', + expires_at: Math.floor(time.now().getTime() / 1000) + 3600, + success_url: 'https://example.test/billing', + }); + return dev.completeCheckout(session.id).id; + }; + const created: string[] = []; + for (let i = 0; i < 25; i++) created.push(await subscribe('cus_listed')); + await subscribe('cus_other'); + const newest = [...created].reverse(); + const first = await dev.stripe.subscriptions.list({ + customer: 'cus_listed', + }); + expect(first.data.map((s) => s.id)).toEqual(newest.slice(0, 10)); + expect(first.has_more).toBe(true); + const last = await dev.stripe.subscriptions.list({ + customer: 'cus_listed', + limit: 10, + starting_after: newest[19]!, + }); + expect(last.data.map((s) => s.id)).toEqual(newest.slice(20)); + expect(last.has_more).toBe(false); + const exact = await dev.stripe.subscriptions.list({ + customer: 'cus_listed', + limit: 5, + starting_after: newest[19]!, + }); + expect(exact.data).toHaveLength(5); + expect(exact.has_more).toBe(false); + // The SDK's auto-pagination follows each page's last ID. + const all: string[] = []; + for await (const sub of dev.stripe.subscriptions.list({ + customer: 'cus_listed', + limit: 7, + })) + all.push(sub.id); + expect(all).toEqual(newest); + for (const limit of [0, 101]) + await expect( + dev.stripe.subscriptions.list({ customer: 'cus_listed', limit }), + ).rejects.toThrow('limit must be an integer between 1 and 100'); + await expect( + dev.stripe.subscriptions.list({ + customer: 'cus_listed', + starting_after: 'sub_unknown', + }), + ).rejects.toThrow('No such subscription'); + } finally { + await dev.close(); + } +}); diff --git a/vitest.config.ts b/vitest.config.ts index c959de5..30113bb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,7 +6,12 @@ export default defineConfig({ hookTimeout: 120000, maxWorkers: 4, update: process.env.PGSTENCIL_UPDATE === '1' ? 'all' : 'none', - forceRerunTriggers: ['**/packages/*/migrations/*.sql', '**/compose.yaml'], + forceRerunTriggers: [ + '**/packages/*/migrations/*.sql', + '**/packages/auth/better-auth-migrations/*.sql', + '**/tests/fixtures/migrations/*.sql', + '**/compose.yaml', + ], sequence: { concurrent: false }, }, });