Skip to content

feat(connections): #199 · list live dapp sessions in the app, revocable per site#223

Merged
hellno merged 4 commits into
mainfrom
hellno/browser-session-revoke
Jul 13, 2026
Merged

feat(connections): #199 · list live dapp sessions in the app, revocable per site#223
hellno merged 4 commits into
mainfrom
hellno/browser-session-revoke

Conversation

@hellno

@hellno hellno commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Closes #199. The ending beat of the browser demo: connect → attribute (#198) → revoke → the site is locked out.

Per the issue's 2026-07-10 scope decision and ADR 0006: revoke is a session-scoped disconnect, not a ban. The revoked site's next request fails 4100 Unauthorized; it may reconnect via eth_requestAccounts for a fresh session. No blocklist, nothing persisted.

The channel decision the issue left to this PR

App ↔ bridge = a minimal POST /admin on the bridge's existing loopback HTTP server. Sessions stay where they live (bridge RAM, DappSessionStore); the daemon, wire contract, MockDaemon, freeze fixtures and capabilities.rs are untouched — the daemon-relay alternative would pay that four-place #31 wire tax for zero alpha benefit, and a daemon-side copy of bridge-RAM sessions could drift. Consequence: the issue's "wire round-trips for the three new variants" test bullet doesn't apply (there are no new wire variants); it collapses into bridge-level admin-dispatch tests instead.

Ops are list / revoke / clear, and every op replies with the post-op live session list, so the app refreshes its whole view in one round-trip.

Why a web page can't call /admin

/admin requires the x-deckard-admin header (any value). That header is deliberately never in access-control-allow-headers: a custom request header forces a CORS preflight, the preflight refuses it, so a malicious page can neither enumerate your connected origins nor disconnect sessions — only a local, preflight-free process (the app) can. A const-level test pins the invariant. This stays inside ADR 0006's "minimal loopback check, no token auth" posture.

App side

  • bridge_admin.rs — hand-rolled blocking loopback HTTP client (no new dependencies; Cargo.toml/Cargo.lock untouched), called only from cx.background_spawn.
  • A 5s whole-session Connections poll mirroring the Surface pending approvals from any surface (background poll + badge) #200 pending poll: epoch-fenced, in-flight-guarded, plus a connections_epoch so a poll reply already in flight when you revoke can't land last and resurrect the just-revoked row ("the row is gone" is the promise — it must not flicker back).
  • Sidebar Connections rows: neutral Globe + origin host + ghost ✕ per site. DESIGN names "favicon + domain + a trust dot"; favicons and the trust model are PRD-05: Per-origin permissions, registry & anti-phishing #48's, so we render the neutral Globe and no fabricated trust dot.
  • The right rail's Connections row goes live (1 connected) — it was hardcoded "none" and would have lied the moment a site connected.
  • ⌘K revoke-site (inserted before revoke-all, which stays the registry's last entry) with a live label: one site → Revoke connected site — <host>; several → Revoke all N connected sites; the action always matches the displayed label (per-site precision at N>1 lives on the row's ✕; revoking more only reduces authority — the fail-safe direction).
  • Lock and Lockdown clear all bridge sessions (best-effort, detached — the key-zeroize remains the real brake).

Tests

Bridge: revoke → 4100 → reconnect-fresh round-trip; admin dispatch (list excludes revoked / unknown-origin revoke is a no-op / clear empties / missing-origin + unknown-op error); the header gate; the CORS const invariant. App: response-parser + origin_host units; registry order; and a new reflective registry↔handler test — every ⌘K id must have a run_palette_command arm (include_str! + exact "<id>" => token).

Out of scope, on purpose: #203 / #218 (the issue suggested folding them only on the daemon path — this PR has zero daemon.rs churn), connect-time approval + per-origin permissions (#48), session persistence.

Definition of done

  1. cargo fmt --all --check — clean
  2. just check — green on default and --features tray
  3. TMPDIR=/tmp cargo test --workspace461 passed, 2 ignored (39 suites)
  4. No new or changed dependencies

Live acceptance run (QA vault + just qa-bridge + anvil)

Connect grants a session; the admin gate refuses a header-less request:

$ curl …/rpc -H 'x-deckard-origin: https://app.example.org' -d '{"method":"eth_requestAccounts",…}'
{"result":["0xf39f…2266"]}
$ curl …/admin -d '{"op":"list"}'                  # no x-deckard-admin header
403

