E2E remote control, stages 6–7: sealed push, built worker, docs and structural lint - #518
Conversation
A push is the one message the two endpoints exchange with no live Noise session between them, so it gets its own domain-separated construction: X25519 over the two pinned statics, WebCrypto HKDF-SHA-256 under a fresh 32-byte salt per message, and ChaCha20-Poly1305 at the all-zero nonce — spent exactly once because the key is minted per message. Never a Noise `CipherState`: a transport state is a shared counter, and a push has no shared position on either side. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`PushSendRequest` becomes per-recipient — every Client has its own static,
so there is no shared payload — and the Server validates shape and bounds,
forwards exactly `JSON.stringify({ hostId, ...sealed })` with the `hostId`
from the sending Host's own token, and reads no notification text at all.
`boundedPushText` leaves the send path with it.
Rows whose `hostId` has left `hosts.json` are now dropped on read, beside
the malformed-row guard, so hand-editing the documented revocation file
cascades to the subscriptions it stranded.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`sendPush` bounds `{ title, body, tag }`, then seals that one plaintext once
per active ACL record — to that record's own Client static, so there is no
shared payload and no group key. The Settings dialog's test push takes the
same path.
`RemoteHost.sealPushForClient` is a capability, not a key: the Noise static
stays the nonextractable `CryptoKey` the Host imported, and `AlertPushDeps`
carries the seal rather than the material.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`pocket/public/sw.js` was a verbatim-copied classic script that could import nothing, which is why the sanitization rule lived in it three times over. It is now `src/remote/pocket-app/sw.ts`: it imports `openPush`, the IndexedDB `KnownHostStore`, and the shared `boundedPushText`, decrypts against the pinned record for the envelope's `hostId`, and re-bounds what comes out. Every failure — no payload, unknown Host, a `pairing-required` record, a decrypt failure, malformed plaintext — still ends in the generic notice, as `userVisibleOnly: true` requires. `vite.sw.config.ts` builds it as one classic IIFE at the stable, unhashed `dist-pocket/sw.js` with `emptyOutDir: false`; `scripts/assert-pocket-worker.mjs` runs last in `build:pocket` and refuses module syntax, a dynamic-import loader, or a sibling chunk. `dev:pocket` bundles the same config in memory per request, so the dev server serves the code production would emit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`remote-security-model.md` gains a Push sealing section beside the ceremonies, and stage 6 leaves the ledger — the scope's `## Future` now holds only stage 7. `alert.md`, `pocket-app.md`, and `server.md` follow: the Host seals per recipient, the Server forwards ciphertext, the worker is built and re-sanitizes at the sink. `SECURITY.md`'s "re-sanitized on the Server" claim was false the moment the Server stopped being able to read a payload; the two `FAIL IF` lines it rested on are rewritten to name the worker and to forbid the send route reading, rewriting, or logging notification text at all. The security-model budget rises by 200 words for the new section. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
"Compromising the Server reveals no notification text" is now one of the enumerated guarantees, so it is driven end to end like the rest: a Host seals to one of its two paired Clients, and the other — holding its own active record on the same Host — opens nothing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Quality-only pass over the sealed-push stage. Reuse: - `sealKey` no longer re-implements the handshake's X25519. `noise.ts` exports `x25519Agree`, so both key schedules reject the same degenerate points instead of carrying two zero-checks that had already diverged. - `SealedPushPayload` was declared and unused; the send route now builds its payload `satisfies` it, and `sw.test.ts` returns it. - Tests reach for `fromBase64Url`, `randomBase64Url`, and `PushSendRequest` rather than re-deriving them. Simplification: - `sw.test.ts` carried a literal NUL, so git classified the whole new worker suite as binary and no diff rendered it. Escaped, with the bidi override beside it. - `PushSubscriptionStore`'s `hosts` join was optional with a null default and one caller that always passed it; required now, and the unreachable branch is gone. - `sw-entry.ts` is the bundler entry, so importing the worker's logic installs no listeners structurally rather than by sniffing `globalThis`. - The dev middleware reads `/sw.js` off the build config that owns the name, and runs as a plain async handler. - Comment proportionality: the seal, the worker, the delivery path, and both build configs restated their spec sections wholesale. Each keeps the constraint the code cannot show and points at the owning spec. Stale prose the stage invalidated: `boundedPushText` and `toPushText` still described the Server as re-sanitizing push text and named the deleted `lib/pocket/public/sw.js` as a third mirror. No change to the seal construction, the envelope shape, or the worker's generic-notification rule. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Four confirmed findings, each pinned by a test proven to fail without its
fix.
**The send route spread its input.** `JSON.stringify({ hostId, ...sealed })`
let a body-supplied `sealed.hostId` override the one taken from the Host's
token, and let any other key ride through to the phone — including readable
text, since `isSealedPushV1` bounds the three fields it knows and ignores
the rest, and nothing caps the body. Both halves are exactly what
`SECURITY.md` had just been rewritten to forbid. The route now copies `v`,
`salt`, and `ct` by name.
**The fan-out had no clamp, and then had the wrong one.** The send route
started refusing more than `MAX_PUSH_QUERY_DELIVERY_IDS` recipients in this
stage, and it refuses the whole POST — so a Host past the bound reached
nobody rather than most. Clamping from the *front* would have been worse
than useless: `activeRecords()` is in approval order and re-pairing a phone
that lost its IndexedDB mints a fresh Client static that supersedes nothing,
so the head of that list is where dead records pile up. Keeps the newest end.
**`SECURITY.md` overclaimed.** "Web Push is no exception" sat on a sentence
about injection, but a sealed push carries no counter by design, so a Server
that keeps an envelope can re-deliver it. Narrowed to confidentiality, with
the replay residual stated where the construction is.
**The worker assertion never ran in CI.** Neither root `pnpm build` nor
`pnpm test` reached `build:pocket`, so `assert-pocket-worker.mjs` only ever
saw its own fixtures — and its whole premise is that a broken worker is
invisible from a desktop. Root `pnpm build` now runs it.
Also pins the partial-seal case: one recipient failing to seal costs that
phone its notification, not every phone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Stage 6 is where "every delivery ends in a notification" was written down and pinned, and the handler did not enforce it: `notificationForPush` is total, but `scope.registration.showNotification` still rejects — on a permission revoked between subscribe and delivery, and on options the UA refuses — leaving `waitUntil` an unhandled rejection in a worker. Retried once with the generic notice, then swallowed. The retry is what can still land in the second case, where it was the payload-derived options the UA objected to; the swallow is for the first, where nothing helps and there is nothing left to try. The generic notice carries no collapse key, so a retry cannot replace an unrelated Session's notification. The deleted `lib/pocket/public/sw.js` had the same shape, so this is not a regression — but it is the one branch my own review checklist named and I waved through as untestable. It is not: both cases are driven by a rejecting `showNotification` in `sw.test.ts`, and both fail without this change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
…le (stage 7) Stages 1-7 are built, so `Scope: e2e-client-host` and its ledger are completed plan text: every bold reference in server.md and remote-api.md is rewritten as a present-tense pointer at the section that now owns the behavior, and the definition is deleted. What the scope paragraph said about the shipped model — the trusted endpoints, and the Server being trusted with nothing but the ability to drop traffic — moves above the fold into Trust Model; the "no plaintext path, no negotiation, no compatibility reader" rule moves into Noise suite, where the lint that enforces it can be named; the independent-cryptographic-review obligation moves into Security Guarantees, since it constrains what may be claimed today rather than describing unbuilt work. `## Future` keeps only Device verification and Revocation propagation. remote-api.md's Transport described a WebSocket carrying JSON and a lazily created `RemoteApiSession`; it now describes the Noise session and the session created at promotion, which is what `#promoteConnection` does. Item 8 is WebRTC-only, the transport half having shipped. One stale doc comment in `wire.ts` still named a `challenge` frame that no longer exists. The rationale split, applied: remote-security-model.md is 5,236 -> 4,796 words against a 4,800 budget, with the evidence moved to remote-security-model.rationale.md under five new headings — the possession-only delivery-id argument, the three-prompt first run and the absent app-session signing key, the QR's omissions and the two single-use rules around it, the `@noble/ciphers` note that belongs at the import rather than in two places, the enrollment backfill, and the two accepted metadata leaks. House form throughout: the Host bounds constants become a table naming the file each is declared in (three of the seven do not live in `e2e-bounds.ts`, which SECURITY.md implied), and `Source of truth:` pointers are consolidated per section. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
…rompt (stage 7) SECURITY.md -> "Remote Control", read against the tree rather than against itself. The trust-boundary narrative now states the one-channel property where the compromise ladder starts, and names the lint that makes it a build failure instead of a reading. Three claims were wrong and are fixed: `isE2eServerToHostFrame` does not run on the relay (`isE2eClientFrame` / `isE2eHostFrame` do, which is the same argument with the right identifier); the setup-password FAIL IF named `app.ts` for a constant-time compare that lives in `secrets.ts`; and Host bounds pointed at `e2e-bounds.ts` for three constants declared elsewhere. Push sealing had no named implementation and no named test in a section that already rested on it — it gets its own FAIL IF over `sealPush`/`openPush`, the seal capability, the per-recipient rule, and the worker as the sanitization sink; the recipient clamp is named; and "Notification text is not yet sealed" is gone from Accepted limitations, which contradicted three other lines of the same section. What replaces it is the residual that is actually open: a replayable push, endpoint correlation across a phone's Hosts, and the two device-only facts no CI runner can observe. One new FAIL IF binds `scripts/e2e-lint.mjs` and its self-test into `pnpm test`. `docs/stories/pairing.mdx` still taught a four-layer model with a Device Key in it. It is five layers with the Noise channel first, the server is introduced as a ciphertext pipe, sealed push is explained where the push card is, and a "three limits" list that had four items is counted. The nightly application-security prompt asked about `adopt` paths and device keys, neither of which exists. It now names the e2e modules and harnesses by path and asks the adversarial questions this boundary actually turns on: relay opacity and forged frames, presence binding and replay, the four-field conjunction, invitation reservation under concurrency, Host bounds beneath a guard-less relay, sealed push and its sink, Host static custody, and drift in the sections that changed most recently. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
The properties `SECURITY.md` -> "Remote Control" rests on are absences: one Noise suite with no way to select another, no JavaScript curve, no plaintext relay route, no legacy frame discriminant left to answer, no Server-side view of protocol-v1, no checked-in worker shadowing the built one, no optional field on a ciphertext or a transcript. An absence is what a reviewer stops noticing — nothing in a diff announces that a second cipher suite became reachable — and the nightly audit is thorough but probabilistic. `scripts/e2e-lint.mjs` (`pnpm lint:e2e`) makes the cheap half a build failure: 13 rules over the e2e modules, scoped explicitly so that WebAuthn's mandatory ES256 in `passkey.ts` and the Server's SPKI import are not swept up by a curve rule, and so that the regression tests naming the retired tags to assert their rejection are not flagged for containing them. Each rule also names the `SECURITY.md` line it enforces, and the lint fails if that line is gone — a rule can otherwise outlive the prose that authorized it. Two of those lines did not exist and now do: nothing in the section said a private key agreement stays inside WebCrypto (which is what makes the Host and Client statics non-extractable rather than bytes in the process that owns every PTY), and nothing named a Server-side protocol-v1 type import as the leading indicator of a relay that has started to care what it carries. `scripts/e2e-lint-selftest.mjs` is the inverse of `deploy-lint-selftest.mjs`, because the lints are: the installer lint checks that controls are present, so removing one is the test; every rule here checks an absence, whose characteristic failure is a pattern that cannot see the thing it names. So it re-introduces each forbidden thing, adds a copy for the exact-count rule, and deletes each rule's `SECURITY.md` line, requiring a red lint each time — 26 cases. It caught one rule immediately: the root-build check matched `dev:pocket-server`'s copy of the same command, so it stayed green after `build` stopped building the worker at all. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Each in an earlier stage's file, each with a test that goes red without it. `ScanInvitation` shared one `<video>` between effect runs while `startScan` attached a stream to it, so two overlapping starts raced for `srcObject` and the loser's teardown — ours, or the decoder's own `stop()` — took the winner's preview down and stopped tracks it did not own. StrictMode double-invokes the effect, which made a black viewfinder reporting `camera === 'live'` the ordinary mount path rather than a rare race. Starts are now serialized on one chain, and a run superseded before its turn never opens a camera at all. Separately, a rejection from an earlier read outlived the code that replaced it, so a failed ceremony stacked its error under a stale "that is not a code"; accepting a parsed code clears it. `App.connectTo` let a rejecting `adapter.init()` throw straight out with the session established, leaving the user on a Hosts list that cannot end anything while the phone kept keepaliving and holding one of the Host's session slots. It now leaves what leaving the wall leaves. `RemoteHost.mintInvitation` did all of its bookkeeping *before* its one await. Two mints overlapping across `generateNoiseKeyPair` each evicted against the same pre-await size and then both inserted, exceeding `MAX_TOKENS_PER_HOST` — the cap the Server's own setup-token bound is shared with — and a `stop()` in that window re-armed the reaper on a Host holding nothing and returned a QR the panel paints `live` over a relay socket that is gone. Both decisions moved after the keygen, onto one synchronous stretch. `openPocketDb`'s `onblocked` rejected and walked away, but `blocked` is not terminal: the other tab can close and the open then succeeds, handing back a connection nobody holds a reference to and nothing can close. `server/src/app.ts` checked the `/ws/host` bearer once, at the upgrade, and never again — so deleting a row from `hosts.json`, which is the *documented* revocation, left the revoked Host's socket relaying for as long as it stayed connected. A bounded sweep (`HOST_REVOCATION_SWEEP_MS`, one minute, `unref`'d) now closes it with `WS_CLOSE_HOST_REVOKED` and tells its Clients `host-gone`, the same teardown a disconnect performs; `sweepRevokedHosts` is exposed so the test drives the decision rather than the timer. `server.md` -> Guardrails states the rule, and that revoking a *Client* is still the Host's ACL and still needs a Host restart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
The lint pair and the installer lint pair now share their plumbing and only
their plumbing, through `scripts/lint-kit.mjs`: one `repoRoot` (resolved from
`import.meta.url`, the idiom spec-lint and loopback-lint already use, rather
than a fourth `git rev-parse` spawn), one `normalizeEol`, one child-process
lint runner, and one backup/mutate/restore that `deploy-lint-selftest.mjs`
previously wrote inline twice. Rules and patterns stay in each lint.
Two things fell out of that. `e2e-lint`'s tracked-file listing had forked
`loopback-lint`'s and already lost its hardening: without `-z`, a tracked path
containing a space arrives git-quoted and is silently filtered out, which is a
rule whose scope shrinks without saying so — the exact failure the file's own
header warns about. And the listing plus every file read is now computed once
per run instead of once per rule: 5 `git ls-files` spawns and 1201 reads become
1 and 303. The SECURITY.md half of the e2e self-test now runs once per distinct
line rather than once per rule — six of the thirteen cited a sentence another
rule had already deleted and restored, so the honest count is 20, not 26. Eight
`e2e-lint` exports had no consumer and are module-local again.
`RemoteHost.mintInvitation`'s post-keygen guard was `#stopped`, a narrower copy
of the epoch the class already uses for exactly this ("did a teardown land
across my await", `#enqueue`). Only `stop()` sets `#stopped`, while `#onClose`
retires every invitation without it — so a mint spanning a dropped socket still
inserted into the map that was just cleared and returned a QR over a socket
that is gone, which is the far more common trigger. The test now drives both
teardowns.
`server/src/app.ts` no longer starts a timer: `createApp` had none before this,
`index.ts` is the process owner, and every `freshApp()` in the suite was
otherwise leaving an interval behind. `AppConfig.hostRevocationSweepMs` had no
caller and is gone; `RelayHub.closeHost` imports the close code beside the
REPLACED pair it already held rather than taking it as two parameters.
`App.tsx` gains `endSession` — the third copy of `teardownAdapter(); close()`
became the first call of one function. `ScanInvitation`'s chain tail can no
longer be a rejected promise, and the paste form drops a `setRejected(false)`
that `accept` now performs. `pocket-db`'s `settled` is `rejected`, which is
what it means, and loses a dead assignment after `error`.
Prose: the rationale file no longer re-issues rules it may not own (the
delivery-id listing rule, the vector's contents, the same-commit pin rule, the
idle-deadline exclusions), and the dead-approach story about a paragraph that
was deleted is deleted. The spec drops two because-clauses its rationale
already carried verbatim. 4,796 -> 4,773 words.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
The revocation sweep could close every socket on the server. Its comment
claimed a `hosts.json` caught mid-edit throws out of `list()`, and half of that
is true — unparseable JSON does — but `JsonFileStore.read` answers its fallback
on ENOENT, so a file that is *absent* reads as `[]`, which is exactly what
revoking everyone looks like. A file is briefly absent whenever an editor saves
by rename rather than truncating in place, which is how several of them save.
`HostStore.listIfPresent` is the distinction `list()` deliberately erases, and
the sweep now closes nothing on an answer it did not actually read. Revoking is
still emptying the array, which still answers an enrolled set of zero and still
closes everything. Both halves are pinned.
`mintInvitation`'s epoch guard also fires on an ordinary reconnect, which is
correct — invitations go with the socket — but it was the one refusal out of
`#setupQr` without the `could not mint a setup code:` prefix its siblings
carry, and it said "no longer connected" to someone whose panel was about to
read connected again. Reworded for every teardown the epoch covers.
`ScanInvitation`'s chain tail was asserted never to reject and nothing checked
it. Without the guard, one throw out of a start's teardown — a release inside
the `catch` — is inherited by every start queued behind it and the scanner
never opens a camera again; the new case fails without it.
Rule 2 of `e2e-lint` anchored a suite selector on `{,;` only, so
`deriveKey(pattern: string, …)` — the same rule broken as a positional
parameter — was invisible; `(` and `<` join the class, with no new match
anywhere in the tree. `presence.ts` joins `E2E_MODULES`, which the audit prompt
already listed as part of the boundary: it derives a challenge and never
verifies an assertion, so unlike `passkey.ts` it has no business naming a
curve.
Docs: the spec's invitation lifecycle now states that a mint straddling a
teardown is refused rather than inserted — user-observable, so it belongs above
the fold — and `pairing.mdx` no longer says a removed record waits for a Host
restart, which is now true only of a *Client's* approval.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Deploying mouseterm with
|
| Latest commit: |
d81a9e7
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://c83e993a.mouseterm.pages.dev |
| Branch Preview URL: | https://e2e-client-host-3.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
One finding, on the hosts.json join added to PushSubscriptionStore.list(). Everything else read clean: the seal construction and its bounds, the Server's field-by-field forward, the worker's decision table, the revocation sweep's absent-vs-empty distinction, and the lint/selftest pair all hold up. spec-lint, e2e-lint, and deploy-lint pass locally on the merged tree.
The join reads the enrolled set through HostStore.list(), which collapses "absent" to [] — the exact distinction listIfPresent() was carved out for two classes up. upsert() then calls list() and writes the result back (await this.writeAtomic(kept)), so a subscribe landing inside the rename window of a hand edit persists the truncation: every other row is gone from disk, not just filtered from one read. removeDelivery and removeEndpoints are safe by accident — both short-circuit when kept.length === all.length, which an empty all satisfies.
Narrow window, but it is the same operator action the sweep guards against, and host-revocation.test.mjs already has the "a hosts.json absent for an instant revokes nobody" case that push.test.mjs has no counterpart for. The inline suggestion mirrors what the sweep does.
`PushSubscriptionStore.list()` joined the enrolled set through `HostStore.list()`, which collapses an absent `hosts.json` to `[]` — so during the rename window of a hand edit every subscription row read as orphaned. `upsert()` writes `list()`'s answer back, which would make that truncation durable rather than a momentary blank read. Join through `listIfPresent()` instead and keep every row when the file is not there at all, the same absent-vs-empty distinction the relay's revocation sweep already makes. Emptying the array is still the revocation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
main's Noise rewrite (#515, #517, #518, #522, #523, #524) already landed this PR's change: `test-remote-host-link.ts` now imports `DEFAULT_PAIRING_TTL_MS` from `server-lib-common` and builds the fixture's `expiresAt` from it, exactly as this branch intended. The surrounding QR grammar it was written against is gone — the fixture composes a positional `#pair?` invitation via `formatPairingInvitationUrl`, and `SetupTokenResponse` no longer carries `mintId`. Both conflict hunks resolved wholly on main's side, so the branch is now an exact no-op against main: nothing pre-Noise is reintroduced, and the intent this PR chased is already enforced by main's import. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Third of three stacked PRs for the e2e-client-host scope. Stacked on #517 (which is stacked on #515) — review the diff against
e2e-client-host-2. With this PR the scope is complete: every stage is above the fold in the specs and the## Futureledger is gone.What lands
sealPush/openPush(dormouse/push/v1): X25519 of the Host and Client statics → WebCrypto HKDF-SHA-256 with a random 32-byte salt → ChaCha20-Poly1305 at the all-zero nonce, one key per salt; a separate construction from the Noise session./api/push/sendtakes per-recipient sealed envelopes and the Server forwards exactly{ hostId, salt, ct }, keeping and logging no text. The Host seals once per active ACL record through a capability over its non-extractable static.lib/pocket/public/sw.jsis replaced by a TypeScript worker built as one classic IIFE intodist-pocket/sw.js(emptyOutDir: false), guarded bylib/scripts/assert-pocket-worker.mjs, which now runs in the rootpnpm build; the worker decrypts with the pinned Host record and re-sanitizes at the sink, showing a generic notification on any failure. Rows for a Host that lefthosts.jsonare dropped on read.## Future;remote-security-model.mdrestructured with a rationale split (4,796 words against a 4,800 budget);remote-api.md's Transport rewritten for the Noise session;SECURITY.md→ "Remote Control" re-audited against the tree;docs/stories/pairing.mdxand the nightlyapplication-securityaudit prompt extended to the new boundary.scripts/e2e-lint.mjs(13 textual rules: one Noise suite, no negotiation or AES/P-256 fallback in the e2e modules, no JavaScript curve library, no legacy relay discriminant, no Server import of protocol-v1 plaintext types, no hand-maintainedsw.js, classic registration, no optional ciphertext fields, worker assertion wired) withscripts/e2e-lint-selftest.mjsproving each rule load-bearing, both in the rootpnpm test. Seven carried findings from the stage-6 review fixed with tests, including a revoked-Host socket sweep (WS_CLOSE_HOST_REVOKED) so deleting ahosts.jsonrow now ends that Host's relay socket.Each stage had a
/simplifypass and a high-effort/code-reviewpass committed on top.Verification
Root
pnpm test(all seven lints incl. the new e2e lint and selftest, every package) andpnpm build(now includingbuild:pocketand the worker assertion) pass on the head commit;cd standalone && npx tsc --noEmitis clean.Still open
Two device-only facts (recorded under
remote-security-model.md→ Future → Device verification): an X25519CryptoKeysurviving IndexedDB structured clone on iOS Safari, andgetUserMediainside a Home Screen web app. An independent cryptographic review is required before paid SaaS may claim this model.🤖 Generated with Claude Code
https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj