Skip to content

feat: hosts as first-class account entities - #46

Draft
aristotl-dylan wants to merge 25 commits into
feat/remotefrom
t3code/design-remote-host-entities
Draft

feat: hosts as first-class account entities#46
aristotl-dylan wants to merge 25 commits into
feat/remotefrom
t3code/design-remote-host-entities

Conversation

@aristotl-dylan

@aristotl-dylan aristotl-dylan commented Aug 14, 2026

Copy link
Copy Markdown
Owner

What Changed

Hosts become first-class entities on the account. A host is a user's machine running a Synara server; it registers on their account, syncs to all their devices, and clients reach it over whichever transport works — loopback, LAN, Tailscale, or the cloud relay — behind one identity and one credential.

Five slices, built and tested independently, then proven together:

  • A — Account API (apps/api): user-owned hosts (ownerUserId is the authorization key, org is tenancy), per-host discoverability, Ed25519 keypair link over a nonce challenge, device registration with proof-of-possession, single-use relay grants, and a revocation feed. 3 additive migrations.
  • B — Relay (apps/relay, new): a stateless splice. It verifies a grant, pairs two sockets 1:1, and forwards opaque frames with TCP-level backpressure. It cannot read session traffic and holds no session state.
  • C — Host dial (apps/server): the host's keypair identity, a supervisor-kept relay control socket, and the mint gateway that issues session credentials. This is the ADR 0012 cutover — the synhost_ bearer token is gone, host↔API auth is by signature, and revocation is deleting a public key.
  • D — Clients (apps/web): non-extractable ES256 device keys, the transport probe race, enrollment UX, device management.
  • E — Secrets sync: per-host config encrypted end to end under a Sync Key the cloud never sees, with device-to-device pairing over a confirmed verification code and rotation on device removal.

Plus packages/relay-protocol (shared close codes and frame types) and apps/e2e (integration harness, 10 scenarios).

The design is in docs/adr/00010015 and docs/specs/; vocabulary in CONTEXT.md. ADR 0010 supersedes 0005 and 0009 — reachability is attempt-based, so there is no presence store and no push channel.

Why

Hosts previously existed as a registration record, not an entity you own. Ownership was implicit, there was one transport, and revoking access meant rotating a shared bearer token. That doesn't survive the thing this is for: your machines, on your account, reachable from any of your devices, with access you can take back.

Three decisions carry most of the weight:

The host mints its own session credentials (ADR 0011). The cloud issues a short-lived grant; the host verifies it and mints the credential. So the cloud is a directory and a pipe, never a party to your sessions — and the owner short-circuit is pinned to the link-time owner, which means you can still reach your own machine while the API is down.

One credential, every transport (ADR 0013). The credential is DPoP-bound to a device key and says nothing about how you connected, so switching from relay to LAN mid-session doesn't re-authenticate.

The keypair is the only host credential (ADR 0012). A hard cutover rather than a dual-path era: deployed hosts force re-link once, and there is no window where two auth paths need to agree.

UI Changes

Host list, enrollment, device management and Sync-Key pairing all follow the token system in slice-d-clients.md and use disclosureMotion.ts for every open/close — no bespoke transitions. Reachability reads as text and opacity rather than status dots: the palette has no success/warning token, and attempt-based reachability means there is no live presence to show.

Screenshots pending the polish pass — which is why this is a draft.

Verification

Full suite green: api 297, e2e 10 scenarios, relay 70, shared 645, contracts 244, server host suites 81, web host suites 78. bun fmt, bun lint, bun typecheck all clean.

Worth stating plainly, because it changes how much that green means:

The per-slice suites each fake their neighbours — the relay tests fake the API, the host tests fake the relay — so they only prove each side honors the contract as that side understands it. The apps/e2e harness runs the real API, the real relay, a real host and a real client in one process, and it found 7 bugs the per-slice suites structurally could not: a closing-handshake hang, silently dropped frames, and several accept-before-listener races.