After ⌘K → Revoke connected site (Enter):

$ curl …/rpc … eth_sendTransaction   → {"error":{"code":4100,…}}
$ curl …/rpc … eth_accounts          → {"result":[]}
$ curl …/rpc … eth_requestAccounts   → {"result":["0xf39f…2266"]}   # disconnect, not ban

After ⌘K → STOP (Lockdown): eth_accounts → [] — every session cleared.

Before — no connections

199-01-before-no-connections

Connected — sidebar row + live rail count

199-02-connected-row

⌘K — the live label names exactly what Enter disconnects

199-03-palette-revoke-site

After revoke — the row is gone

199-04-after-revoke

Lockdown — sessions cleared with the key

199-05-lockdown

hellno added 3 commits July 13, 2026 11:59
…le per site

The ending beat of the browser demo: connect → attribute → revoke → the site
is locked out. Revoke is a session-scoped DISCONNECT, not a ban (ADR 0006):
the revoked site's next request fails 4100 Unauthorized and it may reconnect
via eth_requestAccounts for a fresh session.

Bridge: sessions stay in bridge RAM (DappSessionStore gains list/revoke_all;
revoke() wakes from dead code). New POST /admin on the existing loopback
server (list | revoke | clear — every op returns the post-op live list).
Gate: /admin requires the x-deckard-admin header, which is deliberately NEVER
CORS-allowed — a web page can't clear the preflight, so only local processes
(the app) can enumerate or disconnect sessions. No daemon/wire/contract
changes at all — the daemon-relay alternative would pay the #31 wire tax for
zero alpha benefit.

App: hand-rolled blocking loopback client (bridge_admin.rs, no new deps), a
5s whole-session connections poll mirroring the #200 pending poll (epoch-
fenced + in-flight-guarded, plus a connections_epoch so a stale poll reply
can't resurrect a just-revoked row), sidebar Connections rows (Globe + host +
ghost ✕), the rail's Connections count goes live, and ⌘K gains revoke-site
with a live label (1 site → its host; N → "all N") whose action always
matches the label. Lock and Lockdown best-effort-clear all bridge sessions.

Tests: revoke→4100→reconnect-fresh round-trip, admin dispatch semantics, the
admin gate, a const-level CORS invariant, response-parser + origin_host units,
registry order, and a new reflective registry↔handler test (every ⌘K id must
have a run_palette_command arm).

Closes #199
…e session clear

Codex adversarial review (gpt-5.5 xhigh) finding: Lock and Lockdown fired the
daemon key-zeroize and the bridge clear_sites as two unordered detached tasks.
If the bridge clear won the race, a dapp reconnecting via eth_requestAccounts
in the window before the daemon locked would be granted a fresh session that
survives the lock. Sequenced in one background task — daemon authority down
first, then the bridge disconnect — that reconnect now hits a locked daemon
and grants nothing.

Also documents the accepted low-severity edge codex flagged: two OVERLAPPING
per-site revokes whose loopback replies reorder can leave the slower row on
screen until the next 5s poll tick reconciles (every revoke IS applied on the
bridge; only the interim display can lag).
…ocation

