feat: hosts as first-class account entities - #46
Draft
aristotl-dylan wants to merge 25 commits into
Draft
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
apps/api): user-owned hosts (ownerUserIdis 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.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.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 — thesynhost_bearer token is gone, host↔API auth is by signature, and revocation is deleting a public key.apps/web): non-extractable ES256 device keys, the transport probe race, enrollment UX, device management.Plus
packages/relay-protocol(shared close codes and frame types) andapps/e2e(integration harness, 10 scenarios).The design is in
docs/adr/0001–0015anddocs/specs/; vocabulary inCONTEXT.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.mdand usedisclosureMotion.tsfor 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 typecheckall 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/e2eharness 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 uploadfails deterministically (2/2) on a cleanfeat/remoteworktree with the composer never mounting inside the shared helper's 20swaitFor. That same step is also red onmain, 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-protocolwas missing from the release workspace manifest list whileapps/serverandapps/webboth depend on it, sobun installin the release root could not resolve it. That list is also whatbuild-desktop-artifactstages, 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
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.