A mutation sweep then broke the product in 78 ways and found that 60 of them failed no test. All 60 are closed, each proven by applying the exact mutation, watching a named test fail, restoring, and confirming green. The dominant failure mode was assertions that were true by construction — a test asserting a symbol while the product used that same symbol, blind to the value changing underneath. That is how a close-code namespace collision shipped in the first place. Values are pinned as literals now, and the crypto paths have frozen wire vectors, because nothing else catches a domain-separator bump — which would silently make every stored host secret undecryptable on deploy, with no error until someone opened one.

18 product defects were found and fixed, including three where a revoked device kept or regained access.

CI status. Format, Lint, Typecheck, Test, Migration Lineage, Release Smoke and Windows Process Regression all pass. One step fails — Browser test (stable) — and it is a pre-existing break, not something this branch introduces: ChatView.browser.tsx > keeps worktree setup resolvable while attachments upload fails deterministically (2/2) on a clean feat/remote worktree with the composer never mounting inside the shared helper's 20s waitFor. That same step is also red on main, there in a different timing-sensitive test. The browser test this PR adds passes: SyncKeyPairingPanel.browser.tsx, 5/5. I have left it alone rather than fold an unrelated fix into a 220-file PR — happy to take it as a follow-up.

Three CI failures that were mine are fixed in c75d35d40. One is worth calling out because it was not a test problem: @synara/relay-protocol was missing from the release workspace manifest list while apps/server and apps/web both depend on it, so bun install in the release root could not resolve it. That list is also what build-desktop-artifact stages, so this was a broken desktop release that Release Smoke caught correctly.

Not yet done: the relay isn't deployed, and Tier 2 (three local processes) and Tier 3 (two Macs) manual testing are still ahead.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes

Not small — it's a feature epic, and the slices interlock: the credential is minted by the host (C) against a grant issued by the API (A) and spliced by the relay (B), so no subset of them is independently testable. Slice boundaries are one commit each if that helps review. Screenshots and interaction video land with the polish pass before this leaves draft.

Grilling session output: glossary (CONTEXT.md), ADRs 0001-0015, and the
consolidated spec. Compared against t3code's production architecture;
adopted host-minted DPoP-bound session credentials and attempt-based
reachability, kept our own splice relay data plane.
… review

25 confirmed findings resolved in slice-a v2: additive landing (legacy
surface survives until Slice C cutover), single link protocol (DB-row
nonce, no challenge JWT), owner-only re-link (no org-member takeover),
RFC 8628-shaped device-code flow, device-key proof-of-possession with
per-user jkt scoping, fail-closed API signing key, FK-free revocation
queue with lag-watermark cursor, org-departure enforcement at grant
time, unlink route, ADR 0011 threat-model precision.
Adds the account-side foundation for hosts as first-class account entities
(docs/specs/slice-a-account-api.md). Additive: the legacy register/host-token
surface keeps working until the Slice C cutover.

Schema: hosts gain owner_user_id (the authorization key), discoverable,
public_key_jwk and key_generation; new devices, link_challenges and
revocation_events tables; migration 0008 backfills ownership and strips the
retired 'public' endpoint transport.

Auth: hosts authenticate by Ed25519 proof (HostProof) instead of bearer
tokens; linking runs a DB-nonce challenge verified TOFU against the key the
proof carries, with an RFC 8628-shaped device-code flow for headless boxes.
Devices register with proof of possession and are the DPoP binding target for
short-lived relay grants; hosts fetch relay tickets for the (stateless) relay.

Revocation: discoverability changes, unlink, delete, device revocation and
owner org-departure append to a poll-able feed whose watermark is bounded by
transaction xmin, so an event can never be skipped by a slow writer.

Verified: apps/api 275/275, contracts 243/243, fmt/lint/typecheck clean, no
migration drift.
Follow-up hardening from the Slice A crypto review:

- Expiry is absolute for short-lived proofs. jose honors a token until
  exp + clockTolerance, which on a 60s host proof doubled the replay window
  (and reached ~3x combined with forward-stamped iat). Skew tolerance stays
  on the not-yet-valid side only.
- Device revocation fan-out is capped and filtered to hosts the device could
  actually have reached (linked, owned-or-discoverable, most recently seen
  first). Previously one member of a large org could write an event per host
  per revoke and saturate the shared feed for 24h.
- POST/DELETE /devices are rate limited per user, since device churn is the
  write amplifier on that feed.
