Repository navigation
feat: the bridge's web page, signed in from the phone - #143
Merged
Merged
Conversation
deymosh
force-pushed
the
feat/bridge-web
branch
from
October 16, 2026 19:27
8389c7d to
7110180
Compare
… page The bridge's web page has no password: it shows a short code, and a paired phone sending that code as a signed command (`web-sign-in`) is what lets the browser in. The engine keeps the codes the page is showing (told by the runtime as each is shown), matches one once and only before it lapses, answers the phone with `web-sign-in-result`, and tells the runtime which browser to let in. A code read off a screen is worthless without a paired phone, and a typed one is read in any case with or without its dash. Co-Authored-By: Claude Code <noreply@anthropic.com>
…e phone The direct link's `wss://` listener now also serves a web page: a request that is not a WebSocket upgrade is a page request, the bytes read to tell them apart are replayed to the WebSocket handshake, and the onion listener serves no page. Same port and certificate, so a bridge that has a direct link has the page with no new setting, over TLS on the LAN or a VPN. A browser signs in with the owner's phone: the page shows a code and its QR, and once a paired phone sends it the browser gets a cookie that only the server reads, only HTTPS carries, and no other site sends (`__Host-`, `Secure`, `HttpOnly`, `SameSite=Strict`), for 30 days. Requests must name an IP address, localhost or a direct endpoint's host; a POST must come from the page's own origin; every response forbids framing, sniffing, referrers, caching and any resource not from the page; sign-in starts are rate-limited per address and pending codes and sessions are capped. The first page shows the bridge, its direct link, its agents with their credential status and its sessions — the heartbeat, which never carries a secret. Co-Authored-By: Claude Code <noreply@anthropic.com>
…the bind address In a container the bind address is the container's own, which no browser on the host can reach; the page is on the direct link's listener, so it is wherever the bridge tells phones to dial. Co-Authored-By: Claude Code <noreply@anthropic.com>
… development switch skips sign-in
A browser that types `http://` reached the TLS listener and got TLS bytes
it could not read ("invalid response"). The listener now peeks at the first
byte: a TLS handshake opens with a handshake record, anything else is
answered with a redirect to the same page over HTTPS — and only for a host
the page would serve, so it redirects nowhere else.
`CODEDECK_WEB_NO_SIGN_IN=1` lets a browser on a loopback or private address
in without a phone sign-in, to work on the page before the phone side
exists. It is off unless set, logs a warning at start, and the page shows a
banner while it is on. It is for development and goes before the page
ships.
Co-Authored-By: Claude Code <noreply@anthropic.com>
The wire already carries `web-sign-in` and its result; the phone now has the
core plumbing behind them, in the shape the provider-profile ack already set.
`UniffiIntent::WebSignIn` trims the code — whitespace is not a code, and an
empty one is refused as an intent error rather than published — and the
runtime sends the command on the ordinary signed path, recording `{code,
Sending}` for that machine in the ui store. The bridge's answer moves that
entry to `SignedIn` or `NotMatched` only when it names the code recorded now:
an answer to an older code, from a retyped one or a screen that was reopened,
is not about the attempt on screen, so it is dropped instead of reported. An
unanswered `Sending` stays as it is — the screen showing the code times
itself out, so silence is never turned into a verdict here.
`ClearWebSignIn` forgets the entry when the screen closes, so a late answer
cannot move something nothing is showing any more. `UniffiUiView.web_sign_in`
carries the entry per machine; it is UI state only, never persisted, like the
other ack statuses.
Co-Authored-By: Claude Code <noreply@anthropic.com>
The engine reads a typed code in any case, with or without its dash, but answered with the page's spelling, and the phone matches an answer to the code it sent — so a code typed in lower case signed the browser in while the phone kept waiting. The answer now carries the phone's own spelling. Engine tests cover the single use, the any-case match, and an unknown or lapsed code. Co-Authored-By: Claude Code <noreply@anthropic.com>
A machine's page gets a "Web page" group: the https address of the page on each wss:// direct endpoint (onion and plain ws:// ones serve none), each copyable, and a "Sign in a browser" row. That page scans the QR the web page shows, through the pairing scanner (which now takes the line it prompts with), or takes the typed code, and sends it with WebSignIn; it shows "Signing in", then the bridge's answer, or that none came within 15 s. A scanned code is sent only when the QR names this machine: a code shown by one bridge handed to another would sign in the wrong page. There is no deep link for the QR on purpose, so a link opened elsewhere cannot sign a browser in without the user scanning it on this page. Closing the page clears the machine's sign-in in the core, so a late answer is not kept for a screen that is gone; the clear is dispatched on the core host's own scope, since the composition's scope is already cancelled in onDispose. The page is wired in the shell and in Settings, each going back to the machine's page. The full-page snapshots get a taller canvas: the machine's page outgrew the old one. Co-Authored-By: Claude Code <noreply@anthropic.com>
PROTOCOL.md gets the sign-in messages (the code's format, that it works once for 5 minutes, that the answer echoes the code as the phone sent it, and that a phone sends a scanned code only to the machine its QR names), with their traffic cost. BRIDGE.md says where the page is served, how a browser signs in and for how long, what it shows, and how it is hardened for a network that is not trusted. CLIENT.md describes the phone's "Web page" group and sign-in page. Co-Authored-By: Claude Code <noreply@anthropic.com>
… out The bridge keeps signed-in browsers in memory only, so a restart ends every web session before its 30 days; the phone's confirmation and BRIDGE.md now say so rather than promise the whole 30 days. Co-Authored-By: Claude Code <noreply@anthropic.com>
… of linking a private const Public rustdoc may not link a private item (cargo doc with -D warnings fails on it), so the module doc says what the constant means: 30 days, ended early by a restart since signed-in browsers live in memory only. Co-Authored-By: Claude Code <noreply@anthropic.com>
The engine honoured every code the page had shown until it lapsed, so a code whose browser had signed out, or had been dropped to make room, still answered a phone "signed in" for a browser that no longer existed. Input::WebSignInWithdrawn removes the code, and the engine's cap is public (MAX_WEB_SIGN_IN_CODES) so the page keeps exactly as many pending. Co-Authored-By: Claude Code <noreply@anthropic.com>
- Signed-in browsers are capped at 64 as documented, dropping the longest signed in; before, the only cap counted pending and signed-in browsers together (up to 96) and could drop a browser still showing a live code. - Pending sign-ins are capped at the engine's own count, and every pending browser dropped or signed out withdraws its code from the engine. - The caps drop by an order counter rather than a millisecond clock, which gives two browsers the same age. - Sign-in starts are counted per IPv6 /64 (IPv4-mapped addresses as IPv4): one host owns a whole /64 and could otherwise start without limit. The table of counts is bounded. - The sign-in steps name the phone's row as it is: "Sign in a browser", under "Web page". Co-Authored-By: Claude Code <noreply@anthropic.com>
A code works once. When the first answer was late and the phone sent the code again, the first answer signed the browser in and the second, for the same code, was a refusal that turned the screen to "did not match". A refusal of a code that already signed in now leaves the entry as it is. Co-Authored-By: Claude Code <noreply@anthropic.com>
…ery send waits anew "No answer" was set after 15 s and cleared only by a new send, so a refusal arriving later stayed hidden behind it; any change of the answer now clears it. A resend of the same code changed none of the wait's keys, so it got no wait of its own and could spin forever; the wait is keyed on the attempt. Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
The dashboard is now tabs (Overview, Usage) on the hash, rendered from the status the page already polls, so switching tabs costs no request. Usage shows, per agent, its sessions, what runs or waits now, transcript size and the models in use, then every session's context fill, fullest first. All of it is what the heartbeat already carries: nothing new is asked of the bridge or the agents. Session states are read as the heartbeat names them: a session waiting for a permission or an answer showed its raw state in grey, since the page looked for a "waiting" state that does not exist. Co-Authored-By: Claude Code <noreply@anthropic.com>
…config set Signed-in browsers now survive a restart: the page keeps them in `<home>/web-sessions.json`, owner-only (0600) and written atomically, keyed by the cookie's SHA-256 — never the token, so a copy of the file cannot be replayed as a cookie. Pending sign-ins are not written, a missing or corrupt file starts empty (with a warning), and `lastSeenMs` moves in memory on every authenticated request while reaching the disk at most once a minute. Each browser also keeps the request's User-Agent (control characters stripped, at most 120 characters) so the owner can tell one from another. Three JSON APIs follow, all needing a signed-in browser (401 otherwise) and, for a POST, the page's own Origin as before: - `GET /api/browsers` lists the signed-in browsers and `POST /api/browsers/sign-out` ends one by the first 16 hex characters of its key (404 when unknown). Any signed-in browser may end any other: they are all the owner's. - `GET /api/devices` shows the paired identities and their clients (labels and key expiries). The runtime hands the page the snapshot alongside the heartbeat and on every pairing change, and it carries nothing secret — not even a session key's public half. - `GET /api/config` shows the config as it is in effect and `POST /api/config` changes only the safe set — the machine name, the relays, the direct endpoints, the transcript retention and the relay/Blossom registration tokens. Each value is validated, then written back keeping every other key (including ones this bridge does not know). A secret is shown only as set/not set, its value never reaches a response or a log line, and every field says whether a flag or environment variable overrides the file. The safe set is deliberately small: the other keys name paths, listeners or process settings whose change could break a running bridge, so the page has no business writing them. Co-Authored-By: Claude Code <noreply@anthropic.com>
The bridge runs on one thread, shared by the relays, the engine, the phones' direct links and the web page, so anything the page does there is time every phone's traffic waits: - The browsers file is written by a thread of its own, in order, and a config write runs on the blocking pool with only its own request waiting for it: each write ends in an fsync, which a slow disk or a Docker Desktop volume can hold for tens of milliseconds. - At most 16 page requests are served at once; past that one is answered 503 rather than let it queue work in front of the phones'. - One host may hold at most 8 of the listener's 64 connections (an IPv6 host counted by its /64), so a flood of page requests, or handshakes left hanging until their timeout, cannot take the slots phones need. Co-Authored-By: Claude Code <noreply@anthropic.com>
- Browsers: every signed-in browser (which browser and system, its address, when it signed in and was last seen, which identity let it in), this one marked, each with Sign out. - Devices: the paired phones, with their key, when they paired, how many copies of the app each has, whether one is here now and until when their keys are valid. Read-only: pairing and unpairing stay with the phone and the machine. - Settings: the safe set as a form (machine name, relays, direct link addresses, transcripts kept, and an admin token beside its endpoint, write-only: shown as set or not, typed to replace, ticked to clear). A field a flag or an environment variable overrides is shown disabled, saying so. Everything else is listed read-only under "Set on the machine". Saving says the bridge needs a restart to apply it. The form is built once per visit to the tab, so the page's poll never wipes what is being typed. Co-Authored-By: Claude Code <noreply@anthropic.com>
…he browser out The bridge now keeps signed-in browsers across restarts, so the confirmation promises what is true: 30 days, or until it is signed out (by itself, or from another browser's Browsers tab). Co-Authored-By: Claude Code <noreply@anthropic.com>
…start - A direct endpoint is accepted only when the bridge runs the listener it needs (wss:// the direct listener, ws:// the onion one): load refuses to start otherwise, so a page edit could have stopped the bridge until config.json was mended by hand. As a last guard, the changed file must still read as the bridge's config before it is written. - An admin endpoint shows, and its token is offered, before the token is set: the endpoint was read from the pair that exists only once both are. - A field counts as overridden only when load would take the flag or environment value; an empty or unparsable one leaves the file in effect. - The Usage tab's meters show the context window's size, not the window object. Co-Authored-By: Claude Code <noreply@anthropic.com>
The development switch that let a browser on a private address see the page without signing in is gone, with its banner and its compose entry: the page shows the machine's sessions and agents, and a LAN or VPN is not a trusted place, so a phone's sign-in is the only way in. Co-Authored-By: Claude Code <noreply@anthropic.com>
deymosh
force-pushed
the
feat/bridge-web
branch
from
October 17, 2026 16:02
a55e4d4 to
92afdb0
Compare
…eb page groups Both groups landed on the page from separate branches; the golden now renders them together. Co-Authored-By: Claude Code <noreply@anthropic.com>
…b page
The bridge's web page already lets a signed-in browser read which browsers
are signed in (`GET /api/browsers`) and end one (`POST /api/browsers/sign-out`),
but a paired phone had no way to do either. It now does, so the owner can
manage the page's browsers from the phone without opening the page itself.
Three messages, v12 with no bump (a new variant is additive within a
version):
- `web-browsers-request` (phone to bridge, no fields) asks for the list. The
phone sends it when the screen opens, never on a timer, so the list is
fetched only when someone is looking at it.
- `web-browsers {browsers}` (bridge to phone, to the asking client alone)
answers with one entry per signed-in browser. Each entry carries the page's
short public id — the first 16 hex of the stored key's SHA-256, never the
key and never the session token — the npub that signed it in, the sign-in,
last-seen and expiry times, the address and the cleaned User-Agent.
- `web-browser-sign-out {id}` (phone to bridge) ends one by that id, and is
answered with the same `web-browsers`, the fresh list, so a sign-out is
confirmed by the list it produced.
Corpus fixtures (valid and rejected) and the conformance test's type lists
cover the three, so the wire shape is pinned.
Co-Authored-By: Claude Code <noreply@anthropic.com>
The engine holds no browser list — the page does — so both new commands become effects the runtime answers from its own `Web`: `web-browsers` for a request, and for a sign-out the browser is revoked and the fresh list sent, so the answer is always the state that resulted. Both go to the asking client alone, like the other per-client replies. `Web::browsers()` and `GET /api/browsers` now read one shared helper, so the phone's list and the page's own JSON can never disagree about which browsers are signed in or in what order; only the page's rows carry the `current` mark, which only its own request can know. A bridge with no web page (no direct link) answers an empty list rather than staying silent. `revoke` persists through the page's writer thread, so ending a browser never blocks the bridge's one thread. Co-Authored-By: Claude Code <noreply@anthropic.com>
…e phone The phone can now show the browsers signed in to a machine's web page and end one, the same thing the page's own APIs do. The list lives in the ui store — transient, so it is never persisted or backed up — as `UiState.web_browsers`, keyed by machine. Two intents: `RequestWebBrowsers` sends the bare request when the screen opens (never on a timer, so an unopened screen costs nothing), and `SignOutWebBrowser` drops the row optimistically before sending, so a browser the user ended does not linger while the answer is in flight. The bridge's `web-browsers` replaces the whole list for its machine, which is what confirms a sign-out. The view carries the list as `UiView.web_browsers`, projected through client-ffi as the `UniffiWebBrowser` record (its `id` is the page's short public id, never the session token). Regenerated the Kotlin bindings. Co-Authored-By: Claude Code <noreply@anthropic.com>
PROTOCOL.md gains the three messages in the web page sign-in section, with the request's send-on-open rule and what each field of a browser entry is; BRIDGE.md and CLIENT.md say in one place each that the phone reads and ends the page's browsers through the same list the page's own APIs serve. Co-Authored-By: Claude Code <noreply@anthropic.com>
…ns one out
The machine's "Sign in a browser" page becomes "Browsers": signing a
browser in as before, then the browsers signed in now, each named as the
web page names it ("Firefox on Linux"), with its address, when it was last
seen and, when another identity signed it in, which one. Sign out asks
first, as the page does. The list is asked for when the page opens and
again after a sign-in from it, never polled.
Co-Authored-By: Claude Code <noreply@anthropic.com>
The web page's own instructions, BRIDGE.md and CLIENT.md name the page as the phone now calls it, and CLIENT.md says when the list is asked for. Co-Authored-By: Claude Code <noreply@anthropic.com>
A lapsed browser is only reaped when it next asks for something, so until then the page's list, and now the phone's, showed it signed in. The list leaves it out. Co-Authored-By: Claude Code <noreply@anthropic.com>
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.
The bridge's web page, signed in from a paired phone, and the phone's list of the browsers signed in. Rebased onto the terminal (#142).
What
web-sign-in {code}(phone → bridge) andweb-sign-in-result {code, signedIn};web-browsers-requestandweb-browser-sign-out {id}(phone → bridge) answered withweb-browsers {browsers}to the asking client only (a sign-out is answered with the fresh list). A browser'sidis the page's short public id, never its key or cookie. Corpus fixtures for all.wss://port: the same listener and certificate, with a WebSocket upgrade still going to the direct link. Plainhttp://redirects tohttps://; the onion listener serves no page. Sign-in uses a code plus a QR (codedeck://web-sign-in?machine=<npub>&code=) confirmed from a paired phone. The engine (engine/web.rs) matches each code once, within 5 minutes; the page withdraws a code it stops showing (WebSignInWithdrawn).Hostallowlist; anOrigincheck on every POST; CSP, no-store, nosniff and frame-deny on every response; a__Host-cookie (Secure, HttpOnly, SameSite=Strict, 30 days) whose SHA-256, never the token itself, is whatweb-sessions.json(0600) keeps across restarts; sign-in starts rate-limited per address (IPv6 per /64); caps on pending codes (the engine's own count), on sessions (64) and on request sizes.WebSignIn/ClearWebSignIn/RequestWebBrowsers/SignOutWebBrowserintents and the UI view'swebSignInandwebBrowsers. A machine's page gets a "Web page" group (its https addresses) and a "Browsers" page: scan the QR in the app's own scanner, or type the code; then the browsers signed in now, each signed out after a confirmation. A QR naming another machine is refused, and there is deliberately no deep link. It is wired in the shell and in Settings, with snapshots.Traffic cost
Two small command-class events per sign-in, and nothing while no one signs in. The browser list costs one request and one answer when the phone's Browsers page opens (and after a sign-in from it), one sign-out and one answer per sign-out; never polled. The page polls the bridge over HTTPS: every 2 s while a code is pending, every 5 s on the dashboard. None of that is Nostr traffic.
Breaking
New protocol messages, no migration.
Verified
./codedeck checkgreen;cargo doc --workspace --no-depsand clippy with-D warnings; bindings regenerated with no drift.test verifyPaparazziDebug lintDebug: 203 tests, "No issues found". New snapshots: the Browsers page with its list, while asking, and with none; the machine page with the Terminal and the Web page groups together.CODEDECK_WEB_NO_SIGN_INis removed (bridge, page banner, compose file).🤖 Generated with Claude Code