diff --git a/.claude/launch.json b/.claude/launch.json index 68f0369..2a757ef 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,21 @@ "runtimeExecutable": "deno", "runtimeArgs": ["task", "dev"], "port": 5173 + }, + { + "name": "dev-local", + "runtimeExecutable": "deno", + "runtimeArgs": [ + "run", + "--env-file=.env.localmode", + "--unstable-cron", + "-A", + "npm:vite", + "dev", + "--port", + "5174" + ], + "port": 5174 } ] } diff --git a/.env.localmode.example b/.env.localmode.example new file mode 100644 index 0000000..6aad4fc --- /dev/null +++ b/.env.localmode.example @@ -0,0 +1,18 @@ +# Local mode (single-user desktop build). Copy to `.env.localmode` for +# `deno task dev:local`. No login, no OAuth, no allowlist — supplying +# ALLOWED_DIDS or OAUTH_PRIVATE_KEY_JWK here is a hard error. + +QUANTUM_MODE=local + +# Where the SQLite database lives. If unset, defaults to the OS application-data +# directory (e.g. %APPDATA%\Quantum\quantum.db). +DB_PATH=./data/local-dev.db + +# The single local user's display name. If unset, the app sends you to the +# first-launch /welcome screen to choose one. +QUANTUM_LOCAL_DISPLAY_NAME=You + +# Optional: bind a loopback-only MCP listener on this port for local agents. +# The desktop build sets this; for `dev:local` the SvelteKit /mcp route already +# works, so it's usually left unset. +# QUANTUM_LOCAL_MCP_PORT=5175 diff --git a/.gitignore b/.gitignore index 1cc7def..3c586ad 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ Thumbs.db .env .env.* !.env.example +!.env.localmode.example !.env.test # Vite diff --git a/deno.json b/deno.json index 80c7b91..680f775 100644 --- a/deno.json +++ b/deno.json @@ -8,8 +8,10 @@ }, "tasks": { "dev": "deno run --env-file --unstable-cron -A npm:vite dev", + "dev:local": "deno run --env-file=.env.localmode --unstable-cron -A npm:vite dev", "build": "deno run -A npm:vite build", "start": "deno run --env-file --unstable-cron -A build/index.js", + "desktop": "deno desktop --unstable-cron -A .", "check": "deno run -A npm:@sveltejs/kit/svelte-kit sync && deno run -A npm:svelte-check --tsconfig ./tsconfig.json", "test": "deno test -A src", "fmt": "deno fmt", diff --git a/openspec/changes/add-desktop-local-remote-modes/README.md b/openspec/changes/add-desktop-local-remote-modes/README.md index d662e26..8d0a546 100644 --- a/openspec/changes/add-desktop-local-remote-modes/README.md +++ b/openspec/changes/add-desktop-local-remote-modes/README.md @@ -1,3 +1,5 @@ # 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 +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 index 577883c..4ab17f1 100644 --- a/openspec/changes/add-desktop-local-remote-modes/design.md +++ b/openspec/changes/add-desktop-local-remote-modes/design.md @@ -1,7 +1,7 @@ ## Context -The server core is transport-agnostic (design D6) and already boots from a small, -well-defined seam. The relevant current state: +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 @@ -15,18 +15,18 @@ well-defined seam. The relevant current state: - **`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. +- **`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. +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 @@ -38,8 +38,8 @@ two runtimes. **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. +- 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. @@ -48,12 +48,12 @@ two runtimes. **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). +- 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. +- 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). @@ -63,39 +63,40 @@ two runtimes. **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. +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. +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. +**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. +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 @@ -103,19 +104,19 @@ 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. +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. +**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 +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 @@ -131,31 +132,32 @@ 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. +**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." +**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. +_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. +`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 @@ -167,10 +169,10 @@ revocable path — this only pre-places it where a local host looks. **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. +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 @@ -183,9 +185,9 @@ 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. +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 @@ -194,10 +196,11 @@ 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. +**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 @@ -211,8 +214,8 @@ carries a clear permissions error, so this is a path default, not new machinery. and the interval is a focused-only bonus. The parked tray runtime would remove the concern entirely by keeping the process resident. -- **The deep-link handoff carries a live credential across a process boundary** → - single-use at handoff, registered-scheme-only delivery, session-scoped and +- **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 @@ -220,9 +223,9 @@ carries a clear permissions error, so this is a path default, not new machinery. 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. +- **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`, @@ -234,8 +237,8 @@ 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 +- 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. diff --git a/openspec/changes/add-desktop-local-remote-modes/proposal.md b/openspec/changes/add-desktop-local-remote-modes/proposal.md index 8b84e0f..4bf49c6 100644 --- a/openspec/changes/add-desktop-local-remote-modes/proposal.md +++ b/openspec/changes/add-desktop-local-remote-modes/proposal.md @@ -1,26 +1,26 @@ ## 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. +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. +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. +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 @@ -29,13 +29,14 @@ 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. + 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 @@ -62,7 +63,7 @@ bearer-authentication path from API tokens to OAuth-issued session tokens. 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 + - 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 @@ -75,16 +76,17 @@ open questions), mobile builds, and auto-update. The MCP server itself is - `desktop-app`: Packaging Quantum as a native desktop binary; the first-launch choice between local and remote mode and its once-then-reset persistence; - local mode's no-login single-user runtime, app-data database, sync cadence, and - loopback MCP mount; and remote mode's thin-client shell with system-browser - OAuth deep-link handoff. + 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. + (`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 @@ -115,11 +117,12 @@ scheduled, and packaged. dropped and a local DB path in the app-data directory is defaulted. - `src/hooks.server.ts` — `init` conditionally skips `initOAuthClient` and the `Deno.cron` registration in local mode, seeds `did:local:self`, and starts the - on-launch-plus-interval sync and the loopback MCP listener. `handle` treats 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. + 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 @@ -131,10 +134,11 @@ scheduled, and packaged. 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. +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 index 11857d2..ff9f405 100644 --- a/openspec/changes/add-desktop-local-remote-modes/specs/auth/spec.md +++ b/openspec/changes/add-desktop-local-remote-modes/specs/auth/spec.md @@ -3,10 +3,10 @@ ### 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 +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 @@ -37,14 +37,14 @@ by the local single-user runtime. ### 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 +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. @@ -74,16 +74,18 @@ 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. +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 +- **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 diff --git a/openspec/changes/add-desktop-local-remote-modes/specs/desktop-app/spec.md b/openspec/changes/add-desktop-local-remote-modes/specs/desktop-app/spec.md index cbf647a..fcb0108 100644 --- a/openspec/changes/add-desktop-local-remote-modes/specs/desktop-app/spec.md +++ b/openspec/changes/add-desktop-local-remote-modes/specs/desktop-app/spec.md @@ -6,7 +6,8 @@ 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. +container image and server deployment SHALL remain a supported, unchanged +output. #### Scenario: Desktop artifact produced @@ -22,12 +23,12 @@ container image and server deployment SHALL remain a supported, unchanged output ### 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. +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 @@ -48,13 +49,14 @@ the user to the mode-selection screen on the next launch. ### 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. +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 @@ -91,8 +93,8 @@ agent host. A local agent request without a valid token SHALL be rejected. #### Scenario: Loopback MCP still requires a token -- **WHEN** a process on the machine connects to the loopback MCP endpoint without - a valid 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 diff --git a/openspec/changes/add-desktop-local-remote-modes/tasks.md b/openspec/changes/add-desktop-local-remote-modes/tasks.md index 97fd78a..32fedfa 100644 --- a/openspec/changes/add-desktop-local-remote-modes/tasks.md +++ b/openspec/changes/add-desktop-local-remote-modes/tasks.md @@ -3,10 +3,10 @@ - [ ] 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 + 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 @@ -15,8 +15,12 @@ ## 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. +- [x] 2.1 Add a `desktop` task to `deno.json` invoking `deno desktop` against + the SvelteKit build. Do not disturb the existing `build`/`start`/`image` + tasks. — Added `desktop` (`deno desktop --unstable-cron -A .`) and a + `dev:local` convenience task; existing tasks untouched. (Booting the + packaged app end-to-end still needs the app-config-driven mode selection of + tasks 2.2/7.1.) - [ ] 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. @@ -27,53 +31,85 @@ ## 3. Config: server vs. local -- [ ] 3.1 In `src/lib/server/config.ts`, add `mode: 'server' | 'local'` sourced +- [x] 3.1 In `src/lib/server/config.ts`, add `mode: 'server' | 'local'` sourced from `QUANTUM_MODE` (default `server`). In `server` mode, keep the current required-config validation exactly. In `local` mode, drop the `APP_URL` / `ALLOWED_DIDS` / `OAUTH_PRIVATE_KEY_JWK` requirements and default `dbPath` - to the OS app-data directory. -- [ ] 3.2 Make the modes mutually exclusive: `QUANTUM_MODE=local` together with + to the OS app-data directory. — `loadLocalConfig` branch; + `defaultLocalDbPath` resolves `%APPDATA%`/`Application Support`/XDG. Also + carries an optional `localDisplayName` from `QUANTUM_LOCAL_DISPLAY_NAME`. +- [x] 3.2 Make the modes mutually exclusive: `QUANTUM_MODE=local` together with `ALLOWED_DIDS` or OAuth key configuration MUST be a hard configuration - error, so a server can never half-apply the local relaxations. -- [ ] 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. + error, so a server can never half-apply the local relaxations. — Both + trigger a hard error in `loadLocalConfig`. +- [x] 3.3 Extend `config.test.ts`: local mode loads with none of the server env + vars; local + server config is rejected; server mode is unchanged. — 4 new + tests (8 total, all green). ## 4. Local mode boot and no-login auth -- [ ] 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 +- [x] 4.1 In `src/hooks.server.ts` `init`, branch on mode. In local mode: skip + `initOAuthClient`; seed + `upsertUser(db, 'did:local:self', )`; skip the `Deno.cron` + registration. — Seeds when `localDisplayName` is set (the welcome flow + seeds it otherwise); starts the local sync cadence. +- [x] 4.2 In `handle`, in local mode set `event.locals.user` to the local user unconditionally and never redirect to `/login`. Server mode is unchanged - (including `add-mcp-server`'s bearer path). -- [ ] 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. + (including `add-mcp-server`'s bearer path). — Split into `handleLocal` / + `handleServer`; `/login` and `/welcome` redirect to `/` once set up. + `/mcp` stays token-authenticated even locally. +- [x] 4.3 Read the display name from the first-launch flow (task 7); until set, + block app routes behind the local setup screen rather than a login page. — + `handleLocal` redirects protected routes to `/welcome` when + `did:local:self` is not yet seeded. (The `/welcome` screen itself is task + 7.) +- [x] 4.4 Tests: in local mode every protected route resolves as + `did:local:self` with no cookie; manual categorization records the local + actor and its display name surfaces in provenance; server-mode auth is + untouched. — Verified live against a headless local-mode server (port + 5174): `/` and `/ledger` → 200 no login, `/login` → 303 `/`, `/mcp` → 401 + without a token; the DB seeded `did:local:self` / "Graham". Server-mode + auth unchanged (its full suite still green). Hook logic is verified live + like the server-mode hooks, which are also not unit-tested (module + singletons). ## 5. Local sync cadence and loopback MCP mount -- [ ] 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 +- [x] 5.1 In local mode, run `runSync(getDb())` once during `init` (catch-up), + then on a `setInterval` at the configured interval while running. Honor + the task 1.1 finding for the interval and for whether background firing is + relied upon. Keep the server's daily `Deno.cron` path unchanged. — + `startLocalSync`; 6-hour interval, fire-and-log on launch. Interval value + to be revisited against spike 1.1 (background-timer behavior). +- [x] 5.2 If task 1.2 shows the server port is not reachable, start a loopback `Deno.serve` listener in local mode and mount `add-mcp-server`'s transport-only MCP handler on it, requiring the same bearer token. If the - port is reachable, skip this and document why. -- [ ] 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. + port is reachable, skip this and document why. — `startLoopbackMcp` binds + `127.0.0.1:QUANTUM_LOCAL_MCP_PORT` (opt-in via env so the dev/server run + isn't double-served) and reuses `handleMcpRequest`. Verified live: + loopback `/mcp` → 401 without a token, full 15-tool access with one. + Whether it is strictly needed vs. the SvelteKit route is spike 1.2 (needs + the desktop binary); the fallback is proven functional either way. +- [x] 5.3 Write the loopback endpoint (and optionally a freshly minted token) to + a well-known app-data location so a local agent host can be configured in + one step. The token still goes through the normal scoped/revocable mint + path. — Writes `agent.json` (`{ mcpUrl }`) next to the database on + loopback start. Deliberately writes the URL only, never a token: the user + mints a scoped, revocable token in Settings, so no plaintext credential + sits on disk. +- [x] 5.4 Tests/verification: a local agent reaches MCP over loopback with a + valid token and is rejected without one; sync-on-launch brings data + current after a simulated closed period. — Loopback token gating verified + live (401 without, 15 tools with). Sync-on-launch fires from `init` + (observed attempting `runSync` on the headless local-mode boot); a genuine + closed-period catch-up needs a real SimpleFIN connection, so its data + effect is covered by the existing `sync` service tests. ## 6. Remote mode thin client 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.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 @@ -84,36 +120,39 @@ `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. + 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.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 +- [ ] 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 +- [ ] 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. + 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 +- [x] 8.5 Confirm a hosted server rejects `QUANTUM_MODE=local` combined with server config, and that the default (no `QUANTUM_MODE`) behaves exactly as - the current release. + the current release. — Covered by `config.test.ts`: local + `ALLOWED_DIDS` + or local + OAuth key is rejected; default (no `QUANTUM_MODE`) is server + mode and the existing server-config tests are unchanged. diff --git a/openspec/changes/add-mcp-server/README.md b/openspec/changes/add-mcp-server/README.md index af00770..fac327a 100644 --- a/openspec/changes/add-mcp-server/README.md +++ b/openspec/changes/add-mcp-server/README.md @@ -1,3 +1,4 @@ # 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 +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 index b0e6279..08b9742 100644 --- a/openspec/changes/add-mcp-server/design.md +++ b/openspec/changes/add-mcp-server/design.md @@ -1,7 +1,7 @@ ## Context -Quantum's server core is transport-agnostic by construction (`src/lib/server/README.md`, -design D6). The relevant current state: +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 @@ -16,9 +16,9 @@ design D6). The relevant current state: `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. + 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." @@ -51,7 +51,7 @@ diluting the provenance guarantee that is central to the product. **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*, + 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. @@ -69,21 +69,21 @@ diluting the provenance guarantee that is central to the product. **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 +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 +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 +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. @@ -109,34 +109,37 @@ 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. +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. +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 +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 +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`. @@ -144,12 +147,13 @@ 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`. +`'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: @@ -157,8 +161,8 @@ 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. +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 @@ -179,41 +183,41 @@ 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` | +| 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 | -| --- | --- | +| Tool | Service | +| ------------------------ | --------------------------------------- | | `categorize_transaction` | `categorizeByAgent` (`source: 'agent'`) | -| `create_category` | `createCategory` | -| `create_rule` | `createRule` | -| `set_rule_active` | `setRuleActive` | -| `apply_rules` | `applyRulesToUncategorized` | -| `trigger_sync` | `runSync` | +| `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. +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. +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 @@ -230,29 +234,29 @@ transport and scope, consistent with D6. - **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. + 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. +- **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. +- **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 @@ -263,33 +267,38 @@ current max): 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. + 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. +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. ## Resolved During Implementation -- **Revocation must be soft, not a hard delete.** `categorization_events.api_token_id` - is a foreign key to `api_tokens`, so once a token has authored even one agent - categorization, `DELETE FROM api_tokens` fails the FK constraint — and forcing - it would orphan the provenance the whole feature exists to preserve. Revoke - therefore stamps a `revoked_at` column: `verifyToken` refuses a revoked token - immediately (satisfying "revocation takes effect immediately"), `listTokens` - hides it from the management list, and the row survives so its label keeps - resolving in the append-only event history. Found by revoking a token that had - categorized a live transaction; the migration and service were updated and a - regression test added. `api_tokens` gains `revoked_at TEXT` (nullable). +- **Revocation must be soft, not a hard delete.** + `categorization_events.api_token_id` is a foreign key to `api_tokens`, so once + a token has authored even one agent categorization, `DELETE FROM api_tokens` + fails the FK constraint — and forcing it would orphan the provenance the whole + feature exists to preserve. Revoke therefore stamps a `revoked_at` column: + `verifyToken` refuses a revoked token immediately (satisfying "revocation + takes effect immediately"), `listTokens` hides it from the management list, + and the row survives so its label keeps resolving in the append-only event + history. Found by revoking a token that had categorized a live transaction; + the migration and service were updated and a regression test added. + `api_tokens` gains `revoked_at TEXT` (nullable). ## 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. +- **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/specs/auth/spec.md b/openspec/changes/add-mcp-server/specs/auth/spec.md index ad45b85..f7b7197 100644 --- a/openspec/changes/add-mcp-server/specs/auth/spec.md +++ b/openspec/changes/add-mcp-server/specs/auth/spec.md @@ -5,21 +5,22 @@ 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. +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 +- **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 @@ -33,7 +34,8 @@ session. #### 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 +- **THEN** it is authenticated exactly as before, regardless of any bearer + header #### Scenario: Logout diff --git a/openspec/changes/add-mcp-server/specs/mcp-server/spec.md b/openspec/changes/add-mcp-server/specs/mcp-server/spec.md index ac8de91..6f2aeea 100644 --- a/openspec/changes/add-mcp-server/specs/mcp-server/spec.md +++ b/openspec/changes/add-mcp-server/specs/mcp-server/spec.md @@ -19,7 +19,8 @@ self-authenticated, requiring no server-held session across requests. #### Scenario: Authenticated tools listing -- **WHEN** a client presents a valid token and issues an MCP `tools/list` request +- **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 @@ -27,8 +28,8 @@ self-authenticated, requiring no server-held session across requests. - **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 + 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 @@ -59,16 +60,16 @@ used. A revoked token SHALL be rejected immediately on its next use. #### 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 +- **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 +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, @@ -95,15 +96,16 @@ account, or connection. 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 +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 +- **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 diff --git a/openspec/changes/add-mcp-server/tasks.md b/openspec/changes/add-mcp-server/tasks.md index 6bcef9d..deefda7 100644 --- a/openspec/changes/add-mcp-server/tasks.md +++ b/openspec/changes/add-mcp-server/tasks.md @@ -4,12 +4,14 @@ minimal `src/routes/mcp/+server.ts` that binds `McpServer` to `WebStandardStreamableHTTPServerTransport` in stateless mode and registers one trivial read tool. -- [x] 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`). +- [x] 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`). **Spike findings (resolved):** SDK `1.29.0`. Transport is `WebStandardStreamableHTTPServerTransport` from @@ -34,17 +36,20 @@ UNIQUE), `scope` NOT NULL CHECK in (`read`, `readwrite`), `user_did` (nullable, FK to users), `created_at` (NOT NULL), `last_used_at` (nullable). — `migrations/006_api_tokens.sql`. -- [x] 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. — `source` had a CHECK; table rebuilt - (leaf table, no incoming FKs) with a new `source != 'agent' OR - api_token_id IS NOT NULL` invariant. No prior table-rebuild pattern - existed in migrations; this is the first. -- [x] 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 +- [x] 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. — `source` had a CHECK; table rebuilt (leaf table, + no incoming FKs) with a new + `source != 'agent' OR + api_token_id IS NOT NULL` invariant. No prior + table-rebuild pattern existed in migrations; this is the first. +- [x] 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. Also relaxed the csv test's brittle exact-migration-count assertion to a `schema_version` membership check (it broke when 006 was added, and would @@ -52,13 +57,17 @@ ## 3. API token service -- [x] 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)`. — SHA-256 over a `qtm_`-prefixed - 256-bit random value; fast hash is sufficient for a high-entropy secret. +- [x] 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)`. — SHA-256 over a + `qtm_`-prefixed 256-bit random value; fast hash is sufficient for a + high-entropy secret. - [x] 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, @@ -75,8 +84,8 @@ `/mcp` a clean 401 (not the browser login redirect) when unauthenticated. - [x] 4.2 Update `app.d.ts` `App.Locals` with the optional `apiToken` principal. — Imports `TokenPrincipal` from the service. -- [x] 4.3 Confirm cookie-session routes are entirely unaffected (a request with a - cookie ignores any bearer header; a request with neither is redirected/ +- [x] 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). — Verified live over HTTP: unauthenticated `/mcp` → 401, valid bearer → 200 initialize, bad bearer → 401, `/ledger` without a cookie → 303 `/login` (unchanged). @@ -89,28 +98,28 @@ (mirroring the `manual`-requires-`actorDid` invariant). Persist `api_token_id` in the event insert. - [x] 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`. — Returns - `boolean` (false = refused because a manual event is latest); tool wrapper - (task 6) surfaces the refusal to the agent. + paralleling `categorizeManually`, and extend the manual-outranks rule so + an agent write refuses a transaction whose latest event is `manual`. — + Returns `boolean` (false = refused because a manual event is latest); tool + wrapper (task 6) surfaces the refusal to the agent. - [x] 5.3 Extend `listEvents` selection and the `CategorizationEvent` shape to carry the agent token's label (join `api_tokens`) for provenance display. — Added `apiTokenId` and `actorTokenLabel` to the event shape. -- [x] 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 +- [x] 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. — New file, 5 tests; full suite 108 passed, check green. ## 6. MCP server and tool catalog -- [x] 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 +- [x] 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. — `src/lib/server/mcp/server.ts`; - `buildMcpServer(db, principal)` closes over the principal per request; write - tools call a `requireWrite()` guard that returns an error result for `read` - scope. All 15 tools are always listed (a read token sees them, is refused - execution) per the spec. + `buildMcpServer(db, principal)` closes over the principal per request; + write tools call a `requireWrite()` guard that returns an error result for + `read` scope. All 15 tools are always listed (a read token sees them, is + refused execution) per the spec. - [x] 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`, @@ -120,38 +129,40 @@ `create_rule`, `set_rule_active`, `apply_rules`, `trigger_sync`. In each tool's description, direct the agent to `probe_rule` before `create_rule`. — `create_rule` uses `principal.userDid` as creator; errors clearly if the - token has no bound user (the local-mode case this change doesn't yet serve). -- [x] 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. — 15-minute minimum; a too-soon call returns a - `skipped` result naming when the last sync ran. + token has no bound user (the local-mode case this change doesn't yet + serve). +- [x] 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. — 15-minute minimum; a too-soon + call returns a `skipped` result naming when the last sync ran. - [x] 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. - — Route reads `locals.apiToken` (set by the hook) and delegates to - `handleMcpRequest`; the handler itself takes only `(request, db, principal)`. -- [x] 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. - — `mcp/server.test.ts` drives the real transport in-process via + handler free of SvelteKit/cookie reads so the desktop change can remount + it. — Route reads `locals.apiToken` (set by the hook) and delegates to + `handleMcpRequest`; the handler itself takes only + `(request, db, principal)`. +- [x] 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. — + `mcp/server.test.ts` drives the real transport in-process via `handleMcpRequest` + constructed `Request`s; 4 tests, all green. ## 7. Settings: connect an agent -- [x] 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. — Verified live: the mint action returns the - one-time token + label; the reveal block shows it with a Copy button. -- [x] 7.2 Add the revoke action with a confirmation naming the token label. - — Revoke is soft (see design's Resolved-During-Implementation note); the +- [x] 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. — Verified live: the mint action returns + the one-time token + label; the reveal block shows it with a Copy button. +- [x] 7.2 Add the revoke action with a confirmation naming the token label. — + Revoke is soft (see design's Resolved-During-Implementation note); the row's label survives for provenance. Verified the action removes the token from the live list. - [x] 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. — The MCP URL and token - value use `--font-mono`; timestamps carry `tnum`; the reveal uses a neutral - `--bg-sunken` panel, no alarm color. + value use `--font-mono`; timestamps carry `tnum`; the reveal uses a + neutral `--bg-sunken` panel, no alarm color. ## 8. Verification @@ -162,25 +173,27 @@ 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. — Driven via - real MCP JSON-RPC over the live `/mcp` route: `initialize`, `tools/list` - (15), read tools (accounts, net worth, sync status, list transactions, - list categories), and `categorize_transaction` on a live uncategorized - row, then `get_transaction_history` confirmed `source: agent` with the - token label and no actor DID. Rule creation, probe, and manual-outranks are - covered by the in-process integration tests (`mcp/server.test.ts`, - `categorization.test.ts`). -- [x] 8.3 Confirm a `read`-scope token can perform every read tool and is refused - every write tool. — Verified live: a read token reads `list_categories` - (no error) and is refused `categorize_transaction` with the read-write - message; the in-process test refuses all six write tools for a read token. -- [x] 8.4 Revoke the token mid-session and confirm the next tool call is rejected. - — Verified live: an authenticated `get_net_worth` returned 200, the token - was soft-revoked, and the identical next call returned 401. + categorized transaction is left untouched by an agent attempt. — Driven + via real MCP JSON-RPC over the live `/mcp` route: `initialize`, + `tools/list` (15), read tools (accounts, net worth, sync status, list + transactions, list categories), and `categorize_transaction` on a live + uncategorized row, then `get_transaction_history` confirmed + `source: agent` with the token label and no actor DID. Rule creation, + probe, and manual-outranks are covered by the in-process integration tests + (`mcp/server.test.ts`, `categorization.test.ts`). +- [x] 8.3 Confirm a `read`-scope token can perform every read tool and is + refused every write tool. — Verified live: a read token reads + `list_categories` (no error) and is refused `categorize_transaction` with + the read-write message; the in-process test refuses all six write tools + for a read token. +- [x] 8.4 Revoke the token mid-session and confirm the next tool call is + rejected. — Verified live: an authenticated `get_net_worth` returned 200, + the token was soft-revoked, and the identical next call returned 401. - [x] 8.5 Confirm the web app is unchanged for cookie users: login, categorize, logout all behave as before. — The hook consults a bearer token only when no cookie session resolves, so the cookie path is structurally untouched; verified a cookie session renders `/settings` (including the new section) and that a request with neither cookie nor token still redirects `/ledger` → `/login`. Full OAuth login/logout not driven (needs a live ATProto - provider); the cookie-resolution code is unchanged from before this change. + provider); the cookie-resolution code is unchanged from before this + change. diff --git a/src/app.d.ts b/src/app.d.ts index e4debe9..946620c 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -26,6 +26,10 @@ declare global { schedule: string, handler: () => void | Promise, ): void; + serve( + options: { port: number; hostname?: string }, + handler: (request: Request) => Response | Promise, + ): { finished: Promise; shutdown(): Promise }; }; } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 5fc5b96..bfa9ae9 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,5 +1,10 @@ import { building } from "$app/environment"; -import { type Handle, redirect, type ServerInit } from "@sveltejs/kit"; +import { + type Handle, + redirect, + type RequestEvent, + type ServerInit, +} from "@sveltejs/kit"; import { getConfig } from "$lib/server/config"; import { getDb, initDb } from "$lib/server/db"; import { initOAuthClient } from "$lib/server/auth/oauth-client"; @@ -8,32 +13,112 @@ import { getSessionUser, } from "$lib/server/services/sessions"; import { verifyToken } from "$lib/server/services/api-tokens"; +import { getUser, upsertUser } from "$lib/server/services/users"; import { runSync } from "$lib/server/services/sync"; +import { handleMcpRequest } from "$lib/server/mcp/server"; +import process from "node:process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** The synthetic single user of a local desktop build (no login). */ +export const LOCAL_DID = "did:local:self"; + +/** Log the outcome of a sync run to the console. */ +function logSyncOutcomes(outcomes: Awaited>): void { + for (const o of outcomes) { + console.log( + `sync connection ${o.connectionId}: ${ + o.ok ? "ok" : `FAILED (${o.error})` + }, +${o.newTransactions} txns, ${o.reconciled} reconciled, ${o.ruleCategorized} rule-categorized`, + ); + } +} export const init: ServerInit = async () => { if (building) return; const config = getConfig(); // fails fast with a clear error on missing/invalid env const db = initDb(config.dbPath, "migrations"); + + if (config.mode === "local") { + // Single-user desktop build: no login, no OAuth client. Seed the local + // identity when the display name is already chosen; otherwise the welcome + // flow seeds it on first launch. + if (config.localDisplayName) { + upsertUser(db, LOCAL_DID, config.localDisplayName); + } + startLocalSync(); + startLoopbackMcp(config.dbPath); + return; + } + deleteExpiredSessions(db); await initOAuthClient(config, db); // Daily sync. The Bridge refreshes bank data roughly daily; more often is pointless. try { Deno.cron("daily simplefin sync", "0 11 * * *", async () => { - const outcomes = await runSync(getDb()); - for (const o of outcomes) { - console.log( - `sync connection ${o.connectionId}: ${ - o.ok ? "ok" : `FAILED (${o.error})` - }, +${o.newTransactions} txns, ${o.reconciled} reconciled, ${o.ruleCategorized} rule-categorized`, - ); - } + logSyncOutcomes(await runSync(getDb())); }); } catch (err) { console.warn("Deno.cron unavailable; scheduled sync disabled:", err); } }; +// Local mode is not always on, and Deno.cron keeps its schedule in memory with +// no catch-up for missed fires, so a fixed daily time would silently skip any +// day the app wasn't open. Instead: sync once on launch (catching up whatever +// was missed while closed) and periodically while the app runs. +const LOCAL_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours + +function startLocalSync(): void { + const run = () => + runSync(getDb()) + .then(logSyncOutcomes) + .catch((err) => console.warn("local sync failed:", err)); + run(); // on launch + setInterval(run, LOCAL_SYNC_INTERVAL_MS); // while running +} + +// A `deno desktop` build talks to its webview over an in-process channel, so +// the embedded server's HTTP port is not guaranteed to be a reachable surface +// for a local agent. When the desktop entrypoint sets QUANTUM_LOCAL_MCP_PORT, +// bind a loopback-only listener that mounts the same transport-only MCP handler +// and requires the same bearer token. Left unset (dev/server), the SvelteKit +// /mcp route serves agents directly and this is a no-op. +function startLoopbackMcp(dbPath: string): void { + const portRaw = process.env.QUANTUM_LOCAL_MCP_PORT?.trim(); + if (!portRaw) return; + const port = Number(portRaw); + if (!Number.isInteger(port) || port <= 0) { + console.warn(`QUANTUM_LOCAL_MCP_PORT is not a valid port: ${portRaw}`); + return; + } + Deno.serve({ port, hostname: "127.0.0.1" }, (request) => { + const path = new URL(request.url).pathname; + if (!isMcpPath(path)) return new Response("Not found", { status: 404 }); + const bearer = readBearer(request.headers.get("authorization")); + const principal = bearer ? verifyToken(getDb(), bearer) : null; + if (!principal) return new Response("Unauthorized", { status: 401 }); + return handleMcpRequest(request, getDb(), principal); + }); + const mcpUrl = `http://127.0.0.1:${port}/mcp`; + console.log(`local MCP listening on ${mcpUrl}`); + + // Publish the endpoint next to the database so a local agent host can be + // pointed at it in one step. The URL only — never a token; the user mints a + // scoped, revocable token in Settings, so no plaintext credential sits on disk. + try { + const dir = dirname(dbPath); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "agent.json"), + JSON.stringify({ mcpUrl }, null, 2), + ); + } catch (err) { + console.warn("could not write agent.json endpoint file:", err); + } +} + export const SESSION_COOKIE = "quantum_session"; /** Routes reachable without a session: login, OAuth plumbing, health check. */ @@ -52,7 +137,21 @@ function readBearer(header: string | null): string | null { return match ? match[1].trim() : null; } -export const handle: Handle = async ({ event, resolve }) => { +function isMcpPath(path: string): boolean { + return path === "/mcp" || path.startsWith("/mcp/"); +} + +export const handle: Handle = ({ event, resolve }) => + getConfig().mode === "local" + ? handleLocal(event, resolve) + : handleServer(event, resolve); + +type Resolve = Parameters[0]["resolve"]; + +async function handleServer( + event: RequestEvent, + resolve: Resolve, +): Promise { const cookie = event.cookies.get(SESSION_COOKIE); event.locals.user = cookie ? getSessionUser(getDb(), cookie) : null; @@ -70,9 +169,7 @@ export const handle: Handle = async ({ event, resolve }) => { const authenticated = event.locals.user || event.locals.apiToken; if (!authenticated && !PUBLIC_PATHS.has(path)) { // API endpoints answer 401; browser routes get the login redirect. - if (path === "/mcp" || path.startsWith("/mcp/")) { - return new Response("Unauthorized", { status: 401 }); - } + if (isMcpPath(path)) return new Response("Unauthorized", { status: 401 }); redirect(303, "/login"); } if (event.locals.user && path === "/login") { @@ -80,4 +177,45 @@ export const handle: Handle = async ({ event, resolve }) => { } return resolve(event); -}; +} + +// Local mode (add-desktop-local-remote-modes): no login. The single local user +// is authenticated on every web request; MCP still requires a bearer token even +// on the loopback, so another process on the machine cannot reach the ledger +// unauthenticated. +async function handleLocal( + event: RequestEvent, + resolve: Resolve, +): Promise { + const db = getDb(); + const path = event.url.pathname; + + // MCP is token-authenticated, never the local session. + if (isMcpPath(path)) { + const bearer = readBearer(event.request.headers.get("authorization")); + event.locals.user = null; + event.locals.apiToken = bearer ? verifyToken(db, bearer) : null; + if (!event.locals.apiToken) { + return new Response("Unauthorized", { status: 401 }); + } + return resolve(event); + } + + event.locals.apiToken = null; + const localUser = getUser(db, LOCAL_DID); + + // Until the user has completed first-launch (no local identity yet), send + // them to the setup screen instead of a login page. + if (!localUser) { + event.locals.user = null; + if (path !== "/welcome" && !PUBLIC_PATHS.has(path)) { + redirect(303, "/welcome"); + } + return resolve(event); + } + + event.locals.user = localUser; + // Login and welcome are meaningless once set up. + if (path === "/login" || path === "/welcome") redirect(303, "/"); + return resolve(event); +} diff --git a/src/lib/server/config.test.ts b/src/lib/server/config.test.ts index 1dec8e4..a163fbc 100644 --- a/src/lib/server/config.test.ts +++ b/src/lib/server/config.test.ts @@ -70,3 +70,52 @@ Deno.test("non-DID allowlist entry rejected", () => { } if (!threw) throw new Error("expected non-DID entry to be rejected"); }); + +Deno.test("default mode is server", () => { + if (loadConfig(VALID_ENV).mode !== "server") { + throw new Error("expected server mode by default"); + } +}); + +Deno.test("local mode loads with none of the server env vars", () => { + const config = loadConfig({ QUANTUM_MODE: "local", DB_PATH: "./data/x.db" }); + if (config.mode !== "local") throw new Error("expected local mode"); + if (config.appUrl !== "" || config.allowedDids.length !== 0) { + throw new Error("local mode should carry no public origin or allowlist"); + } + if (config.dbPath !== "./data/x.db") throw new Error("DB_PATH not honored"); +}); + +Deno.test("local mode defaults the database path when DB_PATH is unset", () => { + const config = loadConfig({ + QUANTUM_MODE: "local", + HOME: "/home/u", + USERPROFILE: "C:\\Users\\u", + APPDATA: "C:\\Users\\u\\AppData\\Roaming", + XDG_DATA_HOME: "/home/u/.local/share", + }); + if (!config.dbPath.includes("Quantum")) { + throw new Error(`expected an app-data default path, got ${config.dbPath}`); + } +}); + +Deno.test("local mode rejects server auth configuration", () => { + for ( + const bad of [ + { QUANTUM_MODE: "local", ALLOWED_DIDS: "did:plc:aaa" }, + { QUANTUM_MODE: "local", OAUTH_PRIVATE_KEY_JWK: VALID_JWK }, + ] + ) { + let threw = false; + try { + loadConfig(bad); + } catch { + threw = true; + } + if (!threw) { + throw new Error( + `local mode must reject server auth config: ${JSON.stringify(bad)}`, + ); + } + } +}); diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index 77a6f57..128afed 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -1,19 +1,57 @@ import process from "node:process"; +import { join } from "node:path"; + +/** + * How this instance runs (add-desktop-local-remote-modes): + * - `server`: the hosted, multi-user deployment — ATProto OAuth login, a DID + * allowlist, and a public origin are all required. + * - `local`: the single-user desktop build — no login, no OAuth, data on the + * user's own disk. The server-only requirements are dropped. + */ +export type Mode = "server" | "local"; export interface Config { - /** Public HTTPS origin, no trailing slash. Drives OAuth client_id, redirect URI, JWKS URL. */ + /** How this instance runs. */ + mode: Mode; + /** Public HTTPS origin, no trailing slash. Drives OAuth client_id, redirect URI, JWKS URL. Empty in local mode. */ appUrl: string; - /** DIDs permitted to use this instance. */ + /** DIDs permitted to use this instance. Empty in local mode. */ allowedDids: string[]; /** Path to the SQLite database file. */ dbPath: string; - /** ES256 private key (JWK with kid) used for OAuth client authentication. */ + /** ES256 private key (JWK with kid) used for OAuth client authentication. Empty in local mode. */ oauthPrivateKeyJwk: Record; + /** + * Display name for the single local user (local mode only), supplied by the + * desktop first-launch flow. Undefined until the user has chosen one, which + * gates the app behind the setup screen. + */ + localDisplayName?: string; +} + +/** The OS-conventional per-user data directory for the local-mode database. */ +function defaultLocalDbPath( + env: Record, +): string { + const home = env.HOME ?? env.USERPROFILE ?? "."; + let dir: string; + if (process.platform === "win32") { + dir = env.APPDATA ?? join(home, "AppData", "Roaming"); + } else if (process.platform === "darwin") { + dir = join(home, "Library", "Application Support"); + } else { + dir = env.XDG_DATA_HOME ?? join(home, ".local", "share"); + } + return join(dir, "Quantum", "quantum.db"); } export function loadConfig( env: Record = process.env, ): Config { + const mode: Mode = env.QUANTUM_MODE?.trim() === "local" ? "local" : "server"; + + if (mode === "local") return loadLocalConfig(env); + const problems: string[] = []; const appUrlRaw = env.APP_URL?.trim(); @@ -86,7 +124,45 @@ export function loadConfig( throw new Error(`Invalid configuration:\n - ${problems.join("\n - ")}`); } - return { appUrl, allowedDids, dbPath: dbPath!, oauthPrivateKeyJwk }; + return { + mode: "server", + appUrl, + allowedDids, + dbPath: dbPath!, + oauthPrivateKeyJwk, + }; +} + +/** + * Local single-user mode: no login, no OAuth, no allowlist. Server-only + * settings are not merely optional — supplying them is a hard error, so a + * server image can never silently boot into the no-login relaxations. + */ +function loadLocalConfig(env: Record): Config { + const problems: string[] = []; + + if (env.ALLOWED_DIDS?.trim()) { + problems.push( + "ALLOWED_DIDS must not be set in local mode (QUANTUM_MODE=local is single-user and has no login)", + ); + } + if (env.OAUTH_PRIVATE_KEY_JWK?.trim()) { + problems.push( + "OAUTH_PRIVATE_KEY_JWK must not be set in local mode (no OAuth client runs)", + ); + } + if (problems.length > 0) { + throw new Error(`Invalid configuration:\n - ${problems.join("\n - ")}`); + } + + return { + mode: "local", + appUrl: "", // no public origin; the loopback MCP URL is surfaced elsewhere + allowedDids: [], + dbPath: env.DB_PATH?.trim() || defaultLocalDbPath(env), + oauthPrivateKeyJwk: {}, + localDisplayName: env.QUANTUM_LOCAL_DISPLAY_NAME?.trim() || undefined, + }; } let cached: Config | null = null; diff --git a/src/lib/server/db.test.ts b/src/lib/server/db.test.ts index 6f85c9a..e93676e 100644 --- a/src/lib/server/db.test.ts +++ b/src/lib/server/db.test.ts @@ -165,8 +165,9 @@ Deno.test("api_tokens migration applies to a populated database", () => { ).run(now); db.prepare("INSERT INTO users (did, handle, created_at) VALUES (?, ?, ?)") .run("did:plc:alice", "alice.test", now); - const txId = (db.prepare("SELECT id FROM transactions WHERE sfin_id = 'sfin-1'") - .get() as { id: number }).id; + const txId = + (db.prepare("SELECT id FROM transactions WHERE sfin_id = 'sfin-1'") + .get() as { id: number }).id; // A pre-existing manual event that the rebuild must copy verbatim. db.prepare( `INSERT INTO categorization_events (transaction_id, source, actor_did, created_at) @@ -200,8 +201,9 @@ Deno.test("api_tokens migration applies to a populated database", () => { `INSERT INTO api_tokens (label, token_hash, scope, created_at) VALUES ('claude', 'hash-1', 'readwrite', ?)`, ).run(now); - const tokenId = (db.prepare("SELECT id FROM api_tokens WHERE token_hash = 'hash-1'") - .get() as { id: number }).id; + const tokenId = + (db.prepare("SELECT id FROM api_tokens WHERE token_hash = 'hash-1'") + .get() as { id: number }).id; db.prepare( `INSERT INTO categorization_events (transaction_id, source, api_token_id, created_at) VALUES (?, 'agent', ?, ?)`, @@ -224,8 +226,9 @@ Deno.test("agent categorization event requires an api_token_id", () => { `INSERT INTO transactions (account_id, sfin_id, amount_cents, description, created_at) VALUES ('acct-1', 'sfin-1', -1234, 'COFFEE', ?)`, ).run(now); - const txId = (db.prepare("SELECT id FROM transactions WHERE sfin_id = 'sfin-1'") - .get() as { id: number }).id; + const txId = + (db.prepare("SELECT id FROM transactions WHERE sfin_id = 'sfin-1'") + .get() as { id: number }).id; let threw = false; try { diff --git a/src/lib/server/mcp/server.ts b/src/lib/server/mcp/server.ts index 43ed36b..7b218b9 100644 --- a/src/lib/server/mcp/server.ts +++ b/src/lib/server/mcp/server.ts @@ -5,18 +5,30 @@ import type { DatabaseSync } from "node:sqlite"; import type { TokenPrincipal } from "../services/api-tokens.ts"; import { listAccounts, listStaleAccounts } from "../services/accounts.ts"; -import { type LedgerFilters, listLedger, listMonths } from "../services/ledger.ts"; +import { + type LedgerFilters, + listLedger, + listMonths, +} from "../services/ledger.ts"; import { categorizeByAgent, listEvents } from "../services/categorization.ts"; -import { monthlyReport, netWorthSeries, pendingStats } from "../services/reports.ts"; -import { type CategoryKind, createCategory, listCategories } from "../services/categories.ts"; +import { + monthlyReport, + netWorthSeries, + pendingStats, +} from "../services/reports.ts"; +import { + type CategoryKind, + createCategory, + listCategories, +} from "../services/categories.ts"; import { applyRulesToUncategorized, countRuleMatches, createRule, listRules, probeRule, - type RuleProbe, ruleMatchHealth, + type RuleProbe, setRuleActive, } from "../services/rules.ts"; import { getConnectionErrors, getLastSync } from "../services/sync-status.ts"; @@ -62,11 +74,9 @@ export function buildMcpServer( const server = new McpServer({ name: "quantum", version: "1.0.0" }); const requireWrite = (): ToolResult | null => - principal.scope === "readwrite" - ? null - : error( - "This tool requires a read-write token. The presented token is read-only.", - ); + principal.scope === "readwrite" ? null : error( + "This tool requires a read-write token. The presented token is read-only.", + ); // ---- Read tools (any scope) ------------------------------------------ @@ -98,12 +108,16 @@ export function buildMcpServer( availableMonths: listMonths(db), })); - server.registerTool("get_transaction_history", { - description: - "The full, ordered categorization history (provenance chain) of one transaction: every rule, person, reconciliation, or agent event that set its category.", - inputSchema: { transactionId: z.number().int() }, - }, ({ transactionId }: { transactionId: number }) => - json(listEvents(db, transactionId))); + server.registerTool( + "get_transaction_history", + { + description: + "The full, ordered categorization history (provenance chain) of one transaction: every rule, person, reconciliation, or agent event that set its category.", + inputSchema: { transactionId: z.number().int() }, + }, + ({ transactionId }: { transactionId: number }) => + json(listEvents(db, transactionId)), + ); server.registerTool("get_monthly_report", { description: @@ -120,12 +134,16 @@ export function buildMcpServer( "The net-worth-over-time series, one point per date across all non-hidden accounts.", }, () => json(netWorthSeries(db))); - server.registerTool("list_categories", { - description: - "List categories with their kind (income, expense, or transfer). Pass activeOnly to exclude deactivated ones.", - inputSchema: { activeOnly: z.boolean().optional() }, - }, ({ activeOnly }: { activeOnly?: boolean }) => - json(listCategories(db, { activeOnly }))); + server.registerTool( + "list_categories", + { + description: + "List categories with their kind (income, expense, or transfer). Pass activeOnly to exclude deactivated ones.", + inputSchema: { activeOnly: z.boolean().optional() }, + }, + ({ activeOnly }: { activeOnly?: boolean }) => + json(listCategories(db, { activeOnly })), + ); server.registerTool("list_rules", { description: diff --git a/src/lib/server/services/categorization.test.ts b/src/lib/server/services/categorization.test.ts index 3c7dac3..0fd7d59 100644 --- a/src/lib/server/services/categorization.test.ts +++ b/src/lib/server/services/categorization.test.ts @@ -108,7 +108,8 @@ Deno.test("agent overwrites a rule-set category but is refused over a manual one db.prepare( "INSERT INTO rules (pattern, match_type, category_id, created_by_did, active, created_at) VALUES ('COFFEE','contains',1,'did:plc:t',1,?)", ).run(new Date().toISOString()); - const ruleId = (db.prepare("SELECT id FROM rules").get() as { id: number }).id; + const ruleId = + (db.prepare("SELECT id FROM rules").get() as { id: number }).id; appendCategorizationEvent(db, { transactionId: ruled, categoryId: 1, diff --git a/src/routes/(app)/settings/+page.server.ts b/src/routes/(app)/settings/+page.server.ts index 63f925a..30ed04f 100644 --- a/src/routes/(app)/settings/+page.server.ts +++ b/src/routes/(app)/settings/+page.server.ts @@ -140,7 +140,12 @@ export const actions: Actions = { } // A protected route: locals.user is present. The token is attributed to // its creator so agent-authored events trace back to a person. - const { token } = mintToken(getDb(), label, scope, locals.user?.did ?? null); + const { token } = mintToken( + getDb(), + label, + scope, + locals.user?.did ?? null, + ); // Returned once, for display; only its hash is stored. return { mintedToken: token, mintedLabel: label }; }, diff --git a/src/routes/welcome/+page.server.ts b/src/routes/welcome/+page.server.ts new file mode 100644 index 0000000..2710da9 --- /dev/null +++ b/src/routes/welcome/+page.server.ts @@ -0,0 +1,26 @@ +import { fail, redirect } from "@sveltejs/kit"; +import { getConfig } from "$lib/server/config"; +import { getDb } from "$lib/server/db"; +import { upsertUser } from "$lib/server/services/users"; +import { LOCAL_DID } from "../../hooks.server"; +import type { Actions, PageServerLoad } from "./$types"; + +// First-launch setup for the single local user. Only meaningful in local mode; +// a server deployment authenticates via login instead. +export const load: PageServerLoad = () => { + if (getConfig().mode !== "local") redirect(303, "/"); + return {}; +}; + +export const actions: Actions = { + default: async ({ request }) => { + if (getConfig().mode !== "local") redirect(303, "/"); + const form = await request.formData(); + const name = String(form.get("name") ?? "").trim(); + if (!name) { + return fail(400, { name, message: "Enter a name to continue." }); + } + upsertUser(getDb(), LOCAL_DID, name); + redirect(303, "/"); + }, +}; diff --git a/src/routes/welcome/+page.svelte b/src/routes/welcome/+page.svelte new file mode 100644 index 0000000..facbd57 --- /dev/null +++ b/src/routes/welcome/+page.svelte @@ -0,0 +1,99 @@ + + +
+
+

Quantum

+

Set up your ledger.

+ +
{ + submitting = true; + return async ({ update }) => { + await update(); + submitting = false; + }; + }} + > + + +

+ This is a personal, single-user copy of Quantum. Your name labels the + categorizations you make. Everything stays on this computer. +

+ {#if form?.message} + + {/if} + +
+
+
+ +