New deployable apps/relay plus packages/relay-protocol (shared control
messages and close codes), per docs/specs/slice-b-relay-service.md.

The relay is a dumb pipe with no database: hosts hold a supervisor-kept
control socket authenticated by an API-signed relay ticket, clients present a
single-use grant, and the relay splices one client socket to one freshly
dialed host socket, forwarding opaque frames. Grants and tickets are verified
statelessly against the API's JWKS with the same discipline as the API
itself — pinned typ/alg/aud/iss, bounded lifetime, absolute expiry. Routing
keys on hostId only: environmentId is self-asserted and unique per org, so
routing on it would be a cross-org takeover.

Revocations are polled from /internal/revocations, advancing only to the
watermark and tolerating duplicates; host_unlinked tears down the host's
sockets and tombstones it for the ticket lifetime so an already-minted ticket
cannot reconnect.

Fixes found by running the socket-level tests the implementation sandbox
could not execute: a paused socket could never complete the WebSocket closing
handshake, so refusals never delivered their close code; backpressure
discarded frames the receiver had already parsed rather than queueing them,
silently corrupting the tunneled stream; established pairs outlived their
control socket, escaping revocation and leaking capacity; a client closing
mid-admission leaked a pair slot permanently; spliced sockets inherited ws's
100 MiB default payload cap.

Verified: relay 57/57, relay-protocol 3/3, api 276/276, contracts 243/243,
fmt/lint/typecheck clean.
Connections page in Paper is empty, so there is no host-UI artboard to match;
record the actual token values and the conventions that follow from them
(no color-coded status dots, destructive reserved for revoke/delete/unlink).
…ay; ADR 0012 cutover

Implements docs/specs/slice-c-host-relay-dial.md and completes the migration
off host tokens.

Host identity: Ed25519 keypair persisted with atomic 0600 writes, generated on
first link and rotated on re-link. All host->API calls authenticate with a
synara-host-proof+jwt; both the session and device-code link flows match the
shipped Slice A API.

Remote access: a supervisor-kept relay control socket (ticket auth, ready/
ping/pong, splice dial, revocation delivery, jittered reconnect) plus a
transport-independent mint gateway that verifies the grant against the API
JWKS, binds it to the device key, and mints a 1h host-signed session
credential. Relay, direct and SSH-forward transports all bridge through the
ordinary local WebSocket admission path. A session registry drops sessions on
revocation: owner sessions survive discoverability-off, org-member ones do not.

Cutover: migration 0009 drops host_tokens and hosts.registered_by_user_id, and
the legacy register/rotate surface is removed from the API, contracts and
shared client. Verified no live-source references remain.

Fixes from review before landing:
- forwarding a peer's 1005/1006/1015 close code threw synchronously inside a
  close listener, so any relay restart on a bridged session crashed the host
- handshake frames were processed concurrently, letting two session_authorize
  frames in one segment both bridge the same socket and duplicate every RPC
- grant jti was cached only to exp while verification accepts exp+tolerance,
  leaving a replay window the relay already handled correctly
- no forced JWKS refetch on unknown kid: every mint failed for up to five
  minutes after a routine API key rotation
- an SSH forward left local trust intact, so ssh -L to the main port bypassed
  the credential ADR 0013 requires; a forward now withdraws local trust
- backoff never reset after a healthy connection, and a splice dial could not
  be aborted, so a session could be created after teardown

Verified: api 263/263, shared 634/634, contracts 237/237, relay 57/57,
server 3931 passing (one pre-existing GitCore timing flake, fails identically
on a clean baseline), fmt/lint/typecheck clean, no migration drift.
Implements docs/specs/slice-e-secrets-sync.md §3. The API stores opaque
ciphertext and enforces only ownership and versioning; it is structurally
incapable of reading Host Secrets (ADR 0004).

Adds host_secrets (+ bounded history) and sync_key_wraps via migration 0010,
with no foreign key to hosts — mirroring revocation_events so no deletion
order can destroy data. Owner-session-only routes: GET/PUT secrets with
compare-and-swap on version so a Sync Key rotation and a concurrent edit
cannot silently clobber each other, and a single-delivery pairing wrap
exchange for handing the Sync Key to a newly paired device.

