diff --git a/openspec/changes/add-desktop-local-remote-modes/.openspec.yaml b/openspec/changes/add-desktop-local-remote-modes/.openspec.yaml new file mode 100644 index 0000000..4f36b28 --- /dev/null +++ b/openspec/changes/add-desktop-local-remote-modes/.openspec.yaml @@ -0,0 +1,6 @@ +schema: spec-driven +created: 2026-07-17 +goal: Let an individual run Quantum on the desktop without self-hosting (local + mode) while letting an existing server's users run the same app as a native + client (remote mode), reusing the MCP handler and bearer path from + add-mcp-server diff --git a/openspec/changes/add-desktop-local-remote-modes/README.md b/openspec/changes/add-desktop-local-remote-modes/README.md new file mode 100644 index 0000000..d662e26 --- /dev/null +++ b/openspec/changes/add-desktop-local-remote-modes/README.md @@ -0,0 +1,3 @@ +# add-desktop-local-remote-modes + +Package Quantum as a deno desktop app with a first-launch Local/Remote mode choice: a single-user no-login local build with an embedded server, and a thin remote client to an existing Quantum server via OAuth deep-link handoff diff --git a/openspec/changes/add-desktop-local-remote-modes/design.md b/openspec/changes/add-desktop-local-remote-modes/design.md new file mode 100644 index 0000000..577883c --- /dev/null +++ b/openspec/changes/add-desktop-local-remote-modes/design.md @@ -0,0 +1,259 @@ +## Context + +The server core is transport-agnostic (design D6) and already boots from a small, +well-defined seam. The relevant current state: + +- **`loadConfig`** (`config.ts`) hard-requires `APP_URL`, `ALLOWED_DIDS`, and + `OAUTH_PRIVATE_KEY_JWK`, throwing a fail-fast error if any is missing. Its + output drives OAuth client identity and the DID allowlist. +- **`init`** (`hooks.server.ts`) unconditionally calls `initOAuthClient` and + registers a `Deno.cron("daily simplefin sync", "0 11 * * *", …)`, wrapped in a + try/catch that degrades a missing cron to "scheduled sync disabled". +- **`handle`** (`hooks.server.ts:47`) resolves the session cookie to + `event.locals.user` and redirects any unauthenticated request off non-public + paths to `/login`. +- **`upsertUser`** (`users.ts`) takes a DID and handle; nothing in the schema or + services parses DID shape — `users.did` is an opaque TEXT primary key, + `actor_did` joins by equality, and the UI renders the stored handle. +- **`claimSetupToken`** (`connections.ts`) claims a SimpleFIN token and stores an + Access URL. It takes no user, no DID — bank setup is independent of login. +- **Sessions** (`sessions.ts`) are opaque, bearer-ready tokens, per the auth + spec. +- **`add-mcp-server`** introduces a transport-only MCP handler (no cookie/view + reads) and a bearer-authentication path in `handle` for API tokens. + +`deno desktop` (Deno 2.9) compiles this SvelteKit app into a native binary, +running the production server in-process with the UI in an OS webview. Backend↔UI +communication is in-process channels, not socket IPC — so a desktop build cannot +assume its embedded server is reachable on a localhost TCP port. Deno is already +pinned at 2.9.3 in the Dockerfile. + +The user-facing goal: an individual runs Quantum on the desktop with no server, +no login, data on their own disk; a server owner runs the same app as a native +client against their existing instance. One binary, one first-launch question, +two runtimes. + +## Goals / Non-Goals + +**Goals:** + +- Ship a native desktop build without forking the codebase or the service layer. +- Local mode: no server, no login, single user, local data, working bank sync and + local agent access. +- Remote mode: native client to an existing server, authenticated by reusing the + server's ATProto OAuth via a system-browser handoff, without the app becoming + an OAuth client. +- Keep every server-only guarantee (login, DID allowlist, OAuth) intact and + strictly un-relaxed on the server. + +**Non-Goals:** + +- A tray/background-resident runtime. It would change sync cadence materially and + carries its own UX decisions (close-to-tray, run-in-background preference); it + is parked for a follow-up (see Open Questions). +- Mobile builds and auto-update. Separate efforts. +- Multi-user local mode. Local is deliberately one person; the two-person product + is the server. +- Re-attributing history when a local user later migrates to a server. Explicit + non-goal (see decision 2). + +## Decisions + +### 1. Mode is a runtime identity chosen once, not a feature flag + +**Decision:** `loadConfig` gains a `mode: 'server' | 'local'`, sourced from +`QUANTUM_MODE` (defaulting to `server`, which the container sets implicitly by +never setting `local`). The desktop first-launch screen writes the chosen mode to +desktop app-config (a file in the app-data directory, outside the SQLite +database). Changing mode is an explicit reset that clears app-config and re-shows +the screen; there is no in-app local↔remote toggle. + +**Why:** Local and remote are not two configurations of one runtime — they differ +in whether an embedded server runs at all, whether there is a database, and how +auth works. A once-then-reset choice models that honestly and keeps the branching +at boot, not scattered through the app. Storing mode outside the database is +required: in remote mode there is no local database to store it in, and in local +mode the mode must be known before the database is opened. + +**Server-safety is a validation invariant, not a convention.** `loadConfig` in +`local` mode drops the OAuth/`APP_URL`/`ALLOWED_DIDS` requirements; in `server` +mode it keeps them. A configuration that sets `QUANTUM_MODE=local` *and* supplies +`ALLOWED_DIDS`/OAuth keys is a hard error, so the relaxations can never be half- +applied to a server image by accident. + +### 2. Local identity is a synthetic DID, `did:local:self` + +**Decision:** Local mode seeds one user at startup via `upsertUser(db, +'did:local:self', )` and treats it as the authenticated user for +every request; `handle` never redirects to `/login` in local mode. The +first-launch flow prompts for the display name. + +**Why:** DIDs are opaque everywhere in Quantum, so a synthetic one slots in with +zero schema or service change, keeping the existing invariants — including +`manual events require actorDid` (`categorization.ts`) — satisfied without +special-casing. `did:local:self` is syntactically a valid DID (`did:` + a +lowercase method + an id), greps cleanly, and cannot collide with a real identity +because no `local` DID method resolves. The alternatives are worse: a bare +`"local"` violates the `did:` convention the config validator enforces, and +`did:web:localhost` abuses a method that *is* resolvable. + +**Prompting for a display name** (rather than defaulting to `"you"` or the OS +username) keeps provenance legible — a badge reading the person's chosen name is +truer than a generic placeholder, and it costs one field on a screen the user is +already looking at. + +**Migration re-attribution is a non-goal.** If a local user later moves to a +server, their history stays attributed to `did:local:self` rather than their real +DID. Rewriting attribution is out of scope; stated so no one expects it. + +### 3. Local mode does not use `Deno.cron` — it triggers sync on launch and on an interval + +**Decision:** In local mode, skip the `Deno.cron` registration entirely. Instead, +run `runSync` once during `init` (catching up whatever was missed while the app +was closed) and then on a `setInterval` while the app runs. The server keeps its +daily `Deno.cron` unchanged. + +**Why:** The runtime's `Deno.cron` keeps its schedule in memory and has **no +catch-up** — a missed fire is skipped, never replayed, and it only fires while the +process is alive. A fixed `"0 11 * * *"` is therefore meaningless for a +part-time desktop process: any day the app isn't open at 11:00 UTC, that day's +sync simply never happens, silently. An event-driven trigger (on launch + while +running) matches how a desktop app actually lives. This is true independent of +whether `Deno.cron` even functions inside a `deno desktop` binary, so the design +sidesteps that question rather than depending on its answer. + +**The open empirical question is background timers, not cron** (see Open +Questions and task 1): whether the Deno event loop keeps firing `setInterval` +while the desktop window is minimized. If backgrounded timers are suspended, +"interval while running" degrades to "interval while focused" and sync-on-launch +becomes the primary trigger — acceptable, but it shapes the cadence, so it is +spiked before the cadence is fixed. + +### 4. One MCP handler, remounted on a loopback listener for local mode + +**Decision:** Local mode starts a dedicated loopback `Deno.serve` listener during +`init` and mounts `add-mcp-server`'s transport-only MCP handler on it, so a local +agent reaches the same tools a server exposes at `/mcp`. Remote mode adds no MCP +mount — the user's server already serves it. + +**Why:** `add-mcp-server` deliberately built its handler as a bare `Request → +Response` with no cookie or view dependency, precisely so it could mount outside +the web router. A loopback listener is necessary because `deno desktop`'s +in-process UI channel means the embedded SvelteKit server's own port is not a +reliable public surface. The listener binds loopback-only and requires the same +bearer API token as the server route, so "local" does not mean "unauthenticated +to any process on the machine." + +**A verification, not an assumption:** whether `deno desktop` release mode +*already* exposes the server's `/mcp` route on a reachable port is checked during +implementation (task 5). If it does, the loopback mount is redundant and can be +dropped; the design works either way and does not bet on it. + +### 4a. Local MCP token bootstrapping + +**Decision:** In local mode, the "Connect an agent" Settings surface from +`add-mcp-server` still mints tokens (the app shell renders for `did:local:self`), +and the desktop app writes the loopback URL and, optionally, a freshly minted +token to a well-known app-data location so a local agent host can be configured +with one step. + +**Why:** Local mode's whole appeal is low friction; making the user hand-copy a +port and token into an agent config re-introduces exactly the setup wall the +desktop build removes. The token is still minted through the normal scoped, +revocable path — this only pre-places it where a local host looks. + +### 5. Remote mode: system-browser OAuth handoff, server stays the sole client + +**Decision:** Remote mode registers a custom URL scheme for the app. To +authenticate, the app opens the server's existing ATProto login in the system +browser; on success the server redirects to the app's registered deep link +carrying a **bearer session token**. The app stores that token and presents it as +`Authorization: Bearer` on subsequent requests (reusing `add-mcp-server`'s bearer +path, widened from API tokens to session tokens). The desktop app never registers +as an ATProto OAuth client. + +**Why:** The server already is a fully configured confidential ATProto client +with DPoP-bound tokens; duplicating that in every desktop install would multiply +the OAuth client surface and the secrets to protect. Routing login through the +system browser also means the user authenticates in a real, trusted browser with +their existing session and password manager — not a webview that could be +spoofed. The session token is opaque and bearer-ready by existing design, so the +server side is a small addition, not a redesign. + +**Handoff is the sensitive step** and is constrained accordingly: the deep-link +token is single-use at handoff (the app immediately exchanges or binds it), +delivered only to the app's registered scheme, and scoped to a normal session's +lifetime and revocability. A handoff that is intercepted yields at most a session +the user can log out to kill — the same blast radius as a stolen session cookie, +no worse. + +### 6. Data lives in the OS application-data directory in local mode + +**Decision:** Local mode defaults the SQLite path to the platform app-data +directory (e.g. `%APPDATA%/Quantum/quantum.db` on Windows, the XDG/Application +Support equivalents elsewhere), created on first launch. `DB_PATH` may still +override it. + +**Why:** A user who never chose to self-host should not have to choose a database +location either; the OS convention is the least-surprising home and survives app +updates. The existing `openDatabase` already creates parent directories and +carries a clear permissions error, so this is a path default, not new machinery. + +## Risks / Trade-offs + +- **A relaxation leaks to a server** (no-login or skipped OAuth on a hosted + instance) → gated on `QUANTUM_MODE=local`, which the container never sets, and + made mutually exclusive with server settings in `loadConfig` so a mixed config + is a hard error rather than a silent downgrade. + +- **Background timers may be suspended when the window is minimized** → spiked + before cadence is fixed (task 1); worst case sync-on-launch carries freshness + and the interval is a focused-only bonus. The parked tray runtime would remove + the concern entirely by keeping the process resident. + +- **The deep-link handoff carries a live credential across a process boundary** → + single-use at handoff, registered-scheme-only delivery, session-scoped and + revocable; blast radius equals a stolen session cookie. + +- **`deno desktop` is experimental (2.9)** → the surface used here (SvelteKit + auto-detection, webview backend, runtime flags) is the stable core of it; + packaging specifics are pinned to 2.9.x and validated in task 2 before deeper + work. + +- **Local data has no server backup** → an accepted property of local mode, not a + defect; the honest mitigation is documentation (where the file is, that it is + the user's to back up), not silent cloud sync. + +- **Two runtimes double the surface to test** → mitigated by both sharing the + entire service layer untouched; the divergence is confined to config, `init`, + `handle`, and packaging, which is where the tests concentrate. + +## Migration Plan + +No data migration. This change is additive at the boot and packaging layers: + +- `loadConfig` gains `mode`; server mode is the default and behaves exactly as + today, so existing deployments are unaffected with no config change. +- No schema change is required by local mode itself. If remote-mode handoff needs + to mark a session's delivery method, that is one nullable column added + additively to `sessions`, NULL for every existing row. +- The desktop artifact is a new build output; the container image build is + unchanged. + +## Open Questions + +- **Tray/background-resident runtime (parked).** `deno desktop` exposes a + taskbar/tray API. A tray-resident process that stays alive after the window + closes would turn "interval while running" into "interval while resident" — + effectively always-on — and largely dissolve the background-timer question. It + also pulls in its own decisions: does closing the window quit or minimize to + tray, and is there a "run in background" preference? Deferred to a follow-up, + to be revisited once the core two-mode build works. It may supersede the + on-launch-plus-interval sync model in decision 3. +- **Background-timer behavior (spike, task 1).** Does `setInterval` keep firing + while the desktop window is minimized under `deno desktop`? Gates the local + sync cadence. +- **Does `deno desktop` release mode expose the server port?** (task 5) If yes, + the loopback MCP mount in decision 4 is redundant. +- **Auto-update and artifact signing.** Out of scope here, but the release story + will need it before wide distribution. diff --git a/openspec/changes/add-desktop-local-remote-modes/proposal.md b/openspec/changes/add-desktop-local-remote-modes/proposal.md new file mode 100644 index 0000000..8b84e0f --- /dev/null +++ b/openspec/changes/add-desktop-local-remote-modes/proposal.md @@ -0,0 +1,140 @@ +## Why + +Quantum is self-hosted software, and self-hosting is a wall. An individual who +wants a Mint-style mirror of their own accounts must stand up a server, a domain, +an ATProto OAuth client, and a SimpleFIN connection before they see a single +number. The couple this product was built for cleared that wall; most people +won't. + +`deno desktop` (Deno 2.9, June 2026) removes it. It compiles a SvelteKit project +into a native, self-contained desktop binary — the UI in an OS webview, the +existing server running in-process — with no Chromium to ship and no daemon to +install. The same build system also makes a *thin* desktop client viable: a +native shell pointed at an existing Quantum server for the users who already have +one. + +These two audiences want opposite things from the same binary. The individual +wants "just run it, no account, my data on my disk." The server owner wants "log +me into my server and get out of the way." So the desktop app asks once, at first +launch, which one you are — and becomes a different runtime accordingly. This +change builds both, because shipping only local mode would strand the existing +two-person server behind a web browser while everyone else got a native app, and +because both modes share one authentication seam (bearer credentials) that is +cheaper to build once. + +This change depends on `add-mcp-server`: local mode remounts that change's MCP +handler on a loopback listener, and remote mode extends that change's +bearer-authentication path from API tokens to OAuth-issued session tokens. + +## What Changes + +- A **`deno desktop` build** of the existing SvelteKit app, producing a native + binary per platform, alongside the current container image (which is + unchanged and remains the server deployment). +- A **first-launch mode-selection screen**: *Local* ("just me, on this + computer") or *Remote* ("I have a Quantum server"). The choice is made once and + persisted in desktop app-config outside the database. Changing it later is an + explicit **reset** action that clears the app-config and re-shows the screen — + the two modes are treated as near-separate installs, not a runtime toggle. +- **Local mode** — a single-user, no-login runtime: + - The embedded server boots with `QUANTUM_MODE=local`, which **skips** the + `APP_URL` / `ALLOWED_DIDS` / `OAUTH_PRIVATE_KEY_JWK` validation and the + ATProto OAuth client initialization that a server requires. + - There is no login. A synthetic single user, DID `did:local:self`, is seeded + at startup and treated as the authenticated user for every request. The + first-launch flow **prompts for a display name** for this user, used + wherever a handle would appear (provenance badges, the app shell). + - The SQLite database lives in the OS application-data directory. + - Bank data still works exactly as today: the user pastes a SimpleFIN setup + token in Settings. Connection setup was never coupled to login. + - Sync moves from a fixed-time daily schedule to **sync-on-launch plus an + interval while the app runs**, because a desktop app is not always on and + the runtime's cron has no catch-up for missed fires. + - The MCP endpoint from `add-mcp-server` is available locally, mounted on a + **loopback listener** so local agents can reach it — the same handler the + server exposes as a route. +- **Remote mode** — a thin native client: + - No embedded server, no local database. The webview is pointed at the user's + Quantum origin. + - Authentication uses a **system-browser OAuth handoff**: the app opens the + server's existing ATProto login in the real browser, and the server hands a + **bearer session token** back to the app via a registered deep link. The + backend remains the sole ATProto OAuth client; the desktop app never becomes + one. The app presents that token as a bearer credential on subsequent + requests, reusing the bearer path `add-mcp-server` introduced. + - MCP in remote mode is served *by the user's server*, not the app — the app + adds no local MCP mount. + +Not in this change: the tray/background-resident runtime (parked — see Design's +open questions), mobile builds, and auto-update. The MCP server itself is +`add-mcp-server`, not this change. + +## Capabilities + +### New Capabilities + +- `desktop-app`: Packaging Quantum as a native desktop binary; the first-launch + choice between local and remote mode and its once-then-reset persistence; + local mode's no-login single-user runtime, app-data database, sync cadence, and + loopback MCP mount; and remote mode's thin-client shell with system-browser + OAuth deep-link handoff. + +### Modified Capabilities + +- `auth`: Adds a no-login local runtime authenticated as a synthetic single user + (`did:local:self`), gated to the desktop local build; and a bearer *session* + token issued to a native remote client via an OAuth deep-link handoff, building + on the bearer path from `add-mcp-server`. Server cookie login is unchanged. +- `simplefin-sync`: The sync trigger becomes deployment-dependent — an always-on + server keeps the daily schedule; a desktop local build syncs on launch and + periodically while running. Sync's fetch, archival, normalization, and + reconciliation behavior are unchanged. + +## Impact + +**Affected specs**: new `desktop-app`; modified `auth` (local no-login runtime + +remote bearer-session handoff) and `simplefin-sync` (trigger model). + +**Depends on**: `add-mcp-server` — this change remounts its MCP handler (local +loopback) and extends its bearer-authentication hook from API tokens to session +tokens. Sequence `add-mcp-server` first. + +**Deliberately unaffected**: The container image and server deployment. A hosted +server still requires `APP_URL`, `ALLOWED_DIDS`, OAuth keys, cookie login, the +DID allowlist, and the daily cron — none of that is relaxed for the server; the +local relaxations are gated strictly to `QUANTUM_MODE=local`. The two-person +constraint holds for the server product; local desktop is a distinct single-user +deployment, not a repeal of it. Every service in `src/lib/server/services/` is +untouched — this change is entirely about how the core is booted, authenticated, +scheduled, and packaged. + +**Affected code**: + +- `src/lib/server/config.ts` — `loadConfig` learns a `mode` (`server` | + `local`). In `local`, the OAuth/`APP_URL`/`ALLOWED_DIDS` requirements are + dropped and a local DB path in the app-data directory is defaulted. +- `src/hooks.server.ts` — `init` conditionally skips `initOAuthClient` and the + `Deno.cron` registration in local mode, seeds `did:local:self`, and starts the + on-launch-plus-interval sync and the loopback MCP listener. `handle` treats the + synthetic user as authenticated in local mode and never redirects to `/login`. +- `src/routes/login`, `src/routes/oauth/callback` — unaffected on the server; not + reached in local mode. A new deep-link callback path supports the remote-mode + handoff. +- `src/lib/server/services/sessions.ts` — session tokens are already opaque and + bearer-ready; remote-mode handoff issues one for the native client to hold. A + small addition marks a session as delivered by handoff if needed for its + lifetime. +- New desktop entry/config: a `deno desktop` invocation and launch config; a + first-launch UI (mode choice + local display-name prompt); app-config + read/write for the persisted mode; the deep-link registration for remote mode. +- `deno.json` — a `desktop` task; the build/release scripts learn to produce and + version the desktop artifact alongside the image. + +**Risk**: Two relaxations that must never leak to a server: no-login auth and +skipped OAuth config. Both are gated on `QUANTUM_MODE=local`, which the container +image never sets and which the config validator treats as mutually exclusive with +server settings — a build that sets `local` and also presents `ALLOWED_DIDS` is a +configuration error, not a silent downgrade. The remote-mode deep-link handoff is +the other sensitive surface: it carries a live session token across a process +boundary, so the token is single-use at handoff, bound to the requesting app +instance, and delivered only to a registered scheme — detailed in Design. diff --git a/openspec/changes/add-desktop-local-remote-modes/specs/auth/spec.md b/openspec/changes/add-desktop-local-remote-modes/specs/auth/spec.md new file mode 100644 index 0000000..11857d2 --- /dev/null +++ b/openspec/changes/add-desktop-local-remote-modes/specs/auth/spec.md @@ -0,0 +1,97 @@ +## MODIFIED Requirements + +### Requirement: ATProto OAuth login + +The system SHALL authenticate users via AT Protocol OAuth using handle-based +login when running as a server. The user enters their handle (or DID); the system +resolves it, performs the OAuth authorization flow (PAR, PKCE, DPoP) against the +user's authorization server, and establishes an application session on success. +The system SHALL request only the `atproto` scope and SHALL NOT make +authenticated requests to the user's PDS after authentication. Resolving and +rendering profile pictures from public ATProto profile data — without using the +OAuth session or any application credential — is permitted. When the application +runs as a desktop build in Local mode, it SHALL NOT perform this login flow and +SHALL NOT initialize an ATProto OAuth client; authentication is governed instead +by the local single-user runtime. + +#### Scenario: Successful login with allowlisted handle + +- **WHEN** a user whose DID is in the allowlist completes the OAuth flow +- **THEN** the system creates an application session and sets an HTTP-only, + Secure session cookie +- **AND** the user is redirected to the dashboard + +#### Scenario: Login with unknown handle + +- **WHEN** a user submits a handle that cannot be resolved to a DID +- **THEN** the system shows an error on the login page without starting the + OAuth flow + +#### Scenario: Local mode performs no login + +- **WHEN** the application runs as a desktop build in Local mode +- **THEN** no login page is presented, no OAuth client is initialized, and + requests are authenticated as the local single user + +## ADDED Requirements + +### Requirement: Local single-user runtime + +When the application runs as a desktop build in Local mode, it SHALL operate as a +single-user system with no login. It SHALL seed one synthetic user with the DID +`did:local:self` and a user-chosen display name, and SHALL treat that user as the +authenticated principal for every request, never redirecting to a login page. +This runtime SHALL be reachable only when the application is explicitly +configured for Local mode; a server deployment SHALL NOT enable it, and supplying +server authentication configuration together with Local mode SHALL be a +configuration error rather than a silent relaxation. All existing per-actor +invariants (such as manual categorization recording an actor) SHALL be satisfied +by the synthetic user without special-casing. + +#### Scenario: Local user authenticated without a session + +- **WHEN** any request is handled in Local mode +- **THEN** it is authenticated as `did:local:self` with the chosen display name, + and no session cookie or login is required + +#### Scenario: Display name drives provenance + +- **WHEN** the local user manually categorizes a transaction +- **THEN** the categorization records the local user as actor and provenance + displays the chosen display name + +#### Scenario: Local relaxation cannot apply to a server + +- **WHEN** a deployment is configured for Local mode but also supplies + `ALLOWED_DIDS` or OAuth signing configuration +- **THEN** startup fails with a configuration error rather than running a server + without login + +### Requirement: Native client session handoff + +When the application runs as a desktop build in Remote mode, it SHALL +authenticate against an existing Quantum server via a system-browser OAuth +handoff. The application SHALL open the server's login in the operating system's +browser and SHALL receive, via a registered deep link, an opaque bearer session +token issued by the server. The application SHALL present that token as a bearer +credential on subsequent requests. The server SHALL remain the sole ATProto OAuth +client; the desktop application SHALL NOT register as one. The handed-off token +SHALL be single-use at handoff, delivered only to the application's registered +scheme, and SHALL carry the lifetime and revocability of an ordinary session. + +#### Scenario: Remote client authenticates via the system browser + +- **WHEN** a Remote-mode user initiates login +- **THEN** the server's ATProto login opens in the system browser, and on success + a bearer session token is delivered to the app through its registered deep link + +#### Scenario: Bearer session token authenticates requests + +- **WHEN** the Remote-mode app holds a valid handed-off session token +- **THEN** it authenticates to the server by presenting that token as a bearer + credential, without the app acting as an OAuth client + +#### Scenario: Handoff token is single-use + +- **WHEN** a handed-off token is presented a second time at the handoff step +- **THEN** it is rejected, having already been consumed diff --git a/openspec/changes/add-desktop-local-remote-modes/specs/desktop-app/spec.md b/openspec/changes/add-desktop-local-remote-modes/specs/desktop-app/spec.md new file mode 100644 index 0000000..cbf647a --- /dev/null +++ b/openspec/changes/add-desktop-local-remote-modes/specs/desktop-app/spec.md @@ -0,0 +1,117 @@ +## ADDED Requirements + +### Requirement: Native desktop build + +The system SHALL be buildable as a native desktop application from the existing +web application, producing a self-contained binary per platform whose UI runs in +the operating system's webview and whose server logic runs in-process. The +desktop build SHALL NOT fork or duplicate the domain service layer. The existing +container image and server deployment SHALL remain a supported, unchanged output. + +#### Scenario: Desktop artifact produced + +- **WHEN** the desktop build is run for a platform +- **THEN** it produces a single distributable binary for that platform that runs + the application without a separately installed runtime or browser + +#### Scenario: Server deployment unchanged + +- **WHEN** the container image is built and deployed as before +- **THEN** it behaves identically to prior releases, with no dependency on the + desktop build + +### Requirement: First-launch mode selection + +On first launch, the desktop application SHALL require the user to choose between +Local mode ("just me, on this computer") and Remote mode ("I have a Quantum +server"). The choice SHALL be persisted outside the application database. The +application SHALL NOT present an in-app toggle to switch modes; changing mode +SHALL be an explicit reset action that clears the persisted choice and returns +the user to the mode-selection screen on the next launch. + +#### Scenario: Mode chosen on first launch + +- **WHEN** the desktop app is launched for the first time +- **THEN** it presents the Local/Remote choice and does not proceed to the app + until one is chosen + +#### Scenario: Choice persisted across launches + +- **WHEN** a user has chosen a mode and relaunches the app +- **THEN** the app starts directly in the chosen mode without asking again + +#### Scenario: Reset returns to selection + +- **WHEN** a user performs the reset action +- **THEN** the persisted mode is cleared and the next launch presents the + mode-selection screen again + +### Requirement: Local mode runtime + +In Local mode, the application SHALL run its embedded server configured for local +operation: it SHALL NOT require or validate `APP_URL`, `ALLOWED_DIDS`, or OAuth +signing configuration, and SHALL NOT initialize an ATProto OAuth client. The +application database SHALL be stored in the operating system's application-data +directory by default. On first launch in Local mode, the application SHALL prompt +for a display name and SHALL seed a single synthetic user for it. Connecting a +bank via a SimpleFIN setup token SHALL work in Local mode exactly as on a server. + +#### Scenario: Local mode boots without server configuration + +- **WHEN** the app starts in Local mode with no `APP_URL`, `ALLOWED_DIDS`, or + OAuth key configured +- **THEN** it starts successfully, initializing no OAuth client and requiring no + login + +#### Scenario: Local database in app-data directory + +- **WHEN** Local mode initializes its database for the first time +- **THEN** the SQLite file is created under the operating system's + application-data directory + +#### Scenario: Bank connection in local mode + +- **WHEN** a Local-mode user pastes a valid SimpleFIN setup token in Settings +- **THEN** the connection is claimed and synced exactly as on a server, with no + login required + +### Requirement: Local mode agent access + +In Local mode, the application SHALL expose the MCP server on a loopback-only +listener, using the same MCP handler and requiring the same bearer API token as +the server route. The application MAY write the loopback endpoint and a minted +token to a well-known application-data location to simplify configuring a local +agent host. A local agent request without a valid token SHALL be rejected. + +#### Scenario: Local agent reaches MCP over loopback + +- **WHEN** an agent on the same machine connects to the loopback MCP endpoint + with a valid token +- **THEN** it can list and call the same tools the server route exposes + +#### Scenario: Loopback MCP still requires a token + +- **WHEN** a process on the machine connects to the loopback MCP endpoint without + a valid token +- **THEN** the request is rejected + +### Requirement: Remote mode thin client + +In Remote mode, the application SHALL act as a native client to an existing +Quantum server: it SHALL point its webview at the server's origin and SHALL NOT +run an embedded server or a local database. Authentication SHALL use a +system-browser OAuth handoff (specified in the auth capability). The application +SHALL NOT register itself as an ATProto OAuth client, and SHALL NOT expose a +local MCP mount — MCP in Remote mode is served by the user's server. + +#### Scenario: Remote mode targets an existing server + +- **WHEN** a user selects Remote mode and provides their Quantum server address +- **THEN** the app presents that server's interface and stores no ledger data + locally + +#### Scenario: Remote mode runs no local server + +- **WHEN** the app is running in Remote mode +- **THEN** it has not started an embedded server, opened a local database, or + mounted a local MCP listener diff --git a/openspec/changes/add-desktop-local-remote-modes/specs/simplefin-sync/spec.md b/openspec/changes/add-desktop-local-remote-modes/specs/simplefin-sync/spec.md new file mode 100644 index 0000000..9a76011 --- /dev/null +++ b/openspec/changes/add-desktop-local-remote-modes/specs/simplefin-sync/spec.md @@ -0,0 +1,42 @@ +## MODIFIED Requirements + +### Requirement: Scheduled and manual sync + +The system SHALL sync each connection automatically on a schedule appropriate to +how it is deployed, and SHALL provide a manual "sync now" action in the UI. A +server deployment SHALL sync each connection once daily via a scheduled job. A +desktop build in Local mode, which is not continuously running, SHALL instead +sync on application launch — catching up data missed while the app was closed — +and periodically while the application runs, rather than relying on a fixed-time +schedule that has no catch-up for missed fires. In all cases a sync fetches +`GET {access_url}/accounts` including pending transactions and a start date that +safely overlaps previously fetched data. + +#### Scenario: Daily scheduled sync on a server + +- **WHEN** the daily schedule fires on a server deployment +- **THEN** the system performs a sync for every connection and records the + outcome + +#### Scenario: Sync on launch in local mode + +- **WHEN** a desktop Local-mode application starts +- **THEN** it performs a sync for every connection, bringing data current after + any period the app was closed + +#### Scenario: Periodic sync while running in local mode + +- **WHEN** a desktop Local-mode application has been running for the configured + interval +- **THEN** it performs another sync for every connection + +#### Scenario: Manual sync + +- **WHEN** a user triggers "sync now" +- **THEN** a sync runs immediately and the UI reflects the result + +#### Scenario: Sync failure + +- **WHEN** the SimpleFIN request fails (network error or non-2xx) +- **THEN** the system records a failed sync with the error detail and leaves all + previously normalized data untouched diff --git a/openspec/changes/add-desktop-local-remote-modes/tasks.md b/openspec/changes/add-desktop-local-remote-modes/tasks.md new file mode 100644 index 0000000..97fd78a --- /dev/null +++ b/openspec/changes/add-desktop-local-remote-modes/tasks.md @@ -0,0 +1,119 @@ +## 1. Spikes (de-risk before committing the design) + +- [ ] 1.1 **Background timers.** In a minimal `deno desktop` build of the app, + register a `setInterval` that logs, minimize the window, and confirm + whether it keeps firing while minimized/backgrounded. Record the finding — + it decides whether "interval while running" is real or effectively + "while focused", and thus the local sync cadence (design decision 3). +- [ ] 1.2 **Server port exposure.** Determine whether `deno desktop` release mode + exposes the embedded SvelteKit server's `/mcp` route on a reachable + loopback port. If yes, the separate loopback MCP mount (task 5) is + redundant; if no, it is required (design decision 4). +- [ ] 1.3 **Packaging sanity.** Confirm `deno desktop` (2.9.x) auto-detects this + SvelteKit project and produces a runnable Windows binary from the existing + build, with the webview backend, before building mode logic on top. + +## 2. Build and mode plumbing + +- [ ] 2.1 Add a `desktop` task to `deno.json` invoking `deno desktop` against the + SvelteKit build. Do not disturb the existing `build`/`start`/`image` tasks. +- [ ] 2.2 Add a desktop launch config and the app-config read/write for the + persisted mode (a file in the OS app-data directory, outside the SQLite + database). Mode is one of `local` | `remote`, absent until first launch. +- [ ] 2.3 Extend the release/build scripts to produce and version the desktop + artifact alongside the container image, reusing the existing ChronVer + version source. (Coordinate with the `release` skill's flow; do not fork + versioning.) + +## 3. Config: server vs. local + +- [ ] 3.1 In `src/lib/server/config.ts`, add `mode: 'server' | 'local'` sourced + from `QUANTUM_MODE` (default `server`). In `server` mode, keep the current + required-config validation exactly. In `local` mode, drop the `APP_URL` / + `ALLOWED_DIDS` / `OAUTH_PRIVATE_KEY_JWK` requirements and default `dbPath` + to the OS app-data directory. +- [ ] 3.2 Make the modes mutually exclusive: `QUANTUM_MODE=local` together with + `ALLOWED_DIDS` or OAuth key configuration MUST be a hard configuration + error, so a server can never half-apply the local relaxations. +- [ ] 3.3 Extend `config.test.ts`: local mode loads with none of the server + env vars; local + server config is rejected; server mode is unchanged. + +## 4. Local mode boot and no-login auth + +- [ ] 4.1 In `src/hooks.server.ts` `init`, branch on mode. In local mode: skip + `initOAuthClient`; seed `upsertUser(db, 'did:local:self', )`; + skip the `Deno.cron` registration. +- [ ] 4.2 In `handle`, in local mode set `event.locals.user` to the local user + unconditionally and never redirect to `/login`. Server mode is unchanged + (including `add-mcp-server`'s bearer path). +- [ ] 4.3 Read the display name from the first-launch flow (task 7); until set, + block app routes behind the local setup screen rather than a login page. +- [ ] 4.4 Tests: in local mode every protected route resolves as `did:local:self` + with no cookie; manual categorization records the local actor and its + display name surfaces in provenance; server-mode auth is untouched. + +## 5. Local sync cadence and loopback MCP mount + +- [ ] 5.1 In local mode, run `runSync(getDb())` once during `init` (catch-up), + then on a `setInterval` at the configured interval while running. Honor the + task 1.1 finding for the interval and for whether background firing is + relied upon. Keep the server's daily `Deno.cron` path unchanged. +- [ ] 5.2 If task 1.2 shows the server port is not reachable, start a loopback + `Deno.serve` listener in local mode and mount `add-mcp-server`'s + transport-only MCP handler on it, requiring the same bearer token. If the + port is reachable, skip this and document why. +- [ ] 5.3 Write the loopback endpoint (and optionally a freshly minted token) to a + well-known app-data location so a local agent host can be configured in one + step. The token still goes through the normal scoped/revocable mint path. +- [ ] 5.4 Tests/verification: a local agent reaches MCP over loopback with a valid + token and is rejected without one; sync-on-launch brings data current after + a simulated closed period. + +## 6. Remote mode thin client and OAuth handoff + +- [ ] 6.1 Register a custom URL scheme for the desktop app (deep link). +- [ ] 6.2 Remote-mode shell: point the webview at the user-provided server origin; + start no embedded server and open no local database. +- [ ] 6.3 Server side: add a handoff endpoint that, after a normal ATProto login + completed in the system browser, issues an opaque bearer **session** token + and redirects to the app's registered deep link carrying it. Reuse + `sessions.ts`; if a delivery-method marker is needed, add it as one + nullable column (NULL for existing rows). +- [ ] 6.4 Make the handoff token single-use at handoff and delivered only to the + registered scheme. The app stores the token and presents it via + `Authorization: Bearer`, reusing `add-mcp-server`'s bearer path (widened + from API tokens to session tokens). +- [ ] 6.5 Tests: a handed-off token authenticates server requests as a bearer + credential; re-presenting it at the handoff step is rejected; the app never + registers as an OAuth client. + +## 7. First-launch experience + +- [ ] 7.1 Build the first-launch mode-selection screen: Local ("just me, on this + computer") vs Remote ("I have a Quantum server"), shell-less and calm per + DESIGN.md, persisting the choice to app-config. +- [ ] 7.2 Local branch: prompt for a display name and seed the local user with it. +- [ ] 7.3 Remote branch: collect the server address and initiate the OAuth handoff + (task 6). +- [ ] 7.4 Add the reset action (Settings) that clears app-config and returns to + the mode-selection screen on next launch. Confirm with a plain sentence + about what reset does and does not delete (local data stays on disk). + +## 8. Verification + +- [ ] 8.1 Run `deno task test` and `deno task check`. Confirm the server build and + container image are unchanged (server-mode tests all green, no config + change required for existing deployments). +- [ ] 8.2 Local mode end to end: fresh launch → choose Local → set a display name + → paste a SimpleFIN token → sync populates → categorize a transaction and + confirm the display name in provenance → close and relaunch (starts in + Local, syncs on launch) → connect a local agent over loopback MCP and + categorize via a token, confirming `agent` provenance. +- [ ] 8.3 Remote mode end to end against a dev server: choose Remote → system- + browser login → deep-link handoff → app authenticates as the logged-in user + → no local database created. +- [ ] 8.4 Reset from each mode returns to the selection screen; local data files + remain on disk after a reset. +- [ ] 8.5 Confirm a hosted server rejects `QUANTUM_MODE=local` combined with + server config, and that the default (no `QUANTUM_MODE`) behaves exactly as + the current release. diff --git a/openspec/changes/add-mcp-server/.openspec.yaml b/openspec/changes/add-mcp-server/.openspec.yaml new file mode 100644 index 0000000..8793dfb --- /dev/null +++ b/openspec/changes/add-mcp-server/.openspec.yaml @@ -0,0 +1,5 @@ +schema: spec-driven +created: 2026-07-17 +goal: Let a Claude-style agent read and safely write the household ledger + through a transport-agnostic MCP endpoint that works identically for a + self-hosted server and a future local desktop build diff --git a/openspec/changes/add-mcp-server/README.md b/openspec/changes/add-mcp-server/README.md new file mode 100644 index 0000000..af00770 --- /dev/null +++ b/openspec/changes/add-mcp-server/README.md @@ -0,0 +1,3 @@ +# add-mcp-server + +Expose Quantum's service layer to agents via an MCP server over Streamable HTTP, with bearer API tokens and a new 'agent' provenance source diff --git a/openspec/changes/add-mcp-server/design.md b/openspec/changes/add-mcp-server/design.md new file mode 100644 index 0000000..2a9f349 --- /dev/null +++ b/openspec/changes/add-mcp-server/design.md @@ -0,0 +1,282 @@ +## Context + +Quantum's server core is transport-agnostic by construction (`src/lib/server/README.md`, +design D6). The relevant current state: + +- **Services are pure-ish functions.** Every domain operation in + `src/lib/server/services/` takes typed inputs and a `DatabaseSync` handle and + returns plain data — `monthlyReport`, `netWorthSeries`, `listLedger`, + `listAccounts`, `listCategories`, `listRules`, `probeRule`, + `applyRulesToUncategorized`, `runSync`, and so on. No `Request`/`Response`, no + cookies, no SvelteKit imports. An MCP tool is a thin function-to-function + adapter over these. +- **Categorization is an append-only event log** (`categorization.ts`). + `EventSource` is a closed enum `'rule' | 'manual' | 'reconciliation'`. + `appendCategorizationEvent` enforces per-source invariants (`rule` requires a + `ruleId`, `manual` requires an `actorDid`) and updates the denormalized + `transactions.category_id` cache in the same DB transaction (design D4). +- **Sessions are opaque tokens** (`sessions.ts`), delivered by HTTP-only cookie + but documented as bearer-ready. The `handle` hook (`hooks.server.ts:47`) + reads the cookie, resolves it to a user, sets `event.locals.user`, and + redirects unauthenticated requests away from non-public paths. +- **The auth spec already anticipated this**: session tokens "SHALL NOT assume + cookie transport, so future native clients can present the same token as a + bearer credential." +- **The MCP TypeScript SDK ships a web-standard transport.** + `WebStandardStreamableHTTPServerTransport` is built on `Request`/`Response`/ + `ReadableStream` and is explicitly Deno-compatible, so it composes with a + SvelteKit `+server.ts` handler (which is itself `(event) => Response`) without + a Node `IncomingMessage`/`ServerResponse` shim. + +The user-facing goal is to let an agent take over the repetitive half of the +ledger — categorizing transactions, drafting rules, pulling the numbers — over a +protocol it already speaks, without weakening the web app's security model or +diluting the provenance guarantee that is central to the product. + +## Goals / Non-Goals + +**Goals:** + +- Expose the existing service layer to agents over MCP, with one handler + implementation that serves both the remote server and a future local desktop + mount. +- Authenticate agents with a durable, scoped, revocable bearer credential that + reuses the opaque-token design D6 promised, without touching cookie sessions. +- Make agent authorship a first-class, honest provenance variant — never a + person's identity, never silently anonymous. +- Give agents a write surface wide enough to be useful (categorize, create + categories, draft and probe rules, run the rulebook, trigger sync) and no + wider — nothing that creates accounts, sets up connections, or hard-deletes. + +**Non-Goals:** + +- Spec-compliant MCP OAuth. The MCP spec's HTTP auth story casts the resource + server as an OAuth authorization server. Quantum is an ATProto OAuth *client*, + not an AS; standing up a full AS for a two-person household app is + disproportionate. Bearer tokens minted in-app are the deliberate trade — see + decision 3. +- The desktop package, no-login local mode, and remote-mode OAuth handoff. Those + live in `add-desktop-local-remote-modes`, which consumes this change's handler + and bearer path. +- CSV import and SimpleFIN setup tools. Wrong shape (interactive wizard) and + credential-adjacent (token claiming), respectively. +- Per-tool or per-resource fine-grained scopes. Two scopes — `read` and + `readwrite` — are enough for two people; anything finer is speculative. + +## Decisions + +### 1. One handler, two mounts + +**Decision:** The MCP server is a single web-standard request handler bound to +`WebStandardStreamableHTTPServerTransport`. It mounts today as `POST /mcp` via a +SvelteKit `+server.ts`. The forthcoming desktop change mounts the *same* handler +on a loopback `Deno.serve` listener for local mode. The handler never reads a +cookie, a SvelteKit `event`, or an environment mode. + +**Why:** The two modalities the product targets — remote (agent → hosted server) +and local (agent → desktop app) — are the same protocol at two addresses. An MCP +handler is a pure function from an HTTP request to an HTTP response, so "the same +code at two mounts" is achievable rather than aspirational. Encoding the +transport as a bare handler keeps local mode from ever needing a second +implementation, and keeps this change from having to know local mode exists. + +**Alternative considered:** A stdio MCP binary that opens the SQLite file +directly for local mode. Rejected: it puts a second process on the same database +as the desktop app, and `initDb`'s process-wide singleton, the migration runner, +and the sync scheduler all assume a single writer. A stdio *shim* that proxies +stdio ⇄ localhost HTTP remains available later purely for agent-host UX (hosts +that only speak stdio), but it is a proxy over this handler, not a second core. + +### 2. Stateless transport + +**Decision:** Run the Streamable HTTP transport in stateless mode — no +server-held session map across requests, each request self-authenticated by its +bearer token. + +**Why:** Stateful MCP sessions exist to amortize expensive per-session setup and +to support server-initiated streaming to long-lived clients. A two-person +finance server has neither need. Stateless is fewer moving parts, no session +eviction to reason about, and it survives a server restart trivially — which +matters more once the same handler runs in a desktop app that sleeps and wakes. + +### 3. Bearer API tokens, not an OAuth authorization server + +**Decision:** A new `api_tokens` table. Minting generates a high-entropy token +(same `randomBytes(32).toString('base64url')` shape as sessions), shows it once, +and stores only its hash. Each token has a `label`, a `scope` (`read` | +`readwrite`), a nullable `user_did` (the creator on a multi-user server; NULL in +future single-user local mode), `created_at`, and `last_used_at`. The MCP +endpoint accepts only `Authorization: Bearer `. + +**Why:** This reuses the opaque-token model design D6 already blessed and keeps +the credential surface uniform. Standing up an OAuth AS to satisfy the MCP spec's +HTTP auth section would be weeks of PKCE/PAR/consent machinery to protect two +users who can paste a token in ten seconds. The cost of the trade is that MCP +hosts offering "add server by URL with automatic OAuth" won't complete a flow — +users paste a token instead. For this audience that is acceptable, and stated +plainly rather than hidden. + +**Storage as a hash, not plaintext:** a leaked database should not hand over live +agent credentials, and sessions already set the precedent that the server holds +opaque handles, not reversible secrets. `last_used_at` turns a forgotten token +into something visible and revocable rather than an invisible standing grant. + +### 4. Bearer resolution lives in the hook, beside cookie resolution + +**Decision:** `handle` in `hooks.server.ts` resolves auth in one place: if a +session cookie is present, resolve it as today; else if an `Authorization: +Bearer` header is present, verify it against `api_tokens` and attach the +resulting principal to `event.locals`. `/mcp` is not added to `PUBLIC_PATHS`, so +an unauthenticated MCP request is rejected by the existing guard. + +**Why:** One authentication chokepoint is easier to keep correct than two. +Putting bearer resolution beside cookie resolution means every protected route +automatically accepts a token too, which is exactly what the desktop remote mode +will need for *session* bearer tokens later — this change builds that seam for +API tokens, and the companion change widens it to OAuth-issued session tokens. + +**The principal is richer than today's `event.locals.user`.** A cookie yields a +user; a token yields a token identity that *may* carry a user DID and always +carries a scope. `event.locals` gains an optional `apiToken` (id, scope, +userDid?) alongside `user`, so a route/tool can enforce scope and attribute +authorship correctly. Web routes ignore it and keep using `user`. + +### 5. `agent` is a fourth event source, attributed to a token + +**Decision:** `EventSource` becomes +`'rule' | 'manual' | 'reconciliation' | 'agent'`. +`categorization_events` gains a nullable `api_token_id` FK. +`appendCategorizationEvent` enforces that an `agent` event carries an +`apiTokenId` (and no `actorDid`), mirroring the existing invariant that a +`manual` event carries an `actorDid`. A new `categorizeByAgent(db, txId, +categoryId, apiTokenId)` parallels `categorizeManually`. + +**Why:** The product's first principle is that every categorization traces to a +rule or a person. An agent is neither, and both available shortcuts are wrong: +attributing an agent write to the token's user DID (option a) would print a +person's handle on a categorization a person never made — a quiet lie in the one +place the product promises the truth; leaving it anonymous would break the +"always traces to something" guarantee. A distinct source attributed to the +token is the honest encoding: the badge reads `🤖 `, the popover can +name the human the token belongs to, and the audit trail is exact. + +**Manual still outranks agent.** Agent categorization uses the same "humans +outrank everything" rule already in force — an `agent` event can set a category +where none was chosen or where a rule chose, but the rule engine's existing +"never overwrite a `manual` latest event" precedence is extended so agents also +never overwrite a human's manual choice. Agents are a faster rule-writer and +bulk-categorizer, not an authority above the two people. + +**Alternative considered:** Attribute agent writes to the token's `user_did` and +skip the enum change. Rejected on principle #2 above; the whole point of the +provenance system is defeated if an automated actor can wear a person's face. + +### 6. The tool catalog: fifteen tools, thin over services + +**Decision:** Nine read tools and six write tools, each a direct wrapper over an +existing service function. Read tools require any valid token; write tools +require a `readwrite` token. + +Read: + +| Tool | Service | +| --- | --- | +| `list_accounts` | `listAccounts` + `listStaleAccounts` | +| `list_transactions` | `listLedger`, `listMonths` | +| `get_transaction_history` | `listEvents` | +| `get_monthly_report` | `monthlyReport` + `pendingStats` | +| `get_net_worth` | `netWorthSeries` | +| `list_categories` | `listCategories` | +| `list_rules` | `listRules` + `ruleMatchHealth` | +| `probe_rule` | `probeRule` / `countRuleMatches` | +| `get_sync_status` | `getLastSync` + `getConnectionErrors` | + +Write: + +| Tool | Service | +| --- | --- | +| `categorize_transaction` | `categorizeByAgent` (`source: 'agent'`) | +| `create_category` | `createCategory` | +| `create_rule` | `createRule` | +| `set_rule_active` | `setRuleActive` | +| `apply_rules` | `applyRulesToUncategorized` | +| `trigger_sync` | `runSync` | + +**Why these and not others:** the read set makes "where did our money go this +month, and are we gaining ground?" fully answerable — the product's two +headline questions. The write set targets the toil (categorization) and its +force multiplier (rules), plus the freshness lever (sync). `probe_rule` is +paired with `create_rule` deliberately: an agent should dry-run a pattern's blast +radius before committing it, and the tool descriptions will say so. + +**Why not more:** account hide/classify and category rename are settings-surface +polish, easy to add once the pattern exists; CSV import is an interactive wizard; +`claimSetupToken` handles a bank credential and stays human-only; nothing touches +sessions or users. The catalog is intentionally small and boring — every tool is +a function that already has tests. + +### 7. Scope enforcement at the tool boundary + +**Decision:** The MCP server groups tools by required scope and refuses a write +tool when the authenticating token is `read`. Scope is checked once, at +dispatch, from the principal on `event.locals`. + +**Why:** A `read` token handed to a summarizing agent should be structurally +incapable of mutating the ledger, not merely trusted not to. Enforcing at +dispatch (rather than inside each service) keeps the services oblivious to +transport and scope, consistent with D6. + +## Risks / Trade-offs + +- **A bearer token is a durable key to the whole ledger** → shown once, stored + hashed, scoped, individually revocable, and stamped with `last_used_at` so a + stale token is visible. The write surface excludes account creation, connection + setup, and hard deletion, so the blast radius of a leaked `readwrite` token is + bounded to reversible, auditable categorization changes. + +- **Non-spec-compliant MCP auth** means some hosts' automatic OAuth "add by URL" + flows won't work; users paste a token → accepted trade for a two-person app, + stated in the docs rather than papered over. If a host strictly requires the + OAuth flow, that host is unsupported for now. + +- **An agent miscategorizes at scale** → every agent write is an `agent` event in + the append-only log, attributed to a named token, and never overwrites a manual + choice. Undoing a bad batch is re-categorizing, and the provenance trail shows + exactly which token did what and when. + +- **Two writers to the database in future local mode** (desktop app + an MCP + mount) → avoided by decision 1: the local mount is in-process on the same + `DatabaseSync` handle, not a second process. This change, running only as a + SvelteKit route, has a single writer regardless. + +- **The SDK's web-standard transport is newer than its Node transport** → de-risk + with an early spike (task 1) that stands up the transport in a `+server.ts` + under Deno and completes one `initialize` + `tools/list` round-trip before any + tool is wired. + +## Migration Plan + +One additive migration (`006_api_tokens.sql`, number to be confirmed against the +current max): + +- `CREATE TABLE api_tokens` — `id`, `label` (NOT NULL), `token_hash` (NOT NULL, + UNIQUE), `scope` NOT NULL CHECK in (`read`, `readwrite`), `user_did` + (nullable, FK to users), `created_at` NOT NULL, `last_used_at` (nullable). +- `ALTER TABLE categorization_events ADD COLUMN api_token_id INTEGER REFERENCES + api_tokens (id)` — nullable; set only on `agent` events. +- Recreate `categorization_events` with a widened `source` CHECK admitting + `agent` (SQLite cannot alter a CHECK in place; follow the existing + table-rebuild pattern used elsewhere in migrations, preserving all rows and + the append-only guarantee). If the current schema declares `source` without a + CHECK constraint, this step is a no-op beyond documentation. + +Every existing categorization event reads back with `api_token_id IS NULL`, which +is correct — none were agent-sourced. Forward-only, no data rewrite of values. + +## Open Questions + +- **Token label uniqueness** — should two tokens be allowed the same label? Leaning + yes (label is a human hint, `id` is identity), resolved at implementation. +- **Rate limiting `trigger_sync`** — `runSync` hits SimpleFIN; an agent looping on + it could hammer the Bridge. A minimum interval (reuse the daily-sync rationale) + may be worth enforcing in the tool wrapper. Decide when wiring the tool. diff --git a/openspec/changes/add-mcp-server/proposal.md b/openspec/changes/add-mcp-server/proposal.md new file mode 100644 index 0000000..b11b153 --- /dev/null +++ b/openspec/changes/add-mcp-server/proposal.md @@ -0,0 +1,110 @@ +## Why + +Categorizing a month of transactions is the most repetitive chore in Quantum, +and answering "where did our money go?" already lives entirely in the service +layer — `monthlyReport`, `netWorthSeries`, `listLedger` return plain data with +no view attached. An agent that can read those and write categorizations would +turn "sit down and sort the ledger" into "ask Claude to draft the +categorizations, then glance at what it proposed." + +The server was built for this. `src/lib/server/README.md` (design D6) commits to +a transport-agnostic core: domain logic is plain functions, routes are thin +adapters, and sessions are opaque tokens explicitly meant to be "presentable as +a bearer credential by future native clients." An MCP server is the second +adapter that README predicted — the same functions the web routes call, exposed +over a protocol agents already speak. + +This change is deliberately server-only and modality-agnostic. It ships on the +self-hosted server today, and the same request/response handler will later mount +inside the desktop build's local mode without modification (see the companion +change `add-desktop-local-remote-modes`). Nothing here assumes a browser, a +cookie, or a logged-in user. + +## What Changes + +- A **Model Context Protocol server** exposed at `POST /mcp`, speaking the + Streamable HTTP transport, implemented as a single web-standard + `Request → Response` handler so it can mount both as a SvelteKit route (remote + mode) and on a loopback listener (future local mode) from one implementation. +- **Bearer API tokens**, minted from Settings under "Connect an agent." A token + carries a human-readable label, a scope (`read` or `readwrite`), and, on a + multi-user server, the DID of the user who created it. Tokens are presented as + `Authorization: Bearer ` and are the sole credential the MCP endpoint + accepts — no cookie, no OAuth dance. +- The session-resolving hook learns to authenticate a **bearer credential** when + no session cookie is present, so `/mcp` and future native clients share one + authentication path. Cookie-based web sessions are unchanged. +- A new **`agent` categorization event source**. When an agent categorizes a + transaction, the provenance chain records the acting API token, and the + provenance badge reads as the token's label (`🤖 claude`) rather than + impersonating a person. This honors the product's first principle — every + categorization traces to a rule or a person — by making "an agent, acting for + a person" a distinct, honest third kind of actor rather than a disguise. +- **Fifteen tools** over existing services: nine read, six write. Every tool is + a thin wrapper over a function that already exists in `services/`. The write + tools include a dry-run rule probe so an agent can check a pattern before + creating a rule. + +Not in this change: the desktop package, local/no-login mode, remote-mode OAuth +handoff, CSV import tools, and SimpleFIN connection setup tools. See Impact. + +## Capabilities + +### New Capabilities + +- `mcp-server`: Serving the Model Context Protocol over authenticated Streamable + HTTP; minting, scoping, and revoking bearer API tokens; and exposing a fixed + catalog of read and write tools over the existing service layer, subject to + each token's scope. + +### Modified Capabilities + +- `categorization`: The append-only event log gains `agent` as a fourth event + source, attributed to an API token rather than a user DID, and surfaced as a + distinct provenance variant. +- `auth`: Session management gains a bearer authentication path — a request + bearing a valid API token is authenticated as that token's principal — while + preserving cookie sessions unchanged and keeping every non-public route + protected. + +## Impact + +**Affected specs**: new `mcp-server`; modified `categorization` (event-source +enum) and `auth` (bearer authentication path). + +**Deliberately unaffected**: `simplefin-sync` and `csv-import` gain no tools. +SimpleFIN setup (`claimSetupToken`) is credential-adjacent and stays a +human-only Settings flow; CSV import is an inherently interactive multi-step +wizard whose shape does not fit a tool call. `account-management`'s rule that +accounts originate only from sync is untouched — no tool creates an account. + +**Affected code**: + +- `migrations/` — new migration: an `api_tokens` table; extend the + `categorization_events.source` CHECK constraint to admit `agent` and add a + nullable `api_token_id` FK for agent-sourced events. +- `src/lib/server/services/categorization.ts` — `EventSource` gains `"agent"`; + `appendCategorizationEvent` accepts an `apiTokenId` and enforces that `agent` + events carry one (mirroring the existing `manual`-requires-DID invariant). A + new `categorizeByAgent` wrapper parallels `categorizeManually`. +- `src/lib/server/services/` — a new `api-tokens.ts` service (mint, hash, + verify, list, revoke, touch `last_used_at`). Token verification returns a + principal the hook can attach to `event.locals`. +- `src/hooks.server.ts` — the `handle` hook resolves an `Authorization: Bearer` + credential when no session cookie is present; `PUBLIC_PATHS` and the redirect + logic are otherwise unchanged. `/mcp` is reachable only with a valid token. +- New `src/routes/mcp/+server.ts` — a thin adapter that hands the request to the + transport-bound MCP handler. New MCP server module wiring the tool catalog to + services. The `@modelcontextprotocol/sdk` `WebStandardStreamableHTTPServer` + transport is web-standard and runs under Deno without Node req/res shimming. +- `src/lib/server/services/categorization.ts` provenance display and the auth + spec's avatar/provenance rendering — the badge gains an `agent` variant. +- New Settings surface for minting and revoking API tokens. + +**Risk**: A bearer token is a durable credential to the whole ledger. It is +shown once at mint time, stored only as a hash, scoped, individually revocable, +and records `last_used_at` so a stale or leaked token is visible and killable. +The write surface is bounded to categorization, categories, rules, and sync — +never account creation, never connection setup, never destructive deletion — so +the worst an agent (or a stolen `readwrite` token) can do is miscategorize, +which the append-only event log makes fully reversible and auditable. diff --git a/openspec/changes/add-mcp-server/specs/auth/spec.md b/openspec/changes/add-mcp-server/specs/auth/spec.md new file mode 100644 index 0000000..ad45b85 --- /dev/null +++ b/openspec/changes/add-mcp-server/specs/auth/spec.md @@ -0,0 +1,48 @@ +## MODIFIED Requirements + +### Requirement: Session management + +The system SHALL persist application sessions and OAuth client state (state +store, session store) in SQLite. Sessions SHALL be identified by opaque tokens, +delivered to the web frontend via HTTP-only cookie; the token format SHALL NOT +assume cookie transport, so future native clients can present the same token as a +bearer credential. The system SHALL additionally accept a bearer API token: a +request that presents no session cookie but carries a valid `Authorization: +Bearer` API token SHALL be authenticated as that token's principal, carrying the +token's scope and, where present, the creating user's DID. Bearer resolution +SHALL occur in the same request-handling chokepoint as cookie resolution, and +SHALL NOT alter cookie-session behavior. Every route except login, the OAuth +callback, client metadata, and JWKS SHALL require either a valid session or a +valid API token. Users SHALL be able to log out, which destroys the application +session. + +#### Scenario: Unauthenticated access to a protected route + +- **WHEN** a request without a valid session cookie and without a valid API token + targets any protected route +- **THEN** the system redirects to the login page or, for an API endpoint, + rejects the request as unauthorized + +#### Scenario: Bearer token authenticates an API request + +- **WHEN** a request carries no session cookie but presents a valid + `Authorization: Bearer` API token +- **THEN** the system authenticates it as that token's principal, exposing the + token's scope for downstream authorization + +#### Scenario: Cookie session unchanged + +- **WHEN** a browser request presents a valid session cookie +- **THEN** it is authenticated exactly as before, regardless of any bearer header + +#### Scenario: Logout + +- **WHEN** an authenticated user triggers logout +- **THEN** the session record is deleted, the cookie is cleared, and subsequent + requests are treated as unauthenticated + +#### Scenario: Session survives server restart + +- **WHEN** the server process restarts and a user presents a previously issued + valid session cookie +- **THEN** the session is honored because it is persisted in SQLite diff --git a/openspec/changes/add-mcp-server/specs/categorization/spec.md b/openspec/changes/add-mcp-server/specs/categorization/spec.md new file mode 100644 index 0000000..4514539 --- /dev/null +++ b/openspec/changes/add-mcp-server/specs/categorization/spec.md @@ -0,0 +1,66 @@ +## MODIFIED Requirements + +### Requirement: Append-only categorization event log + +The system SHALL record every category assignment as an immutable event: +transaction id, category id, source (`rule`, `manual`, `reconciliation`, or +`agent`), the rule id for rule events, the acting user's DID for manual events, +the acting API token for agent events, and a timestamp. A transaction's current +category SHALL be the latest event, denormalized onto the transaction row in the +same database transaction as the event insert. Events SHALL never be updated or +deleted. An `agent` event SHALL carry the id of the API token that produced it +and SHALL NOT carry a user DID, so an automated actor is never recorded as a +person. + +#### Scenario: Manual categorization records actor + +- **WHEN** an authenticated user assigns a category to a transaction +- **THEN** a `manual` event is appended with that user's DID and the + transaction's cached category is updated atomically with it + +#### Scenario: Agent categorization records the token + +- **WHEN** an agent assigns a category to a transaction through the MCP write + tool +- **THEN** an `agent` event is appended recording the acting API token, with no + user DID, and the transaction's cached category is updated atomically with it + +#### Scenario: Provenance is visible + +- **WHEN** a user views a categorized transaction's history +- **THEN** the UI shows every event in order: what assigned it (rule pattern, + person, or agent token), to which category, and when + +#### Scenario: Recategorization preserves history + +- **WHEN** a user changes an already-categorized transaction to a different + category +- **THEN** a new event is appended and prior events remain queryable + +### Requirement: Manual decisions outrank rules + +The system SHALL never allow a rule or an agent to overwrite a categorization +whose latest event is `manual`. Rules fire only on transactions with no current +category. An agent categorization SHALL be permitted where no category is set or +where the latest event is `rule`, `reconciliation`, or `agent`, but SHALL NOT +overwrite a `manual` latest event. When a user creates a rule, the system SHALL +offer to retroactively apply it to existing matching transactions that are +currently uncategorized, and SHALL NOT touch categorized ones. + +#### Scenario: Rule does not overwrite manual choice + +- **WHEN** a rule matching a transaction is created or runs, and that + transaction's latest event is `manual` +- **THEN** the transaction's category is unchanged + +#### Scenario: Agent does not overwrite manual choice + +- **WHEN** an agent attempts to categorize a transaction whose latest event is + `manual` +- **THEN** the transaction's category is unchanged and the attempt is refused + +#### Scenario: Retroactive application on rule creation + +- **WHEN** a user creates a rule and accepts the retroactive-apply offer +- **THEN** the rule is applied to all matching currently-uncategorized + transactions, each receiving a `rule` event diff --git a/openspec/changes/add-mcp-server/specs/mcp-server/spec.md b/openspec/changes/add-mcp-server/specs/mcp-server/spec.md new file mode 100644 index 0000000..ac8de91 --- /dev/null +++ b/openspec/changes/add-mcp-server/specs/mcp-server/spec.md @@ -0,0 +1,120 @@ +## ADDED Requirements + +### Requirement: MCP endpoint over authenticated Streamable HTTP + +The system SHALL expose a Model Context Protocol server at `POST /mcp` using the +Streamable HTTP transport. The endpoint SHALL require a valid bearer API token +and SHALL reject any request without one. The MCP handler SHALL be a +transport-bound request handler that reads neither cookies nor server-side view +state, so the identical handler can serve both a hosted server and an in-process +local mount. The transport SHALL operate statelessly, each request +self-authenticated, requiring no server-held session across requests. + +#### Scenario: Unauthenticated MCP request rejected + +- **WHEN** a request reaches `/mcp` without a valid `Authorization: Bearer` + token +- **THEN** the system does not process any MCP method and responds as + unauthorized, creating no session + +#### Scenario: Authenticated tools listing + +- **WHEN** a client presents a valid token and issues an MCP `tools/list` request +- **THEN** the system returns the catalog of tools available to that token's + scope + +#### Scenario: Handler is transport-only + +- **WHEN** the MCP handler processes a request +- **THEN** it derives the caller's identity solely from the bearer token, reads + no session cookie, and depends on no web-view state, so the same handler can be + mounted outside the web router + +### Requirement: Bearer API tokens + +The system SHALL allow an authenticated user to mint API tokens from Settings, +each with a human-readable label and a scope of `read` or `readwrite`. On a +multi-user server the token SHALL record the DID of the user who created it. The +full token value SHALL be shown exactly once, at creation, and the system SHALL +persist only a hash of it, never the token itself. Each token SHALL be +individually revocable, and the system SHALL record when each token was last +used. A revoked token SHALL be rejected immediately on its next use. + +#### Scenario: Token shown once and stored hashed + +- **WHEN** a user mints an API token +- **THEN** the full token value is displayed a single time, only its hash is + persisted, and the value cannot be retrieved again afterward + +#### Scenario: Last-used recorded + +- **WHEN** a token authenticates an MCP request +- **THEN** the system updates that token's last-used timestamp + +#### Scenario: Revocation takes effect immediately + +- **WHEN** a user revokes a token and a client then presents it +- **THEN** the request is rejected as unauthorized + +#### Scenario: Token labelled for recognition + +- **WHEN** a user views their API tokens in Settings +- **THEN** each token is listed by its label, scope, creation time, and last-used + time, and the token value is not shown + +### Requirement: Read tool catalog + +The system SHALL expose read-only tools, available to any valid token regardless +of scope, that surface the existing service layer without modifying data: listing +accounts with balances and staleness, listing and filtering transactions, +retrieving a transaction's full categorization history, producing a monthly +income-versus-expense report, retrieving the net-worth series, listing +categories, listing rules with their match health, probing a prospective or +existing rule without applying it, and reporting sync status and connection +errors. A read tool SHALL NOT modify any transaction, event, rule, category, +account, or connection. + +#### Scenario: Answering where the money went + +- **WHEN** a client calls the monthly-report tool for a given month +- **THEN** the system returns income and expense totals by category for that + month, computed by the same service the web report uses, and changes nothing + +#### Scenario: Probe does not mutate + +- **WHEN** a client calls the rule-probe tool with a candidate pattern +- **THEN** the system returns the transactions the pattern would match and their + current categorization status, and no transaction or event is modified + +#### Scenario: Read scope suffices for read tools + +- **WHEN** a client authenticating with a `read`-scope token calls any read tool +- **THEN** the tool executes normally + +### Requirement: Write tool catalog gated by scope + +The system SHALL expose write tools — categorizing a transaction, creating a +category, creating a rule, activating or deactivating a rule, applying rules to +uncategorized transactions, and triggering a sync — and SHALL permit them only to +a token whose scope is `readwrite`. A write tool invoked with a `read`-scope +token SHALL be refused without effect. The write catalog SHALL NOT include +creating accounts, claiming SimpleFIN setup tokens, importing CSV files, or +hard-deleting any record. + +#### Scenario: Read token refused a write tool + +- **WHEN** a client authenticating with a `read`-scope token invokes a write tool +- **THEN** the system refuses the call and makes no change + +#### Scenario: Readwrite token categorizes + +- **WHEN** a client with a `readwrite` token invokes the categorize tool for a + transaction and category +- **THEN** the transaction is categorized via an `agent` event attributed to the + authenticating token + +#### Scenario: Excluded operations absent + +- **WHEN** a client lists available tools +- **THEN** no tool creates an account, claims a bank setup token, imports a CSV, + or hard-deletes a record diff --git a/openspec/changes/add-mcp-server/tasks.md b/openspec/changes/add-mcp-server/tasks.md new file mode 100644 index 0000000..5c0c18d --- /dev/null +++ b/openspec/changes/add-mcp-server/tasks.md @@ -0,0 +1,122 @@ +## 1. Transport spike (de-risk first) + +- [ ] 1.1 Add `@modelcontextprotocol/sdk` (npm) as a dependency. Stand up a + minimal `src/routes/mcp/+server.ts` that binds `McpServer` to + `WebStandardStreamableHTTPServerTransport` in stateless mode and registers + one trivial read tool. +- [ ] 1.2 Confirm under Deno (both `deno task dev` via Vite and a `deno task + build` node-adapter build) that an MCP client completes `initialize` + + `tools/list` + one `tools/call` round-trip against `/mcp`, with no Node + `IncomingMessage`/`ServerResponse` shimming required. Confirm `deno task + check` stays green with the new dependency (watch for the JSR-vs-npm + resolution trap that bit `csv-parse`). + +## 2. Schema + +- [ ] 2.1 Add a migration (`migrations/00N_api_tokens.sql`, N = current max + 1) + creating `api_tokens`: `id`, `label` (NOT NULL), `token_hash` (NOT NULL, + UNIQUE), `scope` NOT NULL CHECK in (`read`, `readwrite`), `user_did` + (nullable, FK to users), `created_at` (NOT NULL), `last_used_at` + (nullable). +- [ ] 2.2 In the same migration, add `api_token_id INTEGER REFERENCES api_tokens + (id)` (nullable) to `categorization_events`, and widen the `source` value + set to admit `agent`. If `source` is constrained by a CHECK, rebuild the + table following the existing migration table-rebuild pattern, preserving + every row and the append-only guarantee; if it is unconstrained, document + that no rebuild is needed. +- [ ] 2.3 Extend `src/lib/server/db.test.ts` to assert the migration applies to a + populated database, that existing `categorization_events` read back with + `api_token_id IS NULL`, and that an `agent`-source event inserts. + +## 3. API token service + +- [ ] 3.1 Create `src/lib/server/services/api-tokens.ts`: `mintToken(db, label, + scope, userDid?)` returning the one-time plaintext plus the stored record; + persist only the hash (hash the `randomBytes(32)` value; do not store + plaintext). `verifyToken(db, plaintext)` returning a principal (`{ id, + scope, userDid }`) or null, and touching `last_used_at` on success. + `listTokens(db)`, `revokeToken(db, id)`. +- [ ] 3.2 Relative imports within `src/lib/server/` only, so the service and its + test run under plain `deno test`. Add `api-tokens.test.ts`: mint→verify + round-trip, hash-not-plaintext storage, revoked token fails verify, + `last_used_at` advances, unknown token returns null. + +## 4. Bearer authentication in the hook + +- [ ] 4.1 In `src/hooks.server.ts`, extend `handle`: when no session cookie + resolves a user, read `Authorization: Bearer ` and call + `verifyToken`. Attach the principal to `event.locals.apiToken` (id, scope, + userDid?), leaving `event.locals.user` for cookie sessions. Do not add + `/mcp` to `PUBLIC_PATHS`; the existing guard rejects unauthenticated MCP + requests. +- [ ] 4.2 Update `app.d.ts` `App.Locals` with the optional `apiToken` principal. +- [ ] 4.3 Confirm cookie-session routes are entirely unaffected (a request with a + cookie ignores any bearer header; a request with neither is redirected/ + rejected as today). + +## 5. Agent categorization source + +- [ ] 5.1 In `src/lib/server/services/categorization.ts`, add `"agent"` to + `EventSource`; add optional `apiTokenId` to `CategorizationEventInput`; + enforce that `agent` events carry an `apiTokenId` and no `actorDid` + (mirroring the `manual`-requires-`actorDid` invariant). Persist + `api_token_id` in the event insert. +- [ ] 5.2 Add `categorizeByAgent(db, transactionId, categoryId, apiTokenId)` + paralleling `categorizeManually`, and extend the manual-outranks rule so an + agent write refuses a transaction whose latest event is `manual`. +- [ ] 5.3 Extend `listEvents` selection and the `CategorizationEvent` shape to + carry the agent token's label (join `api_tokens`) for provenance display. +- [ ] 5.4 Tests in `categorization.test.ts`: an `agent` event requires a token id; + an agent write over a `manual` latest event is refused; over a `rule` or + empty latest event it succeeds; history lists the agent event with its + token label. + +## 6. MCP server and tool catalog + +- [ ] 6.1 Create the MCP server module wiring the tool catalog to services. Group + tools by required scope; read the authenticating principal's scope from + `event.locals.apiToken` and refuse write tools to a `read` token at + dispatch, without reaching the service. +- [ ] 6.2 Implement the nine read tools as thin wrappers: `list_accounts`, + `list_transactions`, `get_transaction_history`, `get_monthly_report`, + `get_net_worth`, `list_categories`, `list_rules`, `probe_rule`, + `get_sync_status`. Each returns the service's plain data; none mutate. +- [ ] 6.3 Implement the six write tools: `categorize_transaction` + (`categorizeByAgent`, threading the token id), `create_category`, + `create_rule`, `set_rule_active`, `apply_rules`, `trigger_sync`. In each + tool's description, direct the agent to `probe_rule` before `create_rule`. +- [ ] 6.4 Consider a minimum interval on `trigger_sync` in its wrapper so an agent + loop cannot hammer the SimpleFIN Bridge (reuse the daily-sync rationale); + decide and document inline. +- [ ] 6.5 Replace the spike route with the real `src/routes/mcp/+server.ts`: a + thin adapter handing the request to the transport-bound handler. Keep the + handler free of SvelteKit/cookie reads so the desktop change can remount it. +- [ ] 6.6 Tests: a `read` token lists all tools but is refused each + write tool; a `readwrite` token categorizes a transaction and the resulting + event is `agent`-sourced with the token id; `probe_rule` mutates nothing. + +## 7. Settings: connect an agent + +- [ ] 7.1 Add a Settings section "Connect an agent": list existing tokens (label, + scope, created, last used) and a mint form (label + scope). On mint, show + the token value once with a copy affordance and a plain caution that it + will not be shown again. +- [ ] 7.2 Add the revoke action with a confirmation naming the token label. +- [ ] 7.3 Render an empty state (one sentence, one action) for no-tokens-yet. + Style per DESIGN.md: monospace for the token value, tabular numerals on + timestamps, no new red unless data is at risk. + +## 8. Verification + +- [ ] 8.1 Run `deno task test` and `deno task check`. +- [ ] 8.2 Drive a real MCP client (e.g. Claude with the server added by URL + + pasted token) end to end in dev: read the current month's report, list + uncategorized transactions, probe a pattern, create a rule, categorize a + handful of transactions, and confirm in the web ledger that each shows an + `agent` provenance badge with the token label — and that a manually + categorized transaction is left untouched by an agent attempt. +- [ ] 8.3 Confirm a `read`-scope token can perform every read tool and is refused + every write tool. +- [ ] 8.4 Revoke the token mid-session and confirm the next tool call is rejected. +- [ ] 8.5 Confirm the web app is unchanged for cookie users: login, categorize, + logout all behave as before.