From 763124a3e04cc34fd11c45809e720686b8cb1177 Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Thu, 23 Jul 2026 15:37:10 +0000 Subject: [PATCH] archive add-desktop-local-remote-modes change and sync specs --- openspec/changes/add-desktop-local-remote-modes/.openspec.yaml | 6 ------ openspec/changes/add-desktop-local-remote-modes/README.md | 6 ------ openspec/changes/add-desktop-local-remote-modes/design.md | 381 --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- openspec/changes/add-desktop-local-remote-modes/proposal.md | 143 ----------------------------------------------------------------------------------------------------------------------------------------------- openspec/changes/add-desktop-local-remote-modes/tasks.md | 294 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ openspec/specs/auth/spec.md | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------- openspec/specs/desktop-app/spec.md | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ openspec/specs/simplefin-sync/spec.md | 27 ++++++++++++++++++++++----- openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/.openspec.yaml | 6 ++++++ openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/README.md | 6 ++++++ openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/design.md | 381 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/proposal.md | 143 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/tasks.md | 294 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ openspec/changes/add-desktop-local-remote-modes/specs/auth/spec.md | 98 -------------------------------------------------------------------------------------------------- openspec/changes/add-desktop-local-remote-modes/specs/desktop-app/spec.md | 120 ------------------------------------------------------------------------------------------------------------------------ openspec/changes/add-desktop-local-remote-modes/specs/simplefin-sync/spec.md | 42 ------------------------------------------ openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/specs/auth/spec.md | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/specs/desktop-app/spec.md | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/specs/simplefin-sync/spec.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 19 file(s) changed, 1328 insertion(s)(+), 1102 deletion(s)(-) diff --git a/openspec/changes/add-desktop-local-remote-modes/.openspec.yaml b/openspec/changes/add-desktop-local-remote-modes/.openspec.yaml deleted file mode 100644 --- a/openspec/changes/add-desktop-local-remote-modes/.openspec.yaml +++ /dev/null @@ -1,6 +0,0 @@ -schema: spec-driven -created: 2026-07-17 -goal: Let an individual run Quantum on the desktop without self-hosting (local - mode) while letting an existing server's users run the same app as a native - client (remote mode), reusing the MCP handler and bearer path from - add-mcp-server diff --git a/openspec/changes/add-desktop-local-remote-modes/README.md b/openspec/changes/add-desktop-local-remote-modes/README.md deleted file mode 100644 --- a/openspec/changes/add-desktop-local-remote-modes/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# add-desktop-local-remote-modes - -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 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 deleted file mode 100644 --- a/openspec/changes/add-desktop-local-remote-modes/design.md +++ /dev/null @@ -1,381 +0,0 @@ -## Context - -The server core is transport-agnostic (design D6) and already boots from a -small, well-defined seam. The relevant current state: - -- **`loadConfig`** (`config.ts`) hard-requires `APP_URL`, `ALLOWED_DIDS`, and - `OAUTH_PRIVATE_KEY_JWK`, throwing a fail-fast error if any is missing. Its - output drives OAuth client identity and the DID allowlist. -- **`init`** (`hooks.server.ts`) unconditionally calls `initOAuthClient` and - registers a `Deno.cron("daily simplefin sync", "0 11 * * *", …)`, wrapped in a - try/catch that degrades a missing cron to "scheduled sync disabled". -- **`handle`** (`hooks.server.ts:47`) resolves the session cookie to - `event.locals.user` and redirects any unauthenticated request off non-public - paths to `/login`. -- **`upsertUser`** (`users.ts`) takes a DID and handle; nothing in the schema or - services parses DID shape — `users.did` is an opaque TEXT primary key, - `actor_did` joins by equality, and the UI renders the stored handle. -- **`claimSetupToken`** (`connections.ts`) claims a SimpleFIN token and stores - an Access URL. It takes no user, no DID — bank setup is independent of login. -- **Sessions** (`sessions.ts`) are opaque, bearer-ready tokens, per the auth - spec. -- **`add-mcp-server`** introduces a transport-only MCP handler (no cookie/view - 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 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 -client against their existing instance. One binary, one first-launch question, -two runtimes. - -## Goals / Non-Goals - -**Goals:** - -- 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 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. - -**Non-Goals:** - -- A tray/background-resident runtime. It would change sync cadence materially - and carries its own UX decisions (close-to-tray, run-in-background - preference); it is parked for a follow-up (see Open Questions). -- Mobile builds and auto-update. Separate efforts. -- Multi-user local mode. Local is deliberately one person; the two-person - product is the server. -- Re-attributing history when a local user later migrates to a server. Explicit - non-goal (see decision 2). - -## Decisions - -### 1. Mode is a runtime identity chosen once, not a feature flag - -**Decision:** `loadConfig` gains a `mode: 'server' | 'local'`, sourced from -`QUANTUM_MODE` (defaulting to `server`, which the container sets implicitly by -never setting `local`). The desktop first-launch screen writes the chosen mode -to desktop app-config (a file in the app-data directory, outside the SQLite -database). Changing mode is an explicit reset that clears app-config and -re-shows the screen; there is no in-app local↔remote toggle. - -**Why:** Local and remote are not two configurations of one runtime — they -differ in whether an embedded server runs at all, whether there is a database, -and how auth works. A once-then-reset choice models that honestly and keeps the -branching at boot, not scattered through the app. Storing mode outside the -database is required: in remote mode there is no local database to store it in, -and in local mode the mode must be known before the database is opened. - -**Server-safety is a validation invariant, not a convention.** `loadConfig` in -`local` mode drops the OAuth/`APP_URL`/`ALLOWED_DIDS` requirements; in `server` -mode it keeps them. A configuration that sets `QUANTUM_MODE=local` _and_ -supplies `ALLOWED_DIDS`/OAuth keys is a hard error, so the relaxations can never -be half- applied to a server image by accident. - -### 2. Local identity is a synthetic DID, `did:local:self` - -**Decision:** Local mode seeds one user at startup via -`upsertUser(db, -'did:local:self', )` and treats it as the -authenticated user for every request; `handle` never redirects to `/login` in -local mode. The first-launch flow prompts for the display name. - -**Why:** DIDs are opaque everywhere in Quantum, so a synthetic one slots in with -zero schema or service change, keeping the existing invariants — including -`manual events require actorDid` (`categorization.ts`) — satisfied without -special-casing. `did:local:self` is syntactically a valid DID (`did:` + a -lowercase method + an id), greps cleanly, and cannot collide with a real -identity because no `local` DID method resolves. The alternatives are worse: a -bare `"local"` violates the `did:` convention the config validator enforces, and -`did:web:localhost` abuses a method that _is_ resolvable. - -**Prompting for a display name** (rather than defaulting to `"you"` or the OS -username) keeps provenance legible — a badge reading the person's chosen name is -truer than a generic placeholder, and it costs one field on a screen the user is -already looking at. - -**Migration re-attribution is a non-goal.** If a local user later moves to a -server, their history stays attributed to `did:local:self` rather than their -real DID. Rewriting attribution is out of scope; stated so no one expects it. - -### 3. Local mode does not use `Deno.cron` — it triggers sync on launch and on an interval - -**Decision:** In local mode, skip the `Deno.cron` registration entirely. -Instead, run `runSync` once during `init` (catching up whatever was missed while -the app was closed) and then on a `setInterval` while the app runs. The server -keeps its daily `Deno.cron` unchanged. - -**Why:** The runtime's `Deno.cron` keeps its schedule in memory and has **no -catch-up** — a missed fire is skipped, never replayed, and it only fires while -the process is alive. A fixed `"0 11 * * *"` is therefore meaningless for a -part-time desktop process: any day the app isn't open at 11:00 UTC, that day's -sync simply never happens, silently. An event-driven trigger (on launch + while -running) matches how a desktop app actually lives. This is true independent of -whether `Deno.cron` even functions inside a `deno desktop` binary, so the design -sidesteps that question rather than depending on its answer. - -**The open empirical question is background timers, not cron** (see Open -Questions and task 1): whether the Deno event loop keeps firing `setInterval` -while the desktop window is minimized. If backgrounded timers are suspended, -"interval while running" degrades to "interval while focused" and sync-on-launch -becomes the primary trigger — acceptable, but it shapes the cadence, so it is -spiked before the cadence is fixed. - -### 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 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: 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 - -**Decision:** Local mode defaults the SQLite path to the platform app-data -directory (e.g. `%APPDATA%/Quantum/quantum.db` on Windows, the XDG/Application -Support equivalents elsewhere), created on first launch. `DB_PATH` may still -override it. - -**Why:** A user who never chose to self-host should not have to choose a -database location either; the OS convention is the least-surprising home and -survives app updates. The existing `openDatabase` already creates parent -directories and carries a clear permissions error, so this is a path default, -not new machinery. - -## Risks / Trade-offs - -- **A relaxation leaks to a server** (no-login or skipped OAuth on a hosted - instance) → gated on `QUANTUM_MODE=local`, which the container never sets, and - made mutually exclusive with server settings in `loadConfig` so a mixed config - is a hard error rather than a silent downgrade. - -- **Background timers may be suspended when the window is minimized** → spiked - before cadence is fixed (task 1); worst case sync-on-launch carries freshness - and the interval is a focused-only bonus. The parked tray runtime would remove - the concern entirely by keeping the process resident. - -- **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; - packaging specifics are pinned to 2.9.x and validated in task 2 before deeper - work. - -- **Local data has no server backup** → an accepted property of local mode, not - a defect; the honest mitigation is documentation (where the file is, that it - is the user's to back up), not silent cloud sync. - -- **Two runtimes double the surface to test** → mitigated by both sharing the - entire service layer untouched; the divergence is confined to config, `init`, - `handle`, and packaging, which is where the tests concentrate. - -## Migration Plan - -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 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. - -## Resolved During Implementation - -Getting `deno desktop` to actually package and run this SvelteKit app took a -chain of discoveries, each committed as a fix: - -- **Detection needs `svelte.config.js`.** This project keeps its SvelteKit - config inline in `vite.config.ts`, so there was no `svelte.config.js`. - `deno desktop` detects SvelteKit by that file; without it, it treats the app - as generic Vite and tries to embed a nonexistent `dist/`. Added a - `svelte.config.js` stub (SvelteKit ignores it — warns it's ignored — but - `deno desktop` reads it both to detect the framework and to pick the adapter - output). It must stay in sync with `vite.config.ts`'s adapter selection. -- **Only the Deno adapter is consumable.** `@sveltejs/adapter-node`'s `build/` - layout isn't found by `deno desktop` — its generated entry imports - `build/server/*.js` files the adapter doesn't emit, which `--no-check` hid and - which crashed the binary at runtime. The desktop build uses - `@deno/svelte-adapter` (`.deno-deploy/…`) instead, selected by - `QUANTUM_TARGET=desktop` in vite.config.ts. The server/container stays on - adapter-node. -- **`--no-check` is required** for the desktop compile: the Deno adapter's - generated `handler.ts` has a benign type skew against `@sveltejs/kit@2.69.x` - (`RouteParam.matcher`). It hides only that; the fatal adapter-node module - errors above are avoided by not using adapter-node at all. -- **Migrations must be embedded, not read from disk.** The compiled binary has - no `migrations/` directory relative to its CWD, so `readdirSync` failed - wherever it was launched from. `scripts/generate-migrations.ts` bakes the SQL - into `migrations.generated.ts`; `runMigrations()` uses it when no dir is - passed (a drift test guards staleness). This also frees the server container - from shipping the directory. -- **`@deno/experimental-route-config@0.0.5` has a Windows static-serving bug.** - `parseConfig` resolves static destinations to absolute backslash paths, but - `parsePattern`'s regex only matches forward-slash `/:param`, so the - `/_app/immutable/:file*` catch-all is never substituted → every asset request - opens the literal `…\:file*` → os error 123 → crash on first paint. - `scripts/patch-route-config.ts` widens the regex (`\/:` → `[\\/]:`); it's - chained into the desktop build so the fix is bundled, and reported upstream. -- **The default OS-webview backend does not render here.** The window opened and - closed instantly. Isolating the app in a real browser proved the app is - healthy (renders, no errors), so the failure is `deno desktop`'s `webview` - backend (WebView2/laufey) on this machine. `--backend cef` (bundled Chromium, - ~300MB binary vs ~68MB) renders reliably. `desktop` uses CEF by default; - `desktop:webview` keeps the smaller variant for machines where it works. -- **The webview leaves localhost freely — remote mode needs no Deno API.** A - packaged CEF spike confirmed the webview navigates to external https origins - by a page's own `location.href` redirect, and that `Deno.BrowserWindow` - exists (the first construction adopts the startup window) with a working - `navigate()` and cross-origin `executeJs()`. Remote mode is therefore just a - hook-level 303: `handleLocal` redirects every request to the configured - origin and the webview lives there from then on. No window API in the app. -- **The CEF profile is ephemeral: remote mode re-logs-in each launch.** The - spike's Max-Age cookie did not survive a relaunch; no profile directory is - written, the docs expose no cache-path knob, and Chromium switches passed to - the exe are not forwarded. Within a session cookies behave normally, so the - login flow works — it just runs again on each launch until `deno desktop` - exposes a persistent profile (tracked as an open question). Decision 5's - architecture is unaffected; this is a UX cost, stated rather than papered - over. -- **The database is created only when local mode is chosen.** `init` used to - open `quantum.db` unconditionally, which would have left a stray database on - every remote install. Local machinery (database, identity seed, sync timers, - loopback MCP) now arms exactly once via `armLocalMode` — from `init` when - local mode is already persisted, or lazily from the hook the moment the user - picks Local on `/setup`, which also removes the old implicit relaunch - dependency. First launch and remote mode touch no database at all. -- **First-launch is app-config-driven, not baked.** A shipped binary can't have - a mode or display name compiled in. `QUANTUM_DESKTOP=1` makes the app read - `quantum-desktop.json` (beside the database, outside it) at runtime; when - absent, the hook routes to a `/setup` mode-selection screen → local → the - existing `/welcome` name screen → dashboard, all persisted. Remote mode's - option is shown but deferred (see below). - -## Open Questions - -- **Tray/background-resident runtime (parked).** `deno desktop` exposes a - taskbar/tray API. A tray-resident process that stays alive after the window - closes would turn "interval while running" into "interval while resident" — - effectively always-on — and largely dissolve the background-timer question. It - also pulls in its own decisions: does closing the window quit or minimize to - tray, and is there a "run in background" preference? Deferred to a follow-up, - to be revisited once the core two-mode build works. It may supersede the - on-launch-plus-interval sync model in decision 3. -- **Background-timer behavior — RESOLVED (spike, task 1.1).** A Deno-side - `setInterval` in a packaged CEF binary keeps firing at its exact cadence - while the window is OS-minimized — no suspension or throttling observed, in - either the runtime or the page. "Interval while running" is real, so the - 6-hour local sync cadence in decision 3 stands. (The window API also turned - out to have `hide()`/`show()` but no `minimize()` — useful raw material for - the parked tray runtime above.) -- **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`. -- **Bindings invocation is broken in the CEF backend (upstream, unreported).** - Spiked for the Disconnect action (task 7.4): the `bindings` proxy IS injected - into every page including foreign https origins — so "am I inside the - Quantum desktop shell?" detection works from the server's own Settings page — - but calling a bound name fails with "No callback bound for: " from - both page-world and executeJs, on the adopted startup window and a freshly - constructed one alike (deno 2.9.3, laufey cef 0.5.0, Windows). The name - propagates to the page shim after a reload (the pre-bind error differs: - "No binding for ''"), so registration reaches the shim but the - Deno-side handler table misses — likely a laufey CEF bug; their tracker has - no matching issue (the known #11 null-return bindings bug was fixed in - 0.3.2, before our 0.5.0). Plan: local-mode Disconnect ships bindings-free - (the Settings page is served in-process); remote-mode Disconnect renders - behind the `bindings`-presence check and starts working when upstream fixes - invocation. Fallback if that stalls: a fixed-port loopback shell API with a - CORS `/disconnect` (which would also settle the fixed-port open question). -- **CEF profile persistence (upstream).** The webview's cookie jar does not - survive relaunch (see Resolved During Implementation), so remote mode - re-authenticates every launch. Watch `deno desktop` for a persistent-profile - or cache-path option; when one lands, remote-mode sessions become durable - with no change to our code. -- **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 deleted file mode 100644 --- a/openspec/changes/add-desktop-local-remote-modes/proposal.md +++ /dev/null @@ -1,143 +0,0 @@ -## Why - -Quantum is self-hosted software, and self-hosting is a wall. An individual who -wants a Mint-style mirror of their own accounts must stand up a server, a -domain, an ATProto OAuth client, and a SimpleFIN connection before they see a -single number. The couple this product was built for cleared that wall; most -people won't. - -`deno desktop` (Deno 2.9, June 2026) removes it. It compiles a SvelteKit project -into a native, self-contained desktop binary — the UI in an OS webview, the -existing server running in-process — with no Chromium to ship and no daemon to -install. The same build system also makes a _thin_ desktop client viable: a -native shell pointed at an existing Quantum server for the users who already -have one. - -These two audiences want opposite things from the same binary. The individual -wants "just run it, no account, my data on my disk." The server owner wants "log -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. - -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 - -- A **`deno desktop` build** of the existing SvelteKit app, producing a native - binary per platform, alongside the current container image (which is unchanged - and remains the server deployment). -- A **first-launch mode-selection screen**: _Local_ ("just me, on this - computer") or _Remote_ ("I have a Quantum server"). The choice is made once - and persisted in desktop app-config outside the database. Changing it later is - an explicit **reset** action that clears the app-config and re-shows the - screen — the two modes are treated as near-separate installs, not a runtime - toggle. -- **Local mode** — a single-user, no-login runtime: - - The embedded server boots with `QUANTUM_MODE=local`, which **skips** the - `APP_URL` / `ALLOWED_DIDS` / `OAUTH_PRIVATE_KEY_JWK` validation and the - ATProto OAuth client initialization that a server requires. - - There is no login. A synthetic single user, DID `did:local:self`, is seeded - at startup and treated as the authenticated user for every request. The - first-launch flow **prompts for a display name** for this user, used - wherever a handle would appear (provenance badges, the app shell). - - The SQLite database lives in the OS application-data directory. - - Bank data still works exactly as today: the user pastes a SimpleFIN setup - token in Settings. Connection setup was never coupled to login. - - 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 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 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. - -Not in this change: the tray/background-resident runtime (parked — see Design's -open questions), mobile builds, and auto-update. The MCP server itself is -`add-mcp-server`, not this change. - -## Capabilities - -### New Capabilities - -- `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 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 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 - reconciliation behavior are unchanged. - -## Impact - -**Affected specs**: new `desktop-app`; modified `auth` (local no-login runtime + -remote cookie login in the webview) and `simplefin-sync` (trigger model). - -**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 -DID allowlist, and the daily cron — none of that is relaxed for the server; the -local relaxations are gated strictly to `QUANTUM_MODE=local`. The two-person -constraint holds for the server product; local desktop is a distinct single-user -deployment, not a repeal of it. Every service in `src/lib/server/services/` is -untouched — this change is entirely about how the core is booted, authenticated, -scheduled, and packaged. - -**Affected code**: - -- `src/lib/server/config.ts` — `loadConfig` learns a `mode` (`server` | - `local`). In `local`, the OAuth/`APP_URL`/`ALLOWED_DIDS` requirements are - 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 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` — 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 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**: 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/tasks.md b/openspec/changes/add-desktop-local-remote-modes/tasks.md deleted file mode 100644 --- a/openspec/changes/add-desktop-local-remote-modes/tasks.md +++ /dev/null @@ -1,294 +0,0 @@ -## 1. Spikes (de-risk before committing the design) - -- [x] 1.1 **Background timers.** In a minimal `deno desktop` build of the app, - register a `setInterval` that logs, minimize the window, and confirm - 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). — Spiked - in a packaged CEF binary (2026-07-17): a Deno-side 2s interval (the same - layer `startLocalSync` runs in) ticked at a rock-steady 2000–2016ms - through an OS-minimized stretch — no suspension, no throttling, no missed - ticks; the page-side interval wasn't meaningfully throttled either. - **"Interval while running" is real**; the 6-hour cadence in 5.1 stands. - Incidental API findings: `Deno.BrowserWindow` has no `minimize()`, but - does have `hide()`/`show()` — relevant to the parked tray question. -- [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. — 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. -- [x] 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. — - Bundling now works (finding chain, all resolved in code): (1) detection - needs a `svelte.config.js` — without it `deno desktop` sees generic Vite - and tries to embed a nonexistent `dist/`; added a detection stub - (SvelteKit ignores it, uses the inline vite config, but the file makes - `deno desktop` detect SvelteKit). (2) SvelteKit support wants the Deno - adapter's `.deno-deploy/server.ts`, not adapter-node's `build/`; added - `@deno/svelte-adapter`, selected via `QUANTUM_TARGET=desktop` in - vite.config.ts (server/container stays adapter-node). (3) the Deno - adapter's generated `handler.ts` type-skews against `@sveltejs/kit@2.69.2` - (`RouteParam.matcher`), so compile needs `--no-check`. (4) the compiled - binary reads `migrations/` off disk via a relative path that isn't - bundled; fixed with `--include=./migrations`. With those, the binary - **boots**: runtime loads, `getConfig()` succeeds in local mode (env baked - from `.env.desktop`), it binds a real `127.0.0.1:` (confirming - spike 1.2 empirically), and migrations run. **BLOCKED (upstream):** - serving static client assets crashes — - `@deno/experimental-route-config@0.0.5` (via `@deno/svelte-adapter@0.2.1`, - both latest) tries to `open` the literal catch-all pattern - `…/_app/immutable/:file*` instead of the resolved filename; `:`/`*` are - illegal in Windows paths (os error 123). Not our code; no released fix. A - working native window on Windows is blocked until the adapter is fixed - upstream or we bypass it with a custom `Deno.serve` entrypoint (the docs' - escape hatch). Also: `deno desktop .` compiles then exits (run the emitted - `quantum/quantum.exe`); `--hmr` runs the `dev` task, which loads `.env` - (server config) and collides with local mode — neither gives a quick live - window today. - - **RESOLVED** (see design's "Resolved During Implementation"): the static - bug was fixed by patching `@deno/experimental-route-config`'s regex - (`scripts/patch-route-config.ts`, chained into the build); migrations are - embedded via codegen (not `--include`); and the OS-webview backend, which - failed to render on Windows, was replaced with `--backend cef`. The window - now opens, stays, and renders local-mode Quantum end to end. - -## 2. Build and mode plumbing - -- [x] 2.1 Add a `desktop` task to `deno.json` invoking `deno desktop` against - 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.) -- [x] 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. — - `src/lib/server/desktop-config.ts` reads/writes `quantum-desktop.json` - beside the database; `QUANTUM_DESKTOP=1` (baked into the desktop env) - makes `loadConfig` carry a `desktop` flag so the hook reads the app-config - at runtime rather than a baked mode. `dev-desktop` launch config added. -- [x] 2.3 Extend the release/build scripts to produce and version the desktop - artifact alongside the container image, reusing the existing ChronVer - version source. (Coordinate with the `release` skill's flow; do not fork - versioning.) — `scripts/build-desktop.ts` (`deno task desktop:package`) - cross-compiles all three platforms from one host (deno desktop - `--target`; prebuilt denort + CEF per target) into fresh - `dist/build//` dirs and archives - `dist/quantum-desktop--{windows-x86_64.zip, - macos-aarch64.tar.gz, linux-x86_64.AppImage}`, reading the version from - `currentVersion()` (package.json, same source as the image; no fork). - Hard lessons encoded in the script: the macOS artifact must be a - tar.gz written by @std/tar with modes stamped 0755 and the framework - symlinks preserved as symlink entries — a bsdtar zip from NTFS records - every binary 0644 (unlaunchable .app) and the first attempt shipped - exactly that; the bundle's real entrypoint is - `Contents/MacOS/laufey`, which the per-target payload check now - asserts. Every archive is verified to contain no `.quantum/` runtime - state (the artifact is deleted on violation). The `release` skill's - step 9 documents the three artifacts and their caveats: macOS/Linux - builds not yet launched on real hardware, nothing signed, .dmg needs a - Mac. CI stays container-only for now — no artifact host/signing story - yet (already an open question). `dist/` gitignored. - -## 3. Config: server vs. local - -- [x] 3.1 In `src/lib/server/config.ts`, add `mode: 'server' | 'local'` sourced - from `QUANTUM_MODE` (default `server`). In `server` mode, keep the current - required-config validation exactly. In `local` mode, drop the `APP_URL` / - `ALLOWED_DIDS` / `OAUTH_PRIVATE_KEY_JWK` requirements and default `dbPath` - to the OS app-data directory. — `loadLocalConfig` branch; - `defaultLocalDbPath` resolves `%APPDATA%`/`Application Support`/XDG. Also - carries an optional `localDisplayName` from `QUANTUM_LOCAL_DISPLAY_NAME`. -- [x] 3.2 Make the modes mutually exclusive: `QUANTUM_MODE=local` together with - `ALLOWED_DIDS` or OAuth key configuration MUST be a hard configuration - error, so a server can never half-apply the local relaxations. — Both - trigger a hard error in `loadLocalConfig`. -- [x] 3.3 Extend `config.test.ts`: local mode loads with none of the server env - vars; local + server config is rejected; server mode is unchanged. — 4 new - tests (8 total, all green). - -## 4. Local mode boot and no-login auth - -- [x] 4.1 In `src/hooks.server.ts` `init`, branch on mode. In local mode: skip - `initOAuthClient`; seed - `upsertUser(db, 'did:local:self', )`; skip the `Deno.cron` - registration. — Seeds when `localDisplayName` is set (the welcome flow - seeds it otherwise); starts the local sync cadence. -- [x] 4.2 In `handle`, in local mode set `event.locals.user` to the local user - unconditionally and never redirect to `/login`. Server mode is unchanged - (including `add-mcp-server`'s bearer path). — Split into `handleLocal` / - `handleServer`; `/login` and `/welcome` redirect to `/` once set up. - `/mcp` stays token-authenticated even locally. -- [x] 4.3 Read the display name from the first-launch flow (task 7); until set, - block app routes behind the local setup screen rather than a login page. — - `handleLocal` redirects protected routes to `/welcome` when - `did:local:self` is not yet seeded. (The `/welcome` screen itself is task - 7.) -- [x] 4.4 Tests: in local mode every protected route resolves as - `did:local:self` with no cookie; manual categorization records the local - actor and its display name surfaces in provenance; server-mode auth is - untouched. — Verified live against a headless local-mode server (port - 5174): `/` and `/ledger` → 200 no login, `/login` → 303 `/`, `/mcp` → 401 - without a token; the DB seeded `did:local:self` / "Graham". Server-mode - auth unchanged (its full suite still green). Hook logic is verified live - like the server-mode hooks, which are also not unit-tested (module - singletons). - -## 5. Local sync cadence and loopback MCP mount - -- [x] 5.1 In local mode, run `runSync(getDb())` once during `init` (catch-up), - then on a `setInterval` at the configured interval while running. Honor - the task 1.1 finding for the interval and for whether background firing is - relied upon. Keep the server's daily `Deno.cron` path unchanged. — - `startLocalSync`; 6-hour interval, fire-and-log on launch. Spike 1.1 - confirmed the interval fires while minimized, so the 6-hour cadence is - final. -- [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. — 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 - path. — Writes `agent.json` (`{ mcpUrl }`) next to the database on - loopback start. Deliberately writes the URL only, never a token: the user - mints a scoped, revocable token in Settings, so no plaintext credential - sits on disk. -- [x] 5.4 Tests/verification: a local agent reaches MCP over loopback with a - valid token and is rejected without one; sync-on-launch brings data - current after a simulated closed period. — Loopback token gating verified - live (401 without, 15 tools with). Sync-on-launch fires from `init` - (observed attempting `runSync` on the headless local-mode boot); a genuine - 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 (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.) - -- [x] 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). — Implemented as a hook-level gate: - in desktop remote mode `handleLocal` 303s every request (including - `/mcp`) to the configured origin, before anything touches the database. - The embedded server can't be *not started* (`deno desktop` owns it), but - it serves nothing and opens nothing. Database creation is now deferred - until local mode is actually chosen (`armLocalMode`, called lazily), so - first launch and remote mode never create `quantum.db` — verified live - (data dir holds only `quantum-desktop.json`). A packaged-binary spike - proved the CEF webview navigates to external https origins (both a page's - own `location.href` redirect and `Deno.BrowserWindow.navigate()`), so the - 303 hand-off works in the shipped app. **Cookie finding:** the CEF - profile is ephemeral — even Max-Age cookies do not survive a relaunch, - and no `deno desktop` knob or pass-through Chromium switch persists it — - so remote mode means logging in again on each launch until upstream - exposes a persistent profile. In-session cookies behave normally. The - in-webview login E2E against a real server is 8.3. -- [x] 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. — Confirmed: the remote-mode - diff touches only `hooks.server.ts` and `src/routes/setup`; login and - callback routes and the schema are untouched. - -## 7. First-launch experience - -- [x] 7.1 Build the first-launch mode-selection screen: Local ("just me, on this - computer") vs Remote ("I have a Quantum server"), shell-less and calm per - DESIGN.md, persisting the choice to app-config. — `src/routes/setup`; - shell-less, Local functional (writes `{mode:'local'}` and routes on to - `/welcome`), Remote shown but marked "soon" (deferred, see 7.3). Verified - the full flow live and in the CEF window. -- [x] 7.2 Local branch: prompt for a display name and seed the local user with - it. — `src/routes/welcome` (built earlier); reached after the mode screen. -- [x] 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. — Un-deferred: - the navigation spike (see 6.1) resolved the architectural uncertainty, so - the "soon" placeholder is now a working option. Selecting it expands an - inline form; the action normalizes the address (scheme defaults to - https), probes `GET /healthz` for `{ ok: true }` with a 5s timeout so a - typo or non-Quantum host fails with a message instead of stranding the - webview, persists `{ mode: "remote", serverUrl }`, and 303s the webview - to the origin. Verified live: bad-address and non-Quantum-host errors - render inline with the input preserved; a valid address lands the - browser on the remote origin; every subsequent request to the embedded - server redirects there. -- [x] 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). — - Shipped as **Disconnect** (renamed: nothing is deleted), a Settings - section with a two-step inline confirm, effective immediately rather - than on next launch. Two variants: in the desktop build's own pages - (local mode) it is a server action — `disarmLocalMode()` (stops the - 6-hour sync interval and shuts down the loopback MCP listener so a - shell switching to remote never keeps syncing the abandoned ledger), - clears app-config, 303 → `/setup`. On a hosted server's pages viewed - through the desktop shell (remote mode), the section renders when the - webview's injected `bindings` proxy is detected and calls - `bindings.quantumDisconnect()` — registered by `registerDesktopShell` - in the shell, which clears config and navigates the window home; - currently fails gracefully with a manual-fallback message because - laufey cef 0.5.0 never populates the binding callback table (see - design's open question). Local machinery moved from `hooks.server.ts` - to `src/lib/server/desktop-runtime.ts` (arm/disarm; re-armable). - -## 8. Verification - -- [x] 8.1 Run `deno task test` and `deno task check`. Confirm the server build - and container image are unchanged (server-mode tests all green, no config - change required for existing deployments). -- [~] 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 to the MCP URL from `agent.json` and - categorize via a token, confirming `agent` provenance. — The first-launch path - (fresh → /setup → Local → /welcome → dashboard) is verified live and confirmed - rendering in the CEF window by Graham. The SimpleFIN → sync → categorize → - agent-provenance tail still wants a real bank connection exercised in the - packaged app; the pieces are individually tested (sync, MCP, provenance) but - not yet driven as one desktop session. -- [x] 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. — Driven by Graham - against his real hosted instance in the rebuilt packaged binary - (2026-07-17): Remote selected on `/setup`, the server's ATProto cookie - login completed inside the webview, and the app ran as the logged-in - user. No-local-database is verified at the hook level (remote mode never - calls `initDb`; the data dir holds only `quantum-desktop.json`). As - predicted by the profile spike, the session did not survive an app - relaunch (ephemeral CEF profile — see design's open question); login runs - again each launch. -- [~] 8.4 Reset from each mode returns to the selection screen; local data files - remain on disk after a reset. — Local mode verified end to end (headless and - in-browser): fresh → Local → welcome → Settings → Disconnect → setup screen - in-session; `quantum-desktop.json` cleared, database files intact; loopback - MCP provably stopped (connection refused) and re-armed (401, token-gated) - after re-choosing Local, which lands straight on the dashboard with data - intact. Remote-mode in-app disconnect is blocked on the upstream laufey - bindings bug — the button renders and degrades to a manual-fallback message; - re-verify once upstream fixes invocation (or the server is updated and a - packaged shell drives it live). -- [x] 8.5 Confirm a hosted server rejects `QUANTUM_MODE=local` combined with - server config, and that the default (no `QUANTUM_MODE`) behaves exactly as - the current release. — Covered by `config.test.ts`: local + `ALLOWED_DIDS` - or local + OAuth key is rejected; default (no `QUANTUM_MODE`) is server - mode and the existing server-config tests are unchanged. diff --git a/openspec/specs/auth/spec.md b/openspec/specs/auth/spec.md --- a/openspec/specs/auth/spec.md +++ b/openspec/specs/auth/spec.md @@ -10,13 +10,16 @@ ### Requirement: ATProto OAuth login The system SHALL authenticate users via AT Protocol OAuth using handle-based -login. The user enters their handle (or DID); the system resolves it, performs -the OAuth authorization flow (PAR, PKCE, DPoP) against the user's authorization -server, and establishes an application session on success. The system SHALL -request only the `atproto` scope and SHALL NOT make authenticated requests to -the user's PDS after authentication. Resolving and rendering profile pictures -from public ATProto profile data — without using the OAuth session or any -application credential — is permitted. +login when running as a server. The user enters their handle (or DID); the +system resolves it, performs the OAuth authorization flow (PAR, PKCE, DPoP) +against the user's authorization server, and establishes an application session +on success. The system SHALL request only the `atproto` scope and SHALL NOT make +authenticated requests to the user's PDS after authentication. Resolving and +rendering profile pictures from public ATProto profile data — without using the +OAuth session or any application credential — is permitted. When the application +runs as a desktop build in Local mode, it SHALL NOT perform this login flow and +SHALL NOT initialize an ATProto OAuth client; authentication is governed instead +by the local single-user runtime. #### Scenario: Successful login with allowlisted handle @@ -30,6 +33,12 @@ - **WHEN** a user submits a handle that cannot be resolved to a DID - **THEN** the system shows an error on the login page without starting the OAuth flow + +#### Scenario: Local mode performs no login + +- **WHEN** the application runs as a desktop build in Local mode +- **THEN** no login page is presented, no OAuth client is initialized, and + requests are authenticated as the local single user ### Requirement: DID allowlist authorization @@ -157,3 +166,65 @@ - **WHEN** pages containing avatars are rendered - **THEN** all profile-image requests originate from the browser against public endpoints, and the application server performs no profile-data requests + +### Requirement: Local single-user runtime + +When the application runs as a desktop build in Local mode, it SHALL operate as +a single-user system with no login. It SHALL seed one synthetic user with the +DID `did:local:self` and a user-chosen display name, and SHALL treat that user +as the authenticated principal for every request, never redirecting to a login +page. This runtime SHALL be reachable only when the application is explicitly +configured for Local mode; a server deployment SHALL NOT enable it, and +supplying server authentication configuration together with Local mode SHALL be +a configuration error rather than a silent relaxation. All existing per-actor +invariants (such as manual categorization recording an actor) SHALL be satisfied +by the synthetic user without special-casing. + +#### Scenario: Local user authenticated without a session + +- **WHEN** any request is handled in Local mode +- **THEN** it is authenticated as `did:local:self` with the chosen display name, + and no session cookie or login is required + +#### Scenario: Display name drives provenance + +- **WHEN** the local user manually categorizes a transaction +- **THEN** the categorization records the local user as actor and provenance + displays the chosen display name + +#### Scenario: Local relaxation cannot apply to a server + +- **WHEN** a deployment is configured for Local mode but also supplies + `ALLOWED_DIDS` or OAuth signing configuration +- **THEN** startup fails with a configuration error rather than running a server + without login + +### Requirement: Remote client cookie login + +When the application runs as a desktop build in Remote mode, it SHALL +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 logs in through the webview + +- **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: Session cookie authenticates subsequent requests + +- **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: App is not an OAuth client + +- **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/specs/desktop-app/spec.md b/openspec/specs/desktop-app/spec.md new file mode 100644 --- /dev/null +++ b/openspec/specs/desktop-app/spec.md @@ -0,0 +1,136 @@ +# desktop-app Specification + +## Purpose + +Package the existing Quantum web application as a native desktop app with two +modes chosen at first launch: Local mode, a single-user runtime with no login +that stores its database in the operating system's application-data directory +and runs the embedded server and a loopback-only MCP listener in-process; and +Remote mode, a thin webview client onto a self-hosted Quantum server that uses +the server's normal cookie login and serves nothing locally. The mode choice is +persisted outside the database and changed only via an explicit Disconnect +action. The domain service layer is shared, not forked, and the container-based +server deployment remains a supported, unchanged output. + +## Requirements + +### Requirement: Native desktop build + +The system SHALL be buildable as a native desktop application from the existing +web application, producing a self-contained distributable artifact per platform +whose UI runs in an embedded webview and whose server logic runs in-process. +The desktop build SHALL NOT fork or duplicate the domain service layer. The +existing container image and server deployment SHALL remain a supported, +unchanged output. + +#### Scenario: Desktop artifact produced + +- **WHEN** the desktop build is run for a platform +- **THEN** it produces a self-contained distributable artifact for that platform + that runs the application without a separately installed runtime or browser + +#### Scenario: Server deployment unchanged + +- **WHEN** the container image is built and deployed as before +- **THEN** it behaves identically to prior releases, with no dependency on the + desktop build + +### Requirement: First-launch mode selection + +On first launch, the desktop application SHALL require the user to choose +between Local mode ("just me, on this computer") and Remote mode ("I have a +Quantum server"). The choice SHALL be persisted outside the application +database. The application SHALL NOT present an in-app toggle to switch modes; +changing mode SHALL be an explicit Disconnect action that clears the persisted +choice and returns the user to the mode-selection screen immediately, deleting +no data. + +#### Scenario: Mode chosen on first launch + +- **WHEN** the desktop app is launched for the first time +- **THEN** it presents the Local/Remote choice and does not proceed to the app + until one is chosen + +#### Scenario: Choice persisted across launches + +- **WHEN** a user has chosen a mode and relaunches the app +- **THEN** the app starts directly in the chosen mode without asking again + +#### Scenario: Disconnect returns to selection + +- **WHEN** a user performs the Disconnect action +- **THEN** the persisted mode is cleared and the mode-selection screen is + presented, immediately and on every launch until a mode is chosen again, + with local data left on disk + +### Requirement: Local mode runtime + +In Local mode, the application SHALL run its embedded server configured for +local operation: it SHALL NOT require or validate `APP_URL`, `ALLOWED_DIDS`, or +OAuth signing configuration, and SHALL NOT initialize an ATProto OAuth client. +The application database SHALL be stored in the operating system's +application-data directory by default. On first launch in Local mode, the +application SHALL prompt for a display name and SHALL seed a single synthetic +user for it. Connecting a bank via a SimpleFIN setup token SHALL work in Local +mode exactly as on a server. + +#### Scenario: Local mode boots without server configuration + +- **WHEN** the app starts in Local mode with no `APP_URL`, `ALLOWED_DIDS`, or + OAuth key configured +- **THEN** it starts successfully, initializing no OAuth client and requiring no + login + +#### Scenario: Local database in app-data directory + +- **WHEN** Local mode initializes its database for the first time +- **THEN** the SQLite file is created under the operating system's + application-data directory + +#### Scenario: Bank connection in local mode + +- **WHEN** a Local-mode user pastes a valid SimpleFIN setup token in Settings +- **THEN** the connection is claimed and synced exactly as on a server, with no + login required + +### Requirement: Local mode agent access + +In Local mode, the application SHALL expose the MCP server on a loopback-only +listener, using the same MCP handler and requiring the same bearer API token as +the server route. The application MAY write the loopback endpoint and a minted +token to a well-known application-data location to simplify configuring a local +agent host. A local agent request without a valid token SHALL be rejected. + +#### Scenario: Local agent reaches MCP over loopback + +- **WHEN** an agent on the same machine connects to the loopback MCP endpoint + with a valid token +- **THEN** it can list and call the same tools the server route exposes + +#### Scenario: Loopback MCP still requires a token + +- **WHEN** a process on the machine connects to the loopback MCP endpoint + without a valid token +- **THEN** the request is rejected + +### Requirement: Remote mode thin client + +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, SHALL NOT +serve application content locally, and SHALL NOT open 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 + +- **WHEN** a user selects Remote mode and provides their Quantum server address +- **THEN** the app presents that server's interface and stores no ledger data + locally + +#### Scenario: Remote mode serves nothing locally + +- **WHEN** the app is running in Remote mode +- **THEN** every local request is handed to the server's origin, no local + database has been opened, and no local MCP listener is mounted diff --git a/openspec/specs/simplefin-sync/spec.md b/openspec/specs/simplefin-sync/spec.md --- a/openspec/specs/simplefin-sync/spec.md +++ b/openspec/specs/simplefin-sync/spec.md @@ -31,20 +31,37 @@ ### Requirement: Scheduled and manual sync -The system SHALL sync each connection once daily via a scheduled job and SHALL -provide a manual "sync now" action in the UI. A sync fetches +The system SHALL sync each connection automatically on a schedule appropriate to +how it is deployed, and SHALL provide a manual "sync now" action in the UI. A +server deployment SHALL sync each connection once daily via a scheduled job. A +desktop build in Local mode, which is not continuously running, SHALL instead +sync on application launch — catching up data missed while the app was closed — +and periodically while the application runs, rather than relying on a fixed-time +schedule that has no catch-up for missed fires. In all cases a sync fetches `GET {access_url}/accounts` including pending transactions and a start date that safely overlaps previously fetched data. -#### Scenario: Daily scheduled sync +#### Scenario: Daily scheduled sync on a server -- **WHEN** the daily schedule fires +- **WHEN** the daily schedule fires on a server deployment - **THEN** the system performs a sync for every connection and records the outcome +#### Scenario: Sync on launch in local mode + +- **WHEN** a desktop Local-mode application starts +- **THEN** it performs a sync for every connection, bringing data current after + any period the app was closed + +#### Scenario: Periodic sync while running in local mode + +- **WHEN** a desktop Local-mode application has been running for the configured + interval +- **THEN** it performs another sync for every connection + #### Scenario: Manual sync -- **WHEN** an authenticated user triggers "sync now" +- **WHEN** a user triggers "sync now" - **THEN** a sync runs immediately and the UI reflects the result #### Scenario: Sync failure diff --git a/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/.openspec.yaml b/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/.openspec.yaml new file mode 100644 --- /dev/null +++ b/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/.openspec.yaml @@ -0,0 +1,6 @@ +schema: spec-driven +created: 2026-07-17 +goal: Let an individual run Quantum on the desktop without self-hosting (local + mode) while letting an existing server's users run the same app as a native + client (remote mode), reusing the MCP handler and bearer path from + add-mcp-server diff --git a/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/README.md b/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/README.md new file mode 100644 --- /dev/null +++ b/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/README.md @@ -0,0 +1,6 @@ +# add-desktop-local-remote-modes + +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 that logs into an existing Quantum server via its normal cookie +login inside the webview diff --git a/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/design.md b/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/design.md new file mode 100644 --- /dev/null +++ b/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/design.md @@ -0,0 +1,381 @@ +## Context + +The server core is transport-agnostic (design D6) and already boots from a +small, well-defined seam. The relevant current state: + +- **`loadConfig`** (`config.ts`) hard-requires `APP_URL`, `ALLOWED_DIDS`, and + `OAUTH_PRIVATE_KEY_JWK`, throwing a fail-fast error if any is missing. Its + output drives OAuth client identity and the DID allowlist. +- **`init`** (`hooks.server.ts`) unconditionally calls `initOAuthClient` and + registers a `Deno.cron("daily simplefin sync", "0 11 * * *", …)`, wrapped in a + try/catch that degrades a missing cron to "scheduled sync disabled". +- **`handle`** (`hooks.server.ts:47`) resolves the session cookie to + `event.locals.user` and redirects any unauthenticated request off non-public + paths to `/login`. +- **`upsertUser`** (`users.ts`) takes a DID and handle; nothing in the schema or + services parses DID shape — `users.did` is an opaque TEXT primary key, + `actor_did` joins by equality, and the UI renders the stored handle. +- **`claimSetupToken`** (`connections.ts`) claims a SimpleFIN token and stores + an Access URL. It takes no user, no DID — bank setup is independent of login. +- **Sessions** (`sessions.ts`) are opaque, bearer-ready tokens, per the auth + spec. +- **`add-mcp-server`** introduces a transport-only MCP handler (no cookie/view + 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 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 +client against their existing instance. One binary, one first-launch question, +two runtimes. + +## Goals / Non-Goals + +**Goals:** + +- 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 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. + +**Non-Goals:** + +- A tray/background-resident runtime. It would change sync cadence materially + and carries its own UX decisions (close-to-tray, run-in-background + preference); it is parked for a follow-up (see Open Questions). +- Mobile builds and auto-update. Separate efforts. +- Multi-user local mode. Local is deliberately one person; the two-person + product is the server. +- Re-attributing history when a local user later migrates to a server. Explicit + non-goal (see decision 2). + +## Decisions + +### 1. Mode is a runtime identity chosen once, not a feature flag + +**Decision:** `loadConfig` gains a `mode: 'server' | 'local'`, sourced from +`QUANTUM_MODE` (defaulting to `server`, which the container sets implicitly by +never setting `local`). The desktop first-launch screen writes the chosen mode +to desktop app-config (a file in the app-data directory, outside the SQLite +database). Changing mode is an explicit reset that clears app-config and +re-shows the screen; there is no in-app local↔remote toggle. + +**Why:** Local and remote are not two configurations of one runtime — they +differ in whether an embedded server runs at all, whether there is a database, +and how auth works. A once-then-reset choice models that honestly and keeps the +branching at boot, not scattered through the app. Storing mode outside the +database is required: in remote mode there is no local database to store it in, +and in local mode the mode must be known before the database is opened. + +**Server-safety is a validation invariant, not a convention.** `loadConfig` in +`local` mode drops the OAuth/`APP_URL`/`ALLOWED_DIDS` requirements; in `server` +mode it keeps them. A configuration that sets `QUANTUM_MODE=local` _and_ +supplies `ALLOWED_DIDS`/OAuth keys is a hard error, so the relaxations can never +be half- applied to a server image by accident. + +### 2. Local identity is a synthetic DID, `did:local:self` + +**Decision:** Local mode seeds one user at startup via +`upsertUser(db, +'did:local:self', )` and treats it as the +authenticated user for every request; `handle` never redirects to `/login` in +local mode. The first-launch flow prompts for the display name. + +**Why:** DIDs are opaque everywhere in Quantum, so a synthetic one slots in with +zero schema or service change, keeping the existing invariants — including +`manual events require actorDid` (`categorization.ts`) — satisfied without +special-casing. `did:local:self` is syntactically a valid DID (`did:` + a +lowercase method + an id), greps cleanly, and cannot collide with a real +identity because no `local` DID method resolves. The alternatives are worse: a +bare `"local"` violates the `did:` convention the config validator enforces, and +`did:web:localhost` abuses a method that _is_ resolvable. + +**Prompting for a display name** (rather than defaulting to `"you"` or the OS +username) keeps provenance legible — a badge reading the person's chosen name is +truer than a generic placeholder, and it costs one field on a screen the user is +already looking at. + +**Migration re-attribution is a non-goal.** If a local user later moves to a +server, their history stays attributed to `did:local:self` rather than their +real DID. Rewriting attribution is out of scope; stated so no one expects it. + +### 3. Local mode does not use `Deno.cron` — it triggers sync on launch and on an interval + +**Decision:** In local mode, skip the `Deno.cron` registration entirely. +Instead, run `runSync` once during `init` (catching up whatever was missed while +the app was closed) and then on a `setInterval` while the app runs. The server +keeps its daily `Deno.cron` unchanged. + +**Why:** The runtime's `Deno.cron` keeps its schedule in memory and has **no +catch-up** — a missed fire is skipped, never replayed, and it only fires while +the process is alive. A fixed `"0 11 * * *"` is therefore meaningless for a +part-time desktop process: any day the app isn't open at 11:00 UTC, that day's +sync simply never happens, silently. An event-driven trigger (on launch + while +running) matches how a desktop app actually lives. This is true independent of +whether `Deno.cron` even functions inside a `deno desktop` binary, so the design +sidesteps that question rather than depending on its answer. + +**The open empirical question is background timers, not cron** (see Open +Questions and task 1): whether the Deno event loop keeps firing `setInterval` +while the desktop window is minimized. If backgrounded timers are suspended, +"interval while running" degrades to "interval while focused" and sync-on-launch +becomes the primary trigger — acceptable, but it shapes the cadence, so it is +spiked before the cadence is fixed. + +### 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 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: 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 + +**Decision:** Local mode defaults the SQLite path to the platform app-data +directory (e.g. `%APPDATA%/Quantum/quantum.db` on Windows, the XDG/Application +Support equivalents elsewhere), created on first launch. `DB_PATH` may still +override it. + +**Why:** A user who never chose to self-host should not have to choose a +database location either; the OS convention is the least-surprising home and +survives app updates. The existing `openDatabase` already creates parent +directories and carries a clear permissions error, so this is a path default, +not new machinery. + +## Risks / Trade-offs + +- **A relaxation leaks to a server** (no-login or skipped OAuth on a hosted + instance) → gated on `QUANTUM_MODE=local`, which the container never sets, and + made mutually exclusive with server settings in `loadConfig` so a mixed config + is a hard error rather than a silent downgrade. + +- **Background timers may be suspended when the window is minimized** → spiked + before cadence is fixed (task 1); worst case sync-on-launch carries freshness + and the interval is a focused-only bonus. The parked tray runtime would remove + the concern entirely by keeping the process resident. + +- **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; + packaging specifics are pinned to 2.9.x and validated in task 2 before deeper + work. + +- **Local data has no server backup** → an accepted property of local mode, not + a defect; the honest mitigation is documentation (where the file is, that it + is the user's to back up), not silent cloud sync. + +- **Two runtimes double the surface to test** → mitigated by both sharing the + entire service layer untouched; the divergence is confined to config, `init`, + `handle`, and packaging, which is where the tests concentrate. + +## Migration Plan + +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 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. + +## Resolved During Implementation + +Getting `deno desktop` to actually package and run this SvelteKit app took a +chain of discoveries, each committed as a fix: + +- **Detection needs `svelte.config.js`.** This project keeps its SvelteKit + config inline in `vite.config.ts`, so there was no `svelte.config.js`. + `deno desktop` detects SvelteKit by that file; without it, it treats the app + as generic Vite and tries to embed a nonexistent `dist/`. Added a + `svelte.config.js` stub (SvelteKit ignores it — warns it's ignored — but + `deno desktop` reads it both to detect the framework and to pick the adapter + output). It must stay in sync with `vite.config.ts`'s adapter selection. +- **Only the Deno adapter is consumable.** `@sveltejs/adapter-node`'s `build/` + layout isn't found by `deno desktop` — its generated entry imports + `build/server/*.js` files the adapter doesn't emit, which `--no-check` hid and + which crashed the binary at runtime. The desktop build uses + `@deno/svelte-adapter` (`.deno-deploy/…`) instead, selected by + `QUANTUM_TARGET=desktop` in vite.config.ts. The server/container stays on + adapter-node. +- **`--no-check` is required** for the desktop compile: the Deno adapter's + generated `handler.ts` has a benign type skew against `@sveltejs/kit@2.69.x` + (`RouteParam.matcher`). It hides only that; the fatal adapter-node module + errors above are avoided by not using adapter-node at all. +- **Migrations must be embedded, not read from disk.** The compiled binary has + no `migrations/` directory relative to its CWD, so `readdirSync` failed + wherever it was launched from. `scripts/generate-migrations.ts` bakes the SQL + into `migrations.generated.ts`; `runMigrations()` uses it when no dir is + passed (a drift test guards staleness). This also frees the server container + from shipping the directory. +- **`@deno/experimental-route-config@0.0.5` has a Windows static-serving bug.** + `parseConfig` resolves static destinations to absolute backslash paths, but + `parsePattern`'s regex only matches forward-slash `/:param`, so the + `/_app/immutable/:file*` catch-all is never substituted → every asset request + opens the literal `…\:file*` → os error 123 → crash on first paint. + `scripts/patch-route-config.ts` widens the regex (`\/:` → `[\\/]:`); it's + chained into the desktop build so the fix is bundled, and reported upstream. +- **The default OS-webview backend does not render here.** The window opened and + closed instantly. Isolating the app in a real browser proved the app is + healthy (renders, no errors), so the failure is `deno desktop`'s `webview` + backend (WebView2/laufey) on this machine. `--backend cef` (bundled Chromium, + ~300MB binary vs ~68MB) renders reliably. `desktop` uses CEF by default; + `desktop:webview` keeps the smaller variant for machines where it works. +- **The webview leaves localhost freely — remote mode needs no Deno API.** A + packaged CEF spike confirmed the webview navigates to external https origins + by a page's own `location.href` redirect, and that `Deno.BrowserWindow` + exists (the first construction adopts the startup window) with a working + `navigate()` and cross-origin `executeJs()`. Remote mode is therefore just a + hook-level 303: `handleLocal` redirects every request to the configured + origin and the webview lives there from then on. No window API in the app. +- **The CEF profile is ephemeral: remote mode re-logs-in each launch.** The + spike's Max-Age cookie did not survive a relaunch; no profile directory is + written, the docs expose no cache-path knob, and Chromium switches passed to + the exe are not forwarded. Within a session cookies behave normally, so the + login flow works — it just runs again on each launch until `deno desktop` + exposes a persistent profile (tracked as an open question). Decision 5's + architecture is unaffected; this is a UX cost, stated rather than papered + over. +- **The database is created only when local mode is chosen.** `init` used to + open `quantum.db` unconditionally, which would have left a stray database on + every remote install. Local machinery (database, identity seed, sync timers, + loopback MCP) now arms exactly once via `armLocalMode` — from `init` when + local mode is already persisted, or lazily from the hook the moment the user + picks Local on `/setup`, which also removes the old implicit relaunch + dependency. First launch and remote mode touch no database at all. +- **First-launch is app-config-driven, not baked.** A shipped binary can't have + a mode or display name compiled in. `QUANTUM_DESKTOP=1` makes the app read + `quantum-desktop.json` (beside the database, outside it) at runtime; when + absent, the hook routes to a `/setup` mode-selection screen → local → the + existing `/welcome` name screen → dashboard, all persisted. Remote mode's + option is shown but deferred (see below). + +## Open Questions + +- **Tray/background-resident runtime (parked).** `deno desktop` exposes a + taskbar/tray API. A tray-resident process that stays alive after the window + closes would turn "interval while running" into "interval while resident" — + effectively always-on — and largely dissolve the background-timer question. It + also pulls in its own decisions: does closing the window quit or minimize to + tray, and is there a "run in background" preference? Deferred to a follow-up, + to be revisited once the core two-mode build works. It may supersede the + on-launch-plus-interval sync model in decision 3. +- **Background-timer behavior — RESOLVED (spike, task 1.1).** A Deno-side + `setInterval` in a packaged CEF binary keeps firing at its exact cadence + while the window is OS-minimized — no suspension or throttling observed, in + either the runtime or the page. "Interval while running" is real, so the + 6-hour local sync cadence in decision 3 stands. (The window API also turned + out to have `hide()`/`show()` but no `minimize()` — useful raw material for + the parked tray runtime above.) +- **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`. +- **Bindings invocation is broken in the CEF backend (upstream, unreported).** + Spiked for the Disconnect action (task 7.4): the `bindings` proxy IS injected + into every page including foreign https origins — so "am I inside the + Quantum desktop shell?" detection works from the server's own Settings page — + but calling a bound name fails with "No callback bound for: " from + both page-world and executeJs, on the adopted startup window and a freshly + constructed one alike (deno 2.9.3, laufey cef 0.5.0, Windows). The name + propagates to the page shim after a reload (the pre-bind error differs: + "No binding for ''"), so registration reaches the shim but the + Deno-side handler table misses — likely a laufey CEF bug; their tracker has + no matching issue (the known #11 null-return bindings bug was fixed in + 0.3.2, before our 0.5.0). Plan: local-mode Disconnect ships bindings-free + (the Settings page is served in-process); remote-mode Disconnect renders + behind the `bindings`-presence check and starts working when upstream fixes + invocation. Fallback if that stalls: a fixed-port loopback shell API with a + CORS `/disconnect` (which would also settle the fixed-port open question). +- **CEF profile persistence (upstream).** The webview's cookie jar does not + survive relaunch (see Resolved During Implementation), so remote mode + re-authenticates every launch. Watch `deno desktop` for a persistent-profile + or cache-path option; when one lands, remote-mode sessions become durable + with no change to our code. +- **Auto-update and artifact signing.** Out of scope here, but the release story + will need it before wide distribution. diff --git a/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/proposal.md b/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/proposal.md new file mode 100644 --- /dev/null +++ b/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/proposal.md @@ -0,0 +1,143 @@ +## Why + +Quantum is self-hosted software, and self-hosting is a wall. An individual who +wants a Mint-style mirror of their own accounts must stand up a server, a +domain, an ATProto OAuth client, and a SimpleFIN connection before they see a +single number. The couple this product was built for cleared that wall; most +people won't. + +`deno desktop` (Deno 2.9, June 2026) removes it. It compiles a SvelteKit project +into a native, self-contained desktop binary — the UI in an OS webview, the +existing server running in-process — with no Chromium to ship and no daemon to +install. The same build system also makes a _thin_ desktop client viable: a +native shell pointed at an existing Quantum server for the users who already +have one. + +These two audiences want opposite things from the same binary. The individual +wants "just run it, no account, my data on my disk." The server owner wants "log +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. + +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 + +- A **`deno desktop` build** of the existing SvelteKit app, producing a native + binary per platform, alongside the current container image (which is unchanged + and remains the server deployment). +- A **first-launch mode-selection screen**: _Local_ ("just me, on this + computer") or _Remote_ ("I have a Quantum server"). The choice is made once + and persisted in desktop app-config outside the database. Changing it later is + an explicit **reset** action that clears the app-config and re-shows the + screen — the two modes are treated as near-separate installs, not a runtime + toggle. +- **Local mode** — a single-user, no-login runtime: + - The embedded server boots with `QUANTUM_MODE=local`, which **skips** the + `APP_URL` / `ALLOWED_DIDS` / `OAUTH_PRIVATE_KEY_JWK` validation and the + ATProto OAuth client initialization that a server requires. + - There is no login. A synthetic single user, DID `did:local:self`, is seeded + at startup and treated as the authenticated user for every request. The + first-launch flow **prompts for a display name** for this user, used + wherever a handle would appear (provenance badges, the app shell). + - The SQLite database lives in the OS application-data directory. + - Bank data still works exactly as today: the user pastes a SimpleFIN setup + token in Settings. Connection setup was never coupled to login. + - 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 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 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. + +Not in this change: the tray/background-resident runtime (parked — see Design's +open questions), mobile builds, and auto-update. The MCP server itself is +`add-mcp-server`, not this change. + +## Capabilities + +### New Capabilities + +- `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 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 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 + reconciliation behavior are unchanged. + +## Impact + +**Affected specs**: new `desktop-app`; modified `auth` (local no-login runtime + +remote cookie login in the webview) and `simplefin-sync` (trigger model). + +**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 +DID allowlist, and the daily cron — none of that is relaxed for the server; the +local relaxations are gated strictly to `QUANTUM_MODE=local`. The two-person +constraint holds for the server product; local desktop is a distinct single-user +deployment, not a repeal of it. Every service in `src/lib/server/services/` is +untouched — this change is entirely about how the core is booted, authenticated, +scheduled, and packaged. + +**Affected code**: + +- `src/lib/server/config.ts` — `loadConfig` learns a `mode` (`server` | + `local`). In `local`, the OAuth/`APP_URL`/`ALLOWED_DIDS` requirements are + 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 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` — 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 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**: 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/archive/2026-07-23-add-desktop-local-remote-modes/tasks.md b/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/tasks.md new file mode 100644 --- /dev/null +++ b/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/tasks.md @@ -0,0 +1,294 @@ +## 1. Spikes (de-risk before committing the design) + +- [x] 1.1 **Background timers.** In a minimal `deno desktop` build of the app, + register a `setInterval` that logs, minimize the window, and confirm + 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). — Spiked + in a packaged CEF binary (2026-07-17): a Deno-side 2s interval (the same + layer `startLocalSync` runs in) ticked at a rock-steady 2000–2016ms + through an OS-minimized stretch — no suspension, no throttling, no missed + ticks; the page-side interval wasn't meaningfully throttled either. + **"Interval while running" is real**; the 6-hour cadence in 5.1 stands. + Incidental API findings: `Deno.BrowserWindow` has no `minimize()`, but + does have `hide()`/`show()` — relevant to the parked tray question. +- [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. — 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. +- [x] 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. — + Bundling now works (finding chain, all resolved in code): (1) detection + needs a `svelte.config.js` — without it `deno desktop` sees generic Vite + and tries to embed a nonexistent `dist/`; added a detection stub + (SvelteKit ignores it, uses the inline vite config, but the file makes + `deno desktop` detect SvelteKit). (2) SvelteKit support wants the Deno + adapter's `.deno-deploy/server.ts`, not adapter-node's `build/`; added + `@deno/svelte-adapter`, selected via `QUANTUM_TARGET=desktop` in + vite.config.ts (server/container stays adapter-node). (3) the Deno + adapter's generated `handler.ts` type-skews against `@sveltejs/kit@2.69.2` + (`RouteParam.matcher`), so compile needs `--no-check`. (4) the compiled + binary reads `migrations/` off disk via a relative path that isn't + bundled; fixed with `--include=./migrations`. With those, the binary + **boots**: runtime loads, `getConfig()` succeeds in local mode (env baked + from `.env.desktop`), it binds a real `127.0.0.1:` (confirming + spike 1.2 empirically), and migrations run. **BLOCKED (upstream):** + serving static client assets crashes — + `@deno/experimental-route-config@0.0.5` (via `@deno/svelte-adapter@0.2.1`, + both latest) tries to `open` the literal catch-all pattern + `…/_app/immutable/:file*` instead of the resolved filename; `:`/`*` are + illegal in Windows paths (os error 123). Not our code; no released fix. A + working native window on Windows is blocked until the adapter is fixed + upstream or we bypass it with a custom `Deno.serve` entrypoint (the docs' + escape hatch). Also: `deno desktop .` compiles then exits (run the emitted + `quantum/quantum.exe`); `--hmr` runs the `dev` task, which loads `.env` + (server config) and collides with local mode — neither gives a quick live + window today. + + **RESOLVED** (see design's "Resolved During Implementation"): the static + bug was fixed by patching `@deno/experimental-route-config`'s regex + (`scripts/patch-route-config.ts`, chained into the build); migrations are + embedded via codegen (not `--include`); and the OS-webview backend, which + failed to render on Windows, was replaced with `--backend cef`. The window + now opens, stays, and renders local-mode Quantum end to end. + +## 2. Build and mode plumbing + +- [x] 2.1 Add a `desktop` task to `deno.json` invoking `deno desktop` against + 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.) +- [x] 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. — + `src/lib/server/desktop-config.ts` reads/writes `quantum-desktop.json` + beside the database; `QUANTUM_DESKTOP=1` (baked into the desktop env) + makes `loadConfig` carry a `desktop` flag so the hook reads the app-config + at runtime rather than a baked mode. `dev-desktop` launch config added. +- [x] 2.3 Extend the release/build scripts to produce and version the desktop + artifact alongside the container image, reusing the existing ChronVer + version source. (Coordinate with the `release` skill's flow; do not fork + versioning.) — `scripts/build-desktop.ts` (`deno task desktop:package`) + cross-compiles all three platforms from one host (deno desktop + `--target`; prebuilt denort + CEF per target) into fresh + `dist/build//` dirs and archives + `dist/quantum-desktop--{windows-x86_64.zip, + macos-aarch64.tar.gz, linux-x86_64.AppImage}`, reading the version from + `currentVersion()` (package.json, same source as the image; no fork). + Hard lessons encoded in the script: the macOS artifact must be a + tar.gz written by @std/tar with modes stamped 0755 and the framework + symlinks preserved as symlink entries — a bsdtar zip from NTFS records + every binary 0644 (unlaunchable .app) and the first attempt shipped + exactly that; the bundle's real entrypoint is + `Contents/MacOS/laufey`, which the per-target payload check now + asserts. Every archive is verified to contain no `.quantum/` runtime + state (the artifact is deleted on violation). The `release` skill's + step 9 documents the three artifacts and their caveats: macOS/Linux + builds not yet launched on real hardware, nothing signed, .dmg needs a + Mac. CI stays container-only for now — no artifact host/signing story + yet (already an open question). `dist/` gitignored. + +## 3. Config: server vs. local + +- [x] 3.1 In `src/lib/server/config.ts`, add `mode: 'server' | 'local'` sourced + from `QUANTUM_MODE` (default `server`). In `server` mode, keep the current + required-config validation exactly. In `local` mode, drop the `APP_URL` / + `ALLOWED_DIDS` / `OAUTH_PRIVATE_KEY_JWK` requirements and default `dbPath` + to the OS app-data directory. — `loadLocalConfig` branch; + `defaultLocalDbPath` resolves `%APPDATA%`/`Application Support`/XDG. Also + carries an optional `localDisplayName` from `QUANTUM_LOCAL_DISPLAY_NAME`. +- [x] 3.2 Make the modes mutually exclusive: `QUANTUM_MODE=local` together with + `ALLOWED_DIDS` or OAuth key configuration MUST be a hard configuration + error, so a server can never half-apply the local relaxations. — Both + trigger a hard error in `loadLocalConfig`. +- [x] 3.3 Extend `config.test.ts`: local mode loads with none of the server env + vars; local + server config is rejected; server mode is unchanged. — 4 new + tests (8 total, all green). + +## 4. Local mode boot and no-login auth + +- [x] 4.1 In `src/hooks.server.ts` `init`, branch on mode. In local mode: skip + `initOAuthClient`; seed + `upsertUser(db, 'did:local:self', )`; skip the `Deno.cron` + registration. — Seeds when `localDisplayName` is set (the welcome flow + seeds it otherwise); starts the local sync cadence. +- [x] 4.2 In `handle`, in local mode set `event.locals.user` to the local user + unconditionally and never redirect to `/login`. Server mode is unchanged + (including `add-mcp-server`'s bearer path). — Split into `handleLocal` / + `handleServer`; `/login` and `/welcome` redirect to `/` once set up. + `/mcp` stays token-authenticated even locally. +- [x] 4.3 Read the display name from the first-launch flow (task 7); until set, + block app routes behind the local setup screen rather than a login page. — + `handleLocal` redirects protected routes to `/welcome` when + `did:local:self` is not yet seeded. (The `/welcome` screen itself is task + 7.) +- [x] 4.4 Tests: in local mode every protected route resolves as + `did:local:self` with no cookie; manual categorization records the local + actor and its display name surfaces in provenance; server-mode auth is + untouched. — Verified live against a headless local-mode server (port + 5174): `/` and `/ledger` → 200 no login, `/login` → 303 `/`, `/mcp` → 401 + without a token; the DB seeded `did:local:self` / "Graham". Server-mode + auth unchanged (its full suite still green). Hook logic is verified live + like the server-mode hooks, which are also not unit-tested (module + singletons). + +## 5. Local sync cadence and loopback MCP mount + +- [x] 5.1 In local mode, run `runSync(getDb())` once during `init` (catch-up), + then on a `setInterval` at the configured interval while running. Honor + the task 1.1 finding for the interval and for whether background firing is + relied upon. Keep the server's daily `Deno.cron` path unchanged. — + `startLocalSync`; 6-hour interval, fire-and-log on launch. Spike 1.1 + confirmed the interval fires while minimized, so the 6-hour cadence is + final. +- [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. — 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 + path. — Writes `agent.json` (`{ mcpUrl }`) next to the database on + loopback start. Deliberately writes the URL only, never a token: the user + mints a scoped, revocable token in Settings, so no plaintext credential + sits on disk. +- [x] 5.4 Tests/verification: a local agent reaches MCP over loopback with a + valid token and is rejected without one; sync-on-launch brings data + current after a simulated closed period. — Loopback token gating verified + live (401 without, 15 tools with). Sync-on-launch fires from `init` + (observed attempting `runSync` on the headless local-mode boot); a genuine + 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 (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.) + +- [x] 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). — Implemented as a hook-level gate: + in desktop remote mode `handleLocal` 303s every request (including + `/mcp`) to the configured origin, before anything touches the database. + The embedded server can't be *not started* (`deno desktop` owns it), but + it serves nothing and opens nothing. Database creation is now deferred + until local mode is actually chosen (`armLocalMode`, called lazily), so + first launch and remote mode never create `quantum.db` — verified live + (data dir holds only `quantum-desktop.json`). A packaged-binary spike + proved the CEF webview navigates to external https origins (both a page's + own `location.href` redirect and `Deno.BrowserWindow.navigate()`), so the + 303 hand-off works in the shipped app. **Cookie finding:** the CEF + profile is ephemeral — even Max-Age cookies do not survive a relaunch, + and no `deno desktop` knob or pass-through Chromium switch persists it — + so remote mode means logging in again on each launch until upstream + exposes a persistent profile. In-session cookies behave normally. The + in-webview login E2E against a real server is 8.3. +- [x] 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. — Confirmed: the remote-mode + diff touches only `hooks.server.ts` and `src/routes/setup`; login and + callback routes and the schema are untouched. + +## 7. First-launch experience + +- [x] 7.1 Build the first-launch mode-selection screen: Local ("just me, on this + computer") vs Remote ("I have a Quantum server"), shell-less and calm per + DESIGN.md, persisting the choice to app-config. — `src/routes/setup`; + shell-less, Local functional (writes `{mode:'local'}` and routes on to + `/welcome`), Remote shown but marked "soon" (deferred, see 7.3). Verified + the full flow live and in the CEF window. +- [x] 7.2 Local branch: prompt for a display name and seed the local user with + it. — `src/routes/welcome` (built earlier); reached after the mode screen. +- [x] 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. — Un-deferred: + the navigation spike (see 6.1) resolved the architectural uncertainty, so + the "soon" placeholder is now a working option. Selecting it expands an + inline form; the action normalizes the address (scheme defaults to + https), probes `GET /healthz` for `{ ok: true }` with a 5s timeout so a + typo or non-Quantum host fails with a message instead of stranding the + webview, persists `{ mode: "remote", serverUrl }`, and 303s the webview + to the origin. Verified live: bad-address and non-Quantum-host errors + render inline with the input preserved; a valid address lands the + browser on the remote origin; every subsequent request to the embedded + server redirects there. +- [x] 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). — + Shipped as **Disconnect** (renamed: nothing is deleted), a Settings + section with a two-step inline confirm, effective immediately rather + than on next launch. Two variants: in the desktop build's own pages + (local mode) it is a server action — `disarmLocalMode()` (stops the + 6-hour sync interval and shuts down the loopback MCP listener so a + shell switching to remote never keeps syncing the abandoned ledger), + clears app-config, 303 → `/setup`. On a hosted server's pages viewed + through the desktop shell (remote mode), the section renders when the + webview's injected `bindings` proxy is detected and calls + `bindings.quantumDisconnect()` — registered by `registerDesktopShell` + in the shell, which clears config and navigates the window home; + currently fails gracefully with a manual-fallback message because + laufey cef 0.5.0 never populates the binding callback table (see + design's open question). Local machinery moved from `hooks.server.ts` + to `src/lib/server/desktop-runtime.ts` (arm/disarm; re-armable). + +## 8. Verification + +- [x] 8.1 Run `deno task test` and `deno task check`. Confirm the server build + and container image are unchanged (server-mode tests all green, no config + change required for existing deployments). +- [~] 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 to the MCP URL from `agent.json` and + categorize via a token, confirming `agent` provenance. — The first-launch path + (fresh → /setup → Local → /welcome → dashboard) is verified live and confirmed + rendering in the CEF window by Graham. The SimpleFIN → sync → categorize → + agent-provenance tail still wants a real bank connection exercised in the + packaged app; the pieces are individually tested (sync, MCP, provenance) but + not yet driven as one desktop session. +- [x] 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. — Driven by Graham + against his real hosted instance in the rebuilt packaged binary + (2026-07-17): Remote selected on `/setup`, the server's ATProto cookie + login completed inside the webview, and the app ran as the logged-in + user. No-local-database is verified at the hook level (remote mode never + calls `initDb`; the data dir holds only `quantum-desktop.json`). As + predicted by the profile spike, the session did not survive an app + relaunch (ephemeral CEF profile — see design's open question); login runs + again each launch. +- [~] 8.4 Reset from each mode returns to the selection screen; local data files + remain on disk after a reset. — Local mode verified end to end (headless and + in-browser): fresh → Local → welcome → Settings → Disconnect → setup screen + in-session; `quantum-desktop.json` cleared, database files intact; loopback + MCP provably stopped (connection refused) and re-armed (401, token-gated) + after re-choosing Local, which lands straight on the dashboard with data + intact. Remote-mode in-app disconnect is blocked on the upstream laufey + bindings bug — the button renders and degrades to a manual-fallback message; + re-verify once upstream fixes invocation (or the server is updated and a + packaged shell drives it live). +- [x] 8.5 Confirm a hosted server rejects `QUANTUM_MODE=local` combined with + server config, and that the default (no `QUANTUM_MODE`) behaves exactly as + the current release. — Covered by `config.test.ts`: local + `ALLOWED_DIDS` + or local + OAuth key is rejected; default (no `QUANTUM_MODE`) is server + mode and the existing server-config tests are unchanged. 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 deleted file mode 100644 --- a/openspec/changes/add-desktop-local-remote-modes/specs/auth/spec.md +++ /dev/null @@ -1,98 +0,0 @@ -## MODIFIED Requirements - -### Requirement: ATProto OAuth login - -The system SHALL authenticate users via AT Protocol OAuth using handle-based -login when running as a server. The user enters their handle (or DID); the -system resolves it, performs the OAuth authorization flow (PAR, PKCE, DPoP) -against the user's authorization server, and establishes an application session -on success. The system SHALL request only the `atproto` scope and SHALL NOT make -authenticated requests to the user's PDS after authentication. Resolving and -rendering profile pictures from public ATProto profile data — without using the -OAuth session or any application credential — is permitted. When the application -runs as a desktop build in Local mode, it SHALL NOT perform this login flow and -SHALL NOT initialize an ATProto OAuth client; authentication is governed instead -by the local single-user runtime. - -#### Scenario: Successful login with allowlisted handle - -- **WHEN** a user whose DID is in the allowlist completes the OAuth flow -- **THEN** the system creates an application session and sets an HTTP-only, - Secure session cookie -- **AND** the user is redirected to the dashboard - -#### Scenario: Login with unknown handle - -- **WHEN** a user submits a handle that cannot be resolved to a DID -- **THEN** the system shows an error on the login page without starting the - OAuth flow - -#### Scenario: Local mode performs no login - -- **WHEN** the application runs as a desktop build in Local mode -- **THEN** no login page is presented, no OAuth client is initialized, and - requests are authenticated as the local single user - -## ADDED Requirements - -### Requirement: Local single-user runtime - -When the application runs as a desktop build in Local mode, it SHALL operate as -a single-user system with no login. It SHALL seed one synthetic user with the -DID `did:local:self` and a user-chosen display name, and SHALL treat that user -as the authenticated principal for every request, never redirecting to a login -page. This runtime SHALL be reachable only when the application is explicitly -configured for Local mode; a server deployment SHALL NOT enable it, and -supplying server authentication configuration together with Local mode SHALL be -a configuration error rather than a silent relaxation. All existing per-actor -invariants (such as manual categorization recording an actor) SHALL be satisfied -by the synthetic user without special-casing. - -#### Scenario: Local user authenticated without a session - -- **WHEN** any request is handled in Local mode -- **THEN** it is authenticated as `did:local:self` with the chosen display name, - and no session cookie or login is required - -#### Scenario: Display name drives provenance - -- **WHEN** the local user manually categorizes a transaction -- **THEN** the categorization records the local user as actor and provenance - displays the chosen display name - -#### Scenario: Local relaxation cannot apply to a server - -- **WHEN** a deployment is configured for Local mode but also supplies - `ALLOWED_DIDS` or OAuth signing configuration -- **THEN** startup fails with a configuration error rather than running a server - without login - -### Requirement: Remote client cookie login - -When the application runs as a desktop build in Remote mode, it SHALL -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 logs in through the webview - -- **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: Session cookie authenticates subsequent requests - -- **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: App is not an OAuth client - -- **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 deleted file mode 100644 --- a/openspec/changes/add-desktop-local-remote-modes/specs/desktop-app/spec.md +++ /dev/null @@ -1,120 +0,0 @@ -## ADDED Requirements - -### Requirement: Native desktop build - -The system SHALL be buildable as a native desktop application from the existing -web application, producing a self-contained binary per platform whose UI runs in -the operating system's webview and whose server logic runs in-process. The -desktop build SHALL NOT fork or duplicate the domain service layer. The existing -container image and server deployment SHALL remain a supported, unchanged -output. - -#### Scenario: Desktop artifact produced - -- **WHEN** the desktop build is run for a platform -- **THEN** it produces a single distributable binary for that platform that runs - the application without a separately installed runtime or browser - -#### Scenario: Server deployment unchanged - -- **WHEN** the container image is built and deployed as before -- **THEN** it behaves identically to prior releases, with no dependency on the - desktop build - -### Requirement: First-launch mode selection - -On first launch, the desktop application SHALL require the user to choose -between Local mode ("just me, on this computer") and Remote mode ("I have a -Quantum server"). The choice SHALL be persisted outside the application -database. The application SHALL NOT present an in-app toggle to switch modes; -changing mode SHALL be an explicit reset action that clears the persisted choice -and returns the user to the mode-selection screen on the next launch. - -#### Scenario: Mode chosen on first launch - -- **WHEN** the desktop app is launched for the first time -- **THEN** it presents the Local/Remote choice and does not proceed to the app - until one is chosen - -#### Scenario: Choice persisted across launches - -- **WHEN** a user has chosen a mode and relaunches the app -- **THEN** the app starts directly in the chosen mode without asking again - -#### Scenario: Reset returns to selection - -- **WHEN** a user performs the reset action -- **THEN** the persisted mode is cleared and the next launch presents the - mode-selection screen again - -### Requirement: Local mode runtime - -In Local mode, the application SHALL run its embedded server configured for -local operation: it SHALL NOT require or validate `APP_URL`, `ALLOWED_DIDS`, or -OAuth signing configuration, and SHALL NOT initialize an ATProto OAuth client. -The application database SHALL be stored in the operating system's -application-data directory by default. On first launch in Local mode, the -application SHALL prompt for a display name and SHALL seed a single synthetic -user for it. Connecting a bank via a SimpleFIN setup token SHALL work in Local -mode exactly as on a server. - -#### Scenario: Local mode boots without server configuration - -- **WHEN** the app starts in Local mode with no `APP_URL`, `ALLOWED_DIDS`, or - OAuth key configured -- **THEN** it starts successfully, initializing no OAuth client and requiring no - login - -#### Scenario: Local database in app-data directory - -- **WHEN** Local mode initializes its database for the first time -- **THEN** the SQLite file is created under the operating system's - application-data directory - -#### Scenario: Bank connection in local mode - -- **WHEN** a Local-mode user pastes a valid SimpleFIN setup token in Settings -- **THEN** the connection is claimed and synced exactly as on a server, with no - login required - -### Requirement: Local mode agent access - -In Local mode, the application SHALL expose the MCP server on a loopback-only -listener, using the same MCP handler and requiring the same bearer API token as -the server route. The application MAY write the loopback endpoint and a minted -token to a well-known application-data location to simplify configuring a local -agent host. A local agent request without a valid token SHALL be rejected. - -#### Scenario: Local agent reaches MCP over loopback - -- **WHEN** an agent on the same machine connects to the loopback MCP endpoint - with a valid token -- **THEN** it can list and call the same tools the server route exposes - -#### Scenario: Loopback MCP still requires a token - -- **WHEN** a process on the machine connects to the loopback MCP endpoint - without a valid token -- **THEN** the request is rejected - -### Requirement: Remote mode thin client - -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 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 - -- **WHEN** a user selects Remote mode and provides their Quantum server address -- **THEN** the app presents that server's interface and stores no ledger data - locally - -#### Scenario: Remote mode runs no local server - -- **WHEN** the app is running in Remote mode -- **THEN** it has not started an embedded server, opened a local database, or - mounted a local MCP listener diff --git a/openspec/changes/add-desktop-local-remote-modes/specs/simplefin-sync/spec.md b/openspec/changes/add-desktop-local-remote-modes/specs/simplefin-sync/spec.md deleted file mode 100644 --- a/openspec/changes/add-desktop-local-remote-modes/specs/simplefin-sync/spec.md +++ /dev/null @@ -1,42 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Scheduled and manual sync - -The system SHALL sync each connection automatically on a schedule appropriate to -how it is deployed, and SHALL provide a manual "sync now" action in the UI. A -server deployment SHALL sync each connection once daily via a scheduled job. A -desktop build in Local mode, which is not continuously running, SHALL instead -sync on application launch — catching up data missed while the app was closed — -and periodically while the application runs, rather than relying on a fixed-time -schedule that has no catch-up for missed fires. In all cases a sync fetches -`GET {access_url}/accounts` including pending transactions and a start date that -safely overlaps previously fetched data. - -#### Scenario: Daily scheduled sync on a server - -- **WHEN** the daily schedule fires on a server deployment -- **THEN** the system performs a sync for every connection and records the - outcome - -#### Scenario: Sync on launch in local mode - -- **WHEN** a desktop Local-mode application starts -- **THEN** it performs a sync for every connection, bringing data current after - any period the app was closed - -#### Scenario: Periodic sync while running in local mode - -- **WHEN** a desktop Local-mode application has been running for the configured - interval -- **THEN** it performs another sync for every connection - -#### Scenario: Manual sync - -- **WHEN** a user triggers "sync now" -- **THEN** a sync runs immediately and the UI reflects the result - -#### Scenario: Sync failure - -- **WHEN** the SimpleFIN request fails (network error or non-2xx) -- **THEN** the system records a failed sync with the error detail and leaves all - previously normalized data untouched diff --git a/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/specs/auth/spec.md b/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/specs/auth/spec.md new file mode 100644 --- /dev/null +++ b/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/specs/auth/spec.md @@ -0,0 +1,98 @@ +## MODIFIED Requirements + +### Requirement: ATProto OAuth login + +The system SHALL authenticate users via AT Protocol OAuth using handle-based +login when running as a server. The user enters their handle (or DID); the +system resolves it, performs the OAuth authorization flow (PAR, PKCE, DPoP) +against the user's authorization server, and establishes an application session +on success. The system SHALL request only the `atproto` scope and SHALL NOT make +authenticated requests to the user's PDS after authentication. Resolving and +rendering profile pictures from public ATProto profile data — without using the +OAuth session or any application credential — is permitted. When the application +runs as a desktop build in Local mode, it SHALL NOT perform this login flow and +SHALL NOT initialize an ATProto OAuth client; authentication is governed instead +by the local single-user runtime. + +#### Scenario: Successful login with allowlisted handle + +- **WHEN** a user whose DID is in the allowlist completes the OAuth flow +- **THEN** the system creates an application session and sets an HTTP-only, + Secure session cookie +- **AND** the user is redirected to the dashboard + +#### Scenario: Login with unknown handle + +- **WHEN** a user submits a handle that cannot be resolved to a DID +- **THEN** the system shows an error on the login page without starting the + OAuth flow + +#### Scenario: Local mode performs no login + +- **WHEN** the application runs as a desktop build in Local mode +- **THEN** no login page is presented, no OAuth client is initialized, and + requests are authenticated as the local single user + +## ADDED Requirements + +### Requirement: Local single-user runtime + +When the application runs as a desktop build in Local mode, it SHALL operate as +a single-user system with no login. It SHALL seed one synthetic user with the +DID `did:local:self` and a user-chosen display name, and SHALL treat that user +as the authenticated principal for every request, never redirecting to a login +page. This runtime SHALL be reachable only when the application is explicitly +configured for Local mode; a server deployment SHALL NOT enable it, and +supplying server authentication configuration together with Local mode SHALL be +a configuration error rather than a silent relaxation. All existing per-actor +invariants (such as manual categorization recording an actor) SHALL be satisfied +by the synthetic user without special-casing. + +#### Scenario: Local user authenticated without a session + +- **WHEN** any request is handled in Local mode +- **THEN** it is authenticated as `did:local:self` with the chosen display name, + and no session cookie or login is required + +#### Scenario: Display name drives provenance + +- **WHEN** the local user manually categorizes a transaction +- **THEN** the categorization records the local user as actor and provenance + displays the chosen display name + +#### Scenario: Local relaxation cannot apply to a server + +- **WHEN** a deployment is configured for Local mode but also supplies + `ALLOWED_DIDS` or OAuth signing configuration +- **THEN** startup fails with a configuration error rather than running a server + without login + +### Requirement: Remote client cookie login + +When the application runs as a desktop build in Remote mode, it SHALL +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 logs in through the webview + +- **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: Session cookie authenticates subsequent requests + +- **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: App is not an OAuth client + +- **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/archive/2026-07-23-add-desktop-local-remote-modes/specs/desktop-app/spec.md b/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/specs/desktop-app/spec.md new file mode 100644 --- /dev/null +++ b/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/specs/desktop-app/spec.md @@ -0,0 +1,122 @@ +## ADDED Requirements + +### Requirement: Native desktop build + +The system SHALL be buildable as a native desktop application from the existing +web application, producing a self-contained distributable artifact per platform +whose UI runs in an embedded webview and whose server logic runs in-process. +The desktop build SHALL NOT fork or duplicate the domain service layer. The +existing container image and server deployment SHALL remain a supported, +unchanged output. + +#### Scenario: Desktop artifact produced + +- **WHEN** the desktop build is run for a platform +- **THEN** it produces a self-contained distributable artifact for that platform + that runs the application without a separately installed runtime or browser + +#### Scenario: Server deployment unchanged + +- **WHEN** the container image is built and deployed as before +- **THEN** it behaves identically to prior releases, with no dependency on the + desktop build + +### Requirement: First-launch mode selection + +On first launch, the desktop application SHALL require the user to choose +between Local mode ("just me, on this computer") and Remote mode ("I have a +Quantum server"). The choice SHALL be persisted outside the application +database. The application SHALL NOT present an in-app toggle to switch modes; +changing mode SHALL be an explicit Disconnect action that clears the persisted +choice and returns the user to the mode-selection screen immediately, deleting +no data. + +#### Scenario: Mode chosen on first launch + +- **WHEN** the desktop app is launched for the first time +- **THEN** it presents the Local/Remote choice and does not proceed to the app + until one is chosen + +#### Scenario: Choice persisted across launches + +- **WHEN** a user has chosen a mode and relaunches the app +- **THEN** the app starts directly in the chosen mode without asking again + +#### Scenario: Disconnect returns to selection + +- **WHEN** a user performs the Disconnect action +- **THEN** the persisted mode is cleared and the mode-selection screen is + presented, immediately and on every launch until a mode is chosen again, + with local data left on disk + +### Requirement: Local mode runtime + +In Local mode, the application SHALL run its embedded server configured for +local operation: it SHALL NOT require or validate `APP_URL`, `ALLOWED_DIDS`, or +OAuth signing configuration, and SHALL NOT initialize an ATProto OAuth client. +The application database SHALL be stored in the operating system's +application-data directory by default. On first launch in Local mode, the +application SHALL prompt for a display name and SHALL seed a single synthetic +user for it. Connecting a bank via a SimpleFIN setup token SHALL work in Local +mode exactly as on a server. + +#### Scenario: Local mode boots without server configuration + +- **WHEN** the app starts in Local mode with no `APP_URL`, `ALLOWED_DIDS`, or + OAuth key configured +- **THEN** it starts successfully, initializing no OAuth client and requiring no + login + +#### Scenario: Local database in app-data directory + +- **WHEN** Local mode initializes its database for the first time +- **THEN** the SQLite file is created under the operating system's + application-data directory + +#### Scenario: Bank connection in local mode + +- **WHEN** a Local-mode user pastes a valid SimpleFIN setup token in Settings +- **THEN** the connection is claimed and synced exactly as on a server, with no + login required + +### Requirement: Local mode agent access + +In Local mode, the application SHALL expose the MCP server on a loopback-only +listener, using the same MCP handler and requiring the same bearer API token as +the server route. The application MAY write the loopback endpoint and a minted +token to a well-known application-data location to simplify configuring a local +agent host. A local agent request without a valid token SHALL be rejected. + +#### Scenario: Local agent reaches MCP over loopback + +- **WHEN** an agent on the same machine connects to the loopback MCP endpoint + with a valid token +- **THEN** it can list and call the same tools the server route exposes + +#### Scenario: Loopback MCP still requires a token + +- **WHEN** a process on the machine connects to the loopback MCP endpoint + without a valid token +- **THEN** the request is rejected + +### Requirement: Remote mode thin client + +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, SHALL NOT +serve application content locally, and SHALL NOT open 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 + +- **WHEN** a user selects Remote mode and provides their Quantum server address +- **THEN** the app presents that server's interface and stores no ledger data + locally + +#### Scenario: Remote mode serves nothing locally + +- **WHEN** the app is running in Remote mode +- **THEN** every local request is handed to the server's origin, no local + database has been opened, and no local MCP listener is mounted diff --git a/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/specs/simplefin-sync/spec.md b/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/specs/simplefin-sync/spec.md new file mode 100644 --- /dev/null +++ b/openspec/changes/archive/2026-07-23-add-desktop-local-remote-modes/specs/simplefin-sync/spec.md @@ -0,0 +1,42 @@ +## MODIFIED Requirements + +### Requirement: Scheduled and manual sync + +The system SHALL sync each connection automatically on a schedule appropriate to +how it is deployed, and SHALL provide a manual "sync now" action in the UI. A +server deployment SHALL sync each connection once daily via a scheduled job. A +desktop build in Local mode, which is not continuously running, SHALL instead +sync on application launch — catching up data missed while the app was closed — +and periodically while the application runs, rather than relying on a fixed-time +schedule that has no catch-up for missed fires. In all cases a sync fetches +`GET {access_url}/accounts` including pending transactions and a start date that +safely overlaps previously fetched data. + +#### Scenario: Daily scheduled sync on a server + +- **WHEN** the daily schedule fires on a server deployment +- **THEN** the system performs a sync for every connection and records the + outcome + +#### Scenario: Sync on launch in local mode + +- **WHEN** a desktop Local-mode application starts +- **THEN** it performs a sync for every connection, bringing data current after + any period the app was closed + +#### Scenario: Periodic sync while running in local mode + +- **WHEN** a desktop Local-mode application has been running for the configured + interval +- **THEN** it performs another sync for every connection + +#### Scenario: Manual sync + +- **WHEN** a user triggers "sync now" +- **THEN** a sync runs immediately and the UI reflects the result + +#### Scenario: Sync failure + +- **WHEN** the SimpleFIN request fails (network error or non-2xx) +- **THEN** the system records a failed sync with the error detail and leaves all + previously normalized data untouched -- tangled.sh