diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19601e77..0866e65c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,13 +237,24 @@ jobs: mutation-gate: name: mutation-gate — changed packages only runs-on: ubuntu-latest - timeout-minutes: 20 + # Budget (2026-08-10): local full six-package ratchet exits 0 in ~828s + # (~13m48s) on M-series; this job mutates only packages with `src/` + # changes vs origin/main (this branch: worker only — robots + rooms). + # Worker alone dominates wall-clock. CI ubuntu runners are slower than + # local, and run 31388973185 cancelled the Stryker step at + # timeout-minutes:20 with zero score output — a cancelled gate is + # indistinguishable from green at a glance and trains people to ignore + # it. 45m ≈ ~2.5× the local full-matrix baseline, deliberate headroom + # so the gate can finish and report a real score rather than timing out. + # Do NOT lower Stryker break thresholds to "fit" a short budget. + timeout-minutes: 45 needs: test # Fast PR-gate mutation check. Runs Stryker only on packages whose # `src/` changed in the PR, so docs/test/config-only PRs skip it and - # single-package PRs stay under ~2 minutes. The full matrix lives in - # nightly.yml; see AGENTS.md §5.3 + docs/MUTATION_REPORT.md for the + # single-package PRs stay under the budget above. The full matrix lives + # in nightly.yml; see AGENTS.md §5.3 + docs/MUTATION_REPORT.md for the # ratchet workflow. + steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: diff --git a/AGENTS.md b/AGENTS.md index 75d5226f..5f490268 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ line/branch/function/statement coverage on gated packages in CI. | User guide + FAQ | [docs.ethercalc.net](https://docs.ethercalc.net) · `packages/docs/` | | HTTP API | `API.md` | | Self-host hardening | `docs/SELFHOST_HARDENING.md` | +| Prod upgrade runbook | `docs/migration/PROD_UPGRADE_PLAN.md` | | Oracle replay | `tests/oracle/README.md` · `packages/oracle-harness/` | | Mutation baselines | `docs/MUTATION_REPORT.md` | | Sandstorm `.spk` | `SANDSTORM.md` (manual `spk pack` — app owner signs) | @@ -117,7 +118,31 @@ spikes/ Immutable research provenance (not the maintained work ## Session log Per-session history is in `docs/historic/REWRITE_ULTRAPLAN.md` §14 (append-only, -newest last). Latest: full security audit of `main` against prod +newest last). Latest: operator runbook for upgrading production +`ethercalc.net` to `main` (`docs/migration/PROD_UPGRADE_PLAN.md` plus +`DELTA_AUDIT.md`, `SKEW_AND_RECONNECT.md`, `PREFLIGHT_RESULTS.md`, +`INVENTORY.md`). Read-only probes overturned the assumed +`0.20260717.0`/`149ebcf` baseline — `GET /_auth/whoami` returns +`{"uid":null,"enabled":true}`, root HTML serves `./static/passkey/ui.js`, +and `/static/index-bootstrap.js` is 404 — so production sits in +`[d2afa90, b7d8840)` with passkeys/`AuthDO`/ACL/private rooms already live +and the security-audit page-script extraction not. The original Phase-2 +`ETHERCALC_AUTH="0"` soak would have failed `authEnabled()`, nulled every +session principal, and 403'd every private room; corrected plan is a single +gradual ramp with `AUTH="1"` throughout (three-phase analysis kept as +Appendix A). Candidate-range checks that made the simpler plan safe: +`wrangler.toml` declares only `v1`/`v2`, all three D1 migrations are +byte-identical, and `lib/authorize.ts` is byte-identical. Exposed +regression: `ec_sess` → `__Host-ec_sess` with no dual-read (silent logout +at rollout). Search-indexing policy restored after an accidental +dual-purpose-commit revert (`ae86756` → `c789249`; only `/` stays +indexable; robots.txt has no `Disallow` so crawlers can still observe +`noindex`). Tip `327fa3d`: worker 53/1526 node + 13/197 workers-pool at +100% coverage (2706/1936/298/2416); mutation ratchet exit 0 in 828s, +`@ethercalc/worker` +0.03pp, `robots.ts` 100% (9/0). Open: six +`[OPERATOR-VERIFY]` items (chiefly `CURRENT_PROD_VERSION_ID` via +`wrangler deployments list`) and the staging rehearsal. +Prior: full security audit of `main` against prod `ethercalc.net`, with every confirmed finding fixed at its owning boundary — stored DOM XSS in the client graph panel, multi-sheet `postMessage`/TOC trust, per-message WS/Socket.IO authentication and attachment-room binding (no more @@ -140,7 +165,3 @@ fail-closed Helm passkey anchors. Mutation floors re-measured: worker 90.21, shared 99.69, socketio-shim 84.68, migrate 90.38, oracle-harness 83.46, client 77.61 — equivalent mutants carry written `// Stryker disable` justifications rather than padded tests. -Prior: merged passkey accounts + private sheets (Phase A) from -`feat/passkey-permissions` (PR #841) — WebAuthn `AuthDO` relying party, -RoomDO-enforced `meta:access`/`meta:acl` private rooms, principal-threaded -routes, dependency-free passkey UI, WS session-expiry enforcement. diff --git a/API.md b/API.md index 21bc5063..7e401cd8 100644 --- a/API.md +++ b/API.md @@ -100,6 +100,8 @@ To find out which command corresponds to which spreadsheet actions, perform the actions on the web interface and check the _Audit_ tab for the recorded commands. +Command batches that would push the sheet's declared area past the 200,000-cell limit are rejected with `413`. The same limit applies over WebSocket, where the connection closes with code `1008` and reason `Command exceeds sheet limits`. + + Request (application/json) + Response 202 @@ -107,6 +109,12 @@ for the recorded commands. {"command": "..."} ``` ++ Response 413 (text/plain) + + ```text + command exceeds sheet limits + ``` + ## Append Rows [POST] Takes a CSV structure that contains fields to be appended to the first column after the last row. diff --git a/Changes.txt b/Changes.txt index 94b169a7..d5a8d850 100644 --- a/Changes.txt +++ b/Changes.txt @@ -34,6 +34,13 @@ Security - Made staging deploy validation pass `--config wrangler.toml` explicitly, so Vite's generated production-config redirect cannot silently ignore `--env staging`. +- Room URLs stay unlisted by design: every non-landing response now carries + `X-Robots-Tag: noindex, nofollow, noarchive`, and only `/` remains + indexable. `GET /robots.txt` returns a real robots file (Allow: `/` only) + instead of falling through the `/:room` catch-all as app HTML. The robots + file deliberately carries no `Disallow` directive — `Disallow` forbids + fetching, so a crawler blocked from a URL can never observe its `noindex` + header and already-indexed rooms would be stranded permanently. Tooling - Migrated the repository interface to Vite+ 0.2.4: package operations use @@ -54,6 +61,10 @@ Tooling - Hardened self-host Docker builds against local-state leakage: build context excludes dependencies, Vite/Wrangler output, secrets, migration dumps, and persistent room data; the smoke gate asserts the resulting image is clean. +- Fixed `scripts/ratchet-verify.sh` mutation score parsing in `jq` and `node` + branches by computing the score from mutant status counts rather than reading + a non-existent `mutationScore` property, resolving an unconditional parse + error (exit 2) during mutation ratchet audits. Integration - Prepared the headless adapter for the next SocialCalc release: bundle @@ -68,6 +79,12 @@ Integration measured 920px cutoff, avoiding overlap with SocialCalc's expanded menu while preserving fixed top-right chrome on wider sheets. +Bug fixes +- Fixed `POST /_/:room` returning `202` on Durable Object command rejections, + which silently discarded writes. The route now propagates the DO status and + response body (such as `413 command exceeds sheet limits` when a batch exceeds + 200,000 cells). + ============================================================================== 0.20260717.0 — 2026-07-17 ============================================================================== diff --git a/bun.lock b/bun.lock index 1e78513b..920c409f 100644 --- a/bun.lock +++ b/bun.lock @@ -149,6 +149,7 @@ "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.18.0", "@cloudflare/workers-types": "^4.20260702.1", + "socialcalc-308": "npm:socialcalc@3.0.8", "vite": "catalog:", "vitest": "catalog:", "wrangler": "^4.107.0", @@ -2146,6 +2147,8 @@ "socialcalc": ["socialcalc@3.1.0", "", {}, "sha512-bBaT/RiRNeL9Hlw1QCL3UGa6vzRq480JaNBFQez0kcpMh4+4pp8xXGOWjsQ9K3iHIy29dgiPNGAksfm1mddvLg=="], + "socialcalc-308": ["socialcalc@3.0.8", "", {}, "sha512-2XdnQttQpNQWCJARP7cHIzzi9xo0MEAcmb3gCxM4SjmdBmia6mJZsJ4SCR5lR6kI0cb7jpq6GtaTQJEyR3ZJqw=="], + "socket.io-client": ["socket.io-client@1.0.6", "", { "dependencies": { "component-bind": "1.0.0", "component-emitter": "1.1.2", "debug": "0.7.4", "engine.io-client": "1.3.1", "has-binary-data": "0.1.1", "indexof": "0.0.1", "object-component": "0.0.3", "parseuri": "0.0.2", "socket.io-parser": "2.2.0", "to-array": "0.1.3" } }, "sha512-itdtz6fQBTFIDBP4+hJox0OlT+SbCVdENjPgjMup3ehu7OsiG6t0FYBXCx+k/upt9lbeyp9BmUNNi5EfnGa5Vw=="], "socket.io-parser": ["socket.io-parser@2.2.0", "", { "dependencies": { "debug": "0.7.4", "emitter": "http://github.com/component/emitter/archive/1.0.1.tar.gz", "isarray": "0.0.1", "json3": "3.2.6" } }, "sha512-uW3UiLVibAyleKq8r/yZe1oPO51olhY18T6HtnN0iI6RLqJfYC0YiyAFlsPw1+8I0Z1qFd8jFLTRZo2vr6ISxA=="], diff --git a/docs/historic/REWRITE_ULTRAPLAN.md b/docs/historic/REWRITE_ULTRAPLAN.md index 7de5828e..d106df0a 100644 --- a/docs/historic/REWRITE_ULTRAPLAN.md +++ b/docs/historic/REWRITE_ULTRAPLAN.md @@ -457,6 +457,7 @@ Append one entry per session. | 2026-07-16 | tooling | **Migrated the repository interface to Vite+ 0.2.4.** `vp install/add/remove/update` now owns package operations; `vp run`/`vp exec` own task and binary orchestration; package Vite/Vitest imports, scripts, contributor docs, e2e child processes, and CI/deploy/nightly/release workflows were migrated. Root `vite.config.ts` aggregates package Vitest projects and replaces Biome with Vite+ Oxlint while preserving the three-rule/no-formatter contract; Biome and its lockfile graph were removed. `bun@1.3.14` remains pinned as the underlying package manager/native runtime, with direct Bun confined to Bun-native scripts/tests and Docker or packed-package paths where Vite+ is not shipped. Verified frozen install, `vp check`, all-package typecheck, 2,208 aggregated Vitest tests, the full `vp run test` workflow, asset builds, and 15 Playwright e2e tests. | (this commit) | | 2026-08-03 | security-audit | **Full-branch security audit of `main` against prod `ethercalc.net`, fixes landed at the owning boundary.** Closed: stored DOM XSS in the client graph panel (persisted `graphrange`/named-range values reached `innerHTML`; now `textContent` plus a built Hide-Help control, removing the last inline `onclick`); multi-sheet TOC/`postMessage` trust (origin+source checked in `index.html`, `SheetFrame` targets its own origin, TOC links validated by `isSafeMultiSheetLink`); WS/Socket.IO authentication and memory (per-message `verifyAuth` on every state-changing frame, attachment-room binding instead of client-supplied `parsed.room`, per-socket rate window, message/frame/chat/cell caps in `@ethercalc/shared`, bounded handshake sessions with teardown in the shim); unbounded ingestion (auth and suffix-import bodies now pass the shared size guard; ZIP archive/sheet/cell/column caps; bounded cross-sheet hydration; chunked-snapshot metadata validation); `/_timetrigger` no longer a public metadata/side-effect endpoint, and the scheduler no longer treats refusals as fires or deletes non-fired rows; session/identity (`__Host-ec_sess`, AuthDO-side revocation with monotonic windows, per-IP ceremony rate limits, challenge capacity cap, session expiry enforced on live sockets); trust-proxy correctness (`CF-Connecting-IP` honoured only from the trusted edge, nginx now replaces rather than appends forwarding headers, bucket eviction); room-ID entropy restored to ≥62 bits; creation-limit classifier extended to private/form/multi-import routes; `no-store` on authenticated/private responses plus global security headers; export sanitizer allowlist mirrored to the client policy. Root HTML page scripts were extracted to five tracked files under `static/` (`index-bootstrap`, `index-l10n`, `start-bootstrap`, `start-page`, `panels`) with a `requiredFiles` assertion in `scripts/build-assets.ts`, since the whole-directory copy checks no individual names — note the app CSP still needs `script-src 'unsafe-inline'` for SocialCalc's toolbar, so this is hygiene, not yet a boundary. Infra: compat date 2024-11-12 → 2026-07-21 (capnp in lockstep), `run_worker_first`, pinned base image, read-only/no-new-privileges containers, `automountServiceAccountToken: false`, Helm passkey trust anchors that fail closed, with `scripts/check-helm-hardening.sh` covering both. Verification: `vp check`, all-package typecheck, 1499 worker-node + 194 workers-pool + the remaining package suites, 100% worker coverage, 47/47 Playwright e2e, `wrangler deploy --dry-run`, helm lint/template matrix, self-host and proxy smoke. Mutation floors re-measured: worker 90.21 (break 90), shared 99.69 (99), socketio-shim 84.68 (84), migrate 90.38 (90), oracle-harness 83.46 (83), client 77.61 (77); provably equivalent mutants carry written `// Stryker disable` justifications instead of padded tests. | (working tree) | | 2026-08-03 | security-audit/follow-up | **Closed the final Host-derived CSP seam caught during delivery review.** `index.ts` had built `connect-src ws(s)://…` from `new URL(request.url).host`, even though the same file correctly forbids using attacker-controlled `Host` for cross-origin redirects. New pure `lib/csp.ts#websocketAuthority` anchors the WebSocket authority on `ETHERCALC_ORIGIN` (including configured scheme/port), falling back to the request host only for self-hosts without a valid configured origin. Ten Node tests pin configured/fallback/scheme/port/invalid-origin lanes. Live `wrangler dev` probes with `Host: attacker.test` and `Host: evil.example:1337` both retained `connect-src 'self' wss://ethercalc.net`. Post-fix worker typecheck, 100% coverage, mutation 90.21 ≥ 90, and `vp check` passed; Stryker restored cleanly with no instrumentation markers. | (working tree) | +| 2026-08-10 | prod-upgrade | **Operator runbook for upgrading production `ethercalc.net` to `main`.** Deliverable `docs/migration/PROD_UPGRADE_PLAN.md` with companions `DELTA_AUDIT.md`, `SKEW_AND_RECONNECT.md`, `PREFLIGHT_RESULTS.md`, `INVENTORY.md`. Central finding overturned the original premise: production is not tag `0.20260717.0` / `149ebcf`. Read-only probes (`GET /_auth/whoami` → `{"uid":null,"enabled":true}`, a route absent at `149ebcf`; root HTML serves `./static/passkey/ui.js`; `GET /static/index-bootstrap.js` is 404) bound production to `[d2afa90, b7d8840)` — passkeys, `AuthDO`, ACL, and private rooms are already live; the security-audit page-script extraction is not. That mattered because the original three-phase cutover set `ETHERCALC_AUTH="0"` in Phase 2 as a safety soak; against real production that fails `authEnabled()`, nulls every session principal, and 403s every existing private room to its owner — a self-inflicted outage. Corrected plan is a single gradual ramp with `AUTH="1"` throughout; superseded three-phase analysis preserved as Appendix A. Verified constraints that made the simpler plan safe: across the whole candidate range `wrangler.toml` declares only `v1`/`v2` (no lifecycle change), all three D1 migration files are byte-identical (blob OIDs match), and `lib/authorize.ts` is byte-identical (no declassification on rollback). A real regression the corrected baseline exposed: `lib/session.ts` renames the session cookie `ec_sess` → `__Host-ec_sess` with no dual-read, so every signed-in passkey user is silently logged out at rollout and an open tab on a private room can fail to reconnect. Search-indexing policy restored and shipped (`c789249`): a worker session misread the originating commit's `docs(migration):` subject, judged the bundled production code accidental, and reverted it in `ae86756`; recovered in `c789249` with all four blobs verified byte-identical to the originals (cautionary for dual-purpose commits). Measurements at tip `327fa3d`: worker suites 53 files / 1526 tests (node), 13 / 197 (workers-pool), 100% coverage (2706/1936/298/2416); mutation ratchet exit 0 in 828s with every audited package under 1pp of its break floor and `@ethercalc/worker` at +0.03pp — `robots.ts` itself scored 100% (9 killed / 0 survived). Still open: six `[OPERATOR-VERIFY]` items needing live Cloudflare access, chiefly `wrangler deployments list` to pin `CURRENT_PROD_VERSION_ID` (needed for the rollback target, not for the ramp's safety), and the staging rehearsal. | (this branch) | --- diff --git a/docs/migration/DELTA_AUDIT.md b/docs/migration/DELTA_AUDIT.md new file mode 100644 index 00000000..0ea11bf3 --- /dev/null +++ b/docs/migration/DELTA_AUDIT.md @@ -0,0 +1,992 @@ +# Delta Audit Report: production candidate range → current `main` + +> **Status / scope — corrected 2026-08-10.** The original analysis used +> `0.20260717.0` / `149ebcf` as production and audited `149ebcf..d486f33`. +> Read-only production evidence in `PROD_UPGRADE_PLAN.md` disproves that +> premise and bounds the deployed source to **`[d2afa90, b7d8840)`**: +> passkeys and the `AuthDO` stack are present (`GET /_auth/whoami` → +> `enabled:true`; root serves `./static/passkey/ui.js`), but the +> security-audit page-script extraction at `b7d8840` is not +> (`GET /static/index-bootstrap.js` → 404; root still has inline scripts +> and `manifest.appcache`). +> +> This document therefore re-classifies every item against +> **`d2afa90` → classification target `35e3f71`**. `d2afa90` (`Merge +> feat/passkey-permissions (#841)`, 2026-07-18) is the earliest possible +> production revision, so this is the conservative **upper bound** on +> remaining delta. If the operator later pins production to a later +> commit in the candidate range, the real delta can only be smaller — +> only `wrangler deployments list` settles the exact SHA. Item +> classifications (Executive Summary **a)**–**k)** and matching Step 2 +> Rescope blocks) were verified against tree **`35e3f71`** (the branch +> tip when the 2026-08-10 correction landed). Pre-edit source tip used +> for the packages spot-check is **`a4b5acf`**: +> `git diff --stat 35e3f71..a4b5acf -- packages/` is **not** empty +> (search-indexing `robots.ts` restore + tests only — `c789249` / +> related). That packages delta does **not** change the **a)**–**k)** +> already-deployed / still-in-delta verdicts below; it is an additional +> still-in-delta ship item recorded in the runbook, not a re-open of +> passkey/`AuthDO`/ACL/SocialCalc engine scope. Commits after `a4b5acf` +> on this branch are documentation-only (this file and the runbook); +> do not re-pin the Status block to each successive docs commit SHA. +> +> Historical audit findings written against `149ebcf` are **kept** and +> marked **Superseded** where the real baseline changes the verdict. +> **Where this file and the runbook disagree, `PROD_UPGRADE_PLAN.md` is +> authoritative.** +> +> **Original scope (superseded baseline):** state/compatibility/deploy +> delta from tag `0.20260717.0` / `149ebcf` to `main` @ `d486f33`, +> audited 2026-08-10 before the runbook’s fact-check revisions. + + +--- + +## Executive Summary & Delta Inventory + +Corrected against `d2afa90..35e3f71` (conservative production floor → +classification target; see Status header). Original classifications from +the `149ebcf` audit are retained in the body and marked superseded where +the real baseline changes the verdict. + +| Item | Rescope (`d2afa90..HEAD`) | Original class (`149ebcf` audit) | Blast radius now | Mitigation now | +| :--- | :--- | :--- | :--- | :--- | +| **a) DO Classes & Migrations** | **ALREADY DEPLOYED** | `[NEEDS MIGRATION]` / `[FORWARD-COMPATIBLE]` | None for this cutover. `wrangler.toml` declares identical `v1`/`RoomDO` + `v2`/`AuthDO` at `d2afa90` and `HEAD`; live `GET /_auth/whoami` → `enabled:true` requires `env.AUTH` bound, so `v2` is applied. | No DO lifecycle step. Gradual `versions deploy` is available. | +| **b) DO Storage Key Schema** | **ALREADY DEPLOYED** (keys + authorize) | `[FORWARD-COMPATIBLE]` / `[BREAKING-ON-ROLLBACK]` | No declassify on rollback inside the real range: `meta:access`/`meta:acl` and `lib/authorize.ts` exist at `d2afa90` and `git diff d2afa90..HEAD -- …/authorize.ts` is empty. Residual risk is session **lockout** from the cookie rename (see **i**), not public exposure. | Do not strip `meta:*`. Prefer rollback to current prod version; deep pre-passkey rollback is outside this cutover. | +| **c) D1 Schema & Migrations** | **ALREADY DEPLOYED** (schema) | `[FORWARD-COMPATIBLE]` | `packages/worker/migrations/` is byte-identical across `d2afa90..HEAD`. D1 still holds index + audit/chat tails only at ~1.8M-room / 10 GB scale. | Confirm applied migrations with `wrangler d1 migrations list` (operator). Tiny `rooms-index` null-prototype tweak is product-neutral. | +| **d) KV & R2 Layout** | **ALREADY DEPLOYED** (still unused) | `[FORWARD-COMPATIBLE]` | None. Bindings remain scaffolding. | None. | +| **e) Environment Variables & Secrets** | **ALREADY DEPLOYED** (passkey anchors) / **STILL IN DELTA** (minor) | `[NEEDS MIGRATION]` / `[FORWARD-COMPATIBLE]` | Passkey vars already live (`ETHERCALC_AUTH=1`, RP anchors in `wrangler.toml` at `d2afa90`; whoami `enabled:true`). Remaining: keep anchors on; optional email-binding comment tightening; no green-field passkey enable. | Do **not** set `ETHERCALC_AUTH=0`. Re-verify secrets with `wrangler secret list`. | +| **f) Compatibility Date & Capnp** | **STILL IN DELTA** | `[FORWARD-COMPATIBLE]` / `[NEEDS MIGRATION]` | `wrangler.toml` `2024-11-12` → `2026-07-21`; `config.capnp` `2025-04-01` → `2026-07-14`; plus `run_worker_first = true` on assets. | Staging must rehearse ship compat date; self-host needs workerd ≥ capnp date. | +| **g) Wire Protocol & Version Skew** | **STILL IN DELTA** | `[FORWARD-COMPATIBLE]` | Field caps, canonical parser, attachment-room binding, sheet `command-limits`, Socket.IO session/poll caps, and 1009 close behavior are new since `d2afa90`. Raw 1 MiB string-frame ceiling already existed. | Stock clients pass; oversized/malformed fail closed. See `SKEW_AND_RECONNECT.md`. | +| **h) Static Assets & CSP** | **STILL IN DELTA** | `[UNKNOWN]` | Root script extraction + CSP module land at `b7d8840` (absent in prod). Skew/AppCache behavior now documented in skew companion — no longer unknown. | `build-assets.ts` before deploy; edge purge; probe extracted `static/*.js`. | +| **i) Auth & Authz** | **PARTIALLY IN DELTA** | `[FORWARD-COMPATIBLE]` / `[NEEDS MIGRATION]` | Passkeys, `AuthDO`, ACL, private rooms **already live**. Still shipping: `ec_sess` → `__Host-ec_sess` (no legacy read), Origin/Content-Type guards, logout revocation, AuthDO rate limits / alarm trim. | Communicate re-login; private tabs may 403 until new cookie. | +| **j) SocialCalc Upgrade** | **ALREADY DEPLOYED** (engine) | `[FORWARD-COMPATIBLE]` | `socialcalc@^3.1.0` since release `0.20260716.0` (`Changes.txt:100-108`), before both old and real baselines. `d2afa90` and `HEAD` both depend on `^3.1.0`. Delta only adds headless harden + load-compat **tests**, not an engine bump. | None for cutover serialisation. | +| **k) Alarms, D1 Mirror & Scheduler** | **PARTIALLY IN DELTA** | `[FORWARD-COMPATIBLE]` | Private-index exclusion + basic alarms already at `d2afa90`. Still shipping: scheduler/`_timetrigger` hardening (token gate, refusal handling), AuthDO alarm expiry trim, related room.ts mirror/limit integration. | Public path fine; private recovery still runbook §2.4. | + +--- + +## Step 1: Baseline Determination & Evidence + +### 1.1 Established Baseline +* **Tag / Revision Baseline:** Tag `0.20260717.0` (commit `149ebcf16104b01254ca2b796beb701c88bd6ff8`, tagged Fri Jul 17 21:18:06 2026 +0800). +* **Target Revision for Audit:** `main` HEAD (`d486f33`). + +### 1.2 Evidence Grounding +1. **Git Tag Baseline:** `0.20260717.0` is the latest git release tag in the repository. + ```text + tag 0.20260717.0 + Tagger: Audrey Tang + Date: Fri Jul 17 21:18:06 2026 +0800 + commit 149ebcf16104b01254ca2b796beb701c88bd6ff8 + ``` +2. **Package Version:** Root `package.json` specifies `"version": "0.20260717.0"`. +3. **Changelog:** `Changes.txt` records `0.20260717.0 — 2026-07-17` as the last formal release header. +4. **Deployment Pipeline:** `.github/workflows/deploy-production.yml` uses `on: workflow_dispatch:` (manual trigger for `wrangler deploy`). No automated continuous deployment triggers on commit or tag. + +### 1.3 Confidence & Caveats +* **Confidence Level:** High for repo baseline; **UNKNOWN** for live production SHA. +* **Caveat / External Checks:** Because production deployments are dispatched manually via GitHub Actions or CLI `wrangler deploy`, the exact running revision on `ethercalc.net` cannot be determined from repository files alone. +* **Required External Checks to Settle Live Revision:** + 1. `wrangler deployments list --env=""` via Cloudflare API. + 2. Querying `https://ethercalc.net/_health` (currently returns `{"status":"ok","version":"0.0.0"}` because `version` is hardcoded `'0.0.0'` in `packages/worker/src/handlers/health.ts:15`, so HTTP probes cannot settle the SHA). + +### 1.4 Corrected production baseline (2026-08-10) + +Sections 1.1–1.3 record the **original** audit premise (latest git tag = +production). That premise is **false** for live `ethercalc.net`. + +* **Corrected candidate range:** `[d2afa90, b7d8840)` — + `d2afa90` = `Merge feat/passkey-permissions (#841)` (2026-07-18); + `b7d8840` = `security: harden EtherCalc trust boundaries` (2026-08-04), + exclusive upper bound because extracted page scripts are not live. +* **Conservative comparison used below:** `d2afa90..HEAD` (upper bound + on remaining work). +* **Live evidence (read-only):** whoami `enabled:true`; root serves + passkey UI + inline scripts + `manifest.appcache`; + `/static/index-bootstrap.js` → 404. Full probe log: runbook STOP + banner / live-evidence section. +* **Still UNKNOWN until operator runs it:** exact production Worker + version ID / git SHA via `wrangler deployments list`. + +--- + +## Step 2: Detailed Delta Enumeration + +### a) Durable Object Classes & Migrations +* **Tag Evidence (`149ebcf`):** `git show 149ebcf:packages/worker/wrangler.toml` + ```toml + [[durable_objects.bindings]] + name = "ROOM" + class_name = "RoomDO" + + [[migrations]] + tag = "v1" + new_sqlite_classes = ["RoomDO"] + ``` +* **Main Evidence (`d486f33`):** `packages/worker/wrangler.toml:31-48` + ```toml + [[durable_objects.bindings]] + name = "ROOM" + class_name = "RoomDO" + + [[durable_objects.bindings]] + name = "AUTH" + class_name = "AuthDO" + + [[migrations]] + tag = "v1" + new_sqlite_classes = ["RoomDO"] + + [[migrations]] + tag = "v2" + new_sqlite_classes = ["AuthDO"] + ``` +* **Analysis:** A new Durable Object class `AuthDO` (`AUTH` binding) was added along with migration `tag = "v2"` specifying `new_sqlite_classes = ["AuthDO"]`. Both `RoomDO` and `AuthDO` use SQLite-backed DO storage (`new_sqlite_classes`). No classes were renamed or deleted. +* **Classification:** `[NEEDS MIGRATION]` (wrangler deploy executes `v2` migration) / `[FORWARD-COMPATIBLE]` (existing `RoomDO` instances are untouched). +* **Rescope (`d2afa90..HEAD`) — ALREADY DEPLOYED:** The original + `[NEEDS MIGRATION]` verdict assumed production was pre-`v2`. Both + `d2afa90` and `HEAD` declare the same `[[migrations]]` block + (`tag=v1` RoomDO, `tag=v2` AuthDO) and the same `ROOM`/`AUTH` + bindings (`git diff d2afa90..HEAD -- packages/worker/wrangler.toml` + changes only `compatibility_date`, asset `run_worker_first`, cron + comments, and email-binding comments — **not** migrations/classes). + Live `GET /_auth/whoami` → `{"uid":null,"enabled":true}` requires + `env.AUTH` bound and `authEnabled`, so migration `v2` is already + applied in production. **Superseded:** “execute wrangler deploy to + register v2” / “rollback leaves AuthDO unreferenced” as cutover + steps — there is no DO lifecycle change left in this delta, which is + what makes a normal gradual rollout possible. + +--- + +### b) DO Storage Key Schema +* **Tag Evidence (`149ebcf`):** `git show 149ebcf:packages/shared/src/storage-keys.ts` + Did NOT contain `metaAccess`, `metaAcl`, or `metaGroup`. +* **Main Evidence (`d486f33`):** `packages/shared/src/storage-keys.ts:37-51` + ```ts + metaAccess: 'meta:access', + metaAcl: 'meta:acl', + metaGroup: 'meta:group', + ``` + `packages/worker/src/lib/authorize.ts:20` + ```ts + if (access == null || access === 'public') return true; + ``` + `packages/worker/src/auth-do.ts:297,325,475,589` + ```ts + // AuthDO storage keys: 'session-secret', 'challenge:', 'cred:', 'revocation:' + ``` +* **Analysis:** + * **Public/Existing Rooms:** `meta:access` and `meta:acl` are absent (`undefined`) in DO storage for existing rooms. `authorize()` returns `true` when `access == null`, maintaining complete backward compatibility. + * **Private Rooms:** Created via `POST /_/private` or `POST /_do/init-private`, setting `meta:access = 'private'` and `meta:acl`. + * **Rollback Behavior:** Older worker code (`149ebcf`) does NOT check `meta:access` or `meta:acl`. If rolled back, an old worker waking on a private room will serve `snapshot` and process commands without checking access control (exposing private sheets publicly). +* **Classification:** `[FORWARD-COMPATIBLE]` (for public rooms) / `[BREAKING-ON-ROLLBACK]` (for private rooms created post-upgrade). +* **Rescope (`d2afa90..HEAD`) — ALREADY DEPLOYED (keys + authorize):** + At `d2afa90`, `packages/shared/src/storage-keys.ts` already defines + `metaAccess` / `metaAcl` / `metaGroup`, and + `packages/worker/src/lib/authorize.ts` already implements + deny-overrides private ACL. Both files are **byte-identical** across + `d2afa90..HEAD` (`git diff` empty). **Superseded rollback hazard:** + “rollback makes private sheets public” was premised on rolling back + to pre-ACL `149ebcf` (where `authorize.ts` did not exist). A rollback + *inside* the real production candidate range keeps ACL enforcement; + it does **not** declassify. Residual authz-adjacent risk is session + **lockout** from the cookie rename in **i**, not world-readable + private sheets. Still never strip `meta:access` (missing = public). + +--- + +### c) D1 Schema & Migrations +* **Evidence:** `packages/worker/migrations/` + * `0001_rooms.sql:17-21`: `CREATE TABLE rooms (room TEXT PRIMARY KEY, updated_at INTEGER NOT NULL, cors_public INTEGER NOT NULL DEFAULT 0);` — **no sheet/snapshot content column**. + * `0002_cron.sql`: `CREATE TABLE cron_triggers (room TEXT NOT NULL, cell TEXT NOT NULL, fire_at INTEGER NOT NULL, PRIMARY KEY (room, cell, fire_at));` + * `0003_audit_chat.sql`: `CREATE TABLE audit_log (...); CREATE TABLE chat_log (...);` + * `packages/worker/src/lib/d1-schema.ts:15-18`: `CREATE TABLE IF NOT EXISTS` lazy self-healing. + * `mirrorRoomToD1` (`packages/worker/src/lib/rooms-index.ts:46-60`) upserts only `(room, updated_at)` — never SocialCalc save strings, cell values, or DO snapshots. +* **Analysis:** No D1 migration files were added between `149ebcf` and `d486f33`. All migrations are expand-only (`CREATE TABLE`). D1 is the **cross-room index + bounded audit/chat tails + cron schedule**, not a mirror of RoomDO sheet state. Authoritative sheet content lives solely in Durable Object storage (see runbook §0.2.1 / §2.1). +* **Scale (hosted):** Production cardinality is the migration-seed order of magnitude **~1.8M rooms** (`rooms-index.ts:63-82`), against D1’s hard **10 GB** per-database ceiling (runbook §0.2.2). Operator procedures must not assume a small test fleet. +* **Self-host:** Standalone `workerd` `config.capnp` binds only `ROOM`, `AUTH`, `ASSETS`, `BASEPATH` + env vars — **no `DB` / D1 binding**, so the room index, audit/chat mirror, and `cron_triggers` path do not exist there (runbook §7). +* **Classification:** `[FORWARD-COMPATIBLE]`. +* **Rescope (`d2afa90..HEAD`) — ALREADY DEPLOYED (schema):** + `git diff d2afa90..HEAD -- packages/worker/migrations` is empty; the + three SQL migrations are unchanged. D1 role (index + audit/chat + tails, not snapshots; private rooms excluded from `rooms` index) is + already the production shape at the passkey merge. No schema + migration step remains for this cutover. (A one-line + `Object.create(null)` tweak in `rooms-index.ts` is not a schema + change.) + +--- + +### d) KV & R2 Storage +* **Evidence:** `packages/worker/src/env.ts:13-183`, `packages/worker/wrangler.toml:149-152` +* **Analysis:** KV namespaces and R2 buckets are scaffolding placeholders in `wrangler.toml` and are not bound or read in `packages/worker/src`. +* **Classification:** `[FORWARD-COMPATIBLE]`. +* **Rescope (`d2afa90..HEAD`) — ALREADY DEPLOYED (still unused):** + KV/R2 remain unbound scaffolding at both ends of the real delta. + Nothing to ship. + +--- + +### e) Environment Variables & Fail-Closed Behavior +* **Main Evidence (`d486f33`):** `packages/worker/src/env.ts:7-9, 20-30`, `packages/worker/src/lib/room-index-access.ts:21-45`, `packages/worker/src/routes/auth.ts:34-43`, `packages/worker/src/auth-do.ts:244-253` + ```ts + // packages/worker/src/routes/auth.ts:34-43 + function authEnabled(env: Env): boolean { + return ( + flagEnabled(env.ETHERCALC_AUTH) && + env.AUTH !== undefined && + typeof env.ETHERCALC_RP_ID === 'string' && + env.ETHERCALC_RP_ID.length > 0 && + typeof env.ETHERCALC_ORIGIN === 'string' && + env.ETHERCALC_ORIGIN.length > 0 + ); + } + ``` + ```ts + // packages/worker/src/auth-do.ts:244-253 + const rpID = this.#env.ETHERCALC_RP_ID; + const origin = this.#env.ETHERCALC_ORIGIN; + if (!rpID || !origin) { + return plainResponse('Auth is not configured', 503); + } + const config: AuthConfig = { + rpID, + origin, + rpName: this.#env.ETHERCALC_RP_NAME || 'EtherCalc', + }; + ``` +* **Var-by-Var Breakdown:** + 1. `ETHERCALC_AUTH`: Default when unset = flag disabled (`flagEnabled(null) === false`). WebAuthn ceremonies and private rooms return 404/401 (fails closed). + 2. `ETHERCALC_RP_ID` & `ETHERCALC_ORIGIN`: WebAuthn trust anchors. If unset while `ETHERCALC_AUTH="1"`, `authEnabled` evaluates to `false` and `AuthDO` returns `503 Auth is not configured` (fails closed). Under workerd's null quirk, handled via `typeof ... === 'string' && length > 0`. `ETHERCALC_ORIGIN` also anchors CSP `connect-src`. + 3. `ETHERCALC_RP_NAME`: Human-readable relying party name. Default when unset or null = `'EtherCalc'` (`auth-do.ts:252`). Workerd's null quirk handled via `this.#env.ETHERCALC_RP_NAME || 'EtherCalc'`. + 4. `ETHERCALC_DISABLE_ROOM_INDEX`: Default when unset = falls back to `ETHERCALC_CORS` (which is `"1"` in `wrangler.toml`, so `/_rooms*` endpoints return 403). Workerd null quirk handled via `explicit != null && explicit.trim() !== ''`. + 5. `ETHERCALC_RATELIMIT`: Default when unset = `null` (rate limiting disabled; hosted relies on CF edge WAF). + 6. `ETHERCALC_KEY`: Default when unset = `undefined` (anonymous identity HMAC, `hmac(room) = room`). + 7. `EMAIL` (`send_email` binding): Default when unset = `undefined`. Handled gracefully by `buildEmailSender` returning `DisabledEmailSender` ("E-mail disabled: no send_email binding configured"). +* **Classification:** `[NEEDS MIGRATION]` (for auth environment configuration) / `[FORWARD-COMPATIBLE]`. +* **Rescope (`d2afa90..HEAD`) — ALREADY DEPLOYED (passkey anchors) / + STILL IN DELTA (minor comments only):** `d2afa90` already sets + `ETHERCALC_AUTH = "1"`, `ETHERCALC_RP_ID`, `ETHERCALC_RP_NAME`, and + `ETHERCALC_ORIGIN` in `wrangler.toml` `[vars]`. Live whoami + `enabled:true` proves those anchors plus the `AUTH` binding are + configured in production. **Superseded:** “populate WebAuthn trust + anchors before cutover” / green-field passkey enable as a migration + step. **Do not** turn `ETHERCALC_AUTH` off (self-inflicted private- + room outage — runbook STOP banner). Remaining env-adjacent delta is + documentary (www-redirect note, email `allowed_*` comment examples) + plus operator re-verification of secrets (`ETHERCALC_KEY`, migrate + token) — not a new fail-closed enablement. + +--- + +### f) Compatibility Date & Capnp Lockstep +* **Tag Evidence (`149ebcf`):** `compatibility_date = "2024-11-12"` in `wrangler.toml`. +* **Main Evidence (`d486f33`):** `packages/worker/wrangler.toml:3` + ```toml + compatibility_date = "2026-07-21" + compatibility_flags = ["nodejs_compat"] + ``` + `packages/worker/workerd/config.capnp:58-62` + ```capnp + # Standalone workerd rejects dates newer than its baked-in release date. + # Keep this at or below the workerd version pinned in bun.lock; Wrangler + # may safely use a newer date because it clamps with a warning. + compatibilityDate = "2026-07-14", + compatibilityFlags = ["nodejs_compat"], + ``` +* **Analysis:** `compatibility_date` in `wrangler.toml` moved from `"2024-11-12"` to `"2026-07-21"`. `config.capnp` uses `"2026-07-14"`. The capnp lockstep constraint requires that the standalone `workerd` binary release date is >= `2026-07-14`. +* **Classification:** `[FORWARD-COMPATIBLE]` (Cloudflare Wrangler) / `[NEEDS MIGRATION]` (Self-host workerd binary). +* **Rescope (`d2afa90..HEAD`) — STILL IN DELTA:** Confirmed against the + real floor, not only the old tag. `git diff d2afa90..HEAD -- + packages/worker/wrangler.toml` moves `compatibility_date` from + `"2024-11-12"` → `"2026-07-21"` and adds `run_worker_first = true` on + both default and staging `[assets]`. `config.capnp` moves + `compatibilityDate` from `"2025-04-01"` → `"2026-07-14"`. Hosted + Wrangler still clamps; self-host must ship a workerd binary whose + release date is ≥ the capnp pin. Staging rehearsal must use the ship + tree’s dates (runbook §C). + +--- + +### g) Wire Protocol & Version Skew +* **Evidence:** `packages/shared/src/messages.ts:117-125, 255-260` + ```ts + export const MAX_WS_FRAME_CHARS = 1024 * 1024; // 1 MiB + export const MAX_WS_ROOM_CHARS = 2_048; + export const MAX_WS_USER_CHARS = 256; + export const MAX_WS_AUTH_CHARS = 512; + export const MAX_WS_CHAT_CHARS = 16 * 1024; // 16 KiB + export const MAX_WS_CELL_CHARS = 64; + export const MAX_COMMAND_UTF8_BYTES = 120 * 1024; // 120 KiB + ``` + `packages/worker/src/room.ts:1695-1698` (attachment room binding enforcement) +* **Version Skew Analysis:** + * **Old Client -> New Worker:** Standard client messages are well below protocol caps and pass structural checks. Oversized malformed messages are dropped (returns null). + * **New Client -> Old Worker:** Old worker parses standard JSON frames without strict bounds checks. + * **Legacy `/socket.io/*` Shim:** Kept intact and bound to room attachment. +* **Classification:** `[FORWARD-COMPATIBLE]`. +* **Rescope (`d2afa90..HEAD`) — STILL IN DELTA:** Genuinely new relative + to production floor. At `d2afa90`, `parseClientMessage` is type-only; + `MAX_WS_*` field caps, canonical `parseClientMessageValue`, + `packages/shared/src/multi.ts`, Socket.IO session/poll caps, and + attachment-room equality binding land later (primarily `b7d8840`). + `command-limits.ts` is **absent** at `d2afa90` (`git cat-file -e` + fails) and is introduced by `b7d8840`. Qualification vs original + text: the raw **1 MiB** native string-frame ceiling already exists at + `d2afa90` as `MAX_FRAME` in `room.ts` (silent drop); `HEAD` keeps the + same threshold as `MAX_WS_FRAME_CHARS` but closes with `1009` and + covers binary + per-field limits. Stock frames still pass; oversized + / malformed fail closed. See `SKEW_AND_RECONNECT.md` §§2–4. + +--- + +### h) Static Assets & CSP +* **Evidence:** `scripts/build-assets.ts:33-39`, `packages/worker/src/lib/csp.ts:21-40`, `packages/worker/src/index.ts:91-95` + ```ts + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + `connect-src 'self' ${websocketAuthority(c.env.ETHERCALC_ORIGIN, requestUrl, secureTransport)}`, + ``` +* **Analysis:** HTML inline scripts were moved to five tracked static files (`static/index-bootstrap.js`, `static/index-110n.js`, `static/panels.js`, `static/start-bootstrap.js`, `static/start.js`). CSP `connect-src` uses `ETHERCALC_ORIGIN` for WebSocket authority. +* **Uncertainty Note:** Behavior under stale cached HTML + new CSP or vice versa is **UNKNOWN** (no automated integration test covers asset-vs-CSP version skew). +* **Classification:** `[UNKNOWN]`. +* **Rescope (`d2afa90..HEAD`) — STILL IN DELTA (no longer UNKNOWN):** + Live prod root still has inline bootstraps + + `` and + `GET /static/index-bootstrap.js` → 404, so page-script extraction has + **not** shipped. `b7d8840` adds `static/index-bootstrap.js`, + `index-l10n.js`, `panels.js`, `start-bootstrap.js`, `start-page.js`, + drops the manifest attribute from `index.html`, and introduces + `packages/worker/src/lib/csp.ts` (`connect-src` anchored on + `ETHERCALC_ORIGIN`). Passkey static assets were already on the + `d2afa90` root — do not re-announce them. Skew behavior is documented + in `SKEW_AND_RECONNECT.md` §6 (cached old HTML usually boots on the + new Worker; ship HTML against pre-`b7d8840` assets 404s; AppCache can + pin old master HTML because the manifest blob is unchanged). Original + `[UNKNOWN]` classification is **superseded** by that analysis. + +--- + +### i) Auth/Authz (`AuthDO`, ACL, `?auth=`, `__Host-ec_sess`) +* **Evidence:** `packages/worker/src/routes/auth.ts`, `packages/worker/src/lib/auth-session.ts`, `packages/worker/src/routes/rooms.ts:634-637`, `packages/worker/src/lib/auth.ts:34,70` +* **Analysis:** + * **Existing Rooms:** Access mode defaults to `'public'` when `meta:access` is absent in DO storage. No existing public room becomes inaccessible. + * **Legacy `?auth=` Links:** Preserved verbatim for HMAC validation when `ETHERCALC_KEY` is set; `auth=0` continues to enforce view-only access. + * **Session Cookie:** Passkey login sets `__Host-ec_sess` cookie; Worker verifies session with `AuthDO` and threads `X-EC-Uid` internally (inbound `X-EC-Uid` headers from clients are stripped by `doFetch`). +* **Classification:** `[FORWARD-COMPATIBLE]` (Existing rooms/links) / `[NEEDS MIGRATION]` (Passkey activation). +* **Rescope (`d2afa90..HEAD`) — PARTIALLY IN DELTA:** + * **Already deployed:** WebAuthn passkey UI (`static/passkey/ui.js` + on live root), `AuthDO` / `/_auth/*` (whoami route absent at + `149ebcf`, present and enabled in prod), private rooms + RoomDO + `meta:access`/`meta:acl`, principal threading, legacy `?auth=` + demotion. Passkey activation is **not** a green-field cutover + feature. + * **Still in delta (security-audit hardening on top of live auth):** + session cookie rename `ec_sess` → `__Host-ec_sess` with **no** + legacy read fallback (`git diff d2afa90..HEAD -- + packages/worker/src/lib/session.ts`); AuthDO ceremony Origin / + Content-Type guards, client-IP forwarding, logout revocation + (`routes/auth.ts` + `auth-do.ts`); www→naked origin redirect before + ceremonies. Users holding only `ec_sess` appear signed out until + re-login — temporary **lockout**, not declassification. + * **Superseded:** classifying the whole auth stack as + `[NEEDS MIGRATION]` for passkey activation against real production. + +--- + +### j) SocialCalc Upgrade & Serialization +* **Evidence:** `packages/socialcalc-headless/src/socialcalc.bundled.ts` (`createSocialCalcFactory`), `packages/socialcalc-headless/src/index.ts` (`createSpreadsheet` / `loadSocialCalc`), `packages/socialcalc-headless/test/legacy-snapshot-compat.test.ts`, `packages/socialcalc-headless/test/legacy-snapshot-reverse.node.test.ts`, `tests/oracle/recorded/exports/get-snapshot.json`, `packages/oracle-harness/src/scenarios/fixtures.ts` (`MINIMAL_SCSAVE`), `node_modules/.bun/socialcalc@3.0.8/…/dist/SocialCalc.js`. +* **Analysis:** Upgraded from SocialCalc 3.0.8 to 3.1.0 with build-time `createSocialCalcFactory()`. The sheet body still uses `version:1.5`. A genuine pre-3.1.0 multipart save recorded from the legacy oracle (A1 text `oracle`) loads through the production path without throw, exposes `datavalue === 'oracle'`, and round-trips cell data after `createSpreadsheetSave()`. +* **Forward (3.0.x → 3.1.0):** PROVED — `createSpreadsheet({ snapshot: MINIMAL_SCSAVE })` on the 3.1.0 factory. +* **Reverse (3.1.0 → 3.0.8):** PROVED — a factory built from the real npm `socialcalc@3.0.8` UMD (no `EscapeUntrustedHtml`) parses both (1) a 3.1.0 reserialisation of the oracle fixture (expanded `part:edit`/`part:audit` envelope, optional `tvf:undefined`) and (2) a realistic multi-cell 3.1.0 save with formulas; cell values and CSV survive. Unknown MIME parts are ignored gracefully. +* **Phase 1 / rollback context:** Tag `0.20260717.0` / commit `149ebcf` and `.worktrees/phase1-lifecycle` already depend on `socialcalc@^3.1.0` and ship a `socialcalc.bundled.ts` **byte-identical** to current `main` (sha256 `9efe968d…`). Phase 2 → Phase 1 therefore does **not** roll SocialCalc from 3.1.0 back to 3.0.8; reverse 3.0.8 proof is defense-in-depth for any older operator artifact, not the primary Phase 1 path. +* **Round-trip finding:** Re-serialisation is **not byte-identical**. Observed first-save rewrite under both 3.0.8 and 3.1.0 when starting from a sheet-only oracle envelope: `socialcalc:version:1.5` → `1.0`; adds empty `part:edit` + `part:audit` sections. Cell payload (`cell:A1:t:oracle`) is preserved; CSV remains `oracle\n`. +* **`tvf:undefined` determination:** **(a) fixture dangling-index artifact, not a general 3.1.0 write bug.** The oracle `MINIMAL_SCSAVE` declares `sheet:…:tvf:1` without a matching `valueformat:1:…` line, so parse sets `defaulttextvalueformat=1` while `valueformats[1]` is missing; both 3.0.8 and 3.1.0 then stringify that hole as the literal `undefined`. Native 3.1.0 creates and realistic sheets that set a real `nontextvalueformat` (e.g. `#,##0.00`) do **not** emit `undefined`. Not a cutover blocker; not filed as a SocialCalc fix here. +* **Note on stale "10/13" wording:** `packages/worker/test/oracle-replay.test.ts` loads **all** recorded JSON fixtures via `import.meta.glob('…/recorded/**/*.json')` (lines 70–73; ~45 files on disk today). It does **not** filter a 13-scenario set and does **not** mark three scenarios failed or skipped. Individually it asserts only the 9 names in `PHASE5_EXPECTED_PASS` (lines 195–207, 215–231). A separate test checks `static/socialcalc.js` for a sanitiser mechanism rather than byte-equality with the oracle fixture (lines 234–255). The describe title `10/13 green` and the meta-check `pass >= 10` over *every* loaded scenario (lines 257–271) are a **historical floor**; comments name favicon/start/root-index as intentional product divergences (§13 Q1 / glassmorphic UI / player.js), not as a current exact 3-failure count. That suite is unrelated to raw snapshot parse compatibility — proved separately by the legacy-snapshot tests above. +* **Classification:** `[FORWARD-COMPATIBLE]` (bidirectional for the oracle + realistic save shapes). Residual risk is limited to untested exotic historical save variants beyond that corpus. +* **Rescope (`d2afa90..HEAD`) — ALREADY DEPLOYED (engine):** + SocialCalc `^3.1.0` shipped in release `0.20260716.0` + (`Changes.txt:100-108`), **before** both the old assumed baseline + `149ebcf` and the real floor `d2afa90`. + `packages/socialcalc-headless/package.json` depends on + `socialcalc: ^3.1.0` at both `d2afa90` and `HEAD`. This item is + **not in the production→main delta at all** as an engine upgrade. + Commits under `packages/socialcalc-headless/` in `d2afa90..HEAD` are + headless DOM/bundle hardening plus load-compat **tests** + (`legacy-snapshot-compat`, reverse 3.0.8 proof) — defense-in-depth, + not a serialisation cutover. Original bidirectional compat proofs + remain valid background; they do not describe work left to ship. + +--- + +### k) Stateful Alarms, D1 Mirror, PITR & Scheduler +* **Evidence:** `packages/worker/src/room.ts:199, 2266-2310, 2424-2480`, `packages/worker/src/handlers/cron.ts`, `packages/worker/src/scheduled.ts`, `packages/worker/src/lib/rooms-index.ts:46-82` +* **Analysis:** + * **Alarms (`setAlarm`):** RoomDO housekeeping alarm optimized to gate re-arming on active WebSocket connections or configured `ETHERCALC_EXPIRE` TTL. AuthDO uses alarms to trim expired challenges and session revocations. + * **D1 room index (`rooms`):** `#mirrorIndex` (`room.ts:2266-2273`) early-returns when `access === 'private'`, so private rooms are **excluded** from the public D1 index / `GET /_rooms*`. `mirrorRoomToD1` writes metadata only (`room`, `updated_at`) — **not** sheet snapshots. + * **D1 audit/chat tails:** `#mirrorAudit` / `#mirrorChat` route through `#d1()` (`room.ts:2289-2310`) with **no** access check — private-room commands and chat **are** mirrored when the room is active. Idle private rooms (never edited/chatted) do not appear in those tables. `wrangler d1 export` dumps therefore contain private-room command/chat text (runbook §2.1 / §2.4 / §10 item 7). + * **Mirroring mechanics:** `audit_log` and `chat_log` receive idempotent mirrors (`INSERT OR IGNORE`); best-effort via `#d1` choke-point. + * **PITR Restore:** Endpoints (`/_do/pitr-restore`) retain an undo bookmark in DO storage. + * **Scheduler (`_timetrigger`):** Fixed bug where partial scheduler retries treated failures as success. +* **Classification:** `[FORWARD-COMPATIBLE]`. +* **Rescope (`d2afa90..HEAD`) — PARTIALLY IN DELTA:** + * **Already at `d2afa90`:** RoomDO `#mirrorIndex` early-return when + `access === 'private'`; basic `setAlarm` housekeeping; audit/chat + D1 mirror paths; PITR bookmark endpoints. Private-index exclusion + is not newly arriving. + * **Still in delta:** `scheduled.ts` / `routes/timetrigger.ts` / + `handlers/cron.ts` / `lib/cron.ts` harden across the range + (~85/+69 lines combined) — gated `/_timetrigger` (Bearer migrate + token), scheduler refusal handling (no longer treats refusals as + fires), email sender structuring. AuthDO alarm expiry boundary + work and large `room.ts` integration with command-limits / + attachment binding / mirror apply paths also ship here. Public + sheet path needs no special alarm migration step; treat scheduler + and AuthDO alarm trim as part of security-audit hardening, not as + a green-field cron introduction. + +--- + +## Step 3: Irreversibility & Mitigation Strategies + +### 3.1 Irreversible State & Deployment Steps +1. **Private Room DO State (`meta:access` & `meta:acl`):** + * *Irreversibility:* Once a room is initialized as private (`POST /_/private` or `POST /_do/init-private`), keys `meta:access` and `meta:acl` are written to DO storage. + * *Rollback Risk:* Older worker code (`149ebcf`) does not have `meta:access` or `meta:acl` or `authorize()` logic (`git show 149ebcf:packages/worker/src/lib/authorize.ts` yields `fatal: path does not exist`). On rollback, old code serves the room's `snapshot` to ANY requester, exposing private room contents publicly. + * *Mitigation:* + - **Edge deny / WAF (safe control):** If rollback past the passkey/ACL stack is required while private rooms may exist, block access with Cloudflare Edge WAF rules against the private-room candidate set (path predicates and/or enumerated room URIs) — see runbook §6.2. **Do not** strip `meta:access` / `meta:acl` keys: `authorize()` treats missing access as public (`packages/worker/src/lib/authorize.ts:20`), so deleting those keys would itself expose private rooms on any build that still consults them, and on pre-authorize builds the keys were already ignored. + - **Bounded DO PITR only:** There is no fleet-wide DO export. Per-room `POST /_/:room/pitr-restore` (runbook §2.3–§2.4) may rewind a **pre-enumerated, bounded** candidate set inside the ~30-day window; it is not a whole-instance undo at ~1.8M rooms. + - **Prefer Phase-2 lockout over deep rollback:** Rolling Phase 3 → Phase 2 (`ETHERCALC_AUTH="0"`) locks private rooms without exposing content (runbook §6.2 Path A). +2. **Durable Object Class Migration `v2` (`AuthDO`):** + * *Irreversibility:* Running `wrangler deploy` applies migration `v2`, instantiating the SQLite-backed `AuthDO`. + * *Rollback Risk:* Rollback leaves `AuthDO` storage orphaned. WebAuthn accounts registered during the new deployment window will not be reachable on old code. + * *Mitigation:* Dual-write is not applicable for AuthDO since passkeys do not exist in old code. Communicate that passkey accounts created during a rollback window must be re-registered upon re-deployment. +3. **WebAuthn Relying Party Anchors:** + * *Irreversibility:* Passkey credentials bound to `ETHERCALC_RP_ID` ("ethercalc.net") cannot be used under a different RP ID. + * *Mitigation:* Ensure `ETHERCALC_RP_ID` and `ETHERCALC_ORIGIN` are pinned in `wrangler.toml` `[vars]` prior to initial deploy. + +### 3.2 Rescope note on irreversibility (2026-08-10) + +The three bullets above retain the original `149ebcf`-baseline analysis +so the runbook’s older wording stays explainable. Against **real** +production they change as follows: + +1. **Private-room declassify on rollback — superseded for this cutover.** + Production already runs ACL-aware code (`authorize.ts` present at + `d2afa90` and unchanged through `HEAD`). Rolling back to the current + production version (or any revision in `[d2afa90, b7d8840)`) does + **not** strip access checks. The deep pre-passkey rollback path + remains theoretically hazardous but is **outside** the supported + rollback floor for this upgrade (runbook §C.4). Prefer version + rollback + edge purge; do not use `ETHERCALC_AUTH=0` as a soak. +2. **`v2` / AuthDO class migration — already applied.** No new DO class + registration remains in `d2afa90..HEAD`. Orphan-AuthDO language + applies only to a rollback *past* `v2`, which platform gradual + deploy will not do for this ship bundle. +3. **RP anchors — already pinned in production vars.** Keep them stable; + do not re-bind RP ID as part of cutover. +4. **New first-class residual:** session cookie rename + (`ec_sess` → `__Host-ec_sess`) causes temporary lockout-until-relogin + for passkey users, not content exposure. + +--- + +## Summary of UNKNOWN Items & Verification Steps + +1. **Exact Live Production Revision of `ethercalc.net`:** + * *Reason:* Production deployments are manually triggered via GitHub Actions `workflow_dispatch` or CLI `wrangler deploy`. + * *Verification Check:* Execute `wrangler deployments list --env=""` or check deployment logs in Cloudflare Dashboard. +2. **Hardcoded Version Health Endpoint:** + * *Reason:* `GET /_health` returns hardcoded `version: "0.0.0"` (`packages/worker/src/handlers/health.ts:15`), so HTTP probes cannot confirm the live git SHA. +3. **Production `ETHERCALC_KEY` Secret Status:** + * *Reason:* `ETHERCALC_KEY` is marked as `unset by default` in `wrangler.toml` and injected via `wrangler secret put`. + * *Verification Check:* Run `wrangler secret list --env=""` to verify if an HMAC secret is set in production. +4. **~~Behavior under Static Asset & CSP Version Skew~~ (re-scoped 2026-08-10):** + * *Update:* No longer a blank UNKNOWN. `SKEW_AND_RECONNECT.md` §6 + documents cached-old-HTML vs ship-Worker (usually boots; prolongs + hybrid skew; AppCache can pin old master HTML) and the sharp + inverse (ship HTML needs extracted `static/*.js`). Automated + integration coverage is still thin; operator probes + edge purge + remain load-bearing. Item **h** is **STILL IN DELTA**. +5. **~~Raw Snapshot Parsing Compatibility for Old SocialCalc 3.0.x Saves~~ (resolved 2026-08-10):** + * *Resolution:* Forward — `legacy-snapshot-compat.test.ts` feeds the genuine legacy oracle save through 3.1.0 `createSocialCalcFactory()` / `createSpreadsheet({ snapshot })`; A1=`oracle`; semantic round-trip. Reverse — `legacy-snapshot-reverse.node.test.ts` builds a factory from real npm `socialcalc@3.0.8` and loads 3.1.0-written saves (expanded envelope + realistic multi-cell); cell data preserved. Phase 1/`149ebcf` already ships the same 3.1.0 bundle as `main`, so Phase 2→1 is not a SocialCalc downgrade. `tvf:undefined` is a dangling-index artifact of the oracle fixture (also reproduced under 3.0.8), not a general 3.1.0 write bug. Item **j** remains `[FORWARD-COMPATIBLE]` (now bidirectional). + +--- + +## Legacy-room compatibility proof + +### 1. Existing Test Analysis +* **Access Gate Unit Tests:** `packages/worker/test/authorize.node.test.ts:42-46` asserts that when `access` mode is `null` / `undefined`, `authorize('read'|'write', principal, access, null)` returns `true`. +* **Integration Test Gap:** No existing integration test in `room.test.ts` or `routes-rooms.test.ts` explicitly verified a room whose storage has **NEITHER** `meta:access` **NOR** `meta:acl` set, asserting (a) anonymous HTTP GET succeeds, (b) anonymous WebSocket connect + cell edit succeeds, and (c) legacy `?auth=` query path behaves as expected (`auth=0` demoted to view-only 403 / edit rejection). + +### 2. Added Test Implementation +We added an integration test in `packages/worker/test/room.test.ts` (lines 257-353): +```ts + it('legacy room without meta:access or meta:acl storage keys remains fully accessible to anonymous users', async () => { + const { stub } = getStub('legacy-unprotected-room'); + + // Seed the room snapshot (empty body) so it exists + const seedPut = await stub.fetch('https://do/_do/snapshot', { + method: 'PUT', + body: '', + }); + expect(seedPut.status).toBe(201); + + // Verify that DO storage has neither meta:access nor meta:acl keys + await runInDurableObject(stub, async (_instance: RoomDO, state) => { + const storedAccess = await state.storage.get('meta:access'); + const storedAcl = await state.storage.get('meta:acl'); + expect(storedAccess).toBeUndefined(); + expect(storedAcl).toBeUndefined(); + }); + + // (a) Anonymous HTTP GET succeeds (returns 200) + const getRes = await stub.fetch('https://do/_do/snapshot'); + expect(getRes.status).toBe(200); + + // (b) Anonymous WebSocket connect + a cell edit succeeds + const upgradeRes = await stub.fetch( + 'https://do/_do/ws?user=anon&auth=anon&room=legacy-unprotected-room', + { headers: { Upgrade: 'websocket' } }, + ); + expect(upgradeRes.status).toBe(101); + const client = upgradeRes.webSocket!; + client.accept(); + client.send( + JSON.stringify({ + type: 'execute', + room: 'legacy-unprotected-room', + user: 'anon', + auth: 'anon', + cmdstr: 'set A1 value n 999', + }), + ); + + // Query back until the edit is reflected in the DO state + let a1Value: number | undefined; + for (let i = 0; i < 20; i++) { + const cellRes = await stub.fetch('https://do/_do/cells/A1'); + const json = (await cellRes.json()) as { datavalue?: number } | null; + if (json?.datavalue === 999) { + a1Value = 999; + break; + } + const { promise, resolve } = Promise.withResolvers(); + queueMicrotask(resolve); + await promise; + } + client.close(); + expect(a1Value).toBe(999); + + // (c) Legacy ?auth= query path: auth=0 is view-only (rejects edits), unconfigured auth works + const viewOnlyRes = await stub.fetch( + 'https://do/_do/ws?user=anon&auth=0&room=legacy-unprotected-room', + { headers: { Upgrade: 'websocket' } }, + ); + expect(viewOnlyRes.status).toBe(101); + const viewClient = viewOnlyRes.webSocket!; + viewClient.accept(); + viewClient.send( + JSON.stringify({ + type: 'execute', + room: 'legacy-unprotected-room', + user: 'anon', + auth: '0', + cmdstr: 'set A1 value n 111', + }), + ); + + // Send a follow-up ask.log to ensure the prior message was processed by the DO + const { promise: logPromise, resolve: logResolve } = Promise.withResolvers(); + viewClient.addEventListener( + 'message', + (event) => { + if (typeof event.data === 'string') logResolve(event.data); + }, + { once: true }, + ); + viewClient.send( + JSON.stringify({ + type: 'ask.log', + room: 'legacy-unprotected-room', + user: 'anon', + }), + ); + await logPromise; + viewClient.close(); + + // A1 value remains 999 because auth=0 edit was rejected + const cellResAfterViewOnly = await stub.fetch('https://do/_do/cells/A1'); + expect(((await cellResAfterViewOnly.json()) as { datavalue?: number } | null)?.datavalue).toBe(999); + }); +``` + +### 3. Execution Command & Real Verbatim Output +**Command Executed:** +```bash +./node_modules/.bin/vp run @ethercalc/worker#test +``` + +**Verbatim Terminal Output:** +```text +~/packages/worker$ vp test run --config vitest.node.config.ts ⊘ cache disabled + + RUN v4.1.10 /Users/au/w/ethercalc/packages/worker + + Test Files 52 passed (52) + Tests 1514 passed (1514) + Start at 12:14:38 + Duration 1.47s (transform 5.88s, setup 0ms, import 9.79s, tests 2.49s, environment 4ms) + +~/packages/worker$ vp test run ⊘ cache disabled + + RUN v4.1.10 /Users/au/w/ethercalc/packages/worker + + Test Files 13 passed (13) + Tests 196 passed (196) + Start at 12:23:43 + Duration 4.18s (transform 9.96s, setup 0ms, import 33.83s, tests 1.25s, environment 0ms) + +--- +vp run: 0/2 cache hit (0%). +``` + +### 4. Proof of Rollback Semantics (`meta:access='private'`) +* **Question:** On rollback to tag `149ebcf`, does old code (a) ignore the key and serve private rooms publicly, or (b) fail? +* **Proof & Code Quote:** Option **(a) — Old code ignores `meta:access` and serves private rooms publicly**. + Executing `git show 149ebcf:packages/worker/src/lib/authorize.ts` yields: + ```text + fatal: path 'packages/worker/src/lib/authorize.ts' exists on disk, but not in '149ebcf' + ``` + `packages/worker/src/lib/authorize.ts` did not exist at tag `149ebcf`. Old `RoomDO` code at `149ebcf` read only `snapshot`, `log:`, `chat:`, and `audit:` keys from DO storage (`git show 149ebcf:packages/worker/src/room.ts`), without checking `meta:access` or `meta:acl`. Thus, rolling back to `149ebcf` strips access control on private rooms, rendering them publicly readable and writable. + + **Superseded for this cutover (historical audit finding):** the live + production floor is `d2afa90`, which already has `authorize.ts` and + ACL enforcement; supported rollback is to `CURRENT_PROD_VERSION_ID` + inside `[d2afa90, b7d8840)`, **not** to `149ebcf`. Deep pre-passkey + rollback remains theoretically hazardous but is outside this upgrade’s + supported floor — see Executive Summary **b)** and §3.2. + +--- + +## Oversized legacy sheet exposure + +### 1. Call-Site Evidence & Reaction on `false` +`packages/worker/src/lib/command-limits.ts` exports `isSnapshotWithinSheetLimits(snapshot)` and `isCommandBatchWithinLimits(command, initial?)`. + +* **`isSnapshotWithinSheetLimits` Call Sites:** + 1. `packages/worker/src/room.ts:667` inside `#putSnapshot`: + ```ts + if (!isSnapshotWithinSheetLimits(body)) { + return plainResponse('snapshot exceeds sheet limits', 413); + } + ``` + *Behavior on `false`:* Returns HTTP `413 Payload Too Large` (`'snapshot exceeds sheet limits'`). + 2. `packages/worker/src/room.ts:1331` inside `#postInitPrivate`: + ```ts + if (!isSnapshotWithinSheetLimits(snapshot)) { + return plainResponse('init-private snapshot exceeds sheet limits', 413); + } + ``` + *Behavior on `false`:* Returns HTTP `413 Payload Too Large` (`'init-private snapshot exceeds sheet limits'`). + +* **`isCommandBatchWithinLimits` Call Sites:** + 1. `packages/worker/src/room.ts:2079` inside `#applyCommand`: + ```ts + const ss = await this.#getSpreadsheet(); + const attribs = ss.exportSheetData().attribs; + if ( + !isCommandBatchWithinLimits(body, { + rows: Number(attribs['lastrow'] ?? 1), + columns: Number(attribs['lastcol'] ?? 1), + }) + ) { + return null; + } + ``` + *Behavior on `false`:* Returns `null`. For HTTP POST `/_do/commands`, `#postCommands` returns HTTP `413 Payload Too Large` (`'command exceeds sheet limits'`). For WebSocket `execute` frames (`packages/worker/src/room.ts:1805`), the DO **closes the WebSocket with status code 1008** and reason `'Command exceeds sheet limits'`. + + * **Front-door `POST /_/:room` (historical audit finding, now superseded):** + At the time of this audit, the Worker front door could still answer + HTTP `202 {"command":…}` even when RoomDO had rejected the batch with + `413` on `/_do/commands` (silent acceptance at the public HTTP API). + **Superseded:** runbook §8 PR 4 / §10 item 3 landed on this branch — + both dispatch sites in `packages/worker/src/routes/rooms.ts` + (`xlsx-deferred` ~778-784 and main text-command tail ~924-936 / + success echo ~972-975) now propagate the RoomDO non-2xx status and + body (e.g. `413` `command exceeds sheet limits`), and only emit the + legacy `202` command echo on successful DO dispatch. Tests: + `POST /_/:room command mutations propagate a DO 413 sheet-limit verdict` + and `POST /_/:room returns 202 command echo on successful DO dispatch` + in `packages/worker/test/routes-rooms.node.test.ts`. Keep the original + observation: the DO-layer 413 described above was always correct; the + missing piece was Worker status propagation. + +### 2. Ingest vs. Wake/Load Path Distinction +* **Inbound Untrusted Mutations:** Snapshot limit checks apply to direct snapshot overwrites (`PUT /_do/snapshot` or `POST /_/private`). +* **Wake / Hydrate Path:** `packages/worker/src/room.ts:2161-2175` (`#getSpreadsheet()`): + ```ts + async #getSpreadsheet(): Promise { + if (this.#ss) return this.#ss; + const snapshot = await readSnapshot(this.#state.storage); + const ss = snapshot + ? createSpreadsheet({ snapshot }) + : createSpreadsheet({ log: await this.#listPrefix(STORAGE_KEYS.logPrefix) }); + ... + this.#ss = ss; + return this.#ss; + } + ``` + **Code Proof:** `#getSpreadsheet()` reads `snapshot` directly from DO storage and passes it to `createSpreadsheet()` without invoking `isSnapshotWithinSheetLimits`. Pre-existing oversized rooms stored in DO storage wake, load, and serve without error. + +### 3. Edit Behavior for Existing Oversized Sheets +`packages/worker/src/lib/command-limits.ts:150-162` (`setDimensions`): +```ts +function setDimensions( + dimensions: MutableDimensions, + rows: number, + columns: number, +): boolean { + const priorArea = declaredArea(dimensions); + const nextArea = rows * columns; + if (nextArea > MAX_SHEET_CELLS && nextArea > priorArea) return false; + dimensions.rows = rows; + dimensions.columns = columns; + return true; +} +``` +**Code Proof:** When an existing room already has `priorArea > MAX_SHEET_CELLS` (e.g. 500 rows × 500 columns = 250,000 cells), editing an existing cell yields `nextArea = 250,000 <= priorArea = 250,000`. The condition `nextArea > MAX_SHEET_CELLS && nextArea > priorArea` evaluates to `false`, allowing non-growing cell edits. Commands attempting to grow declared bounds further (e.g. `rows = 501`, area = 250,500 > 250,000) return `false` and are rejected with HTTP 413 / WS 1008 (`'command exceeds sheet limits'`). + +*Note on Structural Operations:* `command-limits.ts:313,339,352` (`boundedStructuralSheet`) explicitly checks `declaredArea(dimensions) <= MAX_SHEET_CELLS`. Therefore, structural operations (`insertrow`, `insertcol`, `deleterow`, `deletecol`, `moveinsert`) return `false` when a sheet's declared area exceeds 200,000 cells. + +### 4. Practical User Thresholds & Concrete Examples +The threshold is defined by declared area (`rows × columns <= 200,000`). +* **Passing Example (Within Limit):** A 2,000-row × 100-column sheet = 200,000 declared cells (`2,000 × 100 = 200,000 <= 200,000`). Passes snapshot ingest and area expansion checks. +* **Failing Example (Exceeds Limit for New Creation / Expansion):** A 500-row × 500-column sheet (columns A–SF) = 250,000 declared cells (`500 × 500 = 250,000 > 200,000`). Fails `isSnapshotWithinSheetLimits` for new `PUT /_/` uploads, and rejects edits that expand beyond 500×500. + +### 5. Migration Seeding Path (`packages/migrate/`) +* Migration target (`PUT /_migrate/seed/:room`) forwards payload to DO `POST /_do/seed` (`packages/worker/src/room.ts:1048-1110` `#postSeed`). +* **Code Proof:** `#postSeed` folds base snapshot + log and writes `snapshotEntries` directly to DO storage without calling `isSnapshotWithinSheetLimits`. +* **Result:** Legacy Redis/filesystem dumps containing oversized rooms are seeded into DO storage without being dropped, truncated, or rejected by limit gates. + +### 6. Integration Test Proof +We added an integration test to `packages/worker/test/room.test.ts` (lines 356–396): +```ts + it('legacy oversized sheet (>200k cells) loads, allows non-growing edits, and rejects area-expanding edits', async () => { + const { stub } = getStub('oversized-legacy-room'); + + // Build a valid SocialCalc save string with cell SF500 set (column SF = 500, row = 500 -> 250,000 cells > 200,000) + const ss = createSpreadsheet(); + ss.executeCommand('set SF500 value n 10'); + const snapshot = ss.createSpreadsheetSave(); + + // Seed an oversized legacy sheet via migration seed + const seedRes = await stub.fetch('https://do/_do/seed?name=oversized-legacy-room', { + method: 'POST', + body: JSON.stringify({ snapshot }), + }); + expect(seedRes.status).toBe(201); + + // 1. Verify DO loads and serves the oversized room without error + const cellRes = await stub.fetch('https://do/_do/cells/SF500'); + expect(cellRes.status).toBe(200); + expect(((await cellRes.json()) as { datavalue?: number } | null)?.datavalue).toBe(10); + + // 2. Non-growing edit within existing 500x500 dimensions (e.g. set SF500 value n 42) SUCCEEDS + const editRes = await stub.fetch('https://do/_do/commands', { + method: 'POST', + body: 'set SF500 value n 42', + }); + expect(editRes.status).toBe(202); + + const updatedCellRes = await stub.fetch('https://do/_do/cells/SF500'); + expect(((await updatedCellRes.json()) as { datavalue?: number } | null)?.datavalue).toBe(42); + + // 3. Area-expanding edit beyond existing 500x500 dimensions (e.g. set SF501 value n 1) is REJECTED + const expandRes = await stub.fetch('https://do/_do/commands', { + method: 'POST', + body: 'set SF501 value n 1', + }); + expect(expandRes.status).toBe(413); + expect(await expandRes.text()).toBe('command exceeds sheet limits'); + }); +``` + +**Verbatim Suite Output:** +```text +~/packages/worker$ vp test run --config vitest.node.config.ts ⊘ cache disabled + + RUN v4.1.10 /Users/au/w/ethercalc/packages/worker + + Test Files 52 passed (52) + Tests 1514 passed (1514) + Start at 12:23:41 + Duration 1.37s (transform 5.71s, setup 0ms, import 9.08s, tests 2.40s, environment 3ms) + +~/packages/worker$ vp test run ⊘ cache disabled + + RUN v4.1.10 /Users/au/w/ethercalc/packages/worker + + Test Files 13 passed (13) + Tests 196 passed (196) + Start at 12:23:43 + Duration 4.18s (transform 9.96s, setup 0ms, import 33.83s, tests 1.25s, environment 0ms) +``` + +### 7. Verdict +**Verdict:** **NON-ISSUE** (with a minor monitored risk for users attempting structural edits or area expansions on oversized sheets). +*Pre-existing oversized sheets migrate seamlessly, wake without error, and allow cell edits within their existing declared bounds; structural operations (`insertrow`, `insertcol`, `deleterow`, `deletecol`, `moveinsert`) and commands expanding declared bounds beyond both 200,000 cells and their current area are capped.* + +--- + +## What remains to ship (`d2afa90` → classification target `35e3f71`) + +Operator-facing inventory of the **real** remaining delta as classified +against tree **`35e3f71`** (see Status header). Inventory commands +(documentation-time; re-run against current tip if needed): + +```bash +git log --oneline d2afa90..35e3f71 -- packages/ scripts/ static/ +git diff --stat d2afa90..35e3f71 -- packages/ scripts/ static/ +``` + +**Caveat:** `d2afa90` is the conservative floor (earliest possible +production revision). If production is later pinned to a commit after +`d2afa90` inside `[d2afa90, b7d8840)`, some rows below are already live. +Only `wrangler deployments list` settles the exact SHA. **Post-`35e3f71` +packages note:** through pre-edit tip `a4b5acf`, `c789249` restored +search-indexing `robots.ts` (+ tests); that is an additional +still-in-delta ship item per `PROD_UPGRADE_PLAN.md` (§8 / §10), not a +change to **a)**–**k)** already-deployed verdicts. + +### Totals + +- **27 commits** touching `packages/|scripts/|static/` after `d2afa90`. +- **164 files**, **+13692 / −1724** in that path filter. +- Dominant single commit: `b7d8840` `security: harden EtherCalc trust + boundaries`, plus follow-ups (`5d37bd0` POST rejection propagation, + `2acd1d0` formdata hydrate, CI/test pins, headless harden). + +### Commit list (newest first) + +``` +290dcc7 test(socialcalc-headless): prove 3.1.0 saves load on genuine 3.0.8 +91a6d29 test(socialcalc-headless): prove legacy 3.0.x snapshots load on 3.1.0 +1824aa4 test(worker): pin both serveAsset MIME branches for player.js +b71874d test(worker): pin player.js MIME and socket.io handshake wire shape +7543317 fix(scripts): compute mutation score from mutant statuses in ratchet-verify +5d37bd0 fix(worker): propagate RoomDO command rejections from POST /_/:room +d486f33 fix(ci): repair nightly mutation and oracle replay +2acd1d0 fix(e2e): pin wrangler source and restore main-room hydrate +1648284 fix(worker): accept bare sheet: lines in snapshot limits +20c14ac fix(worker): freeze AuthDO alarm expiry boundary test +c1433b7 fix(worker): O(n) snapshot chunker for CI timeout +d0a4a46 fix(ci): pin staging Wrangler source config +ebb0817 security(docs): upgrade sharp past libvips advisories +b7d8840 security: harden EtherCalc trust boundaries +59a2d5a test: harden seam security boundaries +9f3475f Avoid account chrome menu overlap +44a98ce Harden headless DOM rendering +96fb19f Test multisheet bridge contracts +e3fad6a Harden SocialCalc headless bundle generation +b160b7d Harden self-host Docker build context +908e3d1 Fix root Vite+ build and dev workflows +d30e899 fix(worker): serve multi-sheet assets in standalone +5f731d5 test(oracle-harness): raised oracle mutation coverage thresholds +7f083e1 fix: restore multi-sheet entry flow (#838) +59562b6 Fix API command websocket broadcasts +93cc22f Merge remote-tracking branch 'origin/main' into issue-842-merge +b352c29 Fix relative WebSocket URLs in older browsers +``` + +### By theme + +#### 1. Security hardening (primary ship payload) + +Lands mostly at `b7d8840` and tightens trust boundaries already partially +present at the passkey merge: + +- **Protocol / intake:** shared message field caps + canonical parser + (`packages/shared/src/messages.ts`); multi-sheet TOC caps (`multi.ts`); + Socket.IO session/poll/queue caps (`socketio-shim`); RoomDO attachment- + room binding; sheet/command limits (`command-limits.ts` — **new file**); + formula limits in headless; XLSX/import/cross-sheet bounds; HTML export + sanitizer tightening; rate-limit / room-name / create-limit harden. +- **HTTP / Worker edge:** global security headers + CSP module + (`lib/csp.ts`, `ETHERCALC_ORIGIN`-anchored `connect-src`); + `run_worker_first`; body guards; trusted-proxy / client-IP handling; + gated `/_timetrigger`; scheduler refusal handling. +- **Auth harden (not green-field auth):** `__Host-ec_sess` rename; AuthDO + ceremony Origin/Content-Type guards; logout revocation; per-IP ceremony + limits; www→naked redirect. +- **Client XSS / multi-sheet trust:** graph panel sanitizer, + `postMessage`/TOC harden, passkey chrome layout tweak. + +Largest product files in the diff (illustrative): `room.ts` (+540/−179), +`formula-limits.ts` (+650), `command-limits.ts` (+464), `auth-do.ts` +(+181/−39), `messages.ts` (+172), `socketio-shim/adapter.ts` (+126), +`index.ts` (+131/−20). + +#### 2. API contract changes operators/clients will notice + +- `POST /_/:room` propagates RoomDO non-2xx (e.g. **413** sheet limits) + instead of a false **202** — `5d37bd0`. +- WS close **1008** / **1009** on over-limit execute / oversize frames + (failure mode change vs silent drop for the 1 MiB case). +- Stricter reject of malformed Socket.IO / native frames and cross-room + spoofed `parsed.room` (except documented formdata sibling path fixed + in client `2acd1d0`). +- Public anonymous write on public rooms remains by design. + +#### 3. Assets / UI layout + +- Extract root inline scripts → `static/index-bootstrap.js`, + `index-l10n.js`, `panels.js`, `start-bootstrap.js`, `start-page.js` + (`b7d8840`); drop `manifest` attribute from `index.html`. +- `scripts/build-assets.ts` enforces the REQUIRED_PAGE_SCRIPTS set. +- Multi-sheet entry restore (`7f083e1`), relative WS URL fix (`b352c29`), + standalone multi asset serve (`d30e899`), account-menu overlap + (`9f3475f`). +- Passkey UI already deployed at floor — only small chrome/CSS deltas + remain in tree. + +Approx theme weight under path filter: ~8 asset files (+492/−4) plus +related client sources counted under security/API above. + +#### 4. Infra / runtime pins + +- `wrangler.toml`: `compatibility_date` `2024-11-12` → `2026-07-21`; + `run_worker_first = true`; comment-only cron/email binding edits. +- `workerd/config.capnp`: `compatibilityDate` `2025-04-01` → `2026-07-14`. +- Self-host Docker build-context harden (`b160b7d`); Helm hardening + check script; smoke-selfhost; staging wrangler `--config` pins; + sharp/libvips bump in docs image path; Vite+ workflow scripts. + +#### 5. Tests / CI (majority of line volume) + +~78 test/CI files (+8858/−756) pinning the above contracts: room limits, +auth-do, CSP, routes-security, socketio adapter, shared messages, legacy +snapshot forward/reverse, oracle-harness mutation thresholds, e2e +wrangler source pin, mutation ratchet-verify fix. These ship with the +bundle but are not user-visible cutover steps. + +#### 6. Explicitly **not** remaining (do not re-ship as new) + +| Topic | Why not in remaining delta | +| :--- | :--- | +| DO migration `v2` / `AuthDO` class | Identical at both ends; live whoami proves applied | +| `meta:access` / `meta:acl` / `authorize()` | Present and unchanged since `d2afa90` | +| Passkey enable + RP env vars | Live `enabled:true`; vars on at floor | +| SocialCalc 3.0.8 → 3.1.0 engine bump | `0.20260716.0`; both ends `^3.1.0` | +| D1 SQL migrations 0001–0003 | Byte-identical across range | +| KV/R2 | Still unused scaffolding | + +### Cutover implication (pointer only) + +Because no new Durable Object class lifecycle remains, Cloudflare +**gradual `versions deploy`** is available for this ship bundle (runbook +§C). Residual operator risks are cookie rename lockout, asset/AppCache +skew, stricter protocol/sheet limits, and truthful 413 on over-limit +POSTs — not private-sheet declassification and not a green-field +passkey launch. diff --git a/docs/migration/INVENTORY.md b/docs/migration/INVENTORY.md new file mode 100644 index 00000000..ba3eb95f --- /dev/null +++ b/docs/migration/INVENTORY.md @@ -0,0 +1,2512 @@ +# EtherCalc migration inventory + +Mechanical repository inventory collected on 2026-08-10 from `/Users/au/w/ethercalc`. Source quotations are prefixed with their original 1-based line number; the text after the first colon is verbatim. The collection scope for path/content scans is Git-tracked files plus non-ignored untracked files present in the worktree, excluding this output file itself. + +> **Status:** Mechanical verbatim dump of config/workflows/migrations/self-host +> artifacts collected early on 2026-08-10. Supporting evidence for +> `PROD_UPGRADE_PLAN.md` — not a procedure. Spot-checked against the primary +> tree on reconcile: full `packages/worker/wrangler.toml` dump and all three +> D1 migration SQL dumps still match byte-for-byte; this inventory never +> dumped `workerd/config.capnp` (self-host bindings are in the runbook §7). +> **Where interpretive prose elsewhere disagrees with the runbook, the +> runbook is authoritative.** Do not treat this dump as a substitute for +> reading live files after further branch edits. + + +## Summary / absences + +- **Wrangler configs:** exactly one: `packages/worker/wrangler.toml`. It defines production custom domains, two SQLite Durable Objects, one D1 database, Workers Assets, vars, cron, one Text rule, and a staging overlay. +- **Deploy workflows exist:** manual production Worker deploy and manual docs Pages deploy. npm and Docker publish on `0.2*.*` tags (with manual dispatch options). Nightly performs a staging **dry-run only**. +- **No automatic production Worker deploy on push/tag:** `deploy-production.yml` is `workflow_dispatch` only and requires `confirm == 'deploy'`. +- **D1 migrations exist:** three ordered SQL files, `0001_rooms.sql`, `0002_cron.sql`, `0003_audit_chat.sql`. There is **no root `migrations/` directory**. +- **No active KV, R2, Queue, AI, or `send_email` binding:** `send_email` is only a commented example. No gradual-deployment configuration or use of the word `gradual` was found. +- **Self-host artifacts exist:** three Dockerfiles, four Compose files, a Helm chart, and nginx config. +- **Tool-version files absent:** no `.tool-versions`, `.nvmrc`, or `.node-version`. Bun is pinned by `packageManager` and Docker; Node is pinned only by Docker base-image major/tag; Wrangler manifests use caret ranges and `bun.lock` resolves exact versions. + +## 1. Wrangler configuration +### Files found + +- `packages/worker/wrangler.toml` + +No `wrangler.json`, `wrangler.jsonc`, or additional `wrangler.toml` was found outside ignored/generated state. + +### Field/binding inventory + +| Item | Verbatim source | +|---|---| +| name | `packages/worker/wrangler.toml:1` — `name = "ethercalc"` | +| main | `packages/worker/wrangler.toml:2` — `main = "src/index.ts"` | +| compatibility_date | `packages/worker/wrangler.toml:3` — `compatibility_date = "2026-07-21"` | +| compatibility_flags | `packages/worker/wrangler.toml:4` — `compatibility_flags = ["nodejs_compat"]` | +| workers.dev | `packages/worker/wrangler.toml:13` — `workers_dev = false` | +| routes/custom domains | `packages/worker/wrangler.toml:20-27` — `ethercalc.net` and `www.ethercalc.net`, both `custom_domain = true`, `zone_name = "ethercalc.net"` | +| Durable Object binding | `packages/worker/wrangler.toml:31-33` — `ROOM` → `RoomDO` | +| Durable Object binding | `packages/worker/wrangler.toml:37-39` — `AUTH` → `AuthDO` | +| migration v1 | `packages/worker/wrangler.toml:41-43` — `new_sqlite_classes = ["RoomDO"]` | +| migration v2 | `packages/worker/wrangler.toml:45-47` — `new_sqlite_classes = ["AuthDO"]` | +| production vars | `packages/worker/wrangler.toml:57-68` — `BASEPATH`, `ETHERCALC_CORS`, `ETHERCALC_AUTH`, `ETHERCALC_RP_ID`, `ETHERCALC_RP_NAME`, `ETHERCALC_ORIGIN` | +| staging overrides | `packages/worker/wrangler.toml:72-108` — distinct name, workers.dev/preview URLs, empty routes, vars, DO bindings, D1, Assets, and empty crons | +| Assets | `packages/worker/wrangler.toml:126-129` — directory `../../assets`, binding `ASSETS`, `run_worker_first = true` | +| Cron | `packages/worker/wrangler.toml:134-135` — `*/1 * * * *` | +| send_email | `packages/worker/wrangler.toml:144-147` — commented example only; not active | +| D1 | `packages/worker/wrangler.toml:164-168` — `DB`, `ethercalc_rooms`, database id `bd9247bd-5b50-4c47-8ce6-de3196511684`, migrations `./migrations` | +| rule | `packages/worker/wrangler.toml:181-184` — Text globs for `SocialCalc.js`, fallthrough true | +| KV/R2/Queues/AI | `packages/worker/wrangler.toml:149-151` states KV/R2 are deferred; no KV, R2, Queue, or AI tables occur in the config | + +### Full primary Worker config + +**`packages/worker/wrangler.toml:1-194`** + +~~~~~~~text +1:name = "ethercalc" +2:main = "src/index.ts" +3:compatibility_date = "2026-07-21" +4:compatibility_flags = ["nodejs_compat"] +5: +6:# Disable the `*.workers.dev` hostname for production (L-14). It serves the +7:# identical Worker but lives OUTSIDE the `ethercalc.net` zone, so any zone- +8:# scoped WAF / Rate Limiting rules (the whole §13 Q7 abuse-prevention plan) +9:# do NOT apply to it — an attacker could hit the workers.dev URL and bypass +10:# every rate-limit. With this false, only the zone-protected custom domains +11:# below serve traffic. Migration tooling must target the custom domain +12:# (https://ethercalc.net) rather than the old workers.dev URL. +13:workers_dev = false +14: +15:# Custom domains. Requires the `ethercalc.net` zone to live under this +16:# Cloudflare account (one-time nameserver swap at the registrar). On +17:# deploy, wrangler auto-creates proxied DNS records and provisions TLS. +18:# `override_existing_dns_record = true` replaces the legacy A/CNAME +19:# records (2026-04-22 cutover from Zappa/Node to this worker). +20:[[routes]] +21:pattern = "ethercalc.net" +22:zone_name = "ethercalc.net" +23:custom_domain = true +24:[[routes]] +25:pattern = "www.ethercalc.net" +26:zone_name = "ethercalc.net" +27:custom_domain = true +28:# Durable Object: one instance per spreadsheet room. +29:# Holds live SocialCalc state, WebSocket clients, and the snapshot/log/audit +30:# storage. See AGENTS.md §3.2 and §3.3. +31:[[durable_objects.bindings]] +32:name = "ROOM" +33:class_name = "RoomDO" +34: +35:# Phase A — singleton AuthDO: WebAuthn credentials, challenges, and the +36:# session-signing secret. Addressed only as `idFromName('auth')`. +37:[[durable_objects.bindings]] +38:name = "AUTH" +39:class_name = "AuthDO" +40: +41:[[migrations]] +42:tag = "v1" +43:new_sqlite_classes = ["RoomDO"] +44: +45:[[migrations]] +46:tag = "v2" +47:new_sqlite_classes = ["AuthDO"] +48: +49:# Default vars — overridden per-deploy by `wrangler secret put` or +50:# wrangler.toml overlays. `ETHERCALC_KEY` is unset by default (anonymous +51:# mode / identity HMAC); production deploys should run +52:# `wrangler secret put ETHERCALC_KEY` instead of committing a value. +53:# `BASEPATH` is empty by default (legacy `--basepath` CLI flag). +54:# +55:# §13 Q6: the CLI (`bin/ethercalc --key …`, Phase 11) will also set +56:# ETHERCALC_KEY in the Miniflare env for self-host parity. +57:[vars] +58:BASEPATH = "" +59:ETHERCALC_CORS = "1" +60:# Phase A — passkey auth. `ETHERCALC_AUTH` is the master switch for the +61:# `/_auth/*` ceremonies and the private-room routes. The RP ID / origin +62:# are WebAuthn trust anchors: they MUST match the site users visit. +63:# NOTE: WebAuthn origins are exact. The Worker redirects the configured +64:# origin's `www` alias to this naked origin before starting any ceremony. +65:ETHERCALC_AUTH = "1" +66:ETHERCALC_RP_ID = "ethercalc.net" +67:ETHERCALC_RP_NAME = "EtherCalc" +68:ETHERCALC_ORIGIN = "https://ethercalc.net" +69: +70:# Staging overlay — isolated from production routes and cron triggers. It uses +71:# its own D1 database and a temporary workers.dev hostname for release checks. +72:[env.staging] +73:name = "ethercalc-staging" +74:workers_dev = true +75:preview_urls = true +76:routes = [] +77: +78:[env.staging.vars] +79:BASEPATH = "" +80:ETHERCALC_CORS = "1" +81:# Phase A — passkey auth is live on staging for the WebAuthn acceptance test. +82:# RP ID / origin MUST match the workers.dev hostname exactly. +83:ETHERCALC_AUTH = "1" +84:ETHERCALC_RP_ID = "ethercalc-staging.audreyt.workers.dev" +85:ETHERCALC_RP_NAME = "EtherCalc Staging" +86:ETHERCALC_ORIGIN = "https://ethercalc-staging.audreyt.workers.dev" +87: +88:[[env.staging.durable_objects.bindings]] +89:name = "ROOM" +90:class_name = "RoomDO" +91: +92:[[env.staging.durable_objects.bindings]] +93:name = "AUTH" +94:class_name = "AuthDO" +95: +96:[[env.staging.d1_databases]] +97:binding = "DB" +98:database_name = "ethercalc_rooms_staging" +99:database_id = "273b1db3-17bc-44dd-bbc2-62ce1727abde" +100:migrations_dir = "./migrations" +101: +102:[env.staging.assets] +103:directory = "../../assets" +104:binding = "ASSETS" +105:run_worker_first = true +106: +107:[env.staging.triggers] +108:crons = [] +109: +110:# Workers Assets binding (Phase 4.1/11). `directory` is resolved relative +111:# to this wrangler.toml, so it points up two levels to the repo-root +112:# `assets/` produced by `scripts/build-assets.ts`. That builder copies the +113:# curated set of static files (index.html, start.html, panels.html, +114:# icons, manifest.appcache, manifest.json, l10n/*.json, the built +115:# single-sheet client at static/player.js, the SocialCalc runtime at +116:# static/socialcalc.js, and the multi-sheet React app under multi/). +117:# +118:# The legacy repo root contains `node_modules/` and other >25 MiB files +119:# that trip Workers' per-asset size limit, so we point at the curated +120:# directory instead of `./`. Also `assets/` is .gitignored — CI rebuilds +121:# it before `wrangler deploy --dry-run`. +122:# +123:# Runtime fallback: when `ASSETS` is unbound (test env), the route layer +124:# at `src/routes/assets.ts` returns 404. Integration tests stub the +125:# binding with a mock Fetcher to exercise the proxied path. +126:[assets] +127:directory = "../../assets" +128:binding = "ASSETS" +129:run_worker_first = true +130: +131:# Phase 9 — Cron Trigger. Cloudflare invokes `scheduled()` every minute. +132:# The legacy HTTP trigger remains only for self-host operators and requires +133:# `Authorization: Bearer $ETHERCALC_MIGRATE_TOKEN`. +134:[triggers] +135:crons = ["*/1 * * * *"] +136: +137:# Cloudflare Email Service binding. Commented out by default because sender +138:# domain onboarding and explicit recipient policy are deployment-specific. +139:# Spreadsheet content can schedule mail, so production bindings MUST restrict +140:# both sender and destination addresses rather than expose account-wide quota. +141:# The structured `EMAIL.send({from: {email},to,subject,text})` call is +142:# implemented in `src/lib/email.ts`; unbound deployments report email disabled. +143:# +144:# [[send_email]] +145:# name = "EMAIL" +146:# allowed_sender_addresses = ["noreply@example.com"] +147:# allowed_destination_addresses = ["alerts@example.com"] +148: +149:# KV, R2 bindings will be added in subsequent phases as they are used. +150:# Scaffolding them eagerly would force empty bindings into the local +151:# Miniflare state and muddy the test environment. +152: +153:# D1 — cross-room index mirror (Phase 5.1). DO storage is the +154:# authoritative source of truth for a single room; this table provides +155:# the cross-room query surface needed by `/_rooms`, `/_roomlinks`, +156:# `/_roomtimes`. Mirror writes land in `src/room.ts` after every +157:# snapshot mutation; reads in `src/routes/rooms.ts` fall back to empty +158:# when the binding is absent (Node unit tests without Miniflare). +159:# +160:# Binding name is `DB`. Schema at `migrations/0001_rooms.sql`. The +161:# `database_id` placeholder is fine for local Miniflare and `wrangler +162:# deploy --dry-run` — a real id is required only for an actual +163:# `wrangler deploy`. See §10.2 for the Redis → D1 mapping table. +164:[[d1_databases]] +165:binding = "DB" +166:database_name = "ethercalc_rooms" +167:database_id = "bd9247bd-5b50-4c47-8ce6-de3196511684" +168:migrations_dir = "./migrations" +169: +170:# Load SocialCalc.js as a Text module so `new Function(rawSource)` inside +171:# `@ethercalc/socialcalc-headless` can eval it. The `?raw` Vite suffix +172:# handles this under vitest-pool-workers (Vite resolves it directly); +173:# wrangler's esbuild bundler needs an explicit rule, otherwise it tries +174:# to parse the 27k-line UMD (sloppy-mode `delete varname;` that esbuild +175:# rejects in strict ESM). +176:# +177:# Important: this rule is only consumed by `wrangler deploy`. The +178:# `vitest.config.ts` sets its miniflare options directly (no `wrangler: +179:# { configPath }`) so this rule doesn't leak into the vitest runner and +180:# mangle `?raw` imports with `?mf_vitest_force=Text`. +181:[[rules]] +182:type = "Text" +183:globs = ["**/SocialCalc.js?raw", "**/SocialCalc.js"] +184:fallthrough = true +185: +186:# `unsafe_eval` binding is no longer needed: `socialcalc.bundled.ts` is +187:# transpiled at build time into the worker bundle (see +188:# `packages/socialcalc-headless/scripts/build.js`). No runtime `eval` / +189:# `new Function` calls remain, so CF validation no longer needs the +190:# capability object. If we ever revert to `?raw`-import + eval, re-add: +191:# +192:# [[unsafe.bindings]] +193:# name = "UNSAFE_EVAL" +194:# type = "unsafe_eval" +~~~~~~~ +## 2. GitHub Actions workflows +| Workflow | Triggers | Jobs | Production/staging/deploy/publish behavior | +|---|---|---|---| +| `.github/workflows/ci.yml` | `push` main; `pull_request` main | `test`, `build-selfhost`, `helm-lint`, `e2e`, `mutation-gate` | No deploy/publish. Worker build is dry-run through package script. | +| `.github/workflows/nightly.yml` | schedule `0 6 * * *`; manual `workflow_dispatch` with package list | `mutation`, `summary`, `oracle-replay`, `staging-dry-run` | Touches staging configuration only via `wrangler deploy --dry-run --config wrangler.toml --env staging`; no remote deploy. | +| `.github/workflows/publish-npm.yml` | tag push `0.2*.*`; manual `workflow_dispatch` | `validate`, `publish` | Packs and publishes npm using OIDC; manual dispatch without `PUBLISH` is dry-run. | +| `.github/workflows/deploy-docs.yml` | manual `workflow_dispatch` with `confirm` | `deploy`, `rejected` | Creates Pages project if absent and deploys docs when confirm is exactly `deploy`; environment `docs-production`. | +| `.github/workflows/deploy-production.yml` | manual `workflow_dispatch` with `confirm` | `deploy`, `rejected` | Deploys the production Worker when confirm is exactly `deploy`; environment `production`; optional live smoke. | +| `.github/workflows/publish-image.yml` | tag push `0.2*.*`; manual `workflow_dispatch` with image tag | `publish` | Pushes multi-arch images to Docker Hub as version/dev and, for tags, `latest`. | +| `.github/workflows/lemmascript.yml` | `push` main; `pull_request` main | `dafny`, `lean-gen` | No deploy/publish. | + +### Verbatim deploy/publish/staging steps + +#### Production Worker deploy + +**`.github/workflows/deploy-production.yml:45-69`** + +~~~~~~~text +45: - name: Build client assets +46: run: | +47: vp run @ethercalc/client#build +48: vp run @ethercalc/client-multi#build +49: vp exec bun scripts/build-assets.ts +50: +51: - name: Cloudflare API token +52: id: cloudflare-token +53: run: ./scripts/ci-cloudflare-token.sh +54: env: +55: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} +56: CLOUDFLARE_OAUTH_REFRESH_TOKEN: ${{ secrets.CLOUDFLARE_OAUTH_REFRESH_TOKEN }} +57: +58: - name: Deploy to Cloudflare Workers +59: run: vp exec wrangler deploy --env="" +60: working-directory: packages/worker +61: env: +62: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} +63: CLOUDFLARE_API_TOKEN: ${{ steps.cloudflare-token.outputs.cloudflare_api_token }} +64: +65: - name: Post-deploy smoke (optional) +66: if: vars.PRODUCTION_SMOKE_URL != '' +67: env: +68: PRODUCTION_SMOKE_URL: ${{ vars.PRODUCTION_SMOKE_URL }} +69: run: curl --fail --silent --show-error "${PRODUCTION_SMOKE_URL%/}/_health" +~~~~~~~ +#### Docs production Pages deploy + +**`.github/workflows/deploy-docs.yml:46-67`** + +~~~~~~~text +46: - name: Build Starlight site +47: run: vp run @ethercalc/docs#build +48: - name: Cloudflare API token +49: id: cloudflare-token +50: run: ./scripts/ci-cloudflare-token.sh +51: env: +52: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} +53: CLOUDFLARE_OAUTH_REFRESH_TOKEN: ${{ secrets.CLOUDFLARE_OAUTH_REFRESH_TOKEN }} +54: +55: - name: Ensure Pages project exists +56: run: | +57: vp exec wrangler pages project list | grep -q ethercalc-docs \ +58: || vp exec wrangler pages project create ethercalc-docs --production-branch=main +59: env: +60: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} +61: CLOUDFLARE_API_TOKEN: ${{ steps.cloudflare-token.outputs.cloudflare_api_token }} +62: +63: - name: Deploy to Cloudflare Pages +64: run: vp exec wrangler pages deploy packages/docs/dist --project-name=ethercalc-docs --branch=main --commit-dirty=true +65: env: +66: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} +67: CLOUDFLARE_API_TOKEN: ${{ steps.cloudflare-token.outputs.cloudflare_api_token }} +~~~~~~~ +#### npm release packing and publish + +**`.github/workflows/publish-npm.yml:63-129`** + +~~~~~~~text +63: - name: Pack and verify deterministic package contract +64: id: pack +65: run: | +66: set -euo pipefail +67: mkdir -p dist-release +68: +69: VERSION="$(node -p "require('./package.json').version")" +70: if [[ ! "$VERSION" =~ ^[0-9A-Za-z][0-9A-Za-z._-]{0,127}$ ]]; then +71: echo "::error::Unsafe package version" +72: exit 1 +73: fi +74: TARBALL_NAME="ethercalc-${VERSION}.tgz" +75: +76: # Pack twice and verify byte-for-byte identity (reproducibility check) +77: vp pm pack --out dist-release/pack1.tgz +78: vp pm pack --out dist-release/pack2.tgz +79: +80: SHA1="$(sha256sum dist-release/pack1.tgz | awk '{print $1}')" +81: SHA2="$(sha256sum dist-release/pack2.tgz | awk '{print $1}')" +82: +83: if [ "$SHA1" != "$SHA2" ]; then +84: echo "::error::Package is non-deterministic: pack1 sha256=$SHA1, pack2 sha256=$SHA2" +85: exit 1 +86: fi +87: +88: mv dist-release/pack1.tgz "dist-release/$TARBALL_NAME" +89: rm -f dist-release/pack2.tgz +90: +91: # Extract and assert no test, e2e, or Stryker files leaked +92: mkdir -p /tmp/pack-verify-extract +93: tar -xzf "dist-release/$TARBALL_NAME" -C /tmp/pack-verify-extract +94: +95: FORBIDDEN=( +96: "package/packages/e2e" +97: "package/packages/oracle-harness" +98: "package/.stryker-tmp" +99: "package/stryker-setup" +100: ) +101: for path in "${FORBIDDEN[@]}"; do +102: if [ -e "/tmp/pack-verify-extract/$path" ]; then +103: echo "::error::Forbidden path leaked into npm package: $path" +104: exit 1 +105: fi +106: done +107: +108: PACKED_VERSION="$(node -p "require('/tmp/pack-verify-extract/package/package.json').version")" +109: if [ "$PACKED_VERSION" != "$VERSION" ]; then +110: echo "::error::Packed package version mismatch" +111: exit 1 +112: fi +113: +114: { +115: echo "tarball-path=./dist-release/$TARBALL_NAME" +116: echo "tarball-name=$TARBALL_NAME" +117: echo "version=$VERSION" +118: } >> "$GITHUB_OUTPUT" +119: +120: # Archive the build artifact for the publish job +121: mkdir -p /tmp/artifact-sharing +122: cp "dist-release/$TARBALL_NAME" /tmp/artifact-sharing/ +123: +124: - name: Upload release artifact +125: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 +126: with: +127: name: npm-release-tarball +128: path: dist-release/ +129: retention-days: 7 +~~~~~~~ +**`.github/workflows/publish-npm.yml:142-175`** + +~~~~~~~text +142: - name: Download release artifact +143: uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 +144: with: +145: name: npm-release-tarball +146: path: dist-release +147: +148: - name: Setup Node.js (required for npm OIDC publish) +149: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 +150: with: +151: node-version: 'lts/*' +152: registry-url: 'https://registry.npmjs.org' +153: +154: - name: Publish to npm +155: # If triggered by a version tag push, or manual workflow_dispatch with confirm=PUBLISH, publish the package. +156: # Otherwise, run a dry-run check. Expression values enter through env, +157: # never shell source, and the validated build output pins the filename. +158: env: +159: CONFIRM_INPUT: ${{ github.event.inputs.confirm }} +160: EVENT_NAME: ${{ github.event_name }} +161: TARBALL: ${{ needs.validate.outputs.tarball-path }} +162: VERSION: ${{ needs.validate.outputs.version }} +163: run: | +164: set -euo pipefail +165: if [[ ! "$TARBALL" =~ ^\./dist-release/ethercalc-[0-9A-Za-z._-]+\.tgz$ ]] || [ ! -f "$TARBALL" ]; then +166: echo "Invalid release artifact path" >&2 +167: exit 1 +168: fi +169: if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$CONFIRM_INPUT" != "PUBLISH" ]; then +170: echo "Dry-run mode: verifying package signature only." +171: npm publish "$TARBALL" --provenance --dry-run +172: else +173: echo "Publishing version $VERSION to npm..." +174: npm publish "$TARBALL" --provenance +175: fi +~~~~~~~ +#### Docker Hub publish + +**`.github/workflows/publish-image.yml:37-82`** + +~~~~~~~text +37: - name: Derive image tags +38: id: tags +39: env: +40: INPUT_TAG: ${{ github.event.inputs.tag }} +41: run: | +42: set -euo pipefail +43: if [ "${GITHUB_REF_TYPE}" = "tag" ]; then +44: version="${GITHUB_REF_NAME}" +45: tags="audreyt/ethercalc:${version},audreyt/ethercalc:latest" +46: else +47: version="${INPUT_TAG:-dev}" +48: tags="audreyt/ethercalc:${version}" +49: fi +50: if [[ ! "$version" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then +51: echo "Invalid Docker tag" >&2 +52: exit 1 +53: fi +54: echo "tags=${tags}" >> "$GITHUB_OUTPUT" +55: echo "version=${version}" >> "$GITHUB_OUTPUT" +56: +57: - name: Set up QEMU (for arm64 on an amd64 runner) +58: uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 +59: +60: - name: Set up Docker Buildx +61: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 +62: +63: - name: Log in to Docker Hub +64: uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 +65: with: +66: username: ${{ secrets.DOCKERHUB_USERNAME }} +67: password: ${{ secrets.DOCKERHUB_TOKEN }} +68: +69: - name: Build and push +70: uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 +71: with: +72: context: . +73: file: ./Dockerfile +74: platforms: linux/amd64,linux/arm64 +75: push: true +76: tags: ${{ steps.tags.outputs.tags }} +77: cache-from: type=gha +78: cache-to: type=gha,mode=max +79: labels: | +80: org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} +81: org.opencontainers.image.revision=${{ github.sha }} +82: org.opencontainers.image.version=${{ steps.tags.outputs.version }} +~~~~~~~ +#### Nightly staging dry-run + +**`.github/workflows/nightly.yml:290-301`** + +~~~~~~~text +290: - name: Build client assets +291: run: | +292: vp run @ethercalc/client#build +293: vp run @ethercalc/client-multi#build +294: vp exec bun scripts/build-assets.ts +295: +296: # Vite writes `.wrangler/deploy/config.json`, redirecting Wrangler to +297: # the generated production config. An explicit source config is required +298: # here; otherwise `--env staging` silently validates production instead. +299: - name: wrangler deploy --dry-run --config wrangler.toml --env staging +300: run: vp exec wrangler deploy --dry-run --config wrangler.toml --env staging +301: working-directory: packages/worker +~~~~~~~ +## 3. Package/Vite+/vp scripts mentioning deploy, publish, migrate, seed, or release +| File | Script | Exact command | +|---|---|---| +| `packages/migrate/package.json:23` | `migrate` | `bun src/cli.ts` | +| `packages/worker/package.json:16` | `build:dry` | `vp exec wrangler deploy --dry-run` | + +`vite.config.mts` and all `vite*.config.*` files contain **no task/script entry** with these names or commands. `vite.config.mts:151` contains only the test-coverage source glob `'packages/migrate/src/**/*.ts'`, not a task. No `vp.config.*` file exists. + +## 4. D1 migration / SQL files +Filename ordering is lexical/numeric: `0001_rooms.sql`, `0002_cron.sql`, `0003_audit_chat.sql`. No root `migrations/` directory exists. + +### `packages/worker/migrations/0001_rooms.sql` + +**`packages/worker/migrations/0001_rooms.sql:1-23`** + +~~~~~~~text +1:-- Phase 5.1 — D1 rooms index. +2:-- +3:-- The DO holds the authoritative room state (snapshot/log/audit/chat/ecell +4:-- under state.storage). This table is the *cross-room* mirror used by +5:-- +6:-- GET /_rooms → list of room names (ordered by name) +7:-- GET /_roomlinks → HTML `` list of the above +8:-- GET /_roomtimes → {room: updated_at} hash, sorted desc by value +9:-- +10:-- See AGENTS.md §3.3 (data model) and §10.2 (Redis → DO/D1/KV mapping). +11:-- Every RoomDO snapshot write (POST/PUT /_/:room, POST /_do/commands) +12:-- upserts this row via `mirrorRoomToD1`; DELETE /_do/all removes it via +13:-- `deleteRoomFromD1`. +14:-- +15:-- `cors_public` is pre-wired for the `?cors=1` flag (Phase 9+ — CORS +16:-- toggle on /_rooms etc). Defaults to 0; no Phase 5.1 code sets it yet. +17:CREATE TABLE rooms ( +18: room TEXT PRIMARY KEY, +19: updated_at INTEGER NOT NULL, +20: cors_public INTEGER NOT NULL DEFAULT 0 +21:); +22:-- Descending index — /_roomtimes reads most-recent-first. +23:CREATE INDEX rooms_updated_at ON rooms(updated_at DESC); +~~~~~~~ +### `packages/worker/migrations/0002_cron.sql` + +**`packages/worker/migrations/0002_cron.sql:1-34`** + +~~~~~~~text +1:-- Phase 9 — cron_triggers table. +2:-- +3:-- Legacy behavior (src/main.ls:184-217 + src/sc.ls:220-244): a Redis hash +4:-- named `cron-list` mapped `!` to a comma-separated list of +5:-- epoch-minute timestamps. An external cron pinged `GET /_timetrigger` +6:-- every minute to fire due entries and prune them from the list. +7:-- +8:-- In the Worker world we replace both sides: +9:-- - Storage: this `cron_triggers` table (one row per fire_at). +10:-- - Pulse: Cloudflare Cron Trigger (`*/1 * * * *`) invokes the +11:-- Worker's `scheduled()` handler directly. The legacy +12:-- `GET /_timetrigger` endpoint stays wired as a backwards- +13:-- compat surface for self-host users whose external cron +14:-- still pings it (§6.1 Q3 Phase 9 brief). +15:-- +16:-- `fire_at` is epoch MINUTES (`Math.floor(Date.now()/60000)`) to keep +17:-- byte-equivalent semantics with the legacy `timeList` values. A row +18:-- is "due" when `fire_at <= now_minutes`. +19:-- +20:-- PRIMARY KEY (room, cell, fire_at) lets a single cell carry multiple +21:-- future triggers — matches the legacy comma-list. Dedup is the +22:-- caller's responsibility (the `settimetrigger` handler does +23:-- `INSERT OR IGNORE`). +24:-- +25:-- The secondary index on `fire_at` keeps the scheduled scan cheap even +26:-- as the table grows: the cron handler reads `WHERE fire_at <= ?` and +27:-- deletes the fired rows, so hot access is always by that column. +28:CREATE TABLE cron_triggers ( +29: room TEXT NOT NULL, +30: cell TEXT NOT NULL, +31: fire_at INTEGER NOT NULL, -- epoch minutes (matches legacy `timeList` semantics) +32: PRIMARY KEY (room, cell, fire_at) +33:); +34:CREATE INDEX cron_triggers_fire_at ON cron_triggers(fire_at); +~~~~~~~ +### `packages/worker/migrations/0003_audit_chat.sql` + +**`packages/worker/migrations/0003_audit_chat.sql:1-38`** + +~~~~~~~text +1:-- Storage-growth fold follow-up — durable audit_log + chat_log tables. +2:-- +3:-- The DO's `state.storage` keeps only a bounded recent tail of `audit:` and +4:-- `chat:` (the command log is a ring buffer; the alarm trims chat/audit), so +5:-- per-room DO storage stops growing without limit. The COMPLETE record is +6:-- mirrored here in D1 at append time so the trims don't lose data: +7:-- - every command mirrors its audit entry (src/room.ts #applyCommandAndMirror) +8:-- - every chat message mirrors here (src/room.ts appendChat) +9:-- and the alarm only drops DO entries that have already been mirrored. +10:-- +11:-- Both tables share the shape (room, seq, ts, body) with a (room, seq) +12:-- primary key so re-mirroring an already-durable entry is an idempotent +13:-- `ON CONFLICT(room, seq) DO NOTHING` no-op (safe under seed re-runs). +14:-- +15:-- NOTE: src/lib/d1-schema.ts also creates these lazily (CREATE TABLE IF NOT +16:-- EXISTS on first "no such table" error), so the code self-heals even before +17:-- this migration runs — there is no deploy-ordering hazard. This file is the +18:-- explicit, reviewable schema of record. +19:-- +20:-- The secondary index on `room` keeps the per-room delete (on DELETE +21:-- /_do/all) and any future per-room history read cheap. +22:CREATE TABLE audit_log ( +23: room TEXT NOT NULL, +24: seq INTEGER NOT NULL, +25: ts INTEGER NOT NULL, +26: body TEXT NOT NULL, +27: PRIMARY KEY (room, seq) +28:); +29:CREATE INDEX audit_log_room ON audit_log(room); +30: +31:CREATE TABLE chat_log ( +32: room TEXT NOT NULL, +33: seq INTEGER NOT NULL, +34: ts INTEGER NOT NULL, +35: body TEXT NOT NULL, +36: PRIMARY KEY (room, seq) +37:); +38:CREATE INDEX chat_log_room ON chat_log(room); +~~~~~~~ +## 5. Self-host artifacts +### Artifact list + +- Dockerfiles: `Dockerfile`, `tests/oracle/Dockerfile.oracle`, `deploy/legacy/Dockerfile`. +- Compose: `docker-compose.yml`, `docker-compose.proxy.yml`, `docker-compose.legacy.yml`, `tests/oracle/docker-compose.yml`. +- Helm: `helm/Chart.yaml`, `helm/values.yaml`, templates under `helm/templates/`; env-setting templates are `deployment.yaml` and `secret.yaml`. +- nginx: `deploy/nginx/ethercalc.conf`. + +### Environment variables and defaults by artifact + +| Artifact | Variable | Default/value | Source | +|---|---|---|---| +| Dockerfile | ETHERCALC_PORT | `8000` | `Dockerfile:88` | +| Dockerfile | ETHERCALC_HOST | `0.0.0.0` | `Dockerfile:89` | +| Dockerfile | ETHERCALC_DISABLE_ROOM_INDEX | `1` | `Dockerfile:90` | +| Dockerfile | ETHERCALC_DATA_DIR | `/data` | `Dockerfile:91` | +| tests/oracle/Dockerfile.oracle | ORACLE_SHA | build arg `042b731d9e98f1d30537e6cb656f65792afdecdf`; exported unchanged | `tests/oracle/Dockerfile.oracle:18-19` | +| deploy/legacy/Dockerfile | HOME | `/home/ethercalc` | `deploy/legacy/Dockerfile:16` | +| deploy/legacy/Dockerfile | ETHERCALC_KEY | unset/empty; command includes `--key` only when nonempty | `deploy/legacy/Dockerfile:21` | +| deploy/legacy/Dockerfile | ETHERCALC_BASEPATH | unset/empty; command includes `--basepath` only when nonempty | `deploy/legacy/Dockerfile:21` | +| deploy/legacy/Dockerfile | ETHERCALC_EXPIRE | unset/empty; command includes `--expire` only when nonempty | `deploy/legacy/Dockerfile:21` | +| docker-compose.yml / ethercalc | ETHERCALC_PORT | `8000` | `docker-compose.yml:42` | +| docker-compose.yml / ethercalc | ETHERCALC_HOST | `0.0.0.0` | `docker-compose.yml:43` | +| docker-compose.yml / ethercalc | ETHERCALC_KEY | empty | `docker-compose.yml:44` | +| docker-compose.yml / ethercalc | ETHERCALC_DISABLE_ROOM_INDEX | `1` | `docker-compose.yml:45` | +| docker-compose.yml / ethercalc | ETHERCALC_CORS | empty | `docker-compose.yml:46` | +| docker-compose.yml / ethercalc | ETHERCALC_BASEPATH | empty | `docker-compose.yml:47` | +| docker-compose.yml / ethercalc | ETHERCALC_EXPIRE | empty | `docker-compose.yml:48` | +| docker-compose.yml / ethercalc | ETHERCALC_AUTH | empty | `docker-compose.yml:52` | +| docker-compose.yml / ethercalc | ETHERCALC_RP_ID | empty | `docker-compose.yml:53` | +| docker-compose.yml / ethercalc | ETHERCALC_RP_NAME | empty | `docker-compose.yml:54` | +| docker-compose.yml / ethercalc | ETHERCALC_ORIGIN | empty | `docker-compose.yml:55` | +| docker-compose.yml / ethercalc | ETHERCALC_MIGRATE_TOKEN | empty | `docker-compose.yml:58` | +| docker-compose.yml / volume | ETHERCALC_LEGACY_DUMP | `./legacy-dump.rdb` | `docker-compose.yml:91` | +| docker-compose.yml / migrator | ETHERCALC_MIGRATE_TOKEN | empty | `docker-compose.yml:113,122` | +| docker-compose.yml / migrator | ETHERCALC_MIGRATE_CONCURRENCY | `8` | `docker-compose.yml:124` | +| docker-compose.proxy.yml / ethercalc | ETHERCALC_PORT | `8000` | `docker-compose.proxy.yml:28` | +| docker-compose.proxy.yml / ethercalc | ETHERCALC_HOST | literal `0.0.0.0` | `docker-compose.proxy.yml:29` | +| docker-compose.proxy.yml / ethercalc | ETHERCALC_KEY | empty | `docker-compose.proxy.yml:30` | +| docker-compose.proxy.yml / ethercalc | ETHERCALC_DISABLE_ROOM_INDEX | `1` | `docker-compose.proxy.yml:31` | +| docker-compose.proxy.yml / ethercalc | ETHERCALC_CORS | empty | `docker-compose.proxy.yml:32` | +| docker-compose.proxy.yml / ethercalc | ETHERCALC_EXPIRE | `2592000` | `docker-compose.proxy.yml:36` | +| docker-compose.proxy.yml / ethercalc | ETHERCALC_ROOM_CREATE_LIMIT | `1` | `docker-compose.proxy.yml:37` | +| docker-compose.proxy.yml / ethercalc | ETHERCALC_MIGRATE_TOKEN | empty | `docker-compose.proxy.yml:38` | +| docker-compose.proxy.yml / ethercalc | ETHERCALC_AUTH | empty | `docker-compose.proxy.yml:41` | +| docker-compose.proxy.yml / ethercalc | ETHERCALC_RP_ID | empty | `docker-compose.proxy.yml:42` | +| docker-compose.proxy.yml / ethercalc | ETHERCALC_RP_NAME | empty | `docker-compose.proxy.yml:43` | +| docker-compose.proxy.yml / ethercalc | ETHERCALC_ORIGIN | empty | `docker-compose.proxy.yml:44` | +| docker-compose.proxy.yml / proxy port | ETHERCALC_PROXY_HTTP_PORT | `80` | `docker-compose.proxy.yml:66` | +| docker-compose.proxy.yml / proxy port | ETHERCALC_PROXY_HTTPS_PORT | `443` (commented mapping) | `docker-compose.proxy.yml:69` | +| docker-compose.legacy.yml / ethercalc | REDIS_HOST | literal `redis` | `docker-compose.legacy.yml:22` | +| docker-compose.legacy.yml / ethercalc | REDIS_PORT | literal `6379` | `docker-compose.legacy.yml:23` | +| docker-compose.legacy.yml / ethercalc | ETHERCALC_KEY | empty | `docker-compose.legacy.yml:24` | +| docker-compose.legacy.yml / ethercalc | ETHERCALC_BASEPATH | empty | `docker-compose.legacy.yml:25` | +| docker-compose.legacy.yml / ethercalc | ETHERCALC_EXPIRE | empty | `docker-compose.legacy.yml:26` | +| docker-compose.legacy.yml / volume | ETHERCALC_LEGACY_REDIS_DATA | `./legacy-redis-data` | `docker-compose.legacy.yml:38` | +| docker-compose.legacy.yml / host port | ETHERCALC_PORT | `8000` | `docker-compose.legacy.yml:20` | +| tests/oracle/docker-compose.yml | REDIS_HOST | literal `redis` | `tests/oracle/docker-compose.yml:33` | +| tests/oracle/docker-compose.yml | REDIS_PORT | literal `6379` | `tests/oracle/docker-compose.yml:34` | +| tests/oracle/docker-compose.yml | OPENSHIFT_DATA_DIR | literal `.` | `tests/oracle/docker-compose.yml:35` | +| Helm deployment | ETHERCALC_HOST | literal `0.0.0.0` | `helm/templates/deployment.yaml:53-54` | +| Helm deployment | ETHERCALC_PORT | literal `8000` | `helm/templates/deployment.yaml:55-56` | +| Helm deployment | ETHERCALC_BASEPATH | omitted unless `config.basepath`; values default empty | `helm/templates/deployment.yaml:57-60`; `helm/values.yaml:96` | +| Helm deployment | ETHERCALC_DEFAULT_ROOM | omitted unless `config.defaultRoom`; values default empty | `helm/templates/deployment.yaml:61-64`; `helm/values.yaml:98` | +| Helm deployment | ETHERCALC_DISABLE_ROOM_INDEX | `1` because `config.disableRoomIndex: true` | `helm/templates/deployment.yaml:65-66`; `helm/values.yaml:100` | +| Helm deployment | ETHERCALC_CORS | omitted because `config.cors: false` | `helm/templates/deployment.yaml:67-70`; `helm/values.yaml:103` | +| Helm deployment | ETHERCALC_EXPIRE | omitted; values default empty | `helm/templates/deployment.yaml:71-74`; `helm/values.yaml:105` | +| Helm deployment | ETHERCALC_RATELIMIT | omitted; values default empty | `helm/templates/deployment.yaml:75-78`; `helm/values.yaml:108` | +| Helm deployment | ETHERCALC_ROOM_CREATE_LIMIT | omitted; values default empty | `helm/templates/deployment.yaml:79-82`; `helm/values.yaml:110` | +| Helm deployment | ETHERCALC_AUTH | omitted because `config.auth.enabled: false` | `helm/templates/deployment.yaml:83-85`; `helm/values.yaml:114` | +| Helm deployment | ETHERCALC_RP_ID | omitted; required if auth enabled; value default empty | `helm/templates/deployment.yaml:86-87`; `helm/values.yaml:115` | +| Helm deployment | ETHERCALC_RP_NAME | `EtherCalc` if auth enabled | `helm/templates/deployment.yaml:88-89`; `helm/values.yaml:116` | +| Helm deployment | ETHERCALC_ORIGIN | omitted; required if auth enabled; value default empty | `helm/templates/deployment.yaml:90-91`; `helm/values.yaml:117` | +| Helm Secret | ETHERCALC_KEY | empty unless `secrets.key` or existing Secret | `helm/templates/secret.yaml:10`; `helm/values.yaml:126` | +| Helm Secret | ETHERCALC_MIGRATE_TOKEN | empty unless `secrets.migrateToken` or existing Secret | `helm/templates/secret.yaml:11`; `helm/values.yaml:129` | +| nginx | NONE | nginx config sets no process environment variables | `deploy/nginx/ethercalc.conf:1-92` | + +### Verbatim self-host files / env-bearing chart files + +#### `Dockerfile` + +**`Dockerfile:1-93`** + +~~~~~~~text +1:# EtherCalc self-host image (§13 Q5, AGENTS.md §8 Phase 11). +2:# +3:# Launches `workerd serve` directly against a pre-bundled worker module, +4:# persisting Durable Object state to /data (a bind-mount volume). We +5:# deliberately do NOT run `wrangler dev` — wrangler's startup fetches +6:# Cloudflare metadata (`setupCf`), which fails in CI runners and in +7:# network-sandboxed environments like Sandstorm grains with an opaque +8:# "Unexpected server response: 101" that blocks the worker from ever +9:# binding a port. The standalone workerd path has no such dependency. +10:# +11:# Build pipeline inside the image: +12:# 1. bun install — workspace deps +13:# 2. bun run build:clients + build-assets.sh — static tree under /app/assets +14:# 3. scripts/build-workerd-bundle.sh — produces +15:# /app/packages/worker/workerd/worker/index.js (the bundled ES +16:# module) alongside the checked-in config.capnp. +17:# +18:# Runtime: +19:# 4. /app/bin/workerd-entrypoint.sh launches `workerd serve` with +20:# per-invocation disk overrides (`-ddo=/data/do -dassets=…`). +21: +22:FROM oven/bun:1.3.14 +23: +24:RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +25: +26:WORKDIR /app +27: +28:COPY package.json bun.lock ./ +29:COPY packages ./packages +30:COPY bin ./bin +31:COPY scripts ./scripts +32: +33:COPY index.html start.html panels.html \ +34: favicon.ico favicon-16x16.png favicon-32x32.png \ +35: android-chrome-192x192.png apple-touch-icon.png \ +36: mstile-150x150.png mstile-310x310.png \ +37: safari-pinned-tab.svg browserconfig.xml \ +38: manifest.json manifest.appcache \ +39: ./ +40:COPY l10n ./l10n +41:COPY images ./images +42:COPY static ./static +43:# Source for the license notice scripts/build-assets.ts copies to +44:# static/passkey/NOTICE (see vite.passkey.config.ts's banner comment in +45:# the passkey bundle it ships alongside). +46:COPY third-party ./third-party +47: +48:RUN bun install --frozen-lockfile +49: +50:# Build the client bundles + curated assets/ dir. +51:RUN bun run --cwd packages/client build \ +52: && bun run --cwd packages/client-multi build \ +53: && ./scripts/build-assets.sh +54: +55:# Build the standalone workerd bundle. Produces +56:# packages/worker/workerd/worker/index.js from wrangler's dry-run, plus +57:# a symlink `packages/worker/workerd/assets` → /app/assets which the +58:# runtime overrides via the `-dassets=` flag anyway. Remove the symlink +59:# after build so we can bake in an explicit path below. +60:RUN ./scripts/build-workerd-bundle.sh \ +61: && rm -f /app/packages/worker/workerd/assets +62: +63:# Persistent storage for Durable Object state. `workerd serve`'s +64:# on-disk DO backend writes SQLite files under /data/do//. +65:# Owned by the unprivileged `bun` user (uid 1000, ships with oven/bun) +66:# so named volumes initialise writable. No `USER` directive on purpose: +67:# Linux bind mounts (./ethercalc-data) arrive root-owned, so the +68:# entrypoint starts as root, chowns the data dir once, then drops to +69:# `bun` via setpriv before exec'ing workerd (SH-8). Kubernetes deploys +70:# skip the drop — the Helm chart sets runAsNonRoot + fsGroup instead. +71:RUN mkdir -p /data && chown bun:bun /data +72:VOLUME ["/data"] +73: +74:EXPOSE 8000 +75: +76:# Environment variables that override runtime behavior. Documented here so +77:# `docker inspect` users can see them without reading README: +78:# ETHERCALC_PORT — listening port (default 8000) +79:# ETHERCALC_HOST — listening address (default 0.0.0.0) +80:# ETHERCALC_KEY — HMAC secret for --key auth (§6.4) +81:# ETHERCALC_DISABLE_ROOM_INDEX — "1" to hide /_rooms* and /_exists +82:# ETHERCALC_CORS — legacy room-index gate; CORS headers are always on +83:# ETHERCALC_BASEPATH — URL prefix when running behind a reverse proxy +84:# ETHERCALC_EXPIRE — seconds of inactivity before a room is pruned +85:# ETHERCALC_RATELIMIT — optional per-IP HTTP limit (off by default) +86:# ETHERCALC_DEFAULT_ROOM — single-grain default room (302 from `/`) +87:# ETHERCALC_MIGRATE_TOKEN — enable PUT /_migrate/seed +88:ENV ETHERCALC_PORT=8000 \ +89: ETHERCALC_HOST=0.0.0.0 \ +90: ETHERCALC_DISABLE_ROOM_INDEX=1 \ +91: ETHERCALC_DATA_DIR=/data +92: +93:CMD ["bash", "/app/bin/workerd-entrypoint.sh"] +~~~~~~~ +#### `tests/oracle/Dockerfile.oracle` + +**`tests/oracle/Dockerfile.oracle:1-48`** + +~~~~~~~text +1:# Oracle image: current-main EtherCalc (pre-rewrite, LiveScript/zappajs) boot via bun. +2:# +3:# This is the ground truth that Phase 3+ scenarios record against. It +4:# reads from the Redis sidecar service on host `redis:6379`. +5:# +6:# The pinned SHA is passed in at build time via --build-arg so the +7:# docker-compose file can keep it in one place. Default matches the +8:# last legacy commit on origin/main at the time of Phase 3 kickoff: +9:# 042b731d9e98f1d30537e6cb656f65792afdecdf — "Switch from node/npm to bun" +10:# +11:# We use Node 20 on Debian + layer Bun on top: the zappajs 0.5.x stack +12:# only ships Node-compatible dependencies, and `bun install` handles +13:# all of them via its npm compat shim. `bun app.js` runs the server +14:# (app.js is compiled LiveScript output checked into the repo). +15: +16:FROM node:20-bookworm-slim +17: +18:ARG ORACLE_SHA=042b731d9e98f1d30537e6cb656f65792afdecdf +19:ENV ORACLE_SHA=${ORACLE_SHA} +20: +21:RUN apt-get update \ +22: && apt-get install -y --no-install-recommends git ca-certificates curl unzip \ +23: && rm -rf /var/lib/apt/lists/* +24: +25:# Install bun (the legacy app's runtime since the 042b731 commit). +26:RUN curl -fsSL https://bun.sh/install | BUN_INSTALL=/opt/bun bash \ +27: && ln -s /opt/bun/bin/bun /usr/local/bin/bun +28: +29:WORKDIR /srv/ethercalc +30: +31:# Clone the pinned SHA. Using a shallow clone keeps the image small; +32:# passing --depth=1 with a specific SHA requires a two-step fetch. +33:RUN git init . \ +34: && git remote add origin https://github.com/audreyt/ethercalc.git \ +35: && git fetch --depth 1 origin ${ORACLE_SHA} \ +36: && git checkout FETCH_HEAD +37: +38:# Add runtime-compat patch to stringify nextTriggerTime to avoid TypeError on newer Bun/Node +39:RUN sed -i "s/nextTriggerTime, 'utf8'/String(nextTriggerTime), 'utf8'/g" main.js sc.js +40: +41:RUN bun install +42: +43:EXPOSE 8000 +44: +45:# CORS disabled on the oracle: lets us record `/_rooms`, `/_roomlinks`, +46:# `/_roomtimes` (which are 403-gated with CORS on). Workers can opt-in +47:# CORS via the recorder scenario headers when we get there. +48:CMD ["bun", "app.js", "--port", "8000", "--host", "0.0.0.0"] +~~~~~~~ +#### `deploy/legacy/Dockerfile` + +**`deploy/legacy/Dockerfile:1-21`** + +~~~~~~~text +1:# Legacy Redis-backed EtherCalc (Node + LiveScript). +2:# Last npm release before the TypeScript rewrite: 0.20201228.1 +3:FROM node:20-bookworm-slim +4: +5:RUN apt-get update \ +6: && apt-get install -y --no-install-recommends python3 make g++ \ +7: && rm -rf /var/lib/apt/lists/* +8: +9:RUN useradd ethercalc --create-home \ +10: && npm install -g ethercalc@0.20201228.1 \ +11: && apt-get purge -y python3 make g++ \ +12: && apt-get autoremove -y +13: +14:USER ethercalc +15:WORKDIR /home/ethercalc +16:ENV HOME=/home/ethercalc +17:EXPOSE 8000 +18: +19:# REDIS_HOST / REDIS_PORT must point at the redis service. +20:# Optional: ETHERCALC_KEY, ETHERCALC_BASEPATH, ETHERCALC_EXPIRE. +21:CMD ["sh", "-c", "exec ethercalc --host 0.0.0.0 --port 8000 --cors ${ETHERCALC_KEY:+--key \"$ETHERCALC_KEY\"} ${ETHERCALC_BASEPATH:+--basepath \"$ETHERCALC_BASEPATH\"} ${ETHERCALC_EXPIRE:+--expire \"$ETHERCALC_EXPIRE\"}"] +~~~~~~~ +#### `docker-compose.yml` + +**`docker-compose.yml:1-125`** + +~~~~~~~text +1:# EtherCalc self-host compose file (§13 Q5). +2:# +3:# Default service — the standalone workerd Worker. No Redis: room state +4:# lives in Durable Object SQLite files under the `./ethercalc-data` +5:# bind-mounted at /data in the container. `docker compose up -d` is the +6:# whole install story. +7:# +8:# Migration profile — activated with `--profile migrate`, adds two +9:# short-lived services (`legacy-redis` + `migrator`) that ingest a +10:# legacy Redis dump.rdb into the new Worker in one shot. Driven by +11:# `bin/migrate-legacy.sh`; end users should not invoke the profile +12:# directly. +13: +14:services: +15: ethercalc: +16: build: +17: context: . +18: dockerfile: Dockerfile +19: image: ethercalc:selfhost +20: container_name: ethercalc +21: restart: unless-stopped +22: # Contain a Worker compromise: application bytes stay immutable and +23: # setuid/file-capability privilege gains are disabled. Durable state keeps +24: # its dedicated writable mount; libc/workerd scratch is isolated in tmpfs. +25: read_only: true +26: security_opt: +27: - no-new-privileges:true +28: tmpfs: +29: - /tmp:size=64m,mode=1777 +30: ports: +31: # Local-only by default. Internet-facing installs MUST use the proxy +32: # compose file (or an equivalent hardened reverse proxy). +33: - "127.0.0.1:${ETHERCALC_PORT:-8000}:${ETHERCALC_PORT:-8000}" +34: volumes: +35: # Persistent Durable Object state. Survives container restarts. +36: # Created on first `up` if missing. +37: - ./ethercalc-data:/data +38: environment: +39: # All variables are optional; sensible defaults live in the image. +40: # Pass through from the host so `ETHERCALC_KEY=secret docker compose +41: # up -d` Just Works without editing this file. +42: ETHERCALC_PORT: "${ETHERCALC_PORT:-8000}" +43: ETHERCALC_HOST: "${ETHERCALC_HOST:-0.0.0.0}" +44: ETHERCALC_KEY: "${ETHERCALC_KEY:-}" +45: ETHERCALC_DISABLE_ROOM_INDEX: "${ETHERCALC_DISABLE_ROOM_INDEX:-1}" +46: ETHERCALC_CORS: "${ETHERCALC_CORS:-}" +47: ETHERCALC_BASEPATH: "${ETHERCALC_BASEPATH:-}" +48: ETHERCALC_EXPIRE: "${ETHERCALC_EXPIRE:-}" +49: # Phase A — passkey auth (all four required to enable; see +50: # docs/SELFHOST_HARDENING.md). RP ID / origin must match the URL +51: # users visit, e.g. sheets.example.com / https://sheets.example.com. +52: ETHERCALC_AUTH: "${ETHERCALC_AUTH:-}" +53: ETHERCALC_RP_ID: "${ETHERCALC_RP_ID:-}" +54: ETHERCALC_RP_NAME: "${ETHERCALC_RP_NAME:-}" +55: ETHERCALC_ORIGIN: "${ETHERCALC_ORIGIN:-}" +56: # Always forwarded; the migrate endpoint returns 404 when unset +57: # so leaving it blank keeps the route invisible in normal runs. +58: ETHERCALC_MIGRATE_TOKEN: "${ETHERCALC_MIGRATE_TOKEN:-}" +59: # Health check lets the migrator service wait until /_health is +60: # answering before firing the first seed PUT. +61: healthcheck: +62: test: ["CMD-SHELL", "curl -fsS http://localhost:8000/_health >/dev/null || exit 1"] +63: interval: 2s +64: timeout: 2s +65: retries: 60 +66: start_period: 5s +67: +68: # ────────────────────────── migration profile ────────────────────────── +69: # Activated with `docker compose --profile migrate up`. Typical entry +70: # point is `./bin/migrate-legacy.sh`, which wires the dump file, token, +71: # and post-run backup in one go. +72: +73: legacy-redis: +74: profiles: ["migrate"] +75: image: redis:7-alpine +76: container_name: ethercalc-legacy-redis +77: # Redis's upstream entrypoint chowns /data/* at startup, which +78: # fails against a read-only bind mount. Bind the user's dump to +79: # an /input/ stage instead, copy it into the container-owned +80: # /data at boot, then exec redis. Side effect: peak disk use +81: # during migration is 2× the dump size (acceptable — migration +82: # is transient, and the host copy is preserved untouched). +83: entrypoint: ["/bin/sh", "-c"] +84: command: +85: - 'cp /input/dump.rdb /data/dump.rdb && exec redis-server --dir /data --dbfilename dump.rdb --save "" --appendonly no' +86: volumes: +87: # `ETHERCALC_LEGACY_DUMP` is set by `bin/migrate-legacy.sh` to +88: # the absolute path of the user's dump.rdb. `:ro` guarantees the +89: # source file is never modified even if something in the stack +90: # misbehaves. +91: - "${ETHERCALC_LEGACY_DUMP:-./legacy-dump.rdb}:/input/dump.rdb:ro" +92: healthcheck: +93: test: ["CMD", "redis-cli", "ping"] +94: interval: 2s +95: timeout: 2s +96: retries: 60 +97: start_period: 5s +98: +99: migrator: +100: profiles: ["migrate"] +101: image: ethercalc:selfhost +102: container_name: ethercalc-migrator +103: # Same image as the worker — bun + bin/ethercalc are already there. +104: # One-shot: `ethercalc migrate`, then exit. `--abort-on-container- +105: # exit` + `--exit-code-from migrator` on `docker compose up` makes +106: # the wrapper script fail loudly if the migrator itself fails. +107: depends_on: +108: legacy-redis: +109: condition: service_healthy +110: ethercalc: +111: condition: service_healthy +112: environment: +113: ETHERCALC_MIGRATE_TOKEN: "${ETHERCALC_MIGRATE_TOKEN:-}" +114: entrypoint: ["/app/bin/ethercalc"] +115: command: +116: - "migrate" +117: - "--source" +118: - "redis://legacy-redis:6379" +119: - "--target" +120: - "http://ethercalc:8000" +121: - "--token" +122: - "${ETHERCALC_MIGRATE_TOKEN:-}" +123: - "--concurrency" +124: - "${ETHERCALC_MIGRATE_CONCURRENCY:-8}" +125: restart: "no" +~~~~~~~ +#### `docker-compose.proxy.yml` + +**`docker-compose.proxy.yml:1-72`** + +~~~~~~~text +1:# Alternate internet-facing self-host recipe. It keeps workerd off the host +2:# network and places nginx in front for TLS termination and abuse throttling. +3:# +4:# docker compose -f docker-compose.proxy.yml up -d +5:# +6:# The nginx config lives at deploy/nginx/ethercalc.conf. For production HTTPS: +7:# place certificates under deploy/nginx/certs/, uncomment the 443 listener in +8:# that config, AND uncomment the 443 ports mapping on the proxy service below +9:# — or adapt the same upstream/limit rules to your existing edge. +10: +11:services: +12: ethercalc: +13: build: +14: context: . +15: dockerfile: Dockerfile +16: image: ethercalc:selfhost +17: restart: unless-stopped +18: read_only: true +19: security_opt: +20: - no-new-privileges:true +21: tmpfs: +22: - /tmp:size=64m,mode=1777 +23: expose: +24: - "8000" +25: volumes: +26: - ./ethercalc-data:/data +27: environment: +28: ETHERCALC_PORT: "${ETHERCALC_PORT:-8000}" +29: ETHERCALC_HOST: "0.0.0.0" +30: ETHERCALC_KEY: "${ETHERCALC_KEY:-}" +31: ETHERCALC_DISABLE_ROOM_INDEX: "${ETHERCALC_DISABLE_ROOM_INDEX:-1}" +32: ETHERCALC_CORS: "${ETHERCALC_CORS:-}" +33: # No ETHERCALC_BASEPATH here on purpose: the bundled nginx config +34: # proxies at the URL root and does not strip a prefix, so setting a +35: # basepath behind it produces broken links. +36: ETHERCALC_EXPIRE: "${ETHERCALC_EXPIRE:-2592000}" +37: ETHERCALC_ROOM_CREATE_LIMIT: "${ETHERCALC_ROOM_CREATE_LIMIT:-1}" +38: ETHERCALC_MIGRATE_TOKEN: "${ETHERCALC_MIGRATE_TOKEN:-}" +39: # Passkey trust anchors. All four must be set together; the public +40: # origin must be the HTTPS URL users actually visit through this proxy. +41: ETHERCALC_AUTH: "${ETHERCALC_AUTH:-}" +42: ETHERCALC_RP_ID: "${ETHERCALC_RP_ID:-}" +43: ETHERCALC_RP_NAME: "${ETHERCALC_RP_NAME:-}" +44: ETHERCALC_ORIGIN: "${ETHERCALC_ORIGIN:-}" +45: healthcheck: +46: test: ["CMD-SHELL", "curl -fsS http://localhost:8000/_health >/dev/null || exit 1"] +47: interval: 2s +48: timeout: 2s +49: retries: 60 +50: start_period: 5s +51: +52: proxy: +53: image: nginx:1.27-alpine +54: restart: unless-stopped +55: read_only: true +56: security_opt: +57: - no-new-privileges:true +58: tmpfs: +59: - /var/cache/nginx:size=64m +60: - /var/run:size=1m +61: - /tmp:size=16m,mode=1777 +62: depends_on: +63: ethercalc: +64: condition: service_healthy +65: ports: +66: - "${ETHERCALC_PROXY_HTTP_PORT:-80}:80" +67: # Uncomment for HTTPS (together with the 443 listener in +68: # deploy/nginx/ethercalc.conf and certs in deploy/nginx/certs/): +69: # - "${ETHERCALC_PROXY_HTTPS_PORT:-443}:443" +70: volumes: +71: - ./deploy/nginx/ethercalc.conf:/etc/nginx/conf.d/default.conf:ro +72: - ./deploy/nginx/certs:/etc/nginx/certs:ro +~~~~~~~ +#### `docker-compose.legacy.yml` + +**`docker-compose.legacy.yml:1-45`** + +~~~~~~~text +1:# Legacy Redis-backed EtherCalc — the pre-2026 Node/LiveScript stack. +2:# +3:# Use this when you want to keep an existing Redis data directory and are +4:# not ready to migrate to the new workerd/Durable Object self-host path. +5:# +6:# docker compose -f docker-compose.legacy.yml up -d +7:# +8:# Pin the image explicitly — `audreyt/ethercalc:latest` now ships the +9:# TypeScript rewrite and does not speak Redis. +10: +11:services: +12: ethercalc: +13: build: +14: context: . +15: dockerfile: deploy/legacy/Dockerfile +16: image: audreyt/ethercalc:0.20201228.1 +17: container_name: ethercalc-legacy +18: restart: unless-stopped +19: ports: +20: - "${ETHERCALC_PORT:-8000}:8000" +21: environment: +22: REDIS_HOST: redis +23: REDIS_PORT: "6379" +24: ETHERCALC_KEY: "${ETHERCALC_KEY:-}" +25: ETHERCALC_BASEPATH: "${ETHERCALC_BASEPATH:-}" +26: ETHERCALC_EXPIRE: "${ETHERCALC_EXPIRE:-}" +27: depends_on: +28: redis: +29: condition: service_healthy +30: +31: redis: +32: image: redis:7-alpine +33: container_name: ethercalc-legacy-redis +34: restart: unless-stopped +35: volumes: +36: # Point at your existing Redis data dir, e.g. +37: # ETHERCALC_LEGACY_REDIS_DATA=/var/lib/redis docker compose -f docker-compose.legacy.yml up -d +38: - "${ETHERCALC_LEGACY_REDIS_DATA:-./legacy-redis-data}:/data" +39: command: redis-server --appendonly yes +40: healthcheck: +41: test: ["CMD", "redis-cli", "ping"] +42: interval: 2s +43: timeout: 2s +44: retries: 30 +45: start_period: 3s +~~~~~~~ +#### `tests/oracle/docker-compose.yml` + +**`tests/oracle/docker-compose.yml:1-40`** + +~~~~~~~text +1:# Oracle stack — EtherCalc (current main) + Redis, isolated from the +2:# new worker. Pinned SHA captured at Phase 3 kickoff: +3:# oracle_sha: 042b731d9e98f1d30537e6cb656f65792afdecdf +4:# That's the last legacy LiveScript/zappajs commit on origin/main +5:# before the TypeScript rewrite scaffolding (Phases 1-2) landed on +6:# local main. Bump this whenever the upstream oracle moves — all +7:# recordings must be re-captured when it does. +8:# +9:# To bring it up: +10:# docker compose -f tests/oracle/docker-compose.yml up --build -d +11:# To tear down: +12:# docker compose -f tests/oracle/docker-compose.yml down -v +13:# +14:# The oracle listens on host :8000. Redis stays internal-only on the +15:# default bridge network. +16: +17:services: +18: redis: +19: image: redis:7-alpine +20: command: ["redis-server", "--appendonly", "yes"] +21: volumes: +22: - redis-data:/data +23: +24: ethercalc: +25: build: +26: context: . +27: dockerfile: Dockerfile.oracle +28: args: +29: ORACLE_SHA: 042b731d9e98f1d30537e6cb656f65792afdecdf +30: depends_on: +31: - redis +32: environment: +33: REDIS_HOST: redis +34: REDIS_PORT: "6379" +35: OPENSHIFT_DATA_DIR: "." +36: ports: +37: - "8000:8000" +38: +39:volumes: +40: redis-data: +~~~~~~~ +#### `helm/Chart.yaml` + +**`helm/Chart.yaml:1-22`** + +~~~~~~~text +1:apiVersion: v2 +2:name: ethercalc +3:description: EtherCalc — multi-user spreadsheet server (TypeScript rewrite, self-hosted via workerd) +4:type: application +5:# Chart version bumps on chart changes; appVersion follows the image tag +6:# produced by `.github/workflows/publish-image.yml`. +7:version: 0.3.2 +8:appVersion: "0.20260612.4" +9:home: https://ethercalc.net/ +10:sources: +11: - https://github.com/audreyt/ethercalc +12:maintainers: +13: - name: Audrey Tang +14: email: audreyt@audreyt.org +15: url: https://audreyt.org/ +16:keywords: +17: - ethercalc +18: - spreadsheet +19: - collaboration +20: - realtime +21: - socialcalc +22:icon: https://ethercalc.net/favicon.ico +~~~~~~~ +#### `helm/values.yaml` + +**`helm/values.yaml:1-151`** + +~~~~~~~text +1:# Default values for EtherCalc. +2:# +3:# IMPORTANT: EtherCalc's self-host image runs a single `workerd serve` +4:# process that owns all Durable Object state locally (under /data). Two +5:# replicas sharing the PVC will silently corrupt rooms — there's no +6:# consensus layer to keep them in sync. The chart enforces replicas: 1 +7:# and strategy: Recreate. Do NOT override these. +8: +9:image: +10: repository: audreyt/ethercalc +11: # Overrides Chart.yaml's appVersion if set. Leave empty to pin to the +12: # chart-compatible image version. +13: tag: "" +14: pullPolicy: IfNotPresent +15: +16:imagePullSecrets: [] +17:nameOverride: "" +18:fullnameOverride: "" +19: +20:serviceAccount: +21: create: true +22: annotations: {} +23: name: "" +24: +25:podAnnotations: {} +26:podLabels: {} +27: +28:# Restricted-profile defaults (SH-8). The image's unprivileged user is +29:# `bun` (uid/gid 1000); fsGroup makes the PVC writable without the +30:# entrypoint's root chown path, and the entrypoint never writes to the +31:# rootfs (socket override is a workerd CLI flag), so a read-only root +32:# filesystem works out of the box. /tmp is an emptyDir mount. +33:podSecurityContext: +34: runAsNonRoot: true +35: runAsUser: 1000 +36: runAsGroup: 1000 +37: fsGroup: 1000 +38: fsGroupChangePolicy: OnRootMismatch +39: seccompProfile: +40: type: RuntimeDefault +41:securityContext: +42: allowPrivilegeEscalation: false +43: readOnlyRootFilesystem: true +44: capabilities: +45: drop: +46: - ALL +47: +48:service: +49: type: ClusterIP +50: port: 8000 +51: +52:ingress: +53: enabled: false +54: className: "" +55: annotations: {} +56: # kubernetes.io/ingress.class: nginx +57: # cert-manager.io/cluster-issuer: letsencrypt-prod +58: # nginx.ingress.kubernetes.io/limit-rps: "10" +59: # nginx.ingress.kubernetes.io/limit-connections: "20" +60: hosts: +61: - host: ethercalc.local +62: paths: +63: - path: / +64: pathType: Prefix +65: tls: [] +66: # - secretName: ethercalc-tls +67: # hosts: +68: # - ethercalc.local +69: +70:resources: +71: # Reasonable defaults for a small spreadsheet deployment. Raise `memory` +72: # if rooms grow large — each live room holds its sheet in RAM. +73: limits: +74: cpu: 1000m +75: memory: 1Gi +76: requests: +77: cpu: 100m +78: memory: 256Mi +79: +80:persistence: +81: # Durable Object state lives on disk under /data. +82: # Disable only for ephemeral dev setups; production MUST have this on. +83: enabled: true +84: # Leave empty to use the cluster default storage class. +85: storageClass: "" +86: accessMode: ReadWriteOnce +87: size: 10Gi +88: # Retain the PVC when the chart is uninstalled so data survives a +89: # `helm uninstall` + `helm install` cycle. +90: existingClaim: "" +91: +92:# Runtime configuration. These map 1:1 to the env vars documented in the +93:# README's "Environment variables" table. +94:config: +95: # Base URL prefix when EtherCalc sits behind a reverse proxy at a sub-path. +96: basepath: "" +97: # When set, EtherCalc redirects `/` to this room name. +98: defaultRoom: "" +99: # Hide `/_rooms`, `/_roomlinks`, `/_roomtimes`, and `/_exists/:room`. +100: disableRoomIndex: true +101: # Legacy room-index gate. The Worker currently emits CORS headers +102: # unconditionally for embed compatibility. +103: cors: false +104: # Seconds of inactivity before a room is pruned. Leave empty to disable. +105: expire: "" +106: # Optional in-Worker per-IP HTTP limit (off by default). "1" = 10 req/s; +107: # belt-and-suspenders behind ingress rate limits — not a substitute. +108: rateLimit: "" +109: # Optional per-IP cap on room creation (POST /_, /_new, /_from, PUT /_/room). +110: roomCreateLimit: "" +111: # Optional passkey accounts/private-room UI. Enabling requires all three +112: # WebAuthn trust anchors; origin must be the exact public HTTPS origin. +113: auth: +114: enabled: false +115: rpId: "" +116: rpName: "EtherCalc" +117: origin: "" +118: +119:# Secrets held in a Kubernetes Secret, never in values.yaml plaintext in +120:# production. Set `existingSecret: ` to reference an external Secret +121:# you created out-of-band; otherwise the chart will create one from these +122:# fields at install time. +123:secrets: +124: existingSecret: "" +125: # HMAC key for read-only vs. edit auth. Generate with e.g. `openssl rand -hex 32`. +126: key: "" +127: # Bearer token gating `PUT /_migrate/seed/:room`. Required for +128: # `ethercalc migrate --token ...`. +129: migrateToken: "" +130: +131:livenessProbe: +132: httpGet: +133: path: /_health +134: port: http +135: initialDelaySeconds: 10 +136: periodSeconds: 30 +137: timeoutSeconds: 5 +138: failureThreshold: 3 +139: +140:readinessProbe: +141: httpGet: +142: path: /_health +143: port: http +144: initialDelaySeconds: 5 +145: periodSeconds: 10 +146: timeoutSeconds: 3 +147: failureThreshold: 3 +148: +149:nodeSelector: {} +150:tolerations: [] +151:affinity: {} +~~~~~~~ +#### `helm/templates/deployment.yaml` + +**`helm/templates/deployment.yaml:1-145`** + +~~~~~~~text +1:apiVersion: apps/v1 +2:kind: Deployment +3:metadata: +4: name: {{ include "ethercalc.fullname" . }} +5: labels: +6: {{- include "ethercalc.labels" . | nindent 4 }} +7:spec: +8: # HARD INVARIANT: replicas MUST stay 1 and strategy MUST be Recreate. +9: # The worker is a single-process workerd runtime; two pods sharing +10: # the PVC would corrupt Durable Object state silently, with no +11: # consensus layer to reconcile them. See chart values.yaml preamble. +12: replicas: 1 +13: strategy: +14: type: Recreate +15: selector: +16: matchLabels: +17: {{- include "ethercalc.selectorLabels" . | nindent 6 }} +18: template: +19: metadata: +20: {{- with .Values.podAnnotations }} +21: annotations: +22: {{- toYaml . | nindent 8 }} +23: {{- end }} +24: labels: +25: {{- include "ethercalc.labels" . | nindent 8 }} +26: {{- with .Values.podLabels }} +27: {{- toYaml . | nindent 8 }} +28: {{- end }} +29: spec: +30: {{- with .Values.imagePullSecrets }} +31: imagePullSecrets: +32: {{- toYaml . | nindent 8 }} +33: {{- end }} +34: serviceAccountName: {{ include "ethercalc.serviceAccountName" . }} +35: automountServiceAccountToken: false +36: {{- with .Values.podSecurityContext }} +37: securityContext: +38: {{- toYaml . | nindent 8 }} +39: {{- end }} +40: containers: +41: - name: {{ .Chart.Name }} +42: image: {{ include "ethercalc.image" . | quote }} +43: imagePullPolicy: {{ .Values.image.pullPolicy }} +44: {{- with .Values.securityContext }} +45: securityContext: +46: {{- toYaml . | nindent 12 }} +47: {{- end }} +48: ports: +49: - name: http +50: containerPort: 8000 +51: protocol: TCP +52: env: +53: - name: ETHERCALC_HOST +54: value: "0.0.0.0" +55: - name: ETHERCALC_PORT +56: value: "8000" +57: {{- if .Values.config.basepath }} +58: - name: ETHERCALC_BASEPATH +59: value: {{ .Values.config.basepath | quote }} +60: {{- end }} +61: {{- if .Values.config.defaultRoom }} +62: - name: ETHERCALC_DEFAULT_ROOM +63: value: {{ .Values.config.defaultRoom | quote }} +64: {{- end }} +65: - name: ETHERCALC_DISABLE_ROOM_INDEX +66: value: {{ ternary "1" "0" .Values.config.disableRoomIndex | quote }} +67: {{- if .Values.config.cors }} +68: - name: ETHERCALC_CORS +69: value: "1" +70: {{- end }} +71: {{- if .Values.config.expire }} +72: - name: ETHERCALC_EXPIRE +73: value: {{ .Values.config.expire | quote }} +74: {{- end }} +75: {{- if .Values.config.rateLimit }} +76: - name: ETHERCALC_RATELIMIT +77: value: {{ .Values.config.rateLimit | quote }} +78: {{- end }} +79: {{- if .Values.config.roomCreateLimit }} +80: - name: ETHERCALC_ROOM_CREATE_LIMIT +81: value: {{ .Values.config.roomCreateLimit | quote }} +82: {{- end }} +83: {{- if .Values.config.auth.enabled }} +84: - name: ETHERCALC_AUTH +85: value: "1" +86: - name: ETHERCALC_RP_ID +87: value: {{ required "config.auth.rpId is required when passkey auth is enabled" .Values.config.auth.rpId | quote }} +88: - name: ETHERCALC_RP_NAME +89: value: {{ required "config.auth.rpName is required when passkey auth is enabled" .Values.config.auth.rpName | quote }} +90: - name: ETHERCALC_ORIGIN +91: value: {{ required "config.auth.origin is required when passkey auth is enabled" .Values.config.auth.origin | quote }} +92: {{- end }} +93: - name: ETHERCALC_KEY +94: valueFrom: +95: secretKeyRef: +96: name: {{ include "ethercalc.secretName" . }} +97: key: ETHERCALC_KEY +98: optional: true +99: - name: ETHERCALC_MIGRATE_TOKEN +100: valueFrom: +101: secretKeyRef: +102: name: {{ include "ethercalc.secretName" . }} +103: key: ETHERCALC_MIGRATE_TOKEN +104: optional: true +105: {{- with .Values.livenessProbe }} +106: livenessProbe: +107: {{- toYaml . | nindent 12 }} +108: {{- end }} +109: {{- with .Values.readinessProbe }} +110: readinessProbe: +111: {{- toYaml . | nindent 12 }} +112: {{- end }} +113: {{- with .Values.resources }} +114: resources: +115: {{- toYaml . | nindent 12 }} +116: {{- end }} +117: volumeMounts: +118: - name: data +119: mountPath: /data +120: # Writable scratch space — the rootfs is read-only by default +121: # (values.yaml securityContext), and SQLite/libc expect /tmp. +122: - name: tmp +123: mountPath: /tmp +124: volumes: +125: - name: data +126: {{- if .Values.persistence.enabled }} +127: persistentVolumeClaim: +128: claimName: {{ include "ethercalc.pvcName" . }} +129: {{- else }} +130: emptyDir: {} +131: {{- end }} +132: - name: tmp +133: emptyDir: {} +134: {{- with .Values.nodeSelector }} +135: nodeSelector: +136: {{- toYaml . | nindent 8 }} +137: {{- end }} +138: {{- with .Values.affinity }} +139: affinity: +140: {{- toYaml . | nindent 8 }} +141: {{- end }} +142: {{- with .Values.tolerations }} +143: tolerations: +144: {{- toYaml . | nindent 8 }} +145: {{- end }} +~~~~~~~ +#### `helm/templates/secret.yaml` + +**`helm/templates/secret.yaml:1-12`** + +~~~~~~~text +1:{{- if not .Values.secrets.existingSecret }} +2:apiVersion: v1 +3:kind: Secret +4:metadata: +5: name: {{ include "ethercalc.secretName" . }} +6: labels: +7: {{- include "ethercalc.labels" . | nindent 4 }} +8:type: Opaque +9:stringData: +10: ETHERCALC_KEY: {{ .Values.secrets.key | default "" | quote }} +11: ETHERCALC_MIGRATE_TOKEN: {{ .Values.secrets.migrateToken | default "" | quote }} +12:{{- end }} +~~~~~~~ +#### `deploy/nginx/ethercalc.conf` + +**`deploy/nginx/ethercalc.conf:1-92`** + +~~~~~~~text +1:# nginx reverse-proxy recipe for internet-facing self-hosts. +2:# This file is included inside nginx's `http {}` context by the official image. +3:# +4:# Note on basepath: this recipe proxies the app at the URL root and does +5:# NOT strip a path prefix. Do not set ETHERCALC_BASEPATH behind it — +6:# the worker expects a prefix-stripping edge when a basepath is in use. +7: +8:limit_req_zone $binary_remote_addr zone=ethercalc_api:10m rate=10r/s; +9:# Two conn zones: WebSockets are held open for a tab's whole lifetime, so +10:# they get a generous budget; plain HTTP requests are short-lived and a +11:# small in-flight cap is plenty. (A single shared zone with mixed limits +12:# would let idle WS tabs starve HTTP requests behind one NAT/CGNAT IP.) +13:limit_conn_zone $binary_remote_addr zone=ethercalc_conn_ws:10m; +14:limit_conn_zone $binary_remote_addr zone=ethercalc_conn_http:10m; +15: +16:map $http_upgrade $connection_upgrade { +17: default upgrade; +18: '' close; +19:} +20: +21:upstream ethercalc_backend { +22: server ethercalc:8000; +23:} +24: +25:server { +26: listen 80; +27: server_name _; +28: +29: # For production HTTPS, uncomment these lines after mounting certs +30: # under deploy/nginx/certs/ AND publishing 443 in +31: # docker-compose.proxy.yml (see the commented ports entry there): +32: # listen 443 ssl; +33: # http2 on; +34: # ssl_certificate /etc/nginx/certs/fullchain.pem; +35: # ssl_certificate_key /etc/nginx/certs/privkey.pem; +36: +37: client_max_body_size 25m; +38: +39: location /_ws/ { +40: limit_req zone=ethercalc_api burst=30 nodelay; +41: limit_conn ethercalc_conn_ws 100; +42: proxy_http_version 1.1; +43: proxy_set_header Upgrade $http_upgrade; +44: proxy_set_header Connection $connection_upgrade; +45: proxy_set_header Host $host; +46: # Replace, never append to, attacker-controlled forwarding headers. +47: proxy_set_header CF-Connecting-IP $remote_addr; +48: proxy_set_header X-Forwarded-For $remote_addr; +49: proxy_set_header X-Forwarded-Proto $scheme; +50: # Spreadsheet WebSockets idle between keystrokes and neither end +51: # heartbeats; without these, nginx's 60s default read timeout +52: # severs every quiet connection. +53: proxy_read_timeout 1h; +54: proxy_send_timeout 1h; +55: proxy_pass http://ethercalc_backend; +56: } +57: +58: location /socket.io/ { +59: limit_req zone=ethercalc_api burst=30 nodelay; +60: limit_conn ethercalc_conn_ws 100; +61: proxy_http_version 1.1; +62: proxy_set_header Upgrade $http_upgrade; +63: proxy_set_header Connection $connection_upgrade; +64: proxy_set_header Host $host; +65: proxy_set_header CF-Connecting-IP $remote_addr; +66: proxy_set_header X-Forwarded-For $remote_addr; +67: proxy_set_header X-Forwarded-Proto $scheme; +68: proxy_read_timeout 1h; +69: proxy_send_timeout 1h; +70: proxy_pass http://ethercalc_backend; +71: } +72: +73: location /_/ { +74: limit_req zone=ethercalc_api burst=60 nodelay; +75: limit_conn ethercalc_conn_http 20; +76: proxy_set_header Host $host; +77: proxy_set_header CF-Connecting-IP $remote_addr; +78: proxy_set_header X-Forwarded-For $remote_addr; +79: proxy_set_header X-Forwarded-Proto $scheme; +80: proxy_pass http://ethercalc_backend; +81: } +82: +83: location / { +84: limit_req zone=ethercalc_api burst=60 nodelay; +85: limit_conn ethercalc_conn_http 20; +86: proxy_set_header Host $host; +87: proxy_set_header CF-Connecting-IP $remote_addr; +88: proxy_set_header X-Forwarded-For $remote_addr; +89: proxy_set_header X-Forwarded-Proto $scheme; +90: proxy_pass http://ethercalc_backend; +91: } +92:} +~~~~~~~ +## 6. Deduplicated env/process.env references in package src and scripts +| Variable/binding | One verbatim reference | Fallback/default in code | +|---|---|---| +| `ASSETS` | `packages/worker/src/routes/assets.ts:114` — `if (!env.ASSETS) {` | YES — missing binding returns 404 (`packages/worker/src/routes/assets.ts:114-117`). | +| `AUTH` | `packages/worker/src/lib/auth-session.ts:16` — `if (!flagEnabled(env.ETHERCALC_AUTH) \|\| !env.AUTH) return null;` | YES — missing binding or disabled flag returns no session (`packages/worker/src/lib/auth-session.ts:16`). | +| `BASEPATH` | `packages/worker/src/routes/assets.ts:173` — `const basepath = c.env.BASEPATH ?? "";` | YES — `?? ""` (`packages/worker/src/routes/assets.ts:173`). | +| `CI` | `scripts/install-runtime-deps.js:11` — `delete env.CI;` | NO value fallback; the copied key is explicitly deleted before the child install (`scripts/install-runtime-deps.js:11`). | +| `DB` | `packages/worker/src/room.ts:2267` — `if (!this.#env.DB \|\| !roomName) return;` | YES — absent D1 short-circuits index reads/writes to empty/no-op behavior (`packages/worker/src/routes/rooms.ts:119-123`). | +| `DEV` | `packages/client-multi/src/url.ts:56` — `isDev: boolean = import.meta.env.DEV,` | Vite-provided boolean; no explicit value fallback. False takes the same-origin `.` branch (`packages/client-multi/src/url.ts:56-64`). | +| `DEVMODE` | `packages/worker/src/routes/assets.ts:422` — `return env.DEVMODE === "1" \|\| env.DEVMODE === "true";` | YES — anything other than exact `1`/`true` is false (`packages/worker/src/routes/assets.ts:422`). | +| `EMAIL` | `packages/worker/src/handlers/cron.ts:84` — `if (!env.EMAIL) return new DisabledEmailSender();` | YES — missing binding selects `DisabledEmailSender` (`packages/worker/src/handlers/cron.ts:83-86`). | +| `EMAIL_FROM` | `packages/worker/src/handlers/cron.ts:85` — `const from = env.EMAIL_FROM ?? 'noreply@ethercalc.invalid';` | YES — `noreply@ethercalc.invalid` (`packages/worker/src/handlers/cron.ts:85`). | +| `ETHERCALC_AUTH` | `packages/worker/src/lib/auth-session.ts:16` — `if (!flagEnabled(env.ETHERCALC_AUTH) \|\| !env.AUTH) return null;` | YES — unset/false-like disables auth (`packages/worker/src/lib/auth-session.ts:16`). | +| `ETHERCALC_CORS` | `packages/worker/src/lib/room-index-access.ts:45` — `return flagEnabled(env.ETHERCALC_CORS);` | YES — unset is false; also the legacy fallback when `ETHERCALC_DISABLE_ROOM_INDEX` is absent (`packages/worker/src/lib/room-index-access.ts:40-45`). | +| `ETHERCALC_DEFAULT_ROOM` | `packages/worker/src/routes/assets.ts:168` — `const defaultRoom = c.env.ETHERCALC_DEFAULT_ROOM;` | YES — unset means no root-room redirect (`packages/worker/src/routes/assets.ts:168-172`). | +| `ETHERCALC_DISABLE_ROOM_INDEX` | `packages/worker/src/lib/room-index-access.ts:41` — `const explicit = env.ETHERCALC_DISABLE_ROOM_INDEX;` | YES — absent/empty falls back to `ETHERCALC_CORS` (`packages/worker/src/lib/room-index-access.ts:40-45`). | +| `ETHERCALC_EXPIRE` | `packages/worker/src/room.ts:2451` — `const ttlMs = parseExpireMs(this.#env.ETHERCALC_EXPIRE);` | YES — unset parses as no TTL / rooms live forever (`packages/worker/src/env.ts:154-161`). | +| `ETHERCALC_KEY` | `packages/worker/src/room.ts:1698` — `if (!(await verifyAuth(this.#env.ETHERCALC_KEY, room, auth))) return;` | YES — unset uses legacy identity-HMAC anonymous mode (`packages/worker/src/lib/auth.ts:40-43`). | +| `ETHERCALC_MIGRATE_TOKEN` | `packages/worker/src/routes/migrate.ts:38` — `c.env.ETHERCALC_MIGRATE_TOKEN,` | YES — unset disables/hides operator routes with 404 (`packages/worker/src/lib/migrate-auth.ts:5-10`). | +| `ETHERCALC_ORIGIN` | `packages/worker/src/auth-do.ts:245` — `const origin = this.#env.ETHERCALC_ORIGIN;` | PARTIAL — WebAuthn has no default and fails configuration closed; CSP has a request-host fallback (`packages/worker/src/auth-do.ts:244-247`, `packages/worker/src/lib/csp.ts:21-39`). | +| `ETHERCALC_RATELIMIT` | `packages/worker/src/lib/rate-limit.ts:117` — `return parseRateLimitConfig(env.ETHERCALC_RATELIMIT);` | YES — unset/false-like disables the limiter (`packages/worker/src/lib/rate-limit.ts:53-69`). | +| `ETHERCALC_ROOM_CREATE_LIMIT` | `packages/worker/src/lib/room-create-limit.ts:89` — `return parseRoomCreateLimitConfig(env.ETHERCALC_ROOM_CREATE_LIMIT);` | YES — unset/false-like disables the creation limiter (`packages/worker/src/lib/room-create-limit.ts:36-51`). | +| `ETHERCALC_RP_ID` | `packages/worker/src/auth-do.ts:244` — `const rpID = this.#env.ETHERCALC_RP_ID;` | NO — missing RP ID makes auth unconfigured (`packages/worker/src/auth-do.ts:244-247`). | +| `ETHERCALC_RP_NAME` | `packages/worker/src/auth-do.ts:252` — `rpName: this.#env.ETHERCALC_RP_NAME \|\| 'EtherCalc',` | YES — `EtherCalc` (`packages/worker/src/auth-do.ts:252`). | +| `ETHERCALC_SANDSTORM` | `packages/worker/src/lib/sandstorm-access.ts:15` — `return flagEnabled(env.ETHERCALC_SANDSTORM);` | YES — unset/false-like is disabled (`packages/worker/src/lib/sandstorm-access.ts:15`). | +| `ROOM` | `packages/worker/src/lib/do-dispatch.ts:11` — `const id = env.ROOM.idFromName(encodeRoom(room));` | NO — required Durable Object binding. | +| `VITEST` | `scripts/vite-workflow.test.ts:352` — `const previousVitest = process.env.VITEST;` | NO code value fallback in the direct `process.env` test mutation; prior value is restored (`scripts/vite-workflow.test.ts:352-376`). | +| `VITE_ETHERCALC_BASE` | `packages/client-multi/src/url.ts:45` — `envValue: unknown = import.meta.env.VITE_ETHERCALC_BASE,` | YES — non-string/unset becomes empty, then API base becomes same-origin `.` outside local dev (`packages/client-multi/src/url.ts:43-64`). | +| `VP_RESOLVING_CONFIG_METADATA` | `scripts/vite-workflow.test.ts:396` — `const previousMetadata = process.env.VP_RESOLVING_CONFIG_METADATA;` | NO value fallback in the direct `process.env` test mutation; prior value is restored (`scripts/vite-workflow.test.ts:395-436`). | + +Total deduplicated names: **26**. + +## 7. Version and repository state facts +- Current branch: `main`. + +### `git log --oneline -30` + +~~~~~~~text +d486f33 fix(ci): repair nightly mutation and oracle replay +2acd1d0 fix(e2e): pin wrangler source and restore main-room hydrate +1648284 fix(worker): accept bare sheet: lines in snapshot limits +20c14ac fix(worker): freeze AuthDO alarm expiry boundary test +c1433b7 fix(worker): O(n) snapshot chunker for CI timeout +d0a4a46 fix(ci): pin staging Wrangler source config +ebb0817 security(docs): upgrade sharp past libvips advisories +b7d8840 security: harden EtherCalc trust boundaries +59a2d5a test: harden seam security boundaries +9b15205 Document SocialCalc tandem preparation +9f3475f Avoid account chrome menu overlap +44a98ce Harden headless DOM rendering +96fb19f Test multisheet bridge contracts +e3fad6a Harden SocialCalc headless bundle generation +b160b7d Harden self-host Docker build context +908e3d1 Fix root Vite+ build and dev workflows +d30e899 fix(worker): serve multi-sheet assets in standalone +5f731d5 1. test(oracle-harness): Raised oracle mutation coverage thresholds - Added ODS and XLSX canonicalization boundary tests for mutation survivors. - Raised Stryker thresholds to match the measured 83.46% mutation score. - Recorded the recovered mutation baseline and remaining canonicalizer survivors. +7f083e1 fix: restore multi-sheet entry flow (#838) +2cd3f2d * ignore logs +59562b6 Fix API command websocket broadcasts +93cc22f Merge remote-tracking branch 'origin/main' into issue-842-merge +b352c29 Fix relative WebSocket URLs in older browsers +86c295b docs: fix stale AGENTS.md session-log tail after passkey Phase A merge +d2afa90 Merge feat/passkey-permissions (#841) +4c3875a docs(worker): reconcile mutation-testing evidence with final 90.20 score +8ae354e test(worker): tighten malformed auth and ACL branches +d4c34d8 test: harden rate and room creation limit boundaries +d96c745 style(test): fix indentation of new authorize.node.test.ts block +2f43ccd test(worker): harden auth session and ACL mutation coverage +~~~~~~~ + +### `git tag --sort=-creatordate | head -30` + +~~~~~~~text +0.20260717.0 +v0.20260716.0 +0.20260710.1 +0.20260710.0 +0.20260612.4 +0.20260612.3 +0.20260612.2 +0.20260612.1 +0.20260611.1 +0.20260611.0 +0.20260424.0 +0.20260423.0 +0.20260422.3 +0.20260422.2 +0.20260422.1 +sandstorm-legacy +0.20260422.0 +0.20170704.0 +0.20151108.1 +0.20151108.0 +0.20151028.0 +0 +~~~~~~~ + +### Every `package.json` version + +| File | version | +|---|---| +| `package.json:3` | `0.20260717.0` | +| `packages/cli/package.json:3` | `0.0.0` | +| `packages/client/package.json:3` | `0.0.0` | +| `packages/client-multi/package.json:3` | `0.0.0` | +| `packages/docs/package.json:3` | `0.0.0` | +| `packages/e2e/package.json:3` | `0.0.0` | +| `packages/migrate/package.json:3` | `0.0.0` | +| `packages/oracle-harness/package.json:3` | `0.0.0` | +| `packages/shared/package.json:3` | `0.0.0` | +| `packages/socialcalc-headless/package.json:3` | `0.0.0` | +| `packages/socketio-shim/package.json:3` | `0.0.0` | +| `packages/worker/package.json:3` | `0.0.0` | + +### Bun / Node / Wrangler pins + +- `package.json:100-102`: Bun engine range is `>=1.1.0`. +- `package.json:127`: package manager is exactly `bun@1.3.14`. +- `Dockerfile:22`: `FROM oven/bun:1.3.14`. +- `tests/oracle/Dockerfile.oracle:16` and `deploy/legacy/Dockerfile:3`: `FROM node:20-bookworm-slim`. +- `.tool-versions`: NONE FOUND. `.nvmrc`: NONE FOUND. `.node-version`: NONE FOUND. +- `package.json:114`: Wrangler manifest range `^4.112.0`; `bun.lock:2371` resolves `wrangler@4.112.0`. +- `packages/worker/package.json:37` and `packages/socialcalc-headless/package.json:28`: Wrangler devDependency range `^4.107.0`; `bun.lock:2495` resolves `wrangler@4.107.0`. +- `.github/workflows/publish-npm.yml:151`: publish job requests Node `lts/*` (moving major; not an exact pin). + +## 8. Paths/content mentioning ethercalc.net, staging, production, or gradual +The scan is case-insensitive and uses `ethercalc\.net|\bstaging\b|\bproduction\b|\bgradual\b` so `production` does not spuriously match `reproduction`. Every matching text line is transcribed below. Binary/non-UTF-8 files cannot have a quoted text line; none had a matching path-only name without another content match. **No `gradual` match was found.** + +### `.github/workflows/ci.yml` + +~~~~~~~text +99: - name: docs — Starlight production build +179: # production Workers Assets bundle (both the single-sheet and React 19 +~~~~~~~ + +### `.github/workflows/deploy-docs.yml` + +~~~~~~~text +9:# Manual deploy only; wire DNS (e.g. docs.ethercalc.net) to the Pages project. +31: environment: docs-production +58: || vp exec wrangler pages project create ethercalc-docs --production-branch=main +~~~~~~~ + +### `.github/workflows/deploy-production.yml` + +~~~~~~~text +1:name: Deploy Production +3:# Manual production deploy to Cloudflare Workers (ethercalc.net). +9:# Does NOT run automatically; invoke via Actions → Deploy Production → Run workflow. +15: description: 'Type "deploy" to confirm production deploy to ethercalc.net' +20: group: deploy-production +28: name: wrangler deploy (production) +31: environment: production +~~~~~~~ + +### `.github/workflows/nightly.yml` + +~~~~~~~text +185: # oracle replay, and staging dry-run together. +189: needs: [mutation, oracle-replay, staging-dry-run] +196: echo "staging-dry-run: ${{ needs.staging-dry-run.result }}" +207: check staging-dry-run "${{ needs.staging-dry-run.result }}" +209: echo "Nightly green — mutation, oracle replay, staging dry-run." +274: staging-dry-run: +275: name: wrangler deploy --dry-run (staging) +297: # the generated production config. An explicit source config is required +298: # here; otherwise `--env staging` silently validates production instead. +299: - name: wrangler deploy --dry-run --config wrangler.toml --env staging +300: run: vp exec wrangler deploy --dry-run --config wrangler.toml --env staging +~~~~~~~ + +### `AGENTS.md` + +~~~~~~~text +19:| User guide + FAQ | [docs.ethercalc.net](https://docs.ethercalc.net) · `packages/docs/` | +73:replay against legacy docker + staging dry-run (`.github/workflows/nightly.yml`). +121:`ethercalc.net`, with every confirmed finding fixed at its owning boundary — +~~~~~~~ + +### `API.md` + +~~~~~~~text +8:* Overview: http://ethercalc.net/ +~~~~~~~ + +### `Changes.txt` + +~~~~~~~text +34:- Made staging deploy validation pass `--config wrangler.toml` explicitly, so +35: Vite's generated production-config redirect cannot silently ignore +36: `--env staging`. +49:- Made root `vp build` and `vp dev` production-faithful full-stack workflows: +201: private RoomDO HTTP and WebSocket authorization; production Worker-served +308: those assets plus the Bun lockfile, and install production workspace +315:- An isolated Cloudflare staging deployment used its own D1 database; 19 safe +316: HTTP routes matched production in status and content type, with only the +355:- Oracle rooms-index fixtures corrected; docs canonical URL docs.ethercalc.net. +369:- Starlight docs site; production deploy workflow (manual dispatch). +374:- client-multi: keep same-origin basePath in production builds; only redirect +399: structural HTML matcher; nightly oracle-replay + staging dry-run. +~~~~~~~ + +### `README.md` + +~~~~~~~text +3:* Overview: https://ethercalc.net/ +4:- User guide: [docs.ethercalc.net](https://docs.ethercalc.net) (Starlight). Local: `vp run @ethercalc/docs#dev` +5:* 中文版: http://tw.ethercalc.net/ +6:* 简体中文: http://cn.ethercalc.net/ +15:[docs.ethercalc.net](https://docs.ethercalc.net) for architecture. +71:timeouts, so idle spreadsheets stay connected). For production HTTPS: +280:Lean generation feeds Leanstral; only Bun tests decide production behavior. +~~~~~~~ + +### `deploy/nginx/ethercalc.conf` + +~~~~~~~text +29: # For production HTTPS, uncomment these lines after mounting certs +~~~~~~~ + +### `docker-compose.proxy.yml` + +~~~~~~~text +6:# The nginx config lives at deploy/nginx/ethercalc.conf. For production HTTPS: +~~~~~~~ + +### `docs/SELFHOST_HARDENING.md` + +~~~~~~~text +21:configuration plus the presence or absence of an edge.** The hosted `ethercalc.net` deploy is hardened because +~~~~~~~ + +### `docs/historic/REWRITE_ULTRAPLAN.md` + +~~~~~~~text +286:- [x] Nightly `wrangler deploy --dry-run --env staging` (`.github/workflows/nightly.yml`). +290:- [x] Production deploy workflow (`.github/workflows/deploy-production.yml`, manual `workflow_dispatch`). +375:- [x] `wrangler deploy --dry-run --env staging` (nightly.yml). +432:| 2026-06-12 | 3/8/11/12 | **Export bundle, form clone, fill-down, multi-sheet, oracle expansion.** socialcalc 3.0.2→3.0.3: CSV formula import (#304), formatted export (#638/#355), money `/` (#577), `increment_amount` fill-down fallback (#314/#564/#769/#785). Ethercalc: `POST /_do/clone` + `GET /:template/form` 302 (`c52061f`); headless smokes; migrate mutation #828 closed. client-multi: stable tab keys (#635), `rowsRev` iframe sync (#698), Foldr link dedupe (#727). Oracle harness: 22 scenarios (room CRUD, csv/html export, form redirect); structural HTML matcher wired. Nightly oracle-replay + staging dry-run jobs added. E2E: fill-down, multi rename, form clone specs. Triage script batch-closes verified-fixed issues. Released `0.20260612.1`. | 59996c5+ | +433:| 2026-06-12 | 3/10/12 | **Oracle WS + multi TOC sync + docs + deploy workflow.** Oracle harness: 27 scenarios (WS connect/ask-log/execute recorded via socket.io-client 1.x against legacy oracle; native replay on worker); XLSX/ODS export fixtures; `--ws-transport` CLI flag. client-multi: `useTocPoll` live TOC sync (#698 follow-up). Starlight docs at `packages/docs/`. Production deploy workflow (`deploy-production.yml`, manual dispatch). socialcalc 3.0.4 blocked on npm publish (registry still 3.0.3). | (this commit) | +434:| 2026-06-12 | 3/11/12 | **socialcalc 3.0.4, Starlight user guide + FAQ, Sandstorm on main.** Bump headless to ^3.0.4; #232 viewer export fix; #292 same-origin basePath in production; wiki → user-guide + FAQ; Sandstorm packaging restored (`run_grain.sh`, `SANDSTORM.md`); CONTRIBUTING triage runbook; docs CI + `deploy-docs.yml`; closed #25/#59/#262/#288/#292/#335/#494/#789/#800. Released `0.20260612.2`. | 76558eb | +438:| 2026-06-12 | oracle | **Nightly green: worker oracle-replay matchers + agent-doc slim.** Fixed the nightly worker oracle-replay leg — `last-modified` canonicalizer + WS snapshot matchers, replay cwd — so it runs from the worker (8787) as well as the legacy oracle (8000); nightly summary now aggregates oracle-replay + staging dry-run. Mutation floors at this point: worker 90 / oracle-harness 83. Slimmed `CLAUDE.md` to a thin agent-context doc and archived this rewrite ultraplan to `docs/historic/`. `SANDSTORM.md` documents the app-owner `spk pack` publish flow (manual, app owner signs — not CI). | cc84ed2, f603002, 979fe49 | +441:| 2026-07-10 | release | **Candidate `0.20260710.0` prepared and staged.** Consolidated the unpublished `0.20260612.5` and `0.20260619.0` notes into the first public release since `0.20260612.4`; bumped bundled/headless SocialCalc `^3.0.4`→`^3.0.8`; aligned Sandstorm `appVersion`/marketing version. Made `package.json` negative `files` entries authoritative after `npm pack --dry-run` exposed package tests and local `.wrangler` SQLite state that the root `.npmignore` could not exclude; added prepack asset rebuilding and packaged-workspace production dependency installation. Created the isolated `ethercalc-staging` Worker and `ethercalc_rooms_staging` D1 database, applied all three migrations, and deployed version `5aa7c6cb-c7a7-4eff-b12f-f9ec99083b79`. Nineteen safe HTTP routes matched production in status/content type, with the expected `static/socialcalc.js` body as the sole asset difference; browser editing persisted and rehydrated after reload. One reader egress retained the initial bare-`/` 404 cache entry, while cache-busted reads and 54 direct TPE probes returned the current 200 landing page. A worker-served multi-sheet cold room still fails to persist the client’s `text/csv` TOC POST; the same behavior was reproduced locally and on production, so it is a pre-existing defect rather than a candidate regression and was not folded into this release. Release checks: Biome lint (known broken `server.js` symlink warning only), all workspace typechecks, full root suite including 14 Playwright scenarios, Worker staging deploy/dry-run, and fresh npm pack/install/CLI smokes with required Worker/SocialCalc/client runtimes retained and forbidden local/test paths absent. | (this commit) | +442:| 2026-07-10 | release | **Released `0.20260710.0`.** Release commit/tag `dead71a0f721573fd38624cb45d6f94d9347629a`; npm `latest` published with SHA-1 `c0c9646ad4a2a5565b798a69bc6560da45733446`; GitHub release `351817076`; Docker workflow `29055595789` published `audreyt/ethercalc:0.20260710.0` and `:latest` as the same OCI index (`sha256:197b8e6d57c4d3e33d61f9f4789cd1848920b08b885257ff2aec703309a37096`) for linux/amd64 + linux/arm64, then the pulled versioned image passed health, landing-page, SocialCalc, and `/=:room` shell smokes. Production workflow `29055969233` deployed Cloudflare Worker version `55505397-d579-4194-aa89-992243847310`; all 19 safe comparison routes became byte-identical to staging, and a real browser edit persisted and rehydrated without page/console errors. Smoke rooms and containers were removed. Docker Actions emitted only the upstream Node.js 20 deprecation annotation. | dead71a | +444:| 2026-07-10 | release | **Released `0.20260710.1`.** Point release carrying only the multi-sheet TOC `text/csv` POST fix (merge `42d9966`). Release commit `5d0f655`, tag `0.20260710.1`; npm `latest` published with SHA-1 `01aaf887ae7446d50e57351fa5f6ad52d138cf8f` (550 files, identical file list to the published `.0` tarball); GitHub release created; Docker workflow `29065032994` published `audreyt/ethercalc:0.20260710.1` + `:latest` (amd64 + arm64). Staging deploy `ba840251-9d0c-4695-bcac-570025de3981` verified the fix (first probe hit the previous version during propagation — enriched `loadclipboard`/`paste A2 all` + persisted csv.json confirmed on retry). Production workflow `29065473986` deployed Worker version `bcb7b5fb-c05d-476d-ad74-455c6884b065`; the exact probe that demonstrated the defect on production now returns the enriched command array and a 200 `csv.json` TOC grid, and the live `/=:room` UI renders the persisted Sheet1 tab. All probe rooms deleted on staging and production. | 5d0f655 | +445:| 2026-07-10 | 8/ops | **Hosted per-room PITR restore landed.** New operator API `POST /_/:room/pitr-restore` (`Authorization: Bearer ETHERCALC_MIGRATE_TOKEN`; unset token hides the route with 404) exposes Cloudflare's 30-day SQLite DO history: JSON `{at\|bookmark, dryRun?}` → `parsePitrRequest`/`bookmarkStorage`/`isPitrUnavailableError` in `src/lib/pitr.ts`, internal `POST /_do/pitr-restore` resolves via `getBookmarkForTime`/`onNextSessionRestoreBookmark`, replies `{bookmark, undoBookmark, nonce}`, then schedules `state.abort()` behind a `waitUntil`-tracked timer; the route polls `/_do/ping` for a nonce change (bounded 20×100 ms) and finalizes D1 mirror + TTL alarm through `POST /_do/pitr-touch` (deletes stale D1 rows when the restored point predates the room). Post-acceptance failures return JSON `{accepted:true, bookmark, undoBookmark, error}` (500 restart-timeout / 502 finalization) so the operator always keeps the reverse handle. Local Miniflare/workerd map the documented capability error to `501`. Staging smoke proved the full cycle end to end (dry-run resolve → overwrite → restore → D1 row → undo → cleanup); fresh rooms have no hosted history for ≈1 min (observed 57 s) — recovery tooling polls the dry-run, no runtime workaround. Tests: `pitr.node.test.ts`, RoomDO unit + route suites, workers-pool 501 pin; worker node coverage stays 100%. Docs: API.md section + design spec/plan under `docs/superpowers/`. | — | +447:| 2026-07-10 | passkey-A | **Passkey Phase A post-review hardening + PR #841 open, CI green.** Real-browser staging pass (CDP virtual authenticator) found an admission-check gap: an anonymous or under-privileged visitor on a private room got a full editable `SpreadsheetControl` (Edit/Save/Sort/chat enabled) even though server-side writes were already DO-blocked — divergent local edits could be entered and silently discarded. Fixed with a server-side capability probe (`GET /:room` catch-all and `GET /:room/edit` call `/_do/access` via `getSessionPrincipal`) that 302s to `/:room/view` when `canRead`/`canWrite` are unmet, or to a fresh `/:room/edit` when a private owner's cached `auth=` token is stale; client `ScheduleSheetCommands` now refuses local mutations outright when `SocialCalc._view` is set. Two more destructive-boundary holes closed: (1) private rooms could still forward WS `submitform` frames into their legacy public `_formdata` sibling — `WsContext.allowSubmitForm()` now allow-lists `null`/`'public'` access (not deny-lists `'private'`); (2) `DELETE /_/:room` checked only the legacy per-room HMAC, which — with `ETHERCALC_KEY` unset (the hosted default) — accepts *any* non-`'0'` auth, letting anyone delete a private room outright; it now falls through to an `/_do/access` ACL check (`isPrivate && canWrite`) whenever the legacy HMAC fails, with `auth=0` preserved as an absolute veto. Separately closed reviewer finding #3: WebSocket sessions never expired for the life of a hibernating socket. `SessionPrincipal` gained `exp`; `auth-session.ts`/`auth-do.ts` validate it at verify time; `ws-upgrade.ts` + `routes/ws.ts` carry `X-EC-Session-Exp` from the *verified* principal only (never copied from inbound headers); `room.ts#closeExpiredSessionSocket()` fail-closed-closes (code 1008) any uid-bearing socket with a missing/non-finite/past `sessionExp` before message dispatch, direct reply, and both broadcast fan-out paths — reviewed end-to-end in this session (atomic uid+exp forwarding, no spurious-closure path, event-driven check is a deliberate trade-off against the single per-room alarm slot already owned by TTL). Stryker: fixed a no-op `exclusions` key (Stryker has no such config — use `!`-prefixed negation in `mutate`) to actually exclude `webauthn-ops.ts`; combined mutation score still fell to 84.1% (595 new mutants from Phase A, 114 surviving, 87 in `auth-do.ts`'s WebAuthn ceremony/base64 code that mocked tests don't exercise) — break threshold lowered 90→84 with rationale in `_ratchet_comment`, Phase B follow-up to ratchet back. Also fixed a pre-existing (non-regression, confirmed live on production) toolbar-icon 404: `graph.ts`'s `defaultImagePrefix` was `'images/sc_'` (relative), which resolves against `/:room/edit`'s pushState'd URL instead of document root; changed to `'/images/sc_'` — the `/images/*` route is unconditionally root-absolute (no `BASEPATH` prefix, matching every other static-asset route), and the toolbar reads `defaultImagePrefix` lazily at `TableEditor`/`SpreadsheetControl` construction time (after `installGraph`'s boot-time override), so the fix needed no `SocialCalc.ConstantsSetImagePrefix` propagation. TDD regression test in `graph.test.ts`; verified end-to-end with a real `wrangler dev` + headless-browser pass — all 51 toolbar images 200/304 at their root-absolute URL, zero room-prefixed 404s. Worker: 1085 node tests / 100% coverage, 161 workers-pool; client: 233 tests / 100% coverage; typecheck and targeted Biome clean. PR [#841](https://github.com/audreyt/ethercalc/pull/841) open on `feat/passkey-permissions`, base `main` (merge-base `b15ab3e`), merge state CLEAN, all 5 CI checks green at this commit. | #841 | +448:| 2026-07-11 | passkey-A/staging | **Redeployed the exact PR #841 head `b053e2788acdb843aff35a347185f3514752f7d8` to isolated staging.** Rebuilt both clients/assets, passed `wrangler deploy --env staging --dry-run`, then deployed Worker version `1f47c270-9244-419c-b836-1027c212bd8f`, replacing the pre-hardening `c61b227d` staging version. Real staging acceptance used CDP virtual authenticators for WebAuthn registration → logout → discoverable login; owner, anonymous, and authenticated-non-owner private-room admission/read/write/delete checks; owner editor versus viewer local-mutation behavior; public-room create/edit persistence; copy-to-private; native WS owner write and anonymous `403` handshake; private `submitform` sibling isolation; private D1-index exclusion; and root-absolute toolbar asset loading. Private `/_app` routes resolve to the viewer. Session-expiry cannot be forced safely on staging; focused `room.node.test.ts -t 'closes an expired'` passed 3 cases. Probe rooms were deleted and their D1 rows removed, except one earlier private test room whose random slug was lost when a discarded browser assertion suppressed its result; it is private/unindexed and contains only test data. **No production deployment:** staging is now the soak gate; production still requires a fresh `ethercalc.net` passkey ceremony plus `www` and public-room smoke. | [#841](https://github.com/audreyt/ethercalc/pull/841) | +449:| 2026-07-11 | passkey-A/staging | **Room-access chrome deployed to staging as an uncommitted bundle, not a PR commit.** Provenance: base `a0ddcfdb4fad12b4f845972c676135fa0af0fe94`, with six modified source files and two new files (`static/passkey.css`, `packages/e2e/tests/passkey-room-access.spec.ts`) at deploy time; no changes were staged or committed. Rebuilt `assets/`, passed Worker + E2E typechecks, focused 45-test room-route suite, targeted Biome check, seven Playwright browser contracts, and staging `wrangler deploy --dry-run`; deployed isolated `ethercalc-staging` Worker version `00088787-bdc0-4544-9d7b-2bd4ec97ebae`. Fresh acceptance against that version: public control row is first inside the menu wrapper while tabs/toolbars retain computed `24px`/`40px` styling and a 693px grid; private viewer row is a top-level sibling before its 828px grid, never nested; owner `POST /_/:room` mutation returned 202 and persisted `A1`, while anonymous mutation returned `403 Forbidden`; anonymous private admission showed the gate with the editor hidden; CDP virtual authenticator completed explicit registration → logout → discoverable login with the same uid; account menu was `display:none` while hidden and exposed only after trigger; mobile overflow surfaced Sheet access/New private sheet/Sign out. Production was not dispatched. | PR #841 working tree | +450:| 2026-07-11 | passkey-A/M3E | **Passkey UI M3E rewrite: fixed e2e locators, closed test/CSS/license gaps, staging-verified.** `feat/passkey-permissions` @ `0bd3fe6`. E2E fixes against the M3E structure: scoped the duplicate "Use a passkey" locator to the toolbar (`m3e-bottom-sheet` hides closed sheets via `transform`, not `display:none`, so unscoped `getByRole` matched both); fixed account-menu items from `role=button` to `role=menuitem` (`m3e-menu-item` uses `Role(base, "menuitem")`). New coverage: cold-load custom-element upgrade-race test (gates `ui.js`, verifies the `m3e-theme:not(:defined)` CSS safeguard and post-upgrade height settling on both editable/viewer paths); WCAG contrast regression (caught and fixed a real bug: `m3e-theme`'s body-level background side effect dropped SocialCalc's inactive-tab gray below 4.5:1 AA, `#767676`→`#707070` in `makeup.css`); toolbar full-width geometry regression (real bug: `#ec-room-access { display: flex }` made the host's OWN flex container the thing sizing shadow `.base`, not `.base` itself — `.base` stayed ~430px wide inside a 1250px host; fixed to `display: block` + `--m3e-toolbar-spacing`, all three confirmed to fail-then-pass against the actual regression before/after); reduced-motion regression (real gap: `m3e-menu`'s `@media (prefers-reduced-motion)` rule only zeroes `transition`, not the separate `animation: bounce-open` on open — confirmed via source and empirically at exactly the 250ms default; fixed with an `!important` app-level override zeroing all `--md-sys-motion-duration-*`/`-spring-*` tokens at `html`, since custom properties inherit across shadow boundaries). `boot.ts`'s export dialog rewritten to the documented `` pattern (matches the original Task 6 plan; passkey dialogs correctly keep plain buttons + manual `hide()` since they must survive a failed ceremony, per the design doc). Deleted ~800 lines of dead vex CSS still shipping from `static/index.css` after the standalone `vex.*` files were removed earlier this session (regression test added). Added `third-party/m3e/NOTICE` (full MIT/Apache-2.0 texts for `@m3e/web`, `@m3e/icons`, Material Symbols, `@material/material-color-utilities`, `@floating-ui/dom`, `composed-offset-position`, `lit`, `tslib`), a `generateBundle`-hook license banner in the passkey bundle pointing at a copy served alongside it (`build-assets.ts` → `static/passkey/NOTICE`), and `third-party/` in the Dockerfile build context (verified with a real `docker build`). **Known deviation, not resolved this session:** `static/ethercalc.js` (a separate, pre-existing legacy bundle predating this rewrite, not loaded by any current page's `