diff --git a/openspec/changes/add-desktop-local-remote-modes/README.md b/openspec/changes/add-desktop-local-remote-modes/README.md index 8d0a546..008226c 100644 --- a/openspec/changes/add-desktop-local-remote-modes/README.md +++ b/openspec/changes/add-desktop-local-remote-modes/README.md @@ -2,4 +2,5 @@ Package Quantum as a deno desktop app with a first-launch Local/Remote mode choice: a single-user no-login local build with an embedded server, and a thin -remote client to an existing Quantum server via OAuth deep-link handoff +remote client that logs into an existing Quantum server via its normal cookie +login inside the webview diff --git a/openspec/changes/add-desktop-local-remote-modes/design.md b/openspec/changes/add-desktop-local-remote-modes/design.md index 4ab17f1..a26210e 100644 --- a/openspec/changes/add-desktop-local-remote-modes/design.md +++ b/openspec/changes/add-desktop-local-remote-modes/design.md @@ -23,10 +23,11 @@ small, well-defined seam. The relevant current state: reads) and a bearer-authentication path in `handle` for API tokens. `deno desktop` (Deno 2.9) compiles this SvelteKit app into a native binary, -running the production server in-process with the UI in an OS webview. -Backend↔UI communication is in-process channels, not socket IPC — so a desktop -build cannot assume its embedded server is reachable on a localhost TCP port. -Deno is already pinned at 2.9.3 in the Dockerfile. +running the production server with the UI in an OS webview. Per its serving docs +the server binds a real `127.0.0.1` TCP port (auto-selected, exposed in +`DENO_SERVE_ADDRESS`), the webview reaches it over ordinary HTTP, and "fetch +requests, WebSockets, and cookies all behave identically in `deno run` and +`deno desktop`." Deno is already pinned at 2.9.3 in the Dockerfile. The user-facing goal: an individual runs Quantum on the desktop with no server, no login, data on their own disk; a server owner runs the same app as a native @@ -40,9 +41,9 @@ two runtimes. - Ship a native desktop build without forking the codebase or the service layer. - Local mode: no server, no login, single user, local data, working bank sync and local agent access. -- Remote mode: native client to an existing server, authenticated by reusing the - server's ATProto OAuth via a system-browser handoff, without the app becoming - an OAuth client. +- Remote mode: native client to an existing server, authenticated by the + server's normal cookie login running inside the webview, without the app + becoming an OAuth client. - Keep every server-only guarantee (login, DID allowlist, OAuth) intact and strictly un-relaxed on the server. @@ -130,64 +131,81 @@ while the desktop window is minimized. If backgrounded timers are suspended, becomes the primary trigger — acceptable, but it shapes the cadence, so it is spiked before the cadence is fixed. -### 4. One MCP handler, remounted on a loopback listener for local mode - -**Decision:** Local mode starts a dedicated loopback `Deno.serve` listener -during `init` and mounts `add-mcp-server`'s transport-only MCP handler on it, so -a local agent reaches the same tools a server exposes at `/mcp`. Remote mode -adds no MCP mount — the user's server already serves it. - -**Why:** `add-mcp-server` deliberately built its handler as a bare -`Request → -Response` with no cookie or view dependency, precisely so it could -mount outside the web router. A loopback listener is necessary because -`deno desktop`'s in-process UI channel means the embedded SvelteKit server's own -port is not a reliable public surface. The listener binds loopback-only and -requires the same bearer API token as the server route, so "local" does not mean -"unauthenticated to any process on the machine." - -**A verification, not an assumption:** whether `deno desktop` release mode -_already_ exposes the server's `/mcp` route on a reachable port is checked -during implementation (task 5). If it does, the loopback mount is redundant and -can be dropped; the design works either way and does not bet on it. +### 4. Local agents reach the main `/mcp` route; publish its URL + +**Decision:** In local mode a local agent authenticates to the same +`add-mcp-server` `/mcp` route the server exposes, over the app's ordinary +`127.0.0.1` HTTP listener, with the same bearer API token. The app publishes the +current MCP URL to a well-known app-data file (`agent.json`) so an agent host +can be pointed at it. + +**Why:** the serving docs settle it — `deno desktop` binds a real `127.0.0.1` +TCP port (auto-selected, exposed in `DENO_SERVE_ADDRESS`) and serves standard +HTTP, so a local process can reach `/mcp` directly. No separate mount is needed +for reachability. `/mcp` stays token-gated even in local mode (design in +`hooks.server.ts`'s `handleLocal`), so "local" never means "open to any process +on the machine." + +**The open wrinkle is URL stability, not reachability.** The port is +auto-selected each launch, so the MCP URL changes between runs. Publishing it to +`agent.json` on every launch covers a host that reads the file, but a host +configured with a static URL would break on the next launch. Two candidate +fixes: (a) a second `Deno.serve` bound to a _fixed_ loopback port — feasible +only if the packaged binary lets a second listener escape the +`DENO_SERVE_ADDRESS` override, which is exactly the remaining spike; or (b) +accept the per-launch URL and lean on `agent.json`. The current code implements +(a) as an opt-in fixed-port listener (`QUANTUM_LOCAL_MCP_PORT`), which works +under `deno run`/dev; whether it also works inside the `deno desktop` binary is +unresolved until the binary is built. ### 4a. Local MCP token bootstrapping **Decision:** In local mode, the "Connect an agent" Settings surface from `add-mcp-server` still mints tokens (the app shell renders for -`did:local:self`), and the desktop app writes the loopback URL and, optionally, -a freshly minted token to a well-known app-data location so a local agent host -can be configured with one step. +`did:local:self`), and the desktop app writes the MCP URL to a well-known +app-data location (`agent.json`) so a local agent host can be configured in one +step. The URL only — never a token. **Why:** Local mode's whole appeal is low friction; making the user hand-copy a port and token into an agent config re-introduces exactly the setup wall the desktop build removes. The token is still minted through the normal scoped, revocable path — this only pre-places it where a local host looks. -### 5. Remote mode: system-browser OAuth handoff, server stays the sole client - -**Decision:** Remote mode registers a custom URL scheme for the app. To -authenticate, the app opens the server's existing ATProto login in the system -browser; on success the server redirects to the app's registered deep link -carrying a **bearer session token**. The app stores that token and presents it -as `Authorization: Bearer` on subsequent requests (reusing `add-mcp-server`'s -bearer path, widened from API tokens to session tokens). The desktop app never -registers as an ATProto OAuth client. - -**Why:** The server already is a fully configured confidential ATProto client -with DPoP-bound tokens; duplicating that in every desktop install would multiply -the OAuth client surface and the secrets to protect. Routing login through the -system browser also means the user authenticates in a real, trusted browser with -their existing session and password manager — not a webview that could be -spoofed. The session token is opaque and bearer-ready by existing design, so the -server side is a small addition, not a redesign. - -**Handoff is the sensitive step** and is constrained accordingly: the deep-link -token is single-use at handoff (the app immediately exchanges or binds it), -delivered only to the app's registered scheme, and scoped to a normal session's -lifetime and revocability. A handoff that is intercepted yields at most a -session the user can log out to kill — the same blast radius as a stolen session -cookie, no worse. +### 5. Remote mode: the webview logs in like a browser — no handoff + +**Decision:** Remote mode points the webview at the user's Quantum origin and +lets the server's existing cookie-based ATProto login run **inside the +webview**, exactly as it does in a browser. No custom URL scheme, no deep link, +no bearer-token handoff, and no new server endpoint. The server is unchanged +from its web behavior, and the desktop app never becomes an ATProto OAuth +client. + +**Why:** `deno desktop` serves over ordinary HTTP on `127.0.0.1` and, per its +docs, "fetch requests, WebSockets, and cookies all behave identically in +`deno run` and `deno desktop`." A webview is therefore just a browser: navigate +it to `https://quantum.example.com`, the server redirects it through the ATProto +login and back to `{APP_URL}/oauth/callback`, the server sets its HTTP-only +session cookie, and every later webview request carries it. The server remains +the sole OAuth client; the webview only follows redirects and holds a cookie. + +This removes an entire subsystem the earlier design carried: a registered URL +scheme, a single-use handoff token, a server handoff endpoint, and the extension +of the bearer path from API tokens to session tokens. All unnecessary — the +`add-mcp-server` bearer path stays scoped to API tokens (agents), and +remote-mode web auth stays cookies (people). + +**Trade-off:** login renders in the app's webview rather than the system +browser. The login page is the _server's own_ page loaded over HTTPS, which then +navigates to the real authorization server over HTTPS — the same trust surface +as any webview OAuth. Some identity providers refuse to authenticate inside +embedded webviews; ATProto/PDS logins are not known to, but if one does, the +fallback is to open that navigation in the system browser. Noted, not built. + +**Earlier assumption corrected:** a secondary source suggested `deno desktop` +used an in-process UI channel with no reachable port, which is what motivated +the handoff. The official serving docs are authoritative: it is standard +localhost HTTP with identical cookie behavior. The handoff was solving a problem +that does not exist. ### 6. Data lives in the OS application-data directory in local mode @@ -214,9 +232,12 @@ not new machinery. and the interval is a focused-only bonus. The parked tray runtime would remove the concern entirely by keeping the process resident. -- **The deep-link handoff carries a live credential across a process boundary** - → single-use at handoff, registered-scheme-only delivery, session-scoped and - revocable; blast radius equals a stolen session cookie. +- **Remote login renders in the app's webview, not the system browser** → the + page loaded is the server's own over HTTPS, which navigates to the real + authorization server over HTTPS — the same trust surface as any webview OAuth. + If a provider refuses embedded-webview logins, the fallback is to open that + navigation in the system browser. No credential crosses a process boundary and + no handoff token exists to intercept. - **`deno desktop` is experimental (2.9)** → the surface used here (SvelteKit auto-detection, webview backend, runtime flags) is the stable core of it; @@ -237,9 +258,9 @@ No data migration. This change is additive at the boot and packaging layers: - `loadConfig` gains `mode`; server mode is the default and behaves exactly as today, so existing deployments are unaffected with no config change. -- No schema change is required by local mode itself. If remote-mode handoff - needs to mark a session's delivery method, that is one nullable column added - additively to `sessions`, NULL for every existing row. +- No schema change at all. Remote mode reuses the server's existing cookie login + unchanged, so there is no handoff, no session-delivery column, and no new + endpoint. - The desktop artifact is a new build output; the container image build is unchanged. @@ -256,7 +277,9 @@ No data migration. This change is additive at the boot and packaging layers: - **Background-timer behavior (spike, task 1).** Does `setInterval` keep firing while the desktop window is minimized under `deno desktop`? Gates the local sync cadence. -- **Does `deno desktop` release mode expose the server port?** (task 5) If yes, - the loopback MCP mount in decision 4 is redundant. +- **Can a second `Deno.serve` bind a fixed loopback port in the packaged + binary?** (decision 4) The main server's port is auto-selected per launch, so + a stable MCP URL for a statically-configured agent host depends on this. If + not, fall back to publishing the per-launch URL via `agent.json`. - **Auto-update and artifact signing.** Out of scope here, but the release story will need it before wide distribution. diff --git a/openspec/changes/add-desktop-local-remote-modes/proposal.md b/openspec/changes/add-desktop-local-remote-modes/proposal.md index 4bf49c6..e6f4b73 100644 --- a/openspec/changes/add-desktop-local-remote-modes/proposal.md +++ b/openspec/changes/add-desktop-local-remote-modes/proposal.md @@ -19,12 +19,13 @@ me into my server and get out of the way." So the desktop app asks once, at first launch, which one you are — and becomes a different runtime accordingly. This change builds both, because shipping only local mode would strand the existing two-person server behind a web browser while everyone else got a native -app, and because both modes share one authentication seam (bearer credentials) -that is cheaper to build once. +app. -This change depends on `add-mcp-server`: local mode remounts that change's MCP -handler on a loopback listener, and remote mode extends that change's -bearer-authentication path from API tokens to OAuth-issued session tokens. +This change depends on `add-mcp-server` only for local mode, which exposes that +change's `/mcp` route and bearer API tokens to a local agent. Remote mode needs +nothing new — `deno desktop` serves ordinary localhost HTTP with +browser-identical cookies, so it is just a webview logging into the server the +normal way. ## What Changes @@ -51,18 +52,18 @@ bearer-authentication path from API tokens to OAuth-issued session tokens. - Sync moves from a fixed-time daily schedule to **sync-on-launch plus an interval while the app runs**, because a desktop app is not always on and the runtime's cron has no catch-up for missed fires. - - The MCP endpoint from `add-mcp-server` is available locally, mounted on a - **loopback listener** so local agents can reach it — the same handler the - server exposes as a route. + - The MCP endpoint from `add-mcp-server` is reachable locally: `deno desktop` + serves the app on a real `127.0.0.1` port, so a local agent hits the same + `/mcp` route (token-gated) directly. The app publishes the current MCP URL + to an `agent.json` file so a host can be pointed at it in one step. - **Remote mode** — a thin native client: - No embedded server, no local database. The webview is pointed at the user's Quantum origin. - - Authentication uses a **system-browser OAuth handoff**: the app opens the - server's existing ATProto login in the real browser, and the server hands a - **bearer session token** back to the app via a registered deep link. The - backend remains the sole ATProto OAuth client; the desktop app never becomes - one. The app presents that token as a bearer credential on subsequent - requests, reusing the bearer path `add-mcp-server` introduced. + - Authentication is simply the **server's normal cookie login, run inside the + webview** — a webview is a browser, and per the `deno desktop` docs cookies + behave identically to any browser. The server performs the whole ATProto + OAuth flow and sets its session cookie; the app registers no URL scheme, + holds no token, and never becomes an OAuth client. No deep-link handoff. - MCP in remote mode is served _by the user's server_, not the app — the app adds no local MCP mount. @@ -77,16 +78,16 @@ open questions), mobile builds, and auto-update. The MCP server itself is - `desktop-app`: Packaging Quantum as a native desktop binary; the first-launch choice between local and remote mode and its once-then-reset persistence; local mode's no-login single-user runtime, app-data database, sync cadence, - and loopback MCP mount; and remote mode's thin-client shell with - system-browser OAuth deep-link handoff. + and local agent access; and remote mode's thin-client shell that logs in via + the server's normal cookie flow inside the webview. ### Modified Capabilities - `auth`: Adds a no-login local runtime authenticated as a synthetic single user - (`did:local:self`), gated to the desktop local build; and a bearer _session_ - token issued to a native remote client via an OAuth deep-link handoff, - building on the bearer path from `add-mcp-server`. Server cookie login is - unchanged. + (`did:local:self`), gated to the desktop local build; and specifies that a + remote native client authenticates via the server's ordinary cookie login run + inside its webview (no handoff, no app-side OAuth). Server cookie login is + otherwise unchanged. - `simplefin-sync`: The sync trigger becomes deployment-dependent — an always-on server keeps the daily schedule; a desktop local build syncs on launch and periodically while running. Sync's fetch, archival, normalization, and @@ -95,11 +96,11 @@ open questions), mobile builds, and auto-update. The MCP server itself is ## Impact **Affected specs**: new `desktop-app`; modified `auth` (local no-login runtime + -remote bearer-session handoff) and `simplefin-sync` (trigger model). +remote cookie login in the webview) and `simplefin-sync` (trigger model). -**Depends on**: `add-mcp-server` — this change remounts its MCP handler (local -loopback) and extends its bearer-authentication hook from API tokens to session -tokens. Sequence `add-mcp-server` first. +**Depends on**: `add-mcp-server` — local mode exposes that change's `/mcp` route +and bearer API tokens to local agents. Remote mode needs nothing new from it +(cookies, not tokens). Sequence `add-mcp-server` first. **Deliberately unaffected**: The container image and server deployment. A hosted server still requires `APP_URL`, `ALLOWED_DIDS`, OAuth keys, cookie login, the @@ -117,28 +118,26 @@ scheduled, and packaged. dropped and a local DB path in the app-data directory is defaulted. - `src/hooks.server.ts` — `init` conditionally skips `initOAuthClient` and the `Deno.cron` registration in local mode, seeds `did:local:self`, and starts the - on-launch-plus-interval sync and the loopback MCP listener. `handle` treats + on-launch-plus-interval sync and publishes the local MCP URL. `handle` treats the synthetic user as authenticated in local mode and never redirects to `/login`. -- `src/routes/login`, `src/routes/oauth/callback` — unaffected on the server; - not reached in local mode. A new deep-link callback path supports the - remote-mode handoff. -- `src/lib/server/services/sessions.ts` — session tokens are already opaque and - bearer-ready; remote-mode handoff issues one for the native client to hold. A - small addition marks a session as delivered by handoff if needed for its - lifetime. +- `src/routes/login`, `src/routes/oauth/callback` — entirely unchanged. Remote + mode runs exactly this cookie login inside the webview; local mode never + reaches it. No new callback or handoff route. +- `src/lib/server/services/sessions.ts` — unchanged. Remote mode reuses the + existing cookie session as-is; there is no handoff and no session-delivery + marker. - New desktop entry/config: a `deno desktop` invocation and launch config; a first-launch UI (mode choice + local display-name prompt); app-config - read/write for the persisted mode; the deep-link registration for remote mode. + read/write for the persisted mode; the remote-mode shell that points the + webview at the chosen server origin. - `deno.json` — a `desktop` task; the build/release scripts learn to produce and version the desktop artifact alongside the image. -**Risk**: Two relaxations that must never leak to a server: no-login auth and -skipped OAuth config. Both are gated on `QUANTUM_MODE=local`, which the -container image never sets and which the config validator treats as mutually -exclusive with server settings — a build that sets `local` and also presents -`ALLOWED_DIDS` is a configuration error, not a silent downgrade. The remote-mode -deep-link handoff is the other sensitive surface: it carries a live session -token across a process boundary, so the token is single-use at handoff, bound to -the requesting app instance, and delivered only to a registered scheme — -detailed in Design. +**Risk**: The relaxations that must never leak to a server are local mode's +no-login auth and skipped OAuth config. Both are gated on `QUANTUM_MODE=local`, +which the container image never sets and which the config validator treats as +mutually exclusive with server settings — a build that sets `local` and also +presents `ALLOWED_DIDS` is a configuration error, not a silent downgrade. Remote +mode adds no new auth surface: it is the existing cookie login rendered in a +webview, so its risk profile equals the web app's. diff --git a/openspec/changes/add-desktop-local-remote-modes/specs/auth/spec.md b/openspec/changes/add-desktop-local-remote-modes/specs/auth/spec.md index ff9f405..05a7525 100644 --- a/openspec/changes/add-desktop-local-remote-modes/specs/auth/spec.md +++ b/openspec/changes/add-desktop-local-remote-modes/specs/auth/spec.md @@ -67,33 +67,32 @@ by the synthetic user without special-casing. - **THEN** startup fails with a configuration error rather than running a server without login -### Requirement: Native client session handoff +### Requirement: Remote client cookie login When the application runs as a desktop build in Remote mode, it SHALL -authenticate against an existing Quantum server via a system-browser OAuth -handoff. The application SHALL open the server's login in the operating system's -browser and SHALL receive, via a registered deep link, an opaque bearer session -token issued by the server. The application SHALL present that token as a bearer -credential on subsequent requests. The server SHALL remain the sole ATProto -OAuth client; the desktop application SHALL NOT register as one. The handed-off -token SHALL be single-use at handoff, delivered only to the application's -registered scheme, and SHALL carry the lifetime and revocability of an ordinary -session. +authenticate against an existing Quantum server using that server's normal +cookie-based login, run inside the app's webview exactly as in a browser. The +application SHALL navigate the webview to the configured server origin and SHALL +NOT itself perform, register for, or hold ATProto OAuth credentials — the server +remains the sole OAuth client, and the app is only a webview that follows the +server's redirects and carries the resulting session cookie. The application +SHALL NOT require a custom URL scheme, a deep link, or a bearer-token handoff. -#### Scenario: Remote client authenticates via the system browser +#### Scenario: Remote client logs in through the webview -- **WHEN** a Remote-mode user initiates login -- **THEN** the server's ATProto login opens in the system browser, and on - success a bearer session token is delivered to the app through its registered - deep link +- **WHEN** a Remote-mode user opens the app pointed at their server and is not + yet authenticated +- **THEN** the server's login runs in the webview, the ATProto flow completes, + and the server sets its session cookie, after which the app is authenticated -#### Scenario: Bearer session token authenticates requests +#### Scenario: Session cookie authenticates subsequent requests -- **WHEN** the Remote-mode app holds a valid handed-off session token -- **THEN** it authenticates to the server by presenting that token as a bearer - credential, without the app acting as an OAuth client +- **WHEN** the Remote-mode webview holds a valid session cookie +- **THEN** every request it makes to the server carries that cookie and is + authenticated, with no bearer token and no app-side OAuth -#### Scenario: Handoff token is single-use +#### Scenario: App is not an OAuth client -- **WHEN** a handed-off token is presented a second time at the handoff step -- **THEN** it is rejected, having already been consumed +- **WHEN** the Remote-mode app authenticates a user +- **THEN** it registers no custom URL scheme and holds no OAuth credential; the + server performs the entire OAuth flow diff --git a/openspec/changes/add-desktop-local-remote-modes/specs/desktop-app/spec.md b/openspec/changes/add-desktop-local-remote-modes/specs/desktop-app/spec.md index fcb0108..f03df87 100644 --- a/openspec/changes/add-desktop-local-remote-modes/specs/desktop-app/spec.md +++ b/openspec/changes/add-desktop-local-remote-modes/specs/desktop-app/spec.md @@ -101,10 +101,11 @@ agent host. A local agent request without a valid token SHALL be rejected. In Remote mode, the application SHALL act as a native client to an existing Quantum server: it SHALL point its webview at the server's origin and SHALL NOT -run an embedded server or a local database. Authentication SHALL use a -system-browser OAuth handoff (specified in the auth capability). The application -SHALL NOT register itself as an ATProto OAuth client, and SHALL NOT expose a -local MCP mount — MCP in Remote mode is served by the user's server. +run an embedded server or a local database. Authentication SHALL use the +server's normal cookie login run inside the webview (specified in the auth +capability). The application SHALL NOT register itself as an ATProto OAuth +client, and SHALL NOT expose a local MCP mount — MCP in Remote mode is served by +the user's server. #### Scenario: Remote mode targets an existing server diff --git a/openspec/changes/add-desktop-local-remote-modes/tasks.md b/openspec/changes/add-desktop-local-remote-modes/tasks.md index 32fedfa..e45f72f 100644 --- a/openspec/changes/add-desktop-local-remote-modes/tasks.md +++ b/openspec/changes/add-desktop-local-remote-modes/tasks.md @@ -5,10 +5,16 @@ whether it keeps firing while minimized/backgrounded. Record the finding — it decides whether "interval while running" is real or effectively "while focused", and thus the local sync cadence (design decision 3). -- [ ] 1.2 **Server port exposure.** Determine whether `deno desktop` release +- [x] 1.2 **Server port exposure.** Determine whether `deno desktop` release mode exposes the embedded SvelteKit server's `/mcp` route on a reachable - loopback port. If yes, the separate loopback MCP mount (task 5) is - redundant; if no, it is required (design decision 4). + loopback port. — Answered by the serving docs: yes. The binary binds a + real `127.0.0.1` port (auto-selected, in `DENO_SERVE_ADDRESS`) and serves + standard HTTP, so `/mcp` is reachable by a local process directly. The + residual question is URL _stability_ across launches (auto-port), tracked + as the new open question in design decision 4 — whether a second + fixed-port `Deno.serve` works in the packaged binary, else publish the + per-launch URL via `agent.json`. Still needs the binary to settle that + sub-question. - [ ] 1.3 **Packaging sanity.** Confirm `deno desktop` (2.9.x) auto-detects this SvelteKit project and produces a runnable Windows binary from the existing build, with the webview backend, before building mode logic on top. @@ -19,8 +25,8 @@ the SvelteKit build. Do not disturb the existing `build`/`start`/`image` tasks. — Added `desktop` (`deno desktop --unstable-cron -A .`) and a `dev:local` convenience task; existing tasks untouched. (Booting the - packaged app end-to-end still needs the app-config-driven mode selection of - tasks 2.2/7.1.) + packaged app end-to-end still needs the app-config-driven mode selection + of tasks 2.2/7.1.) - [ ] 2.2 Add a desktop launch config and the app-config read/write for the persisted mode (a file in the OS app-data directory, outside the SQLite database). Mode is one of `local` | `remote`, absent until first launch. @@ -84,12 +90,15 @@ - [x] 5.2 If task 1.2 shows the server port is not reachable, start a loopback `Deno.serve` listener in local mode and mount `add-mcp-server`'s transport-only MCP handler on it, requiring the same bearer token. If the - port is reachable, skip this and document why. — `startLoopbackMcp` binds - `127.0.0.1:QUANTUM_LOCAL_MCP_PORT` (opt-in via env so the dev/server run - isn't double-served) and reuses `handleMcpRequest`. Verified live: - loopback `/mcp` → 401 without a token, full 15-tool access with one. - Whether it is strictly needed vs. the SvelteKit route is spike 1.2 (needs - the desktop binary); the fallback is proven functional either way. + port is reachable, skip this and document why. — Spike 1.2 now shows the + main port **is** reachable (docs), so the SvelteKit `/mcp` route is the + primary path and this separate listener is not needed for reachability. It + is retained (opt-in via `QUANTUM_LOCAL_MCP_PORT`) only as a candidate + answer to the URL-_stability_ wrinkle (auto-port changes per launch); + whether a second fixed-port `Deno.serve` actually works in the packaged + binary is the remaining open question (design decision 4). + `startLoopbackMcp` reuses `handleMcpRequest`; verified live under dev: + `/mcp` → 401 without a token, 15 tools with. - [x] 5.3 Write the loopback endpoint (and optionally a freshly minted token) to a well-known app-data location so a local agent host can be configured in one step. The token still goes through the normal scoped/revocable mint @@ -105,23 +114,21 @@ closed-period catch-up needs a real SimpleFIN connection, so its data effect is covered by the existing `sync` service tests. -## 6. Remote mode thin client and OAuth handoff - -- [ ] 6.1 Register a custom URL scheme for the desktop app (deep link). -- [ ] 6.2 Remote-mode shell: point the webview at the user-provided server - origin; start no embedded server and open no local database. -- [ ] 6.3 Server side: add a handoff endpoint that, after a normal ATProto login - completed in the system browser, issues an opaque bearer **session** token - and redirects to the app's registered deep link carrying it. Reuse - `sessions.ts`; if a delivery-method marker is needed, add it as one - nullable column (NULL for existing rows). -- [ ] 6.4 Make the handoff token single-use at handoff and delivered only to the - registered scheme. The app stores the token and presents it via - `Authorization: Bearer`, reusing `add-mcp-server`'s bearer path (widened - from API tokens to session tokens). -- [ ] 6.5 Tests: a handed-off token authenticates server requests as a bearer - credential; re-presenting it at the handoff step is rejected; the app - never registers as an OAuth client. +## 6. Remote mode thin client (webview cookie login) + +Simplified after the `deno desktop` serving docs: the app serves/uses ordinary +`127.0.0.1` HTTP with identical cookie behavior, so remote mode is just a +webview pointed at the server, logging in via the server's normal cookie flow. +No URL scheme, no deep link, no handoff endpoint, no bearer-session issuance — +the former tasks 6.1/6.3/6.4/6.5 are dropped. (See design decision 5.) + +- [ ] 6.1 Remote-mode shell: point the webview at the user-provided server + origin; start no embedded server and open no local database. Confirm the + server's cookie login completes inside the webview and the session cookie + persists (needs the desktop binary). +- [ ] 6.2 Server side: nothing. Verify `src/routes/login` and + `src/routes/oauth/callback` are used unchanged — no handoff route, no + session-delivery marker, no schema change. ## 7. First-launch experience @@ -130,8 +137,8 @@ DESIGN.md, persisting the choice to app-config. - [ ] 7.2 Local branch: prompt for a display name and seed the local user with it. -- [ ] 7.3 Remote branch: collect the server address and initiate the OAuth - handoff (task 6). +- [ ] 7.3 Remote branch: collect the server address and point the webview at it + (task 6); the server's cookie login takes over from there. - [ ] 7.4 Add the reset action (Settings) that clears app-config and returns to the mode-selection screen on next launch. Confirm with a plain sentence about what reset does and does not delete (local data stays on disk). @@ -144,11 +151,11 @@ - [ ] 8.2 Local mode end to end: fresh launch → choose Local → set a display name → paste a SimpleFIN token → sync populates → categorize a transaction and confirm the display name in provenance → close and relaunch (starts in - Local, syncs on launch) → connect a local agent over loopback MCP and - categorize via a token, confirming `agent` provenance. -- [ ] 8.3 Remote mode end to end against a dev server: choose Remote → system- - browser login → deep-link handoff → app authenticates as the logged-in - user → no local database created. + Local, syncs on launch) → connect a local agent to the MCP URL from + `agent.json` and categorize via a token, confirming `agent` provenance. +- [ ] 8.3 Remote mode end to end against a dev server: choose Remote → the + server's cookie login runs in the webview → app authenticates as the + logged-in user via cookie → no local database created. - [ ] 8.4 Reset from each mode returns to the selection screen; local data files remain on disk after a reset. - [x] 8.5 Confirm a hosted server rejects `QUANTUM_MODE=local` combined with