diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md index 5cc64312d..90949859f 100644 --- a/.github/audit/hosted.md +++ b/.github/audit/hosted.md @@ -8,7 +8,7 @@ This is a code-and-specs audit of Hosted's three Workers — the account application, the relay that serves the Hosted Relay's account-scoped routes, Pocket, and the one-time rendezvous, and managed voice (`docs/specs/hosted.md`). You need no PAT — do not use one. The two pgstencil provenance checks below do read the GitHub API, but only a public repository, which the workflow's default `GITHUB_TOKEN` and the operator's own `gh` login both reach; if that API is unreachable, report those two checks as `UNVERIFIABLE`. -Read `docs/specs/hosted.md`, `docs/specs/one-time.md` (its "Wire contract", "Hosted rendezvous", and "Phone page"), `docs/specs/relay.md` (its "HTTP API", "Setup tokens and the pairing QR", "WebAuthn without a WebAuthn library", "Routing", "Web Push", and "State files", whose semantics the Hosted Relay keeps), `hosted/server/`, `hosted/src/`, `hosted/scripts/`, `hosted/wrangler.jsonc`, `hosted/wrangler.relay.jsonc`, `hosted/wrangler.voice.jsonc`, `remote-lib-common/src/remote/one-time-wire.ts`, `remote-lib-common/src/remote/relay-common.ts`, `remote-lib-common/src/remote/relay-routing.ts`, `remote-lib-common/src/remote/enroll-code.ts`, `remote-lib-common/src/remote/wire.ts`, `scripts/e2e-lint.mjs`, `lib/package.json`, `remote-lib-common/src/remote/web-push.ts` and its test `remote-lib-common/test/web-push.test.mjs` with the vectors under `remote-lib-common/test/vectors/`, the Pocket build the relay serves (`lib/vite.pocket.config.ts`, `lib/pocket/`), the phone page it serves — `lib/vite.one-time.config.ts`, `lib/one-time/`, `lib/src/remote/one-time-app/`, and `lib/scripts/assert-pocket-worker.mjs` — and `.github/workflows/hosted-preview.yml` and `.github/workflows/hosted-production.yml` — `docs/specs/security-hosted.md`'s Deployment boundary quantifies over the preview and production paths, which live in those scripts and workflows rather than in the Worker. +Read `docs/specs/hosted.md`, `docs/specs/one-time.md` (its "Wire contract", "Hosted rendezvous", and "Phone page"), `docs/specs/relay.md` (its "HTTP API", "Setup tokens and the pairing QR", "WebAuthn without a WebAuthn library", "Routing", "Web Push", and "State files", whose semantics the Hosted Relay keeps), `hosted/server/`, `hosted/src/`, `hosted/scripts/`, `hosted/wrangler.jsonc`, `hosted/wrangler.relay.jsonc`, `hosted/wrangler.voice.jsonc`, `website/public/_worker.js` and `website/public/_routes.json` (the site's forward to the account's founders row), `remote-lib-common/src/remote/one-time-wire.ts`, `remote-lib-common/src/remote/relay-common.ts`, `remote-lib-common/src/remote/relay-routing.ts`, `remote-lib-common/src/remote/enroll-code.ts`, `remote-lib-common/src/remote/wire.ts`, `scripts/e2e-lint.mjs`, `lib/package.json`, `remote-lib-common/src/remote/web-push.ts` and its test `remote-lib-common/test/web-push.test.mjs` with the vectors under `remote-lib-common/test/vectors/`, the Pocket build the relay serves (`lib/vite.pocket.config.ts`, `lib/pocket/`), the phone page it serves — `lib/vite.one-time.config.ts`, `lib/one-time/`, `lib/src/remote/one-time-app/`, and `lib/scripts/assert-pocket-worker.mjs` — and `.github/workflows/hosted-preview.yml` and `.github/workflows/hosted-production.yml` — `docs/specs/security-hosted.md`'s Deployment boundary quantifies over the preview and production paths, which live in those scripts and workflows rather than in the Worker. Check the installed `pgstencil`, `@pgstencil/auth`, and `@pgstencil/stripe` packages against "Deployment boundary" by reading each `dist/provenance.json`, `pnpm-lock.yaml` and `pnpm-workspace.yaml` (no `.npmrc` is tracked, so the registry is npm's default), and Hosted's runtime imports. Audit no package code, except that the `FAIL IF`s which name the packed adapter (CSRF and CORS, cookie attributes and JSON scrubbing, the revoked-login callback) may read `@pgstencil/auth`'s `dist/` to verify how Hosted configures it. @@ -46,7 +46,7 @@ Be adversarial, and go past the `FAIL IF` list; a bare section name is `docs/spe - **Can a Hosted login become terminal access, or an account become someone else's?** Trace linking, a revoked login's callback, an unused or unknown provider credential, and every path from a login toward a Burrow ACL grant ("Account boundary"; the relay Worker's cookie rule is "Relay boundary"). Pocket and `/connect/` share the relay origin; check what each page's policy lets it reach of the other. - **Can one account's relay socket reach another's?** Trace an upgrade through `relaySocketRoutes` (`hosted/server/relay-sockets.ts`) into `RelayRoom` (`hosted/server/relay-room.ts`) against "Relay boundary". Look for routing state kept in memory that a hibernated object would lose, a socket torn down twice or routed after its close began, a frame bounded in characters rather than bytes, a `ct` touched outside the shared frame layer's field copy, a Client cap one socket can evict past, a session that outlives its alarm, a ping that wakes the object, a Burrow socket accepted on a row removed after the token check, and a removed or de-entitled Burrow's socket outliving the sweep. - **Can the rendezvous become more than a handshake pipe?** Trace a frame through `OneTimeRoom` against "Rendezvous boundary". Look for a second phone admitted across an await or a hibernation, a room that outlives its alarm, a web page that can mint a room, a join from another origin, a room id a caller can choose, and a limit a caller can step around. The relay is same-site with the account, so account cookies can ride the phone's upgrade. -- **Can billing grant what Stripe did not sell, or end what it should only suspend?** Trace checkout, confirm, the webhook, and the resync in `hosted/server/billing-routes.ts` and `hosted/server/billing.ts` against "Account boundary": a browser-chosen Price, owner, or return URL, an unsigned or replayed event, a 2xx before the commit, a count a redelivery repeats, a stale dispute event read as current, a partial refund, or a refund of an earlier charge, that ends a subscription, a failed count that fails the event, and a lapsed or disputed subscription that still admits; and a checkout URL answered before its consent row, or a founders row for a login without a current founding subscription. +- **Can billing grant what Stripe did not sell, or end what it should only suspend?** Trace checkout, confirm, the webhook, and the resync in `hosted/server/billing-routes.ts` and `hosted/server/billing.ts` against "Account boundary": a browser-chosen Price, owner, or return URL, an unsigned or replayed event, a 2xx before the commit, a count a redelivery repeats, a stale dispute event read as current, a partial refund, or a refund of an earlier charge, that ends a subscription, a failed count that fails the event, and a lapsed or disputed subscription that still admits; and a checkout URL answered before its consent row, or a founders row for a login without a current founding subscription. The founders row is public: what can a reader learn from `/api/hosted/*` beyond the names founders chose, and can a stored profile-image URL make the account Worker fetch anything but a provider's picture? - **Does anything from the test or preview build reach production?** Trace the production entries and the preview configuration and workers against "Deployment boundary". Preview credentials are `ci-and-secrets`' (`docs/specs/security-ci.md` -> "Hosted Deployments"). Does the shipped code still match what the spec and this section claim? Spec drift is a finding; say which side is wrong. diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 3e79edb0a..37f0d880b 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -9,7 +9,7 @@ | Worker | Origin | Serves | Holds | |---|---|---|---| -| `dormouse-hosted` | `https://hosted.dormouse.sh` | the account frontend, `/api/auth/*`, `/api/providers`, Natural Voice on or off, the Relay's account routes ("Burrow enrollment"), billing and Stripe's webhook ("Billing") | the login cookie, auth secrets, Hyperdrive, the approval rate limit, a binding to the relay's `RelayRoom`, the Stripe bindings | +| `dormouse-hosted` | `https://hosted.dormouse.sh` | the account frontend, `/api/auth/*`, `/api/providers`, Natural Voice on or off, the Relay's account routes ("Burrow enrollment"), billing, Stripe's webhook, and the founders row ("Billing") | the login cookie, auth secrets, Hyperdrive, the approval rate limit, a binding to the relay's `RelayRoom`, the Stripe bindings | | `dormouse-relay` | `https://relay.dormouse.sh` | the Hosted Relay, its sockets, and Pocket ("Relay", "Relay sockets"), the one-time rendezvous and `/connect/` (`docs/specs/one-time.md` -> "Hosted rendezvous") | Hyperdrive, `OneTimeRoom`, `RelayRoom`, the one-time, sign-in, setup, and enrollment rate limits, `ACCOUNT_ORIGIN`, `RELAY_ENROLL_SECRET`, the VAPID pair | | `dormouse-voice` | `https://voice.dormouse.sh` | speak, a token's own delete, and the history sweep ("Managed voice") | `ELEVENLABS_API_KEY`, Hyperdrive | @@ -86,6 +86,8 @@ The account Worker bills through `@pgstencil/stripe` (`docs/specs/pricing.md` -> | `POST /api/billing/portal` | login cookie, exact `Origin` | 200 `{ url }` of the customer portal | | `PUT /api/billing/founder` | login cookie, exact `Origin`, JSON `{ shown, name? }` | 200, the `GET` body | | `POST /api/billing/webhook` | `Stripe-Signature` over the raw body | 200 once `webhook()` committed | +| `GET /api/hosted/cohorts` | none | 200 `{ cohort?, seatsLeft?, founders: { total, shown } }` | +| `GET /api/hosted/founders/` | none | 200, a shown founder's picture | Errors are JSON `{ message }`. The cookie routes need no entitlement, answer 401 without a login, and cap a body at 4 KiB. @@ -98,10 +100,11 @@ Errors are JSON `{ message }`. The cookie routes need no entitlement, answer 401 - **Must end at once the subscription a fully refunded charge or a lost dispute's charge paid for**, found through its invoice, while it is the owner's current one: commented `ENDED_FOR_REFUND`, after the commit and on every delivery until ended. A partial refund ends nothing, nor does a charge for an earlier subscription or none, which is logged for the operator. - **Must suspend, never end, on an open dispute**, by its current status read from Stripe, not the event (`dormouse_billing_disputes`); closing in the account's favor restores it. - **Must offer founding at the first ladder step whose completed purchases, refunds included, are short of `FOUNDING_COHORT_SIZE`**; its seats left exclude purchases ended within `REFUND_DAYS`. Past the last step, founding checkout answers 409. +- **Must answer `/api/hosted/*` without resolving a login**, each answer reused for `COHORT_CACHE_MS` per isolate; `total` counts founding purchases net of refunds. The site's Worker forwards it from `dormouse.sh` to this origin, so the 421 gate needs no exception. What it answers and fetches: `docs/specs/security-hosted.md` -> "Account boundary". - **Must resync, every 10 minutes from the account's Cron Trigger, a bounded random batch of owners whose `active` or `trialing` row no longer grants access**, so a missed renewal webhook lapses a member only until a run picks them; a failure fails the invocation after every owner was tried. - **Must grant the account's role the `pgstencil_billing` schema from `runtime-roles.sql`**, additively: the package's migration creates it. -Source of truth: `billingSetup`, `withBilling`, and `resyncBilling` in `hosted/server/billing.ts`; `billingRoutes` and `disputeAction` in `hosted/server/billing-routes.ts`. Pinned by `hosted/server/tests/billing.test.ts` and `hosted/server/tests/billing-routes.test.ts`, on StripeDev. +Source of truth: `billingSetup`, `withBilling`, and `resyncBilling` in `hosted/server/billing.ts`; `billingRoutes` and `disputeAction` in `hosted/server/billing-routes.ts`; `cohortRoutes` in `hosted/server/cohort-routes.ts`; `forwardHosted` in `website/public/_worker.js`. Pinned by `hosted/server/tests/billing.test.ts` and `hosted/server/tests/billing-routes.test.ts`, on StripeDev. ## Managed voice diff --git a/docs/specs/pricing.md b/docs/specs/pricing.md index 9b6fdbac9..50a854446 100644 --- a/docs/specs/pricing.md +++ b/docs/specs/pricing.md @@ -3,7 +3,7 @@ > - See `docs/specs/glossary.md` for Burrow / Client / Relay and Pane / Session vocabulary. > - **Owns:** the plans, the founding ladder, what a plan grants, how a desktop proves membership, the managed-voice boundary, and the content contract of the Hosted page. > - **Defers:** page chrome, rail, and link obligations to `docs/specs/website-docs.md` -> "Reference page chrome"; the Hosted Relay's accounts, enrollment, and entitlement, and managed voice's routes, to `docs/specs/hosted.md`; the cloud-hosted trust boundary to `docs/specs/security-remote.md` -> "Cloud-hosted mode"; alarm delivery to `docs/specs/alert.md` -> "Spoken alarms". -> - **Status:** the Hosted page publishes the plans and the FAQ, a desktop enrolls with Hosted for managed voice and the Hosted Relay, the account's subscription is the entitlement, and the account pages that sell are built, with billing closed until checkout opens; opening it, and everything else that takes money, is under [Future](#future). +> - **Status:** the Hosted page publishes the plans and the FAQ, a desktop enrolls with Hosted for managed voice and the Hosted Relay, the account's subscription is the entitlement, and the account pages that sell, the checkout switch, and the founding card's live half are built, with billing closed until checkout opens; opening it, and everything else that takes money, is under [Future](#future). ## The Hosted page @@ -13,7 +13,7 @@ **Must give every link to the page a `?ref=` from `HOSTED_REFS`, one per link, ahead of its fragment, and strip it from the address bar on arrival**, so a copied link never carries it. `takeVisitRef` answers it, an unlisted value as `other`, and the page's pageview carries it (`docs/specs/website-docs.md` -> "Visit analytics"). Source of truth: `lib/src/lib/hosted-links.ts`; `takeVisitRef` in `website/src/lib/hosted-ref.ts`. -**Prices, inclusions, and the FAQ are prerendered text**, and the page emits `Product` / `Offer` JSON-LD carrying one `Offer` per paid plan at its current price, so an assistant fetching the page can quote it. **Offers stay `PreOrder` while checkout is unbuilt.** +**Prices, inclusions, and the FAQ are prerendered text**, and the page emits `Product` / `Offer` JSON-LD carrying one `Offer` per paid plan at its current price, so an assistant fetching the page can quote it. **Offers are `PreOrder` until `CHECKOUT_OPEN`, then `InStock`.** **Every price on the site has one owner**, `website/src/lib/hosted-pricing.ts`; site code reads it rather than restating a number. @@ -23,16 +23,16 @@ Each paid card carries a 30-day refund beneath its buy button. -- **A buy button opens the unbuilt-checkout notice.** **Never render a buy button that silently does nothing.** +- **While `CHECKOUT_OPEN` is false a buy button opens the closed-checkout notice; once true it links to the account origin's `/checkout?plan=`, carrying the visit's `ref`.** **Never render a buy button that silently does nothing.** ### The founding card's live half -Seats left in the open cohort and the founders row load after hydration from one endpoint; the price beside them is prerendered. +Seats left in the open cohort and the founders row load after hydration from `/api/hosted/cohorts`, which the site's Worker forwards to the account Worker (`docs/specs/hosted.md` -> "Billing"); the price beside them is prerendered. - **Count seats on the server** from the billing provider behind a cache of at most 60 seconds, never on the client and never stored. **Never show a count for a closed cohort.** -- **Show a founder only if they opted in at checkout**; the box starts unticked and the account can untick it. Every other founder counts toward the `+N` that ends the row, as does everyone past the row's cap. +- **Show a founder only if they opted in, and only while their founding subscription holds**; the box starts unticked and the account can untick it. Every other founder counts toward the `+N` that ends the row, as does everyone past the row's cap. - **Must serve avatars from this origin, never the OAuth provider**, so loading the page tells no provider about the reader. The client draws an initial for any avatar that is not a same-origin path after URL normalization, or that fails to load. -- **The page prerenders without the endpoint**: an unreachable endpoint, a non-2xx, or a malformed field drops only that field, never an error. **A cohort closing raises the price at the next deploy.** +- **The page prerenders without the endpoint**: an unreachable endpoint, a non-2xx, or a malformed field drops only that field, never an error. **A cohort closing raises the price at the next deploy**, and **never show seats unless the answer's `cohort` equals the prerendered `FOUNDING_COHORTS_CLOSED`**, so a stale deploy shows none. ### Published prices @@ -49,7 +49,7 @@ Prices in USD; DiffPlug adds or includes applicable tax by jurisdiction. - **The step is $10 per cohort of 100, fixed**, and the ladder's last step is the one below list — reaching list closes founding. - **Show the current price, the struck list price, and the seats left at that price — never the next step or how many cohorts remain.** -Source of truth: `Hosted` in `website/src/pages/Hosted.tsx`; the `/pricing` rule in `website/public/_redirects`, pinned by `checkPricingRedirect` in `scripts/public-docs-lint.mjs`. `website/src/pages/Hosted.test.tsx` pins the page contract. +Source of truth: `Hosted` in `website/src/pages/Hosted.tsx`; `fetchCohort` in `website/src/lib/hosted-cohorts.ts`; the `/pricing` rule in `website/public/_redirects`, pinned by `checkPricingRedirect` in `scripts/public-docs-lint.mjs`. `website/src/pages/Hosted.test.tsx` pins the page contract. ## Checkout and entitlement @@ -69,10 +69,9 @@ Source of truth: `withBilling` and `openCohort` in `hosted/server/billing.ts`; ` **Scope: hosted-sales** — what remains, in staged order: -1. **The cohort endpoint** the page already calls: the open cohort's seats and the opted-in founders, avatars proxied onto this origin. -2. **Opening checkout** ("Checkout"): the website's buy buttons to `/checkout`, then the Stripe bindings. -3. **Managed voice for members**: one voice per Pane. -4. **Renewal, cancellation, and refund** paths. +1. **Opening checkout** ("Checkout"): production's Stripe bindings, then `CHECKOUT_OPEN`. +2. **Managed voice for members**: one voice per Pane. +3. **Renewal, cancellation, and refund** paths. Team and enterprise tiers are never sold through this page. A free hosted tier is undecided — see [Open questions](#open-questions). diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 506f80e4b..24281bd7d 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -25,7 +25,7 @@ Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.t | Guarantee | Feature | Pinned by | | --- | --- | --- | -| **A Hosted account signs you in and grants nothing more.** Login never mints terminal access or stands in for pairing and presence; only an entitled account reaches managed voice, whose Worker neither logs nor stores the text it speaks. Hosted's own counts are daily totals that name no one, and only the admin reads them. Billing takes no price or owner from the browser, records the terms and renewal you agreed to before you pay, and an open payment dispute suspends a subscription rather than ending it. | Hosted phone | `hosted/server/tests/policy.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/billing-routes.test.ts` | +| **A Hosted account signs you in and grants nothing more.** Login never mints terminal access or stands in for pairing and presence; only an entitled account reaches managed voice, whose Worker neither logs nor stores the text it speaks. Hosted's own counts are daily totals that name no one, and only the admin reads them. Billing takes no price or owner from the browser, records the terms and renewal you agreed to before you pay, and an open payment dispute suspends a subscription rather than ending it. The public founders row shows only founders who chose to be shown, and their pictures come from Dormouse's servers, never the sign-in provider. | Hosted phone | `hosted/server/tests/policy.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/billing-routes.test.ts`, `website/src/lib/site-worker.test.ts` | - **FAIL IF** `S83` the consumer changes `authPolicy` away from explicit linking or multiple independent logins, or accepts an explicit connection callback after its initiating login was revoked; inspect `hosted/server/policy.ts` and the packed adapter. - **FAIL IF** `S84` an unused provider credential enables login, an unknown provider name is accepted, or incomplete enabled credentials silently degrade; inspect `providerBindings` in `hosted/server/policy.ts`. @@ -36,6 +36,7 @@ Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.t - **FAIL IF** `S285` a partial refund or an open dispute ends a subscription, an open dispute leaves its account entitled, a dispute's suspension follows the event's status rather than the one Stripe returns, a full refund or a lost dispute leaves the subscription its charge paid for current once a delivery answered 2xx, or ends any other subscription, the owner's current one included; inspect `processEvent`, `disputeAction`, `subscriptionPaidBy`, and `endForRefund` in `hosted/server/billing-routes.ts`. - **FAIL IF** `S286` checkout answers a Stripe URL without `autoRenew: true` and `terms` equal to `TERMS_VERSION`, for a checkout whose Price `publishedPrice` does not read as the request's `price`, or before its `dormouse_checkout_consents` row, naming the checkout, that version, the login, and the plan and that Price, has committed; or that table cascades from or is deleted with the account. Inspect `billingRoutes` in `hosted/server/billing-routes.ts` and `hosted/server/dormouse-migrations/016_checkout_consents.sql`. - **FAIL IF** `S287` a `dormouse_founders` row is inserted or renamed for an account `subscribedSql` does not hold for on the founding Prices, or for any but the cookie's login, or its name passes without `isFounderName` in the route and the table's CHECK refusing a Cc, Cf, Zl, or Zp character, padding, nothing, or more than 64 code points. Inspect the founder route in `hosted/server/billing-routes.ts`, `subscribedSql` in `hosted/server/entitlement.ts`, `isFounderName` in `hosted/server/policy-constants.ts`, and `hosted/server/dormouse-migrations/015_founders.sql`. +- **FAIL IF** `S288` `/api/hosted/*` resolves a login or reads a cookie; names an account id, or a founder without a `dormouse_founders` row, one `subscribedSql` does not hold for on the founding Prices, or past the first `MAX_SHOWN_FOUNDERS`; serves a picture for anyone not in that answer, from anything but an `https:` URL on `AVATAR_HOSTS` fetched without following a redirect or a raster `data:` URL, past `AVATAR_BYTES`, or typed by anything but its bytes as PNG, JPEG, GIF, or WebP; or the site's Worker forwards a request under `/api/hosted/` but a GET or HEAD, to any origin but the account's, with the reader's cookie, credentials, address, or query, or passes back a header but the type, length, and caching. Inspect `cohortRoutes` in `hosted/server/cohort-routes.ts` and `forwardHosted` in `website/public/_worker.js`. - **FAIL IF** `S279` a `dormouse_metrics_daily` row can hold anything but a UTC day, an event, a label on that event's `METRIC_LABELS` list, and a count; or `GET /api/admin/metrics` answers anyone but the verified `ADMIN_EMAIL` (401 without a login, 404 to any other), skips the cookie read gate, or answers more than those rows. Inspect `hosted/server/dormouse-migrations/013_metrics.sql`, `countMetric` in `hosted/server/metrics.ts`, `metricLabel` in `hosted/server/metric-labels.ts`, `cookieAdmin` in `hosted/server/account-gate.ts`, and `adminSql` in `hosted/server/entitlement.ts`. - **FAIL IF** `S86` Hosted account login mints a Burrow ACL grant or substitutes for the existing encrypted pairing/presence proof. The one-time rendezvous carries only handshake ciphertext and authorizes nothing; the ends' handshake and the laptop's confirmation do. diff --git a/docs/specs/website-docs.md b/docs/specs/website-docs.md index ac1af3cbd..2c12b45b9 100644 --- a/docs/specs/website-docs.md +++ b/docs/specs/website-docs.md @@ -159,7 +159,7 @@ dormouse.sh alone counts visits, with PostHog US Cloud (`docs/specs/security-loc - **Must load posthog-js only on `dormouse.sh`, only at the first event, and never under Global Privacy Control or Do Not Track.** - **Must run `cookieless_mode: "always"` and keep nothing in cookies, `localStorage`, `sessionStorage`, or IndexedDB**; the one write is posthog-js's `localStorage` check at init, a test value it removes at once. PostHog drops every event unless the project's Settings → Project → Web analytics → Enable cookieless tracking is on. - **Must send only** one `$pageview` per path, the `/hosted` one carrying the visit's `ref` (`docs/specs/pricing.md` -> "The Hosted page"); `hosted plan clicked` with its `plan`; and `tutorial completed` with its `tutorial`, sent only when this visit checks the last item. **Never enable autocapture, replay, heatmaps, surveys, exception capture, feature flags, `identify`, or a remote script.** -- **Must send to `dormouse.sh/ingest`**, never to a PostHog host. A Pages advanced-mode Worker limited to `/ingest/*` forwards to PostHog's US hosts without cookies or authorization, passing the visitor's address as `X-Forwarded-For`. +- **Must send to `dormouse.sh/ingest`**, never to a PostHog host. A Pages advanced-mode Worker limited to `/ingest/*` and the founders row's `/api/hosted/*` (`docs/specs/hosted.md` -> "Billing") forwards the first to PostHog's US hosts without cookies or authorization, passing the visitor's address as `X-Forwarded-For`. - **Must change Privacy's analytics paragraph in the same edit as anything above** (`website/src/pages/Privacy.tsx`). Source of truth: `website/src/lib/site-analytics.ts`; `website/public/_worker.js`. diff --git a/hosted/server/account-app.ts b/hosted/server/account-app.ts index 00782a1ff..699fd69d9 100644 --- a/hosted/server/account-app.ts +++ b/hosted/server/account-app.ts @@ -3,6 +3,7 @@ import { cookieAdmin, openAuthRead, ownPageRead } from "./account-gate"; import { billingSetup, resyncBilling } from "./billing"; import { billingRoutes, type BillingHost } from "./billing-routes"; import type { AccountEnv } from "./bindings"; +import { cohortRoutes } from "./cohort-routes"; import { accountRules } from "./headers"; import { ADMIN_METRICS_PATH } from "./metric-labels"; import { readMetrics } from "./metrics"; @@ -15,7 +16,7 @@ import { workerApp } from "./worker-app"; /** * The account Worker (`hosted.dormouse.sh`): auth, providers, readiness, * terms acceptance, Natural Voice on or off, the Relay's account routes, the - * admin's metrics, the frontend, and billing. The production and preview entries differ only in `fetchAuth`'s + * admin's metrics, the frontend, billing, and the founders row. The production and preview entries differ only in `fetchAuth`'s * mail and in `bindings`. */ export function accountApp( @@ -58,6 +59,7 @@ export function accountApp( voiceAccountRoutes(app, host); relayAccountRoutes(app, host); billingRoutes(app, host); + cohortRoutes(app, (c) => ({ databaseUrl: c.env.HYPERDRIVE.connectionString, setup: () => billingSetup(c.env) })); app.get(ADMIN_METRICS_PATH, cookieAdmin(host), async (c) => c.json(await readMetrics(c.env.HYPERDRIVE.connectionString)), ); diff --git a/hosted/server/billing.ts b/hosted/server/billing.ts index 3c62cc87c..0e533bef6 100644 --- a/hosted/server/billing.ts +++ b/hosted/server/billing.ts @@ -92,8 +92,11 @@ export function openCohort(gross: readonly number[], net: readonly number[]): Op return { cohort, seatsLeft: Math.max(0, FOUNDING_COHORT_SIZE - (net[cohort] ?? 0)) }; } -/** The open founding cohort, from the synchronized subscriptions. */ -export async function foundingCohort(billing: Billing, founding: readonly string[]) { +/** + * Founding purchases per ladder step, from the synchronized subscriptions: + * `gross` and `net` as {@link openCohort} reads them. + */ +export async function foundingPurchases(billing: Billing, founding: readonly string[]) { // A refund window of 0 counts every completed purchase, refunded or not. const [gross, net] = await Promise.all( [0, REFUND_DAYS].map(async (refundDays) => { @@ -101,7 +104,13 @@ export async function foundingCohort(billing: Billing, founding: readonly return founding.map((price) => counts[price] ?? 0); }), ); - return openCohort(gross!, net!); + return { gross: gross!, net: net! }; +} + +/** The open founding cohort, from the synchronized subscriptions. */ +export async function foundingCohort(billing: Billing, founding: readonly string[]) { + const { gross, net } = await foundingPurchases(billing, founding); + return openCohort(gross, net); } // The system clock: a test bundle's injected `Date` scopes it to the test's. diff --git a/hosted/server/cohort-routes.ts b/hosted/server/cohort-routes.ts new file mode 100644 index 000000000..7b3ad3988 --- /dev/null +++ b/hosted/server/cohort-routes.ts @@ -0,0 +1,201 @@ +// Rules: docs/specs/hosted.md -> "Billing"; docs/specs/pricing.md -> "The +// founding card's live half". +import type { Context, Hono } from "hono"; +import { queryDatabase } from "pgstencil/postgres"; +import { COHORT_ENDPOINT, MAX_SHOWN_FOUNDERS } from "../../website/src/lib/hosted-cohorts"; +import { foundingPurchases, openCohort, withBilling, type BillingSetup } from "./billing"; +import { subscribedSql } from "./entitlement"; +import { CHECKOUT_CLOSED } from "./policy-constants"; + +export { COHORT_ENDPOINT }; + +/** + * What the public routes may read of a deployment: never `auth`, so nothing + * here can resolve a login, whatever a request carries. + */ +export interface CohortHost { + databaseUrl: string; + setup(): BillingSetup | null; +} + +/** Where a shown founder's picture is served, under a token that is no id. */ +export const FOUNDER_AVATAR_PREFIX = "/api/hosted/founders/"; + +/** How long one isolate reuses an answer, and the pictures it names. */ +export const COHORT_CACHE_MS = 60_000; + +/** The largest picture served; a larger one draws the initial. */ +export const AVATAR_BYTES = 256 * 1024; + +const AVATAR_TIMEOUT_MS = 5_000; +const TOKEN = /^[0-9a-f]{64}$/; + +/** Sign-in providers' picture hosts, the only ones fetched. */ +const AVATAR_HOSTS = /^(?:avatars\.githubusercontent\.com|lh[3-6]\.googleusercontent\.com)$/; +/** Microsoft's sign-in stores the picture inline, as Better Auth fetched it. */ +const DATA_IMAGE = /^data:image\/(?:png|jpeg|gif|webp);base64,\s*([A-Za-z0-9+/]+={0,2})$/; + +/** + * Where a founder's picture comes from: a `data:` raster image, or an + * `https:` URL on a provider's picture host; null for anything else, which + * the row draws as an initial. + */ +export function avatarSource(image: string | null): string | null { + if (!image) return null; + if (image.startsWith("data:")) return DATA_IMAGE.test(image) ? image : null; + let url: URL; + try { + url = new URL(image); + } catch { + return null; + } + if (url.protocol !== "https:" || url.port || url.username || url.password || !AVATAR_HOSTS.test(url.hostname)) + return null; + // GitHub serves its full-size picture otherwise; the row draws 28 px. + if (url.hostname === "avatars.githubusercontent.com") url.searchParams.set("s", "96"); + return url.href; +} + +type ImageType = "image/png" | "image/jpeg" | "image/gif" | "image/webp"; + +/** A raster image's type from its leading bytes, or null: never a provider's word for it. */ +export function imageType(bytes: Uint8Array): ImageType | null { + const starts = (...prefix: number[]) => prefix.every((byte, i) => bytes[i] === byte); + if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png"; + if (starts(0xff, 0xd8, 0xff)) return "image/jpeg"; + if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif"; + if (starts(0x52, 0x49, 0x46, 0x46) && String.fromCharCode(...bytes.subarray(8, 12)) === "WEBP") return "image/webp"; + return null; +} + +/** `response`'s body, or null past `max` bytes. */ +async function readCapped(response: Response, max: number): Promise | null> { + const reader = response.body?.getReader(); + if (!reader) return new Uint8Array(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > max) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + const bytes = new Uint8Array(size); + let at = 0; + for (const chunk of chunks) { + bytes.set(chunk, at); + at += chunk.byteLength; + } + return bytes; +} + +interface Avatar { + bytes: Uint8Array; + type: ImageType; +} + +/** The picture `source` names, or null: a redirect, a non-200, too large, or no raster image. */ +async function loadAvatar(source: string): Promise { + let bytes: Uint8Array | null; + const inline = DATA_IMAGE.exec(source); + if (inline) { + bytes = Uint8Array.from(atob(inline[1]!), (char) => char.charCodeAt(0)); + if (bytes.byteLength > AVATAR_BYTES) return null; + } else { + const response = await fetch(source, { + headers: { accept: "image/*" }, + redirect: "manual", + signal: AbortSignal.timeout(AVATAR_TIMEOUT_MS), + }); + if (response.status !== 200) { + await response.body?.cancel(); + return null; + } + bytes = await readCapped(response, AVATAR_BYTES); + } + const type = bytes && imageType(bytes); + return type ? { bytes: bytes!, type } : null; +} + +/** One isolate's answer: the JSON, and the pictures it names by token. */ +interface Snapshot { + text: string; + avatars: Map }>; +} + +/** + * The open cohort, its seats left, and the founders row, read from the + * synchronized subscriptions and `dormouse_founders`. + */ +async function snapshot(host: CohortHost, setup: BillingSetup, origin: string): Promise { + const { gross, net } = await withBilling(setup, host.databaseUrl, origin, (billing) => + foundingPurchases(billing, setup.founding), + ); + const open = openCohort(gross, net); + // Shown only while opted in and the founding subscription still holds, so + // a refund or a lapse takes the founder out of the row. + const rows = await queryDatabase<{ name: string; image: string | null; token: string }>( + host.databaseUrl, + `SELECT f.name, u.image, encode(sha256(convert_to(f."userId", 'UTF8')), 'hex') AS token + FROM dormouse_founders f JOIN "user" u ON u.id = f."userId" + WHERE ${subscribedSql('f."userId"', "$1::text[]")} + ORDER BY f."shownSince", f."userId" LIMIT $2`, + [setup.founding, MAX_SHOWN_FOUNDERS], + ); + const avatars: Snapshot["avatars"] = new Map(); + const shown = rows.map(({ name, image, token }) => { + const source = avatarSource(image); + if (!source) return { name }; + avatars.set(token, { source }); + return { name, avatar: FOUNDER_AVATAR_PREFIX + token }; + }); + const body = { + ...(open && { cohort: open.cohort, seatsLeft: open.seatsLeft }), + founders: { total: net.reduce((sum, sold) => sum + sold, 0), shown }, + }; + return { text: JSON.stringify(body), avatars }; +} + +/** + * The Hosted page's founders row and seat counter, which the site's Worker + * forwards from `dormouse.sh/api/hosted/*`: public reads that resolve no + * login, each answer reused for {@link COHORT_CACHE_MS} in this isolate. + * Closed, both answer as billing's routes do. Call before any `/api/*` + * catch-all. + */ +export function cohortRoutes(app: Hono, host: (c: Context) => CohortHost, now = () => Date.now()) { + let cached: { at: number; value: Promise } | null = null; + const current = (c: Context, setup: BillingSetup) => { + if (!cached || now() - cached.at >= COHORT_CACHE_MS) { + const value = snapshot(host(c), setup, new URL(c.req.url).origin); + const entry = { at: now(), value }; + cached = entry; + // A failure is never reused: the next request tries again. + value.catch(() => { + if (cached === entry) cached = null; + }); + } + return cached.value; + }; + + app.get(COHORT_ENDPOINT, async (c) => { + const setup = host(c).setup(); + if (!setup) return c.json({ message: CHECKOUT_CLOSED }, 503); + return c.body((await current(c, setup)).text, 200, { "content-type": "application/json" }); + }); + + app.get(`${FOUNDER_AVATAR_PREFIX}:token`, async (c) => { + const setup = host(c).setup(); + const token = c.req.param("token"); + const entry = setup && TOKEN.test(token) ? (await current(c, setup)).avatars.get(token) : undefined; + if (!entry) return c.json({ message: "Not found." }, 404); + entry.loaded ??= loadAvatar(entry.source).catch(() => null); + const avatar = await entry.loaded; + if (!avatar) return c.json({ message: "Not found." }, 404); + return c.body(avatar.bytes, 200, { "content-type": avatar.type }); + }); +} diff --git a/hosted/server/tests/billing-routes.test.ts b/hosted/server/tests/billing-routes.test.ts index 7919048ca..bd592e52a 100644 --- a/hosted/server/tests/billing-routes.test.ts +++ b/hosted/server/tests/billing-routes.test.ts @@ -4,6 +4,7 @@ import { Hono } from "hono"; import { createTestContext } from "pgstencil/testing"; import { queryDatabase } from "pgstencil/postgres"; import { FOUNDING_COHORT_SIZE, FOUNDING_LADDER, LIST_ANNUAL, LIST_MONTHLY } from "../../../website/src/lib/hosted-pricing"; +import { MAX_SHOWN_FOUNDERS } from "../../../website/src/lib/hosted-cohorts"; import type { BillingSetup } from "../billing"; import { BILLING_WEBHOOK_PATH, @@ -17,6 +18,7 @@ import { billingRoutes, disputeAction, } from "../billing-routes"; +import { AVATAR_BYTES, COHORT_CACHE_MS, COHORT_ENDPOINT, FOUNDER_AVATAR_PREFIX, cohortRoutes } from "../cohort-routes"; import { entitledSql } from "../entitlement"; import { migrations } from "../migrations"; import { TERMS_VERSION } from "../policy-constants"; @@ -54,8 +56,17 @@ async function fixture({ closed = false } = {}) { }, setup: (): BillingSetup | null => (closed ? null : stripe.setup), })); + /** The isolate's clock the public routes cache by. */ + const clock = { now: 1_000_000 }; + cohortRoutes( + app, + () => ({ databaseUrl: context.database.url, setup: (): BillingSetup | null => (closed ? null : stripe.setup) }), + () => clock.now, + ); // The account Worker's `onError`. app.onError((_error, c) => c.json({ message: "unavailable" }, 503)); + /** A public read, as the site's Worker forwards it: no cookie, no browser headers. */ + const visit = (path: string) => app.request(origin + path, { headers: { accept: "*/*" } }); /** A signed-in account; `email: null` is a provider-only one. */ const account = async (email: string | null = `${randomUUID()}@example.test`, emailVerified = email !== null) => { @@ -146,6 +157,8 @@ async function fixture({ closed = false } = {}) { buy, metrics, entitledOn, + clock, + visit, close: async () => { await stripe.close(); await context.close(); @@ -629,3 +642,205 @@ test("a count that fails rolls back alone and is logged: the webhook still commi expect(error).toHaveBeenCalledWith(expect.stringContaining("Metric checkout.completed not recorded")); expect((await f.metrics()).filter((row) => row.startsWith("checkout.completed"))).toEqual([]); }); + +/** `count` paid founders at founding step `step`, started 40 days ago, straight into the synchronized rows. */ +const fillCohort = (f: Awaited>, step: number, count: number) => + f.sql( + `WITH owners AS ( + INSERT INTO pgstencil_billing.accounts (owner_id, email, created_at) + SELECT 'filler-' || $2::int || '-' || n, 'filler@example.test', now() FROM generate_series(1, $1::int) n + RETURNING owner_id) + INSERT INTO pgstencil_billing.subscriptions + (id, owner_id, price_id, status, started_at, period_end, cancel_at_period_end, updated_at) + SELECT 'sub_' || owner_id, owner_id, $3, 'active', now() - interval '40 days', + now() + interval '325 days', false, now() FROM owners`, + [count, step, f.setup.founding[step]], + ); + +test("the public founders row shows only opted-in founders still subscribed, with the open cohort's seats, cached a minute", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const cohorts = async () => { + const response = await f.visit(COHORT_ENDPOINT); + expect(response.status).toBe(200); + return response.json(); + }; + const later = () => (f.clock.now += COHORT_CACHE_MS); + expect(await cohorts()).toEqual({ cohort: 0, seatsLeft: FOUNDING_COHORT_SIZE, founders: { total: 0, shown: [] } }); + + const [ada, kim, lee, mo] = [await f.account(), await f.account(), await f.account(), await f.account()]; + for (const who of [ada, kim, lee]) await f.buy(who, "founding"); + await f.buy(mo, "yearly"); + await f.sql(`UPDATE "user" SET image = 'https://avatars.githubusercontent.com/u/1?v=4' WHERE id = $1`, [ada.id]); + // Kim joins first; Lee never ticks the box. + expect((await kim.request("/api/billing/founder", { method: "PUT", body: { shown: true, name: "Kim" } })).status).toBe(200); + expect((await ada.request("/api/billing/founder", { method: "PUT", body: { shown: true, name: "Ada" } })).status).toBe(200); + // One isolate reuses its answer for a minute. + expect(await cohorts()).toEqual({ cohort: 0, seatsLeft: FOUNDING_COHORT_SIZE, founders: { total: 0, shown: [] } }); + f.clock.now += COHORT_CACHE_MS - 1; + expect((await cohorts()).seatsLeft).toBe(FOUNDING_COHORT_SIZE); + f.clock.now += 1; + const [{ token }] = await f.sql<{ token: string }>( + `SELECT encode(sha256(convert_to($1::text, 'UTF8')), 'hex') AS token`, + [ada.id], + ); + const shown = await cohorts(); + expect(shown).toEqual({ + cohort: 0, + seatsLeft: FOUNDING_COHORT_SIZE - 3, + founders: { total: 3, shown: [{ name: "Kim" }, { name: "Ada", avatar: FOUNDER_AVATAR_PREFIX + token }] }, + }); + // The answer names no account. + expect(JSON.stringify(shown)).not.toContain(ada.id); + + // A founder whose subscription lapses leaves the row, though their row is + // kept; past the refund window, they still count. + await f.sql( + `UPDATE pgstencil_billing.subscriptions SET status = 'canceled', started_at = now() - interval '40 days', ended_at = now() + WHERE owner_id = $1`, + [kim.id], + ); + later(); + expect((await cohorts()).founders).toEqual({ + total: 3, + shown: [{ name: "Ada", avatar: FOUNDER_AVATAR_PREFIX + token }], + }); + + // A refund inside the window returns its seat and leaves the total. + const refunded = await f.account(); + await f.buy(refunded, "founding"); + await f.sql( + `UPDATE pgstencil_billing.subscriptions SET status = 'canceled', ended_at = started_at + interval '1 day' + WHERE owner_id = $1`, + [refunded.id], + ); + later(); + expect(await cohorts()).toMatchObject({ seatsLeft: FOUNDING_COHORT_SIZE - 3, founders: { total: 3 } }); + + // A filled cohort moves the seats to the next; past the last, founding is closed and only the founders remain. + await fillCohort(f, 0, FOUNDING_COHORT_SIZE - 3); + later(); + expect(await cohorts()).toMatchObject({ cohort: 1, seatsLeft: FOUNDING_COHORT_SIZE }); + for (let step = 1; step < FOUNDING_LADDER.length; step++) await fillCohort(f, step, FOUNDING_COHORT_SIZE); + later(); + expect(await cohorts()).toEqual({ + founders: { total: FOUNDING_LADDER.length * FOUNDING_COHORT_SIZE, shown: [{ name: "Ada", avatar: FOUNDER_AVATAR_PREFIX + token }] }, + }); +}); + +test("the public founders row shows at most MAX_SHOWN_FOUNDERS, the earliest first, and counts the rest", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const many = MAX_SHOWN_FOUNDERS + 2; + await fillCohort(f, 0, many); + await f.sql( + `INSERT INTO "user" (id, name, email, "emailVerified") + SELECT 'filler-0-' || n, 'F' || n, 'filler-' || n || '@example.test', true FROM generate_series(1, $1::int) n`, + [many], + ); + await f.sql( + `INSERT INTO dormouse_founders ("userId", name, "shownSince") + SELECT 'filler-0-' || n, 'F' || n, now() - make_interval(mins => $1::int - n) FROM generate_series(1, $1::int) n`, + [many], + ); + const { founders } = (await (await f.visit(COHORT_ENDPOINT)).json()) as { founders: { total: number; shown: { name: string }[] } }; + expect(founders.total).toBe(many); + expect(founders.shown.map(({ name }) => name)).toEqual(Array.from({ length: MAX_SHOWN_FOUNDERS }, (_, i) => `F${i + 1}`)); +}); + +test("a shown founder's picture is fetched from the provider's host and served as a checked raster image", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); + const JPEG = Uint8Array.from([0xff, 0xd8, 0xff, 0xe0, 4, 5]); + const provider = new Map Response>(); + const fetched: string[] = []; + const realFetch = globalThis.fetch; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + if (!url.startsWith("https://avatars.githubusercontent.com/") && !url.includes("googleusercontent.com")) + return realFetch(input, init); + fetched.push(url); + expect(init?.redirect).toBe("manual"); + return (provider.get(url) ?? (() => new Response("missing", { status: 404 })))(); + }); + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + + /** A founder shown under `name` whose sign-in picture is `image`; answers their avatar path, if any. */ + const founder = async (name: string, image: string | null) => { + const who = await f.account(); + await f.buy(who, "founding"); + await f.sql(`UPDATE "user" SET image = $2 WHERE id = $1`, [who.id, image]); + expect((await who.request("/api/billing/founder", { method: "PUT", body: { shown: true, name } })).status).toBe(200); + return who; + }; + const gh = "https://avatars.githubusercontent.com/u/1?v=4"; + provider.set("https://avatars.githubusercontent.com/u/1?v=4&s=96", () => new Response(PNG, { headers: { "content-type": "text/html" } })); + const big = new Uint8Array(AVATAR_BYTES + 1); + big.set(PNG); + provider.set("https://avatars.githubusercontent.com/u/2?s=96", () => new Response(big)); + provider.set("https://avatars.githubusercontent.com/u/3?s=96", () => new Response(new TextEncoder().encode(""))); + provider.set("https://avatars.githubusercontent.com/u/4?s=96", () => new Response(null, { status: 302, headers: { location: "https://evil.example/" } })); + provider.set("https://avatars.githubusercontent.com/u/6?s=96", () => new Response(PNG, { status: 500 })); + await founder("Png", gh); + await founder("Big", "https://avatars.githubusercontent.com/u/2"); + await founder("Svg", "https://avatars.githubusercontent.com/u/3"); + await founder("Moved", "https://avatars.githubusercontent.com/u/4"); + await founder("Inline", `data:image/jpeg;base64, ${btoa(String.fromCharCode(...JPEG))}`); + await founder("Failed", "https://avatars.githubusercontent.com/u/6"); + await founder("BigInline", `data:image/png;base64,${Buffer.from(big).toString("base64")}`); + // Never fetched: not a provider's picture host, not https, or not a raster data URL. + for (const [name, image] of [ + ["Elsewhere", "https://evil.example/a.png"], + ["Plain", "http://avatars.githubusercontent.com/u/5"], + ["Ported", "https://avatars.githubusercontent.com:8443/u/5"], + ["Vector", `data:image/svg+xml;base64,${btoa("")}`], + ["Code", null], + ] as const) + await founder(name, image); + + const { founders } = (await (await f.visit(COHORT_ENDPOINT)).json()) as { + founders: { shown: { name: string; avatar?: string }[] }; + }; + const avatar = Object.fromEntries(founders.shown.map(({ name, avatar }) => [name, avatar])); + expect(Object.keys(avatar)).toEqual([ + ...["Png", "Big", "Svg", "Moved", "Inline", "Failed", "BigInline"], + ...["Elsewhere", "Plain", "Ported", "Vector", "Code"], + ]); + for (const name of ["Elsewhere", "Plain", "Ported", "Vector", "Code"]) expect(avatar[name], name).toBeUndefined(); + + const png = await f.visit(avatar.Png!); + expect(png.status).toBe(200); + expect(png.headers.get("content-type")).toBe("image/png"); + expect(new Uint8Array(await png.arrayBuffer())).toEqual(PNG); + const inline = await f.visit(avatar.Inline!); + expect(inline.headers.get("content-type")).toBe("image/jpeg"); + expect(new Uint8Array(await inline.arrayBuffer())).toEqual(JPEG); + for (const name of ["Big", "Svg", "Moved", "Failed", "BigInline"]) + expect((await f.visit(avatar[name]!)).status, name).toBe(404); + // Fetched once per answer, however often it is asked for. + await f.visit(avatar.Png!); + expect(fetched.filter((url) => url.includes("/u/1")).length).toBe(1); + expect(fetched.every((url) => url.startsWith("https://avatars.githubusercontent.com/"))).toBe(true); + + // Only a shown founder's token: a founder who leaves, a made-up token, or a user id is not one. + for (const path of [FOUNDER_AVATAR_PREFIX + "0".repeat(64), FOUNDER_AVATAR_PREFIX + "x", FOUNDER_AVATAR_PREFIX]) + expect((await f.visit(path)).status, path).toBe(404); +}); + +test("closed, the public routes answer as billing's do", async ({ onTestFinished }) => { + const f = await fixture({ closed: true }); + onTestFinished(f.close); + const response = await f.visit(COHORT_ENDPOINT); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ message: CHECKOUT_CLOSED }); + expect((await f.visit(FOUNDER_AVATAR_PREFIX + "0".repeat(64))).status).toBe(404); +}); diff --git a/hosted/server/tests/boundary.test.ts b/hosted/server/tests/boundary.test.ts index 811157df9..73b15e464 100644 --- a/hosted/server/tests/boundary.test.ts +++ b/hosted/server/tests/boundary.test.ts @@ -34,6 +34,7 @@ import { createAuthApp } from "@pgstencil/auth/better-auth"; import { authPolicy } from "../policy"; import { prelaunchAuth } from "../prelaunch"; import { BILLING_WEBHOOK_PATH, CHECKOUT_CLOSED } from "../billing-routes"; +import { COHORT_ENDPOINT, FOUNDER_AVATAR_PREFIX } from "../cohort-routes"; import { RECENT_LOGIN_REQUIRED, relayAccountRoutes } from "../relay-account"; import type { RelayRoomRpc } from "../relay-room-contract"; import { voiceApp } from "../voice-app"; @@ -145,8 +146,10 @@ const ACCOUNT_RELAY: [string, string][] = [ ["DELETE", "/api/relay/passkeys/AAAA"], ]; -/** Billing's routes, which only the account serves. */ +/** Billing's routes and the public founders row's, which only the account serves. */ const ACCOUNT_BILLING: [string, string][] = [ + ["GET", COHORT_ENDPOINT], + ["GET", FOUNDER_AVATAR_PREFIX + "0".repeat(64)], ["GET", "/api/billing"], ["POST", "/api/billing/checkout"], ["POST", "/api/billing/confirm"], @@ -292,6 +295,10 @@ test("without Stripe bindings the account's webhook is closed before any databas expect([response.status, await response.json()]).toEqual([503, { message: CHECKOUT_CLOSED }]); expect(response.headers.get("content-security-policy")).toBe(ACCOUNT_POLICY); expect(outbound).toEqual([]); + // The founders row is closed with it, unread too. + const cohorts = await workers.account.dispatchFetch(ORIGINS.account + COHORT_ENDPOINT); + expect([cohorts.status, await cohorts.json()]).toEqual([503, { message: CHECKOUT_CLOSED }]); + expect(outbound).toEqual([]); }); test("the account answers /connect/ with its own shell and policy, never the phone page", async () => { diff --git a/scripts/security-rule-ids.json b/scripts/security-rule-ids.json index e51615e29..06325b075 100644 --- a/scripts/security-rule-ids.json +++ b/scripts/security-rule-ids.json @@ -1,5 +1,5 @@ { - "next": 288, + "next": 289, "retired": [ 265 ] diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 1e9559216..f7dbdf6ce 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -12,7 +12,7 @@ "docs/specs/dor-tools-builtin.md": 1750, "docs/specs/dor-tools-lib.md": 350, "docs/specs/glossary.md": 2950, - "docs/specs/hosted.md": 5650, + "docs/specs/hosted.md": 5700, "docs/specs/layout.md": 7250, "docs/specs/mobile-terminal-ui.md": 1250, "docs/specs/mouse-and-clipboard.md": 4200, @@ -26,7 +26,7 @@ "docs/specs/reopen.md": 1200, "docs/specs/security-audit.md": 5400, "docs/specs/security-ci.md": 3400, - "docs/specs/security-hosted.md": 3700, + "docs/specs/security-hosted.md": 3850, "docs/specs/security-local.md": 6400, "docs/specs/security-remote.md": 7200, "docs/specs/security-supply-chain.md": 1650, diff --git a/website/public/_routes.json b/website/public/_routes.json index 3085d6af4..c1b1a7411 100644 --- a/website/public/_routes.json +++ b/website/public/_routes.json @@ -1,5 +1,5 @@ { "version": 1, - "include": ["/ingest/*"], + "include": ["/ingest/*", "/api/hosted/*"], "exclude": [] } diff --git a/website/public/_worker.js b/website/public/_worker.js index 094fe00b7..ea753a331 100644 --- a/website/public/_worker.js +++ b/website/public/_worker.js @@ -1,15 +1,20 @@ /** - * The site's one server-side route: `/ingest/*`, the first-party path visit - * analytics send to, forwarded to PostHog's US Cloud. `_routes.json` beside it - * limits this Worker to that path, so every other request is the static site - * with `_redirects` as before (docs/specs/website-docs.md -> "Visit - * analytics"). + * The site's server-side routes, each a forward to a first-party path: + * + * - `/ingest/*`, where visit analytics send, to PostHog's US Cloud + * (docs/specs/website-docs.md -> "Visit analytics"). + * - `/api/hosted/*`, the Hosted page's founders row and seat counter, to the + * account Worker (docs/specs/hosted.md -> "Billing"), so a reader's browser + * only ever asks this origin. + * + * `_routes.json` beside it limits this Worker to those paths, so every other + * request is the static site with `_redirects` as before. * * Cloudflare Pages runs this file from the build output in advanced mode, so * it works whatever directory the Pages project builds from; it is plain - * JavaScript because Pages reads no `_worker.ts`. The shape follows PostHog's - * Cloudflare reverse-proxy guide: assets (`/static/`, `/array/`) from the - * assets host, everything else to the ingestion host, cookies and + * JavaScript because Pages reads no `_worker.ts`. The `/ingest` shape follows + * PostHog's Cloudflare reverse-proxy guide: assets (`/static/`, `/array/`) + * from the assets host, everything else to the ingestion host, cookies and * authorization dropped, and the visitor's address passed on as * `X-Forwarded-For`, without which every visitor would hash to Cloudflare's. */ @@ -18,6 +23,12 @@ export const INGEST_PREFIX = "/ingest"; const API_HOST = "us.i.posthog.com"; const ASSET_HOST = "us-assets.i.posthog.com"; +export const HOSTED_PREFIX = "/api/hosted/"; +/** The account Worker. Its own origin, so its 421 gate admits the request as any other. */ +export const HOSTED_ACCOUNT_ORIGIN = "https://hosted.dormouse.sh"; +/** All a Hosted answer keeps; a cookie or anything else the account sets stays there. */ +const HOSTED_RESPONSE_HEADERS = ["content-type", "content-length", "cache-control"]; + /** Where a request under `/ingest` goes upstream, or null for any other path. */ export function upstreamUrl(requestUrl) { const url = new URL(requestUrl); @@ -27,8 +38,31 @@ export function upstreamUrl(requestUrl) { return `https://${host}${path}${url.search}`; } +/** + * A read under `/api/hosted/`, forwarded with nothing of the reader's: no + * cookie, credential, address, or query reaches the account Worker, and only + * the body and its type come back. + */ +export async function forwardHosted(request) { + if (request.method !== "GET" && request.method !== "HEAD") + return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } }); + const { pathname } = new URL(request.url); + const upstream = await fetch(HOSTED_ACCOUNT_ORIGIN + pathname, { + method: request.method, + headers: { accept: request.headers.get("accept") ?? "*/*" }, + redirect: "manual", + }); + const headers = new Headers({ "x-content-type-options": "nosniff" }); + for (const name of HOSTED_RESPONSE_HEADERS) { + const value = upstream.headers.get(name); + if (value !== null) headers.set(name, value); + } + return new Response(upstream.body, { status: upstream.status, headers }); +} + export default { async fetch(request, env) { + if (new URL(request.url).pathname.startsWith(HOSTED_PREFIX)) return forwardHosted(request); const target = upstreamUrl(request.url); if (target === null) return env.ASSETS.fetch(request); const headers = new Headers(request.headers); diff --git a/website/src/lib/hosted-cohorts.ts b/website/src/lib/hosted-cohorts.ts index 9e6a64064..37af5f2c2 100644 --- a/website/src/lib/hosted-cohorts.ts +++ b/website/src/lib/hosted-cohorts.ts @@ -12,8 +12,12 @@ * renders without it rather than showing an error. The price beside them is * prerendered, so a provider outage costs the page nothing it sells. */ +import { FOUNDING_COHORTS_CLOSED } from "./hosted-pricing"; -/** Where both come from, in one request. Not deployed until checkout ships. */ +/** + * Where both come from, in one request: the site's Worker + * (`website/public/_worker.js`) forwards it to the account Worker. + */ export const COHORT_ENDPOINT = "/api/hosted/cohorts"; /** The most avatars the row draws; every other founder joins the `+N`. */ @@ -80,7 +84,11 @@ export async function fetchCohort(signal?: AbortSignal): Promise { const body: unknown = await response.json(); if (typeof body !== "object" || body === null) return { seatsLeft: null, founders: null }; const fields = body as Record; - return { seatsLeft: count(fields.seatsLeft) ?? null, founders: founders(fields.founders) }; + // Seats are for the cohort whose price this build prerendered: after a + // cohort closes and before the next deploy, they would sit beside the + // old price, so the counter waits for the deploy instead. + const seatsLeft = fields.cohort === FOUNDING_COHORTS_CLOSED ? (count(fields.seatsLeft) ?? null) : null; + return { seatsLeft, founders: founders(fields.founders) }; } catch { return { seatsLeft: null, founders: null }; } diff --git a/website/src/lib/hosted-pricing.ts b/website/src/lib/hosted-pricing.ts index ff91105ec..81ff5ab53 100644 --- a/website/src/lib/hosted-pricing.ts +++ b/website/src/lib/hosted-pricing.ts @@ -9,6 +9,7 @@ * * See docs/specs/pricing.md -> Published prices and -> The Hosted page. */ +import { HOSTED_REF_PARAM, type HostedRef } from "dormouse-lib/lib/hosted-links"; /** * List prices, which the founding ladder climbs toward and is struck against. @@ -51,7 +52,8 @@ export const FOUNDING_COHORTS_CLOSED = 0; /** One purchasable plan, at the price it is on sale at today. */ export type Tier = { - id: "monthly" | "annual" | "founding"; + /** The plan name checkout and the account's billing routes use. */ + id: "monthly" | "yearly" | "founding"; /** What the buy button and the checkout notice call it. */ name: string; /** What the buyer pays today, in whole US dollars. */ @@ -74,7 +76,7 @@ export const HOSTED_MONTHLY: Tier = { }; export const HOSTED_YEARLY: Tier = { - id: "annual", + id: "yearly", name: "Hosted yearly", price: LIST_ANNUAL, per: "/year", @@ -98,6 +100,24 @@ export function tiersOnSale(): Tier[] { return [HOSTED_MONTHLY, HOSTED_YEARLY, foundingTier()]; } +/** + * Whether the buy buttons sell. False until the account Worker's Stripe + * bindings are set: each button then opens a notice saying so. True, each + * links to {@link checkoutUrl} and the offers go `InStock`. + */ +export const CHECKOUT_OPEN: boolean = false; + +/** The account app's checkout, which signs the buyer in before it shows the plan. */ +export const CHECKOUT_PAGE = "https://hosted.dormouse.sh/checkout"; + +/** Where `tier`'s buy button goes once checkout is open, carrying the visit's `ref`. */ +export function checkoutUrl(tier: Tier, ref: HostedRef | "other" | null): string { + const url = new URL(CHECKOUT_PAGE); + url.searchParams.set("plan", tier.id); + if (ref) url.searchParams.set(HOSTED_REF_PARAM, ref); + return url.href; +} + /** * `Product` / `Offer` structured data for the paid plans, at the prices the * page prints. @@ -105,9 +125,9 @@ export function tiersOnSale(): Tier[] { * Prerendered with the rest of the prices so an assistant fetching the page * can quote them without running the counters. Availability is `PreOrder` * until checkout opens — the buttons explain what is left to build, so - * claiming `InStock` here would be the page's only false statement. + * claiming `InStock` there would be the page's only false statement. */ -export function pricingJsonLd(pageUrl: string): string { +export function pricingJsonLd(pageUrl: string, checkoutOpen: boolean): string { const data = { "@context": "https://schema.org", "@type": "Product", @@ -123,7 +143,7 @@ export function pricingJsonLd(pageUrl: string): string { price: String(tier.price), priceCurrency: "USD", url: `${pageUrl}#pricing`, - availability: "https://schema.org/PreOrder", + availability: `https://schema.org/${checkoutOpen ? "InStock" : "PreOrder"}`, priceSpecification: { "@type": "UnitPriceSpecification", price: String(tier.price), diff --git a/website/src/lib/site-analytics.test.ts b/website/src/lib/site-analytics.test.ts index c717c5fb7..4b8ee8bd3 100644 --- a/website/src/lib/site-analytics.test.ts +++ b/website/src/lib/site-analytics.test.ts @@ -61,7 +61,7 @@ describe("the loader", () => { const ph = fakePosthog(); const { trackPageview, trackEvent } = await fresh(); trackPageview("readme"); - trackEvent({ name: "hosted plan clicked", plan: "annual" }); + trackEvent({ name: "hosted plan clicked", plan: "yearly" }); await settle(); expect(ph.loaded).not.toHaveBeenCalled(); }); @@ -71,12 +71,12 @@ describe("the loader", () => { const { trackPageview, trackEvent } = await fresh(); expect(ph.loaded).not.toHaveBeenCalled(); trackPageview(null); - trackEvent({ name: "hosted plan clicked", plan: "annual" }); + trackEvent({ name: "hosted plan clicked", plan: "yearly" }); await settle(); expect(ph.loaded).toHaveBeenCalledTimes(1); expect(ph.capture.mock.calls).toEqual([ ["$pageview", undefined], - ["hosted plan clicked", { plan: "annual" }], + ["hosted plan clicked", { plan: "yearly" }], ]); }); }); diff --git a/website/src/lib/site-analytics.ts b/website/src/lib/site-analytics.ts index ada29eb8e..c49868dae 100644 --- a/website/src/lib/site-analytics.ts +++ b/website/src/lib/site-analytics.ts @@ -13,6 +13,7 @@ import type { PostHog, PostHogConfig } from "posthog-js/dist/module.slim.no-external.js"; import type { HostedRef } from "dormouse-lib/lib/hosted-links"; import type { TutorialState } from "./tutorial-state"; +import type { Tier } from "./hosted-pricing"; /** PostHog's public project key: public by design, it only lets a page send. */ export const POSTHOG_PROJECT_KEY = "phc_AxHDxNuQ9uGy5AmejhxcZz3vNUPwZY2WgmCgW6WgNkTU"; @@ -61,7 +62,7 @@ export type SiteRouteHandle = { ownPageview?: boolean }; /** The named events, and nothing else. */ export type SiteEvent = - | { name: "hosted plan clicked"; plan: "monthly" | "annual" | "founding" } + | { name: "hosted plan clicked"; plan: Tier["id"] } | { name: "tutorial completed"; tutorial: "desktop" | "pocket" }; type PrivacySignals = { globalPrivacyControl?: boolean; doNotTrack?: string | null }; diff --git a/website/src/lib/site-worker.test.ts b/website/src/lib/site-worker.test.ts new file mode 100644 index 000000000..8d1665e2b --- /dev/null +++ b/website/src/lib/site-worker.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CHECKOUT_PAGE } from "./hosted-pricing"; + +type SiteWorker = { + HOSTED_ACCOUNT_ORIGIN: string; + default: { fetch(request: Request, env: { ASSETS: { fetch(request: Request): Promise } }): Promise }; +}; +// Plain JavaScript Pages runs as is (`website/public/_worker.js`); a path +// held in a variable keeps the type checker from asking for its declarations. +const WORKER_PATH = "../../public/_worker.js"; +const worker = (await import(/* @vite-ignore */ WORKER_PATH)) as SiteWorker; + +const ASSETS = { fetch: vi.fn(async () => new Response("static")) }; + +afterEach(() => { + vi.unstubAllGlobals(); + ASSETS.fetch.mockClear(); +}); + +/** Sends `request` through the site's Worker with `fetch` answering `upstream`. */ +async function through(request: Request, upstream = new Response("{}")) { + const fetch = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => upstream); + vi.stubGlobal("fetch", fetch); + const response = await worker.default.fetch(request, { ASSETS }); + return { response, fetch }; +} + +describe("the site Worker's /api/hosted/ forward", () => { + it("goes to the account Worker the checkout links name", () => { + expect(worker.HOSTED_ACCOUNT_ORIGIN).toBe(new URL(CHECKOUT_PAGE).origin); + }); + + it("forwards a read's path with nothing of the reader's", async () => { + const { response, fetch } = await through( + new Request("https://dormouse.sh/api/hosted/cohorts?who=me", { + headers: { cookie: "a=b", authorization: "Bearer x", "cf-connecting-ip": "203.0.113.9", accept: "application/json" }, + }), + ); + expect(response.status).toBe(200); + expect(fetch).toHaveBeenCalledTimes(1); + const [url, init] = fetch.mock.calls[0]!; + expect(url).toBe("https://hosted.dormouse.sh/api/hosted/cohorts"); + expect(init?.method).toBe("GET"); + expect(init?.redirect).toBe("manual"); + expect([...new Headers(init?.headers).entries()]).toEqual([["accept", "application/json"]]); + expect(ASSETS.fetch).not.toHaveBeenCalled(); + }); + + it("brings back the body and its type, never a cookie", async () => { + const { response } = await through( + new Request("https://dormouse.sh/api/hosted/founders/ab"), + new Response("png", { + status: 404, + headers: { "content-type": "image/png", "cache-control": "no-store", "set-cookie": "s=1", "x-other": "1" }, + }), + ); + expect(response.status).toBe(404); + expect(await response.text()).toBe("png"); + expect(response.headers.get("content-type")).toBe("image/png"); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("set-cookie")).toBeNull(); + expect(response.headers.get("x-other")).toBeNull(); + }); + + it("refuses anything but a read", async () => { + for (const method of ["POST", "PUT", "DELETE"]) { + const { response, fetch } = await through(new Request("https://dormouse.sh/api/hosted/cohorts", { method, body: "{}" })); + expect(response.status, method).toBe(405); + expect(fetch).not.toHaveBeenCalled(); + } + }); + + it("leaves every other path to the static site", async () => { + const { response, fetch } = await through(new Request("https://dormouse.sh/api/other")); + expect(await response.text()).toBe("static"); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/website/src/pages/Hosted.test.tsx b/website/src/pages/Hosted.test.tsx index 4fda66289..b684c056a 100644 --- a/website/src/pages/Hosted.test.tsx +++ b/website/src/pages/Hosted.test.tsx @@ -11,15 +11,26 @@ vi.mock("dormouse-lib/site/DocsThemeControl", () => ({ default: () => null })); vi.mock("dormouse-lib/site/docs-paint", () => ({ useDocsThemedBody: () => {}, DOCS_HEADER_STYLE: {}, DOCS_PAINT_PROFILE: {} })); const analytics = vi.hoisted(() => ({ trackPageview: vi.fn(), trackEvent: vi.fn() })); vi.mock("../lib/site-analytics", () => analytics); +// `CHECKOUT_OPEN` is committed false; the open page is tested by flipping it here. +const checkout = vi.hoisted(() => ({ open: false })); +vi.mock("../lib/hosted-pricing", async (importOriginal) => ({ + ...(await importOriginal()), + get CHECKOUT_OPEN() { + return checkout.open; + }, +})); const { default: Hosted } = await import("./Hosted"); const { COHORT_ENDPOINT, MAX_SHOWN_FOUNDERS } = await import("../lib/hosted-cohorts"); const { + CHECKOUT_PAGE, + FOUNDING_COHORTS_CLOSED, FOUNDING_COHORT_SIZE, HOSTED_MONTHLY, HOSTED_YEARLY, LIST_ANNUAL, foundingTier, + pricingJsonLd, tiersOnSale, } = await import("../lib/hosted-pricing"); @@ -51,6 +62,7 @@ afterEach(() => { vi.unstubAllGlobals(); analytics.trackPageview.mockClear(); analytics.trackEvent.mockClear(); + checkout.open = false; }); describe("what the Hosted page prerenders", () => { @@ -121,10 +133,19 @@ describe("what the Hosted page prerenders", () => { expect(data["@type"]).toBe("Product"); expect(data.offers.map((offer: { name: string; price: string }) => [offer.name, offer.price])) .toEqual(tiersOnSale().map((tier) => [tier.name, String(tier.price)])); - // Nothing takes payment yet, so the one claim the page must not make is - // that these are in stock. + // Checkout is closed, so the one claim the page must not make is that + // these are in stock. for (const offer of data.offers) expect(offer.availability).toBe("https://schema.org/PreOrder"); }); + + it("puts the offers in stock once checkout opens", () => { + const offers = (open: boolean) => + JSON.parse(pricingJsonLd("https://dormouse.sh/hosted/", open)).offers.map( + (offer: { availability: string }) => offer.availability, + ); + expect(new Set(offers(true))).toEqual(new Set(["https://schema.org/InStock"])); + expect(new Set(offers(false))).toEqual(new Set(["https://schema.org/PreOrder"])); + }); }); describe("the Hosted card's billing toggle", () => { @@ -183,10 +204,22 @@ const mountCohort = (body: unknown) => describe("the founding card's live half", () => { it("fills in the seats left after hydration", async () => { - const el = await mountCohort({ seatsLeft: 73 }); + const el = await mountCohort({ cohort: FOUNDING_COHORTS_CLOSED, seatsLeft: 73 }); expect(el.textContent).toContain(`73 of ${FOUNDING_COHORT_SIZE} seats left at $${foundingTier().price}`); }); + it("shows no seats for a cohort other than the one whose price it prerendered", async () => { + // A cohort closed since this deploy: its seats would sit beside the old price. + for (const cohort of [FOUNDING_COHORTS_CLOSED + 1, undefined, String(FOUNDING_COHORTS_CLOSED)]) { + const el = await mountCohort({ cohort, seatsLeft: 73, founders: { total: 27, shown: [] } }); + expect(el.textContent, String(cohort)).not.toContain("seats left"); + expect(el.querySelector('[aria-label="27 founders so far"]'), String(cohort)).not.toBeNull(); + act(() => root?.unmount()); + container?.remove(); + root = null; + } + }); + it("draws opted-in founders, then +N for everyone else", async () => { const el = await mountCohort({ founders: { total: 12, shown: [{ name: "Ada", avatar: "/api/hosted/founders/ada.png" }, { name: "kim" }] }, @@ -276,6 +309,35 @@ describe("the buy buttons", () => { expect(analytics.trackEvent.mock.calls).toEqual([[{ name: "hosted plan clicked", plan: HOSTED_MONTHLY.id }]]); }); + it("link to the account's checkout once it opens, carrying the visit's ref", async () => { + checkout.open = true; + history.replaceState(null, "", "/hosted/?ref=settings-voice"); + const el = await mount(async () => new Response("{}", { status: 404 })); + const links = [...el.querySelectorAll('a[aria-label^="Buy "]')] as HTMLAnchorElement[]; + expect(buyButtons(el)).toHaveLength(0); + expect(links.map((a) => a.getAttribute("href"))).toEqual([ + `${CHECKOUT_PAGE}?plan=monthly&ref=settings-voice`, + `${CHECKOUT_PAGE}?plan=founding&ref=settings-voice`, + ]); + links[0]!.addEventListener("click", (event) => event.preventDefault()); + await act(async () => { + links[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(analytics.trackEvent.mock.calls).toEqual([[{ name: "hosted plan clicked", plan: "monthly" }]]); + }); + + it("link without a ref when the visit had none", async () => { + checkout.open = true; + history.replaceState(null, "", "/hosted/"); + const el = await mount(async () => new Response("{}", { status: 404 })); + const yearly = [...el.querySelectorAll("button")].find((b) => b.textContent === "Yearly")!; + await act(async () => { + yearly.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(el.querySelector('a[aria-label="Buy Hosted yearly"]')?.getAttribute("href")).toBe(`${CHECKOUT_PAGE}?plan=yearly`); + }); + it("close on Escape", async () => { await openFirst(); await act(async () => { diff --git a/website/src/pages/Hosted.tsx b/website/src/pages/Hosted.tsx index 92535dbd6..5c9da96b1 100644 --- a/website/src/pages/Hosted.tsx +++ b/website/src/pages/Hosted.tsx @@ -9,7 +9,7 @@ * * See docs/specs/pricing.md -> The Hosted page. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CheckIcon, CloudArrowUpIcon, @@ -44,18 +44,21 @@ import { type Founders, } from "../lib/hosted-cohorts"; import { + CHECKOUT_OPEN, FOUNDING_COHORT_SIZE, HOSTED_MONTHLY, HOSTED_YEARLY, LIST_ANNUAL, LIST_MONTHLY, YEARLY_SAVING, + checkoutUrl, foundingTier, pricingJsonLd, type Tier, } from "../lib/hosted-pricing"; import { canonicalUrl, siteMeta, sitePath } from "../lib/site-meta"; import { takeVisitRef } from "../lib/hosted-ref"; +import type { HostedRef } from "dormouse-lib/lib/hosted-links"; import { trackEvent, trackPageview, type SiteRouteHandle } from "../lib/site-analytics"; const PAGE_PATH = "/hosted"; @@ -162,15 +165,23 @@ function Price({ tier }: { tier: Pick }) { ); } +/** What a buy button does: counts the click, then goes to `href` or, without one, opens the notice. */ +type Buy = { onBuy: (tier: Tier) => void; href: (tier: Tier) => string | null }; + /** * "Buy" plus what it buys on the label a screen reader reads, since it hears - * the buttons out of their cards. + * the buttons out of their cards. A link once checkout is open. */ -function BuyButton({ tier, label, onBuy }: { tier: Tier; label: string; onBuy: (tier: Tier) => void }) { - return ( +function BuyButton({ tier, label, buy }: { tier: Tier; label: string; buy: Buy }) { + const href = buy.href(tier); + return href ? ( + buy.onBuy(tier)} aria-label={`Buy ${tier.name}`} className={ACTION_CLASS}> + {label} + + ) : (
- - + +

diff --git a/website/src/pages/Privacy.tsx b/website/src/pages/Privacy.tsx index f620f177b..28f9f6364 100644 --- a/website/src/pages/Privacy.tsx +++ b/website/src/pages/Privacy.tsx @@ -32,7 +32,7 @@ export const PRIVACY_SECTIONS: PolicySection[] = [

You buy Hosted subscriptions from DiffPlug LLC. We use Stripe Billing, Stripe-hosted Checkout, and Stripe’s customer portal to process payments and manage subscriptions. Stripe collects payment details, billing addresses, and applicable tax information. It processes information on our behalf and for its own purposes, such as fraud prevention and legal compliance, as described in Stripe’s privacy policy. Dormouse does not receive or store your full card number.

We receive subscription and billing information from Stripe: customer and subscription identifiers linked to your account, the billing email, the plan and price (including a founding cohort, if any), status, billing-period dates, cancellation status, billing-event records, whether a payment dispute is open, and which Dormouse link led to a purchase (such as the voice setting), which also travels to Stripe with the checkout. Stripe’s dashboard and billing records may also make your billing address, tax information, invoices, and limited payment-method details, such as card brand and last four digits, available to us for support, accounting, refunds, and disputes.

When you start a checkout, we record which plan and price it offered, which version of the terms you agreed to, that you agreed to automatic renewal, and when.

-

If you buy a founding plan and tick the box to appear in the founders row, we show the name you choose there and your profile picture on the Hosted page. The box starts unticked, and you can untick it at any time. We serve those pictures from our own site, so a visitor’s browser never asks your sign-in provider for them. Everyone else counts only toward the total.

+

If you buy a founding plan and tick the box to appear in the founders row, we show the name you choose there and, if your sign-in provider supplied one, your profile picture on the Hosted page. The box starts unticked, and you can untick it at any time. We fetch each picture from your provider and serve it from our own site, so a visitor’s browser never asks your sign-in provider for it. Everyone else counts only toward the total.

After checkout we may ask four optional questions about price. Nothing is sent unless you answer, and we use the answers only to set future prices.

}, { id: "voice", title: "Natural Voice", body: <>