Non-owners get 404, never 403: unlike hosts, which are deliberately visible
to org-mates when discoverable, Host Secrets never cross user boundaries, so
even existence must not leak.

Fixed while testing: a concurrent first write echoed the loser's own stale
version back in the 409, which would have spun a client retrying against a
version that can never recur.

Verified: apps/api 289/289 against real Postgres, contracts 237/237, no
migration drift.
Adds the client half of remote access and the integration harness that proves
the three services actually agree.

Clients (docs/specs/slice-d-clients.md): device identity as a non-extractable
ES256 keypair with proof-of-possession registration and RFC 9449 DPoP; the
ADR 0007 transport race (loopback > LAN > Tailscale > SSH > relay, preference
over latency); and the web UI — host list with attempt-based reachability as
text rather than presence dots (ADR 0010), owner-only discoverability toggle,
the multi-member-org consent prompt (ADR 0002/0015), a /link device-code
approval route, and a devices pane. All open/close motion goes through the
shared disclosure module; no bespoke transitions.

Checkpoint (apps/e2e): boots the real API, real relay and real host on
ephemeral ports and runs eight scenarios end to end — enrollment and re-link
rotation, device-code enrollment, relay traffic byte-identical in both frame
kinds, one credential reused across transports without re-minting, grant
single-use, revocation killing a live session across all three services,
offline degradation, and backpressure integrity.

Product bugs the harness found, each now fixed with a regression test — every
one invisible to the per-slice suites because each slice fakes its neighbours:
- host proofs used the account root as issuer/audience instead of /api/v1, so
  the host could never have authenticated against the real API
- the gateway demanded a fresh mint on every socket, so an existing credential
  could not authorize a direct transport (contradicting ADR 0013)
- the local bridge discarded text/binary frame kind, silently rewriting text
  frames as binary
- both transports accepted a socket before attaching their inbound listener,
  dropping any client whose first frame arrived immediately
- relay shutdown disconnected control sockets first, so active pairs were
  rewritten to 4404 host-unavailable instead of the documented 1001

Also makes the revocation watermark test assert its actual invariant (the
watermark must not pass an uncommitted id) instead of exact equality with a
baseline, which broke whenever another suite shared the database.

Verified: e2e 10/10, api 289/289, relay 58/58, shared 634/634, contracts
237/237, server 3934 passing (two load-sensitive flakes in unrelated files,
both green in isolation), fmt/lint/typecheck clean.
…code collision

Two defects found by the whole-epic review, plus the doc corrections it
prompted.

The relay dial supervisor set its connected flag BEFORE awaiting session
reverification, which calls the account API and therefore the identity
provider. During a provider outage every attempt threw after being marked
healthy, so backoff reset each loop and the fleet would have hammered the
ticket endpoint and relay handshake several times a second, indefinitely.
Reverification is now best-effort with an observability hook, and the flag is
set only once the supervisor is committed to running the socket.

Host-session close codes reused the relay's 44xx range — 4403 meant both
grant-replay and session-revoked. Those codes travel verbatim through a
splice, so a client whose access was revoked read it as a replayed grant and
tried to re-grant, which can only 403. Host-session codes now occupy their own
45xx range in the shared protocol package, and the client classifies them.

ADR 0011 is corrected rather than reinterpreted: it claimed hosts enforce a
last-known local policy at mint time, but the implementation calls the API
live, so under API compromise the same adversary answers both the grant and
the authorization question. The ADR now states what the design actually
guarantees (relay compromise fabricates nothing; the cloud cannot impersonate
a host; access requires a live host) and names the gap.

Adds docs/specs/remote-hosts-follow-ups.md recording what a user can reach
today versus what is still unwired — notably that no shell implements the
NativeApi hosts namespace, so the client UI is unreachable in a real app.

Verified: e2e 10/10, relay 58/58, relay-protocol 59/59, affected server suites
green, fmt/lint/typecheck clean.
… independence

Four items from docs/specs/remote-hosts-follow-ups.md.

