Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions docs/specs/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ The account Worker bills through `@pgstencil/stripe` (`docs/specs/pricing.md` ->

| Route | Credential | Success |
|---|---|---|
| `GET /api/billing` | login cookie | 200 `{ plan, price, active, until, renews, entitled, suspended, founding, founder, consent, withdrawal, withdrawn }`; `plan` names a subscription Stripe still holds |
| `GET /api/billing` | login cookie | 200 `{ plan, price, active, until, renews, entitled, suspended, founding, founder, consent, withdrawal, withdrawn }`; `plan` names a subscription Stripe still holds; closed, 200 `{ closed: true }` |
| `POST /api/billing/checkout` | login cookie, exact `Origin`, JSON `{ plan, ref?, terms, autoRenew, price }` | 200 `{ url }` of Stripe Checkout |
| `POST /api/billing/confirm` | login cookie, exact `Origin`, JSON `{ checkout }` | 200, the `GET` body |
| `POST /api/billing/portal` | login cookie, exact `Origin` | 200 `{ url }` of the customer portal |
Expand All @@ -92,25 +92,25 @@ The account Worker bills through `@pgstencil/stripe` (`docs/specs/pricing.md` ->

Errors are JSON `{ message }`. The cookie routes need no entitlement, answer 401 without a login, and cap a body at 4 KiB.

- **Must keep billing closed without all five bindings**: the secrets `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, and the vars `STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, and `STRIPE_PRICES_FOUNDING` (cohort order); closed, every route past the login gate but leaving the founders row answers 503 `CHECKOUT_CLOSED` and the resync reads nothing. **Must refuse a founding list whose length differs from `FOUNDING_LADDER`**, or a malformed or repeated Price. Live mode follows the key's prefix.
- **Must keep billing closed without all five bindings**: the secrets `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, and the vars `STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, and `STRIPE_PRICES_FOUNDING` (cohort order); closed, every cookie route past the login gate but `GET` and leaving the founders row answers 409 `CHECKOUT_CLOSED`, the webhook and the cohort read 503 `CHECKOUT_CLOSED`, and the resync reads nothing. **Must refuse a founding list whose length differs from `FOUNDING_LADDER`**, or a malformed or repeated Price. Live mode follows the key's prefix.
- **Never offer a trial or Managed Payments; must enable Stripe Tax on every Checkout.**
- **Never take a Price, customer, owner, quantity, or return URL from a request**: checkout takes a plan name and a `ref`, normalized as `checkoutRef` does; the owner is the login, its email only once verified (else Checkout asks), and Stripe returns to `/billing`. A checkout open for another plan is expired first; a held subscription refuses another with 409.
- **Must start a checkout only after agreement to `TERMS_VERSION` and to automatic renewal at the `price` the page showed, recorded in `dormouse_checkout_consents` before its URL is answered**; another version answers 409 `TERMS_CHANGED`, and a checkout whose Price `publishedPrice` does not read as `price` is expired and answers 409 `PRICE_CHANGED`. `GET` shows the latest while a subscription is held.
- **Must start a checkout only after agreement to `TERMS_VERSION` and to automatic renewal at the `price` the page showed, recorded in `dormouse_checkout_consents` before its URL is answered**; another version answers 409 `TERMS_CHANGED`, and a checkout whose Price `priceCatalog` does not price at `price` is expired and answers 409 `PRICE_CHANGED`. `GET` shows the latest while a subscription is held.
- **Must add or rename a founders-row name (`dormouse_founders`) only while `subscribedSql` holds on the founding Prices** (else 409) and `isFounderName` accepts it; leaving always works, a rename keeps `shownSince`. Checks: `docs/specs/security-hosted.md` -> "Account boundary".
- **Must verify the webhook's signature over the raw body before any write** and answer 2xx only once `webhook()` has committed, any other failure 503, which Stripe retries.
- **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 offer the online withdrawal only from the held subscription while `withdrawalOffer` holds**, once per subscription; else withdraw answers 409 `WITHDRAWAL_CLOSED`. A recorded withdrawal (`dormouse_withdrawals`) is then acknowledged by email to the Stripe customer's address and settled: its subscription ended at once (`ENDED_FOR_WITHDRAWAL`), every paid invoice payment refunded in full. Each step is stamped once done and safe to repeat; the cron retries a failed one for 30 days. Checks: `docs/specs/security-hosted.md` -> "Account boundary".
- **Must offer the online withdrawal only from the held subscription while `withdrawalOffer` holds**, once per subscription; else withdraw answers 409 `WITHDRAWAL_CLOSED`. A withdrawal (`dormouse_withdrawals`) is recorded with its acknowledgement, a `withdrawal` notice, then settled: its subscription ended at once (`ENDED_FOR_WITHDRAWAL`), every paid invoice payment refunded in full, stamped once done and safe to repeat; the cron retries a failed settlement for 30 days. Checks: `docs/specs/security-hosted.md` -> "Account boundary".
- **Must suspend, never end, on an open dispute**, by its current status read from Stripe, not the event (`dormouse_billing_disputes`, which outlives the account); closing in the account's favor restores it.
- **Must offer founding at the last ladder step bought at while its purchases net of those ended within `REFUND_DAYS` are short of `FOUNDING_COHORT_SIZE`, else the next**, counting seats left the same way; an earlier step never reopens. 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, finish a bounded batch of unfinished withdrawals and closure refunds, reread the least recently checked open disputes from Stripe as their webhook would, and run the notices ("Billing notices"); a failure fails the invocation after every one was tried.
- **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, finish a bounded batch of unsettled withdrawals and closure refunds, reread the least recently checked open disputes from Stripe as their webhook would, and run the notices ("Billing notices"); a failure fails the invocation after every one 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`; `withdrawalOffer` and `completeWithdrawal` in `hosted/server/withdrawal.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.
Source of truth: `billingSetup`, `withBilling`, and `resyncBilling` in `hosted/server/billing.ts`; `billingRoutes` and `disputeAction` in `hosted/server/billing-routes.ts`; `withdrawalOffer` and `recordWithdrawal` in `hosted/server/withdrawal.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.

## Billing notices

Each notice is a `dormouse_billing_notices` row, recorded before it is sent and stamped once Postmark accepts it, one per kind and renewal or dispute.
Each notice is a `dormouse_billing_notices` row, recorded before it is sent and stamped once Postmark accepts it, one per kind and renewal, dispute, or withdrawal.

| Kind | Due | Still sent while |
|---|---|---|
Expand All @@ -119,13 +119,14 @@ Each notice is a `dormouse_billing_notices` row, recorded before it is sent and
| `price_change` | 21 days before the first renewal on or after the change's `effectiveAt` | the same, on the old Price, over 8 days ahead |
| `dispute_suspended` | a dispute opens a suspension | the dispute is open, for 30 days |
| `dispute_restored` | it closes in the account's favor, no other open | 30 days |
| `withdrawal` (its acknowledgement) | a withdrawal is recorded, in the same statement | 30 days |

- **Must send every notice to the Stripe customer's email**, never the sign-in email, so a provider-only account gets them.
- **Must record a dispute's notice in the webhook's transaction** and send it after the commit; a failed send never fails the event.
- **A price change is an operator's `dormouse_price_changes` row** (`hosted/README.md`); both its Prices grant the plan once inserted. **Never act on one naming a founding Price or another plan's**, or whose Prices Stripe does not hold at its amounts, in USD, at the plan's interval.
- **Must move a subscription to the new Price, without proration, only after its notice was sent at least 7 days before the renewal it names**, while Stripe still renews it then on the old Price; a later notice waits for the next renewal. Checks: `docs/specs/security-hosted.md` -> "Account boundary".

Source of truth: `runNotices` and `sendNotices` in `hosted/server/notices.ts`; `recordDispute` in `hosted/server/billing-routes.ts`; `hosted/server/dormouse-migrations/018_billing_notices.sql`.
Source of truth: `runNotices` and `sendNotices` in `hosted/server/notices.ts`; `recordDispute` in `hosted/server/billing-routes.ts`; `hosted/server/dormouse-migrations/018_billing_notices.sql` and `hosted/server/dormouse-migrations/020_withdrawal_notices.sql`.

## Account closure

Expand All @@ -135,7 +136,7 @@ Source of truth: `runNotices` and `sendNotices` in `hosted/server/notices.ts`; `

Errors are JSON `{ message }`; it needs no entitlement and works with billing closed. What the page says first: `docs/specs/pricing.md` -> "Checkout and entitlement".

- **Must refuse a login older than `LOGIN_FRESH_AGE_MS`** with 403 `CLOSE_RECENT_LOGIN`, and, billing closed, an account with a current subscription row with 503 `CLOSE_UNAVAILABLE`.
- **Must refuse a login older than `LOGIN_FRESH_AGE_MS`** with 403 `CLOSE_RECENT_LOGIN`, and, billing closed, an account with a current subscription row with 409 `CLOSE_UNAVAILABLE`.
- **Must expire an open checkout, then cancel every current subscription in Stripe (`ENDED_FOR_CLOSURE`), before one statement records the closure (`dormouse_account_closures`) and deletes the `"user"` row**; rows without a foreign key stay. Its Burrows' sockets then close as a removal's do ("Relay sockets"). Checks: `docs/specs/security-hosted.md` -> "Account boundary".
- **Must then refund what closing owes** (`docs/specs/pricing.md` -> "Checkout and entitlement"): a first payment while `purchaseStanding` counts the end a refund, a yearly or founding renewal paid within `REFUND_DAYS` of it. The closure is stamped settled once done; the cron retries for 30 days.

Expand Down
4 changes: 2 additions & 2 deletions docs/specs/security-hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.t
- **FAIL IF** `S283` a billing route takes a Stripe Price, customer, owner, quantity, or success or return URL from the request, acts for any owner but the cookie's login, passes Stripe an email the login has not verified, or sends a `ref` or counts a label `checkoutRef` did not produce; inspect `billingRoutes` in `hosted/server/billing-routes.ts`.
- **FAIL IF** `S284` the webhook writes before `Stripe-Signature` verifies over the raw body, reads a body past `WEBHOOK_BODY_BYTES`, answers 2xx before `webhook()` has committed, accepts an event of the other mode, another API version, or a Connect account, counts a metric outside its transaction, so a redelivery counts twice, or lets a failed count fail or roll back the event; inspect `processEvent` in `hosted/server/billing-routes.ts`.
- **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** `S286` checkout answers a Stripe URL without `autoRenew: true` and `terms` equal to `TERMS_VERSION`, for a checkout whose Price `priceCatalog` does not price at 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** `S289` withdraw answers 2xx before its `dormouse_withdrawals` row, naming the login, the subscription, and the submission time, has committed; records one for a subscription that is not the login's held one or outside `withdrawalOffer`; refuses the online withdrawal within `WITHDRAWAL_OPEN_DAYS` to a held subscription whose Stripe billing country is in `WITHDRAWAL_COUNTRIES` or unknown; marks it acknowledged before its email was sent or settled before its subscription ended and every paid invoice payment was refunded in full; lets a failed step go unretried by the cron; or that table cascades from or is deleted with the account. Inspect the withdraw route in `hosted/server/billing-routes.ts`, `hosted/server/withdrawal.ts`, `resyncBilling` in `hosted/server/billing.ts`, and `hosted/server/dormouse-migrations/017_withdrawals_and_closures.sql`.
- **FAIL IF** `S289` withdraw answers 2xx before its `dormouse_withdrawals` row, naming the login, the subscription, and the submission time, has committed; records one for a subscription that is not the login's held one or outside `withdrawalOffer`; refuses the online withdrawal within `WITHDRAWAL_OPEN_DAYS` to a held subscription whose Stripe billing country is in `WITHDRAWAL_COUNTRIES` or unknown; records it without its `withdrawal` notice in the same statement; stamps that notice sent before its email was, or the withdrawal settled before its subscription ended and every paid invoice payment was refunded in full; lets a failed step go unretried by the cron; or that table cascades from or is deleted with the account. Inspect the withdraw route in `hosted/server/billing-routes.ts`, `hosted/server/withdrawal.ts`, `sendNotices` in `hosted/server/notices.ts`, `resyncBilling` in `hosted/server/billing.ts`, `hosted/server/dormouse-migrations/017_withdrawals_and_closures.sql`, and `hosted/server/dormouse-migrations/020_withdrawal_notices.sql`.
- **FAIL IF** `S290` account closure deletes the `"user"` row while any subscription of its owner Stripe holds is current and not yet canceled in Stripe, or with billing closed while a current subscription row exists; acts for any but the cookie's login or one older than `LOGIN_FRESH_AGE_MS`; stamps a closure settled before every payment `closureRefunds` accepts was refunded in full, or leaves a failed refund to no cron; or deletes or cascades the billing records, checkout consents, withdrawals, open disputes, or its closure record. Inspect `hosted/server/closure.ts`, `resyncBilling` in `hosted/server/billing.ts`, `hosted/server/dormouse-migrations/017_withdrawals_and_closures.sql`, and `hosted/server/dormouse-migrations/019_billing_records_outlive_closure.sql`.
- **FAIL IF** `S291` the cron moves a subscription to a `dormouse_price_changes` row's new Price before a `dormouse_billing_notices` row records that change's notice, naming the renewal it takes effect at, as sent to the Stripe customer's email at least `PRICE_NOTICE_MIN_DAYS` before that renewal; moves one off a founding Price, with proration, or after Stripe stopped renewing it then on the old Price; sends a notice to any other address; or that table cascades from or is deleted with the account. Inspect `runNotices` and `sendNotices` in `hosted/server/notices.ts`, `withBilling` in `hosted/server/billing.ts`, and `hosted/server/dormouse-migrations/018_billing_notices.sql`.
- **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`.
Expand Down
Loading
Loading