A self-hosted household ledger.
0

Configure Feed

Select the types of activity you want to include in your feed.

add desktop local mode: config seam, no-login auth, local sync, loopback mcp, welcome screen

author graham.systems date (Jul 23, 2026, 11:19 AM -0700) commit 6f96c750 parent 618e2820 change-id zttvpsuz
+978 -444
+15
.claude/launch.json
··· 6 6 "runtimeExecutable": "deno", 7 7 "runtimeArgs": ["task", "dev"], 8 8 "port": 5173 9 + }, 10 + { 11 + "name": "dev-local", 12 + "runtimeExecutable": "deno", 13 + "runtimeArgs": [ 14 + "run", 15 + "--env-file=.env.localmode", 16 + "--unstable-cron", 17 + "-A", 18 + "npm:vite", 19 + "dev", 20 + "--port", 21 + "5174" 22 + ], 23 + "port": 5174 9 24 } 10 25 ] 11 26 }
+18
.env.localmode.example
··· 1 + # Local mode (single-user desktop build). Copy to `.env.localmode` for 2 + # `deno task dev:local`. No login, no OAuth, no allowlist — supplying 3 + # ALLOWED_DIDS or OAUTH_PRIVATE_KEY_JWK here is a hard error. 4 + 5 + QUANTUM_MODE=local 6 + 7 + # Where the SQLite database lives. If unset, defaults to the OS application-data 8 + # directory (e.g. %APPDATA%\Quantum\quantum.db). 9 + DB_PATH=./data/local-dev.db 10 + 11 + # The single local user's display name. If unset, the app sends you to the 12 + # first-launch /welcome screen to choose one. 13 + QUANTUM_LOCAL_DISPLAY_NAME=You 14 + 15 + # Optional: bind a loopback-only MCP listener on this port for local agents. 16 + # The desktop build sets this; for `dev:local` the SvelteKit /mcp route already 17 + # works, so it's usually left unset. 18 + # QUANTUM_LOCAL_MCP_PORT=5175
+1
.gitignore
··· 20 20 .env 21 21 .env.* 22 22 !.env.example 23 + !.env.localmode.example 23 24 !.env.test 24 25 25 26 # Vite
+2
deno.json
··· 8 8 }, 9 9 "tasks": { 10 10 "dev": "deno run --env-file --unstable-cron -A npm:vite dev", 11 + "dev:local": "deno run --env-file=.env.localmode --unstable-cron -A npm:vite dev", 11 12 "build": "deno run -A npm:vite build", 12 13 "start": "deno run --env-file --unstable-cron -A build/index.js", 14 + "desktop": "deno desktop --unstable-cron -A .", 13 15 "check": "deno run -A npm:@sveltejs/kit/svelte-kit sync && deno run -A npm:svelte-check --tsconfig ./tsconfig.json", 14 16 "test": "deno test -A src", 15 17 "fmt": "deno fmt",
+3 -1
openspec/changes/add-desktop-local-remote-modes/README.md
··· 1 1 # add-desktop-local-remote-modes 2 2 3 - Package Quantum as a deno desktop app with a first-launch Local/Remote mode choice: a single-user no-login local build with an embedded server, and a thin remote client to an existing Quantum server via OAuth deep-link handoff 3 + Package Quantum as a deno desktop app with a first-launch Local/Remote mode 4 + choice: a single-user no-login local build with an embedded server, and a thin 5 + remote client to an existing Quantum server via OAuth deep-link handoff
+83 -80
openspec/changes/add-desktop-local-remote-modes/design.md
··· 1 1 ## Context 2 2 3 - The server core is transport-agnostic (design D6) and already boots from a small, 4 - well-defined seam. The relevant current state: 3 + The server core is transport-agnostic (design D6) and already boots from a 4 + small, well-defined seam. The relevant current state: 5 5 6 6 - **`loadConfig`** (`config.ts`) hard-requires `APP_URL`, `ALLOWED_DIDS`, and 7 7 `OAUTH_PRIVATE_KEY_JWK`, throwing a fail-fast error if any is missing. Its ··· 15 15 - **`upsertUser`** (`users.ts`) takes a DID and handle; nothing in the schema or 16 16 services parses DID shape — `users.did` is an opaque TEXT primary key, 17 17 `actor_did` joins by equality, and the UI renders the stored handle. 18 - - **`claimSetupToken`** (`connections.ts`) claims a SimpleFIN token and stores an 19 - Access URL. It takes no user, no DID — bank setup is independent of login. 18 + - **`claimSetupToken`** (`connections.ts`) claims a SimpleFIN token and stores 19 + an Access URL. It takes no user, no DID — bank setup is independent of login. 20 20 - **Sessions** (`sessions.ts`) are opaque, bearer-ready tokens, per the auth 21 21 spec. 22 22 - **`add-mcp-server`** introduces a transport-only MCP handler (no cookie/view 23 23 reads) and a bearer-authentication path in `handle` for API tokens. 24 24 25 25 `deno desktop` (Deno 2.9) compiles this SvelteKit app into a native binary, 26 - running the production server in-process with the UI in an OS webview. Backend↔UI 27 - communication is in-process channels, not socket IPC — so a desktop build cannot 28 - assume its embedded server is reachable on a localhost TCP port. Deno is already 29 - pinned at 2.9.3 in the Dockerfile. 26 + running the production server in-process with the UI in an OS webview. 27 + Backend↔UI communication is in-process channels, not socket IPC — so a desktop 28 + build cannot assume its embedded server is reachable on a localhost TCP port. 29 + Deno is already pinned at 2.9.3 in the Dockerfile. 30 30 31 31 The user-facing goal: an individual runs Quantum on the desktop with no server, 32 32 no login, data on their own disk; a server owner runs the same app as a native ··· 38 38 **Goals:** 39 39 40 40 - Ship a native desktop build without forking the codebase or the service layer. 41 - - Local mode: no server, no login, single user, local data, working bank sync and 42 - local agent access. 41 + - Local mode: no server, no login, single user, local data, working bank sync 42 + and local agent access. 43 43 - Remote mode: native client to an existing server, authenticated by reusing the 44 44 server's ATProto OAuth via a system-browser handoff, without the app becoming 45 45 an OAuth client. ··· 48 48 49 49 **Non-Goals:** 50 50 51 - - A tray/background-resident runtime. It would change sync cadence materially and 52 - carries its own UX decisions (close-to-tray, run-in-background preference); it 53 - is parked for a follow-up (see Open Questions). 51 + - A tray/background-resident runtime. It would change sync cadence materially 52 + and carries its own UX decisions (close-to-tray, run-in-background 53 + preference); it is parked for a follow-up (see Open Questions). 54 54 - Mobile builds and auto-update. Separate efforts. 55 - - Multi-user local mode. Local is deliberately one person; the two-person product 56 - is the server. 55 + - Multi-user local mode. Local is deliberately one person; the two-person 56 + product is the server. 57 57 - Re-attributing history when a local user later migrates to a server. Explicit 58 58 non-goal (see decision 2). 59 59 ··· 63 63 64 64 **Decision:** `loadConfig` gains a `mode: 'server' | 'local'`, sourced from 65 65 `QUANTUM_MODE` (defaulting to `server`, which the container sets implicitly by 66 - never setting `local`). The desktop first-launch screen writes the chosen mode to 67 - desktop app-config (a file in the app-data directory, outside the SQLite 68 - database). Changing mode is an explicit reset that clears app-config and re-shows 69 - the screen; there is no in-app local↔remote toggle. 66 + never setting `local`). The desktop first-launch screen writes the chosen mode 67 + to desktop app-config (a file in the app-data directory, outside the SQLite 68 + database). Changing mode is an explicit reset that clears app-config and 69 + re-shows the screen; there is no in-app local↔remote toggle. 70 70 71 - **Why:** Local and remote are not two configurations of one runtime — they differ 72 - in whether an embedded server runs at all, whether there is a database, and how 73 - auth works. A once-then-reset choice models that honestly and keeps the branching 74 - at boot, not scattered through the app. Storing mode outside the database is 75 - required: in remote mode there is no local database to store it in, and in local 76 - mode the mode must be known before the database is opened. 71 + **Why:** Local and remote are not two configurations of one runtime — they 72 + differ in whether an embedded server runs at all, whether there is a database, 73 + and how auth works. A once-then-reset choice models that honestly and keeps the 74 + branching at boot, not scattered through the app. Storing mode outside the 75 + database is required: in remote mode there is no local database to store it in, 76 + and in local mode the mode must be known before the database is opened. 77 77 78 78 **Server-safety is a validation invariant, not a convention.** `loadConfig` in 79 79 `local` mode drops the OAuth/`APP_URL`/`ALLOWED_DIDS` requirements; in `server` 80 - mode it keeps them. A configuration that sets `QUANTUM_MODE=local` *and* supplies 81 - `ALLOWED_DIDS`/OAuth keys is a hard error, so the relaxations can never be half- 82 - applied to a server image by accident. 80 + mode it keeps them. A configuration that sets `QUANTUM_MODE=local` _and_ 81 + supplies `ALLOWED_DIDS`/OAuth keys is a hard error, so the relaxations can never 82 + be half- applied to a server image by accident. 83 83 84 84 ### 2. Local identity is a synthetic DID, `did:local:self` 85 85 86 - **Decision:** Local mode seeds one user at startup via `upsertUser(db, 87 - 'did:local:self', <display name>)` and treats it as the authenticated user for 88 - every request; `handle` never redirects to `/login` in local mode. The 89 - first-launch flow prompts for the display name. 86 + **Decision:** Local mode seeds one user at startup via 87 + `upsertUser(db, 88 + 'did:local:self', <display name>)` and treats it as the 89 + authenticated user for every request; `handle` never redirects to `/login` in 90 + local mode. The first-launch flow prompts for the display name. 90 91 91 92 **Why:** DIDs are opaque everywhere in Quantum, so a synthetic one slots in with 92 93 zero schema or service change, keeping the existing invariants — including 93 94 `manual events require actorDid` (`categorization.ts`) — satisfied without 94 95 special-casing. `did:local:self` is syntactically a valid DID (`did:` + a 95 - lowercase method + an id), greps cleanly, and cannot collide with a real identity 96 - because no `local` DID method resolves. The alternatives are worse: a bare 97 - `"local"` violates the `did:` convention the config validator enforces, and 98 - `did:web:localhost` abuses a method that *is* resolvable. 96 + lowercase method + an id), greps cleanly, and cannot collide with a real 97 + identity because no `local` DID method resolves. The alternatives are worse: a 98 + bare `"local"` violates the `did:` convention the config validator enforces, and 99 + `did:web:localhost` abuses a method that _is_ resolvable. 99 100 100 101 **Prompting for a display name** (rather than defaulting to `"you"` or the OS 101 102 username) keeps provenance legible — a badge reading the person's chosen name is ··· 103 104 already looking at. 104 105 105 106 **Migration re-attribution is a non-goal.** If a local user later moves to a 106 - server, their history stays attributed to `did:local:self` rather than their real 107 - DID. Rewriting attribution is out of scope; stated so no one expects it. 107 + server, their history stays attributed to `did:local:self` rather than their 108 + real DID. Rewriting attribution is out of scope; stated so no one expects it. 108 109 109 110 ### 3. Local mode does not use `Deno.cron` — it triggers sync on launch and on an interval 110 111 111 - **Decision:** In local mode, skip the `Deno.cron` registration entirely. Instead, 112 - run `runSync` once during `init` (catching up whatever was missed while the app 113 - was closed) and then on a `setInterval` while the app runs. The server keeps its 114 - daily `Deno.cron` unchanged. 112 + **Decision:** In local mode, skip the `Deno.cron` registration entirely. 113 + Instead, run `runSync` once during `init` (catching up whatever was missed while 114 + the app was closed) and then on a `setInterval` while the app runs. The server 115 + keeps its daily `Deno.cron` unchanged. 115 116 116 117 **Why:** The runtime's `Deno.cron` keeps its schedule in memory and has **no 117 - catch-up** — a missed fire is skipped, never replayed, and it only fires while the 118 - process is alive. A fixed `"0 11 * * *"` is therefore meaningless for a 118 + catch-up** — a missed fire is skipped, never replayed, and it only fires while 119 + the process is alive. A fixed `"0 11 * * *"` is therefore meaningless for a 119 120 part-time desktop process: any day the app isn't open at 11:00 UTC, that day's 120 121 sync simply never happens, silently. An event-driven trigger (on launch + while 121 122 running) matches how a desktop app actually lives. This is true independent of ··· 131 132 132 133 ### 4. One MCP handler, remounted on a loopback listener for local mode 133 134 134 - **Decision:** Local mode starts a dedicated loopback `Deno.serve` listener during 135 - `init` and mounts `add-mcp-server`'s transport-only MCP handler on it, so a local 136 - agent reaches the same tools a server exposes at `/mcp`. Remote mode adds no MCP 137 - mount — the user's server already serves it. 135 + **Decision:** Local mode starts a dedicated loopback `Deno.serve` listener 136 + during `init` and mounts `add-mcp-server`'s transport-only MCP handler on it, so 137 + a local agent reaches the same tools a server exposes at `/mcp`. Remote mode 138 + adds no MCP mount — the user's server already serves it. 138 139 139 - **Why:** `add-mcp-server` deliberately built its handler as a bare `Request → 140 - Response` with no cookie or view dependency, precisely so it could mount outside 141 - the web router. A loopback listener is necessary because `deno desktop`'s 142 - in-process UI channel means the embedded SvelteKit server's own port is not a 143 - reliable public surface. The listener binds loopback-only and requires the same 144 - bearer API token as the server route, so "local" does not mean "unauthenticated 145 - to any process on the machine." 140 + **Why:** `add-mcp-server` deliberately built its handler as a bare 141 + `Request → 142 + Response` with no cookie or view dependency, precisely so it could 143 + mount outside the web router. A loopback listener is necessary because 144 + `deno desktop`'s in-process UI channel means the embedded SvelteKit server's own 145 + port is not a reliable public surface. The listener binds loopback-only and 146 + requires the same bearer API token as the server route, so "local" does not mean 147 + "unauthenticated to any process on the machine." 146 148 147 149 **A verification, not an assumption:** whether `deno desktop` release mode 148 - *already* exposes the server's `/mcp` route on a reachable port is checked during 149 - implementation (task 5). If it does, the loopback mount is redundant and can be 150 - dropped; the design works either way and does not bet on it. 150 + _already_ exposes the server's `/mcp` route on a reachable port is checked 151 + during implementation (task 5). If it does, the loopback mount is redundant and 152 + can be dropped; the design works either way and does not bet on it. 151 153 152 154 ### 4a. Local MCP token bootstrapping 153 155 154 156 **Decision:** In local mode, the "Connect an agent" Settings surface from 155 - `add-mcp-server` still mints tokens (the app shell renders for `did:local:self`), 156 - and the desktop app writes the loopback URL and, optionally, a freshly minted 157 - token to a well-known app-data location so a local agent host can be configured 158 - with one step. 157 + `add-mcp-server` still mints tokens (the app shell renders for 158 + `did:local:self`), and the desktop app writes the loopback URL and, optionally, 159 + a freshly minted token to a well-known app-data location so a local agent host 160 + can be configured with one step. 159 161 160 162 **Why:** Local mode's whole appeal is low friction; making the user hand-copy a 161 163 port and token into an agent config re-introduces exactly the setup wall the ··· 167 169 **Decision:** Remote mode registers a custom URL scheme for the app. To 168 170 authenticate, the app opens the server's existing ATProto login in the system 169 171 browser; on success the server redirects to the app's registered deep link 170 - carrying a **bearer session token**. The app stores that token and presents it as 171 - `Authorization: Bearer` on subsequent requests (reusing `add-mcp-server`'s bearer 172 - path, widened from API tokens to session tokens). The desktop app never registers 173 - as an ATProto OAuth client. 172 + carrying a **bearer session token**. The app stores that token and presents it 173 + as `Authorization: Bearer` on subsequent requests (reusing `add-mcp-server`'s 174 + bearer path, widened from API tokens to session tokens). The desktop app never 175 + registers as an ATProto OAuth client. 174 176 175 177 **Why:** The server already is a fully configured confidential ATProto client 176 178 with DPoP-bound tokens; duplicating that in every desktop install would multiply ··· 183 185 **Handoff is the sensitive step** and is constrained accordingly: the deep-link 184 186 token is single-use at handoff (the app immediately exchanges or binds it), 185 187 delivered only to the app's registered scheme, and scoped to a normal session's 186 - lifetime and revocability. A handoff that is intercepted yields at most a session 187 - the user can log out to kill — the same blast radius as a stolen session cookie, 188 - no worse. 188 + lifetime and revocability. A handoff that is intercepted yields at most a 189 + session the user can log out to kill — the same blast radius as a stolen session 190 + cookie, no worse. 189 191 190 192 ### 6. Data lives in the OS application-data directory in local mode 191 193 ··· 194 196 Support equivalents elsewhere), created on first launch. `DB_PATH` may still 195 197 override it. 196 198 197 - **Why:** A user who never chose to self-host should not have to choose a database 198 - location either; the OS convention is the least-surprising home and survives app 199 - updates. The existing `openDatabase` already creates parent directories and 200 - carries a clear permissions error, so this is a path default, not new machinery. 199 + **Why:** A user who never chose to self-host should not have to choose a 200 + database location either; the OS convention is the least-surprising home and 201 + survives app updates. The existing `openDatabase` already creates parent 202 + directories and carries a clear permissions error, so this is a path default, 203 + not new machinery. 201 204 202 205 ## Risks / Trade-offs 203 206 ··· 211 214 and the interval is a focused-only bonus. The parked tray runtime would remove 212 215 the concern entirely by keeping the process resident. 213 216 214 - - **The deep-link handoff carries a live credential across a process boundary** → 215 - single-use at handoff, registered-scheme-only delivery, session-scoped and 217 + - **The deep-link handoff carries a live credential across a process boundary** 218 + → single-use at handoff, registered-scheme-only delivery, session-scoped and 216 219 revocable; blast radius equals a stolen session cookie. 217 220 218 221 - **`deno desktop` is experimental (2.9)** → the surface used here (SvelteKit ··· 220 223 packaging specifics are pinned to 2.9.x and validated in task 2 before deeper 221 224 work. 222 225 223 - - **Local data has no server backup** → an accepted property of local mode, not a 224 - defect; the honest mitigation is documentation (where the file is, that it is 225 - the user's to back up), not silent cloud sync. 226 + - **Local data has no server backup** → an accepted property of local mode, not 227 + a defect; the honest mitigation is documentation (where the file is, that it 228 + is the user's to back up), not silent cloud sync. 226 229 227 230 - **Two runtimes double the surface to test** → mitigated by both sharing the 228 231 entire service layer untouched; the divergence is confined to config, `init`, ··· 234 237 235 238 - `loadConfig` gains `mode`; server mode is the default and behaves exactly as 236 239 today, so existing deployments are unaffected with no config change. 237 - - No schema change is required by local mode itself. If remote-mode handoff needs 238 - to mark a session's delivery method, that is one nullable column added 240 + - No schema change is required by local mode itself. If remote-mode handoff 241 + needs to mark a session's delivery method, that is one nullable column added 239 242 additively to `sessions`, NULL for every existing row. 240 243 - The desktop artifact is a new build output; the container image build is 241 244 unchanged.
+43 -39
openspec/changes/add-desktop-local-remote-modes/proposal.md
··· 1 1 ## Why 2 2 3 3 Quantum is self-hosted software, and self-hosting is a wall. An individual who 4 - wants a Mint-style mirror of their own accounts must stand up a server, a domain, 5 - an ATProto OAuth client, and a SimpleFIN connection before they see a single 6 - number. The couple this product was built for cleared that wall; most people 7 - won't. 4 + wants a Mint-style mirror of their own accounts must stand up a server, a 5 + domain, an ATProto OAuth client, and a SimpleFIN connection before they see a 6 + single number. The couple this product was built for cleared that wall; most 7 + people won't. 8 8 9 9 `deno desktop` (Deno 2.9, June 2026) removes it. It compiles a SvelteKit project 10 10 into a native, self-contained desktop binary — the UI in an OS webview, the 11 11 existing server running in-process — with no Chromium to ship and no daemon to 12 - install. The same build system also makes a *thin* desktop client viable: a 13 - native shell pointed at an existing Quantum server for the users who already have 14 - one. 12 + install. The same build system also makes a _thin_ desktop client viable: a 13 + native shell pointed at an existing Quantum server for the users who already 14 + have one. 15 15 16 16 These two audiences want opposite things from the same binary. The individual 17 17 wants "just run it, no account, my data on my disk." The server owner wants "log 18 - me into my server and get out of the way." So the desktop app asks once, at first 19 - launch, which one you are — and becomes a different runtime accordingly. This 20 - change builds both, because shipping only local mode would strand the existing 21 - two-person server behind a web browser while everyone else got a native app, and 22 - because both modes share one authentication seam (bearer credentials) that is 23 - cheaper to build once. 18 + me into my server and get out of the way." So the desktop app asks once, at 19 + first launch, which one you are — and becomes a different runtime accordingly. 20 + This change builds both, because shipping only local mode would strand the 21 + existing two-person server behind a web browser while everyone else got a native 22 + app, and because both modes share one authentication seam (bearer credentials) 23 + that is cheaper to build once. 24 24 25 25 This change depends on `add-mcp-server`: local mode remounts that change's MCP 26 26 handler on a loopback listener, and remote mode extends that change's ··· 29 29 ## What Changes 30 30 31 31 - A **`deno desktop` build** of the existing SvelteKit app, producing a native 32 - binary per platform, alongside the current container image (which is 33 - unchanged and remains the server deployment). 34 - - A **first-launch mode-selection screen**: *Local* ("just me, on this 35 - computer") or *Remote* ("I have a Quantum server"). The choice is made once and 36 - persisted in desktop app-config outside the database. Changing it later is an 37 - explicit **reset** action that clears the app-config and re-shows the screen — 38 - the two modes are treated as near-separate installs, not a runtime toggle. 32 + binary per platform, alongside the current container image (which is unchanged 33 + and remains the server deployment). 34 + - A **first-launch mode-selection screen**: _Local_ ("just me, on this 35 + computer") or _Remote_ ("I have a Quantum server"). The choice is made once 36 + and persisted in desktop app-config outside the database. Changing it later is 37 + an explicit **reset** action that clears the app-config and re-shows the 38 + screen — the two modes are treated as near-separate installs, not a runtime 39 + toggle. 39 40 - **Local mode** — a single-user, no-login runtime: 40 41 - The embedded server boots with `QUANTUM_MODE=local`, which **skips** the 41 42 `APP_URL` / `ALLOWED_DIDS` / `OAUTH_PRIVATE_KEY_JWK` validation and the ··· 62 63 backend remains the sole ATProto OAuth client; the desktop app never becomes 63 64 one. The app presents that token as a bearer credential on subsequent 64 65 requests, reusing the bearer path `add-mcp-server` introduced. 65 - - MCP in remote mode is served *by the user's server*, not the app — the app 66 + - MCP in remote mode is served _by the user's server_, not the app — the app 66 67 adds no local MCP mount. 67 68 68 69 Not in this change: the tray/background-resident runtime (parked — see Design's ··· 75 76 76 77 - `desktop-app`: Packaging Quantum as a native desktop binary; the first-launch 77 78 choice between local and remote mode and its once-then-reset persistence; 78 - local mode's no-login single-user runtime, app-data database, sync cadence, and 79 - loopback MCP mount; and remote mode's thin-client shell with system-browser 80 - OAuth deep-link handoff. 79 + local mode's no-login single-user runtime, app-data database, sync cadence, 80 + and loopback MCP mount; and remote mode's thin-client shell with 81 + system-browser OAuth deep-link handoff. 81 82 82 83 ### Modified Capabilities 83 84 84 85 - `auth`: Adds a no-login local runtime authenticated as a synthetic single user 85 - (`did:local:self`), gated to the desktop local build; and a bearer *session* 86 - token issued to a native remote client via an OAuth deep-link handoff, building 87 - on the bearer path from `add-mcp-server`. Server cookie login is unchanged. 86 + (`did:local:self`), gated to the desktop local build; and a bearer _session_ 87 + token issued to a native remote client via an OAuth deep-link handoff, 88 + building on the bearer path from `add-mcp-server`. Server cookie login is 89 + unchanged. 88 90 - `simplefin-sync`: The sync trigger becomes deployment-dependent — an always-on 89 91 server keeps the daily schedule; a desktop local build syncs on launch and 90 92 periodically while running. Sync's fetch, archival, normalization, and ··· 115 117 dropped and a local DB path in the app-data directory is defaulted. 116 118 - `src/hooks.server.ts` — `init` conditionally skips `initOAuthClient` and the 117 119 `Deno.cron` registration in local mode, seeds `did:local:self`, and starts the 118 - on-launch-plus-interval sync and the loopback MCP listener. `handle` treats the 119 - synthetic user as authenticated in local mode and never redirects to `/login`. 120 - - `src/routes/login`, `src/routes/oauth/callback` — unaffected on the server; not 121 - reached in local mode. A new deep-link callback path supports the remote-mode 122 - handoff. 120 + on-launch-plus-interval sync and the loopback MCP listener. `handle` treats 121 + the synthetic user as authenticated in local mode and never redirects to 122 + `/login`. 123 + - `src/routes/login`, `src/routes/oauth/callback` — unaffected on the server; 124 + not reached in local mode. A new deep-link callback path supports the 125 + remote-mode handoff. 123 126 - `src/lib/server/services/sessions.ts` — session tokens are already opaque and 124 127 bearer-ready; remote-mode handoff issues one for the native client to hold. A 125 128 small addition marks a session as delivered by handoff if needed for its ··· 131 134 version the desktop artifact alongside the image. 132 135 133 136 **Risk**: Two relaxations that must never leak to a server: no-login auth and 134 - skipped OAuth config. Both are gated on `QUANTUM_MODE=local`, which the container 135 - image never sets and which the config validator treats as mutually exclusive with 136 - server settings — a build that sets `local` and also presents `ALLOWED_DIDS` is a 137 - configuration error, not a silent downgrade. The remote-mode deep-link handoff is 138 - the other sensitive surface: it carries a live session token across a process 139 - boundary, so the token is single-use at handoff, bound to the requesting app 140 - instance, and delivered only to a registered scheme — detailed in Design. 137 + skipped OAuth config. Both are gated on `QUANTUM_MODE=local`, which the 138 + container image never sets and which the config validator treats as mutually 139 + exclusive with server settings — a build that sets `local` and also presents 140 + `ALLOWED_DIDS` is a configuration error, not a silent downgrade. The remote-mode 141 + deep-link handoff is the other sensitive surface: it carries a live session 142 + token across a process boundary, so the token is single-use at handoff, bound to 143 + the requesting app instance, and delivered only to a registered scheme — 144 + detailed in Design.
+20 -18
openspec/changes/add-desktop-local-remote-modes/specs/auth/spec.md
··· 3 3 ### Requirement: ATProto OAuth login 4 4 5 5 The system SHALL authenticate users via AT Protocol OAuth using handle-based 6 - login when running as a server. The user enters their handle (or DID); the system 7 - resolves it, performs the OAuth authorization flow (PAR, PKCE, DPoP) against the 8 - user's authorization server, and establishes an application session on success. 9 - The system SHALL request only the `atproto` scope and SHALL NOT make 6 + login when running as a server. The user enters their handle (or DID); the 7 + system resolves it, performs the OAuth authorization flow (PAR, PKCE, DPoP) 8 + against the user's authorization server, and establishes an application session 9 + on success. The system SHALL request only the `atproto` scope and SHALL NOT make 10 10 authenticated requests to the user's PDS after authentication. Resolving and 11 11 rendering profile pictures from public ATProto profile data — without using the 12 12 OAuth session or any application credential — is permitted. When the application ··· 37 37 38 38 ### Requirement: Local single-user runtime 39 39 40 - When the application runs as a desktop build in Local mode, it SHALL operate as a 41 - single-user system with no login. It SHALL seed one synthetic user with the DID 42 - `did:local:self` and a user-chosen display name, and SHALL treat that user as the 43 - authenticated principal for every request, never redirecting to a login page. 44 - This runtime SHALL be reachable only when the application is explicitly 45 - configured for Local mode; a server deployment SHALL NOT enable it, and supplying 46 - server authentication configuration together with Local mode SHALL be a 47 - configuration error rather than a silent relaxation. All existing per-actor 40 + When the application runs as a desktop build in Local mode, it SHALL operate as 41 + a single-user system with no login. It SHALL seed one synthetic user with the 42 + DID `did:local:self` and a user-chosen display name, and SHALL treat that user 43 + as the authenticated principal for every request, never redirecting to a login 44 + page. This runtime SHALL be reachable only when the application is explicitly 45 + configured for Local mode; a server deployment SHALL NOT enable it, and 46 + supplying server authentication configuration together with Local mode SHALL be 47 + a configuration error rather than a silent relaxation. All existing per-actor 48 48 invariants (such as manual categorization recording an actor) SHALL be satisfied 49 49 by the synthetic user without special-casing. 50 50 ··· 74 74 handoff. The application SHALL open the server's login in the operating system's 75 75 browser and SHALL receive, via a registered deep link, an opaque bearer session 76 76 token issued by the server. The application SHALL present that token as a bearer 77 - credential on subsequent requests. The server SHALL remain the sole ATProto OAuth 78 - client; the desktop application SHALL NOT register as one. The handed-off token 79 - SHALL be single-use at handoff, delivered only to the application's registered 80 - scheme, and SHALL carry the lifetime and revocability of an ordinary session. 77 + credential on subsequent requests. The server SHALL remain the sole ATProto 78 + OAuth client; the desktop application SHALL NOT register as one. The handed-off 79 + token SHALL be single-use at handoff, delivered only to the application's 80 + registered scheme, and SHALL carry the lifetime and revocability of an ordinary 81 + session. 81 82 82 83 #### Scenario: Remote client authenticates via the system browser 83 84 84 85 - **WHEN** a Remote-mode user initiates login 85 - - **THEN** the server's ATProto login opens in the system browser, and on success 86 - a bearer session token is delivered to the app through its registered deep link 86 + - **THEN** the server's ATProto login opens in the system browser, and on 87 + success a bearer session token is delivered to the app through its registered 88 + deep link 87 89 88 90 #### Scenario: Bearer session token authenticates requests 89 91
+18 -16
openspec/changes/add-desktop-local-remote-modes/specs/desktop-app/spec.md
··· 6 6 web application, producing a self-contained binary per platform whose UI runs in 7 7 the operating system's webview and whose server logic runs in-process. The 8 8 desktop build SHALL NOT fork or duplicate the domain service layer. The existing 9 - container image and server deployment SHALL remain a supported, unchanged output. 9 + container image and server deployment SHALL remain a supported, unchanged 10 + output. 10 11 11 12 #### Scenario: Desktop artifact produced 12 13 ··· 22 23 23 24 ### Requirement: First-launch mode selection 24 25 25 - On first launch, the desktop application SHALL require the user to choose between 26 - Local mode ("just me, on this computer") and Remote mode ("I have a Quantum 27 - server"). The choice SHALL be persisted outside the application database. The 28 - application SHALL NOT present an in-app toggle to switch modes; changing mode 29 - SHALL be an explicit reset action that clears the persisted choice and returns 30 - the user to the mode-selection screen on the next launch. 26 + On first launch, the desktop application SHALL require the user to choose 27 + between Local mode ("just me, on this computer") and Remote mode ("I have a 28 + Quantum server"). The choice SHALL be persisted outside the application 29 + database. The application SHALL NOT present an in-app toggle to switch modes; 30 + changing mode SHALL be an explicit reset action that clears the persisted choice 31 + and returns the user to the mode-selection screen on the next launch. 31 32 32 33 #### Scenario: Mode chosen on first launch 33 34 ··· 48 49 49 50 ### Requirement: Local mode runtime 50 51 51 - In Local mode, the application SHALL run its embedded server configured for local 52 - operation: it SHALL NOT require or validate `APP_URL`, `ALLOWED_DIDS`, or OAuth 53 - signing configuration, and SHALL NOT initialize an ATProto OAuth client. The 54 - application database SHALL be stored in the operating system's application-data 55 - directory by default. On first launch in Local mode, the application SHALL prompt 56 - for a display name and SHALL seed a single synthetic user for it. Connecting a 57 - bank via a SimpleFIN setup token SHALL work in Local mode exactly as on a server. 52 + In Local mode, the application SHALL run its embedded server configured for 53 + local operation: it SHALL NOT require or validate `APP_URL`, `ALLOWED_DIDS`, or 54 + OAuth signing configuration, and SHALL NOT initialize an ATProto OAuth client. 55 + The application database SHALL be stored in the operating system's 56 + application-data directory by default. On first launch in Local mode, the 57 + application SHALL prompt for a display name and SHALL seed a single synthetic 58 + user for it. Connecting a bank via a SimpleFIN setup token SHALL work in Local 59 + mode exactly as on a server. 58 60 59 61 #### Scenario: Local mode boots without server configuration 60 62 ··· 91 93 92 94 #### Scenario: Loopback MCP still requires a token 93 95 94 - - **WHEN** a process on the machine connects to the loopback MCP endpoint without 95 - a valid token 96 + - **WHEN** a process on the machine connects to the loopback MCP endpoint 97 + without a valid token 96 98 - **THEN** the request is rejected 97 99 98 100 ### Requirement: Remote mode thin client
+89 -50
openspec/changes/add-desktop-local-remote-modes/tasks.md
··· 3 3 - [ ] 1.1 **Background timers.** In a minimal `deno desktop` build of the app, 4 4 register a `setInterval` that logs, minimize the window, and confirm 5 5 whether it keeps firing while minimized/backgrounded. Record the finding — 6 - it decides whether "interval while running" is real or effectively 7 - "while focused", and thus the local sync cadence (design decision 3). 8 - - [ ] 1.2 **Server port exposure.** Determine whether `deno desktop` release mode 9 - exposes the embedded SvelteKit server's `/mcp` route on a reachable 6 + it decides whether "interval while running" is real or effectively "while 7 + focused", and thus the local sync cadence (design decision 3). 8 + - [ ] 1.2 **Server port exposure.** Determine whether `deno desktop` release 9 + mode exposes the embedded SvelteKit server's `/mcp` route on a reachable 10 10 loopback port. If yes, the separate loopback MCP mount (task 5) is 11 11 redundant; if no, it is required (design decision 4). 12 12 - [ ] 1.3 **Packaging sanity.** Confirm `deno desktop` (2.9.x) auto-detects this ··· 15 15 16 16 ## 2. Build and mode plumbing 17 17 18 - - [ ] 2.1 Add a `desktop` task to `deno.json` invoking `deno desktop` against the 19 - SvelteKit build. Do not disturb the existing `build`/`start`/`image` tasks. 18 + - [x] 2.1 Add a `desktop` task to `deno.json` invoking `deno desktop` against 19 + the SvelteKit build. Do not disturb the existing `build`/`start`/`image` 20 + tasks. — Added `desktop` (`deno desktop --unstable-cron -A .`) and a 21 + `dev:local` convenience task; existing tasks untouched. (Booting the 22 + packaged app end-to-end still needs the app-config-driven mode selection of 23 + tasks 2.2/7.1.) 20 24 - [ ] 2.2 Add a desktop launch config and the app-config read/write for the 21 25 persisted mode (a file in the OS app-data directory, outside the SQLite 22 26 database). Mode is one of `local` | `remote`, absent until first launch. ··· 27 31 28 32 ## 3. Config: server vs. local 29 33 30 - - [ ] 3.1 In `src/lib/server/config.ts`, add `mode: 'server' | 'local'` sourced 34 + - [x] 3.1 In `src/lib/server/config.ts`, add `mode: 'server' | 'local'` sourced 31 35 from `QUANTUM_MODE` (default `server`). In `server` mode, keep the current 32 36 required-config validation exactly. In `local` mode, drop the `APP_URL` / 33 37 `ALLOWED_DIDS` / `OAUTH_PRIVATE_KEY_JWK` requirements and default `dbPath` 34 - to the OS app-data directory. 35 - - [ ] 3.2 Make the modes mutually exclusive: `QUANTUM_MODE=local` together with 38 + to the OS app-data directory. — `loadLocalConfig` branch; 39 + `defaultLocalDbPath` resolves `%APPDATA%`/`Application Support`/XDG. Also 40 + carries an optional `localDisplayName` from `QUANTUM_LOCAL_DISPLAY_NAME`. 41 + - [x] 3.2 Make the modes mutually exclusive: `QUANTUM_MODE=local` together with 36 42 `ALLOWED_DIDS` or OAuth key configuration MUST be a hard configuration 37 - error, so a server can never half-apply the local relaxations. 38 - - [ ] 3.3 Extend `config.test.ts`: local mode loads with none of the server 39 - env vars; local + server config is rejected; server mode is unchanged. 43 + error, so a server can never half-apply the local relaxations. — Both 44 + trigger a hard error in `loadLocalConfig`. 45 + - [x] 3.3 Extend `config.test.ts`: local mode loads with none of the server env 46 + vars; local + server config is rejected; server mode is unchanged. — 4 new 47 + tests (8 total, all green). 40 48 41 49 ## 4. Local mode boot and no-login auth 42 50 43 - - [ ] 4.1 In `src/hooks.server.ts` `init`, branch on mode. In local mode: skip 44 - `initOAuthClient`; seed `upsertUser(db, 'did:local:self', <display name>)`; 45 - skip the `Deno.cron` registration. 46 - - [ ] 4.2 In `handle`, in local mode set `event.locals.user` to the local user 51 + - [x] 4.1 In `src/hooks.server.ts` `init`, branch on mode. In local mode: skip 52 + `initOAuthClient`; seed 53 + `upsertUser(db, 'did:local:self', <display name>)`; skip the `Deno.cron` 54 + registration. — Seeds when `localDisplayName` is set (the welcome flow 55 + seeds it otherwise); starts the local sync cadence. 56 + - [x] 4.2 In `handle`, in local mode set `event.locals.user` to the local user 47 57 unconditionally and never redirect to `/login`. Server mode is unchanged 48 - (including `add-mcp-server`'s bearer path). 49 - - [ ] 4.3 Read the display name from the first-launch flow (task 7); until set, 50 - block app routes behind the local setup screen rather than a login page. 51 - - [ ] 4.4 Tests: in local mode every protected route resolves as `did:local:self` 52 - with no cookie; manual categorization records the local actor and its 53 - display name surfaces in provenance; server-mode auth is untouched. 58 + (including `add-mcp-server`'s bearer path). — Split into `handleLocal` / 59 + `handleServer`; `/login` and `/welcome` redirect to `/` once set up. 60 + `/mcp` stays token-authenticated even locally. 61 + - [x] 4.3 Read the display name from the first-launch flow (task 7); until set, 62 + block app routes behind the local setup screen rather than a login page. — 63 + `handleLocal` redirects protected routes to `/welcome` when 64 + `did:local:self` is not yet seeded. (The `/welcome` screen itself is task 65 + 7.) 66 + - [x] 4.4 Tests: in local mode every protected route resolves as 67 + `did:local:self` with no cookie; manual categorization records the local 68 + actor and its display name surfaces in provenance; server-mode auth is 69 + untouched. — Verified live against a headless local-mode server (port 70 + 5174): `/` and `/ledger` → 200 no login, `/login` → 303 `/`, `/mcp` → 401 71 + without a token; the DB seeded `did:local:self` / "Graham". Server-mode 72 + auth unchanged (its full suite still green). Hook logic is verified live 73 + like the server-mode hooks, which are also not unit-tested (module 74 + singletons). 54 75 55 76 ## 5. Local sync cadence and loopback MCP mount 56 77 57 - - [ ] 5.1 In local mode, run `runSync(getDb())` once during `init` (catch-up), 58 - then on a `setInterval` at the configured interval while running. Honor the 59 - task 1.1 finding for the interval and for whether background firing is 60 - relied upon. Keep the server's daily `Deno.cron` path unchanged. 61 - - [ ] 5.2 If task 1.2 shows the server port is not reachable, start a loopback 78 + - [x] 5.1 In local mode, run `runSync(getDb())` once during `init` (catch-up), 79 + then on a `setInterval` at the configured interval while running. Honor 80 + the task 1.1 finding for the interval and for whether background firing is 81 + relied upon. Keep the server's daily `Deno.cron` path unchanged. — 82 + `startLocalSync`; 6-hour interval, fire-and-log on launch. Interval value 83 + to be revisited against spike 1.1 (background-timer behavior). 84 + - [x] 5.2 If task 1.2 shows the server port is not reachable, start a loopback 62 85 `Deno.serve` listener in local mode and mount `add-mcp-server`'s 63 86 transport-only MCP handler on it, requiring the same bearer token. If the 64 - port is reachable, skip this and document why. 65 - - [ ] 5.3 Write the loopback endpoint (and optionally a freshly minted token) to a 66 - well-known app-data location so a local agent host can be configured in one 67 - step. The token still goes through the normal scoped/revocable mint path. 68 - - [ ] 5.4 Tests/verification: a local agent reaches MCP over loopback with a valid 69 - token and is rejected without one; sync-on-launch brings data current after 70 - a simulated closed period. 87 + port is reachable, skip this and document why. — `startLoopbackMcp` binds 88 + `127.0.0.1:QUANTUM_LOCAL_MCP_PORT` (opt-in via env so the dev/server run 89 + isn't double-served) and reuses `handleMcpRequest`. Verified live: 90 + loopback `/mcp` → 401 without a token, full 15-tool access with one. 91 + Whether it is strictly needed vs. the SvelteKit route is spike 1.2 (needs 92 + the desktop binary); the fallback is proven functional either way. 93 + - [x] 5.3 Write the loopback endpoint (and optionally a freshly minted token) to 94 + a well-known app-data location so a local agent host can be configured in 95 + one step. The token still goes through the normal scoped/revocable mint 96 + path. — Writes `agent.json` (`{ mcpUrl }`) next to the database on 97 + loopback start. Deliberately writes the URL only, never a token: the user 98 + mints a scoped, revocable token in Settings, so no plaintext credential 99 + sits on disk. 100 + - [x] 5.4 Tests/verification: a local agent reaches MCP over loopback with a 101 + valid token and is rejected without one; sync-on-launch brings data 102 + current after a simulated closed period. — Loopback token gating verified 103 + live (401 without, 15 tools with). Sync-on-launch fires from `init` 104 + (observed attempting `runSync` on the headless local-mode boot); a genuine 105 + closed-period catch-up needs a real SimpleFIN connection, so its data 106 + effect is covered by the existing `sync` service tests. 71 107 72 108 ## 6. Remote mode thin client and OAuth handoff 73 109 74 110 - [ ] 6.1 Register a custom URL scheme for the desktop app (deep link). 75 - - [ ] 6.2 Remote-mode shell: point the webview at the user-provided server origin; 76 - start no embedded server and open no local database. 111 + - [ ] 6.2 Remote-mode shell: point the webview at the user-provided server 112 + origin; start no embedded server and open no local database. 77 113 - [ ] 6.3 Server side: add a handoff endpoint that, after a normal ATProto login 78 114 completed in the system browser, issues an opaque bearer **session** token 79 115 and redirects to the app's registered deep link carrying it. Reuse ··· 84 120 `Authorization: Bearer`, reusing `add-mcp-server`'s bearer path (widened 85 121 from API tokens to session tokens). 86 122 - [ ] 6.5 Tests: a handed-off token authenticates server requests as a bearer 87 - credential; re-presenting it at the handoff step is rejected; the app never 88 - registers as an OAuth client. 123 + credential; re-presenting it at the handoff step is rejected; the app 124 + never registers as an OAuth client. 89 125 90 126 ## 7. First-launch experience 91 127 92 128 - [ ] 7.1 Build the first-launch mode-selection screen: Local ("just me, on this 93 129 computer") vs Remote ("I have a Quantum server"), shell-less and calm per 94 130 DESIGN.md, persisting the choice to app-config. 95 - - [ ] 7.2 Local branch: prompt for a display name and seed the local user with it. 96 - - [ ] 7.3 Remote branch: collect the server address and initiate the OAuth handoff 97 - (task 6). 131 + - [ ] 7.2 Local branch: prompt for a display name and seed the local user with 132 + it. 133 + - [ ] 7.3 Remote branch: collect the server address and initiate the OAuth 134 + handoff (task 6). 98 135 - [ ] 7.4 Add the reset action (Settings) that clears app-config and returns to 99 136 the mode-selection screen on next launch. Confirm with a plain sentence 100 137 about what reset does and does not delete (local data stays on disk). 101 138 102 139 ## 8. Verification 103 140 104 - - [ ] 8.1 Run `deno task test` and `deno task check`. Confirm the server build and 105 - container image are unchanged (server-mode tests all green, no config 141 + - [ ] 8.1 Run `deno task test` and `deno task check`. Confirm the server build 142 + and container image are unchanged (server-mode tests all green, no config 106 143 change required for existing deployments). 107 - - [ ] 8.2 Local mode end to end: fresh launch → choose Local → set a display name 108 - → paste a SimpleFIN token → sync populates → categorize a transaction and 109 - confirm the display name in provenance → close and relaunch (starts in 144 + - [ ] 8.2 Local mode end to end: fresh launch → choose Local → set a display 145 + name → paste a SimpleFIN token → sync populates → categorize a transaction 146 + and confirm the display name in provenance → close and relaunch (starts in 110 147 Local, syncs on launch) → connect a local agent over loopback MCP and 111 148 categorize via a token, confirming `agent` provenance. 112 149 - [ ] 8.3 Remote mode end to end against a dev server: choose Remote → system- 113 - browser login → deep-link handoff → app authenticates as the logged-in user 114 - → no local database created. 150 + browser login → deep-link handoff → app authenticates as the logged-in 151 + user → no local database created. 115 152 - [ ] 8.4 Reset from each mode returns to the selection screen; local data files 116 153 remain on disk after a reset. 117 - - [ ] 8.5 Confirm a hosted server rejects `QUANTUM_MODE=local` combined with 154 + - [x] 8.5 Confirm a hosted server rejects `QUANTUM_MODE=local` combined with 118 155 server config, and that the default (no `QUANTUM_MODE`) behaves exactly as 119 - the current release. 156 + the current release. — Covered by `config.test.ts`: local + `ALLOWED_DIDS` 157 + or local + OAuth key is rejected; default (no `QUANTUM_MODE`) is server 158 + mode and the existing server-config tests are unchanged.
+2 -1
openspec/changes/add-mcp-server/README.md
··· 1 1 # add-mcp-server 2 2 3 - Expose Quantum's service layer to agents via an MCP server over Streamable HTTP, with bearer API tokens and a new 'agent' provenance source 3 + Expose Quantum's service layer to agents via an MCP server over Streamable HTTP, 4 + with bearer API tokens and a new 'agent' provenance source
+99 -90
openspec/changes/add-mcp-server/design.md
··· 1 1 ## Context 2 2 3 - Quantum's server core is transport-agnostic by construction (`src/lib/server/README.md`, 4 - design D6). The relevant current state: 3 + Quantum's server core is transport-agnostic by construction 4 + (`src/lib/server/README.md`, design D6). The relevant current state: 5 5 6 6 - **Services are pure-ish functions.** Every domain operation in 7 7 `src/lib/server/services/` takes typed inputs and a `DatabaseSync` handle and ··· 16 16 `ruleId`, `manual` requires an `actorDid`) and updates the denormalized 17 17 `transactions.category_id` cache in the same DB transaction (design D4). 18 18 - **Sessions are opaque tokens** (`sessions.ts`), delivered by HTTP-only cookie 19 - but documented as bearer-ready. The `handle` hook (`hooks.server.ts:47`) 20 - reads the cookie, resolves it to a user, sets `event.locals.user`, and 21 - redirects unauthenticated requests away from non-public paths. 19 + but documented as bearer-ready. The `handle` hook (`hooks.server.ts:47`) reads 20 + the cookie, resolves it to a user, sets `event.locals.user`, and redirects 21 + unauthenticated requests away from non-public paths. 22 22 - **The auth spec already anticipated this**: session tokens "SHALL NOT assume 23 23 cookie transport, so future native clients can present the same token as a 24 24 bearer credential." ··· 51 51 **Non-Goals:** 52 52 53 53 - Spec-compliant MCP OAuth. The MCP spec's HTTP auth story casts the resource 54 - server as an OAuth authorization server. Quantum is an ATProto OAuth *client*, 54 + server as an OAuth authorization server. Quantum is an ATProto OAuth _client_, 55 55 not an AS; standing up a full AS for a two-person household app is 56 56 disproportionate. Bearer tokens minted in-app are the deliberate trade — see 57 57 decision 3. ··· 69 69 70 70 **Decision:** The MCP server is a single web-standard request handler bound to 71 71 `WebStandardStreamableHTTPServerTransport`. It mounts today as `POST /mcp` via a 72 - SvelteKit `+server.ts`. The forthcoming desktop change mounts the *same* handler 72 + SvelteKit `+server.ts`. The forthcoming desktop change mounts the _same_ handler 73 73 on a loopback `Deno.serve` listener for local mode. The handler never reads a 74 74 cookie, a SvelteKit `event`, or an environment mode. 75 75 76 76 **Why:** The two modalities the product targets — remote (agent → hosted server) 77 77 and local (agent → desktop app) — are the same protocol at two addresses. An MCP 78 - handler is a pure function from an HTTP request to an HTTP response, so "the same 79 - code at two mounts" is achievable rather than aspirational. Encoding the 78 + handler is a pure function from an HTTP request to an HTTP response, so "the 79 + same code at two mounts" is achievable rather than aspirational. Encoding the 80 80 transport as a bare handler keeps local mode from ever needing a second 81 81 implementation, and keeps this change from having to know local mode exists. 82 82 83 83 **Alternative considered:** A stdio MCP binary that opens the SQLite file 84 84 directly for local mode. Rejected: it puts a second process on the same database 85 85 as the desktop app, and `initDb`'s process-wide singleton, the migration runner, 86 - and the sync scheduler all assume a single writer. A stdio *shim* that proxies 86 + and the sync scheduler all assume a single writer. A stdio _shim_ that proxies 87 87 stdio ⇄ localhost HTTP remains available later purely for agent-host UX (hosts 88 88 that only speak stdio), but it is a proxy over this handler, not a second core. 89 89 ··· 109 109 endpoint accepts only `Authorization: Bearer <token>`. 110 110 111 111 **Why:** This reuses the opaque-token model design D6 already blessed and keeps 112 - the credential surface uniform. Standing up an OAuth AS to satisfy the MCP spec's 113 - HTTP auth section would be weeks of PKCE/PAR/consent machinery to protect two 114 - users who can paste a token in ten seconds. The cost of the trade is that MCP 115 - hosts offering "add server by URL with automatic OAuth" won't complete a flow — 116 - users paste a token instead. For this audience that is acceptable, and stated 117 - plainly rather than hidden. 112 + the credential surface uniform. Standing up an OAuth AS to satisfy the MCP 113 + spec's HTTP auth section would be weeks of PKCE/PAR/consent machinery to protect 114 + two users who can paste a token in ten seconds. The cost of the trade is that 115 + MCP hosts offering "add server by URL with automatic OAuth" won't complete a 116 + flow — users paste a token instead. For this audience that is acceptable, and 117 + stated plainly rather than hidden. 118 118 119 - **Storage as a hash, not plaintext:** a leaked database should not hand over live 120 - agent credentials, and sessions already set the precedent that the server holds 121 - opaque handles, not reversible secrets. `last_used_at` turns a forgotten token 122 - into something visible and revocable rather than an invisible standing grant. 119 + **Storage as a hash, not plaintext:** a leaked database should not hand over 120 + live agent credentials, and sessions already set the precedent that the server 121 + holds opaque handles, not reversible secrets. `last_used_at` turns a forgotten 122 + token into something visible and revocable rather than an invisible standing 123 + grant. 123 124 124 125 ### 4. Bearer resolution lives in the hook, beside cookie resolution 125 126 126 127 **Decision:** `handle` in `hooks.server.ts` resolves auth in one place: if a 127 - session cookie is present, resolve it as today; else if an `Authorization: 128 - Bearer` header is present, verify it against `api_tokens` and attach the 129 - resulting principal to `event.locals`. `/mcp` is not added to `PUBLIC_PATHS`, so 130 - an unauthenticated MCP request is rejected by the existing guard. 128 + session cookie is present, resolve it as today; else if an 129 + `Authorization: 130 + Bearer` header is present, verify it against `api_tokens` and 131 + attach the resulting principal to `event.locals`. `/mcp` is not added to 132 + `PUBLIC_PATHS`, so an unauthenticated MCP request is rejected by the existing 133 + guard. 131 134 132 135 **Why:** One authentication chokepoint is easier to keep correct than two. 133 136 Putting bearer resolution beside cookie resolution means every protected route 134 137 automatically accepts a token too, which is exactly what the desktop remote mode 135 - will need for *session* bearer tokens later — this change builds that seam for 138 + will need for _session_ bearer tokens later — this change builds that seam for 136 139 API tokens, and the companion change widens it to OAuth-issued session tokens. 137 140 138 141 **The principal is richer than today's `event.locals.user`.** A cookie yields a 139 - user; a token yields a token identity that *may* carry a user DID and always 142 + user; a token yields a token identity that _may_ carry a user DID and always 140 143 carries a scope. `event.locals` gains an optional `apiToken` (id, scope, 141 144 userDid?) alongside `user`, so a route/tool can enforce scope and attribute 142 145 authorship correctly. Web routes ignore it and keep using `user`. ··· 144 147 ### 5. `agent` is a fourth event source, attributed to a token 145 148 146 149 **Decision:** `EventSource` becomes 147 - `'rule' | 'manual' | 'reconciliation' | 'agent'`. 148 - `categorization_events` gains a nullable `api_token_id` FK. 149 - `appendCategorizationEvent` enforces that an `agent` event carries an 150 - `apiTokenId` (and no `actorDid`), mirroring the existing invariant that a 151 - `manual` event carries an `actorDid`. A new `categorizeByAgent(db, txId, 152 - categoryId, apiTokenId)` parallels `categorizeManually`. 150 + `'rule' | 'manual' | 'reconciliation' | 'agent'`. `categorization_events` gains 151 + a nullable `api_token_id` FK. `appendCategorizationEvent` enforces that an 152 + `agent` event carries an `apiTokenId` (and no `actorDid`), mirroring the 153 + existing invariant that a `manual` event carries an `actorDid`. A new 154 + `categorizeByAgent(db, txId, 155 + categoryId, apiTokenId)` parallels 156 + `categorizeManually`. 153 157 154 158 **Why:** The product's first principle is that every categorization traces to a 155 159 rule or a person. An agent is neither, and both available shortcuts are wrong: ··· 157 161 person's handle on a categorization a person never made — a quiet lie in the one 158 162 place the product promises the truth; leaving it anonymous would break the 159 163 "always traces to something" guarantee. A distinct source attributed to the 160 - token is the honest encoding: the badge reads `🤖 <token label>`, the popover can 161 - name the human the token belongs to, and the audit trail is exact. 164 + token is the honest encoding: the badge reads `🤖 <token label>`, the popover 165 + can name the human the token belongs to, and the audit trail is exact. 162 166 163 167 **Manual still outranks agent.** Agent categorization uses the same "humans 164 168 outrank everything" rule already in force — an `agent` event can set a category ··· 179 183 180 184 Read: 181 185 182 - | Tool | Service | 183 - | --- | --- | 184 - | `list_accounts` | `listAccounts` + `listStaleAccounts` | 185 - | `list_transactions` | `listLedger`, `listMonths` | 186 - | `get_transaction_history` | `listEvents` | 187 - | `get_monthly_report` | `monthlyReport` + `pendingStats` | 188 - | `get_net_worth` | `netWorthSeries` | 189 - | `list_categories` | `listCategories` | 190 - | `list_rules` | `listRules` + `ruleMatchHealth` | 191 - | `probe_rule` | `probeRule` / `countRuleMatches` | 192 - | `get_sync_status` | `getLastSync` + `getConnectionErrors` | 186 + | Tool | Service | 187 + | ------------------------- | ------------------------------------- | 188 + | `list_accounts` | `listAccounts` + `listStaleAccounts` | 189 + | `list_transactions` | `listLedger`, `listMonths` | 190 + | `get_transaction_history` | `listEvents` | 191 + | `get_monthly_report` | `monthlyReport` + `pendingStats` | 192 + | `get_net_worth` | `netWorthSeries` | 193 + | `list_categories` | `listCategories` | 194 + | `list_rules` | `listRules` + `ruleMatchHealth` | 195 + | `probe_rule` | `probeRule` / `countRuleMatches` | 196 + | `get_sync_status` | `getLastSync` + `getConnectionErrors` | 193 197 194 198 Write: 195 199 196 - | Tool | Service | 197 - | --- | --- | 200 + | Tool | Service | 201 + | ------------------------ | --------------------------------------- | 198 202 | `categorize_transaction` | `categorizeByAgent` (`source: 'agent'`) | 199 - | `create_category` | `createCategory` | 200 - | `create_rule` | `createRule` | 201 - | `set_rule_active` | `setRuleActive` | 202 - | `apply_rules` | `applyRulesToUncategorized` | 203 - | `trigger_sync` | `runSync` | 203 + | `create_category` | `createCategory` | 204 + | `create_rule` | `createRule` | 205 + | `set_rule_active` | `setRuleActive` | 206 + | `apply_rules` | `applyRulesToUncategorized` | 207 + | `trigger_sync` | `runSync` | 204 208 205 209 **Why these and not others:** the read set makes "where did our money go this 206 - month, and are we gaining ground?" fully answerable — the product's two 207 - headline questions. The write set targets the toil (categorization) and its 208 - force multiplier (rules), plus the freshness lever (sync). `probe_rule` is 209 - paired with `create_rule` deliberately: an agent should dry-run a pattern's blast 210 - radius before committing it, and the tool descriptions will say so. 210 + month, and are we gaining ground?" fully answerable — the product's two headline 211 + questions. The write set targets the toil (categorization) and its force 212 + multiplier (rules), plus the freshness lever (sync). `probe_rule` is paired with 213 + `create_rule` deliberately: an agent should dry-run a pattern's blast radius 214 + before committing it, and the tool descriptions will say so. 211 215 212 216 **Why not more:** account hide/classify and category rename are settings-surface 213 - polish, easy to add once the pattern exists; CSV import is an interactive wizard; 214 - `claimSetupToken` handles a bank credential and stays human-only; nothing touches 215 - sessions or users. The catalog is intentionally small and boring — every tool is 216 - a function that already has tests. 217 + polish, easy to add once the pattern exists; CSV import is an interactive 218 + wizard; `claimSetupToken` handles a bank credential and stays human-only; 219 + nothing touches sessions or users. The catalog is intentionally small and boring 220 + — every tool is a function that already has tests. 217 221 218 222 ### 7. Scope enforcement at the tool boundary 219 223 ··· 230 234 231 235 - **A bearer token is a durable key to the whole ledger** → shown once, stored 232 236 hashed, scoped, individually revocable, and stamped with `last_used_at` so a 233 - stale token is visible. The write surface excludes account creation, connection 234 - setup, and hard deletion, so the blast radius of a leaked `readwrite` token is 235 - bounded to reversible, auditable categorization changes. 237 + stale token is visible. The write surface excludes account creation, 238 + connection setup, and hard deletion, so the blast radius of a leaked 239 + `readwrite` token is bounded to reversible, auditable categorization changes. 236 240 237 241 - **Non-spec-compliant MCP auth** means some hosts' automatic OAuth "add by URL" 238 242 flows won't work; users paste a token → accepted trade for a two-person app, 239 243 stated in the docs rather than papered over. If a host strictly requires the 240 244 OAuth flow, that host is unsupported for now. 241 245 242 - - **An agent miscategorizes at scale** → every agent write is an `agent` event in 243 - the append-only log, attributed to a named token, and never overwrites a manual 244 - choice. Undoing a bad batch is re-categorizing, and the provenance trail shows 245 - exactly which token did what and when. 246 + - **An agent miscategorizes at scale** → every agent write is an `agent` event 247 + in the append-only log, attributed to a named token, and never overwrites a 248 + manual choice. Undoing a bad batch is re-categorizing, and the provenance 249 + trail shows exactly which token did what and when. 246 250 247 251 - **Two writers to the database in future local mode** (desktop app + an MCP 248 252 mount) → avoided by decision 1: the local mount is in-process on the same 249 253 `DatabaseSync` handle, not a second process. This change, running only as a 250 254 SvelteKit route, has a single writer regardless. 251 255 252 - - **The SDK's web-standard transport is newer than its Node transport** → de-risk 253 - with an early spike (task 1) that stands up the transport in a `+server.ts` 254 - under Deno and completes one `initialize` + `tools/list` round-trip before any 255 - tool is wired. 256 + - **The SDK's web-standard transport is newer than its Node transport** → 257 + de-risk with an early spike (task 1) that stands up the transport in a 258 + `+server.ts` under Deno and completes one `initialize` + `tools/list` 259 + round-trip before any tool is wired. 256 260 257 261 ## Migration Plan 258 262 ··· 263 267 UNIQUE), `scope` NOT NULL CHECK in (`read`, `readwrite`), `user_did` 264 268 (nullable, FK to users), `created_at` NOT NULL, `last_used_at` (nullable). 265 269 - `ALTER TABLE categorization_events ADD COLUMN api_token_id INTEGER REFERENCES 266 - api_tokens (id)` — nullable; set only on `agent` events. 270 + api_tokens (id)` 271 + — nullable; set only on `agent` events. 267 272 - Recreate `categorization_events` with a widened `source` CHECK admitting 268 273 `agent` (SQLite cannot alter a CHECK in place; follow the existing 269 274 table-rebuild pattern used elsewhere in migrations, preserving all rows and 270 275 the append-only guarantee). If the current schema declares `source` without a 271 276 CHECK constraint, this step is a no-op beyond documentation. 272 277 273 - Every existing categorization event reads back with `api_token_id IS NULL`, which 274 - is correct — none were agent-sourced. Forward-only, no data rewrite of values. 278 + Every existing categorization event reads back with `api_token_id IS NULL`, 279 + which is correct — none were agent-sourced. Forward-only, no data rewrite of 280 + values. 275 281 276 282 ## Resolved During Implementation 277 283 278 - - **Revocation must be soft, not a hard delete.** `categorization_events.api_token_id` 279 - is a foreign key to `api_tokens`, so once a token has authored even one agent 280 - categorization, `DELETE FROM api_tokens` fails the FK constraint — and forcing 281 - it would orphan the provenance the whole feature exists to preserve. Revoke 282 - therefore stamps a `revoked_at` column: `verifyToken` refuses a revoked token 283 - immediately (satisfying "revocation takes effect immediately"), `listTokens` 284 - hides it from the management list, and the row survives so its label keeps 285 - resolving in the append-only event history. Found by revoking a token that had 286 - categorized a live transaction; the migration and service were updated and a 287 - regression test added. `api_tokens` gains `revoked_at TEXT` (nullable). 284 + - **Revocation must be soft, not a hard delete.** 285 + `categorization_events.api_token_id` is a foreign key to `api_tokens`, so once 286 + a token has authored even one agent categorization, `DELETE FROM api_tokens` 287 + fails the FK constraint — and forcing it would orphan the provenance the whole 288 + feature exists to preserve. Revoke therefore stamps a `revoked_at` column: 289 + `verifyToken` refuses a revoked token immediately (satisfying "revocation 290 + takes effect immediately"), `listTokens` hides it from the management list, 291 + and the row survives so its label keeps resolving in the append-only event 292 + history. Found by revoking a token that had categorized a live transaction; 293 + the migration and service were updated and a regression test added. 294 + `api_tokens` gains `revoked_at TEXT` (nullable). 288 295 289 296 ## Open Questions 290 297 291 - - **Token label uniqueness** — should two tokens be allowed the same label? Leaning 292 - yes (label is a human hint, `id` is identity), resolved at implementation. 293 - - **Rate limiting `trigger_sync`** — `runSync` hits SimpleFIN; an agent looping on 294 - it could hammer the Bridge. A minimum interval (reuse the daily-sync rationale) 295 - may be worth enforcing in the tool wrapper. Decide when wiring the tool. 298 + - **Token label uniqueness** — should two tokens be allowed the same label? 299 + Leaning yes (label is a human hint, `id` is identity), resolved at 300 + implementation. 301 + - **Rate limiting `trigger_sync`** — `runSync` hits SimpleFIN; an agent looping 302 + on it could hammer the Bridge. A minimum interval (reuse the daily-sync 303 + rationale) may be worth enforcing in the tool wrapper. Decide when wiring the 304 + tool.
+15 -13
openspec/changes/add-mcp-server/specs/auth/spec.md
··· 5 5 The system SHALL persist application sessions and OAuth client state (state 6 6 store, session store) in SQLite. Sessions SHALL be identified by opaque tokens, 7 7 delivered to the web frontend via HTTP-only cookie; the token format SHALL NOT 8 - assume cookie transport, so future native clients can present the same token as a 9 - bearer credential. The system SHALL additionally accept a bearer API token: a 10 - request that presents no session cookie but carries a valid `Authorization: 11 - Bearer` API token SHALL be authenticated as that token's principal, carrying the 12 - token's scope and, where present, the creating user's DID. Bearer resolution 13 - SHALL occur in the same request-handling chokepoint as cookie resolution, and 14 - SHALL NOT alter cookie-session behavior. Every route except login, the OAuth 15 - callback, client metadata, and JWKS SHALL require either a valid session or a 16 - valid API token. Users SHALL be able to log out, which destroys the application 17 - session. 8 + assume cookie transport, so future native clients can present the same token as 9 + a bearer credential. The system SHALL additionally accept a bearer API token: a 10 + request that presents no session cookie but carries a valid 11 + `Authorization: 12 + Bearer` API token SHALL be authenticated as that token's 13 + principal, carrying the token's scope and, where present, the creating user's 14 + DID. Bearer resolution SHALL occur in the same request-handling chokepoint as 15 + cookie resolution, and SHALL NOT alter cookie-session behavior. Every route 16 + except login, the OAuth callback, client metadata, and JWKS SHALL require either 17 + a valid session or a valid API token. Users SHALL be able to log out, which 18 + destroys the application session. 18 19 19 20 #### Scenario: Unauthenticated access to a protected route 20 21 21 - - **WHEN** a request without a valid session cookie and without a valid API token 22 - targets any protected route 22 + - **WHEN** a request without a valid session cookie and without a valid API 23 + token targets any protected route 23 24 - **THEN** the system redirects to the login page or, for an API endpoint, 24 25 rejects the request as unauthorized 25 26 ··· 33 34 #### Scenario: Cookie session unchanged 34 35 35 36 - **WHEN** a browser request presents a valid session cookie 36 - - **THEN** it is authenticated exactly as before, regardless of any bearer header 37 + - **THEN** it is authenticated exactly as before, regardless of any bearer 38 + header 37 39 38 40 #### Scenario: Logout 39 41
+14 -12
openspec/changes/add-mcp-server/specs/mcp-server/spec.md
··· 19 19 20 20 #### Scenario: Authenticated tools listing 21 21 22 - - **WHEN** a client presents a valid token and issues an MCP `tools/list` request 22 + - **WHEN** a client presents a valid token and issues an MCP `tools/list` 23 + request 23 24 - **THEN** the system returns the catalog of tools available to that token's 24 25 scope 25 26 ··· 27 28 28 29 - **WHEN** the MCP handler processes a request 29 30 - **THEN** it derives the caller's identity solely from the bearer token, reads 30 - no session cookie, and depends on no web-view state, so the same handler can be 31 - mounted outside the web router 31 + no session cookie, and depends on no web-view state, so the same handler can 32 + be mounted outside the web router 32 33 33 34 ### Requirement: Bearer API tokens 34 35 ··· 59 60 #### Scenario: Token labelled for recognition 60 61 61 62 - **WHEN** a user views their API tokens in Settings 62 - - **THEN** each token is listed by its label, scope, creation time, and last-used 63 - time, and the token value is not shown 63 + - **THEN** each token is listed by its label, scope, creation time, and 64 + last-used time, and the token value is not shown 64 65 65 66 ### Requirement: Read tool catalog 66 67 67 68 The system SHALL expose read-only tools, available to any valid token regardless 68 - of scope, that surface the existing service layer without modifying data: listing 69 - accounts with balances and staleness, listing and filtering transactions, 70 - retrieving a transaction's full categorization history, producing a monthly 71 - income-versus-expense report, retrieving the net-worth series, listing 69 + of scope, that surface the existing service layer without modifying data: 70 + listing accounts with balances and staleness, listing and filtering 71 + transactions, retrieving a transaction's full categorization history, producing 72 + a monthly income-versus-expense report, retrieving the net-worth series, listing 72 73 categories, listing rules with their match health, probing a prospective or 73 74 existing rule without applying it, and reporting sync status and connection 74 75 errors. A read tool SHALL NOT modify any transaction, event, rule, category, ··· 95 96 96 97 The system SHALL expose write tools — categorizing a transaction, creating a 97 98 category, creating a rule, activating or deactivating a rule, applying rules to 98 - uncategorized transactions, and triggering a sync — and SHALL permit them only to 99 - a token whose scope is `readwrite`. A write tool invoked with a `read`-scope 99 + uncategorized transactions, and triggering a sync — and SHALL permit them only 100 + to a token whose scope is `readwrite`. A write tool invoked with a `read`-scope 100 101 token SHALL be refused without effect. The write catalog SHALL NOT include 101 102 creating accounts, claiming SimpleFIN setup tokens, importing CSV files, or 102 103 hard-deleting any record. 103 104 104 105 #### Scenario: Read token refused a write tool 105 106 106 - - **WHEN** a client authenticating with a `read`-scope token invokes a write tool 107 + - **WHEN** a client authenticating with a `read`-scope token invokes a write 108 + tool 107 109 - **THEN** the system refuses the call and makes no change 108 110 109 111 #### Scenario: Readwrite token categorizes
+90 -77
openspec/changes/add-mcp-server/tasks.md
··· 4 4 minimal `src/routes/mcp/+server.ts` that binds `McpServer` to 5 5 `WebStandardStreamableHTTPServerTransport` in stateless mode and registers 6 6 one trivial read tool. 7 - - [x] 1.2 Confirm under Deno (both `deno task dev` via Vite and a `deno task 8 - build` node-adapter build) that an MCP client completes `initialize` + 9 - `tools/list` + one `tools/call` round-trip against `/mcp`, with no Node 10 - `IncomingMessage`/`ServerResponse` shimming required. Confirm `deno task 11 - check` stays green with the new dependency (watch for the JSR-vs-npm 12 - resolution trap that bit `csv-parse`). 7 + - [x] 1.2 Confirm under Deno (both `deno task dev` via Vite and a 8 + `deno task 9 + build` node-adapter build) that an MCP client completes 10 + `initialize` + `tools/list` + one `tools/call` round-trip against `/mcp`, 11 + with no Node `IncomingMessage`/`ServerResponse` shimming required. Confirm 12 + `deno task 13 + check` stays green with the new dependency (watch for the 14 + JSR-vs-npm resolution trap that bit `csv-parse`). 13 15 14 16 **Spike findings (resolved):** SDK `1.29.0`. Transport is 15 17 `WebStandardStreamableHTTPServerTransport` from ··· 34 36 UNIQUE), `scope` NOT NULL CHECK in (`read`, `readwrite`), `user_did` 35 37 (nullable, FK to users), `created_at` (NOT NULL), `last_used_at` 36 38 (nullable). — `migrations/006_api_tokens.sql`. 37 - - [x] 2.2 In the same migration, add `api_token_id INTEGER REFERENCES api_tokens 38 - (id)` (nullable) to `categorization_events`, and widen the `source` value 39 - set to admit `agent`. If `source` is constrained by a CHECK, rebuild the 40 - table following the existing migration table-rebuild pattern, preserving 41 - every row and the append-only guarantee; if it is unconstrained, document 42 - that no rebuild is needed. — `source` had a CHECK; table rebuilt 43 - (leaf table, no incoming FKs) with a new `source != 'agent' OR 44 - api_token_id IS NOT NULL` invariant. No prior table-rebuild pattern 45 - existed in migrations; this is the first. 46 - - [x] 2.3 Extend `src/lib/server/db.test.ts` to assert the migration applies to a 47 - populated database, that existing `categorization_events` read back with 39 + - [x] 2.2 In the same migration, add 40 + `api_token_id INTEGER REFERENCES api_tokens 41 + (id)` (nullable) to 42 + `categorization_events`, and widen the `source` value set to admit 43 + `agent`. If `source` is constrained by a CHECK, rebuild the table 44 + following the existing migration table-rebuild pattern, preserving every 45 + row and the append-only guarantee; if it is unconstrained, document that 46 + no rebuild is needed. — `source` had a CHECK; table rebuilt (leaf table, 47 + no incoming FKs) with a new 48 + `source != 'agent' OR 49 + api_token_id IS NOT NULL` invariant. No prior 50 + table-rebuild pattern existed in migrations; this is the first. 51 + - [x] 2.3 Extend `src/lib/server/db.test.ts` to assert the migration applies to 52 + a populated database, that existing `categorization_events` read back with 48 53 `api_token_id IS NULL`, and that an `agent`-source event inserts. Also 49 54 relaxed the csv test's brittle exact-migration-count assertion to a 50 55 `schema_version` membership check (it broke when 006 was added, and would ··· 52 57 53 58 ## 3. API token service 54 59 55 - - [x] 3.1 Create `src/lib/server/services/api-tokens.ts`: `mintToken(db, label, 56 - scope, userDid?)` returning the one-time plaintext plus the stored record; 57 - persist only the hash (hash the `randomBytes(32)` value; do not store 58 - plaintext). `verifyToken(db, plaintext)` returning a principal (`{ id, 59 - scope, userDid }`) or null, and touching `last_used_at` on success. 60 - `listTokens(db)`, `revokeToken(db, id)`. — SHA-256 over a `qtm_`-prefixed 61 - 256-bit random value; fast hash is sufficient for a high-entropy secret. 60 + - [x] 3.1 Create `src/lib/server/services/api-tokens.ts`: 61 + `mintToken(db, label, 62 + scope, userDid?)` returning the one-time 63 + plaintext plus the stored record; persist only the hash (hash the 64 + `randomBytes(32)` value; do not store plaintext). 65 + `verifyToken(db, plaintext)` returning a principal 66 + (`{ id, 67 + scope, userDid }`) or null, and touching `last_used_at` on 68 + success. `listTokens(db)`, `revokeToken(db, id)`. — SHA-256 over a 69 + `qtm_`-prefixed 256-bit random value; fast hash is sufficient for a 70 + high-entropy secret. 62 71 - [x] 3.2 Relative imports within `src/lib/server/` only, so the service and its 63 72 test run under plain `deno test`. Add `api-tokens.test.ts`: mint→verify 64 73 round-trip, hash-not-plaintext storage, revoked token fails verify, ··· 75 84 `/mcp` a clean 401 (not the browser login redirect) when unauthenticated. 76 85 - [x] 4.2 Update `app.d.ts` `App.Locals` with the optional `apiToken` principal. 77 86 — Imports `TokenPrincipal` from the service. 78 - - [x] 4.3 Confirm cookie-session routes are entirely unaffected (a request with a 79 - cookie ignores any bearer header; a request with neither is redirected/ 87 + - [x] 4.3 Confirm cookie-session routes are entirely unaffected (a request with 88 + a cookie ignores any bearer header; a request with neither is redirected/ 80 89 rejected as today). — Verified live over HTTP: unauthenticated `/mcp` → 81 90 401, valid bearer → 200 initialize, bad bearer → 401, `/ledger` without a 82 91 cookie → 303 `/login` (unchanged). ··· 89 98 (mirroring the `manual`-requires-`actorDid` invariant). Persist 90 99 `api_token_id` in the event insert. 91 100 - [x] 5.2 Add `categorizeByAgent(db, transactionId, categoryId, apiTokenId)` 92 - paralleling `categorizeManually`, and extend the manual-outranks rule so an 93 - agent write refuses a transaction whose latest event is `manual`. — Returns 94 - `boolean` (false = refused because a manual event is latest); tool wrapper 95 - (task 6) surfaces the refusal to the agent. 101 + paralleling `categorizeManually`, and extend the manual-outranks rule so 102 + an agent write refuses a transaction whose latest event is `manual`. — 103 + Returns `boolean` (false = refused because a manual event is latest); tool 104 + wrapper (task 6) surfaces the refusal to the agent. 96 105 - [x] 5.3 Extend `listEvents` selection and the `CategorizationEvent` shape to 97 106 carry the agent token's label (join `api_tokens`) for provenance display. 98 107 — Added `apiTokenId` and `actorTokenLabel` to the event shape. 99 - - [x] 5.4 Tests in `categorization.test.ts`: an `agent` event requires a token id; 100 - an agent write over a `manual` latest event is refused; over a `rule` or 101 - empty latest event it succeeds; history lists the agent event with its 108 + - [x] 5.4 Tests in `categorization.test.ts`: an `agent` event requires a token 109 + id; an agent write over a `manual` latest event is refused; over a `rule` 110 + or empty latest event it succeeds; history lists the agent event with its 102 111 token label. — New file, 5 tests; full suite 108 passed, check green. 103 112 104 113 ## 6. MCP server and tool catalog 105 114 106 - - [x] 6.1 Create the MCP server module wiring the tool catalog to services. Group 107 - tools by required scope; read the authenticating principal's scope from 108 - `event.locals.apiToken` and refuse write tools to a `read` token at 115 + - [x] 6.1 Create the MCP server module wiring the tool catalog to services. 116 + Group tools by required scope; read the authenticating principal's scope 117 + from `event.locals.apiToken` and refuse write tools to a `read` token at 109 118 dispatch, without reaching the service. — `src/lib/server/mcp/server.ts`; 110 - `buildMcpServer(db, principal)` closes over the principal per request; write 111 - tools call a `requireWrite()` guard that returns an error result for `read` 112 - scope. All 15 tools are always listed (a read token sees them, is refused 113 - execution) per the spec. 119 + `buildMcpServer(db, principal)` closes over the principal per request; 120 + write tools call a `requireWrite()` guard that returns an error result for 121 + `read` scope. All 15 tools are always listed (a read token sees them, is 122 + refused execution) per the spec. 114 123 - [x] 6.2 Implement the nine read tools as thin wrappers: `list_accounts`, 115 124 `list_transactions`, `get_transaction_history`, `get_monthly_report`, 116 125 `get_net_worth`, `list_categories`, `list_rules`, `probe_rule`, ··· 120 129 `create_rule`, `set_rule_active`, `apply_rules`, `trigger_sync`. In each 121 130 tool's description, direct the agent to `probe_rule` before `create_rule`. 122 131 — `create_rule` uses `principal.userDid` as creator; errors clearly if the 123 - token has no bound user (the local-mode case this change doesn't yet serve). 124 - - [x] 6.4 Consider a minimum interval on `trigger_sync` in its wrapper so an agent 125 - loop cannot hammer the SimpleFIN Bridge (reuse the daily-sync rationale); 126 - decide and document inline. — 15-minute minimum; a too-soon call returns a 127 - `skipped` result naming when the last sync ran. 132 + token has no bound user (the local-mode case this change doesn't yet 133 + serve). 134 + - [x] 6.4 Consider a minimum interval on `trigger_sync` in its wrapper so an 135 + agent loop cannot hammer the SimpleFIN Bridge (reuse the daily-sync 136 + rationale); decide and document inline. — 15-minute minimum; a too-soon 137 + call returns a `skipped` result naming when the last sync ran. 128 138 - [x] 6.5 Replace the spike route with the real `src/routes/mcp/+server.ts`: a 129 139 thin adapter handing the request to the transport-bound handler. Keep the 130 - handler free of SvelteKit/cookie reads so the desktop change can remount it. 131 - — Route reads `locals.apiToken` (set by the hook) and delegates to 132 - `handleMcpRequest`; the handler itself takes only `(request, db, principal)`. 133 - - [x] 6.6 Tests: a `read` token lists all tools but is refused each 134 - write tool; a `readwrite` token categorizes a transaction and the resulting 135 - event is `agent`-sourced with the token id; `probe_rule` mutates nothing. 136 - — `mcp/server.test.ts` drives the real transport in-process via 140 + handler free of SvelteKit/cookie reads so the desktop change can remount 141 + it. — Route reads `locals.apiToken` (set by the hook) and delegates to 142 + `handleMcpRequest`; the handler itself takes only 143 + `(request, db, principal)`. 144 + - [x] 6.6 Tests: a `read` token lists all tools but is refused each write tool; 145 + a `readwrite` token categorizes a transaction and the resulting event is 146 + `agent`-sourced with the token id; `probe_rule` mutates nothing. — 147 + `mcp/server.test.ts` drives the real transport in-process via 137 148 `handleMcpRequest` + constructed `Request`s; 4 tests, all green. 138 149 139 150 ## 7. Settings: connect an agent 140 151 141 - - [x] 7.1 Add a Settings section "Connect an agent": list existing tokens (label, 142 - scope, created, last used) and a mint form (label + scope). On mint, show 143 - the token value once with a copy affordance and a plain caution that it 144 - will not be shown again. — Verified live: the mint action returns the 145 - one-time token + label; the reveal block shows it with a Copy button. 146 - - [x] 7.2 Add the revoke action with a confirmation naming the token label. 147 - — Revoke is soft (see design's Resolved-During-Implementation note); the 152 + - [x] 7.1 Add a Settings section "Connect an agent": list existing tokens 153 + (label, scope, created, last used) and a mint form (label + scope). On 154 + mint, show the token value once with a copy affordance and a plain caution 155 + that it will not be shown again. — Verified live: the mint action returns 156 + the one-time token + label; the reveal block shows it with a Copy button. 157 + - [x] 7.2 Add the revoke action with a confirmation naming the token label. — 158 + Revoke is soft (see design's Resolved-During-Implementation note); the 148 159 row's label survives for provenance. Verified the action removes the token 149 160 from the live list. 150 161 - [x] 7.3 Render an empty state (one sentence, one action) for no-tokens-yet. 151 162 Style per DESIGN.md: monospace for the token value, tabular numerals on 152 163 timestamps, no new red unless data is at risk. — The MCP URL and token 153 - value use `--font-mono`; timestamps carry `tnum`; the reveal uses a neutral 154 - `--bg-sunken` panel, no alarm color. 164 + value use `--font-mono`; timestamps carry `tnum`; the reveal uses a 165 + neutral `--bg-sunken` panel, no alarm color. 155 166 156 167 ## 8. Verification 157 168 ··· 162 173 uncategorized transactions, probe a pattern, create a rule, categorize a 163 174 handful of transactions, and confirm in the web ledger that each shows an 164 175 `agent` provenance badge with the token label — and that a manually 165 - categorized transaction is left untouched by an agent attempt. — Driven via 166 - real MCP JSON-RPC over the live `/mcp` route: `initialize`, `tools/list` 167 - (15), read tools (accounts, net worth, sync status, list transactions, 168 - list categories), and `categorize_transaction` on a live uncategorized 169 - row, then `get_transaction_history` confirmed `source: agent` with the 170 - token label and no actor DID. Rule creation, probe, and manual-outranks are 171 - covered by the in-process integration tests (`mcp/server.test.ts`, 172 - `categorization.test.ts`). 173 - - [x] 8.3 Confirm a `read`-scope token can perform every read tool and is refused 174 - every write tool. — Verified live: a read token reads `list_categories` 175 - (no error) and is refused `categorize_transaction` with the read-write 176 - message; the in-process test refuses all six write tools for a read token. 177 - - [x] 8.4 Revoke the token mid-session and confirm the next tool call is rejected. 178 - — Verified live: an authenticated `get_net_worth` returned 200, the token 179 - was soft-revoked, and the identical next call returned 401. 176 + categorized transaction is left untouched by an agent attempt. — Driven 177 + via real MCP JSON-RPC over the live `/mcp` route: `initialize`, 178 + `tools/list` (15), read tools (accounts, net worth, sync status, list 179 + transactions, list categories), and `categorize_transaction` on a live 180 + uncategorized row, then `get_transaction_history` confirmed 181 + `source: agent` with the token label and no actor DID. Rule creation, 182 + probe, and manual-outranks are covered by the in-process integration tests 183 + (`mcp/server.test.ts`, `categorization.test.ts`). 184 + - [x] 8.3 Confirm a `read`-scope token can perform every read tool and is 185 + refused every write tool. — Verified live: a read token reads 186 + `list_categories` (no error) and is refused `categorize_transaction` with 187 + the read-write message; the in-process test refuses all six write tools 188 + for a read token. 189 + - [x] 8.4 Revoke the token mid-session and confirm the next tool call is 190 + rejected. — Verified live: an authenticated `get_net_worth` returned 200, 191 + the token was soft-revoked, and the identical next call returned 401. 180 192 - [x] 8.5 Confirm the web app is unchanged for cookie users: login, categorize, 181 193 logout all behave as before. — The hook consults a bearer token only when 182 194 no cookie session resolves, so the cookie path is structurally untouched; 183 195 verified a cookie session renders `/settings` (including the new section) 184 196 and that a request with neither cookie nor token still redirects `/ledger` 185 197 → `/login`. Full OAuth login/logout not driven (needs a live ATProto 186 - provider); the cookie-resolution code is unchanged from before this change. 198 + provider); the cookie-resolution code is unchanged from before this 199 + change.
+4
src/app.d.ts
··· 26 26 schedule: string, 27 27 handler: () => void | Promise<void>, 28 28 ): void; 29 + serve( 30 + options: { port: number; hostname?: string }, 31 + handler: (request: Request) => Response | Promise<Response>, 32 + ): { finished: Promise<void>; shutdown(): Promise<void> }; 29 33 }; 30 34 } 31 35
+152 -14
src/hooks.server.ts
··· 1 1 import { building } from "$app/environment"; 2 - import { type Handle, redirect, type ServerInit } from "@sveltejs/kit"; 2 + import { 3 + type Handle, 4 + redirect, 5 + type RequestEvent, 6 + type ServerInit, 7 + } from "@sveltejs/kit"; 3 8 import { getConfig } from "$lib/server/config"; 4 9 import { getDb, initDb } from "$lib/server/db"; 5 10 import { initOAuthClient } from "$lib/server/auth/oauth-client"; ··· 8 13 getSessionUser, 9 14 } from "$lib/server/services/sessions"; 10 15 import { verifyToken } from "$lib/server/services/api-tokens"; 16 + import { getUser, upsertUser } from "$lib/server/services/users"; 11 17 import { runSync } from "$lib/server/services/sync"; 18 + import { handleMcpRequest } from "$lib/server/mcp/server"; 19 + import process from "node:process"; 20 + import { mkdirSync, writeFileSync } from "node:fs"; 21 + import { dirname, join } from "node:path"; 22 + 23 + /** The synthetic single user of a local desktop build (no login). */ 24 + export const LOCAL_DID = "did:local:self"; 25 + 26 + /** Log the outcome of a sync run to the console. */ 27 + function logSyncOutcomes(outcomes: Awaited<ReturnType<typeof runSync>>): void { 28 + for (const o of outcomes) { 29 + console.log( 30 + `sync connection ${o.connectionId}: ${ 31 + o.ok ? "ok" : `FAILED (${o.error})` 32 + }, +${o.newTransactions} txns, ${o.reconciled} reconciled, ${o.ruleCategorized} rule-categorized`, 33 + ); 34 + } 35 + } 12 36 13 37 export const init: ServerInit = async () => { 14 38 if (building) return; 15 39 const config = getConfig(); // fails fast with a clear error on missing/invalid env 16 40 const db = initDb(config.dbPath, "migrations"); 41 + 42 + if (config.mode === "local") { 43 + // Single-user desktop build: no login, no OAuth client. Seed the local 44 + // identity when the display name is already chosen; otherwise the welcome 45 + // flow seeds it on first launch. 46 + if (config.localDisplayName) { 47 + upsertUser(db, LOCAL_DID, config.localDisplayName); 48 + } 49 + startLocalSync(); 50 + startLoopbackMcp(config.dbPath); 51 + return; 52 + } 53 + 17 54 deleteExpiredSessions(db); 18 55 await initOAuthClient(config, db); 19 56 20 57 // Daily sync. The Bridge refreshes bank data roughly daily; more often is pointless. 21 58 try { 22 59 Deno.cron("daily simplefin sync", "0 11 * * *", async () => { 23 - const outcomes = await runSync(getDb()); 24 - for (const o of outcomes) { 25 - console.log( 26 - `sync connection ${o.connectionId}: ${ 27 - o.ok ? "ok" : `FAILED (${o.error})` 28 - }, +${o.newTransactions} txns, ${o.reconciled} reconciled, ${o.ruleCategorized} rule-categorized`, 29 - ); 30 - } 60 + logSyncOutcomes(await runSync(getDb())); 31 61 }); 32 62 } catch (err) { 33 63 console.warn("Deno.cron unavailable; scheduled sync disabled:", err); 34 64 } 35 65 }; 36 66 67 + // Local mode is not always on, and Deno.cron keeps its schedule in memory with 68 + // no catch-up for missed fires, so a fixed daily time would silently skip any 69 + // day the app wasn't open. Instead: sync once on launch (catching up whatever 70 + // was missed while closed) and periodically while the app runs. 71 + const LOCAL_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours 72 + 73 + function startLocalSync(): void { 74 + const run = () => 75 + runSync(getDb()) 76 + .then(logSyncOutcomes) 77 + .catch((err) => console.warn("local sync failed:", err)); 78 + run(); // on launch 79 + setInterval(run, LOCAL_SYNC_INTERVAL_MS); // while running 80 + } 81 + 82 + // A `deno desktop` build talks to its webview over an in-process channel, so 83 + // the embedded server's HTTP port is not guaranteed to be a reachable surface 84 + // for a local agent. When the desktop entrypoint sets QUANTUM_LOCAL_MCP_PORT, 85 + // bind a loopback-only listener that mounts the same transport-only MCP handler 86 + // and requires the same bearer token. Left unset (dev/server), the SvelteKit 87 + // /mcp route serves agents directly and this is a no-op. 88 + function startLoopbackMcp(dbPath: string): void { 89 + const portRaw = process.env.QUANTUM_LOCAL_MCP_PORT?.trim(); 90 + if (!portRaw) return; 91 + const port = Number(portRaw); 92 + if (!Number.isInteger(port) || port <= 0) { 93 + console.warn(`QUANTUM_LOCAL_MCP_PORT is not a valid port: ${portRaw}`); 94 + return; 95 + } 96 + Deno.serve({ port, hostname: "127.0.0.1" }, (request) => { 97 + const path = new URL(request.url).pathname; 98 + if (!isMcpPath(path)) return new Response("Not found", { status: 404 }); 99 + const bearer = readBearer(request.headers.get("authorization")); 100 + const principal = bearer ? verifyToken(getDb(), bearer) : null; 101 + if (!principal) return new Response("Unauthorized", { status: 401 }); 102 + return handleMcpRequest(request, getDb(), principal); 103 + }); 104 + const mcpUrl = `http://127.0.0.1:${port}/mcp`; 105 + console.log(`local MCP listening on ${mcpUrl}`); 106 + 107 + // Publish the endpoint next to the database so a local agent host can be 108 + // pointed at it in one step. The URL only — never a token; the user mints a 109 + // scoped, revocable token in Settings, so no plaintext credential sits on disk. 110 + try { 111 + const dir = dirname(dbPath); 112 + mkdirSync(dir, { recursive: true }); 113 + writeFileSync( 114 + join(dir, "agent.json"), 115 + JSON.stringify({ mcpUrl }, null, 2), 116 + ); 117 + } catch (err) { 118 + console.warn("could not write agent.json endpoint file:", err); 119 + } 120 + } 121 + 37 122 export const SESSION_COOKIE = "quantum_session"; 38 123 39 124 /** Routes reachable without a session: login, OAuth plumbing, health check. */ ··· 52 137 return match ? match[1].trim() : null; 53 138 } 54 139 55 - export const handle: Handle = async ({ event, resolve }) => { 140 + function isMcpPath(path: string): boolean { 141 + return path === "/mcp" || path.startsWith("/mcp/"); 142 + } 143 + 144 + export const handle: Handle = ({ event, resolve }) => 145 + getConfig().mode === "local" 146 + ? handleLocal(event, resolve) 147 + : handleServer(event, resolve); 148 + 149 + type Resolve = Parameters<Handle>[0]["resolve"]; 150 + 151 + async function handleServer( 152 + event: RequestEvent, 153 + resolve: Resolve, 154 + ): Promise<Response> { 56 155 const cookie = event.cookies.get(SESSION_COOKIE); 57 156 event.locals.user = cookie ? getSessionUser(getDb(), cookie) : null; 58 157 ··· 70 169 const authenticated = event.locals.user || event.locals.apiToken; 71 170 if (!authenticated && !PUBLIC_PATHS.has(path)) { 72 171 // API endpoints answer 401; browser routes get the login redirect. 73 - if (path === "/mcp" || path.startsWith("/mcp/")) { 74 - return new Response("Unauthorized", { status: 401 }); 75 - } 172 + if (isMcpPath(path)) return new Response("Unauthorized", { status: 401 }); 76 173 redirect(303, "/login"); 77 174 } 78 175 if (event.locals.user && path === "/login") { ··· 80 177 } 81 178 82 179 return resolve(event); 83 - }; 180 + } 181 + 182 + // Local mode (add-desktop-local-remote-modes): no login. The single local user 183 + // is authenticated on every web request; MCP still requires a bearer token even 184 + // on the loopback, so another process on the machine cannot reach the ledger 185 + // unauthenticated. 186 + async function handleLocal( 187 + event: RequestEvent, 188 + resolve: Resolve, 189 + ): Promise<Response> { 190 + const db = getDb(); 191 + const path = event.url.pathname; 192 + 193 + // MCP is token-authenticated, never the local session. 194 + if (isMcpPath(path)) { 195 + const bearer = readBearer(event.request.headers.get("authorization")); 196 + event.locals.user = null; 197 + event.locals.apiToken = bearer ? verifyToken(db, bearer) : null; 198 + if (!event.locals.apiToken) { 199 + return new Response("Unauthorized", { status: 401 }); 200 + } 201 + return resolve(event); 202 + } 203 + 204 + event.locals.apiToken = null; 205 + const localUser = getUser(db, LOCAL_DID); 206 + 207 + // Until the user has completed first-launch (no local identity yet), send 208 + // them to the setup screen instead of a login page. 209 + if (!localUser) { 210 + event.locals.user = null; 211 + if (path !== "/welcome" && !PUBLIC_PATHS.has(path)) { 212 + redirect(303, "/welcome"); 213 + } 214 + return resolve(event); 215 + } 216 + 217 + event.locals.user = localUser; 218 + // Login and welcome are meaningless once set up. 219 + if (path === "/login" || path === "/welcome") redirect(303, "/"); 220 + return resolve(event); 221 + }
+49
src/lib/server/config.test.ts
··· 70 70 } 71 71 if (!threw) throw new Error("expected non-DID entry to be rejected"); 72 72 }); 73 + 74 + Deno.test("default mode is server", () => { 75 + if (loadConfig(VALID_ENV).mode !== "server") { 76 + throw new Error("expected server mode by default"); 77 + } 78 + }); 79 + 80 + Deno.test("local mode loads with none of the server env vars", () => { 81 + const config = loadConfig({ QUANTUM_MODE: "local", DB_PATH: "./data/x.db" }); 82 + if (config.mode !== "local") throw new Error("expected local mode"); 83 + if (config.appUrl !== "" || config.allowedDids.length !== 0) { 84 + throw new Error("local mode should carry no public origin or allowlist"); 85 + } 86 + if (config.dbPath !== "./data/x.db") throw new Error("DB_PATH not honored"); 87 + }); 88 + 89 + Deno.test("local mode defaults the database path when DB_PATH is unset", () => { 90 + const config = loadConfig({ 91 + QUANTUM_MODE: "local", 92 + HOME: "/home/u", 93 + USERPROFILE: "C:\\Users\\u", 94 + APPDATA: "C:\\Users\\u\\AppData\\Roaming", 95 + XDG_DATA_HOME: "/home/u/.local/share", 96 + }); 97 + if (!config.dbPath.includes("Quantum")) { 98 + throw new Error(`expected an app-data default path, got ${config.dbPath}`); 99 + } 100 + }); 101 + 102 + Deno.test("local mode rejects server auth configuration", () => { 103 + for ( 104 + const bad of [ 105 + { QUANTUM_MODE: "local", ALLOWED_DIDS: "did:plc:aaa" }, 106 + { QUANTUM_MODE: "local", OAUTH_PRIVATE_KEY_JWK: VALID_JWK }, 107 + ] 108 + ) { 109 + let threw = false; 110 + try { 111 + loadConfig(bad); 112 + } catch { 113 + threw = true; 114 + } 115 + if (!threw) { 116 + throw new Error( 117 + `local mode must reject server auth config: ${JSON.stringify(bad)}`, 118 + ); 119 + } 120 + } 121 + });
+80 -4
src/lib/server/config.ts
··· 1 1 import process from "node:process"; 2 + import { join } from "node:path"; 3 + 4 + /** 5 + * How this instance runs (add-desktop-local-remote-modes): 6 + * - `server`: the hosted, multi-user deployment — ATProto OAuth login, a DID 7 + * allowlist, and a public origin are all required. 8 + * - `local`: the single-user desktop build — no login, no OAuth, data on the 9 + * user's own disk. The server-only requirements are dropped. 10 + */ 11 + export type Mode = "server" | "local"; 2 12 3 13 export interface Config { 4 - /** Public HTTPS origin, no trailing slash. Drives OAuth client_id, redirect URI, JWKS URL. */ 14 + /** How this instance runs. */ 15 + mode: Mode; 16 + /** Public HTTPS origin, no trailing slash. Drives OAuth client_id, redirect URI, JWKS URL. Empty in local mode. */ 5 17 appUrl: string; 6 - /** DIDs permitted to use this instance. */ 18 + /** DIDs permitted to use this instance. Empty in local mode. */ 7 19 allowedDids: string[]; 8 20 /** Path to the SQLite database file. */ 9 21 dbPath: string; 10 - /** ES256 private key (JWK with kid) used for OAuth client authentication. */ 22 + /** ES256 private key (JWK with kid) used for OAuth client authentication. Empty in local mode. */ 11 23 oauthPrivateKeyJwk: Record<string, unknown>; 24 + /** 25 + * Display name for the single local user (local mode only), supplied by the 26 + * desktop first-launch flow. Undefined until the user has chosen one, which 27 + * gates the app behind the setup screen. 28 + */ 29 + localDisplayName?: string; 30 + } 31 + 32 + /** The OS-conventional per-user data directory for the local-mode database. */ 33 + function defaultLocalDbPath( 34 + env: Record<string, string | undefined>, 35 + ): string { 36 + const home = env.HOME ?? env.USERPROFILE ?? "."; 37 + let dir: string; 38 + if (process.platform === "win32") { 39 + dir = env.APPDATA ?? join(home, "AppData", "Roaming"); 40 + } else if (process.platform === "darwin") { 41 + dir = join(home, "Library", "Application Support"); 42 + } else { 43 + dir = env.XDG_DATA_HOME ?? join(home, ".local", "share"); 44 + } 45 + return join(dir, "Quantum", "quantum.db"); 12 46 } 13 47 14 48 export function loadConfig( 15 49 env: Record<string, string | undefined> = process.env, 16 50 ): Config { 51 + const mode: Mode = env.QUANTUM_MODE?.trim() === "local" ? "local" : "server"; 52 + 53 + if (mode === "local") return loadLocalConfig(env); 54 + 17 55 const problems: string[] = []; 18 56 19 57 const appUrlRaw = env.APP_URL?.trim(); ··· 86 124 throw new Error(`Invalid configuration:\n - ${problems.join("\n - ")}`); 87 125 } 88 126 89 - return { appUrl, allowedDids, dbPath: dbPath!, oauthPrivateKeyJwk }; 127 + return { 128 + mode: "server", 129 + appUrl, 130 + allowedDids, 131 + dbPath: dbPath!, 132 + oauthPrivateKeyJwk, 133 + }; 134 + } 135 + 136 + /** 137 + * Local single-user mode: no login, no OAuth, no allowlist. Server-only 138 + * settings are not merely optional — supplying them is a hard error, so a 139 + * server image can never silently boot into the no-login relaxations. 140 + */ 141 + function loadLocalConfig(env: Record<string, string | undefined>): Config { 142 + const problems: string[] = []; 143 + 144 + if (env.ALLOWED_DIDS?.trim()) { 145 + problems.push( 146 + "ALLOWED_DIDS must not be set in local mode (QUANTUM_MODE=local is single-user and has no login)", 147 + ); 148 + } 149 + if (env.OAUTH_PRIVATE_KEY_JWK?.trim()) { 150 + problems.push( 151 + "OAUTH_PRIVATE_KEY_JWK must not be set in local mode (no OAuth client runs)", 152 + ); 153 + } 154 + if (problems.length > 0) { 155 + throw new Error(`Invalid configuration:\n - ${problems.join("\n - ")}`); 156 + } 157 + 158 + return { 159 + mode: "local", 160 + appUrl: "", // no public origin; the loopback MCP URL is surfaced elsewhere 161 + allowedDids: [], 162 + dbPath: env.DB_PATH?.trim() || defaultLocalDbPath(env), 163 + oauthPrivateKeyJwk: {}, 164 + localDisplayName: env.QUANTUM_LOCAL_DISPLAY_NAME?.trim() || undefined, 165 + }; 90 166 } 91 167 92 168 let cached: Config | null = null;
+9 -6
src/lib/server/db.test.ts
··· 165 165 ).run(now); 166 166 db.prepare("INSERT INTO users (did, handle, created_at) VALUES (?, ?, ?)") 167 167 .run("did:plc:alice", "alice.test", now); 168 - const txId = (db.prepare("SELECT id FROM transactions WHERE sfin_id = 'sfin-1'") 169 - .get() as { id: number }).id; 168 + const txId = 169 + (db.prepare("SELECT id FROM transactions WHERE sfin_id = 'sfin-1'") 170 + .get() as { id: number }).id; 170 171 // A pre-existing manual event that the rebuild must copy verbatim. 171 172 db.prepare( 172 173 `INSERT INTO categorization_events (transaction_id, source, actor_did, created_at) ··· 200 201 `INSERT INTO api_tokens (label, token_hash, scope, created_at) 201 202 VALUES ('claude', 'hash-1', 'readwrite', ?)`, 202 203 ).run(now); 203 - const tokenId = (db.prepare("SELECT id FROM api_tokens WHERE token_hash = 'hash-1'") 204 - .get() as { id: number }).id; 204 + const tokenId = 205 + (db.prepare("SELECT id FROM api_tokens WHERE token_hash = 'hash-1'") 206 + .get() as { id: number }).id; 205 207 db.prepare( 206 208 `INSERT INTO categorization_events (transaction_id, source, api_token_id, created_at) 207 209 VALUES (?, 'agent', ?, ?)`, ··· 224 226 `INSERT INTO transactions (account_id, sfin_id, amount_cents, description, created_at) 225 227 VALUES ('acct-1', 'sfin-1', -1234, 'COFFEE', ?)`, 226 228 ).run(now); 227 - const txId = (db.prepare("SELECT id FROM transactions WHERE sfin_id = 'sfin-1'") 228 - .get() as { id: number }).id; 229 + const txId = 230 + (db.prepare("SELECT id FROM transactions WHERE sfin_id = 'sfin-1'") 231 + .get() as { id: number }).id; 229 232 230 233 let threw = false; 231 234 try {
+39 -21
src/lib/server/mcp/server.ts
··· 5 5 import type { TokenPrincipal } from "../services/api-tokens.ts"; 6 6 7 7 import { listAccounts, listStaleAccounts } from "../services/accounts.ts"; 8 - import { type LedgerFilters, listLedger, listMonths } from "../services/ledger.ts"; 8 + import { 9 + type LedgerFilters, 10 + listLedger, 11 + listMonths, 12 + } from "../services/ledger.ts"; 9 13 import { categorizeByAgent, listEvents } from "../services/categorization.ts"; 10 - import { monthlyReport, netWorthSeries, pendingStats } from "../services/reports.ts"; 11 - import { type CategoryKind, createCategory, listCategories } from "../services/categories.ts"; 14 + import { 15 + monthlyReport, 16 + netWorthSeries, 17 + pendingStats, 18 + } from "../services/reports.ts"; 19 + import { 20 + type CategoryKind, 21 + createCategory, 22 + listCategories, 23 + } from "../services/categories.ts"; 12 24 import { 13 25 applyRulesToUncategorized, 14 26 countRuleMatches, 15 27 createRule, 16 28 listRules, 17 29 probeRule, 18 - type RuleProbe, 19 30 ruleMatchHealth, 31 + type RuleProbe, 20 32 setRuleActive, 21 33 } from "../services/rules.ts"; 22 34 import { getConnectionErrors, getLastSync } from "../services/sync-status.ts"; ··· 62 74 const server = new McpServer({ name: "quantum", version: "1.0.0" }); 63 75 64 76 const requireWrite = (): ToolResult | null => 65 - principal.scope === "readwrite" 66 - ? null 67 - : error( 68 - "This tool requires a read-write token. The presented token is read-only.", 69 - ); 77 + principal.scope === "readwrite" ? null : error( 78 + "This tool requires a read-write token. The presented token is read-only.", 79 + ); 70 80 71 81 // ---- Read tools (any scope) ------------------------------------------ 72 82 ··· 98 108 availableMonths: listMonths(db), 99 109 })); 100 110 101 - server.registerTool("get_transaction_history", { 102 - description: 103 - "The full, ordered categorization history (provenance chain) of one transaction: every rule, person, reconciliation, or agent event that set its category.", 104 - inputSchema: { transactionId: z.number().int() }, 105 - }, ({ transactionId }: { transactionId: number }) => 106 - json(listEvents(db, transactionId))); 111 + server.registerTool( 112 + "get_transaction_history", 113 + { 114 + description: 115 + "The full, ordered categorization history (provenance chain) of one transaction: every rule, person, reconciliation, or agent event that set its category.", 116 + inputSchema: { transactionId: z.number().int() }, 117 + }, 118 + ({ transactionId }: { transactionId: number }) => 119 + json(listEvents(db, transactionId)), 120 + ); 107 121 108 122 server.registerTool("get_monthly_report", { 109 123 description: ··· 120 134 "The net-worth-over-time series, one point per date across all non-hidden accounts.", 121 135 }, () => json(netWorthSeries(db))); 122 136 123 - server.registerTool("list_categories", { 124 - description: 125 - "List categories with their kind (income, expense, or transfer). Pass activeOnly to exclude deactivated ones.", 126 - inputSchema: { activeOnly: z.boolean().optional() }, 127 - }, ({ activeOnly }: { activeOnly?: boolean }) => 128 - json(listCategories(db, { activeOnly }))); 137 + server.registerTool( 138 + "list_categories", 139 + { 140 + description: 141 + "List categories with their kind (income, expense, or transfer). Pass activeOnly to exclude deactivated ones.", 142 + inputSchema: { activeOnly: z.boolean().optional() }, 143 + }, 144 + ({ activeOnly }: { activeOnly?: boolean }) => 145 + json(listCategories(db, { activeOnly })), 146 + ); 129 147 130 148 server.registerTool("list_rules", { 131 149 description:
+2 -1
src/lib/server/services/categorization.test.ts
··· 108 108 db.prepare( 109 109 "INSERT INTO rules (pattern, match_type, category_id, created_by_did, active, created_at) VALUES ('COFFEE','contains',1,'did:plc:t',1,?)", 110 110 ).run(new Date().toISOString()); 111 - const ruleId = (db.prepare("SELECT id FROM rules").get() as { id: number }).id; 111 + const ruleId = 112 + (db.prepare("SELECT id FROM rules").get() as { id: number }).id; 112 113 appendCategorizationEvent(db, { 113 114 transactionId: ruled, 114 115 categoryId: 1,
+6 -1
src/routes/(app)/settings/+page.server.ts
··· 140 140 } 141 141 // A protected route: locals.user is present. The token is attributed to 142 142 // its creator so agent-authored events trace back to a person. 143 - const { token } = mintToken(getDb(), label, scope, locals.user?.did ?? null); 143 + const { token } = mintToken( 144 + getDb(), 145 + label, 146 + scope, 147 + locals.user?.did ?? null, 148 + ); 144 149 // Returned once, for display; only its hash is stored. 145 150 return { mintedToken: token, mintedLabel: label }; 146 151 },
+26
src/routes/welcome/+page.server.ts
··· 1 + import { fail, redirect } from "@sveltejs/kit"; 2 + import { getConfig } from "$lib/server/config"; 3 + import { getDb } from "$lib/server/db"; 4 + import { upsertUser } from "$lib/server/services/users"; 5 + import { LOCAL_DID } from "../../hooks.server"; 6 + import type { Actions, PageServerLoad } from "./$types"; 7 + 8 + // First-launch setup for the single local user. Only meaningful in local mode; 9 + // a server deployment authenticates via login instead. 10 + export const load: PageServerLoad = () => { 11 + if (getConfig().mode !== "local") redirect(303, "/"); 12 + return {}; 13 + }; 14 + 15 + export const actions: Actions = { 16 + default: async ({ request }) => { 17 + if (getConfig().mode !== "local") redirect(303, "/"); 18 + const form = await request.formData(); 19 + const name = String(form.get("name") ?? "").trim(); 20 + if (!name) { 21 + return fail(400, { name, message: "Enter a name to continue." }); 22 + } 23 + upsertUser(getDb(), LOCAL_DID, name); 24 + redirect(303, "/"); 25 + }, 26 + };
+99
src/routes/welcome/+page.svelte
··· 1 + <script lang="ts"> 2 + import { enhance } from "$app/forms"; 3 + 4 + let { form } = $props(); 5 + let submitting = $state(false); 6 + </script> 7 + 8 + <main> 9 + <div class="panel"> 10 + <h1>Quantum</h1> 11 + <p class="tagline">Set up your ledger.</p> 12 + 13 + <form 14 + method="POST" 15 + use:enhance={() => { 16 + submitting = true; 17 + return async ({ update }) => { 18 + await update(); 19 + submitting = false; 20 + }; 21 + }} 22 + > 23 + <label for="name">What should we call you?</label> 24 + <input 25 + id="name" 26 + name="name" 27 + type="text" 28 + placeholder="Your name" 29 + autocomplete="name" 30 + spellcheck="false" 31 + value={form?.name ?? ''} 32 + required 33 + /> 34 + <p class="hint"> 35 + This is a personal, single-user copy of Quantum. Your name labels the 36 + categorizations you make. Everything stays on this computer. 37 + </p> 38 + {#if form?.message} 39 + <p class="error" role="alert">{form.message}</p> 40 + {/if} 41 + <button class="primary" type="submit" disabled={submitting}> 42 + {submitting ? 'Setting up…' : 'Continue'} 43 + </button> 44 + </form> 45 + </div> 46 + </main> 47 + 48 + <style> 49 + main { 50 + min-height: 100dvh; 51 + display: grid; 52 + place-items: center; 53 + padding: var(--space-5); 54 + } 55 + 56 + .panel { 57 + width: 100%; 58 + max-width: 340px; 59 + display: flex; 60 + flex-direction: column; 61 + gap: var(--space-2); 62 + } 63 + 64 + h1 { 65 + font-size: var(--text-2xl); 66 + letter-spacing: -0.02em; 67 + } 68 + 69 + .tagline { 70 + color: var(--text-muted); 71 + margin-bottom: var(--space-6); 72 + } 73 + 74 + form { 75 + display: flex; 76 + flex-direction: column; 77 + gap: var(--space-3); 78 + } 79 + 80 + label { 81 + font-size: var(--text-xs); 82 + font-weight: 500; 83 + color: var(--text-muted); 84 + } 85 + 86 + .hint { 87 + font-size: var(--text-xs); 88 + color: var(--text-muted); 89 + } 90 + 91 + .error { 92 + font-size: var(--text-xs); 93 + color: var(--neg); 94 + } 95 + 96 + button { 97 + margin-top: var(--space-1); 98 + } 99 + </style>