F6 — consent precedes discoverability (ADR 0002). Links no longer insert
discoverable:true unconditionally; a host linked into a workspace with other
members starts PRIVATE and the owner opts in, while a personal workspace stays
frictionless. Failing or absent membership lookup fails closed: the
recoverable outcome is a toggle, the unrecoverable one is silent org-wide code
execution. Tests that exercise sharing now opt in explicitly through a helper,
which makes the consent step visible rather than inherited.

F7 — device revocation survives a missed event. HostAuthorizationSnapshot now
carries recently revoked device thumbprints, bounded to the session-credential
lifetime, so a host that never received the push event — relay restarted, host
offline, fan-out cap elided it — still drops the stolen device's session on
its next reverify instead of waiting out the ~1h TTL.

F10 — the owner path no longer depends on the cloud. Mint decides the owner
from the link-time record rather than a live API answer, so an owner reaches
their own machine during an account-API outage, and a compromised API cannot
nominate itself as owner without also holding the host key. The org-member
path still fails closed through the live check: that is genuinely
cloud-governed policy.

F8 — a linked host with no relay URL now warns loudly at startup. It still
serves direct and ssh-forward sessions but has no control socket, so every
revocation kind silently degrades to credential expiry; that is a
misconfiguration and should read as one.

Verified: api 292/292 against real Postgres, contracts 239/239, host-side
server suites 19/19, fmt and lint clean.
F9 — relay reachability is now per-host. The relay's only health surface was
aggregate, so probing it proved the RELAY was up: every host in a
relay-configured deployment rendered 'Reachable over relay' whether or not its
control socket was connected, and the truth arrived later as a 4404 at session
open. The relay already holds the answer in memory; it now exposes it as
GET /healthz/host/:hostId, boolean and nothing more — no more than a session
attempt would reveal a moment later, and no presence claim beyond what ADR
0010 permits.

