diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 3a05004..941c695 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,172 +1,64 @@ # Deploying Otta -How to stand up a working Otta store from a fresh clone: the commerce service plus a -storefront site, in either of the two supported shapes. Architecture background lives in +How to stand up a working Otta store from a fresh clone. Architecture background lives in [`README.md`](./README.md); design decisions in [`adr/`](./adr/). This guide is -self-contained — section references like "§4" point inside this file. +self-contained — section references like "§2" point inside this file. --- ## 0. What you are deploying -Otta is **two deployables and two databases**: - -1. **The commerce service** (`@otta-sh/service`) — a Hono REST API that owns all money and - stock truth. As a Node bin (`dist/index.mjs` post-publish; run via tsx from a checkout - today — see §2.2) it needs a **Postgres** database and migrates itself forward on boot. - On Workers (Shape B) it is not a separate deployable: commerce runs **in-process inside - the site Worker** — see item 2. -2. **The storefront site** (`sites/staging`) — an EmDash CMS site with the Otta plugin - registered trusted in-process, talking to commerce in-process. It needs a **content - database of its own** (D1 on Workers), entirely separate from the commerce Postgres. - `sites/staging` is the reference site: copy it for your own store rather than treating it - as staging-only. - -> **Status honesty.** The commerce **service** is feature-complete (Phases 0–7, per the root -> README): catalog, inventory, cart, checkout, orders, customers with magic-link auth, -> Stripe + x402 payments, tax, shipping, discounts, entitlements, reporting, and settings. -> The reference **storefront** deliberately covers **catalog + cart only**. Two page surfaces -> are not built yet: the checkout/payment/download pages (issue #27) and the customer -> account/login pages (a parallel follow-up scoped in the site package's README — no issue -> yet). Deploying today gives you a browsable catalog and carts with real inventory holds; -> completing a purchase end-to-end means building the #27 surface or driving the service API -> directly. When #27 and the account-pages task close, this banner shrinks to a version note. - -| | Shape A | Shape B | -|---|---|---| -| Service runtime | Node process (§2.2) | in-process, inside the site Worker | -| Commerce DB | any Postgres you can reach | external Postgres via Hyperdrive | -| Site runtime | EmDash on Node (link-out, §2.5) | `sites/staging` on Workers **free** plan | -| Sweeps | self-intervals + one external driver (§2.4) | `*/15` cron runs all four (§6) | -| Starts at | §2 | §3 | +Otta is **one deployable and one database**: the storefront site (`sites/staging`) — an +EmDash CMS site with the Otta plugin registered trusted, running commerce **in-process** +inside the same Worker. There is no separate commerce service and no second database: +commerce truth lives in the host's per-plugin document store on the site's own D1 database, +alongside CMS content ([ADR-0018](./adr/0018-plugin-owns-commerce-truth-in-process.md), +[ADR-0019](./adr/0019-commerce-aggregates-are-one-document-each.md), +[ADR-0020](./adr/0020-one-deployable-plugin-owns-commerce-truth.md)). + +`sites/staging` is the reference site: copy it for your own store rather than treating it as +staging-only. + +> **Status honesty.** The commerce layer is feature-complete: catalog, inventory, cart, +> checkout, orders, customers with magic-link auth, Stripe + x402 payments, tax, shipping, +> discounts, entitlements, reporting, and settings. The reference **storefront** +> deliberately covers **catalog + cart only**. Two page surfaces are not built yet: the +> checkout/payment/download pages (issue #27) and the customer account/login pages (a +> parallel follow-up scoped in the site package's README — no issue yet). Deploying today +> gives you a browsable catalog and carts with real inventory holds; completing a purchase +> end-to-end means building the #27 surface. When #27 and the account-pages task close, this +> banner shrinks to a version note. ## 1. Universal contracts -Three rules hold in every shape. Everything else in this guide is a consequence of them. +Three rules hold. Everything else in this guide is a consequence of them. - **Deploy-then-claim.** A freshly deployed site is unclaimed: **the first visitor to complete the setup wizard becomes the admin.** Claim it immediately after the first request, in the same session. The wizard's passkey step requires a WebAuthn **secure - context** — HTTPS, or `localhost` (see §2.5 and §3.2). If the unclaimed window worries + context** — HTTPS, or `localhost` (see §2.2). If the unclaimed window worries you, front `/_emdash/*` with Cloudflare Access until setup is claimed, then remove it. - **Seed reality.** The site's first request runs the CMS migrations and applies the seed's **schema, settings, and menus only**. Sample content (the 3 demo products) is applied **only** when the setup wizard is completed with "include sample content" checked. An empty `/products` page right after first boot is **healthy, not a failed boot**. -- **Secrets model.** Every payment and token secret lives **commerce-side** (§4) — the - standalone Node process on Shape A, or the site Worker itself on Shape B, where commerce - runs in-process. The site carries exactly one secret of its own: `EMDASH_ENCRYPTION_KEY`. - Nothing secret-shaped ever goes in a tracked `wrangler.jsonc` (pinned by the site's config - tests). - -## 2. Shape A — Node + Postgres - -The service as a plain Node process against any Postgres you can reach. There is no -Node-hosted site in this repo — §2.5 covers your options for the storefront half. - -### 2.0 Network posture - -The Node bin listens on `PORT` (default 3000) on **all interfaces — it has no bind-address -knob**: the entry calls `serve({ fetch, port })` with no hostname parameter, and there is no -`HOST` env var — do not go looking for one. Issue #43 tracks adding it; once it closes, bind -to loopback directly and this paragraph becomes one line. Until then, keep the service off -the public network by external means: an OS firewall, a private network / VPC, or a -loopback-mapped container port (e.g. `-p 127.0.0.1:3000:3000`). - -Expose nothing publicly until you enable Stripe; then expose **only** `POST -/webhooks/stripe` through a reverse-proxy path allowlist. Anything more exposes the write -surface described in §4 — which you close by provisioning `SERVICE_API_TOKEN` on both -sides (§4). Until that token is set the write surface is open: - -> **Posture:** while `SERVICE_API_TOKEN` is unset, every mutating route is unauthenticated -> (§4). Treat a publicly reachable service whose gate is still open as non-production — -> test-mode payment credentials only, never live-mode Stripe keys on an open write surface. -> Provisioning the token on both sides (§4) closes the gate and lifts this restriction. - -### 2.1 Provision Postgres - -Managed or self-hosted both work — the suite runs against Postgres 16 in CI; older -versions are untested. Pooled vs direct: the service runs its own -`pg` pool and a Kysely migrator that use **prepared statements**, so give it a **direct -connection or a session-mode pooler**. A transaction-mode pooler (e.g. PgBouncer in -transaction mode) breaks prepared statements and will fail in confusing ways. Size the pool -conservatively; the database is the scaling arbiter (§6). - -### 2.2 Run the service - -The `@otta-sh/*` packages are not published yet, and inside the workspace their export maps -point at TypeScript sources — so from a checkout, run the Node entry with a TS-executing -runner rather than the built `dist/index.mjs` (that file is the entry for a future -published install; plain `node` cannot resolve its workspace imports today — issue #44; -when it closes, this step becomes `node dist/index.mjs`). From the repo root: - -```bash -pnpm install -PG_CONNECTION_STRING=postgres://USER:PASSWORD@YOUR-DB-HOST:5432/YOUR-DB-NAME \ - pnpm dlx tsx@4 packages/service/src/index.ts -``` - -`PG_CONNECTION_STRING` is required — the entry throws at startup without it. Migrations run -automatically before the server starts listening (forward-only, idempotent). Smoke it: - -```bash -curl http://127.0.0.1:3000/health -# {"ok":true} -``` - -### 2.3 Configure - -All configuration is environment variables — see the reference table in §5 and the secrets -checklist in §4. The Node bin self-schedules two maintenance intervals out of the box: a -hold sweep (every 60s, `HOLD_SWEEP_INTERVAL_MS`) and an email-outbox drain + login-challenge -prune (every 30s, `EMAIL_DISPATCH_INTERVAL_MS`). Which brings us to the gap: - -### 2.4 The order-expiry sweep gap - -> **Caveat — issue #28.** The Node bin's self-intervals run hold sweeps, email dispatch, and -> login-challenge pruning — **order expiry is the one missing sweep**. Hold correctness does -> not depend on the timer (expiry is also lazy-on-read), but order expiry is clock-driven, -> so on Shape A you must drive it externally until #28 lands: set `INTERNAL_API_TOKEN` (§4) -> and run this on a schedule (cron, systemd timer — every 5–15 minutes is fine): -> -> ```bash -> curl -X POST -H "X-Internal-Token: $INTERNAL_API_TOKEN" \ -> http://127.0.0.1:3000/internal/expire-orders -> ``` -> -> Fixed end-state: #28 adds the order-expiry leg to the Node self-interval; when it closes, -> delete the external cron and this box. - -### 2.5 A site alongside a Node service - -> **No site in this repo talks to the Node service over HTTP.** The plugin runs commerce -> **in-process**, and the mode plumbing that used to let it call a service instead — the -> `COMMERCE_SERVICE_URL` build-time variable and its bundle define — is gone. `sites/staging` -> reads no service URL at build time; setting one changes nothing. The Node service of §2.2 -> is for **API consumers you write yourself**, not for pointing a storefront at. +- **Secrets model.** There is one deployable, so there is one place secrets can live — and + two stores inside it (§3). Two are **Worker secrets** (`wrangler secret put`): + `EMDASH_ENCRYPTION_KEY` and `OTTA_WH_TOKEN`. Every payment and email **credential** is + provisioned by the operator in the admin console's **Settings** page and held in + **write-only plugin `kv`** under `settings:*` — persisted only on a non-empty submit, + never rendered back into a block, read through a fail-closed reader. Nothing + secret-shaped ever goes in a tracked `wrangler.jsonc` (pinned by the site's config tests, + which reject any `vars` key matching `/SECRET|KEY|TOKEN|PASSWORD/i`). -So there is one site option, plus one rule that applies to it: - -- **Run an EmDash site on Node.** Follow EmDash's upstream Node deployment guide - (`deployment/nodejs.mdx` in the [EmDash repo](https://github.com/emdash-cms/emdash)) and - apply the Otta deltas from `sites/staging`: register the plugin trusted via a descriptor - (ADR-0006) and port the theme pages + `/cart/*` cookie-shim endpoints. Commerce then runs - in-process inside that Node site, against its own configured store — nothing to point at - the §2.2 service. No Node-adapter site exists in this repo; this path is - link-out-plus-deltas, not a tested recipe. -- And whatever you front it with, **put HTTPS in front of the site before first boot**: the setup wizard's - passkey step needs a WebAuthn secure context, which workers.dev gives you automatically - but bare Node does not — terminate TLS first (the one exception: `localhost` is a secure - context, so claiming over an SSH tunnel at `http://localhost` works). - -## 3. Shape B — Cloudflare Workers (free tier) +## 2. Cloudflare Workers (free tier) The site as a Worker, with commerce running in-process inside it. This shape is deploy-verified and is what `sites/staging` is built for. -### 3.0 Cost preconditions +### 2.0 Cost preconditions -The free-tier claim rests on three deliberate choices — undo any of them and you are on a +The free-tier claim rests on two deliberate choices — undo either of them and you are on a paid plan: - **The plugin runs trusted in-process** — no `worker_loaders` binding. Worker Loaders (the @@ -176,11 +68,10 @@ paid plan: - **No Cloudflare Images or Stream.** Media lives in R2; the site uses Astro's built-in image service (the config deliberately does not set `imageService: "cloudflare"` — that is the paid resizing product). -- **The service cron is `*/15`**, not every minute, so a serverless Postgres origin (e.g. - Neon's free tier) can autosuspend between ticks. The site's every-minute cron touches only - D1, within free limits. -### 3.1 The site Worker +The site's single `* * * * *` cron touches only D1, within free limits (§5). + +### 2.1 The site Worker 1. **Create the content resources** (from `sites/staging`): @@ -193,7 +84,7 @@ paid plan: 2. **Fill in the local config.** Copy `sites/staging/wrangler.jsonc` (also a template) to `wrangler.local.jsonc` (gitignored) and set your Worker `name` (over `my-otta-store`), D1 `database_name`/`database_id`, and R2 `bucket_name`. Leave the - `global_fetch_strictly_public` compatibility flag alone — §3.4 explains it. + `global_fetch_strictly_public` compatibility flag alone — §2.4 explains it. 3. **Set the site's one secret** (the only secret first boot needs): @@ -228,7 +119,7 @@ paid plan: rebuild** — step 4 owns the build, so your Worker name, D1, and R2 bindings are never silently the tracked template's placeholders. -### 3.2 First boot and claim +### 2.2 First boot and claim 1. **Hit the site once** — `https://..workers.dev/`. The first request runs the CMS migrations and applies the seed's schema/settings/menus (one-time @@ -239,14 +130,14 @@ paid plan: complete setup becomes the admin — do not deploy and walk away. workers.dev is HTTPS, so the passkey step's secure-context requirement (§1) is already met. 3. **Smoke:** `/products` renders the sample catalog (or the friendly empty state); create - and publish a product in the admin and watch the service log the sync upsert; price it - in the admin's **Pricing & inventory** page (the CMS holds no commercial data); + and publish a product in the admin and watch `wrangler tail` log the sync upsert; price + it in the admin's **Pricing & inventory** page (the CMS holds no commercial data); add-to-cart sets the `otta_cart` cookie and creates a hold. The three sample products are content-only until you price them — the seed fires no content hooks, so either price them in Pricing & inventory or run `sites/staging/scripts/seed-demo-commerce.ts` against the SITE. It drives the site's own admin API — the route the Pricing & - inventory page uses — so it needs no service URL and no service token; only the site - URL and a token that can read the CMS and call that route: + inventory page uses — so it needs only the site URL and a token that can read the CMS + and call that route: ```bash SITE_URL=https://.workers.dev \ @@ -259,110 +150,94 @@ paid plan: 4. **`wrangler tail`** (from `sites/staging`) — first boot should be clean: migrations + schema seed, no errors. -### 3.3 Failed-first-boot recovery +### 2.3 Failed-first-boot recovery **Only for an actual failed boot** — errors in `wrangler tail` (migration failures, partial -schema seed). An empty `/products` catalog is NOT a failed boot (§3.2 step 1); never reset a +schema seed). An empty `/products` catalog is NOT a failed boot (§2.2 step 1); never reset a healthy database. The seed applies only to an **empty** D1 database, so a midway failure cannot be retried in place: 1. `wrangler d1 delete YOUR-D1-DATABASE-NAME` and `wrangler d1 create YOUR-D1-DATABASE-NAME`. 2. Update `database_id` in your `wrangler.local.jsonc` with the new id. -3. **Rebuild** (the wrangler config is read at build time — §3.1 step 4), redeploy, then - claim the admin again (§3.1 step 5 → §3.2). - -### 3.4 workers.dev networking — the #1 footgun - -> **Why the site ships `global_fetch_strictly_public`.** Cloudflare blocks -> Worker→`*.workers.dev` subrequests and **stubs them with a 404** that never leaves -> Cloudflare (deploy-verified: parallel `wrangler tail`s showed the request never reached -> the service; direct curl worked). The site's `wrangler.jsonc` therefore carries the -> `global_fetch_strictly_public` compatibility flag, which is what lets its `ctx.http` -> calls reach a service Worker on workers.dev. -> -> **Pairing invariant:** that flag silently breaks the D1 Sessions API — its internal -> routing request is blocked and **every SSR request hangs with nothing in the logs** — so -> `d1()` in the site config must keep `session` **off** while the flag is present. Both -> halves are pinned by tests: `sites/staging/test/site-config.test.ts` (session stays off, -> placeholder equality) and `sites/staging/test/wrangler-config.test.ts` (flag presence, -> template hygiene). Do not "fix" one side without the other. +3. **Rebuild** (the wrangler config is read at build time — §2.1 step 4), redeploy, then + claim the admin again (§2.1 step 5 → §2.2). + +### 2.4 The `global_fetch_strictly_public` pairing invariant + +> The site's `wrangler.jsonc` carries the `global_fetch_strictly_public` compatibility flag. +> That flag silently breaks the D1 Sessions API — its internal routing request is blocked and +> **every SSR request hangs with nothing in the logs** — so `d1()` in the site config must +> keep `session` **off** while the flag is present. Both halves are pinned by tests: +> `sites/staging/test/site-config.test.ts` (session stays off, placeholder equality) and +> `sites/staging/test/wrangler-config.test.ts` (flag presence, template hygiene). Do not +> "fix" one side without the other. > -> Fixed end-state — issue #32: a **custom domain on the commerce service** (custom domains -> are not subject to the workers.dev subrequest block) lets the site drop the flag and -> re-enable `session: "auto"`, and deletes this box. A custom domain is also what unlocks -> zone-level WAF rules (§4). +> A **custom domain** on the site (issue #32) is what unlocks zone-level WAF rules (§3). -## 4. Secrets & tokens checklist +## 3. Secrets & tokens checklist -All of these live on **commerce** (Node env vars on Shape A, `wrangler secret put` on the -site Worker on Shape B, where commerce runs in-process) except the first (site only). On -Workers, **every `wrangler secret put` below** needs `--config wrangler.local.jsonc`: -without it, wrangler defaults to the tracked template and uploads the secret to the -placeholder-named Worker, not yours. In order of appearance in a deployment's life: +Two of these are **Worker secrets** on the site (`wrangler secret put`); the rest are +**plugin credentials** the operator types into the admin console's **Settings** page, which +persists them to write-only plugin `kv` under `settings:*`. On Workers, **every `wrangler +secret put` below** needs `--config wrangler.local.jsonc`: without it, wrangler defaults to +the tracked template and uploads the secret to the placeholder-named Worker, not yours. In +order of appearance in a deployment's life: -| Secret | Deployable | Required? | When to set | +| Secret | Where it lives | Required? | When to set | |---|---|---|---| -| `EMDASH_ENCRYPTION_KEY` | site | yes | before the site's first boot | -| `INTERNAL_API_TOKEN` | commerce | Shape A: yes (§2.4). Shape B: **not used** — nothing reads it there | any time | -| `SERVICE_API_TOKEN` | commerce | to close the write gate | any time | -| `STRIPE_WEBHOOK_SECRET` | commerce | for Stripe payments | before enabling Stripe | -| `STRIPE_SECRET_KEY` | commerce | to take **real** payments (and to refund) | with the webhook secret | -| `X402_PAYTO` + `X402_FACILITATOR_SECRET` | commerce | for x402 (non-production only today) | see fail-closed box | -| `EMAIL_API_KEY` (with `EMAIL_API_URL` / `EMAIL_FROM` vars) | commerce | optional | when wiring real email | +| `EMDASH_ENCRYPTION_KEY` | Worker secret | yes | before the site's first boot | +| `OTTA_WH_TOKEN` | Worker secret **+** admin Settings (same value, both halves) | optional outer gate on the settle routes | with the Stripe webhook secret | +| Stripe webhook signing secret | admin Settings (`settings:stripeWebhookSecret`) | for Stripe payments | before enabling Stripe | +| Stripe secret key | admin Settings (`settings:stripeSecretKey`) | **not yet consumed** — stored, but no live payment path reads it (see below) | when you want it in place ahead of that wiring | +| x402 pay-to + facilitator credential | admin Settings | for x402 | see the x402 box | +| Email API key (with the `EMAIL_API_URL` / `EMAIL_FROM` build-time values) | admin Settings | optional | when wiring real email | - **`EMDASH_ENCRYPTION_KEY`** — generate with `npx emdash secrets generate`; never committed, never echoed into logs; **back it up in a password manager** (it protects the CMS's encrypted data — losing it strands that data). -> **`SERVICE_API_TOKEN` — the write gate ([ADR-0007](./adr/0007-dedicated-service-token-header.md)).** +> **The Stripe webhook endpoint is public by design, and permanently site-owned.** +> Stripe delivers to `POST /webhooks/stripe` on the site (`sites/staging/src/pages/webhooks/stripe.ts`) +> — register **that** path in the Stripe dashboard. It is a transport shim: it reads the raw +> delivered bytes, never parses them, attaches the edge token, and dispatches the plugin's +> **public** `webhooks/stripe/settle` route in-process, replaying the status the plugin asks +> for so Stripe's retry behaviour stays correct. It holds no Stripe secret and verifies no +> signature itself. > -> When set, every non-GET/HEAD request to commerce's HTTP API must carry the token in the -> dedicated **`X-Service-Token`** header — *not* `Authorization: Bearer`, which is the -> customer session credential. This only matters for callers that reach commerce over HTTP -> directly: the storefront plugin no longer does — it talks to commerce in-process, with no -> HTTP hop and nothing to provision on its side. +> **The trust anchor is the Stripe HMAC**, verified unconditionally inside the plugin route +> against `settings:stripeWebhookSecret` — never switchable off by any token. A webhook is +> always unauthenticated, and an anonymous request only ever reaches the host's *public* +> plugin-route dispatcher, so the route being public is structural, not a relaxation. > -> **While unset the write surface is open:** every mutating route is unauthenticated — cart -> creation and line writes, `POST /checkout/orders`, `/inventory/*` mutations, entitlement -> grants — so on a publicly reachable URL anyone who finds it can create orders and burn -> inventory holds. The Worker entry logs a warning once per isolate when the gate is open; -> **the Node entry is silent** — issue #42 tracks warning parity. Provision the token before -> exposing commerce's HTTP API publicly (§2.0). -> -> **Interplay with `INTERNAL_API_TOKEN`:** routes behind both gates (e.g. `PUT /settings`, -> the `/admin/*` writes) require **both** headers when both secrets are set. - -- **`INTERNAL_API_TOKEN`** — the shared secret for the standalone service's operational - surface, and **only** that: it is read by `@otta-sh/service`'s entries, so it means - something on Shape A and nothing on Shape B, where no commerce HTTP API is served at all. - Unset, those - endpoints answer **503** (disabled — never silently open): `POST /internal/expire-holds`, - `POST /internal/expire-orders`, `POST /internal/dispatch-emails`, and — **reads - included** (ADR-0010) — the entire `/admin/*`, `/reports/*` and `/settings` surface. That - means the `/admin/*` order transition and rules CRUD, the rules **GET** reads (shipping - zones/methods/rates, tax classes/rates, coupon lookup by code), and **both** verbs on - `/settings`. `SERVICE_API_TOKEN`'s write gate exempts GET/HEAD, so this token is the only - thing that closes those reads. One caller sends it as - `X-Internal-Token`: your §2.4 cron on Shape A. The plugin's admin console never did and - now structurally cannot — it reads and writes commerce in-process, with no HTTP hop and - nothing to provision on its side (INC-D3a deleted the `settings:internalToken` field and - kv key along with the rest of the service plumbing). The Worker cron path needs no token - either (it calls the domain directly, §6). -- **Stripe** — `STRIPE_WEBHOOK_SECRET` wires the Stripe gateway; until set, - `POST /webhooks/stripe` answers 503. The webhook URL is **public by design**: it is the - single exemption from the `X-Service-Token` write gate, authenticated instead by - `Stripe-Signature` HMAC over the raw body (Stripe cannot carry our token). - **`STRIPE_SECRET_KEY` decides whether checkout can actually be paid.** With it, - `createIntent` performs a real `POST /v1/payment_intents` — the buyer gets a LIVE client - secret, `metadata[order_id]` carries the settlement key the webhook is matched on, and the - checkout `Idempotency-Key` travels as Stripe's native one — and refunds become available. - **Without it**, `createIntent` mints an OFFLINE deterministic handle (`pi_` plus a - fake client secret that no Stripe.js/Elements can ever pay) and the service logs a loud - boot warning (`STRIPE_WEBHOOK_SECRET is set but STRIPE_SECRET_KEY is NOT …`). That stays a - warning, never a boot failure: staging and e2e run offline on purpose. A live-intent - failure (Stripe down or rejecting) answers **502 `PAYMENT_INTENT_FAILED`**; the `pending` - order row is kept deliberately — retrying with the same `Idempotency-Key` re-issues the - *same* PaymentIntent, and `expire-orders` sweeps the order at the checkout TTL (releasing +> **`OTTA_WH_TOKEN` is the cheap outer gate** in front of that anchor: it lets the public +> route refuse an *unattributed* request before it reads another kv key, builds a gateway or +> opens a store. Provision the same value on both halves — `wrangler secret put +> OTTA_WH_TOKEN` on the site and the matching field in admin Settings. Unset on the plugin +> side, the gate **passes through** (degrading to "cryptographic anchor only", never to +> "nothing works" and never to "nothing is checked"); set on the plugin side but unset on +> the site, **every delivery 401s** — that is the dangerous direction, and the reason the +> endpoint replays the 401 into Stripe's dashboard rather than swallowing it. + +- **Stripe** — until the webhook signing secret is set, the settle route answers + `NOT_CONFIGURED`. That secret is the one Stripe credential this build actually consumes: it + is read by the settle route, which constructs the gateway with the **webhook secret only** + (`packages/plugin/src/webhooks/stripe-settle-route.ts`). + + **The Stripe secret key is latent infrastructure, not a live switch.** Settings persists it + to write-only `kv` and `@otta-sh/payments-stripe` knows what to do with it — given one, + `createIntent` performs a real `POST /v1/payment_intents` (LIVE client secret, + `metadata[order_id]` as the settlement key the webhook is matched on, the checkout + `Idempotency-Key` travelling as Stripe's native one) and `refund` becomes possible — but + **no call site in this tree constructs the gateway with it**, so today every deployment is + on the OFFLINE path regardless of what you store: `createIntent` mints a deterministic + handle (`pi_` plus a client secret no Stripe.js/Elements can ever pay). Setting the + key is therefore harmless and forward-looking; it does not by itself make checkout payable + or refunds available. Wiring it into the in-process gateway composition — the way + `wireX402Gateway` does for x402 — is the outstanding work, and it is named as an open caveat + in [ADR-0020](./adr/0020-one-deployable-plugin-owns-commerce-truth.md) §2. Once that lands, + a live-intent failure (Stripe down or rejecting) answers **502 `PAYMENT_INTENT_FAILED`** and + the `pending` order is kept deliberately — retrying with the same `Idempotency-Key` re-issues + the *same* PaymentIntent, and the order-expiry sweep reaps it at the checkout TTL (releasing stock and any coupon use) if it never gets paid. > **Live Stripe is TWO-DECIMAL currencies only.** Otta stores money as integer minor units @@ -371,84 +246,82 @@ placeholder-named Worker, not yours. In order of appearance in a deployment's li > MGA, PYG, RWF, UGX, VUV, XAF, XOF, XPF) that would charge the buyer **100×**, and for > **three-decimal** ones (BHD, JOD, KWD, OMR, TND) it is the mirror error — so the live > `createIntent` **refuses them before any network call**, answering 502 -> `PAYMENT_INTENT_FAILED` (provider code `unsupported_currency` in the service log). Do not -> price a catalog in those currencies against a secret-key-configured deployment; the -> offline (no-secret-key) path is unaffected. Lifting this needs an exponent-aware money -> boundary, not an adapter tweak — the deny-list is `STRIPE_UNSUPPORTED_CURRENCIES` in +> `PAYMENT_INTENT_FAILED` (provider code `unsupported_currency`). Do not price a catalog in +> those currencies against a secret-key-configured deployment; the offline (no-secret-key) +> path is unaffected. Lifting this needs an exponent-aware money boundary, not an adapter +> tweak — the deny-list is `STRIPE_UNSUPPORTED_CURRENCIES` in > `packages/payments-stripe/src/index.ts`. -> **x402 is fail-closed.** The only facilitator the service can currently wire is the -> **offline TEST facilitator** — a shared-secret HMAC check, not real x402 verification: -> any holder of `X402_FACILITATOR_SECRET` can forge a settling proof. Setting `X402_PAYTO` -> + `X402_FACILITATOR_SECRET` without the explicit `X402_ALLOW_TEST_FACILITATOR=true` -> opt-in **refuses to start** (a thrown error, never a silently-armed gateway), and the -> opt-in path warns loudly at startup. **Never set `X402_ALLOW_TEST_FACILITATOR=true` in -> production.** `X402_ACCEPTS` (optional, default `eip155:8453`) is the comma-separated -> accepted-networks list. Fixed end-state: a real facilitator client behind the -> `X402PaymentGateway` seam retires the opt-in gate and this box. +> **x402 settles against a real facilitator over `ctx.http`.** The configured facilitator +> credential goes **on the wire** as `Authorization: Bearer …` to the facilitator host, so +> provision a credential that was minted to be sent. The facilitator host must be in the +> plugin's `allowedHosts` — it is seeded at **build** time from the site's Astro config, not +> from `kv`, so changing facilitators is a rebuild, not a settings edit. The pay-to address +> and the accepted-networks list (default `eip155:8453`) are configuration, not credentials, +> and live alongside it in Settings. -- **Email** — with `EMAIL_API_URL` unset the service uses the console sender: emails are - **logged, not delivered** (visible in `wrangler tail` on Workers). Set `EMAIL_API_URL` + - `EMAIL_API_KEY` + `EMAIL_FROM` for a real HTTP email provider, and `STOREFRONT_BASE_URL` - so magic-link login emails carry a clickable URL (unset, they carry raw challenge - credentials only). +- **Email** — with no email API URL configured the console sender is used: emails are + **logged, not delivered** (visible in `wrangler tail`). The API URL and From address are + build-time values (the URL also seeds `allowedHosts`); the API key is a Settings + credential. Set the storefront base URL so magic-link login emails carry a clickable URL + (unset, they carry raw challenge credentials only). -## 5. Environment variable reference +## 4. Egress and `allowedHosts` -Node bin and Worker read the **same names by design** — on Workers, plain vars go in -`vars`, secrets via `wrangler secret put`. "Entry" says who reads it. +The plugin's only egress is `ctx.http.fetch`, gated by the descriptor's `allowedHosts` +allowlist (capability `network:request`). That allowlist is resolved at **build** time +(`packages/plugin/src/manifest.ts`, fed by `sites/staging/astro.config.ts`) and contains: -| Variable | Entry | Default | What it does | -|---|---|---|---| -| `PG_CONNECTION_STRING` | Node only | — (boot throws) | Postgres DSN. Workers use the `HYPERDRIVE` binding instead — no DSN secret on Workers | -| `PORT` | Node only | `3000` | listen port (no bind-address knob — issue #43, §2.0) | -| `CART_HOLD_TTL_MS` | both | `900000` (15 min) | cart-hold **and** checkout TTL (one knob drives both); must parse as a positive number or boot/first-request fails | -| `HOLD_SWEEP_INTERVAL_MS` | Node only | `60000` | self-interval hold-sweep cadence | -| `EMAIL_DISPATCH_INTERVAL_MS` | Node only | `30000` | self-interval outbox-drain + challenge-prune cadence | -| `INTERNAL_API_TOKEN` | service entries only | unset ⇒ operational surface 503s | §4 — the site Worker reads it nowhere | -| `SERVICE_API_TOKEN` | both | unset ⇒ write surface **open** | §4 — provision it to close the gate | -| `STRIPE_WEBHOOK_SECRET` | both | unset ⇒ webhook 503, gateway unwired | §4 | -| `STRIPE_SECRET_KEY` | both | unset ⇒ **offline, unpayable** intents + no refunds (boot warns) | §4 — set it to create real PaymentIntents | -| `X402_PAYTO` | both | unset ⇒ x402 not configured | x402 pay-to address | -| `X402_FACILITATOR_SECRET` | both | unset ⇒ x402 not configured | test-facilitator HMAC secret (§4) | -| `X402_ACCEPTS` | both | `eip155:8453` | comma-separated x402 accepted networks | -| `X402_ALLOW_TEST_FACILITATOR` | both | unset ⇒ x402 config **refuses to start** | must be `true` to arm the TEST facilitator — never in production (§4) | -| `EMAIL_API_URL` | both | unset ⇒ console sender (log-only) | HTTP email API endpoint | -| `EMAIL_API_KEY` | both | unset | email API key | -| `EMAIL_FROM` | both | `no-reply@otta.local` | From address | -| `STOREFRONT_BASE_URL` | both | unset ⇒ magic-link emails carry raw credentials, no URL | absolute base URL for login links | -| `EMDASH_ENCRYPTION_KEY` | site, secret | — | §4 | - -## 6. Operations & scaling - -**Cron cadences.** On Workers the service's `*/15` cron is the janitor for four jobs: the -hold sweep (a bound on dead-hold lifetime — hold expiry is also lazy-on-read, so correctness -never depends on the timer), **order expiry** (clock-driven, so this cron *is* its -production driver on Workers), the order-email outbox drain, and the login-challenge prune. -The Node bin runs the email/prune pair every 30s; at the Worker's 15-minute tick an -order-status email can lag up to one tick — `POST /internal/dispatch-emails` is the -on-demand lever. The cadence stays `*/15` so a serverless Postgres origin can autosuspend -between ticks (§3.0). The **site's** cron is `* * * * *` — EmDash's scheduled publishing is -minute-granular and the free-plan D1 limits are unaffected; it may be relaxed (e.g. -`*/5 * * * *`) if cron noise ever matters more than publish latency. - -**Scaling.** The app tier is stateless and scales horizontally: the Worker builds -per-event `pg` pools (`max: 5` — Hyperdrive owns the real origin pool) and N Node replicas -behind a load balancer work the same way; the sweeps are idempotent (guarded flips, -atomic claims), so N replicas racing the same sweep never double-release or double-send; -every command carries an idempotency key the store enforces once-only. The arbiter of all -stock and money truth is the **single Postgres** — that is the scaling ceiling, and scaling -reads/writes past it is a database decision, not an app-tier one. - -## 7. Troubleshooting +| Host | When | +|---|---| +| `api.stripe.com` | always — the one constant entry | +| the email API host | when an email API URL is configured | +| the x402 facilitator host | when a facilitator URL is configured | + +One caveat on the first row: `@otta-sh/payments-stripe` defaults its transport to +`globalThis.fetch` rather than `ctx.http.fetch`, unlike the email sender and the x402 +facilitator client — so `api.stripe.com` is allowlisted *in advance* of being perimeter- +enforced. It costs nothing today (no call site constructs the Stripe gateway with a live +transport — §3), but whoever wires that gateway must pass `ctx.http.fetch` or the allowlist +will not be the perimeter for Stripe traffic. Recorded as an open caveat in +[ADR-0020](./adr/0020-one-deployable-plugin-owns-commerce-truth.md) §2. + +Because it is build-time, adding a provider means a rebuild and redeploy — a Settings edit +alone cannot widen it. That is deliberate: the allowlist is the perimeter, and an operator +editing a text field should not be able to move it. + +## 5. Operations & scaling + +**Cron.** Two cadences, and they do different jobs. The **site's** Cron Trigger is +`* * * * *` — that drives the host's cron *executor*, which claims due rows from its own +task table. The **plugin** registers one task, `commerce-sweeps`, due every `*/15`; the +executor fires the plugin's `cron` hook when it comes due. One task drives all nine sweep +legs: they share a store composition and a clock, and splitting them would only put nine +rows in contention on the same documents. + +Every leg is **idempotent** and runs in its own try/catch with its own label, so a leg that +throws cannot starve the eight beside it; a tick always returns a summary, and each leg logs +one line on success and one `console.error` on failure (visible in `wrangler tail`). Per +[ADR-0019](./adr/0019-commerce-aggregates-are-one-document-each.md), these sweepers are not +an optimization — a coupling that spans two aggregates is made idempotently completable +rather than transactional, so **a missing sweeper is a correctness bug**. The site's cron +may be relaxed (e.g. `*/5 * * * *`) if cron noise ever matters more than publish latency, +but relaxing it past the task's own `*/15` delays every sweep. + +**Scaling.** Commerce truth is one document per aggregate in the site's D1 database, written +by compare-and-set; every command carries an idempotency key the store enforces once-only, +and the sweeps are idempotent, so concurrent isolates racing the same sweep never +double-release or double-send. A hot aggregate therefore retries rather than blocking: the +contention budget is a measured number recorded in ADR-0019, not a hope. The scaling ceiling +is that single D1 database. + +## 6. Troubleshooting | Symptom | Cause → fix | |---|---| -| Every SSR request hangs, nothing in logs | `global_fetch_strictly_public` + D1 `session` both on — pairing invariant violated (§3.4); turn `session` off | -| The standalone service's `/internal/*`, `/admin/*`, `/reports/*`, `/settings` answer 503 — **reads too**, e.g. `GET /admin/tax/classes`, `GET /settings` | `INTERNAL_API_TOKEN` unset — set it and send `X-Internal-Token` (§4). Since ADR-0010 the admin **read** surface is gated too, so a deployment that never set this now 503s where it previously answered 200. Shape A only: the plugin's Shipping/Tax/Coupons/Settings screens read in-process and are never affected by this token | +| Every SSR request hangs, nothing in logs | `global_fetch_strictly_public` + D1 `session` both on — pairing invariant violated (§2.4); turn `session` off | | `/products` empty right after deploy | Healthy (§1) — sample content lands via the wizard checkbox, not first boot | -| Stale reads after writes (Shape B) | Hyperdrive query caching left on — recreate the config with `--caching-disabled` | -| `POST /webhooks/stripe` answers 503 | `STRIPE_WEBHOOK_SECRET` unset (§4) | -| Node bin exits: `PG_CONNECTION_STRING is required` | Set the DSN (§2.2) | -| Worker 500s: `Missing Hyperdrive connection string` | `hyperdrive` binding absent or misconfigured — check the binding name and id in the config you deployed with | -| Service refuses to start: `x402 is configured … refusing to start` | Fail-closed x402 gate — remove the x402 vars or (non-production only) opt in (§4) | +| `POST /webhooks/stripe` reports `NOT_CONFIGURED` | The Stripe webhook signing secret is unset — provision it in admin Settings (§3) | +| Every Stripe delivery 401s | `OTTA_WH_TOKEN` set on the plugin side but not on the site (or the values differ) — §3 | +| Sweeps never run | Nothing has bootstrapped the schedule, or the runtime wired no cron executor — check that the site's Cron Trigger is present and hit a storefront route once (§5) | +| An outbound call to Stripe / the email provider / the x402 facilitator never leaves | The host is not in the build-time `allowedHosts` allowlist (§4) — rebuild and redeploy | diff --git a/README.md b/README.md index 4e74538..ba31ca8 100644 --- a/README.md +++ b/README.md @@ -8,22 +8,25 @@ Open source (MIT), version 0.0.1. The WooCommerce-equivalent for ![The Otta storefront: a product listing with three sample products, each showing generated coil artwork, a title, a description, a price, and whether it is in stock — the first is sold out, its price struck through](./docs/storefront.png) -The reference storefront running locally, with prices and stock served by the commerce -service — this is what the [quick start](#quick-start-local-2-minutes) below gives you. +The reference storefront running locally, with prices and stock served in-process by the +Otta plugin — this is what the [quick start](#quick-start-local-2-minutes) below gives you. ## What this is -Otta turns an EmDash site into a store. It ships as three parts: - -1. **Otta plugin** — a sandbox-clean EmDash plugin: storefront routes, content-sync - hooks, cart/checkout orchestration, an admin console (pricing & inventory, orders, - reports, settings), and x402 gating for digital goods. Talks to the commerce service - over HTTP only (`network:request` + `allowedHosts`). The CMS owns content; every - commercial field lives in the commerce service and is edited in the admin console. -2. **Otta commerce service** — a standalone Node/Hono + Postgres service that owns all - money and stock truth: catalog, inventory, cart, checkout, orders, customers, - payments, tax, shipping, discounts, entitlements, reporting, and webhooks. -3. **The reference site** (`sites/staging`) — a default EmDash site with the plugin already +Otta turns an EmDash site into a store. It is **one deployable**, and it ships as two parts: + +1. **Otta plugin** — a sandbox-clean EmDash plugin that owns all money and stock truth + **in-process**: catalog, inventory, cart, checkout, orders, customers, payments, tax, + shipping, discounts, entitlements, reporting, and webhooks, plus storefront routes, + content-sync hooks and an admin console (pricing & inventory, orders, reports, + settings) and x402 gating for digital goods. Commerce state lives in the host's + per-plugin document store (`ctx.storage`) via the `@otta-sh/store-emdash` adapter — no + separate service, no second database. Its only outbound egress is `ctx.http.fetch`, + gated by `network:request` + `allowedHosts`. The CMS owns content; every commercial + field lives in the plugin's store and is edited in the admin console + ([ADR-0018](./adr/0018-plugin-owns-commerce-truth-in-process.md), + [ADR-0020](./adr/0020-one-deployable-plugin-owns-commerce-truth.md)). +2. **The reference site** (`sites/staging`) — a default EmDash site with the plugin already registered, so there's something to actually run. It's the storefront in the screenshot above and what the [quick start](#quick-start-local-2-minutes) boots: product listing pages, cart, and the admin console. Treat it as the worked example to copy from when @@ -59,8 +62,7 @@ commerce fields it does not touch — so give them some: # 2. Price, stock and activate the demo products (second terminal). # It reads the products' real ids from the CMS (matching the seed's slugs), # then prices and stocks each one through the SITE's own admin API — the same -# route the Pricing & inventory page uses, so it needs no service URL and no -# service token of its own. +# route the Pricing & inventory page uses, so the site URL is all it needs. SITE_URL=http://localhost:4321 \ pnpm dlx tsx@4 sites/staging/scripts/seed-demo-commerce.ts ``` @@ -73,44 +75,29 @@ description and images. One thing to know: this storefront covers **catalog + cart only** — see [Status](#status). To deploy this for free on Cloudflare Workers, follow -[`DEPLOYMENT.md`](./DEPLOYMENT.md) §3. - -## Why two parts - -**EmDash's plugin sandbox has no atomic write, compare-and-set, or transaction.** Plugins -get no direct database access — everything crosses a capability-scoped RPC bridge as JSON -copies — and `ctx.storage` is an unconditional upsert whose declared unique indexes are -silently downgraded. Any read-then-write spans two bridge calls and can interleave, so the -sandbox can't express a guarded update, a uniqueness constraint, or a multi-document -commit. Those are the ordinary building blocks of an order pipeline, so for now the -transactional database sits off to the side, in the commerce service. - -The gap is closing. [emdash-cms/emdash#2169](https://github.com/emdash-cms/emdash/pull/2169) -(ours, currently a draft) adds `ctx.storage..updateIf(id, { where, set?, -delta? })` — a guarded `UPDATE … RETURNING` run inside the sandbox. Two sibling primitives, -not yet proposed upstream, cover the rest: an atomic `insert` that classifies unique -violations, and `ctx.storage.batch([...])` for all-or-nothing multi-collection writes. -With all three, a `@otta-sh/store-emdash` adapter already passes the domain's full -`InventoryStore` contract in-process. Once orders, payments, webhooks, and reporting -follow, the split becomes a deployment choice rather than a correctness requirement. +[`DEPLOYMENT.md`](./DEPLOYMENT.md) §2. ## Architecture (summary) - **Product model = hybrid.** Content (title, description, images, SEO, taxonomies) lives in a native EmDash `products` collection; commercial data (price, SKU, stock, - tax, shipping) lives in the commerce service. Link key = the CMS content `id`. -- **Separate databases.** Commerce Postgres is independent of the EmDash content DB - (independent scaling; no cross-DB joins — joined in app code at render time). -- **Ports and adapters.** `@otta-sh/domain` is pure (no IO); every store is a Kysely - adapter dialect-parameterized over better-sqlite3 (dev) and Postgres (CI/prod). The - REST API in `@otta-sh/service` mirrors the domain ports 1:1, and the same client-side - contract suite runs over the wire so the HTTP format can't drift from the port. + tax, shipping) lives in the plugin's own document store. Link key = the CMS content `id`. +- **One database.** Commerce truth and CMS content share the site's single D1 database: + content lives in the CMS's own tables, commerce lives in the host's per-plugin document + store (`ctx.storage`), namespaced by plugin id and collection. They are not joined in + SQL — the hybrid product model is joined in app code at render time. +- **Ports and adapters.** `@otta-sh/domain` is pure (no IO); the stores that implement its + ports live in `@otta-sh/store-emdash`, which writes one document per aggregate to the + host's per-plugin document store (`ctx.storage`) by compare-and-set — D1 in dev and in + production, with a dialect harness that runs the same adapters against SQLite and + Postgres in CI. The plugin composes those stores in-process, and the domain's contract + suites are the spec they are held to ([ADR-0019](./adr/0019-commerce-aggregates-are-one-document-each.md)). - **Pluggable payments.** Stripe (async webhook) and x402 (HTTP-402 at the page layer) behind one `PaymentGateway` interface. -- **Deployment.** Runs on Cloudflare Workers via Hyperdrive over Neon Postgres, with - cron sweeps for cart/reservation expiry. First-party sites may register the plugin - trusted (in-process) to stay on the Workers free plan — the plugin still passes the - full workerd sandbox suite on every CI run, which is the binding contract (ADR-0006). +- **Deployment.** One Worker and one D1 database: the EmDash site with the plugin + registered trusted (in-process), on the Cloudflare Workers **free** plan, with cron + sweeps for cart/reservation expiry. The plugin still passes the full workerd sandbox + suite on every CI run, which is the binding contract (ADR-0006). Step-by-step bootstrap guide: [`DEPLOYMENT.md`](./DEPLOYMENT.md). ## Repository layout @@ -118,11 +105,12 @@ follow, the split becomes a deployment choice rather than a correctness requirem | Package | What it is | |---|---| | `@otta-sh/domain` | Pure ports, use-cases, branded money types, contract-test suites. No IO. | -| `@otta-sh/service` | Thin Hono REST API + Cloudflare Worker entry mirroring the domain ports. | -| `@otta-sh/store-postgres` | Kysely store adapters (better-sqlite3 local, `pg` CI/prod) + forward-only migrations. | +| `@otta-sh/store-emdash` | Store adapters over the host's per-plugin document store — one document per aggregate, compare-and-set writes. | | `@otta-sh/payments-stripe` | Stripe `PaymentGateway` adapter (async-webhook, raw-body HMAC). | | `@otta-sh/payments-x402` | x402 `PaymentGateway` adapter (synchronous page-gate, facilitator-verified). | -| `@otta-sh/plugin` | The EmDash plugin: storefront routes, admin console, content-sync hooks. | +| `@otta-sh/plugin` | The EmDash plugin: commerce composition, storefront routes, admin console, content-sync hooks. | +| `@otta-sh/admin-presentation` | Pure admin presentation primitives (money, dates, short ids, status vocabulary) shared by both console surfaces. No IO. | +| `@otta-sh/admin-react` | The React admin console on the `otta-console` native descriptor (ADR-0014) — Orders and Pricing & inventory. | | `sites/staging` | Staging storefront + admin — EmDash on Cloudflare Workers, plugin registered trusted. | Design decisions live in [`adr/`](./adr/); development practices in @@ -149,7 +137,7 @@ process, so it verifies the SQL is correct, not that it's race-safe under conten **v0.0.1** — first open-source release. The `@otta-sh/*` packages are all at `0.0.1` and are not published to npm yet; consume them from the workspace. -The commerce **service** is feature-complete (Phases 0–7 merged): catalog, inventory, +The commerce **layer** is feature-complete (Phases 0–7 merged): catalog, inventory, cart, checkout, orders, customers with magic-link auth, Stripe + x402 payments, tax, shipping, discounts, entitlements, reporting, and settings. @@ -158,7 +146,7 @@ only**. The checkout / payment / download pages ([#27](https://github.com/UrumiAI/otta.sh/issues/27)) and the customer account pages are not built yet — so today you get a browsable catalog and carts with real inventory holds, but completing a purchase end-to-end means building those pages or driving the -service API directly. +plugin's own commerce routes directly. ## License diff --git a/adr/0002-adapter-based-split.md b/adr/0002-adapter-based-split.md index abd295a..8ed2b5a 100644 --- a/adr/0002-adapter-based-split.md +++ b/adr/0002-adapter-based-split.md @@ -1,8 +1,19 @@ # 0002. Adapter-based split: the plugin↔authority boundary is a deployment choice -- Status: accepted +- Status: accepted, **partially superseded** 2026-09-20 by + [ADR-0020](./0020-one-deployable-plugin-owns-commerce-truth.md) — the **plugin/service split + only**: the separate commerce service is removed, Otta is one deployable, and the five "a + service may remain preferable" reasons in the Context below are answered there as rejected + (pre-launch, no users). The **ports-and-adapters discipline this record established is not + superseded — it is reaffirmed**, and it is what made the deletion safe. Read the Decision's + five numbered clauses as standing; read every sentence promising a service as historical. - Date: 2026-07-10 - Refines: ADR-0001 (does not supersede) +- Refined by: [ADR-0018](./0018-plugin-owns-commerce-truth-in-process.md) (the plugin may own + commerce truth in-process) and + [ADR-0019](./0019-commerce-aggregates-are-one-document-each.md) (the document model that + spends the storage seam, and that answers and reverses this record's single-statement + conditional-`UPDATE` premise) ## Context diff --git a/adr/0018-plugin-owns-commerce-truth-in-process.md b/adr/0018-plugin-owns-commerce-truth-in-process.md index b2dbed9..bf2278b 100644 --- a/adr/0018-plugin-owns-commerce-truth-in-process.md +++ b/adr/0018-plugin-owns-commerce-truth-in-process.md @@ -11,12 +11,17 @@ duration of the vendored host build only**, described below. - Refines: [ADR-0002](./0002-adapter-based-split.md) — the ports-and-adapters seams it designed are what make this possible, and nothing about them changes. This record does **not** answer - ADR-0002's five "a service may remain" reasons and does not retire the service: that is - **ADR-0020**'s job, and until it is written those reasons stand as written. + ADR-0002's five "a service may remain" reasons and does not itself retire the service: that + was [ADR-0020](./0020-one-deployable-plugin-owns-commerce-truth.md)'s job, and it has since + been written — it answers all five as **rejected** (pre-launch, with no users) and removes + the service. Where this record leaves those reasons standing, read that as true of its own + date; ADR-0020 is where they were settled. - Relates to: [ADR-0013](./0013-product-title-is-cms-owned.md) (unchanged by this record; see the closing note) -- Forward references: **ADR-0019** (the storage document model for commerce aggregates) and - **ADR-0020** (one deployable) — both **to be written**. +- Forward references, **both since written**: + [ADR-0019](./0019-commerce-aggregates-are-one-document-each.md) (the storage document model + for commerce aggregates) and + [ADR-0020](./0020-one-deployable-plugin-owns-commerce-truth.md) (one deployable). ## Context diff --git a/adr/0019-commerce-aggregates-are-one-document-each.md b/adr/0019-commerce-aggregates-are-one-document-each.md index 2cfb4f4..dcf6108 100644 --- a/adr/0019-commerce-aggregates-are-one-document-each.md +++ b/adr/0019-commerce-aggregates-are-one-document-each.md @@ -19,7 +19,9 @@ respected: a title still has one home, and the commerce document still only caches it. [ADR-0017](./0017-list-refresh-semantics.md) — unchanged; this record narrows what a list can *search*, not how it refreshes. -- Forward reference: **ADR-0020** (one deployable — the commerce service is removed), to be written. +- Forward reference, **since written**: [ADR-0020](./0020-one-deployable-plugin-owns-commerce-truth.md) + (one deployable — the commerce service is removed, and ADR-0002's five "a service may remain" reasons + are answered there as rejected). - Amends: **nothing.** ## Context diff --git a/adr/0020-one-deployable-plugin-owns-commerce-truth.md b/adr/0020-one-deployable-plugin-owns-commerce-truth.md new file mode 100644 index 0000000..4758fae --- /dev/null +++ b/adr/0020-one-deployable-plugin-owns-commerce-truth.md @@ -0,0 +1,194 @@ +# 0020. One deployable: the plugin owns commerce truth; the service is removed + +- Status: accepted +- Date: 2026-09-20 +- Supersedes **in part**: [ADR-0002](./0002-adapter-based-split.md). The **plugin/service + split** it established — "a separate service is required today and stays a supported option + indefinitely" — is **undone**. Its **ports-and-adapters discipline is not superseded; it is + reaffirmed**, and it is precisely what makes deleting the service safe. This record also + answers ADR-0002's five "a service may remain preferable" reasons, which + [ADR-0018](./0018-plugin-owns-commerce-truth-in-process.md) deliberately left standing. +- Builds on: [ADR-0018](./0018-plugin-owns-commerce-truth-in-process.md) (the plugin may own + commerce truth in-process) and + [ADR-0019](./0019-commerce-aggregates-are-one-document-each.md) (how truth is shaped there). + Both stand unchanged. +- Does **not** deprecate [ADR-0001](./0001-plugin-plus-commerce-service.md). ADR-0001 is + referenced throughout this folder and it is easy to read "the service is removed" as + retiring it wholesale. It does not. ADR-0001's product shape — an EmDash plugin that turns + a site into a store, with the CMS owning content and commerce owning commercial fields — is + what still ships. What ADR-0002 made a deployment choice, and what this record settles, is + only *where the commerce implementation runs*. +- Amends: **nothing.** + +## Context + +ADR-0002 designed the plugin↔authority boundary as a **deployment choice over stable ports**, +and said two things would stay true regardless: that a separate service was *required* for as +long as the host's plugin sandbox lacked conditional writes, and that a service might *remain +preferable* even after those primitives landed, for five named reasons. + +The first has expired. The host's conditional-write primitives exist, ADR-0018 admitted the +plugin to own commerce truth on `ctx.storage`, and ADR-0019 gave that truth a document model +whose guard semantics are pinned by the same contract suites the SQL stores passed. The +`@otta-sh/service` and `@otta-sh/store-postgres` packages have been deleted. + +The second has not expired on its own; it has to be answered. ADR-0018 said so explicitly and +deferred it here. Keeping a second deployable "in case" is not free: it is a second secrets +store, a second database, a second deploy, a second set of migrations, and — the expensive +part — a wire format that every port change has to be mirrored into. The question is whether +any of the five reasons is worth that, **now, for a project that is pre-1.0, unlaunched, and +has no users**. That last clause is doing real work: every one of these reasons is a reason +one might *reintroduce* a service later on evidence, and none is a reason to *carry* one +today on speculation. + +## Decision + +**Otta is one deployable.** The EmDash site Worker, with the plugin registered trusted, runs +commerce in-process against the site's own database. There is no commerce service, and none +is kept on standby. + +### 1. ADR-0002's five reasons, answered + +Each is quoted as ADR-0002 wrote it, and each is **rejected — pre-launch, no users**. + +1. **"payment-secret / PCI isolation."** Rejected. The isolation was never as clean as the + phrase suggests — the site already terminated the buyer's session and already held the + CMS encryption key — and Otta is not in PCI scope: card data never touches our process + (Stripe Elements runs in the buyer's browser, per + [ADR-0012](./0012-storefront-checkout-loads-stripe-elements-in-the-browser.md)). What the + split actually bought was a smaller blast radius for the Stripe API secret, and that is a + real loss, recorded as such in §2 below rather than argued away. With no users and no live + keys in production, taking that loss now — with the mitigations in §2 — costs less than + carrying a second deployable to preserve it. +2. **"stable public webhook URLs."** Rejected, and inverted: the webhook URL is now + **permanently site-owned** (§3). A site the operator already has a domain for is a + *better* home for a stable public URL than a second Worker on `*.workers.dev`, which was + itself the source of the subrequest footgun the deployment guide used to be organised + around. +3. **"independent scaling."** Rejected. There is nothing to scale independently yet, and the + arbiter of stock and money truth was the single database in both designs — so + "independent scaling" meant scaling a stateless tier away from a ceiling it did not own. + The in-process design has the same ceiling and one fewer hop. +4. **"serving non-EmDash storefronts."** Rejected. No such storefront exists, and this is the + reason most clearly answered by re-derivation rather than by standby (§4): the domain + ports are unchanged, so a service for a non-EmDash consumer is a new adapter over an + existing contract, not a resurrection. +5. **"a merged-in plugin on Cloudflare pins commerce truth to D1 (sandboxed plugins are + D1-only)."** Accepted as a fact, rejected as a reason to keep a service. It is true: truth + now lives in D1. ADR-0019 measured what that costs — a per-aggregate compare-and-set + contention budget, stated as numbers and asserted by tests — and the storage seam is still + a port, so the constraint binds the *adapter*, not the domain. Pinning to D1 is a bound we + have measured, not one we have guessed at. + +### 2. The Stripe-secret trust widening, recorded + +This is the one genuine loss, and it is recorded rather than minimised. + +Before the fold-in, the payment and email credentials were environment variables on a +separate Worker or Node process — `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, +`EMAIL_API_KEY`, the x402 facilitator secret. With no second deployable to hold them, they +move to the one operator-provisionable store the plugin has: **write-only plugin `kv`**, +under the existing `settings:*` convention (`packages/plugin/src/payment-secrets.ts`, +work-order increment C3). Every key there is an existing service environment variable, +renamed — nothing was invented. + +**What widened.** The Stripe API secret is now readable inside the same process that renders +storefront pages and the admin console, rather than living behind an HTTP boundary in a +process the storefront could only talk to over a narrow REST surface. A code-execution bug +anywhere in the plugin now reaches it. That is strictly more trust in one process than the +split design asked for, and no amount of `kv` discipline changes that. + +**What bounds it.** + +- Credentials are **write-only**: persisted only on a non-empty submit, never rendered back + into a block, and read through a fail-closed reader that folds `kv` errors, empty strings + and non-strings to `undefined`. +- Outbound egress is **not ambient**. The plugin's only egress is `ctx.http.fetch`, gated by + the descriptor's `allowedHosts` allowlist, which is resolved at **build** time — a Settings + edit cannot widen the perimeter, only a rebuild can. +- The domain stays IO-free (`domain-is-io-free`, enforced on every commit), so the packages + that hold the rules cannot be the ones that exfiltrate a secret. + +**One honest caveat.** `packages/payments-stripe` defaults its transport to `globalThis.fetch` +rather than `ctx.http.fetch`, unlike the email sender and the x402 facilitator client, which +both route through the gated egress explicitly. No current call site constructs the Stripe +gateway with a live transport, so this is latent rather than exploited — but whoever wires the +Stripe gateway into the in-process composition must pass `ctx.http.fetch`, or the allowlist +bound above does not apply to Stripe. + +### 3. The settle route is public, and the site-owned webhook endpoint is permanent + +**The requirement, as actually built and enforced.** Earlier planning called for a +**non-public** settle route, on the reasoning that a public one is reachable directly at the +host's catch-all and so bypasses the endpoint's own verification. That plan is **wrong on this +stack, and is superseded by what shipped.** A webhook is always unauthenticated, the host +binds its *private* plugin-route dispatcher only on the authenticated path, and an anonymous +request therefore only ever reaches `handlePublicPluginApiRoute`. A non-public settle route +cannot receive a webhook at all. + +So the route is registered **`public: true`** (`packages/plugin/src/plugin.ts`), and the trust +anchor moved in with it: + +- **The Stripe HMAC is the anchor.** The plugin route performs a real signature verification + over the exact delivered bytes against `settings:stripeWebhookSecret`, unconditionally, and + no token or setting can switch it off. The bytes are base64-passed and never parsed, because + a `JSON.parse`/`stringify` round-trip is a different byte string and would fail verification + for every genuine delivery. +- **An edge token is the cheap outer gate in front of it.** `OTTA_WH_TOKEN` (a Worker secret + on the site, mirrored into plugin `kv`) lets the public route refuse an *unattributed* + request before it reads another key, builds a gateway or opens a store. It **passes through + when unset** — an unprovisioned deploy degrades to "cryptographic anchor only", never to + "nothing is checked". + +The requirement this record fixes is therefore: **the settle route is public and must stay +public; its defence is the unconditional HMAC, with the edge token as a pre-filter.** A future +change that makes it non-public is a change that stops webhooks working. + +**Gap, named rather than implied.** The admin route's `public: false` is pinned by an +assertion; the settle route's `public: true` is currently **not** pinned by any test, lint +rule or CI check — it is a manifest literal plus commentary. Pinning it is owed. + +**The endpoint is permanent.** Stripe delivers to `POST /webhooks/stripe` on the **site** +(`sites/staging/src/pages/webhooks/stripe.ts`), a transport shim that holds no secret and +verifies no signature; it exists only because the host's route framework JSON-parses a +route's body before any handler runs and exposes no raw-body read. There is no future in +which this endpoint moves back to a separate service: the URL is the operator's, on the +operator's domain, and a registered webhook URL is the kind of thing that must not move. + +### 4. A future service is re-derived, never resurrected + +If a service is ever needed — a non-EmDash storefront, a genuinely independent scaling need, +a customer who requires the isolation §2 gave up — it is **built new from `@otta-sh/domain`, +whose ports are unchanged by this record.** It is not restored from deleted code, and no +service is kept on standby, behind a flag, or in a branch. + +This is exactly the payoff ADR-0002 designed for, claimed once rather than twice: the seams +are what make deletion cheap *and* what would make re-derivation cheap. Deleted code that has +to be rebased over a year of domain changes is a liability; a port that never moved is an +asset. The contract suites are the acceptance test for either direction — a new transport +adapter is done when the existing suite passes against it, the same rule that admitted the +in-process client. + +## Consequences + +- **One deployable, one database, one secrets story.** No service URL, no service token, no + second migration set, no wire format to keep in sync with the ports. The deployment guide + describes one shape. +- **A wire format stops being a public contract.** The REST API was a 1:1 serialization of the + ports, and keeping it honest was real work. That work is gone — and so is the drift test + that guarded it, which is a small loss of evidence, not of correctness: the ports themselves + are still pinned by the contract suites. +- **The blast radius of the plugin process grew** (§2). This is the cost of the decision and + it is accepted knowingly, on the grounds that the project is pre-launch with no users and no + live credentials in production. It is not a cost that gets cheaper with scale. +- **Rollback is "revert the merge", not "switch modes".** There is no second mode. Nothing in + the tree can be flipped back to HTTP, and the deployment guide carries no asymmetric-rollback + caveat because there is nothing asymmetric left to roll back. +- **D1 is the storage floor** (reason 5). Its contention behaviour is a measured budget in + ADR-0019, and a workload that exceeds it reopens the *adapter* choice, not the boundary. + +**What would reopen this decision.** A real non-EmDash consumer; a measured contention or +volume figure that no document-model change brings back inside ADR-0019's budget; or a +compliance requirement that genuinely needs the process isolation §2 gave up. In every case +the answer is a new adapter over the unchanged ports — and in every case the evidence comes +first. diff --git a/adr/README.md b/adr/README.md index 8f669e0..11b42f2 100644 --- a/adr/README.md +++ b/adr/README.md @@ -31,7 +31,7 @@ than rewriting history. ## Records - [0001. Plugin + separate commerce service](./0001-plugin-plus-commerce-service.md) — accepted, amended 2026-07-29 (the product-data field widget was removed), extended 2026-07-30 (the title projection became ADR-0013) -- [0002. Adapter-based split (boundary is a deployment choice)](./0002-adapter-based-split.md) — accepted, refines 0001 +- [0002. Adapter-based split (boundary is a deployment choice)](./0002-adapter-based-split.md) — accepted, refines 0001; **partially superseded** 2026-09-20 by [ADR-0020](./0020-one-deployable-plugin-owns-commerce-truth.md) — **the plugin/service split only** (the separate commerce service is removed; Otta is one deployable, and this record's five "a service may remain preferable" reasons are answered there as rejected, pre-launch, with no users). The **ports-and-adapters discipline it established stands, reaffirmed** — it is what made the deletion safe. Refined by [ADR-0018](./0018-plugin-owns-commerce-truth-in-process.md) and [ADR-0019](./0019-commerce-aggregates-are-one-document-each.md) - [0003. Storefront pages are plugin-owned public routes](./0003-storefront-plugin-routes.md) — accepted, refines 0001 - [0004. Storefront customer auth is magic-link](./0004-customer-auth-mechanism.md) — accepted, refines 0001 - [0005. The commerce service sends transactional email directly](./0005-transactional-email-transport.md) — accepted, refines 0002 @@ -48,16 +48,18 @@ than rewriting history. - [0016. A variant's name is CMS-owned; `product_variants.title` is a derived single-writer cache](./0016-variant-title-is-cms-owned.md) — accepted, refines 0001/0002 and applies [ADR-0013](./0013-product-title-is-cms-owned.md) one level down (ADR-0013 is **not** amended); adds the two clauses the product level had no need for — the variant key is the immutable identity, and removal is deactivation rather than deletion; **amended 2026-08-09** — the enforcement clause only: the CMS cannot express a save-time refusal of a mutated or reused variant key, so the key is enforced by recovery instead (a re-key is deactivate-plus-declare, the dropped row retains its sku, price and stock, and re-declaring the key resurrects it — on a strictly newer watermark, which makes publish the reliable repair verb), and the admin variant list surfacing orphans distinctly becomes load-bearing rather than merely good practice; every other clause reaffirmed unchanged - [0017. Refreshing a list re-walks the window on screen](./0017-list-refresh-semantics.md) — accepted, refines [ADR-0014](./0014-second-native-descriptor-for-react-admin.md) and [ADR-0015](./0015-retire-duplicated-block-kit-screens.md) for the two React lists only: a refresh re-reads the pages on screen from the window's own anchor, following freshly issued cursors, and keeps the operator's depth; the window is REPLACED rather than merged into, so a row the collection no longer holds stops being shown; `Apply filters` over an unchanged predicate is a refresh rather than a collapse. Built from requests the service already answers — no service or plugin surface changes, and the Block Kit lists (which replace rather than accumulate) are untouched -- [0018. The plugin may own commerce truth in-process on `ctx.storage`](./0018-plugin-owns-commerce-truth-in-process.md) — accepted, **amends [ADR-0006](./0006-trusted-in-process-deployment.md) Decision 2's "no direct DB/storage access" clause and only that clause** (every other prohibition stands; Decision 1 — the workerd sandbox suites as the contract gate — is reaffirmed, with an honest statement of what the suites prove and what the D1 tier proves instead), and **amends [ADR-0014](./0014-second-native-descriptor-for-react-admin.md) Decision 5** for the duration of the vendored host build; admits `@otta-sh/domain` and `@otta-sh/store-emdash` into the plugin's dependency perimeter with `domain-is-io-free` as the premise, narrows "zero EmDash dependency" to "zero EmDash **runtime** dependency" for the adapter package, and leaves the capability posture untouched; refines [ADR-0002](./0002-adapter-based-split.md) (the seams it designed are what this spends). [ADR-0019](./0019-commerce-aggregates-are-one-document-each.md) (aggregates) answers the document-model half; ADR-0020 (one deployable) is still to be written. +- [0018. The plugin may own commerce truth in-process on `ctx.storage`](./0018-plugin-owns-commerce-truth-in-process.md) — accepted, **amends [ADR-0006](./0006-trusted-in-process-deployment.md) Decision 2's "no direct DB/storage access" clause and only that clause** (every other prohibition stands; Decision 1 — the workerd sandbox suites as the contract gate — is reaffirmed, with an honest statement of what the suites prove and what the D1 tier proves instead), and **amends [ADR-0014](./0014-second-native-descriptor-for-react-admin.md) Decision 5** for the duration of the vendored host build; admits `@otta-sh/domain` and `@otta-sh/store-emdash` into the plugin's dependency perimeter with `domain-is-io-free` as the premise, narrows "zero EmDash dependency" to "zero EmDash **runtime** dependency" for the adapter package, and leaves the capability posture untouched; refines [ADR-0002](./0002-adapter-based-split.md) (the seams it designed are what this spends). [ADR-0019](./0019-commerce-aggregates-are-one-document-each.md) (aggregates) answers the document-model half; [ADR-0020](./0020-one-deployable-plugin-owns-commerce-truth.md) (one deployable) answers ADR-0002's five "a service may remain" reasons, which this record deliberately left standing -- [0019. Commerce aggregates are one storage document per aggregate; idempotency is the document id](./0019-commerce-aggregates-are-one-document-each.md) — accepted, **amended 2026-09-14** (Phase B as built — corrections, added collections/indexes, and four cross-cutting rules; the decision itself is reaffirmed), **amends nothing**: it is the adapter-side document model that spends the storage seam [ADR-0002](./0002-adapter-based-split.md) designed, inside the boundary [ADR-0018](./0018-plugin-owns-commerce-truth-in-process.md) opened. Records the one rule (an invariant spanning two facts lives in ONE document; a coupling spanning two aggregates is made idempotently completable and swept), the two-tier write strategy, the inventory model **as built** — a durable per-key claim, a reverse index written before the hold, then the guarded decrement and the hold in one compare-and-set — the terminal-answer-before-prune **ordering** rule, the two lookup collections the port signatures force, the bounded movement ring with its accepted residual and the sweeper contract that closes it, the **permanent** CAS contention budget as measured numbers, the typed retryable contention error that must never be reported as out of stock, the collection layout with doc-id idempotency (32 rows naming 35 collections), and the index rule in both halves (declaration is a read contract; materialization is not correctness). **And — because the Kysely stores are deleted — a prose snapshot of the guard semantics of every SQL statement the design replaces**, written as "what the old SQL guaranteed → which document write guarantees it now", with the contract or race test that proves each. [ADR-0013](./0013-product-title-is-cms-owned.md), [ADR-0016](./0016-variant-title-is-cms-owned.md) and [ADR-0017](./0017-list-refresh-semantics.md) are unchanged and respected; the orders-list search narrowing it ratifies is a search change, not a refresh change. ADR-0020 (one deployable) is forward-referenced and still to be written +- [0019. Commerce aggregates are one storage document per aggregate; idempotency is the document id](./0019-commerce-aggregates-are-one-document-each.md) — accepted, **amended 2026-09-14** (Phase B as built — corrections, added collections/indexes, and four cross-cutting rules; the decision itself is reaffirmed), **amends nothing**: it is the adapter-side document model that spends the storage seam [ADR-0002](./0002-adapter-based-split.md) designed, inside the boundary [ADR-0018](./0018-plugin-owns-commerce-truth-in-process.md) opened. Records the one rule (an invariant spanning two facts lives in ONE document; a coupling spanning two aggregates is made idempotently completable and swept), the two-tier write strategy, the inventory model **as built** — a durable per-key claim, a reverse index written before the hold, then the guarded decrement and the hold in one compare-and-set — the terminal-answer-before-prune **ordering** rule, the two lookup collections the port signatures force, the bounded movement ring with its accepted residual and the sweeper contract that closes it, the **permanent** CAS contention budget as measured numbers, the typed retryable contention error that must never be reported as out of stock, the collection layout with doc-id idempotency (32 rows naming 35 collections), and the index rule in both halves (declaration is a read contract; materialization is not correctness). **And — because the Kysely stores are deleted — a prose snapshot of the guard semantics of every SQL statement the design replaces**, written as "what the old SQL guaranteed → which document write guarantees it now", with the contract or race test that proves each. [ADR-0013](./0013-product-title-is-cms-owned.md), [ADR-0016](./0016-variant-title-is-cms-owned.md) and [ADR-0017](./0017-list-refresh-semantics.md) are unchanged and respected; the orders-list search narrowing it ratifies is a search change, not a refresh change. [ADR-0020](./0020-one-deployable-plugin-owns-commerce-truth.md) (one deployable) is the boundary decision this document model is built inside + +- [0020. One deployable: the plugin owns commerce truth; the service is removed](./0020-one-deployable-plugin-owns-commerce-truth.md) — accepted, **supersedes [ADR-0002](./0002-adapter-based-split.md) in part** — the **plugin/service split only**; ADR-0002's ports-and-adapters discipline is reaffirmed and is what made the deletion safe. Answers ADR-0002's five "a service may remain preferable" reasons one by one as **rejected, pre-launch, with no users** (payment-secret/PCI isolation, stable public webhook URLs, independent scaling, non-EmDash storefronts, and the D1 pin). Builds on [ADR-0018](./0018-plugin-owns-commerce-truth-in-process.md) and [ADR-0019](./0019-commerce-aggregates-are-one-document-each.md), both unchanged, and **does not deprecate [ADR-0001](./0001-plugin-plus-commerce-service.md)** — the product shape still ships; only *where the commerce implementation runs* is settled. Records the **Stripe-secret trust widening** (payment/email credentials move from a second deployable's env vars into write-only plugin `kv`, bounded by a build-time `allowedHosts` perimeter, with the `payments-stripe` default-transport caveat named), fixes the **settle-route visibility requirement** (the route is `public: true` and must stay so — an anonymous webhook only ever reaches the host's public dispatcher; the unconditional Stripe HMAC is the trust anchor and the `OTTA_WH_TOKEN` edge gate the pre-filter — and names that no test pins the flag yet), states the **site-owned webhook endpoint is permanent**, and states that a future service would be **re-derived from the unchanged `@otta-sh/domain` ports, never kept on standby or resurrected from deleted code** ## Queued (to promote from draft-plans) Decisions already made that should each become an ADR: - Hybrid product model (content in CMS, commerce in service) -- Separate commerce database (no cross-DB joins) +- ~~Separate commerce database (no cross-DB joins)~~ — **answered and reversed** by [ADR-0018](./0018-plugin-owns-commerce-truth-in-process.md)/[ADR-0020](./0020-one-deployable-plugin-owns-commerce-truth.md): there is one database. Commerce truth lives in the host's per-plugin document store on the **same** database as CMS content, namespaced by plugin id and collection; the hybrid product model is still joined in app code, not in SQL - ~~Backend-agnostic atomic inventory via single-statement conditional UPDATE~~ — **answered and reversed** by [ADR-0019](./0019-commerce-aggregates-are-one-document-each.md): a single-statement conditional `UPDATE` cannot carry a reserve, because the decrement is not idempotent unless the row records who applied it. The atomicity unit is now one storage document written by compare-and-set, and the single guarded statement survives only for single-guard writes — a write whose entire invariant is one comparison on one field - Pluggable payments (Stripe + x402 in parallel) - Customer accounts owned by the commerce service (not EmDash `ctx.users`) diff --git a/sites/staging/README.md b/sites/staging/README.md index 6271407..5da6123 100644 --- a/sites/staging/README.md +++ b/sites/staging/README.md @@ -24,7 +24,7 @@ Nothing else has to be running: commerce is **in-process** in this site's own Wo In `astro dev` the fastest path to a populated catalog is the dev-only bypass, which applies the full seed including the 3 sample products: `/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin`. What first boot does and does -not seed in a real deployment is covered in [`DEPLOYMENT.md`](../../DEPLOYMENT.md) §1. +not seed in a real deployment is covered in [`DEPLOYMENT.md`](../../DEPLOYMENT.md) §2.2. ### Plugin settings are namespaced by plugin id @@ -71,10 +71,12 @@ the key. ## Deploying The deploy runbook for this site lives in the root [`DEPLOYMENT.md`](../../DEPLOYMENT.md): -resource creation, secrets, the build/deploy ordering, first boot + claim, and -failed-first-boot recovery are §3 (Shape B); the workers.dev networking constraints and -the flag⇒session-off pairing invariant are §3.5; the secrets/token checklist — including -why `SERVICE_API_TOKEN` must stay unset for now — is §4. +resource creation, the build/deploy ordering, first boot + claim, and failed-first-boot +recovery are §2; the `global_fetch_strictly_public` ⇒ D1-`session`-off pairing invariant is +§2.4; the secrets & tokens checklist is §3. There is one deployable, so the only Worker +secrets are `EMDASH_ENCRYPTION_KEY` (required before first boot) and the optional +`OTTA_WH_TOKEN` webhook edge gate — every payment and email credential is provisioned in the +admin console's **Settings** page instead. ## Notes diff --git a/sites/staging/src/lib/hold.ts b/sites/staging/src/lib/hold.ts index 89b7d0c..a476eae 100644 --- a/sites/staging/src/lib/hold.ts +++ b/sites/staging/src/lib/hold.ts @@ -12,12 +12,14 @@ * and the no-JS answer. */ -/** The service's hold TTL, in seconds — 15 minutes. +/** The commerce layer's hold TTL, in seconds — 15 minutes. * * AUTHORITY: `DEFAULT_HOLD_TTL_MS` in `packages/domain/src/cart/use-cases.ts` - * (`15 * 60 * 1000`), which the service applies unless a deployment overrides - * it with `CART_HOLD_TTL_MS` (DEPLOYMENT.md §5 — default `900000`). Keep this - * in step with whatever the store this theme is serving actually runs. + * (`15 * 60 * 1000`), which the plugin's in-process cart use-cases apply. It is + * both the default and the effective value today — the admin's `holdTtlMinutes` + * setting is persisted but wired to nothing (issue #127) — so there is no + * deployment knob to document. Keep this in step with whatever the store this + * theme is serving actually runs. * * It is ONLY the fill's denominator. The ribbon needs a window to draw a * fraction against because the wire carries the expiry INSTANT, not the length