···11+# Local mode (single-user desktop build). Copy to `.env.localmode` for
22+# `deno task dev:local`. No login, no OAuth, no allowlist — supplying
33+# ALLOWED_DIDS or OAUTH_PRIVATE_KEY_JWK here is a hard error.
44+55+QUANTUM_MODE=local
66+77+# Where the SQLite database lives. If unset, defaults to the OS application-data
88+# directory (e.g. %APPDATA%\Quantum\quantum.db).
99+DB_PATH=./data/local-dev.db
1010+1111+# The single local user's display name. If unset, the app sends you to the
1212+# first-launch /welcome screen to choose one.
1313+QUANTUM_LOCAL_DISPLAY_NAME=You
1414+1515+# Optional: bind a loopback-only MCP listener on this port for local agents.
1616+# The desktop build sets this; for `dev:local` the SvelteKit /mcp route already
1717+# works, so it's usually left unset.
1818+# QUANTUM_LOCAL_MCP_PORT=5175
···88 },
99 "tasks": {
1010 "dev": "deno run --env-file --unstable-cron -A npm:vite dev",
1111+ "dev:local": "deno run --env-file=.env.localmode --unstable-cron -A npm:vite dev",
1112 "build": "deno run -A npm:vite build",
1213 "start": "deno run --env-file --unstable-cron -A build/index.js",
1414+ "desktop": "deno desktop --unstable-cron -A .",
1315 "check": "deno run -A npm:@sveltejs/kit/svelte-kit sync && deno run -A npm:svelte-check --tsconfig ./tsconfig.json",
1416 "test": "deno test -A src",
1517 "fmt": "deno fmt",
···11# add-desktop-local-remote-modes
2233-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
33+Package Quantum as a deno desktop app with a first-launch Local/Remote mode
44+choice: a single-user no-login local build with an embedded server, and a thin
55+remote client to an existing Quantum server via OAuth deep-link handoff
···11## Context
2233-The server core is transport-agnostic (design D6) and already boots from a small,
44-well-defined seam. The relevant current state:
33+The server core is transport-agnostic (design D6) and already boots from a
44+small, well-defined seam. The relevant current state:
5566- **`loadConfig`** (`config.ts`) hard-requires `APP_URL`, `ALLOWED_DIDS`, and
77 `OAUTH_PRIVATE_KEY_JWK`, throwing a fail-fast error if any is missing. Its
···1515- **`upsertUser`** (`users.ts`) takes a DID and handle; nothing in the schema or
1616 services parses DID shape — `users.did` is an opaque TEXT primary key,
1717 `actor_did` joins by equality, and the UI renders the stored handle.
1818-- **`claimSetupToken`** (`connections.ts`) claims a SimpleFIN token and stores an
1919- Access URL. It takes no user, no DID — bank setup is independent of login.
1818+- **`claimSetupToken`** (`connections.ts`) claims a SimpleFIN token and stores
1919+ an Access URL. It takes no user, no DID — bank setup is independent of login.
2020- **Sessions** (`sessions.ts`) are opaque, bearer-ready tokens, per the auth
2121 spec.
2222- **`add-mcp-server`** introduces a transport-only MCP handler (no cookie/view
2323 reads) and a bearer-authentication path in `handle` for API tokens.
24242525`deno desktop` (Deno 2.9) compiles this SvelteKit app into a native binary,
2626-running the production server in-process with the UI in an OS webview. Backend↔UI
2727-communication is in-process channels, not socket IPC — so a desktop build cannot
2828-assume its embedded server is reachable on a localhost TCP port. Deno is already
2929-pinned at 2.9.3 in the Dockerfile.
2626+running the production server in-process with the UI in an OS webview.
2727+Backend↔UI communication is in-process channels, not socket IPC — so a desktop
2828+build cannot assume its embedded server is reachable on a localhost TCP port.
2929+Deno is already pinned at 2.9.3 in the Dockerfile.
30303131The user-facing goal: an individual runs Quantum on the desktop with no server,
3232no login, data on their own disk; a server owner runs the same app as a native
···3838**Goals:**
39394040- Ship a native desktop build without forking the codebase or the service layer.
4141-- Local mode: no server, no login, single user, local data, working bank sync and
4242- local agent access.
4141+- Local mode: no server, no login, single user, local data, working bank sync
4242+ and local agent access.
4343- Remote mode: native client to an existing server, authenticated by reusing the
4444 server's ATProto OAuth via a system-browser handoff, without the app becoming
4545 an OAuth client.
···48484949**Non-Goals:**
50505151-- A tray/background-resident runtime. It would change sync cadence materially and
5252- carries its own UX decisions (close-to-tray, run-in-background preference); it
5353- is parked for a follow-up (see Open Questions).
5151+- A tray/background-resident runtime. It would change sync cadence materially
5252+ and carries its own UX decisions (close-to-tray, run-in-background
5353+ preference); it is parked for a follow-up (see Open Questions).
5454- Mobile builds and auto-update. Separate efforts.
5555-- Multi-user local mode. Local is deliberately one person; the two-person product
5656- is the server.
5555+- Multi-user local mode. Local is deliberately one person; the two-person
5656+ product is the server.
5757- Re-attributing history when a local user later migrates to a server. Explicit
5858 non-goal (see decision 2).
5959···63636464**Decision:** `loadConfig` gains a `mode: 'server' | 'local'`, sourced from
6565`QUANTUM_MODE` (defaulting to `server`, which the container sets implicitly by
6666-never setting `local`). The desktop first-launch screen writes the chosen mode to
6767-desktop app-config (a file in the app-data directory, outside the SQLite
6868-database). Changing mode is an explicit reset that clears app-config and re-shows
6969-the screen; there is no in-app local↔remote toggle.
6666+never setting `local`). The desktop first-launch screen writes the chosen mode
6767+to desktop app-config (a file in the app-data directory, outside the SQLite
6868+database). Changing mode is an explicit reset that clears app-config and
6969+re-shows the screen; there is no in-app local↔remote toggle.
70707171-**Why:** Local and remote are not two configurations of one runtime — they differ
7272-in whether an embedded server runs at all, whether there is a database, and how
7373-auth works. A once-then-reset choice models that honestly and keeps the branching
7474-at boot, not scattered through the app. Storing mode outside the database is
7575-required: in remote mode there is no local database to store it in, and in local
7676-mode the mode must be known before the database is opened.
7171+**Why:** Local and remote are not two configurations of one runtime — they
7272+differ in whether an embedded server runs at all, whether there is a database,
7373+and how auth works. A once-then-reset choice models that honestly and keeps the
7474+branching at boot, not scattered through the app. Storing mode outside the
7575+database is required: in remote mode there is no local database to store it in,
7676+and in local mode the mode must be known before the database is opened.
77777878**Server-safety is a validation invariant, not a convention.** `loadConfig` in
7979`local` mode drops the OAuth/`APP_URL`/`ALLOWED_DIDS` requirements; in `server`
8080-mode it keeps them. A configuration that sets `QUANTUM_MODE=local` *and* supplies
8181-`ALLOWED_DIDS`/OAuth keys is a hard error, so the relaxations can never be half-
8282-applied to a server image by accident.
8080+mode it keeps them. A configuration that sets `QUANTUM_MODE=local` _and_
8181+supplies `ALLOWED_DIDS`/OAuth keys is a hard error, so the relaxations can never
8282+be half- applied to a server image by accident.
83838484### 2. Local identity is a synthetic DID, `did:local:self`
85858686-**Decision:** Local mode seeds one user at startup via `upsertUser(db,
8787-'did:local:self', <display name>)` and treats it as the authenticated user for
8888-every request; `handle` never redirects to `/login` in local mode. The
8989-first-launch flow prompts for the display name.
8686+**Decision:** Local mode seeds one user at startup via
8787+`upsertUser(db,
8888+'did:local:self', <display name>)` and treats it as the
8989+authenticated user for every request; `handle` never redirects to `/login` in
9090+local mode. The first-launch flow prompts for the display name.
90919192**Why:** DIDs are opaque everywhere in Quantum, so a synthetic one slots in with
9293zero schema or service change, keeping the existing invariants — including
9394`manual events require actorDid` (`categorization.ts`) — satisfied without
9495special-casing. `did:local:self` is syntactically a valid DID (`did:` + a
9595-lowercase method + an id), greps cleanly, and cannot collide with a real identity
9696-because no `local` DID method resolves. The alternatives are worse: a bare
9797-`"local"` violates the `did:` convention the config validator enforces, and
9898-`did:web:localhost` abuses a method that *is* resolvable.
9696+lowercase method + an id), greps cleanly, and cannot collide with a real
9797+identity because no `local` DID method resolves. The alternatives are worse: a
9898+bare `"local"` violates the `did:` convention the config validator enforces, and
9999+`did:web:localhost` abuses a method that _is_ resolvable.
99100100101**Prompting for a display name** (rather than defaulting to `"you"` or the OS
101102username) keeps provenance legible — a badge reading the person's chosen name is
···103104already looking at.
104105105106**Migration re-attribution is a non-goal.** If a local user later moves to a
106106-server, their history stays attributed to `did:local:self` rather than their real
107107-DID. Rewriting attribution is out of scope; stated so no one expects it.
107107+server, their history stays attributed to `did:local:self` rather than their
108108+real DID. Rewriting attribution is out of scope; stated so no one expects it.
108109109110### 3. Local mode does not use `Deno.cron` — it triggers sync on launch and on an interval
110111111111-**Decision:** In local mode, skip the `Deno.cron` registration entirely. Instead,
112112-run `runSync` once during `init` (catching up whatever was missed while the app
113113-was closed) and then on a `setInterval` while the app runs. The server keeps its
114114-daily `Deno.cron` unchanged.
112112+**Decision:** In local mode, skip the `Deno.cron` registration entirely.
113113+Instead, run `runSync` once during `init` (catching up whatever was missed while
114114+the app was closed) and then on a `setInterval` while the app runs. The server
115115+keeps its daily `Deno.cron` unchanged.
115116116117**Why:** The runtime's `Deno.cron` keeps its schedule in memory and has **no
117117-catch-up** — a missed fire is skipped, never replayed, and it only fires while the
118118-process is alive. A fixed `"0 11 * * *"` is therefore meaningless for a
118118+catch-up** — a missed fire is skipped, never replayed, and it only fires while
119119+the process is alive. A fixed `"0 11 * * *"` is therefore meaningless for a
119120part-time desktop process: any day the app isn't open at 11:00 UTC, that day's
120121sync simply never happens, silently. An event-driven trigger (on launch + while
121122running) matches how a desktop app actually lives. This is true independent of
···131132132133### 4. One MCP handler, remounted on a loopback listener for local mode
133134134134-**Decision:** Local mode starts a dedicated loopback `Deno.serve` listener during
135135-`init` and mounts `add-mcp-server`'s transport-only MCP handler on it, so a local
136136-agent reaches the same tools a server exposes at `/mcp`. Remote mode adds no MCP
137137-mount — the user's server already serves it.
135135+**Decision:** Local mode starts a dedicated loopback `Deno.serve` listener
136136+during `init` and mounts `add-mcp-server`'s transport-only MCP handler on it, so
137137+a local agent reaches the same tools a server exposes at `/mcp`. Remote mode
138138+adds no MCP mount — the user's server already serves it.
138139139139-**Why:** `add-mcp-server` deliberately built its handler as a bare `Request →
140140-Response` with no cookie or view dependency, precisely so it could mount outside
141141-the web router. A loopback listener is necessary because `deno desktop`'s
142142-in-process UI channel means the embedded SvelteKit server's own port is not a
143143-reliable public surface. The listener binds loopback-only and requires the same
144144-bearer API token as the server route, so "local" does not mean "unauthenticated
145145-to any process on the machine."
140140+**Why:** `add-mcp-server` deliberately built its handler as a bare
141141+`Request →
142142+Response` with no cookie or view dependency, precisely so it could
143143+mount outside the web router. A loopback listener is necessary because
144144+`deno desktop`'s in-process UI channel means the embedded SvelteKit server's own
145145+port is not a reliable public surface. The listener binds loopback-only and
146146+requires the same bearer API token as the server route, so "local" does not mean
147147+"unauthenticated to any process on the machine."
146148147149**A verification, not an assumption:** whether `deno desktop` release mode
148148-*already* exposes the server's `/mcp` route on a reachable port is checked during
149149-implementation (task 5). If it does, the loopback mount is redundant and can be
150150-dropped; the design works either way and does not bet on it.
150150+_already_ exposes the server's `/mcp` route on a reachable port is checked
151151+during implementation (task 5). If it does, the loopback mount is redundant and
152152+can be dropped; the design works either way and does not bet on it.
151153152154### 4a. Local MCP token bootstrapping
153155154156**Decision:** In local mode, the "Connect an agent" Settings surface from
155155-`add-mcp-server` still mints tokens (the app shell renders for `did:local:self`),
156156-and the desktop app writes the loopback URL and, optionally, a freshly minted
157157-token to a well-known app-data location so a local agent host can be configured
158158-with one step.
157157+`add-mcp-server` still mints tokens (the app shell renders for
158158+`did:local:self`), and the desktop app writes the loopback URL and, optionally,
159159+a freshly minted token to a well-known app-data location so a local agent host
160160+can be configured with one step.
159161160162**Why:** Local mode's whole appeal is low friction; making the user hand-copy a
161163port and token into an agent config re-introduces exactly the setup wall the
···167169**Decision:** Remote mode registers a custom URL scheme for the app. To
168170authenticate, the app opens the server's existing ATProto login in the system
169171browser; on success the server redirects to the app's registered deep link
170170-carrying a **bearer session token**. The app stores that token and presents it as
171171-`Authorization: Bearer` on subsequent requests (reusing `add-mcp-server`'s bearer
172172-path, widened from API tokens to session tokens). The desktop app never registers
173173-as an ATProto OAuth client.
172172+carrying a **bearer session token**. The app stores that token and presents it
173173+as `Authorization: Bearer` on subsequent requests (reusing `add-mcp-server`'s
174174+bearer path, widened from API tokens to session tokens). The desktop app never
175175+registers as an ATProto OAuth client.
174176175177**Why:** The server already is a fully configured confidential ATProto client
176178with DPoP-bound tokens; duplicating that in every desktop install would multiply
···183185**Handoff is the sensitive step** and is constrained accordingly: the deep-link
184186token is single-use at handoff (the app immediately exchanges or binds it),
185187delivered only to the app's registered scheme, and scoped to a normal session's
186186-lifetime and revocability. A handoff that is intercepted yields at most a session
187187-the user can log out to kill — the same blast radius as a stolen session cookie,
188188-no worse.
188188+lifetime and revocability. A handoff that is intercepted yields at most a
189189+session the user can log out to kill — the same blast radius as a stolen session
190190+cookie, no worse.
189191190192### 6. Data lives in the OS application-data directory in local mode
191193···194196Support equivalents elsewhere), created on first launch. `DB_PATH` may still
195197override it.
196198197197-**Why:** A user who never chose to self-host should not have to choose a database
198198-location either; the OS convention is the least-surprising home and survives app
199199-updates. The existing `openDatabase` already creates parent directories and
200200-carries a clear permissions error, so this is a path default, not new machinery.
199199+**Why:** A user who never chose to self-host should not have to choose a
200200+database location either; the OS convention is the least-surprising home and
201201+survives app updates. The existing `openDatabase` already creates parent
202202+directories and carries a clear permissions error, so this is a path default,
203203+not new machinery.
201204202205## Risks / Trade-offs
203206···211214 and the interval is a focused-only bonus. The parked tray runtime would remove
212215 the concern entirely by keeping the process resident.
213216214214-- **The deep-link handoff carries a live credential across a process boundary** →
215215- single-use at handoff, registered-scheme-only delivery, session-scoped and
217217+- **The deep-link handoff carries a live credential across a process boundary**
218218+ → single-use at handoff, registered-scheme-only delivery, session-scoped and
216219 revocable; blast radius equals a stolen session cookie.
217220218221- **`deno desktop` is experimental (2.9)** → the surface used here (SvelteKit
···220223 packaging specifics are pinned to 2.9.x and validated in task 2 before deeper
221224 work.
222225223223-- **Local data has no server backup** → an accepted property of local mode, not a
224224- defect; the honest mitigation is documentation (where the file is, that it is
225225- the user's to back up), not silent cloud sync.
226226+- **Local data has no server backup** → an accepted property of local mode, not
227227+ a defect; the honest mitigation is documentation (where the file is, that it
228228+ is the user's to back up), not silent cloud sync.
226229227230- **Two runtimes double the surface to test** → mitigated by both sharing the
228231 entire service layer untouched; the divergence is confined to config, `init`,
···234237235238- `loadConfig` gains `mode`; server mode is the default and behaves exactly as
236239 today, so existing deployments are unaffected with no config change.
237237-- No schema change is required by local mode itself. If remote-mode handoff needs
238238- to mark a session's delivery method, that is one nullable column added
240240+- No schema change is required by local mode itself. If remote-mode handoff
241241+ needs to mark a session's delivery method, that is one nullable column added
239242 additively to `sessions`, NULL for every existing row.
240243- The desktop artifact is a new build output; the container image build is
241244 unchanged.
···11## Why
2233Quantum is self-hosted software, and self-hosting is a wall. An individual who
44-wants a Mint-style mirror of their own accounts must stand up a server, a domain,
55-an ATProto OAuth client, and a SimpleFIN connection before they see a single
66-number. The couple this product was built for cleared that wall; most people
77-won't.
44+wants a Mint-style mirror of their own accounts must stand up a server, a
55+domain, an ATProto OAuth client, and a SimpleFIN connection before they see a
66+single number. The couple this product was built for cleared that wall; most
77+people won't.
8899`deno desktop` (Deno 2.9, June 2026) removes it. It compiles a SvelteKit project
1010into a native, self-contained desktop binary — the UI in an OS webview, the
1111existing server running in-process — with no Chromium to ship and no daemon to
1212-install. The same build system also makes a *thin* desktop client viable: a
1313-native shell pointed at an existing Quantum server for the users who already have
1414-one.
1212+install. The same build system also makes a _thin_ desktop client viable: a
1313+native shell pointed at an existing Quantum server for the users who already
1414+have one.
15151616These two audiences want opposite things from the same binary. The individual
1717wants "just run it, no account, my data on my disk." The server owner wants "log
1818-me into my server and get out of the way." So the desktop app asks once, at first
1919-launch, which one you are — and becomes a different runtime accordingly. This
2020-change builds both, because shipping only local mode would strand the existing
2121-two-person server behind a web browser while everyone else got a native app, and
2222-because both modes share one authentication seam (bearer credentials) that is
2323-cheaper to build once.
1818+me into my server and get out of the way." So the desktop app asks once, at
1919+first launch, which one you are — and becomes a different runtime accordingly.
2020+This change builds both, because shipping only local mode would strand the
2121+existing two-person server behind a web browser while everyone else got a native
2222+app, and because both modes share one authentication seam (bearer credentials)
2323+that is cheaper to build once.
24242525This change depends on `add-mcp-server`: local mode remounts that change's MCP
2626handler on a loopback listener, and remote mode extends that change's
···2929## What Changes
30303131- A **`deno desktop` build** of the existing SvelteKit app, producing a native
3232- binary per platform, alongside the current container image (which is
3333- unchanged and remains the server deployment).
3434-- A **first-launch mode-selection screen**: *Local* ("just me, on this
3535- computer") or *Remote* ("I have a Quantum server"). The choice is made once and
3636- persisted in desktop app-config outside the database. Changing it later is an
3737- explicit **reset** action that clears the app-config and re-shows the screen —
3838- the two modes are treated as near-separate installs, not a runtime toggle.
3232+ binary per platform, alongside the current container image (which is unchanged
3333+ and remains the server deployment).
3434+- A **first-launch mode-selection screen**: _Local_ ("just me, on this
3535+ computer") or _Remote_ ("I have a Quantum server"). The choice is made once
3636+ and persisted in desktop app-config outside the database. Changing it later is
3737+ an explicit **reset** action that clears the app-config and re-shows the
3838+ screen — the two modes are treated as near-separate installs, not a runtime
3939+ toggle.
3940- **Local mode** — a single-user, no-login runtime:
4041 - The embedded server boots with `QUANTUM_MODE=local`, which **skips** the
4142 `APP_URL` / `ALLOWED_DIDS` / `OAUTH_PRIVATE_KEY_JWK` validation and the
···6263 backend remains the sole ATProto OAuth client; the desktop app never becomes
6364 one. The app presents that token as a bearer credential on subsequent
6465 requests, reusing the bearer path `add-mcp-server` introduced.
6565- - MCP in remote mode is served *by the user's server*, not the app — the app
6666+ - MCP in remote mode is served _by the user's server_, not the app — the app
6667 adds no local MCP mount.
67686869Not in this change: the tray/background-resident runtime (parked — see Design's
···75767677- `desktop-app`: Packaging Quantum as a native desktop binary; the first-launch
7778 choice between local and remote mode and its once-then-reset persistence;
7878- local mode's no-login single-user runtime, app-data database, sync cadence, and
7979- loopback MCP mount; and remote mode's thin-client shell with system-browser
8080- OAuth deep-link handoff.
7979+ local mode's no-login single-user runtime, app-data database, sync cadence,
8080+ and loopback MCP mount; and remote mode's thin-client shell with
8181+ system-browser OAuth deep-link handoff.
81828283### Modified Capabilities
83848485- `auth`: Adds a no-login local runtime authenticated as a synthetic single user
8585- (`did:local:self`), gated to the desktop local build; and a bearer *session*
8686- token issued to a native remote client via an OAuth deep-link handoff, building
8787- on the bearer path from `add-mcp-server`. Server cookie login is unchanged.
8686+ (`did:local:self`), gated to the desktop local build; and a bearer _session_
8787+ token issued to a native remote client via an OAuth deep-link handoff,
8888+ building on the bearer path from `add-mcp-server`. Server cookie login is
8989+ unchanged.
8890- `simplefin-sync`: The sync trigger becomes deployment-dependent — an always-on
8991 server keeps the daily schedule; a desktop local build syncs on launch and
9092 periodically while running. Sync's fetch, archival, normalization, and
···115117 dropped and a local DB path in the app-data directory is defaulted.
116118- `src/hooks.server.ts` — `init` conditionally skips `initOAuthClient` and the
117119 `Deno.cron` registration in local mode, seeds `did:local:self`, and starts the
118118- on-launch-plus-interval sync and the loopback MCP listener. `handle` treats the
119119- synthetic user as authenticated in local mode and never redirects to `/login`.
120120-- `src/routes/login`, `src/routes/oauth/callback` — unaffected on the server; not
121121- reached in local mode. A new deep-link callback path supports the remote-mode
122122- handoff.
120120+ on-launch-plus-interval sync and the loopback MCP listener. `handle` treats
121121+ the synthetic user as authenticated in local mode and never redirects to
122122+ `/login`.
123123+- `src/routes/login`, `src/routes/oauth/callback` — unaffected on the server;
124124+ not reached in local mode. A new deep-link callback path supports the
125125+ remote-mode handoff.
123126- `src/lib/server/services/sessions.ts` — session tokens are already opaque and
124127 bearer-ready; remote-mode handoff issues one for the native client to hold. A
125128 small addition marks a session as delivered by handoff if needed for its
···131134 version the desktop artifact alongside the image.
132135133136**Risk**: Two relaxations that must never leak to a server: no-login auth and
134134-skipped OAuth config. Both are gated on `QUANTUM_MODE=local`, which the container
135135-image never sets and which the config validator treats as mutually exclusive with
136136-server settings — a build that sets `local` and also presents `ALLOWED_DIDS` is a
137137-configuration error, not a silent downgrade. The remote-mode deep-link handoff is
138138-the other sensitive surface: it carries a live session token across a process
139139-boundary, so the token is single-use at handoff, bound to the requesting app
140140-instance, and delivered only to a registered scheme — detailed in Design.
137137+skipped OAuth config. Both are gated on `QUANTUM_MODE=local`, which the
138138+container image never sets and which the config validator treats as mutually
139139+exclusive with server settings — a build that sets `local` and also presents
140140+`ALLOWED_DIDS` is a configuration error, not a silent downgrade. The remote-mode
141141+deep-link handoff is the other sensitive surface: it carries a live session
142142+token across a process boundary, so the token is single-use at handoff, bound to
143143+the requesting app instance, and delivered only to a registered scheme —
144144+detailed in Design.
···33### Requirement: ATProto OAuth login
4455The system SHALL authenticate users via AT Protocol OAuth using handle-based
66-login when running as a server. The user enters their handle (or DID); the system
77-resolves it, performs the OAuth authorization flow (PAR, PKCE, DPoP) against the
88-user's authorization server, and establishes an application session on success.
99-The system SHALL request only the `atproto` scope and SHALL NOT make
66+login when running as a server. The user enters their handle (or DID); the
77+system resolves it, performs the OAuth authorization flow (PAR, PKCE, DPoP)
88+against the user's authorization server, and establishes an application session
99+on success. The system SHALL request only the `atproto` scope and SHALL NOT make
1010authenticated requests to the user's PDS after authentication. Resolving and
1111rendering profile pictures from public ATProto profile data — without using the
1212OAuth session or any application credential — is permitted. When the application
···37373838### Requirement: Local single-user runtime
39394040-When the application runs as a desktop build in Local mode, it SHALL operate as a
4141-single-user system with no login. It SHALL seed one synthetic user with the DID
4242-`did:local:self` and a user-chosen display name, and SHALL treat that user as the
4343-authenticated principal for every request, never redirecting to a login page.
4444-This runtime SHALL be reachable only when the application is explicitly
4545-configured for Local mode; a server deployment SHALL NOT enable it, and supplying
4646-server authentication configuration together with Local mode SHALL be a
4747-configuration error rather than a silent relaxation. All existing per-actor
4040+When the application runs as a desktop build in Local mode, it SHALL operate as
4141+a single-user system with no login. It SHALL seed one synthetic user with the
4242+DID `did:local:self` and a user-chosen display name, and SHALL treat that user
4343+as the authenticated principal for every request, never redirecting to a login
4444+page. This runtime SHALL be reachable only when the application is explicitly
4545+configured for Local mode; a server deployment SHALL NOT enable it, and
4646+supplying server authentication configuration together with Local mode SHALL be
4747+a configuration error rather than a silent relaxation. All existing per-actor
4848invariants (such as manual categorization recording an actor) SHALL be satisfied
4949by the synthetic user without special-casing.
5050···7474handoff. The application SHALL open the server's login in the operating system's
7575browser and SHALL receive, via a registered deep link, an opaque bearer session
7676token issued by the server. The application SHALL present that token as a bearer
7777-credential on subsequent requests. The server SHALL remain the sole ATProto OAuth
7878-client; the desktop application SHALL NOT register as one. The handed-off token
7979-SHALL be single-use at handoff, delivered only to the application's registered
8080-scheme, and SHALL carry the lifetime and revocability of an ordinary session.
7777+credential on subsequent requests. The server SHALL remain the sole ATProto
7878+OAuth client; the desktop application SHALL NOT register as one. The handed-off
7979+token SHALL be single-use at handoff, delivered only to the application's
8080+registered scheme, and SHALL carry the lifetime and revocability of an ordinary
8181+session.
81828283#### Scenario: Remote client authenticates via the system browser
83848485- **WHEN** a Remote-mode user initiates login
8585-- **THEN** the server's ATProto login opens in the system browser, and on success
8686- a bearer session token is delivered to the app through its registered deep link
8686+- **THEN** the server's ATProto login opens in the system browser, and on
8787+ success a bearer session token is delivered to the app through its registered
8888+ deep link
87898890#### Scenario: Bearer session token authenticates requests
8991
···66web application, producing a self-contained binary per platform whose UI runs in
77the operating system's webview and whose server logic runs in-process. The
88desktop build SHALL NOT fork or duplicate the domain service layer. The existing
99-container image and server deployment SHALL remain a supported, unchanged output.
99+container image and server deployment SHALL remain a supported, unchanged
1010+output.
10111112#### Scenario: Desktop artifact produced
1213···22232324### Requirement: First-launch mode selection
24252525-On first launch, the desktop application SHALL require the user to choose between
2626-Local mode ("just me, on this computer") and Remote mode ("I have a Quantum
2727-server"). The choice SHALL be persisted outside the application database. The
2828-application SHALL NOT present an in-app toggle to switch modes; changing mode
2929-SHALL be an explicit reset action that clears the persisted choice and returns
3030-the user to the mode-selection screen on the next launch.
2626+On first launch, the desktop application SHALL require the user to choose
2727+between Local mode ("just me, on this computer") and Remote mode ("I have a
2828+Quantum server"). The choice SHALL be persisted outside the application
2929+database. The application SHALL NOT present an in-app toggle to switch modes;
3030+changing mode SHALL be an explicit reset action that clears the persisted choice
3131+and returns the user to the mode-selection screen on the next launch.
31323233#### Scenario: Mode chosen on first launch
3334···48494950### Requirement: Local mode runtime
50515151-In Local mode, the application SHALL run its embedded server configured for local
5252-operation: it SHALL NOT require or validate `APP_URL`, `ALLOWED_DIDS`, or OAuth
5353-signing configuration, and SHALL NOT initialize an ATProto OAuth client. The
5454-application database SHALL be stored in the operating system's application-data
5555-directory by default. On first launch in Local mode, the application SHALL prompt
5656-for a display name and SHALL seed a single synthetic user for it. Connecting a
5757-bank via a SimpleFIN setup token SHALL work in Local mode exactly as on a server.
5252+In Local mode, the application SHALL run its embedded server configured for
5353+local operation: it SHALL NOT require or validate `APP_URL`, `ALLOWED_DIDS`, or
5454+OAuth signing configuration, and SHALL NOT initialize an ATProto OAuth client.
5555+The application database SHALL be stored in the operating system's
5656+application-data directory by default. On first launch in Local mode, the
5757+application SHALL prompt for a display name and SHALL seed a single synthetic
5858+user for it. Connecting a bank via a SimpleFIN setup token SHALL work in Local
5959+mode exactly as on a server.
58605961#### Scenario: Local mode boots without server configuration
6062···91939294#### Scenario: Loopback MCP still requires a token
93959494-- **WHEN** a process on the machine connects to the loopback MCP endpoint without
9595- a valid token
9696+- **WHEN** a process on the machine connects to the loopback MCP endpoint
9797+ without a valid token
9698- **THEN** the request is rejected
979998100### Requirement: Remote mode thin client
···33- [ ] 1.1 **Background timers.** In a minimal `deno desktop` build of the app,
44 register a `setInterval` that logs, minimize the window, and confirm
55 whether it keeps firing while minimized/backgrounded. Record the finding —
66- it decides whether "interval while running" is real or effectively
77- "while focused", and thus the local sync cadence (design decision 3).
88-- [ ] 1.2 **Server port exposure.** Determine whether `deno desktop` release mode
99- exposes the embedded SvelteKit server's `/mcp` route on a reachable
66+ it decides whether "interval while running" is real or effectively "while
77+ focused", and thus the local sync cadence (design decision 3).
88+- [ ] 1.2 **Server port exposure.** Determine whether `deno desktop` release
99+ mode exposes the embedded SvelteKit server's `/mcp` route on a reachable
1010 loopback port. If yes, the separate loopback MCP mount (task 5) is
1111 redundant; if no, it is required (design decision 4).
1212- [ ] 1.3 **Packaging sanity.** Confirm `deno desktop` (2.9.x) auto-detects this
···15151616## 2. Build and mode plumbing
17171818-- [ ] 2.1 Add a `desktop` task to `deno.json` invoking `deno desktop` against the
1919- SvelteKit build. Do not disturb the existing `build`/`start`/`image` tasks.
1818+- [x] 2.1 Add a `desktop` task to `deno.json` invoking `deno desktop` against
1919+ the SvelteKit build. Do not disturb the existing `build`/`start`/`image`
2020+ tasks. — Added `desktop` (`deno desktop --unstable-cron -A .`) and a
2121+ `dev:local` convenience task; existing tasks untouched. (Booting the
2222+ packaged app end-to-end still needs the app-config-driven mode selection of
2323+ tasks 2.2/7.1.)
2024- [ ] 2.2 Add a desktop launch config and the app-config read/write for the
2125 persisted mode (a file in the OS app-data directory, outside the SQLite
2226 database). Mode is one of `local` | `remote`, absent until first launch.
···27312832## 3. Config: server vs. local
29333030-- [ ] 3.1 In `src/lib/server/config.ts`, add `mode: 'server' | 'local'` sourced
3434+- [x] 3.1 In `src/lib/server/config.ts`, add `mode: 'server' | 'local'` sourced
3135 from `QUANTUM_MODE` (default `server`). In `server` mode, keep the current
3236 required-config validation exactly. In `local` mode, drop the `APP_URL` /
3337 `ALLOWED_DIDS` / `OAUTH_PRIVATE_KEY_JWK` requirements and default `dbPath`
3434- to the OS app-data directory.
3535-- [ ] 3.2 Make the modes mutually exclusive: `QUANTUM_MODE=local` together with
3838+ to the OS app-data directory. — `loadLocalConfig` branch;
3939+ `defaultLocalDbPath` resolves `%APPDATA%`/`Application Support`/XDG. Also
4040+ carries an optional `localDisplayName` from `QUANTUM_LOCAL_DISPLAY_NAME`.
4141+- [x] 3.2 Make the modes mutually exclusive: `QUANTUM_MODE=local` together with
3642 `ALLOWED_DIDS` or OAuth key configuration MUST be a hard configuration
3737- error, so a server can never half-apply the local relaxations.
3838-- [ ] 3.3 Extend `config.test.ts`: local mode loads with none of the server
3939- env vars; local + server config is rejected; server mode is unchanged.
4343+ error, so a server can never half-apply the local relaxations. — Both
4444+ trigger a hard error in `loadLocalConfig`.
4545+- [x] 3.3 Extend `config.test.ts`: local mode loads with none of the server env
4646+ vars; local + server config is rejected; server mode is unchanged. — 4 new
4747+ tests (8 total, all green).
40484149## 4. Local mode boot and no-login auth
42504343-- [ ] 4.1 In `src/hooks.server.ts` `init`, branch on mode. In local mode: skip
4444- `initOAuthClient`; seed `upsertUser(db, 'did:local:self', <display name>)`;
4545- skip the `Deno.cron` registration.
4646-- [ ] 4.2 In `handle`, in local mode set `event.locals.user` to the local user
5151+- [x] 4.1 In `src/hooks.server.ts` `init`, branch on mode. In local mode: skip
5252+ `initOAuthClient`; seed
5353+ `upsertUser(db, 'did:local:self', <display name>)`; skip the `Deno.cron`
5454+ registration. — Seeds when `localDisplayName` is set (the welcome flow
5555+ seeds it otherwise); starts the local sync cadence.
5656+- [x] 4.2 In `handle`, in local mode set `event.locals.user` to the local user
4757 unconditionally and never redirect to `/login`. Server mode is unchanged
4848- (including `add-mcp-server`'s bearer path).
4949-- [ ] 4.3 Read the display name from the first-launch flow (task 7); until set,
5050- block app routes behind the local setup screen rather than a login page.
5151-- [ ] 4.4 Tests: in local mode every protected route resolves as `did:local:self`
5252- with no cookie; manual categorization records the local actor and its
5353- display name surfaces in provenance; server-mode auth is untouched.
5858+ (including `add-mcp-server`'s bearer path). — Split into `handleLocal` /
5959+ `handleServer`; `/login` and `/welcome` redirect to `/` once set up.
6060+ `/mcp` stays token-authenticated even locally.
6161+- [x] 4.3 Read the display name from the first-launch flow (task 7); until set,
6262+ block app routes behind the local setup screen rather than a login page. —
6363+ `handleLocal` redirects protected routes to `/welcome` when
6464+ `did:local:self` is not yet seeded. (The `/welcome` screen itself is task
6565+ 7.)
6666+- [x] 4.4 Tests: in local mode every protected route resolves as
6767+ `did:local:self` with no cookie; manual categorization records the local
6868+ actor and its display name surfaces in provenance; server-mode auth is
6969+ untouched. — Verified live against a headless local-mode server (port
7070+ 5174): `/` and `/ledger` → 200 no login, `/login` → 303 `/`, `/mcp` → 401
7171+ without a token; the DB seeded `did:local:self` / "Graham". Server-mode
7272+ auth unchanged (its full suite still green). Hook logic is verified live
7373+ like the server-mode hooks, which are also not unit-tested (module
7474+ singletons).
54755576## 5. Local sync cadence and loopback MCP mount
56775757-- [ ] 5.1 In local mode, run `runSync(getDb())` once during `init` (catch-up),
5858- then on a `setInterval` at the configured interval while running. Honor the
5959- task 1.1 finding for the interval and for whether background firing is
6060- relied upon. Keep the server's daily `Deno.cron` path unchanged.
6161-- [ ] 5.2 If task 1.2 shows the server port is not reachable, start a loopback
7878+- [x] 5.1 In local mode, run `runSync(getDb())` once during `init` (catch-up),
7979+ then on a `setInterval` at the configured interval while running. Honor
8080+ the task 1.1 finding for the interval and for whether background firing is
8181+ relied upon. Keep the server's daily `Deno.cron` path unchanged. —
8282+ `startLocalSync`; 6-hour interval, fire-and-log on launch. Interval value
8383+ to be revisited against spike 1.1 (background-timer behavior).
8484+- [x] 5.2 If task 1.2 shows the server port is not reachable, start a loopback
6285 `Deno.serve` listener in local mode and mount `add-mcp-server`'s
6386 transport-only MCP handler on it, requiring the same bearer token. If the
6464- port is reachable, skip this and document why.
6565-- [ ] 5.3 Write the loopback endpoint (and optionally a freshly minted token) to a
6666- well-known app-data location so a local agent host can be configured in one
6767- step. The token still goes through the normal scoped/revocable mint path.
6868-- [ ] 5.4 Tests/verification: a local agent reaches MCP over loopback with a valid
6969- token and is rejected without one; sync-on-launch brings data current after
7070- a simulated closed period.
8787+ port is reachable, skip this and document why. — `startLoopbackMcp` binds
8888+ `127.0.0.1:QUANTUM_LOCAL_MCP_PORT` (opt-in via env so the dev/server run
8989+ isn't double-served) and reuses `handleMcpRequest`. Verified live:
9090+ loopback `/mcp` → 401 without a token, full 15-tool access with one.
9191+ Whether it is strictly needed vs. the SvelteKit route is spike 1.2 (needs
9292+ the desktop binary); the fallback is proven functional either way.
9393+- [x] 5.3 Write the loopback endpoint (and optionally a freshly minted token) to
9494+ a well-known app-data location so a local agent host can be configured in
9595+ one step. The token still goes through the normal scoped/revocable mint
9696+ path. — Writes `agent.json` (`{ mcpUrl }`) next to the database on
9797+ loopback start. Deliberately writes the URL only, never a token: the user
9898+ mints a scoped, revocable token in Settings, so no plaintext credential
9999+ sits on disk.
100100+- [x] 5.4 Tests/verification: a local agent reaches MCP over loopback with a
101101+ valid token and is rejected without one; sync-on-launch brings data
102102+ current after a simulated closed period. — Loopback token gating verified
103103+ live (401 without, 15 tools with). Sync-on-launch fires from `init`
104104+ (observed attempting `runSync` on the headless local-mode boot); a genuine
105105+ closed-period catch-up needs a real SimpleFIN connection, so its data
106106+ effect is covered by the existing `sync` service tests.
7110772108## 6. Remote mode thin client and OAuth handoff
7310974110- [ ] 6.1 Register a custom URL scheme for the desktop app (deep link).
7575-- [ ] 6.2 Remote-mode shell: point the webview at the user-provided server origin;
7676- start no embedded server and open no local database.
111111+- [ ] 6.2 Remote-mode shell: point the webview at the user-provided server
112112+ origin; start no embedded server and open no local database.
77113- [ ] 6.3 Server side: add a handoff endpoint that, after a normal ATProto login
78114 completed in the system browser, issues an opaque bearer **session** token
79115 and redirects to the app's registered deep link carrying it. Reuse
···84120 `Authorization: Bearer`, reusing `add-mcp-server`'s bearer path (widened
85121 from API tokens to session tokens).
86122- [ ] 6.5 Tests: a handed-off token authenticates server requests as a bearer
8787- credential; re-presenting it at the handoff step is rejected; the app never
8888- registers as an OAuth client.
123123+ credential; re-presenting it at the handoff step is rejected; the app
124124+ never registers as an OAuth client.
8912590126## 7. First-launch experience
9112792128- [ ] 7.1 Build the first-launch mode-selection screen: Local ("just me, on this
93129 computer") vs Remote ("I have a Quantum server"), shell-less and calm per
94130 DESIGN.md, persisting the choice to app-config.
9595-- [ ] 7.2 Local branch: prompt for a display name and seed the local user with it.
9696-- [ ] 7.3 Remote branch: collect the server address and initiate the OAuth handoff
9797- (task 6).
131131+- [ ] 7.2 Local branch: prompt for a display name and seed the local user with
132132+ it.
133133+- [ ] 7.3 Remote branch: collect the server address and initiate the OAuth
134134+ handoff (task 6).
98135- [ ] 7.4 Add the reset action (Settings) that clears app-config and returns to
99136 the mode-selection screen on next launch. Confirm with a plain sentence
100137 about what reset does and does not delete (local data stays on disk).
101138102139## 8. Verification
103140104104-- [ ] 8.1 Run `deno task test` and `deno task check`. Confirm the server build and
105105- container image are unchanged (server-mode tests all green, no config
141141+- [ ] 8.1 Run `deno task test` and `deno task check`. Confirm the server build
142142+ and container image are unchanged (server-mode tests all green, no config
106143 change required for existing deployments).
107107-- [ ] 8.2 Local mode end to end: fresh launch → choose Local → set a display name
108108- → paste a SimpleFIN token → sync populates → categorize a transaction and
109109- confirm the display name in provenance → close and relaunch (starts in
144144+- [ ] 8.2 Local mode end to end: fresh launch → choose Local → set a display
145145+ name → paste a SimpleFIN token → sync populates → categorize a transaction
146146+ and confirm the display name in provenance → close and relaunch (starts in
110147 Local, syncs on launch) → connect a local agent over loopback MCP and
111148 categorize via a token, confirming `agent` provenance.
112149- [ ] 8.3 Remote mode end to end against a dev server: choose Remote → system-
113113- browser login → deep-link handoff → app authenticates as the logged-in user
114114- → no local database created.
150150+ browser login → deep-link handoff → app authenticates as the logged-in
151151+ user → no local database created.
115152- [ ] 8.4 Reset from each mode returns to the selection screen; local data files
116153 remain on disk after a reset.
117117-- [ ] 8.5 Confirm a hosted server rejects `QUANTUM_MODE=local` combined with
154154+- [x] 8.5 Confirm a hosted server rejects `QUANTUM_MODE=local` combined with
118155 server config, and that the default (no `QUANTUM_MODE`) behaves exactly as
119119- the current release.
156156+ the current release. — Covered by `config.test.ts`: local + `ALLOWED_DIDS`
157157+ or local + OAuth key is rejected; default (no `QUANTUM_MODE`) is server
158158+ mode and the existing server-config tests are unchanged.
···11# add-mcp-server
2233-Expose Quantum's service layer to agents via an MCP server over Streamable HTTP, with bearer API tokens and a new 'agent' provenance source
33+Expose Quantum's service layer to agents via an MCP server over Streamable HTTP,
44+with bearer API tokens and a new 'agent' provenance source
···11## Context
2233-Quantum's server core is transport-agnostic by construction (`src/lib/server/README.md`,
44-design D6). The relevant current state:
33+Quantum's server core is transport-agnostic by construction
44+(`src/lib/server/README.md`, design D6). The relevant current state:
5566- **Services are pure-ish functions.** Every domain operation in
77 `src/lib/server/services/` takes typed inputs and a `DatabaseSync` handle and
···1616 `ruleId`, `manual` requires an `actorDid`) and updates the denormalized
1717 `transactions.category_id` cache in the same DB transaction (design D4).
1818- **Sessions are opaque tokens** (`sessions.ts`), delivered by HTTP-only cookie
1919- but documented as bearer-ready. The `handle` hook (`hooks.server.ts:47`)
2020- reads the cookie, resolves it to a user, sets `event.locals.user`, and
2121- redirects unauthenticated requests away from non-public paths.
1919+ but documented as bearer-ready. The `handle` hook (`hooks.server.ts:47`) reads
2020+ the cookie, resolves it to a user, sets `event.locals.user`, and redirects
2121+ unauthenticated requests away from non-public paths.
2222- **The auth spec already anticipated this**: session tokens "SHALL NOT assume
2323 cookie transport, so future native clients can present the same token as a
2424 bearer credential."
···5151**Non-Goals:**
52525353- Spec-compliant MCP OAuth. The MCP spec's HTTP auth story casts the resource
5454- server as an OAuth authorization server. Quantum is an ATProto OAuth *client*,
5454+ server as an OAuth authorization server. Quantum is an ATProto OAuth _client_,
5555 not an AS; standing up a full AS for a two-person household app is
5656 disproportionate. Bearer tokens minted in-app are the deliberate trade — see
5757 decision 3.
···69697070**Decision:** The MCP server is a single web-standard request handler bound to
7171`WebStandardStreamableHTTPServerTransport`. It mounts today as `POST /mcp` via a
7272-SvelteKit `+server.ts`. The forthcoming desktop change mounts the *same* handler
7272+SvelteKit `+server.ts`. The forthcoming desktop change mounts the _same_ handler
7373on a loopback `Deno.serve` listener for local mode. The handler never reads a
7474cookie, a SvelteKit `event`, or an environment mode.
75757676**Why:** The two modalities the product targets — remote (agent → hosted server)
7777and local (agent → desktop app) — are the same protocol at two addresses. An MCP
7878-handler is a pure function from an HTTP request to an HTTP response, so "the same
7979-code at two mounts" is achievable rather than aspirational. Encoding the
7878+handler is a pure function from an HTTP request to an HTTP response, so "the
7979+same code at two mounts" is achievable rather than aspirational. Encoding the
8080transport as a bare handler keeps local mode from ever needing a second
8181implementation, and keeps this change from having to know local mode exists.
82828383**Alternative considered:** A stdio MCP binary that opens the SQLite file
8484directly for local mode. Rejected: it puts a second process on the same database
8585as the desktop app, and `initDb`'s process-wide singleton, the migration runner,
8686-and the sync scheduler all assume a single writer. A stdio *shim* that proxies
8686+and the sync scheduler all assume a single writer. A stdio _shim_ that proxies
8787stdio ⇄ localhost HTTP remains available later purely for agent-host UX (hosts
8888that only speak stdio), but it is a proxy over this handler, not a second core.
8989···109109endpoint accepts only `Authorization: Bearer <token>`.
110110111111**Why:** This reuses the opaque-token model design D6 already blessed and keeps
112112-the credential surface uniform. Standing up an OAuth AS to satisfy the MCP spec's
113113-HTTP auth section would be weeks of PKCE/PAR/consent machinery to protect two
114114-users who can paste a token in ten seconds. The cost of the trade is that MCP
115115-hosts offering "add server by URL with automatic OAuth" won't complete a flow —
116116-users paste a token instead. For this audience that is acceptable, and stated
117117-plainly rather than hidden.
112112+the credential surface uniform. Standing up an OAuth AS to satisfy the MCP
113113+spec's HTTP auth section would be weeks of PKCE/PAR/consent machinery to protect
114114+two users who can paste a token in ten seconds. The cost of the trade is that
115115+MCP hosts offering "add server by URL with automatic OAuth" won't complete a
116116+flow — users paste a token instead. For this audience that is acceptable, and
117117+stated plainly rather than hidden.
118118119119-**Storage as a hash, not plaintext:** a leaked database should not hand over live
120120-agent credentials, and sessions already set the precedent that the server holds
121121-opaque handles, not reversible secrets. `last_used_at` turns a forgotten token
122122-into something visible and revocable rather than an invisible standing grant.
119119+**Storage as a hash, not plaintext:** a leaked database should not hand over
120120+live agent credentials, and sessions already set the precedent that the server
121121+holds opaque handles, not reversible secrets. `last_used_at` turns a forgotten
122122+token into something visible and revocable rather than an invisible standing
123123+grant.
123124124125### 4. Bearer resolution lives in the hook, beside cookie resolution
125126126127**Decision:** `handle` in `hooks.server.ts` resolves auth in one place: if a
127127-session cookie is present, resolve it as today; else if an `Authorization:
128128-Bearer` header is present, verify it against `api_tokens` and attach the
129129-resulting principal to `event.locals`. `/mcp` is not added to `PUBLIC_PATHS`, so
130130-an unauthenticated MCP request is rejected by the existing guard.
128128+session cookie is present, resolve it as today; else if an
129129+`Authorization:
130130+Bearer` header is present, verify it against `api_tokens` and
131131+attach the resulting principal to `event.locals`. `/mcp` is not added to
132132+`PUBLIC_PATHS`, so an unauthenticated MCP request is rejected by the existing
133133+guard.
131134132135**Why:** One authentication chokepoint is easier to keep correct than two.
133136Putting bearer resolution beside cookie resolution means every protected route
134137automatically accepts a token too, which is exactly what the desktop remote mode
135135-will need for *session* bearer tokens later — this change builds that seam for
138138+will need for _session_ bearer tokens later — this change builds that seam for
136139API tokens, and the companion change widens it to OAuth-issued session tokens.
137140138141**The principal is richer than today's `event.locals.user`.** A cookie yields a
139139-user; a token yields a token identity that *may* carry a user DID and always
142142+user; a token yields a token identity that _may_ carry a user DID and always
140143carries a scope. `event.locals` gains an optional `apiToken` (id, scope,
141144userDid?) alongside `user`, so a route/tool can enforce scope and attribute
142145authorship correctly. Web routes ignore it and keep using `user`.
···144147### 5. `agent` is a fourth event source, attributed to a token
145148146149**Decision:** `EventSource` becomes
147147-`'rule' | 'manual' | 'reconciliation' | 'agent'`.
148148-`categorization_events` gains a nullable `api_token_id` FK.
149149-`appendCategorizationEvent` enforces that an `agent` event carries an
150150-`apiTokenId` (and no `actorDid`), mirroring the existing invariant that a
151151-`manual` event carries an `actorDid`. A new `categorizeByAgent(db, txId,
152152-categoryId, apiTokenId)` parallels `categorizeManually`.
150150+`'rule' | 'manual' | 'reconciliation' | 'agent'`. `categorization_events` gains
151151+a nullable `api_token_id` FK. `appendCategorizationEvent` enforces that an
152152+`agent` event carries an `apiTokenId` (and no `actorDid`), mirroring the
153153+existing invariant that a `manual` event carries an `actorDid`. A new
154154+`categorizeByAgent(db, txId,
155155+categoryId, apiTokenId)` parallels
156156+`categorizeManually`.
153157154158**Why:** The product's first principle is that every categorization traces to a
155159rule or a person. An agent is neither, and both available shortcuts are wrong:
···157161person's handle on a categorization a person never made — a quiet lie in the one
158162place the product promises the truth; leaving it anonymous would break the
159163"always traces to something" guarantee. A distinct source attributed to the
160160-token is the honest encoding: the badge reads `🤖 <token label>`, the popover can
161161-name the human the token belongs to, and the audit trail is exact.
164164+token is the honest encoding: the badge reads `🤖 <token label>`, the popover
165165+can name the human the token belongs to, and the audit trail is exact.
162166163167**Manual still outranks agent.** Agent categorization uses the same "humans
164168outrank everything" rule already in force — an `agent` event can set a category
···179183180184Read:
181185182182-| Tool | Service |
183183-| --- | --- |
184184-| `list_accounts` | `listAccounts` + `listStaleAccounts` |
185185-| `list_transactions` | `listLedger`, `listMonths` |
186186-| `get_transaction_history` | `listEvents` |
187187-| `get_monthly_report` | `monthlyReport` + `pendingStats` |
188188-| `get_net_worth` | `netWorthSeries` |
189189-| `list_categories` | `listCategories` |
190190-| `list_rules` | `listRules` + `ruleMatchHealth` |
191191-| `probe_rule` | `probeRule` / `countRuleMatches` |
192192-| `get_sync_status` | `getLastSync` + `getConnectionErrors` |
186186+| Tool | Service |
187187+| ------------------------- | ------------------------------------- |
188188+| `list_accounts` | `listAccounts` + `listStaleAccounts` |
189189+| `list_transactions` | `listLedger`, `listMonths` |
190190+| `get_transaction_history` | `listEvents` |
191191+| `get_monthly_report` | `monthlyReport` + `pendingStats` |
192192+| `get_net_worth` | `netWorthSeries` |
193193+| `list_categories` | `listCategories` |
194194+| `list_rules` | `listRules` + `ruleMatchHealth` |
195195+| `probe_rule` | `probeRule` / `countRuleMatches` |
196196+| `get_sync_status` | `getLastSync` + `getConnectionErrors` |
193197194198Write:
195199196196-| Tool | Service |
197197-| --- | --- |
200200+| Tool | Service |
201201+| ------------------------ | --------------------------------------- |
198202| `categorize_transaction` | `categorizeByAgent` (`source: 'agent'`) |
199199-| `create_category` | `createCategory` |
200200-| `create_rule` | `createRule` |
201201-| `set_rule_active` | `setRuleActive` |
202202-| `apply_rules` | `applyRulesToUncategorized` |
203203-| `trigger_sync` | `runSync` |
203203+| `create_category` | `createCategory` |
204204+| `create_rule` | `createRule` |
205205+| `set_rule_active` | `setRuleActive` |
206206+| `apply_rules` | `applyRulesToUncategorized` |
207207+| `trigger_sync` | `runSync` |
204208205209**Why these and not others:** the read set makes "where did our money go this
206206-month, and are we gaining ground?" fully answerable — the product's two
207207-headline questions. The write set targets the toil (categorization) and its
208208-force multiplier (rules), plus the freshness lever (sync). `probe_rule` is
209209-paired with `create_rule` deliberately: an agent should dry-run a pattern's blast
210210-radius before committing it, and the tool descriptions will say so.
210210+month, and are we gaining ground?" fully answerable — the product's two headline
211211+questions. The write set targets the toil (categorization) and its force
212212+multiplier (rules), plus the freshness lever (sync). `probe_rule` is paired with
213213+`create_rule` deliberately: an agent should dry-run a pattern's blast radius
214214+before committing it, and the tool descriptions will say so.
211215212216**Why not more:** account hide/classify and category rename are settings-surface
213213-polish, easy to add once the pattern exists; CSV import is an interactive wizard;
214214-`claimSetupToken` handles a bank credential and stays human-only; nothing touches
215215-sessions or users. The catalog is intentionally small and boring — every tool is
216216-a function that already has tests.
217217+polish, easy to add once the pattern exists; CSV import is an interactive
218218+wizard; `claimSetupToken` handles a bank credential and stays human-only;
219219+nothing touches sessions or users. The catalog is intentionally small and boring
220220+— every tool is a function that already has tests.
217221218222### 7. Scope enforcement at the tool boundary
219223···230234231235- **A bearer token is a durable key to the whole ledger** → shown once, stored
232236 hashed, scoped, individually revocable, and stamped with `last_used_at` so a
233233- stale token is visible. The write surface excludes account creation, connection
234234- setup, and hard deletion, so the blast radius of a leaked `readwrite` token is
235235- bounded to reversible, auditable categorization changes.
237237+ stale token is visible. The write surface excludes account creation,
238238+ connection setup, and hard deletion, so the blast radius of a leaked
239239+ `readwrite` token is bounded to reversible, auditable categorization changes.
236240237241- **Non-spec-compliant MCP auth** means some hosts' automatic OAuth "add by URL"
238242 flows won't work; users paste a token → accepted trade for a two-person app,
239243 stated in the docs rather than papered over. If a host strictly requires the
240244 OAuth flow, that host is unsupported for now.
241245242242-- **An agent miscategorizes at scale** → every agent write is an `agent` event in
243243- the append-only log, attributed to a named token, and never overwrites a manual
244244- choice. Undoing a bad batch is re-categorizing, and the provenance trail shows
245245- exactly which token did what and when.
246246+- **An agent miscategorizes at scale** → every agent write is an `agent` event
247247+ in the append-only log, attributed to a named token, and never overwrites a
248248+ manual choice. Undoing a bad batch is re-categorizing, and the provenance
249249+ trail shows exactly which token did what and when.
246250247251- **Two writers to the database in future local mode** (desktop app + an MCP
248252 mount) → avoided by decision 1: the local mount is in-process on the same
249253 `DatabaseSync` handle, not a second process. This change, running only as a
250254 SvelteKit route, has a single writer regardless.
251255252252-- **The SDK's web-standard transport is newer than its Node transport** → de-risk
253253- with an early spike (task 1) that stands up the transport in a `+server.ts`
254254- under Deno and completes one `initialize` + `tools/list` round-trip before any
255255- tool is wired.
256256+- **The SDK's web-standard transport is newer than its Node transport** →
257257+ de-risk with an early spike (task 1) that stands up the transport in a
258258+ `+server.ts` under Deno and completes one `initialize` + `tools/list`
259259+ round-trip before any tool is wired.
256260257261## Migration Plan
258262···263267 UNIQUE), `scope` NOT NULL CHECK in (`read`, `readwrite`), `user_did`
264268 (nullable, FK to users), `created_at` NOT NULL, `last_used_at` (nullable).
265269- `ALTER TABLE categorization_events ADD COLUMN api_token_id INTEGER REFERENCES
266266- api_tokens (id)` — nullable; set only on `agent` events.
270270+ api_tokens (id)`
271271+ — nullable; set only on `agent` events.
267272- Recreate `categorization_events` with a widened `source` CHECK admitting
268273 `agent` (SQLite cannot alter a CHECK in place; follow the existing
269274 table-rebuild pattern used elsewhere in migrations, preserving all rows and
270275 the append-only guarantee). If the current schema declares `source` without a
271276 CHECK constraint, this step is a no-op beyond documentation.
272277273273-Every existing categorization event reads back with `api_token_id IS NULL`, which
274274-is correct — none were agent-sourced. Forward-only, no data rewrite of values.
278278+Every existing categorization event reads back with `api_token_id IS NULL`,
279279+which is correct — none were agent-sourced. Forward-only, no data rewrite of
280280+values.
275281276282## Resolved During Implementation
277283278278-- **Revocation must be soft, not a hard delete.** `categorization_events.api_token_id`
279279- is a foreign key to `api_tokens`, so once a token has authored even one agent
280280- categorization, `DELETE FROM api_tokens` fails the FK constraint — and forcing
281281- it would orphan the provenance the whole feature exists to preserve. Revoke
282282- therefore stamps a `revoked_at` column: `verifyToken` refuses a revoked token
283283- immediately (satisfying "revocation takes effect immediately"), `listTokens`
284284- hides it from the management list, and the row survives so its label keeps
285285- resolving in the append-only event history. Found by revoking a token that had
286286- categorized a live transaction; the migration and service were updated and a
287287- regression test added. `api_tokens` gains `revoked_at TEXT` (nullable).
284284+- **Revocation must be soft, not a hard delete.**
285285+ `categorization_events.api_token_id` is a foreign key to `api_tokens`, so once
286286+ a token has authored even one agent categorization, `DELETE FROM api_tokens`
287287+ fails the FK constraint — and forcing it would orphan the provenance the whole
288288+ feature exists to preserve. Revoke therefore stamps a `revoked_at` column:
289289+ `verifyToken` refuses a revoked token immediately (satisfying "revocation
290290+ takes effect immediately"), `listTokens` hides it from the management list,
291291+ and the row survives so its label keeps resolving in the append-only event
292292+ history. Found by revoking a token that had categorized a live transaction;
293293+ the migration and service were updated and a regression test added.
294294+ `api_tokens` gains `revoked_at TEXT` (nullable).
288295289296## Open Questions
290297291291-- **Token label uniqueness** — should two tokens be allowed the same label? Leaning
292292- yes (label is a human hint, `id` is identity), resolved at implementation.
293293-- **Rate limiting `trigger_sync`** — `runSync` hits SimpleFIN; an agent looping on
294294- it could hammer the Bridge. A minimum interval (reuse the daily-sync rationale)
295295- may be worth enforcing in the tool wrapper. Decide when wiring the tool.
298298+- **Token label uniqueness** — should two tokens be allowed the same label?
299299+ Leaning yes (label is a human hint, `id` is identity), resolved at
300300+ implementation.
301301+- **Rate limiting `trigger_sync`** — `runSync` hits SimpleFIN; an agent looping
302302+ on it could hammer the Bridge. A minimum interval (reuse the daily-sync
303303+ rationale) may be worth enforcing in the tool wrapper. Decide when wiring the
304304+ tool.
···55The system SHALL persist application sessions and OAuth client state (state
66store, session store) in SQLite. Sessions SHALL be identified by opaque tokens,
77delivered to the web frontend via HTTP-only cookie; the token format SHALL NOT
88-assume cookie transport, so future native clients can present the same token as a
99-bearer credential. The system SHALL additionally accept a bearer API token: a
1010-request that presents no session cookie but carries a valid `Authorization:
1111-Bearer` API token SHALL be authenticated as that token's principal, carrying the
1212-token's scope and, where present, the creating user's DID. Bearer resolution
1313-SHALL occur in the same request-handling chokepoint as cookie resolution, and
1414-SHALL NOT alter cookie-session behavior. Every route except login, the OAuth
1515-callback, client metadata, and JWKS SHALL require either a valid session or a
1616-valid API token. Users SHALL be able to log out, which destroys the application
1717-session.
88+assume cookie transport, so future native clients can present the same token as
99+a bearer credential. The system SHALL additionally accept a bearer API token: a
1010+request that presents no session cookie but carries a valid
1111+`Authorization:
1212+Bearer` API token SHALL be authenticated as that token's
1313+principal, carrying the token's scope and, where present, the creating user's
1414+DID. Bearer resolution SHALL occur in the same request-handling chokepoint as
1515+cookie resolution, and SHALL NOT alter cookie-session behavior. Every route
1616+except login, the OAuth callback, client metadata, and JWKS SHALL require either
1717+a valid session or a valid API token. Users SHALL be able to log out, which
1818+destroys the application session.
18191920#### Scenario: Unauthenticated access to a protected route
20212121-- **WHEN** a request without a valid session cookie and without a valid API token
2222- targets any protected route
2222+- **WHEN** a request without a valid session cookie and without a valid API
2323+ token targets any protected route
2324- **THEN** the system redirects to the login page or, for an API endpoint,
2425 rejects the request as unauthorized
2526···3334#### Scenario: Cookie session unchanged
34353536- **WHEN** a browser request presents a valid session cookie
3636-- **THEN** it is authenticated exactly as before, regardless of any bearer header
3737+- **THEN** it is authenticated exactly as before, regardless of any bearer
3838+ header
37393840#### Scenario: Logout
3941
···19192020#### Scenario: Authenticated tools listing
21212222-- **WHEN** a client presents a valid token and issues an MCP `tools/list` request
2222+- **WHEN** a client presents a valid token and issues an MCP `tools/list`
2323+ request
2324- **THEN** the system returns the catalog of tools available to that token's
2425 scope
2526···27282829- **WHEN** the MCP handler processes a request
2930- **THEN** it derives the caller's identity solely from the bearer token, reads
3030- no session cookie, and depends on no web-view state, so the same handler can be
3131- mounted outside the web router
3131+ no session cookie, and depends on no web-view state, so the same handler can
3232+ be mounted outside the web router
32333334### Requirement: Bearer API tokens
3435···5960#### Scenario: Token labelled for recognition
60616162- **WHEN** a user views their API tokens in Settings
6262-- **THEN** each token is listed by its label, scope, creation time, and last-used
6363- time, and the token value is not shown
6363+- **THEN** each token is listed by its label, scope, creation time, and
6464+ last-used time, and the token value is not shown
64656566### Requirement: Read tool catalog
66676768The system SHALL expose read-only tools, available to any valid token regardless
6868-of scope, that surface the existing service layer without modifying data: listing
6969-accounts with balances and staleness, listing and filtering transactions,
7070-retrieving a transaction's full categorization history, producing a monthly
7171-income-versus-expense report, retrieving the net-worth series, listing
6969+of scope, that surface the existing service layer without modifying data:
7070+listing accounts with balances and staleness, listing and filtering
7171+transactions, retrieving a transaction's full categorization history, producing
7272+a monthly income-versus-expense report, retrieving the net-worth series, listing
7273categories, listing rules with their match health, probing a prospective or
7374existing rule without applying it, and reporting sync status and connection
7475errors. A read tool SHALL NOT modify any transaction, event, rule, category,
···95969697The system SHALL expose write tools — categorizing a transaction, creating a
9798category, creating a rule, activating or deactivating a rule, applying rules to
9898-uncategorized transactions, and triggering a sync — and SHALL permit them only to
9999-a token whose scope is `readwrite`. A write tool invoked with a `read`-scope
9999+uncategorized transactions, and triggering a sync — and SHALL permit them only
100100+to a token whose scope is `readwrite`. A write tool invoked with a `read`-scope
100101token SHALL be refused without effect. The write catalog SHALL NOT include
101102creating accounts, claiming SimpleFIN setup tokens, importing CSV files, or
102103hard-deleting any record.
103104104105#### Scenario: Read token refused a write tool
105106106106-- **WHEN** a client authenticating with a `read`-scope token invokes a write tool
107107+- **WHEN** a client authenticating with a `read`-scope token invokes a write
108108+ tool
107109- **THEN** the system refuses the call and makes no change
108110109111#### Scenario: Readwrite token categorizes
···44 minimal `src/routes/mcp/+server.ts` that binds `McpServer` to
55 `WebStandardStreamableHTTPServerTransport` in stateless mode and registers
66 one trivial read tool.
77-- [x] 1.2 Confirm under Deno (both `deno task dev` via Vite and a `deno task
88- build` node-adapter build) that an MCP client completes `initialize` +
99- `tools/list` + one `tools/call` round-trip against `/mcp`, with no Node
1010- `IncomingMessage`/`ServerResponse` shimming required. Confirm `deno task
1111- check` stays green with the new dependency (watch for the JSR-vs-npm
1212- resolution trap that bit `csv-parse`).
77+- [x] 1.2 Confirm under Deno (both `deno task dev` via Vite and a
88+ `deno task
99+ build` node-adapter build) that an MCP client completes
1010+ `initialize` + `tools/list` + one `tools/call` round-trip against `/mcp`,
1111+ with no Node `IncomingMessage`/`ServerResponse` shimming required. Confirm
1212+ `deno task
1313+ check` stays green with the new dependency (watch for the
1414+ JSR-vs-npm resolution trap that bit `csv-parse`).
13151416 **Spike findings (resolved):** SDK `1.29.0`. Transport is
1517 `WebStandardStreamableHTTPServerTransport` from
···3436 UNIQUE), `scope` NOT NULL CHECK in (`read`, `readwrite`), `user_did`
3537 (nullable, FK to users), `created_at` (NOT NULL), `last_used_at`
3638 (nullable). — `migrations/006_api_tokens.sql`.
3737-- [x] 2.2 In the same migration, add `api_token_id INTEGER REFERENCES api_tokens
3838- (id)` (nullable) to `categorization_events`, and widen the `source` value
3939- set to admit `agent`. If `source` is constrained by a CHECK, rebuild the
4040- table following the existing migration table-rebuild pattern, preserving
4141- every row and the append-only guarantee; if it is unconstrained, document
4242- that no rebuild is needed. — `source` had a CHECK; table rebuilt
4343- (leaf table, no incoming FKs) with a new `source != 'agent' OR
4444- api_token_id IS NOT NULL` invariant. No prior table-rebuild pattern
4545- existed in migrations; this is the first.
4646-- [x] 2.3 Extend `src/lib/server/db.test.ts` to assert the migration applies to a
4747- populated database, that existing `categorization_events` read back with
3939+- [x] 2.2 In the same migration, add
4040+ `api_token_id INTEGER REFERENCES api_tokens
4141+ (id)` (nullable) to
4242+ `categorization_events`, and widen the `source` value set to admit
4343+ `agent`. If `source` is constrained by a CHECK, rebuild the table
4444+ following the existing migration table-rebuild pattern, preserving every
4545+ row and the append-only guarantee; if it is unconstrained, document that
4646+ no rebuild is needed. — `source` had a CHECK; table rebuilt (leaf table,
4747+ no incoming FKs) with a new
4848+ `source != 'agent' OR
4949+ api_token_id IS NOT NULL` invariant. No prior
5050+ table-rebuild pattern existed in migrations; this is the first.
5151+- [x] 2.3 Extend `src/lib/server/db.test.ts` to assert the migration applies to
5252+ a populated database, that existing `categorization_events` read back with
4853 `api_token_id IS NULL`, and that an `agent`-source event inserts. Also
4954 relaxed the csv test's brittle exact-migration-count assertion to a
5055 `schema_version` membership check (it broke when 006 was added, and would
···52575358## 3. API token service
54595555-- [x] 3.1 Create `src/lib/server/services/api-tokens.ts`: `mintToken(db, label,
5656- scope, userDid?)` returning the one-time plaintext plus the stored record;
5757- persist only the hash (hash the `randomBytes(32)` value; do not store
5858- plaintext). `verifyToken(db, plaintext)` returning a principal (`{ id,
5959- scope, userDid }`) or null, and touching `last_used_at` on success.
6060- `listTokens(db)`, `revokeToken(db, id)`. — SHA-256 over a `qtm_`-prefixed
6161- 256-bit random value; fast hash is sufficient for a high-entropy secret.
6060+- [x] 3.1 Create `src/lib/server/services/api-tokens.ts`:
6161+ `mintToken(db, label,
6262+ scope, userDid?)` returning the one-time
6363+ plaintext plus the stored record; persist only the hash (hash the
6464+ `randomBytes(32)` value; do not store plaintext).
6565+ `verifyToken(db, plaintext)` returning a principal
6666+ (`{ id,
6767+ scope, userDid }`) or null, and touching `last_used_at` on
6868+ success. `listTokens(db)`, `revokeToken(db, id)`. — SHA-256 over a
6969+ `qtm_`-prefixed 256-bit random value; fast hash is sufficient for a
7070+ high-entropy secret.
6271- [x] 3.2 Relative imports within `src/lib/server/` only, so the service and its
6372 test run under plain `deno test`. Add `api-tokens.test.ts`: mint→verify
6473 round-trip, hash-not-plaintext storage, revoked token fails verify,
···7584 `/mcp` a clean 401 (not the browser login redirect) when unauthenticated.
7685- [x] 4.2 Update `app.d.ts` `App.Locals` with the optional `apiToken` principal.
7786 — Imports `TokenPrincipal` from the service.
7878-- [x] 4.3 Confirm cookie-session routes are entirely unaffected (a request with a
7979- cookie ignores any bearer header; a request with neither is redirected/
8787+- [x] 4.3 Confirm cookie-session routes are entirely unaffected (a request with
8888+ a cookie ignores any bearer header; a request with neither is redirected/
8089 rejected as today). — Verified live over HTTP: unauthenticated `/mcp` →
8190 401, valid bearer → 200 initialize, bad bearer → 401, `/ledger` without a
8291 cookie → 303 `/login` (unchanged).
···8998 (mirroring the `manual`-requires-`actorDid` invariant). Persist
9099 `api_token_id` in the event insert.
91100- [x] 5.2 Add `categorizeByAgent(db, transactionId, categoryId, apiTokenId)`
9292- paralleling `categorizeManually`, and extend the manual-outranks rule so an
9393- agent write refuses a transaction whose latest event is `manual`. — Returns
9494- `boolean` (false = refused because a manual event is latest); tool wrapper
9595- (task 6) surfaces the refusal to the agent.
101101+ paralleling `categorizeManually`, and extend the manual-outranks rule so
102102+ an agent write refuses a transaction whose latest event is `manual`. —
103103+ Returns `boolean` (false = refused because a manual event is latest); tool
104104+ wrapper (task 6) surfaces the refusal to the agent.
96105- [x] 5.3 Extend `listEvents` selection and the `CategorizationEvent` shape to
97106 carry the agent token's label (join `api_tokens`) for provenance display.
98107 — Added `apiTokenId` and `actorTokenLabel` to the event shape.
9999-- [x] 5.4 Tests in `categorization.test.ts`: an `agent` event requires a token id;
100100- an agent write over a `manual` latest event is refused; over a `rule` or
101101- empty latest event it succeeds; history lists the agent event with its
108108+- [x] 5.4 Tests in `categorization.test.ts`: an `agent` event requires a token
109109+ id; an agent write over a `manual` latest event is refused; over a `rule`
110110+ or empty latest event it succeeds; history lists the agent event with its
102111 token label. — New file, 5 tests; full suite 108 passed, check green.
103112104113## 6. MCP server and tool catalog
105114106106-- [x] 6.1 Create the MCP server module wiring the tool catalog to services. Group
107107- tools by required scope; read the authenticating principal's scope from
108108- `event.locals.apiToken` and refuse write tools to a `read` token at
115115+- [x] 6.1 Create the MCP server module wiring the tool catalog to services.
116116+ Group tools by required scope; read the authenticating principal's scope
117117+ from `event.locals.apiToken` and refuse write tools to a `read` token at
109118 dispatch, without reaching the service. — `src/lib/server/mcp/server.ts`;
110110- `buildMcpServer(db, principal)` closes over the principal per request; write
111111- tools call a `requireWrite()` guard that returns an error result for `read`
112112- scope. All 15 tools are always listed (a read token sees them, is refused
113113- execution) per the spec.
119119+ `buildMcpServer(db, principal)` closes over the principal per request;
120120+ write tools call a `requireWrite()` guard that returns an error result for
121121+ `read` scope. All 15 tools are always listed (a read token sees them, is
122122+ refused execution) per the spec.
114123- [x] 6.2 Implement the nine read tools as thin wrappers: `list_accounts`,
115124 `list_transactions`, `get_transaction_history`, `get_monthly_report`,
116125 `get_net_worth`, `list_categories`, `list_rules`, `probe_rule`,
···120129 `create_rule`, `set_rule_active`, `apply_rules`, `trigger_sync`. In each
121130 tool's description, direct the agent to `probe_rule` before `create_rule`.
122131 — `create_rule` uses `principal.userDid` as creator; errors clearly if the
123123- token has no bound user (the local-mode case this change doesn't yet serve).
124124-- [x] 6.4 Consider a minimum interval on `trigger_sync` in its wrapper so an agent
125125- loop cannot hammer the SimpleFIN Bridge (reuse the daily-sync rationale);
126126- decide and document inline. — 15-minute minimum; a too-soon call returns a
127127- `skipped` result naming when the last sync ran.
132132+ token has no bound user (the local-mode case this change doesn't yet
133133+ serve).
134134+- [x] 6.4 Consider a minimum interval on `trigger_sync` in its wrapper so an
135135+ agent loop cannot hammer the SimpleFIN Bridge (reuse the daily-sync
136136+ rationale); decide and document inline. — 15-minute minimum; a too-soon
137137+ call returns a `skipped` result naming when the last sync ran.
128138- [x] 6.5 Replace the spike route with the real `src/routes/mcp/+server.ts`: a
129139 thin adapter handing the request to the transport-bound handler. Keep the
130130- handler free of SvelteKit/cookie reads so the desktop change can remount it.
131131- — Route reads `locals.apiToken` (set by the hook) and delegates to
132132- `handleMcpRequest`; the handler itself takes only `(request, db, principal)`.
133133-- [x] 6.6 Tests: a `read` token lists all tools but is refused each
134134- write tool; a `readwrite` token categorizes a transaction and the resulting
135135- event is `agent`-sourced with the token id; `probe_rule` mutates nothing.
136136- — `mcp/server.test.ts` drives the real transport in-process via
140140+ handler free of SvelteKit/cookie reads so the desktop change can remount
141141+ it. — Route reads `locals.apiToken` (set by the hook) and delegates to
142142+ `handleMcpRequest`; the handler itself takes only
143143+ `(request, db, principal)`.
144144+- [x] 6.6 Tests: a `read` token lists all tools but is refused each write tool;
145145+ a `readwrite` token categorizes a transaction and the resulting event is
146146+ `agent`-sourced with the token id; `probe_rule` mutates nothing. —
147147+ `mcp/server.test.ts` drives the real transport in-process via
137148 `handleMcpRequest` + constructed `Request`s; 4 tests, all green.
138149139150## 7. Settings: connect an agent
140151141141-- [x] 7.1 Add a Settings section "Connect an agent": list existing tokens (label,
142142- scope, created, last used) and a mint form (label + scope). On mint, show
143143- the token value once with a copy affordance and a plain caution that it
144144- will not be shown again. — Verified live: the mint action returns the
145145- one-time token + label; the reveal block shows it with a Copy button.
146146-- [x] 7.2 Add the revoke action with a confirmation naming the token label.
147147- — Revoke is soft (see design's Resolved-During-Implementation note); the
152152+- [x] 7.1 Add a Settings section "Connect an agent": list existing tokens
153153+ (label, scope, created, last used) and a mint form (label + scope). On
154154+ mint, show the token value once with a copy affordance and a plain caution
155155+ that it will not be shown again. — Verified live: the mint action returns
156156+ the one-time token + label; the reveal block shows it with a Copy button.
157157+- [x] 7.2 Add the revoke action with a confirmation naming the token label. —
158158+ Revoke is soft (see design's Resolved-During-Implementation note); the
148159 row's label survives for provenance. Verified the action removes the token
149160 from the live list.
150161- [x] 7.3 Render an empty state (one sentence, one action) for no-tokens-yet.
151162 Style per DESIGN.md: monospace for the token value, tabular numerals on
152163 timestamps, no new red unless data is at risk. — The MCP URL and token
153153- value use `--font-mono`; timestamps carry `tnum`; the reveal uses a neutral
154154- `--bg-sunken` panel, no alarm color.
164164+ value use `--font-mono`; timestamps carry `tnum`; the reveal uses a
165165+ neutral `--bg-sunken` panel, no alarm color.
155166156167## 8. Verification
157168···162173 uncategorized transactions, probe a pattern, create a rule, categorize a
163174 handful of transactions, and confirm in the web ledger that each shows an
164175 `agent` provenance badge with the token label — and that a manually
165165- categorized transaction is left untouched by an agent attempt. — Driven via
166166- real MCP JSON-RPC over the live `/mcp` route: `initialize`, `tools/list`
167167- (15), read tools (accounts, net worth, sync status, list transactions,
168168- list categories), and `categorize_transaction` on a live uncategorized
169169- row, then `get_transaction_history` confirmed `source: agent` with the
170170- token label and no actor DID. Rule creation, probe, and manual-outranks are
171171- covered by the in-process integration tests (`mcp/server.test.ts`,
172172- `categorization.test.ts`).
173173-- [x] 8.3 Confirm a `read`-scope token can perform every read tool and is refused
174174- every write tool. — Verified live: a read token reads `list_categories`
175175- (no error) and is refused `categorize_transaction` with the read-write
176176- message; the in-process test refuses all six write tools for a read token.
177177-- [x] 8.4 Revoke the token mid-session and confirm the next tool call is rejected.
178178- — Verified live: an authenticated `get_net_worth` returned 200, the token
179179- was soft-revoked, and the identical next call returned 401.
176176+ categorized transaction is left untouched by an agent attempt. — Driven
177177+ via real MCP JSON-RPC over the live `/mcp` route: `initialize`,
178178+ `tools/list` (15), read tools (accounts, net worth, sync status, list
179179+ transactions, list categories), and `categorize_transaction` on a live
180180+ uncategorized row, then `get_transaction_history` confirmed
181181+ `source: agent` with the token label and no actor DID. Rule creation,
182182+ probe, and manual-outranks are covered by the in-process integration tests
183183+ (`mcp/server.test.ts`, `categorization.test.ts`).
184184+- [x] 8.3 Confirm a `read`-scope token can perform every read tool and is
185185+ refused every write tool. — Verified live: a read token reads
186186+ `list_categories` (no error) and is refused `categorize_transaction` with
187187+ the read-write message; the in-process test refuses all six write tools
188188+ for a read token.
189189+- [x] 8.4 Revoke the token mid-session and confirm the next tool call is
190190+ rejected. — Verified live: an authenticated `get_net_worth` returned 200,
191191+ the token was soft-revoked, and the identical next call returned 401.
180192- [x] 8.5 Confirm the web app is unchanged for cookie users: login, categorize,
181193 logout all behave as before. — The hook consults a bearer token only when
182194 no cookie session resolves, so the cookie path is structurally untouched;
183195 verified a cookie session renders `/settings` (including the new section)
184196 and that a request with neither cookie nor token still redirects `/ledger`
185197 → `/login`. Full OAuth login/logout not driven (needs a live ATProto
186186- provider); the cookie-resolution code is unchanged from before this change.
198198+ provider); the cookie-resolution code is unchanged from before this
199199+ change.
···11import { building } from "$app/environment";
22-import { type Handle, redirect, type ServerInit } from "@sveltejs/kit";
22+import {
33+ type Handle,
44+ redirect,
55+ type RequestEvent,
66+ type ServerInit,
77+} from "@sveltejs/kit";
38import { getConfig } from "$lib/server/config";
49import { getDb, initDb } from "$lib/server/db";
510import { initOAuthClient } from "$lib/server/auth/oauth-client";
···813 getSessionUser,
914} from "$lib/server/services/sessions";
1015import { verifyToken } from "$lib/server/services/api-tokens";
1616+import { getUser, upsertUser } from "$lib/server/services/users";
1117import { runSync } from "$lib/server/services/sync";
1818+import { handleMcpRequest } from "$lib/server/mcp/server";
1919+import process from "node:process";
2020+import { mkdirSync, writeFileSync } from "node:fs";
2121+import { dirname, join } from "node:path";
2222+2323+/** The synthetic single user of a local desktop build (no login). */
2424+export const LOCAL_DID = "did:local:self";
2525+2626+/** Log the outcome of a sync run to the console. */
2727+function logSyncOutcomes(outcomes: Awaited<ReturnType<typeof runSync>>): void {
2828+ for (const o of outcomes) {
2929+ console.log(
3030+ `sync connection ${o.connectionId}: ${
3131+ o.ok ? "ok" : `FAILED (${o.error})`
3232+ }, +${o.newTransactions} txns, ${o.reconciled} reconciled, ${o.ruleCategorized} rule-categorized`,
3333+ );
3434+ }
3535+}
12361337export const init: ServerInit = async () => {
1438 if (building) return;
1539 const config = getConfig(); // fails fast with a clear error on missing/invalid env
1640 const db = initDb(config.dbPath, "migrations");
4141+4242+ if (config.mode === "local") {
4343+ // Single-user desktop build: no login, no OAuth client. Seed the local
4444+ // identity when the display name is already chosen; otherwise the welcome
4545+ // flow seeds it on first launch.
4646+ if (config.localDisplayName) {
4747+ upsertUser(db, LOCAL_DID, config.localDisplayName);
4848+ }
4949+ startLocalSync();
5050+ startLoopbackMcp(config.dbPath);
5151+ return;
5252+ }
5353+1754 deleteExpiredSessions(db);
1855 await initOAuthClient(config, db);
19562057 // Daily sync. The Bridge refreshes bank data roughly daily; more often is pointless.
2158 try {
2259 Deno.cron("daily simplefin sync", "0 11 * * *", async () => {
2323- const outcomes = await runSync(getDb());
2424- for (const o of outcomes) {
2525- console.log(
2626- `sync connection ${o.connectionId}: ${
2727- o.ok ? "ok" : `FAILED (${o.error})`
2828- }, +${o.newTransactions} txns, ${o.reconciled} reconciled, ${o.ruleCategorized} rule-categorized`,
2929- );
3030- }
6060+ logSyncOutcomes(await runSync(getDb()));
3161 });
3262 } catch (err) {
3363 console.warn("Deno.cron unavailable; scheduled sync disabled:", err);
3464 }
3565};
36666767+// Local mode is not always on, and Deno.cron keeps its schedule in memory with
6868+// no catch-up for missed fires, so a fixed daily time would silently skip any
6969+// day the app wasn't open. Instead: sync once on launch (catching up whatever
7070+// was missed while closed) and periodically while the app runs.
7171+const LOCAL_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
7272+7373+function startLocalSync(): void {
7474+ const run = () =>
7575+ runSync(getDb())
7676+ .then(logSyncOutcomes)
7777+ .catch((err) => console.warn("local sync failed:", err));
7878+ run(); // on launch
7979+ setInterval(run, LOCAL_SYNC_INTERVAL_MS); // while running
8080+}
8181+8282+// A `deno desktop` build talks to its webview over an in-process channel, so
8383+// the embedded server's HTTP port is not guaranteed to be a reachable surface
8484+// for a local agent. When the desktop entrypoint sets QUANTUM_LOCAL_MCP_PORT,
8585+// bind a loopback-only listener that mounts the same transport-only MCP handler
8686+// and requires the same bearer token. Left unset (dev/server), the SvelteKit
8787+// /mcp route serves agents directly and this is a no-op.
8888+function startLoopbackMcp(dbPath: string): void {
8989+ const portRaw = process.env.QUANTUM_LOCAL_MCP_PORT?.trim();
9090+ if (!portRaw) return;
9191+ const port = Number(portRaw);
9292+ if (!Number.isInteger(port) || port <= 0) {
9393+ console.warn(`QUANTUM_LOCAL_MCP_PORT is not a valid port: ${portRaw}`);
9494+ return;
9595+ }
9696+ Deno.serve({ port, hostname: "127.0.0.1" }, (request) => {
9797+ const path = new URL(request.url).pathname;
9898+ if (!isMcpPath(path)) return new Response("Not found", { status: 404 });
9999+ const bearer = readBearer(request.headers.get("authorization"));
100100+ const principal = bearer ? verifyToken(getDb(), bearer) : null;
101101+ if (!principal) return new Response("Unauthorized", { status: 401 });
102102+ return handleMcpRequest(request, getDb(), principal);
103103+ });
104104+ const mcpUrl = `http://127.0.0.1:${port}/mcp`;
105105+ console.log(`local MCP listening on ${mcpUrl}`);
106106+107107+ // Publish the endpoint next to the database so a local agent host can be
108108+ // pointed at it in one step. The URL only — never a token; the user mints a
109109+ // scoped, revocable token in Settings, so no plaintext credential sits on disk.
110110+ try {
111111+ const dir = dirname(dbPath);
112112+ mkdirSync(dir, { recursive: true });
113113+ writeFileSync(
114114+ join(dir, "agent.json"),
115115+ JSON.stringify({ mcpUrl }, null, 2),
116116+ );
117117+ } catch (err) {
118118+ console.warn("could not write agent.json endpoint file:", err);
119119+ }
120120+}
121121+37122export const SESSION_COOKIE = "quantum_session";
3812339124/** Routes reachable without a session: login, OAuth plumbing, health check. */
···52137 return match ? match[1].trim() : null;
53138}
541395555-export const handle: Handle = async ({ event, resolve }) => {
140140+function isMcpPath(path: string): boolean {
141141+ return path === "/mcp" || path.startsWith("/mcp/");
142142+}
143143+144144+export const handle: Handle = ({ event, resolve }) =>
145145+ getConfig().mode === "local"
146146+ ? handleLocal(event, resolve)
147147+ : handleServer(event, resolve);
148148+149149+type Resolve = Parameters<Handle>[0]["resolve"];
150150+151151+async function handleServer(
152152+ event: RequestEvent,
153153+ resolve: Resolve,
154154+): Promise<Response> {
56155 const cookie = event.cookies.get(SESSION_COOKIE);
57156 event.locals.user = cookie ? getSessionUser(getDb(), cookie) : null;
58157···70169 const authenticated = event.locals.user || event.locals.apiToken;
71170 if (!authenticated && !PUBLIC_PATHS.has(path)) {
72171 // API endpoints answer 401; browser routes get the login redirect.
7373- if (path === "/mcp" || path.startsWith("/mcp/")) {
7474- return new Response("Unauthorized", { status: 401 });
7575- }
172172+ if (isMcpPath(path)) return new Response("Unauthorized", { status: 401 });
76173 redirect(303, "/login");
77174 }
78175 if (event.locals.user && path === "/login") {
···80177 }
8117882179 return resolve(event);
8383-};
180180+}
181181+182182+// Local mode (add-desktop-local-remote-modes): no login. The single local user
183183+// is authenticated on every web request; MCP still requires a bearer token even
184184+// on the loopback, so another process on the machine cannot reach the ledger
185185+// unauthenticated.
186186+async function handleLocal(
187187+ event: RequestEvent,
188188+ resolve: Resolve,
189189+): Promise<Response> {
190190+ const db = getDb();
191191+ const path = event.url.pathname;
192192+193193+ // MCP is token-authenticated, never the local session.
194194+ if (isMcpPath(path)) {
195195+ const bearer = readBearer(event.request.headers.get("authorization"));
196196+ event.locals.user = null;
197197+ event.locals.apiToken = bearer ? verifyToken(db, bearer) : null;
198198+ if (!event.locals.apiToken) {
199199+ return new Response("Unauthorized", { status: 401 });
200200+ }
201201+ return resolve(event);
202202+ }
203203+204204+ event.locals.apiToken = null;
205205+ const localUser = getUser(db, LOCAL_DID);
206206+207207+ // Until the user has completed first-launch (no local identity yet), send
208208+ // them to the setup screen instead of a login page.
209209+ if (!localUser) {
210210+ event.locals.user = null;
211211+ if (path !== "/welcome" && !PUBLIC_PATHS.has(path)) {
212212+ redirect(303, "/welcome");
213213+ }
214214+ return resolve(event);
215215+ }
216216+217217+ event.locals.user = localUser;
218218+ // Login and welcome are meaningless once set up.
219219+ if (path === "/login" || path === "/welcome") redirect(303, "/");
220220+ return resolve(event);
221221+}
···11import process from "node:process";
22+import { join } from "node:path";
33+44+/**
55+ * How this instance runs (add-desktop-local-remote-modes):
66+ * - `server`: the hosted, multi-user deployment — ATProto OAuth login, a DID
77+ * allowlist, and a public origin are all required.
88+ * - `local`: the single-user desktop build — no login, no OAuth, data on the
99+ * user's own disk. The server-only requirements are dropped.
1010+ */
1111+export type Mode = "server" | "local";
212313export interface Config {
44- /** Public HTTPS origin, no trailing slash. Drives OAuth client_id, redirect URI, JWKS URL. */
1414+ /** How this instance runs. */
1515+ mode: Mode;
1616+ /** Public HTTPS origin, no trailing slash. Drives OAuth client_id, redirect URI, JWKS URL. Empty in local mode. */
517 appUrl: string;
66- /** DIDs permitted to use this instance. */
1818+ /** DIDs permitted to use this instance. Empty in local mode. */
719 allowedDids: string[];
820 /** Path to the SQLite database file. */
921 dbPath: string;
1010- /** ES256 private key (JWK with kid) used for OAuth client authentication. */
2222+ /** ES256 private key (JWK with kid) used for OAuth client authentication. Empty in local mode. */
1123 oauthPrivateKeyJwk: Record<string, unknown>;
2424+ /**
2525+ * Display name for the single local user (local mode only), supplied by the
2626+ * desktop first-launch flow. Undefined until the user has chosen one, which
2727+ * gates the app behind the setup screen.
2828+ */
2929+ localDisplayName?: string;
3030+}
3131+3232+/** The OS-conventional per-user data directory for the local-mode database. */
3333+function defaultLocalDbPath(
3434+ env: Record<string, string | undefined>,
3535+): string {
3636+ const home = env.HOME ?? env.USERPROFILE ?? ".";
3737+ let dir: string;
3838+ if (process.platform === "win32") {
3939+ dir = env.APPDATA ?? join(home, "AppData", "Roaming");
4040+ } else if (process.platform === "darwin") {
4141+ dir = join(home, "Library", "Application Support");
4242+ } else {
4343+ dir = env.XDG_DATA_HOME ?? join(home, ".local", "share");
4444+ }
4545+ return join(dir, "Quantum", "quantum.db");
1246}
13471448export function loadConfig(
1549 env: Record<string, string | undefined> = process.env,
1650): Config {
5151+ const mode: Mode = env.QUANTUM_MODE?.trim() === "local" ? "local" : "server";
5252+5353+ if (mode === "local") return loadLocalConfig(env);
5454+1755 const problems: string[] = [];
18561957 const appUrlRaw = env.APP_URL?.trim();
···86124 throw new Error(`Invalid configuration:\n - ${problems.join("\n - ")}`);
87125 }
881268989- return { appUrl, allowedDids, dbPath: dbPath!, oauthPrivateKeyJwk };
127127+ return {
128128+ mode: "server",
129129+ appUrl,
130130+ allowedDids,
131131+ dbPath: dbPath!,
132132+ oauthPrivateKeyJwk,
133133+ };
134134+}
135135+136136+/**
137137+ * Local single-user mode: no login, no OAuth, no allowlist. Server-only
138138+ * settings are not merely optional — supplying them is a hard error, so a
139139+ * server image can never silently boot into the no-login relaxations.
140140+ */
141141+function loadLocalConfig(env: Record<string, string | undefined>): Config {
142142+ const problems: string[] = [];
143143+144144+ if (env.ALLOWED_DIDS?.trim()) {
145145+ problems.push(
146146+ "ALLOWED_DIDS must not be set in local mode (QUANTUM_MODE=local is single-user and has no login)",
147147+ );
148148+ }
149149+ if (env.OAUTH_PRIVATE_KEY_JWK?.trim()) {
150150+ problems.push(
151151+ "OAUTH_PRIVATE_KEY_JWK must not be set in local mode (no OAuth client runs)",
152152+ );
153153+ }
154154+ if (problems.length > 0) {
155155+ throw new Error(`Invalid configuration:\n - ${problems.join("\n - ")}`);
156156+ }
157157+158158+ return {
159159+ mode: "local",
160160+ appUrl: "", // no public origin; the loopback MCP URL is surfaced elsewhere
161161+ allowedDids: [],
162162+ dbPath: env.DB_PATH?.trim() || defaultLocalDbPath(env),
163163+ oauthPrivateKeyJwk: {},
164164+ localDisplayName: env.QUANTUM_LOCAL_DISPLAY_NAME?.trim() || undefined,
165165+ };
90166}
9116792168let cached: Config | null = null;