cargo-deny-action v2.1.0 (released 2026-07-13 09:32 UTC, between main's last
green run and this PR's first run) removed the use-git-cli positional from
action.yml without updating the entrypoint's positional indexing, so the
log-level value lands in a command slot and every run dies with
`unrecognized subcommand 'warn'` — on both the advisories and supply-chain
jobs, and it would take the nightly audit down too. Pin all three uses to the
last-good v2.0.20; unpin to @v2 once upstream ships a fix.
@hellno
hellno force-pushed the hellno/browser-session-revoke branch from 7f737e7 to 9d27ef6 Compare July 13, 2026 10:01
@hellno

hellno commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

Post-review updates on this branch:

  • Codex adversarial review (gpt-5.5, xhigh) ran against the diff; two findings.
    • Fixed (High): Lock/Lockdown fired the daemon key-zeroize and the bridge clear_sites as two unordered background tasks — a dapp reconnecting in the window after the bridge clear but before the daemon locked would get a fresh session that survives the lock. Now sequenced in one task: daemon authority down first, then the bridge disconnect (1cc6eb1).
    • Accepted (Low, documented): two overlapping per-site revokes whose loopback replies reorder can leave the slower row on screen for ≤5s until the poll reconciles; every revoke is applied on the bridge, only the interim display can lag. The serialization machinery to close a millisecond, self-healing display race isn't worth its complexity.
  • Rebased onto main for the Daily cargo-deny advisory scan is failing #221 deny.toml ignore.
  • CI unblock (9d27ef6): cargo-deny-action v2.1.0 shipped today at 09:32 UTC — between main's last green run and this PR's first run — with a positional-arg shift that kills every invocation (unrecognized subcommand 'warn', both deny jobs, and it would take the nightly audit down too). Pinned all three uses to the last-good v2.0.20; unpin once upstream fixes it.

DoD re-run after all of the above: fmt clean, just check green on both configs, cargo test --workspace 461 passed / 2 ignored.

…est error semantics, loopback guard, shared revoke plan

Findings from the 10-angle xhigh review (3 angles independently confirmed the
first one):

- Lockdown (stop_revoke_all) now cancels the connections poll exactly like
  lock() does: with the bridge clear sequenced AFTER the daemon revoke, a
  still-running tick could refetch the not-yet-cleared sessions, pass the
  post-bump epoch fence, and flash "N connected" onto a locked-down wallet.
- Honest error semantics: only ECONNREFUSED proves no bridge (→ empty list);
  every other failure — connect timeout, read timeout, non-200 — keeps the
  current list instead of falsely blanking live sessions. A failed single-site
  revoke can no longer wipe sibling rows or read as "all disconnected".
- resolve_loopback_addr refuses a non-loopback DECKARD_BRIDGE_ADDR: the
  x-deckard-admin marker and disconnect ops must never leave this machine, and
  a remote endpoint must never feed the Connections trust surface (+ tests).
- apply_disconnect: one shared helper for per-site revoke and revoke-all — the
  epoch fence, the NEW in-flight guard (a poll can no longer start under the
  post-bump epoch and land a pre-revoke snapshot), and the error handling can
  no longer drift between the two paths.
- RevokeTarget: the ⌘K revoke-site label and its Enter action now read ONE
  computed target (Nothing/One/All) instead of hand-maintaining the same 0/1/N
  partition in two files — the label structurally cannot promise a different
  disconnect than Enter performs.

Accepted, documented: DESIGN's favicon+trust-dot stays #48's (never fabricate
a trust signal); admin DTO sharing via deckard-contract deferred (that crate
is the frozen daemon wire, this channel is the alpha's swappable one); the
per-op session-list clone (tiny by design).
@hellno

hellno commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

/code-review (xhigh, 10 finder angles + adversarial verify + gap sweep) — findings addressed in 4efd1d0:

Fixed (5 correctness/security, 2 structure):

  1. Lockdown poll gap (confirmed by 3 independent angles): stop_revoke_all now cancels the connections poll like lock() — a tick firing between the daemon revoke and the bridge clear could refetch not-yet-cleared sessions past the epoch fence and flash "N connected" onto a locked-down wallet.
  2. Honest error semantics: only ECONNREFUSED means "no bridge → empty list"; connect/read timeouts and non-200s now keep the current list instead of blanking live sessions (a transient error is not proof a dapp disconnected).
  3. Revoke-vs-poll fence gap: disconnect ops now hold the in-flight guard, so a poll can't start under the post-bump epoch and land a pre-revoke snapshot.
  4. Loopback guard on DECKARD_BRIDGE_ADDR: the app refuses to send the admin marker + disconnect ops to a non-loopback host, or to trust a remote endpoint's session list (+ tests).
  5. apply_disconnect + RevokeTarget: the two revoke paths share one fence/error implementation, and the ⌘K label + Enter action read one computed target — neither pair can drift.

Accepted, documented: DESIGN's favicon+trust-dot stays #48's (a fabricated trust dot would break never-fabricate-a-trust-signal); admin DTO sharing via deckard-contract deferred (frozen daemon wire vs. this swappable alpha channel); per-op session-list clone (tiny by design).

DoD after fixes: fmt clean · just check green both configs · cargo test --workspace 462 passed, 2 ignored.

@hellno
hellno merged commit a814f82 into main Jul 13, 2026
5 checks passed
@hellno
hellno deleted the hellno/browser-session-revoke branch July 13, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Connections: daemon-held dapp sessions, listed in the app, revocable per site

1 participant