Security
- Private multi-sheet workbooks now initialize children with immutable
meta:parentdelegation, closing a confidentiality exposure where
sub-sheets of a private room were anonymously readable. RoomDO derives
each child authorization verdict from the parent, rejects local-ACL
conflicts and occupied-room capture, and keeps workbook-internal
children out of the public rooms index. On 2026-08-11 a read-only D1
roomsindex query plus anonymous probes of gate-exempt
GET /_/<parent>/accesscounted 1,883,069 rooms, 68,582 child-shaped
names (<base>.<positive decimal>), and 13,875 distinct candidate
parents — 13,873 public, 2 not-found, 0 private. Pre-fix children had
no access metadata, so#mirrorIndextreated them as public and wrote
them into the index; post-fix parented children are never indexed. No
remediation is required in hosted production: the fix is prospective and
closed the hole before anyone was exposed by it. The invokability facts
remain true and explain why a sweep was never needed (and would still
block anyone who later wants one):/_do/backfill-childrenand
/_do/set-parentexist only on the internal Durable Object dispatch
surface (packages/worker/src/room.ts:371,374) with no Worker route
proxying them;ETHERCALC_MIGRATE_TOKENis unset in production so
operator-gated paths 404 (packages/worker/src/lib/migrate-auth.ts:40);
andRoomDO.#postBackfillChildrenenforceslocal.acl.owner === uid
(packages/worker/src/room.ts:916-928), preventing operator sweeps of
user rooms. A hosted sweep would still require new code and a deploy (a
public Worker route, a provisioned token, and a policy choice between
owner-only vs. operator sweeps). The D1 count is a lower bound — a child
whose mirror write failed, or any self-hosted deployment, is not
represented — but zero is plausible rather than lucky: exposure required
a private room (passkeys reached production only aroundd2afa90,
2026-07-18) and multi-sheet use and a child that received content. New
children are safe on creation, and pristine pre-fix children are marked
on the next TOC write (packages/worker/src/routes/multi-import.ts:415). - Multi-sheet append import maps a mixed-version RoomDO
501on
/_do/import-append-tocto a retryable503with clear copy (Worker/DO
skew during deploy), instead of surfacing bare "Not implemented". - Closed stored DOM-XSS and cross-origin
postMessageseams in graph and
multi-sheet clients; HTML exports now use the same element, attribute, URL,
and CSS-fetch allowlist as the live editor. - Bound native and legacy WebSocket mutations to the authenticated attachment
room, validate complete message schemas and resource caps, re-check auth on
every state-changing frame, and reap bounded Socket.IO handshake sessions. - Added bounded auth/import bodies, ZIP/sheet/cell/command limits, AuthDO
challenge capacity and per-IP ceremony limits, revocable__Host-ec_sess
sessions, live-socket expiry, and fail-closed private-room authorization. - Gated
/_timetrigger, fixed partial scheduler retries, hardened trusted
proxy attribution and room-ID entropy, and added private-response no-store
plus CSP/HSTS/nosniff/referrer/permissions/COOP headers. CSP WebSocket
authority now comes fromETHERCALC_ORIGIN, not a spoofableHost. - Hardened Docker/Helm defaults (read-only/no-new-privileges containers,
disabled service-account token mounting, fail-closed passkey anchors) and
upgraded docs image processing tosharp0.35.3, resolving
GHSA-f88m-g3jw-g9cj in its bundled libvips. - Made staging deploy validation pass
--config wrangler.tomlexplicitly, 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.txtreturns a real robots file (Allow:/only)
instead of falling through the/:roomcatch-all as app HTML. The robots
file deliberately carries noDisallowdirective —Disallowforbids
fetching, so a crawler blocked from a URL can never observe itsnoindex
header and already-indexed rooms would be stranded permanently. - Multi-sheet workbook import (replace and append — xlsx/ods/fods and
csv/tsv/txt/socialcalc) is re-enabled for private rooms: children are
created with immutablemeta:parentso they inherit the parent's access
instead of publishing as public. All formats share one guarded server
append door; the multi-sheet client no longer PUTs sub-rooms directly.
Tooling
- Migrated the repository interface to Vite+ 0.2.4: package operations use
vp install/add/remove/update, workspace tasks usevp run, local binaries
usevp exec, and package Vite/Vitest imports and scripts use Vite+. - Replaced the Biome gate with Vite+ Oxlint while preserving the existing
three-rule lint contract and the repository's no-formatter-gate policy.
CI, deploy, nightly, LemmaScript, and npm validation jobs now use the pinned
setup-vpaction and Vite+ commands. - Pinned the underlying package manager as
bun@1.3.14. Bun remains the
native runtime for Bun-specific tests/scripts and for Docker or packed-package
bootstrap/runtime paths where Vite+ is intentionally not shipped. - Made root
vp buildandvp devproduction-faithful full-stack workflows:
Vite+ prepares the curated browser assets once, then the Cloudflare Vite
plugin builds or serves the Worker, Durable Objects, bindings, and assets. - Kept integrated multi-sheet development same-origin, made standalone port
8080 strict, and excluded generated Worker Vite output from npm tarballs. - 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.shmutation score parsing injqandnode
branches by computing the score from mutant status counts rather than reading
a non-existentmutationScoreproperty, resolving an unconditional parse
error (exit 2) during mutation ratchet audits.
Integration
- Prepared the headless adapter for the next SocialCalc release: bundle
transforms now fail loudly on upstream wrapper drift, and the DOM shim
supports safely serialized attributes, class lists, and text content used
by accessible sheet rendering. - Added multi-sheet bridge regressions for exact TOC routing, child-room
isolation/mutation, and cross-sheet recalculation through sibling RoomDO
hydration. EtherCalc keeps canonical per-sheet persistence; the embedded
SocialCalc workbook remains the in-memory calculation and interchange model. - Moved the account/access cluster into its in-flow fallback at or below the
measured 920px cutoff, avoiding overlap with SocialCalc's expanded menu while
preserving fixed top-right chrome on wider sheets.
Bug fixes
- Passkey sessions now renew while in use.
/_auth/whoamire-issues the
session cookie at most once a day with a fresh 30-day lifetime, capped at
90 days after the passkey ceremony that started the chain (renewed tokens
carry thatauthtime). Logout revocation now compares the chain's
authentication time, so a renewal cannot outlive a later logout. An AuthDO
without the refresh route falls back to plain verification. The sheet
client reconnects its WebSocket daily so sockets pick up the renewed
session. - Fixed silent loss of sheet edits when a passkey session ends while a sheet
is open. RoomDO closes such sockets (1008 Session expired/invalid), but
the client kept retrying, queued every later edit in memory, and showed
nothing, so closing or reloading the tab discarded them./_auth/whoaminow
reportsexpiresInMs; the single-sheet client holds edits shortly before
expiry, shows a banner offering an in-place passkey sign-in that reconnects
and flushes them, surfaces lost connections and reconnect give-ups, and
warns before unloading a tab with unsent edits. - Fixed
POST /_/:roomreturning202on Durable Object command rejections,
which silently discarded writes. The route now propagates the DO status and
response body (such as413 command exceeds sheet limitswhen a batch exceeds
200,000 cells).
Passkey (WebAuthn) accounts and private sheets — Phase A.
- The Worker is now its own WebAuthn relying party:
POST /_auth/ register-init|register-complete|login-init|login-completerun the
ceremonies against a new singletonAuthDO(credentials keyed by
credential ID, usernameless discoverable login, replay-safe signature
counters, HMAC-signed ~30-day sessions). The session travels only in
an HttpOnlyec_sesscookie;GET /_auth/whoamiandPOST /_auth/logoutround out the surface. Zero third-party auth
dependencies beyond@simplewebauthn/server. - Private sheets:
POST /_/privatecreates an owner-only room and
POST /_from/:template/privatecopies a readable sheet into one,
both backed by a new atomicPOST /_do/init-privateprimitive that
claims only truly-empty rooms (409 otherwise) and requires the
caller-owned ACL. RoomDO is the sole authorization boundary: every
/_do/*read/write, export, and WebSocket path enforces the
meta:access/meta:aclplane (deny-overrides; legacy?auth=
tokens are demoted on private rooms), the rooms index and D1 mirror
exclude private rooms, and full-storage replacement paths (snapshot
PUT, seed, install, delete) preserve the access plane — deletion
leaves a tombstone so private room names cannot be squatted, while
TTL expiry still reclaims them. Private rename/clone are refused
rather than declassified. - Anonymous collaboration on public rooms is byte-identical; passkey
auth ships dark on self-host untilETHERCALC_AUTHplus the
ETHERCALC_RP_ID/ETHERCALC_RP_NAME/ETHERCALC_ORIGINtrust
anchors are configured (see docs/SELFHOST_HARDENING.md §7). - Client: a dependency-free
static/passkey.jsadds Sign in / New
Private Sheet / Copy to Private / sign-out affordances plus a
private-sheet notice on both the landing and sheet pages.
Test tooling
- Added release-gated Playwright coverage for two-context native WebSocket
collaboration and reconnect; real CDP WebAuthn registration/login with
private RoomDO HTTP and WebSocket authorization; production Worker-served
multi-sheet assets; and a representative room CRUD/export lifecycle.
Viewer ACL coverage is not implied: no HTTP sharing route currently creates
a genuine read-only viewer state. - Worker mutation testing (
packages/worker/stryker.conf.json,break: 90):
Phase A's post-rebase-onto-main hardening pass (see
docs/MUTATION_REPORT.md's 2026-07-17 addendum) closed test-gap
clusters in auth-do.ts, auth-session.ts/authorize.ts/ws-upgrade.ts, and
rate-limit.ts/room-create-limit.ts, restoring the combined score to
90.20% — at or above main's own pre-feature baseline (90.16%). No
source/config behavior changed; test-only.
npm: ethercalc@0.20261026.0 · Docker: audreyt/ethercalc:0.20261026.0 · Already running on ethercalc.net.