Vocabulary — CONTEXT.md proscribes 'remote host' ('all hosts are the same
entity, reachable or not'), yet the entire client feature taught the banned
term: lib/remoteHosts, useRemoteHosts, RemoteHostsApi, RemoteHostEnrollment.
Renamed to hosts/useHosts/HostsApi throughout, including the user-facing copy.
The server's remoteSessions keeps its name: a session genuinely is remote.

Verified: relay 59/59, web host suites 111/111, full web suite +1 versus the
documented baseline (the same 38 pre-existing zustand store failures),
fmt/lint clean.
…uto-registration

Closes the two items that stood between the built feature and a user being
able to reach it.

F1 — the hosts namespace. Nine owner-guarded RPCs (list, update, delete,
listDevices, revokeDevice, approveDeviceLink, requestGrant, enrollment,
unlinkLocalHost) now span contracts, server and web, so the Connections panel,
the multi-member consent prompt and the /link approval route resolve in a real
shell instead of rendering the 'server does not support hosts yet' fallback.
Every handler runs behind requireOwnerRole, preserving the enumerable owner
boundary the account RPCs established: this is account state, and a paired
client-role session must neither read nor mutate it.

Enrollment — ADR 0015's primary path, previously CLI-only. The shell now
generates an ES256 device key at sign-in, registers it with proof of
possession, and persists it with the same atomic-0600 discipline as the host
identity; the private key never leaves the machine and is re-imported as a
non-extractable signer. requestGrant therefore carries a real thumbprint and
returns a device-bound grant rather than device_not_registered, and a revoked
key re-registers and retries once instead of failing the user. Sign-in also
links the bundled local host automatically, with status retrying a failed
enrollment; sign-out's unlink path is now directly tested.

Verified: api 292/292 and e2e 10/10 against real Postgres, contracts 239/239,
shared 635/635, server 3963 passing (one pre-existing GitCore timing flake,
green in isolation), web host suites 110/110, fmt/lint/typecheck clean. The
PKCE and port-binding tests codex reported as failures pass here — its sandbox
forbids loopback listeners.
Item 5 — host-side session visibility. Owner-only hosts.listSessions and
hosts.endSession, backed by one shared registry instance for host connectivity
and the RPC handlers, with an Active sessions section in the Connections panel
showing who is connected, from which device, over which transport, and since
when — plus a confirmed disconnect through the registry's existing revocation
close path. This closes the third leg of ADR 0011's threat model: the cloud
could already never impersonate a host, and access is now no longer invisible
either.

Item 4 — Slice E's client half. Owner-only pairing RPCs carry the Sync-Key
handoff: both devices derive the six-character verification code, and the
recipient does not unwrap or persist the key until the codes are confirmed to
match, which is the MITM guard ADR 0004 asks for. Revoking a device now
triggers surviving-device rotation of every Host Secret, using CAS writes and
a durable journal so a partial upload or a process restart recovers rather
than stranding half-rotated ciphertext; self-revocation is refused, since a
revoked device cannot be the surviving rotator.

Item 3 (mDNS) is deliberately not implemented: the repo has no
Bonjour/DNS-SD/Zeroconf dependency, and choosing a cross-platform one is a
decision with packaging and maintenance consequences rather than a detail to
slip into a follow-up. buildHostCandidates already accepts a discovered list,
so the choice can be made later without touching the transport race.

Also replaces a hardcoded RPC head count in the contracts test with the
property it meant to assert — every declared hosts method has a request
schema — since a count only fails after someone edits the number instead of
noticing a missing schema.

Verified: api 292/292 and e2e 10/10 (twice) against real Postgres, contracts
239/239, shared 636/636, server host suites 52/52, web host suites 145/145,
fmt/lint/typecheck clean.
mDNS is decided against rather than deferred: directory endpoints plus the
relay already cover discovery, and the case mDNS would fix — a stale
self-reported LAN address — already degrades correctly by losing the probe
race and falling through to the relay. The research behind the decision is
recorded so it can be reopened without redoing it, including the finding that
Windows has no DNS-SD browse tool at all, which rules out the repo's usual
shell-out pattern.

The Sync-Key pairing presentation is now specified: paste a short code
mirroring the existing /link device-code flow, new device displays and the
existing device types, mismatch allows three retypes then burns the pairing
(a typo is forgiven; the other cause of a mismatch is the MITM the code
exists to catch), living in the Connections pane rather than a new route.
A 'Sync host secrets' section in the Connections pane runs both halves of the
handoff, so the crypto committed with Slice E is finally reachable.

The flow is two copy/pastes and nothing more: the new device emits a versioned
synara-sync-v1: blob carrying only its device id and public JWK — no secret
material — which the existing device pastes to produce a wrap; then each side
shows a six-character code and the user types the other device's code to
confirm. The key is unwrapped and persisted only on a match, which is the MITM
gate ADR 0004 asks for. /link and this flow now share one short-code input, so
the alphabet and normalization cannot drift apart.

Two fixes were needed to make the decided behaviour real rather than nominal:

The three-strikes cap did not exist. The coordinator only compared codes, so
'cap it at 3' would have been implemented in React — which is not a cap, since
the confirm RPC is the security boundary and anyone can call it directly. The
cap now lives in HostSecretsCoordinator and throws a typed error carrying
remainingAttempts; at zero it discards the pairing outright, and the test
proves the teeth by showing the CORRECT code stops working after a burn.

That countdown then could not reach the UI: the sensitive-error mapper drops
causes by design, so remainingAttempts was being stripped on its way through
the WS boundary. The mapper now carves out exactly this error and leaves its
cause-dropping intact everywhere else.

Verified: web host suites 152/152, server 54/54 across hostSecrets and the
error mapper, contracts 239/239, shared 636/636, api 292/292 against real
Postgres, fmt/lint/typecheck clean. The browser interaction suite is written
but could not run in the authoring sandbox (port bind refused); it is covered
by typecheck and runs here.
The two features shipped last had unit and component tests but nothing
proving the pieces agree across process boundaries — the exact gap this
harness exists to close.

Scenario 9 drives two real coordinators for one user against the real
account API: both devices derive the same code, the key is provably NOT
adopted before confirmation, a secret sealed by the first device actually
opens on the second (the point of the feature, and the part no unit test can
show), the wrap is single-delivery, three wrong codes burn the pairing so
even the correct code then fails, and revoking a device rotates the secrets
such that old ciphertext stops opening.

Scenario 10 opens sessions over BOTH the relay and the direct transport and
asserts they are listed with distinct transports — proving the registry is
not relay-specific — then ends one by id and checks the client observes the
documented close code while the other session survives, plus expiry sweeping.

Both scenarios were bite-checked rather than assumed: breaking the attempt
cap fails 9, and reintroducing the close-code collision fails 10. That second
check initially did NOT fail, because the assertion named the constant rather
than its value — so a collision could have been reintroduced simply by
changing the constant, which is how it shipped the first time. The test now
pins 4503 and asserts it differs from the relay's 4403.

Verified: e2e 12/12 against real Postgres, api 292/292, server hostSecrets and
remoteSessions 15/15, relay-protocol 3/3, fmt/lint/typecheck clean.
Items 3-5 were marked done or won't-do inline, but the summary section still
listed them as outstanding — left behind when the file was reorganized. The
list is now closed.
Three defects found by a mutation-and-attack sweep over the epic, all in the
revocation path, all letting a removed device stay reachable.

A revoked device could simply reconnect. Revocation killed live sessions but
never invalidated the CREDENTIAL, which stays cryptographically valid for its
full hour — and minting a fresh DPoP proof is trivial for whoever holds the
device key. Over a direct transport there is no relay and no cloud in the
path, so nothing refused it: the removed device opened a new socket, sent
session_authorize with the credential it already had, and was admitted. The
registry now remembers revoked thumbprints for the credential lifetime and
the gateway consults that at admission, so revocation ends access rather than
merely ending a session.

Revocation failed OPEN when the account API was unhealthy. The handler
refreshed the authorization snapshot BEFORE dropping anything, so a 5xx threw
and no session died — exactly when the control plane is unhealthy is when you
least want that. The two kinds that need no cloud answer now kill first:
device_revoked carries the thumbprint in the frame, and host_unlinked drops
everything unconditionally. Discoverability still consults the snapshot, but
a failure there can no longer suppress the kill.

Revocations arriving during connect were dropped on the floor. The relay adds
a host to its delivery map the moment its ticket verifies — readiness is not
a gate — but the supervisor attached its message listener only after awaiting
the connect-time reverify, and ws discards frames nobody is listening for.
This was my own regression from the backoff fix. The listener is now attached
before any await.

Verified: e2e 12/12, relay 59/59, shared 636/636, server remoteSessions and
relayDial and hostAuth 23/23, fmt/lint/typecheck clean.
All found by adversarial testing over the epic, all with a regression test
that fails without the fix.

Two were serious. The local RPC bridge issued a full-privilege client session
and WS token, then attached its close listener three awaits later — so a
remote peer dropping mid-connect was never observed and the session survived,
reachable over loopback for up to an hour. The listener now precedes issuance
and revokes anything already issued. Separately, the Host Secret rotation
journal could wedge permanently on two unsatisfiable conditions (a deleted
host, a concurrent version bump), and because revokeDevice refuses to run
while an unfinished journal exists, one stuck rotation permanently blocked
removing a lost or stolen device. Deleted hosts are now skipped and concurrent
bumps re-read and re-sealed, so a secrets failure can no longer disable
revocation.

Also fixed: the authorization snapshot leaked OTHER tenants' revoked device
thumbprints (the query filtered by revokedAt but not by who could reach that
host); a peer closing during credential verification left a phantom session in
the registry; a device revoked while a grant was in flight still minted a
fresh hour-long credential on the org-member path (the owner path stays
cloud-independent per ADR 0011); overlapping host links could desynchronize
the stored key generation from the key on disk; a transient API failure during
revocation closed the control socket as a protocol error; the host accepted
grants pre-dated an hour ahead and had no absolute expiry bound, where the
relay refused both; the web client always demanded a fresh grant, so a valid
credential could not survive the API outage ADR 0013 promises it can; a client
admitted during relay shutdown was stranded with reads paused; and a malformed
host id raised a Postgres cast 500 instead of a 404.

The cross-tenant fix needed a second pass: its two queries returned different
timestamp shapes and the sort called .getTime() on a raw aggregate, 500ing the
route. That only surfaced when the API suite ran here — the authoring sandbox
could not reach Postgres, which is precisely why its two unexecuted fixes were
re-run rather than trusted.

Verified: api 297/297 against real Postgres, e2e 12/12, relay 60/60,
contracts 244/244, server affected suites 87/87.
A mutation sweep broke the product 60 ways that no test noticed. Each gap is
now closed and each closure was proven the only way that means anything:
apply the exact mutation, watch a named test fail, restore, watch it pass.

The recurring shape was asserting a SYMBOL where the product uses the same
symbol — true by construction, and blind to the value changing underneath.
Close codes, lifetimes, IV and tag sizes, directory modes and the grant
max-age are now pinned as literals, so reintroducing the namespace collision
that already shipped once, or widening a bound, fails a test instead of
passing one.

Frozen wire vectors now cover the seal AAD, the pairing KDF transcript and
the verification code. Nothing else can catch a domain-separator bump, which
would silently make every stored host secret undecryptable on deploy — no
error, no signal, until a user opened one.

Two corrections to the brief, both reported rather than quietly absorbed. The
suggested fix for the relayCore tautology was not achievable: a genuine orphan
pair cannot be constructed, because every route that drops a control also
closes its pairs, so that branch is unreachable defensive code. It is now
covered by what actually distinguishes the paths — the close REASON — with a
comment saying plainly what remains unreachable and why, and it was not
counted toward the 60. And a pre-existing test was comparing a value to
itself: its 'padded' base64url key was built with .slice(0,-1), which
returns the original string byte for byte. It now flips low bits of the final
character and asserts up front that the two spellings differ.

Verified independently before landing: reintroducing the some/every slip in
revocation fan-out fails the relay suite, and bumping the seal AAD domain
fails the crypto suite.

Counts: relay 59->70, web hosts 30->78, shared 645, contracts 244, server
host suites 81, api 297/297 against real Postgres. Product code untouched —
only *.test.ts changed, and no testing seam was added.
@github-actions github-actions Bot added size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Aug 14, 2026
Both CI jobs died 51s and 19s in, at `brand:check`, so nothing downstream had
ever run. Fixing that gate exposed two genuine breakages underneath it.

The guard blocks the retired upstream name in tracked files, and nine ADR and
spec lines cited it by name. They now say "the upstream project Synara was
forked from (see README Origins)". Adding them to `approvedAttributions` was
the wrong tool: that list is a reviewed legal-attribution surface — LICENSE,
README Origins, the 0.7.0 analytics disclosure — not a place for design-doc
citations. The meaning is intact; those ADRs still record that the patterns
were production-proven upstream, which is why we trusted them.

Release Smoke was failing for a real reason: `@synara/relay-protocol` was
missing from the release workspace manifest list while `apps/server` and
`apps/web` both depend on it, so `bun install` in the release root could not
resolve it. That same list is what `build-desktop-artifact` stages, so this
was a broken desktop release, not a broken test. relay-protocol is private
with a fixed version, exactly like `shared` and `profile-ui`, so it belongs in
the manifest list and not in the version-bump list.

The relay load smoke failed under parallel load and passed alone. The cause
was not slowness: instrumenting it showed all 100 clients closing 4404 "splice
timed out" with zero pairs formed. The shared relay pins pendingTimeoutMs to
100ms so the expiry tests in section 3 finish quickly, and that per-splice
budget is unmeetable when 100 splices open at once on a busy runner — the
fixture starving itself, not a product defect. The scenario now gets its own
relay at the production default. Bite-checked both ways: stalling resumeReads
past 50 pairs fails it, and corrupting a single payload fails it.

The Sync-Key paste test was asserting my own mistake. It used `fill()`, which
respects the field's maxLength of 7, so "a-bi0c1d2e3" was truncated to
"a-bi0c1" before the component ever saw it — the assertion was measuring
Playwright. It now performs a real paste, which is the case that matters: a
code copied with lowercase, a separator, and the ambiguous 0/1/I the alphabet
drops. Disabling the alphabet filter now fails it.

Verified under Node 24.17 (CI pins ^24.13.1): brand, fmt, lint, typecheck,
migration lineage, release smoke, desktop build with preload verification,
relay 70/70, desktop 570/575. Four earlier web failures were a local artifact
of Node 25 and reproduce on the clean baseline.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant