diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b72087e..e09610e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -27,3 +27,15 @@ updates: interval: weekly commit-message: prefix: deps + + # The Worker: wrangler, workers-types and the OAuth provider move often, and + # the provider is security-relevant. + - package-ecosystem: npm + directory: /cloud + schedule: + interval: weekly + commit-message: + prefix: deps + groups: + dev-dependencies: + dependency-type: development diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85ad4ad..713d917 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,7 @@ jobs: cache-dependency-path: | package-lock.json landing/package-lock.json + cloud/package-lock.json - name: Install root dependencies run: npm ci @@ -107,6 +108,9 @@ jobs: - name: Install landing dependencies run: npm --prefix landing ci + - name: Install cloud dependencies + run: npm --prefix cloud ci + # Every job here loads the real embedding model, so each one pulls the # 86MB MiniLM from HuggingFace: four jobs a run, and HuggingFace starts # answering 429. That blocked a merge on 2026-08-28 with nothing wrong @@ -133,6 +137,9 @@ jobs: - name: Typecheck (including the test tree) run: npm run typecheck + - name: Cloud Worker typecheck and tests + run: npm run test:cloud + - name: Root security tests and checklist run: | npm run test:security diff --git a/.gitignore b/.gitignore index ca991cc..566168a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ landing/dist/ +.wrangler/ .xtctx/.store/ .xtctx/config.yaml .xtctx/skills/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 74e32da..36c138c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,14 +1,18 @@ # xtctx — Architecture -One npm package, no services. Everything runs in the invoking process on the -developer's machine. `docs/architecture.md` describes module internals; this -document fixes the parts, how a handoff actually flows through them, the -boundaries between them, and what each part is allowed to trust. +One npm package, no services on the handoff path. Everything runs in the +invoking process on the developer's machine. The one exception is optional and +off by default: cloud sync, which uploads an opted-in project's sessions to a +separate Worker (`cloud/`) so the same user's agents on other machines can read +them. Handoff never depends on it. `docs/architecture.md` describes module +internals; this document fixes the parts, how a handoff actually flows through +them, the boundaries between them, and what each part is allowed to trust. ## Parts - **CLI** (`src/cli/`) — `setup`, `status`, `scan`, `export`, `import`, - `calibrate`, `disconnect`, and the internal `--hook session-start` entry point. Bare `xtctx` on a non-TTY stdio pair + `embeddings enable|disable`, `calibrate`, `disconnect`, the cloud-sync + commands `login`, `logout` and `sync`, and the internal `--hook session-start` entry point. Bare `xtctx` on a non-TTY stdio pair starts the MCP server. - **MCP server** (`src/mcp/`) — stdio JSON-RPC server exposing exactly five read-only tools. Spawned by coding agents via `npx -y xtctx`. @@ -46,6 +50,17 @@ boundaries between them, and what each part is allowed to trust. - **Config writers** (`src/config/`) — setup/disconnect logic that edits other tools' config files (MCP config, managed instruction blocks, synced skills, the Claude Code hook in `.claude/settings.json`). +- **Cloud sync client** (`src/sync/`) — optional. Uploads a project's + sessions from its index when, and only when, someone is logged in + (`~/.xtctx/credentials.json`) and the project is on the opt-in list + (`~/.xtctx/cloud-projects.json`). Runs inside the MCP server every 10 + seconds and once on shutdown, or from `xtctx sync`; reads the index through + its own read-only connection; keeps its position per project and account in + `~/.xtctx/sync/`, never in the index. See `docs/cloud-sync.md`. +- **Cloud Worker** (`cloud/`) — a separate Cloudflare Worker, not shipped in + the npm package. Stores uploads in D1 and serves them back over MCP to the + same account. Has its own tests (`npm run test:cloud`) and deploy steps + (`cloud/README.md`). - **Landing site** (`landing/`) — static Astro site on GitHub Pages; no runtime relationship to the package. @@ -123,6 +138,17 @@ would otherwise never vectorize anything; `hybrid` deliberately answers from keyword while the model is still loading, so the first call after a cold start is fast rather than blocked. +**Cloud sync, when a project opts in.** The MCP server starts an upload loop +next to its scan. Each tick reads the sessions this project's index +attributes to this project, sends what changed under the index's own message +ids, and compares the server's per-session count with the local one; on a +mismatch (a re-read replaced rows under new ids, or deleted some) it sends the +session whole with every id it holds, and the server deletes the rest, so the +cloud copy ends equal to the index. (A session with more ids than fit in one +request is resent but not pruned.) On shutdown the final upload runs +alongside the index close, inside the same bounded grace window, so it never +holds up releasing the scan lease. + **What comes back is raw.** Sessions, message text, and pointers — never a generated summary. A recap is the lossy artefact this exists to replace, and the transcripts remain authoritative. @@ -148,6 +174,11 @@ the transcripts remain authoritative. Managed markdown blocks touch nothing outside their markers — including the tail of the file, which is why setup does not trim it and removal gives back exactly the separator it added. +- **Index → cloud:** only with a login and a per-project opt-in, both in the + user's home directory. Metadata is cut to an allowlist with absolute paths + dropped, `source_pointer` is never sent, and environment credentials that + differ from the saved login refuse to upload unless explicitly allowed. + Message text goes as written. - **Process boundary:** the MCP server writes logs to stderr only — stdout is the JSON-RPC transport. The session-start hook fails open: it must never break a host agent's startup. @@ -183,6 +214,11 @@ the transcripts remain authoritative. `xtctx import` checks the header and every line before writing it, binds every value as a SQL parameter, and its content reaches agents through the same fenced MCP output as any other transcript text. +- **The opt-in is the user's, never the repository's.** Cloud sync reads its + login and opt-in list from the home directory, not `.xtctx/config.yaml`, so + a cloned repository cannot turn uploading on. The Worker treats every upload + as untrusted input: it validates the body, caps sizes, and scopes rows to + the authenticated account. - **The registry and npm supply chain** are trusted at install time; CI pins action SHAs and publishes via OIDC with provenance, no long-lived tokens. diff --git a/CHANGELOG.md b/CHANGELOG.md index 611b4c3..b183f59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ leaves this heading in place and empties it when a version is cut. * **mcp:** name an unconfigured project instead of answering with silence ([#315](https://github.com/fstubner/xtctx/issues/315)) * **index:** `xtctx export` and `xtctx import`; migrate an older index in place and carry sessions forward from one set aside; `xtctx status` counts sessions that exist only in the index * **embeddings:** semantic search is an optional add-on, off until `xtctx embeddings enable` installs the local model; keyword-only is a working state that status reports +* **cloud:** optional cloud sync, off by default and opt-in per project: `xtctx login` then `xtctx sync enable`, after which the MCP server uploads that project's sessions every 10 seconds and once on shutdown, and agents on your other machines read them from the cloud's MCP endpoint (OAuth sign-in for MCP clients, `xtctx sync token` for those without it). `xtctx status` and `xtctx sync status` say whether it is on and when it last uploaded or failed; `xtctx logout --delete-data` removes what was sent. What is and is not uploaded is listed in `docs/cloud-sync.md` +* **cloud:** each session's cloud copy is kept equal to the index, including after a scraper upgrade re-reads sessions under new message ids; metadata is cut to an allowlist with absolute paths dropped ### Bug Fixes diff --git a/PRODUCT.md b/PRODUCT.md index e4af712..f0778f1 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -17,10 +17,11 @@ them after 30 days by default). Deleting the index loses those, so xtctx never deletes it: schema upgrades migrate it in place, a corrupt one is set aside and its sessions carried into the rebuilt one, and `xtctx export` / `xtctx import` keep a copy elsewhere. It sends transcript content nowhere -unless a project opts in to an external embedding endpoint, written into -`.xtctx/config.yaml` by hand, trusted by the user in -`XTCTX_TRUSTED_EMBEDDING_ENDPOINTS` (a repository cannot set that), and -reported by `xtctx status`. +unless the user opts a project in to one of two things, both off by default +and both reported by `xtctx status`: cloud sync (`xtctx login` and then +`xtctx sync enable` in that project), or an external embedding endpoint, +written into `.xtctx/config.yaml` by hand and trusted by the user in +`XTCTX_TRUSTED_EMBEDDING_ENDPOINTS` (a repository cannot set that). ## Users @@ -30,7 +31,11 @@ reported by `xtctx status`. tools) that need stable session references and raw-detail pointers — served by `xtctx_handoff_manifest`. -Single-user, single-machine. There is no team, sync, or server component. +Single-user. Handoff itself is single-machine and needs no server. The one +exception is optional cloud sync, opt-in per project: a logged-in user can +upload an opted-in project's transcripts so agents on their other machines can +read them over MCP ([docs/cloud-sync.md](docs/cloud-sync.md)). There is no +team or shared component. ## Success @@ -68,11 +73,15 @@ Single-user, single-machine. There is no team, sync, or server component. index now, `--embed` to finish vectorizing too), `embeddings enable|disable` (install or remove the optional local model; keyword search needs none of it), `calibrate` (time the embedding model on this machine's devices and use - the fastest), `disconnect`. + the fastest), `export` / `import` (keep a copy of the index's sessions + outside it), `disconnect`, and for the optional cloud sync `login`, + `logout` and `sync` (`sync enable` opts the current project in). Out of scope (deliberately, and documented everywhere the product speaks): no daemon, no API server, no dashboard, no generated summaries or briefs, -no durable memory, no write-back tools, no cloud anything. +no durable memory, no write-back tools, and nothing leaves the machine unless +the user opts a project in to cloud sync, which is optional and off by +default. ## Constraints @@ -90,13 +99,17 @@ no durable memory, no write-back tools, no cloud anything. atomic, merge-preserving, and never clobber unparsable user content. - Transcript content handed to a model is untrusted data; the MCP layer fences it and never grows write capabilities. -- Everything runs local by default. Three network dependencies exist. Two are +- Everything runs local by default. Four network dependencies exist. Two are unavoidable and narrow: the one-time embedding-model download from Hugging Face (and the runtime from npm), made only when the user runs `xtctx embeddings enable`, and loopback-only HTTPS calls to Antigravity's local language server (127.0.0.1, exact-PID + CSRF matched; certificate verification is off - because the server is self-signed). The third is opt-in and is the only one - that carries transcript text: an OpenAI-compatible embedding endpoint named - in `.xtctx/config.yaml`. It is never inferred from the environment, the API - key is never stored in that file, and `xtctx status` prints the endpoint - whenever one is set. + because the server is self-signed). The other two are opt-in, and they are + the only ones that carry transcript text. One is an OpenAI-compatible + embedding endpoint named in `.xtctx/config.yaml`: it is never inferred from + the environment, the API key is never stored in that file, and `xtctx + status` prints the endpoint whenever one is set. The other is cloud sync, + which uploads an opted-in project's sessions to the xtctx cloud Worker + (`cloud/`) only while someone is logged in; the opt-in list lives in the + user's home directory, so a repository cannot opt itself in, and `xtctx + status` says whether it is on ([docs/cloud-sync.md](docs/cloud-sync.md)). diff --git a/README.md b/README.md index 746b27d..78da4ff 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,9 @@ xtctx is local cross-tool handoff for AI coding agents. It indexes the transcript files your local coding agents already write, and exposes them over MCP so the next tool you open can find recent sessions and read the raw messages. It does not run a daemon, host an API, generate -summaries, or maintain durable project memory. +summaries, or maintain durable project memory. Everything stays on your +machine unless you opt a project in to cloud sync, which is optional and off +by default ([`docs/cloud-sync.md`](docs/cloud-sync.md)). Each project opts in once with `xtctx setup`. The MCP server resolves the project from the working directory, and in a project that has not opted in it @@ -167,7 +169,8 @@ target drift, and tools that do not have a verified skill surface. It reports the current local index rather than forcing a transcript scan. If the index is empty, ask a configured agent to call `xtctx_recent_sessions`. When the index holds sessions whose transcripts are gone, it says how many and -points at `xtctx export`. +points at `xtctx export`. Its `Cloud` line says whether cloud sync is on for +the project, and when it last uploaded or failed. `xtctx disconnect ` stops xtctx from managing one tool for the project. It removes the xtctx MCP entry for that tool, removes managed instruction @@ -307,7 +310,14 @@ startup hooks; others receive MCP config plus managed instructions only. ## Limits - xtctx is local-only by default: it never uploads transcripts and runs no - telemetry. A project can opt into an external embedding endpoint by writing + telemetry. Cloud sync is optional and opt-in per project: it sends nothing + until you log in (`xtctx login`) *and* opt a project in (`xtctx sync enable`), + and then sends that project's transcript text, including whatever paths or + output the agents wrote into it, to the xtctx cloud server, where your other + machines' agents can read it over MCP. `xtctx status` says whether it is on + for the project and when it last uploaded + ([`docs/cloud-sync.md`](docs/cloud-sync.md)). + A project can opt into an external embedding endpoint by writing one into `.xtctx/config.yaml`, in which case window text is sent there to be vectorized — never inferred from an environment variable, and `xtctx status` names the endpoint in full whenever one is configured. diff --git a/cloud/README.md b/cloud/README.md new file mode 100644 index 0000000..d5207ec --- /dev/null +++ b/cloud/README.md @@ -0,0 +1,142 @@ +# xtctx-cloud + +The optional server behind [xtctx cloud sync](../docs/cloud-sync.md): a Cloudflare Worker that stores what opted-in projects upload (D1) and serves it back over MCP. Nothing reaches it unless a user logs in **and** opts a project in. + +## What it serves + +| Route | Who calls it | Auth | +|---|---|---| +| `POST /mcp` | MCP clients (Streamable HTTP; 2026-07-28 per-request, and 2025-11-25 / 2025-06-18 with `initialize`) | OAuth token with `read`, or one of this server's own tokens with `read` | +| `GET /sse`, `POST /message` | MCP clients on the deprecated 2024-11-05 SSE transport | this server's own token with `read` | +| `/.well-known/oauth-protected-resource/mcp` and `/.well-known/oauth-protected-resource` | MCP clients, discovery | none | +| `/.well-known/oauth-authorization-server`, `/oauth/register`, `/oauth/token`, `/authorize`, `/oauth/github/callback` | MCP clients signing in | see below | +| `POST /auth/device/code`, `POST /auth/device/poll` | `xtctx login` (GitHub device flow) | none | +| `POST /api/stream` | uploads from `xtctx` | CLI token, `sync:write` | +| `POST /api/tokens` | `xtctx sync token` | CLI token, `sync:write` | +| `POST /auth/logout`, `DELETE /api/me` | `xtctx logout [--delete-data]` | CLI token (`account:delete` for the delete) | +| `GET /health` | anyone | none; reports the version, also sent as `X-Xtctx-Server-Version` on every response | + +### Sign-in + +The Worker is an OAuth 2.1 authorization server for its own MCP resource, `${PUBLIC_URL}/mcp`, built on Cloudflare's [`@cloudflare/workers-oauth-provider`](https://github.com/cloudflare/workers-oauth-provider). GitHub is only the identity step. + +- **MCP clients** find it from the `401` on `/mcp` (whose `WWW-Authenticate` names the protected-resource metadata), register through a Client ID Metadata Document (MCP 2026-07-28) or dynamic registration (older clients), and send the user to `/authorize`. That page names the client and where the access goes, then hands off to GitHub's web flow; the callback checks the allowlist and finishes the grant. PKCE with S256 is required of every client. Tokens are bound to the `/mcp` resource, carry only `read`, last an hour, and refresh for 30 days. +- **The CLI** uses GitHub's device flow (`/auth/device/*`) and gets this server's own token (HS256, 30 days) with `read sync:write account:delete`, valid at `/mcp` and `/api/*`. +- **Pasted tokens** (`xtctx sync token`, `POST /api/tokens`) are this server's own tokens with `read` only, valid 90 days at `/mcp` and `/sse`, for clients that cannot sign in by themselves. + +Only GitHub accounts listed in `ALLOWED_GITHUB_IDS` can sign in, by either route. An empty list lets nobody in. Taking someone off the list ends their access at their next request; they can still log out and delete their data. + +`POST /auth/logout` and `DELETE /api/me` move the account's token epoch, which every token carries and every request checks, and revoke its OAuth grants. The epoch is kept when the account's data is deleted, so a token from before a deletion stays dead after the same GitHub account signs in again. + +### Uploads + +`POST /api/stream` takes `{ device, project, sessions: [{ ..., messages, keepIds? }] }` and writes it as **one D1 batch**: all of it or none. Messages are keyed by the client's own ids, so a replay rewrites the same rows; `message_count` is recounted from the rows; `keepIds`, when sent, deletes every other stored message of that session. Limits, each answered with a JSON error the client acts on: + +| Limit | Value | Over it | +|---|---|---| +| body | 1 MiB | `413 body_too_large` | +| one message's text | 64 KiB (UTF-8) | `413 message_too_large` naming the session and message; the client skips that message | +| one message's metadata | 4 KiB | the same | +| sessions per request | 10 | `400` | +| messages per request | 500 | `400` | +| the pre-0.2 upload format | | `426 client_outdated` | + +These keep a request inside D1's limits: messages go in as one JSON parameter per session, and a request makes at most 4 statements per session plus 2 in its batch, and 2 reads around it: 44 queries for 10 sessions, under the free plan's 50 per invocation. A failed write is logged as `{"event":"ingest_failed","userId",...,"bytes","sessions","messages","error"}`, never with content. + +## Deploying + +In this order. Nothing here is run by the tests or by CI. + +### 1. Install + +```bash +cd cloud +npm install +``` + +### 2. D1 database + +New deployment only: `npx wrangler d1 create xtctx-db`, then put the printed `database_id` in `wrangler.toml`. + +### 3. KV namespace for OAuth + +```bash +npx wrangler kv namespace create OAUTH_KV +``` + +Put the printed id in `wrangler.toml` under `[[kv_namespaces]]`, replacing `REPLACE_WITH_OAUTH_KV_NAMESPACE_ID`, which is not a namespace. + +### 4. Schema + +```bash +npm run d1:migrate:remote # wrangler d1 migrations apply xtctx-db --remote +``` + +Migrations live in `migrations/` and `wrangler` records which ran in the database's `d1_migrations` table. Apply them **before** deploying code that needs them. `0000_baseline.sql` is the schema as it was when tracking started; `0001_sync_v2.sql` adds the token epochs and the upload status table, and drops the columns that held absolute paths. + +#### A database created before tracked migrations + +A database made from the old `schema.sql` (with or without the old `0001_token_version.sql`) is brought onto the tracked path without losing data: + +1. Check that `users` has the `token_version` column: + ```bash + npx wrangler d1 execute xtctx-db --remote --command "SELECT name FROM pragma_table_info('users') WHERE name = 'token_version'" + ``` + If that returns no row, add it first (this was the old `0001_token_version.sql`): + ```bash + npx wrangler d1 execute xtctx-db --remote --command "ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0" + ``` +2. Run `npm run d1:migrate:remote`. It applies `0000_baseline.sql`, which is all `CREATE ... IF NOT EXISTS` and so changes nothing on this database, then `0001_sync_v2.sql`, which copies each user's `token_version` into `token_epochs` (nobody is signed out by the copy) and drops `users.token_version`, `sessions.status`, `sessions.source_path` and `messages.source_pointer`. + +Existing rows stay. Messages uploaded by clients before 0.2 have ids in the old scheme; the first sync from each device after it updates replaces them, because the session counts differ and the client then sends the session whole with its ids. + +### 5. GitHub OAuth app + +At [GitHub Developer Settings](https://github.com/settings/developers) → OAuth Apps, on the app whose client id is `GITHUB_CLIENT_ID` in `wrangler.toml`: + +1. Enable **Device Flow** (for `xtctx login`). +2. Set the **Authorization callback URL** to `https://mcp.xtctx.com/oauth/github/callback`, that is `PUBLIC_URL` + `/oauth/github/callback`. +3. Generate a client secret for the next step. + +### 6. Secrets and variables + +```bash +npx wrangler secret put JWT_SECRET # required; e.g. openssl rand -base64 48 +npx wrangler secret put GITHUB_CLIENT_SECRET # the OAuth app's secret, for browser sign-in +``` + +- Until `JWT_SECRET` is set every route except `/health` answers 500. Changing it later invalidates every CLI and pasted token. +- Without `GITHUB_CLIENT_SECRET` the CLI's device flow still works and `/authorize` answers 503. +- Set `ALLOWED_GITHUB_IDS` in `wrangler.toml` `[vars]` to the comma-separated numeric GitHub ids allowed to sign in (yours is `"id"` in `https://api.github.com/users/`). It ships empty, which lets nobody in. +- `PUBLIC_URL` (in `wrangler.toml`) is the origin MCP clients connect to; tokens are bound to `PUBLIC_URL/mcp`, so it must match the domain clients use. +- `ALLOWED_ORIGINS` (optional, comma-separated) is the only way a browser origin gets CORS headers, and the only foreign `Origin` `/mcp` accepts. + +### 7. Deploy + +```bash +npm run deploy +``` + +The Worker is served only on its custom domains (`workers_dev = false`): `mcp.xtctx.com` for MCP clients and `sync.xtctx.com` for the CLI. Logs and traces go to Workers Observability (`[observability]`). + +After a deploy, CLI users of a version before 0.2 get `426` on upload and must update xtctx, and tokens from before this version (they carry no scopes) are refused, so everyone runs `xtctx login` once. + +## Running it locally + +```bash +cd cloud +node node_modules/wrangler/bin/wrangler.js d1 migrations apply xtctx-db --local --persist-to +node node_modules/wrangler/bin/wrangler.js dev --local --port 8787 \ + --local-upstream localhost:8787 --upstream-protocol http --persist-to \ + --var PUBLIC_URL:http://localhost:8787 --var JWT_SECRET: \ + --var GITHUB_CLIENT_SECRET: --var ALLOWED_GITHUB_IDS: +``` + +- `--local-upstream` and `--upstream-protocol`: without them `wrangler dev` presents requests as `https://mcp.xtctx.com/...` (the first route), so the resource metadata and challenge do not match `http://localhost:8787` and MCP clients cannot discover the sign-in. +- On Windows keep `--persist-to` short (`C:\tmp\xw`, say); a long path runs past `MAX_PATH` and D1 commands fail. +- The browser sign-in needs a GitHub OAuth app whose callback is `http://localhost:8787/oauth/github/callback`; a GitHub OAuth app has one callback URL, so use a separate app for local work. +- Point the CLI at it with `xtctx login --sync-url http://localhost:8787` (plain `http` is accepted only for `localhost`). + +## Tests + +`npm test` runs the Worker in Node against an in-memory SQLite D1 (built from `migrations/`, in order) and an in-memory KV, with GitHub stubbed: uploads, revocation, tenant isolation, the MCP protocol for both eras, and the whole OAuth flow from registration to a token used at `/mcp`. `npm run typecheck` checks the Worker's sources. diff --git a/cloud/migrations/0000_baseline.sql b/cloud/migrations/0000_baseline.sql new file mode 100644 index 0000000..1ad988c --- /dev/null +++ b/cloud/migrations/0000_baseline.sql @@ -0,0 +1,85 @@ +-- Baseline: the schema as it stood when migrations started being tracked by +-- `wrangler d1 migrations apply` (schema.sql plus the old 0001_token_version). +-- +-- Every statement is IF NOT EXISTS, so applying this to a database created +-- before then changes nothing; see cloud/README.md ("A database created +-- before tracked migrations") for the one check to make first. + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, -- e.g. "github:123456" + username TEXT NOT NULL, -- e.g. "fstubner" + display_name TEXT, + email TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + token_version INTEGER NOT NULL DEFAULT 0 -- superseded by token_epochs in 0001 +); + +CREATE TABLE IF NOT EXISTS devices ( + id TEXT PRIMARY KEY, -- e.g. UUID or machine slug + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_name TEXT NOT NULL, -- e.g. "felix-desktop", "macbook-air" + hostname TEXT, + last_seen_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_devices_user ON devices(user_id); + +CREATE TABLE IF NOT EXISTS sessions ( + session_ref TEXT PRIMARY KEY, -- e.g. "usr_123:claude-code:abc123" + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_id TEXT NOT NULL REFERENCES devices(id), + tool TEXT NOT NULL, -- "claude-code", "antigravity", "cursor", "copilot", "codex" + source_session_id TEXT NOT NULL, + repo_url TEXT NOT NULL, -- e.g. "github.com/fstubner/xtctx" + project_root TEXT NOT NULL, -- local root path on the origin device + git_branch TEXT, + git_commit TEXT, + started_at TEXT NOT NULL, + last_activity_at TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, + preview TEXT, + source_path TEXT, + status TEXT NOT NULL DEFAULT 'active', -- 'active', 'idle', 'closed' + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_repo + ON sessions(user_id, repo_url, last_activity_at DESC); +CREATE INDEX IF NOT EXISTS idx_sessions_user_activity + ON sessions(user_id, last_activity_at DESC); + +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_ref TEXT NOT NULL REFERENCES sessions(session_ref) ON DELETE CASCADE, + tool TEXT NOT NULL, + source_session_id TEXT NOT NULL, + timestamp TEXT NOT NULL, + role TEXT NOT NULL, -- "user", "assistant", "system", "tool" + content TEXT NOT NULL, + message_index INTEGER NOT NULL, + content_hash TEXT NOT NULL, + metadata_json TEXT NOT NULL, -- JSON string with tool_calls, tokens, etc. + source_pointer TEXT, + indexed_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_messages_session_order + ON messages(session_ref, message_index ASC); + +CREATE TABLE IF NOT EXISTS retrieval_units ( + id TEXT PRIMARY KEY, + session_ref TEXT NOT NULL REFERENCES sessions(session_ref) ON DELETE CASCADE, + tool TEXT NOT NULL, + message_start_index INTEGER NOT NULL, + message_end_index INTEGER NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT NOT NULL, + content TEXT NOT NULL, + content_hash TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_retrieval_units_session + ON retrieval_units(session_ref, message_start_index, message_end_index); diff --git a/cloud/migrations/0001_sync_v2.sql b/cloud/migrations/0001_sync_v2.sql new file mode 100644 index 0000000..c5452fb --- /dev/null +++ b/cloud/migrations/0001_sync_v2.sql @@ -0,0 +1,40 @@ +-- Sync v2: revocation that survives account deletion, authoritative +-- per-session uploads, and no absolute paths held in the cloud. + +-- Revocation state. A token carries its user's epoch when it is minted and +-- is refused once the epoch has moved on: logout and delete-my-data both move +-- it. It lived on `users.token_version`, which delete-my-data removed along +-- with the row, so signing in again started the count over at 0 and every +-- token from before the deletion worked again. This table is never deleted +-- from. +CREATE TABLE IF NOT EXISTS token_epochs ( + user_id TEXT PRIMARY KEY, + epoch INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL +); +INSERT OR IGNORE INTO token_epochs (user_id, epoch, updated_at) + SELECT id, token_version, updated_at FROM users; +ALTER TABLE users DROP COLUMN token_version; + +-- Status was always the constant 'active'. source_path and source_pointer +-- held absolute paths from the uploading machine; nothing reads them. +ALTER TABLE sessions DROP COLUMN status; +ALTER TABLE sessions DROP COLUMN source_path; +ALTER TABLE messages DROP COLUMN source_pointer; + +-- Session detail reads in this order. +DROP INDEX IF EXISTS idx_messages_session_order; +CREATE INDEX IF NOT EXISTS idx_messages_session_time + ON messages(session_ref, timestamp, message_index, id); + +-- One row per user, device and project: when it last uploaded. Lets a reader +-- tell a project that has synced and has nothing from one that never synced. +CREATE TABLE IF NOT EXISTS uploads ( + user_id TEXT NOT NULL, + device_id TEXT NOT NULL, + repo_url TEXT NOT NULL, + project_name TEXT NOT NULL, + last_upload_at TEXT NOT NULL, + client_version TEXT, + PRIMARY KEY (user_id, device_id, repo_url) +); diff --git a/cloud/package-lock.json b/cloud/package-lock.json new file mode 100644 index 0000000..04a38ad --- /dev/null +++ b/cloud/package-lock.json @@ -0,0 +1,2809 @@ +{ + "name": "xtctx-cloud", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "xtctx-cloud", + "version": "0.2.0", + "dependencies": { + "@cloudflare/workers-oauth-provider": "1.2.1" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260930.2", + "@types/node": "^24.19.0", + "typescript": "^5.8.0", + "vitest": "^4.1.11", + "wrangler": "^4.145.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260930.2", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260930.2.tgz", + "integrity": "sha512-wxKsfekWeev6xkjHEMx3x7nz6WfWOn1/scQ8geqWQhUm8qRl1ZRZhjnj+rucIMLhLhP9RGSYMZIjEm3AV0zwVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260930.2", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260930.2.tgz", + "integrity": "sha512-cviPpheZGQ2H5X+Ctxd3wfxhksNrdyoNprjSgg3br2+psetKacjdEvK8CILdN/2GF+u1KOExvr45amSL6lgzGA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260930.2", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260930.2.tgz", + "integrity": "sha512-cwrJAC5y/avPd1OzGyhc5OvUUfPXBFc065TY5CjXp7Hb3dhiTx6JeB5Pc8L3G8L8M44HyAfRHSpoLtRr+SR3cQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260930.2", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260930.2.tgz", + "integrity": "sha512-FHucB4gak1IuT+IxLx3KbtMBMapZhcqD9EdhHvBxk1FpHwD2QU5x26MRRVN53jIKG/O25HUmE9yffb3cMANQgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260930.2", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260930.2.tgz", + "integrity": "sha512-JHFMdGFMqP+M2QiVzZq1S8Ie66Gvxo7Og5EYz/KtmDvGCqge5L3lK+ksZxAhF3j/tpSLqdvlUvcTKoR9PkwTTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-oauth-provider": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-oauth-provider/-/workers-oauth-provider-1.2.1.tgz", + "integrity": "sha512-5bw7JJI9Nd4ArHfNnvTF8H2OpSbA2GbqTmX80ly47h7dR+atgjFsn9Wfz+biKlAKClTEUYE0KAr791RkED41VA==", + "license": "MIT" + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20260930.2", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260930.2.tgz", + "integrity": "sha512-zOJM3vdCVB7iszFDvXvusORu1Ssre6+6x1t+opmbJ4ahi4keylnK/hB6/dKuRPLG7eqS/DO7W4IMjvas6pCvJg==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/miniflare": { + "version": "5.20260930.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260930.0-alpha.tgz", + "integrity": "sha512-vZljfRtOEeynbhJtuvYsnCU8t6zF5wIUm9ql4YFHB7zvYw2+F2tpT0BzXjzaa++eiUIml1gsODCf02qShBmJdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.4", + "undici": "7.29.1", + "workerd": "1.20260930.2", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/workerd": { + "version": "1.20260930.2", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260930.2.tgz", + "integrity": "sha512-k1lQzEiGyCDqaRsR5+o9PBT5wVU5omtas6hXx3spbEHHMFo8a7tkAixVCMsWFeiT7xhrmg21Hd6a9jLNrkcUMg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260930.2", + "@cloudflare/workerd-darwin-arm64": "1.20260930.2", + "@cloudflare/workerd-linux-64": "1.20260930.2", + "@cloudflare/workerd-linux-arm64": "1.20260930.2", + "@cloudflare/workerd-windows-64": "1.20260930.2" + } + }, + "node_modules/wrangler": { + "version": "4.145.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.145.0.tgz", + "integrity": "sha512-TdO9l7RNqM2Kb5/AynhlRPF9WPhaI0W4Gcpq2FkyU1iZ+xPW25sfV8Yc48PsI1GsNEFOfdh2RTOIpW8U22AqHw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260930.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260930.2" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260930.2" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, + "node_modules/youch/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + } + } +} diff --git a/cloud/package.json b/cloud/package.json new file mode 100644 index 0000000..3c9193b --- /dev/null +++ b/cloud/package.json @@ -0,0 +1,26 @@ +{ + "name": "xtctx-cloud", + "version": "0.2.0", + "description": "Optional xtctx cloud: an MCP server over transcripts uploaded from opted-in projects", + "type": "module", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "d1:create": "wrangler d1 create xtctx-db", + "d1:migrate:local": "wrangler d1 migrations apply xtctx-db --local", + "d1:migrate:remote": "wrangler d1 migrations apply xtctx-db --remote", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260930.2", + "@types/node": "^24.19.0", + "typescript": "^5.8.0", + "vitest": "^4.1.11", + "wrangler": "^4.145.0" + }, + "dependencies": { + "@cloudflare/workers-oauth-provider": "1.2.1" + } +} diff --git a/cloud/src/app.ts b/cloud/src/app.ts new file mode 100644 index 0000000..edd4369 --- /dev/null +++ b/cloud/src/app.ts @@ -0,0 +1,276 @@ +import type { Env, AuthUser } from "./types.js"; +import { SCOPE_ACCOUNT_DELETE, SCOPE_READ, SCOPE_SYNC_WRITE } from "./types.js"; +import { + CLI_SCOPES, + CLI_TOKEN_TTL_SECONDS, + READ_TOKEN_TTL_SECONDS, + apiAudience, + authenticateToken, + bearerChallenge, + isAllowedUser, + mcpResource, + mintToken, + type VerifiedToken, +} from "./auth.js"; +import { LIMITS, bumpTokenEpoch, deleteUserData, ingestUpload, parseUpload, upsertUser } from "./db.js"; +import { processMessage } from "./mcp.js"; +import { GITHUB_CALLBACK_PATH, githubUser, handleAuthorize, handleGithubCallback, revokeOAuthGrants } from "./oauth.js"; + +const SESSION_ID = /^[0-9a-f-]{36}$/; +/** An MCP request is one small JSON-RPC message. */ +export const MAX_MCP_BODY_BYTES = 256 * 1024; + +/** + * CORS is for browsers, and nothing here is called from one: the CLI and MCP + * clients are not subject to it. So the default is no CORS headers at all, + * and an origin gets them only by being listed in ALLOWED_ORIGINS. + */ +export function allowedOrigins(env: Env): string[] { + return (env.ALLOWED_ORIGINS ?? "").split(",").map((o) => o.trim()).filter(Boolean); +} + +function corsHeaders(request: Request, env: Env): Record { + const origin = request.headers.get("Origin"); + const headers: Record = { Vary: "Origin" }; + if (origin && allowedOrigins(env).includes(origin)) { + headers["Access-Control-Allow-Origin"] = origin; + headers["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS"; + headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, MCP-Protocol-Version, Mcp-Method, Mcp-Name"; + } + return headers; +} + +/** + * Read a body without holding more than `max` bytes of it. Null when it is + * larger, whether or not Content-Length said so. + */ +export async function readBodyCapped(request: Request, max: number): Promise { + const declared = Number(request.headers.get("Content-Length") ?? "NaN"); + if (Number.isFinite(declared) && declared > max) return null; + if (!request.body) return ""; + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > max) { + await reader.cancel().catch(() => undefined); + return null; + } + chunks.push(value); + } + const all = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + all.set(c, offset); + offset += c.byteLength; + } + return new TextDecoder().decode(all); +} + +async function readJson(request: Request, max: number): Promise<{ ok: true; value: unknown } | { ok: false; status: 400 | 413 }> { + const text = await readBodyCapped(request, max); + if (text === null) return { ok: false, status: 413 }; + try { + return { ok: true, value: JSON.parse(text) }; + } catch { + return { ok: false, status: 400 }; + } +} + +/** Everything that is not `/mcp` or one of the provider's own OAuth endpoints. */ +export async function handleApp(request: Request, env: Env): Promise { + const cors = corsHeaders(request, env); + const json = (body: unknown, status = 200, extra: Record = {}) => + new Response(JSON.stringify(body), { status, headers: { ...cors, "Content-Type": "application/json", ...extra } }); + const url = new URL(request.url); + + if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors }); + + if (url.pathname === "/authorize") return handleAuthorize(request, env); + if (url.pathname === GITHUB_CALLBACK_PATH && request.method === "GET") return handleGithubCallback(request, env); + + if (url.pathname === "/auth/device/code" && request.method === "POST") { + const ghRes = await fetch("https://github.com/login/device/code", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ client_id: env.GITHUB_CLIENT_ID, scope: "read:user" }), + }).catch(() => null); + if (!ghRes) return json({ error: "github_unavailable" }, 502); + return json(await ghRes.json().catch(() => ({ error: "github_unavailable" })), ghRes.ok ? 200 : 502); + } + + if (url.pathname === "/auth/device/poll" && request.method === "POST") { + const body = await readJson(request, 4096); + if (!body.ok) return json({ error: "invalid_request" }, 400); + const deviceCode = (body.value as { device_code?: unknown } | null)?.device_code; + if (typeof deviceCode !== "string" || !deviceCode) return json({ error: "invalid_request" }, 400); + + const ghRes = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: env.GITHUB_CLIENT_ID, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }).catch(() => null); + if (!ghRes) return json({ error: "github_unavailable" }, 502); + const data = (await ghRes.json().catch(() => ({}))) as { access_token?: string; error?: string; interval?: number }; + // authorization_pending, slow_down, expired_token, access_denied: the + // client acts on these, so they pass through as GitHub named them. + if (!data.access_token) { + return json({ error: data.error ?? "github_error", ...(data.interval ? { interval: data.interval } : {}) }, 400); + } + + const ghUser = await githubUser(data.access_token).catch(() => null); + if (!ghUser) return json({ error: "github_user_unavailable" }, 502); + + const userId = `github:${ghUser.id}`; + // Before anything is written for this account: ALLOWED_GITHUB_IDS, and + // nobody when it is empty. + if (!isAllowedUser(env, userId)) { + return json({ error: "not_allowed", detail: "This GitHub account is not on this server's allowlist." }, 403); + } + + const epoch = await upsertUser(env, userId, ghUser.login); + const token = await mintToken( + env, + { userId, username: ghUser.login, epoch, scopes: CLI_SCOPES }, + CLI_TOKEN_TTL_SECONDS, + ); + return json({ token, scope: CLI_SCOPES.join(" "), user: { id: userId, username: ghUser.login } }); + } + + // From here every route needs a token. + const requireToken = async ( + audience: string, + scope: string, + options: { mcp?: boolean; ignoreAllowlist?: boolean } = {}, + ): Promise => { + const verified = await authenticateToken(request, env, audience, options); + if (!verified) { + return json({ error: "unauthorized" }, 401, { + "WWW-Authenticate": bearerChallenge(env, { mcp: options.mcp, error: "invalid_token" }), + }); + } + if (!verified.scopes.includes(scope)) { + return json({ error: "insufficient_scope", scope }, 403, { + "WWW-Authenticate": bearerChallenge(env, { mcp: options.mcp, error: "insufficient_scope", scope: [scope] }), + }); + } + return verified; + }; + const asUser = (v: VerifiedToken): AuthUser => ({ userId: v.userId, username: v.username, deviceId: v.deviceId }); + + // Sign out everywhere: every token carries the user's epoch, and this moves it. + if (url.pathname === "/auth/logout" && request.method === "POST") { + const auth = await requireToken(apiAudience(env), SCOPE_READ, { ignoreAllowlist: true }); + if (auth instanceof Response) return auth; + await bumpTokenEpoch(env, auth.userId); + await revokeOAuthGrants(env, auth.userId); + return json({ success: true }); + } + + // Delete everything held for this user and end every token they hold. + if (url.pathname === "/api/me" && request.method === "DELETE") { + const auth = await requireToken(apiAudience(env), SCOPE_ACCOUNT_DELETE, { ignoreAllowlist: true }); + if (auth instanceof Response) return auth; + await deleteUserData(env, auth.userId); + await revokeOAuthGrants(env, auth.userId); + return json({ success: true }); + } + + // A read-only token to paste into an MCP client that cannot do the OAuth sign-in. + if (url.pathname === "/api/tokens" && request.method === "POST") { + const auth = await requireToken(apiAudience(env), SCOPE_SYNC_WRITE); + if (auth instanceof Response) return auth; + const token = await mintToken( + env, + { userId: auth.userId, username: auth.username, epoch: auth.epoch, scopes: [SCOPE_READ] }, + READ_TOKEN_TTL_SECONDS, + ); + return json({ + token, + scope: SCOPE_READ, + mcp_url: mcpResource(env), + expires_at: new Date(Date.now() + READ_TOKEN_TTL_SECONDS * 1000).toISOString(), + }); + } + + if (url.pathname === "/api/stream" && request.method === "POST") { + const auth = await requireToken(apiAudience(env), SCOPE_SYNC_WRITE); + if (auth instanceof Response) return auth; + const text = await readBodyCapped(request, LIMITS.maxBodyBytes); + if (text === null) return json({ error: "body_too_large", limit: LIMITS.maxBodyBytes }, 413); + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + return json({ error: "invalid_request", detail: "body is not JSON" }, 400); + } + const parsed = parseUpload(raw); + if (!parsed.ok) return json(parsed.error, parsed.status); + const messages = parsed.body.sessions.reduce((n, s) => n + s.messages.length, 0); + try { + return json(await ingestUpload(env, asUser(auth), parsed.body, request.headers.get("X-Xtctx-Client"))); + } catch (err) { + // Sizes and counts, never content. + console.error( + JSON.stringify({ + event: "ingest_failed", + userId: auth.userId, + bytes: text.length, + sessions: parsed.body.sessions.length, + messages, + error: err instanceof Error ? err.message : String(err), + }), + ); + return json({ error: "internal_error" }, 500); + } + } + + // Baseline MCP transport (SSE stream, 2024-11-05). The stream is held in a + // Durable Object so /message reaches it from any isolate. Takes this + // server's own tokens (the CLI's, or a pasted read token): OAuth clients use + // /mcp. + if (url.pathname === "/sse" && request.method === "GET") { + const auth = await requireToken(mcpResource(env), SCOPE_READ, { mcp: true }); + if (auth instanceof Response) return auth; + const sessionId = crypto.randomUUID(); + const stub = env.SSE.get(env.SSE.idFromName(sessionId)); + const stream = await stub.fetch("https://sse/connect", { + method: "POST", + body: JSON.stringify({ userId: auth.userId, endpoint: `${url.origin}/message?sessionId=${sessionId}` }), + }); + return new Response(stream.body, { + headers: { ...cors, "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" }, + }); + } + + if (url.pathname === "/message" && request.method === "POST") { + const auth = await requireToken(mcpResource(env), SCOPE_READ, { mcp: true }); + if (auth instanceof Response) return auth; + const sessionId = url.searchParams.get("sessionId") ?? ""; + if (!SESSION_ID.test(sessionId)) return json({ error: "session_not_found" }, 404); + + const body = await readJson(request, MAX_MCP_BODY_BYTES); + if (!body.ok) { + return json({ jsonrpc: "2.0", id: null, error: { code: body.status === 413 ? -32600 : -32700, message: "Parse error" } }, body.status); + } + const outcome = await processMessage(env, asUser(auth), body.value); + if (outcome.body) { + const stub = env.SSE.get(env.SSE.idFromName(sessionId)); + const sent = await stub.fetch("https://sse/send", { + method: "POST", + body: JSON.stringify({ userId: auth.userId, payload: outcome.body }), + }); + if (!sent.ok) return json({ error: "session_not_found" }, 404); + } + return json({ status: "accepted" }, 202); + } + + return json({ error: "not_found" }, 404); +} diff --git a/cloud/src/auth.ts b/cloud/src/auth.ts new file mode 100644 index 0000000..0db1372 --- /dev/null +++ b/cloud/src/auth.ts @@ -0,0 +1,214 @@ +import type { Env, AuthUser } from "./types.js"; +import { SCOPE_ACCOUNT_DELETE, SCOPE_READ, SCOPE_SYNC_WRITE } from "./types.js"; +import { getAccountState } from "./db.js"; + +/** A CLI login lasts a month. Revocation (below) is what ends one sooner. */ +export const CLI_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30; +/** A read-only token for pasting into an MCP client config. */ +export const READ_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 90; + +/** What `xtctx login` gets: everything the CLI does. */ +export const CLI_SCOPES = [SCOPE_READ, SCOPE_SYNC_WRITE, SCOPE_ACCOUNT_DELETE]; + +const encoder = new TextEncoder(); + +function toBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +} + +function fromBase64Url(text: string): Uint8Array { + let base64 = text.replace(/-/g, "+").replace(/_/g, "/"); + while (base64.length % 4) base64 += "="; + const binary = atob(base64); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)); +} + +function hmacKey(secret: string, usage: "sign" | "verify"): Promise { + return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [usage]); +} + +/** The canonical MCP resource: what OAuth tokens and pasted tokens are bound to. */ +export function mcpResource(env: Env): string { + return `${publicOrigin(env)}/mcp`; +} + +/** The audience of tokens accepted by `/api/*`; only the CLI's own tokens carry it. */ +export function apiAudience(env: Env): string { + return `${publicOrigin(env)}/api`; +} + +export function publicOrigin(env: Env): string { + return new URL(env.PUBLIC_URL).origin; +} + +/** + * Mint an HMAC-SHA256 JWT using standard Web Crypto API. + */ +export async function createJwt( + payload: Record, + secret: string, + ttlSeconds = CLI_TOKEN_TTL_SECONDS, +): Promise { + const now = Math.floor(Date.now() / 1000); + const header = toBase64Url(encoder.encode(JSON.stringify({ alg: "HS256", typ: "JWT" }))); + const body = toBase64Url(encoder.encode(JSON.stringify({ ...payload, exp: now + ttlSeconds, iat: now }))); + const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret, "sign"), encoder.encode(`${header}.${body}`)); + return `${header}.${body}.${toBase64Url(new Uint8Array(signature))}`; +} + +/** Mint a token for the CLI or for pasting, bound to this deployment's audiences. */ +export async function mintToken( + env: Env, + claims: { userId: string; username: string; deviceId?: string; epoch: number; scopes: string[] }, + ttlSeconds: number, +): Promise { + const audiences = [mcpResource(env)]; + if (claims.scopes.includes(SCOPE_SYNC_WRITE) || claims.scopes.includes(SCOPE_ACCOUNT_DELETE)) { + audiences.push(apiAudience(env)); + } + return createJwt( + { + iss: publicOrigin(env), + aud: audiences, + sub: claims.userId, + username: claims.username, + device_id: claims.deviceId, + ver: claims.epoch, + scope: claims.scopes.join(" "), + }, + env.JWT_SECRET!, + ttlSeconds, + ); +} + +export interface VerifiedToken extends AuthUser { + epoch: number; + scopes: string[]; + audiences: string[]; +} + +/** + * Check a token's signature and expiry. Says nothing about revocation; that + * needs the database, see `authenticateToken`. + */ +export async function verifyJwt(token: string, secret: string): Promise { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const [header, body, signature] = parts; + + if (JSON.parse(new TextDecoder().decode(fromBase64Url(header))).alg !== "HS256") return null; + + const valid = await crypto.subtle.verify( + "HMAC", + await hmacKey(secret, "verify"), + fromBase64Url(signature), + encoder.encode(`${header}.${body}`), + ); + if (!valid) return null; + + const payload = JSON.parse(new TextDecoder().decode(fromBase64Url(body))); + if (typeof payload.sub !== "string" || typeof payload.exp !== "number") return null; + if (payload.exp <= Math.floor(Date.now() / 1000)) return null; + // Tokens from before scopes existed carry neither of these and are + // refused: their holders sign in again. + if (typeof payload.scope !== "string" || !Array.isArray(payload.aud)) return null; + if (typeof payload.ver !== "number") return null; + + return { + userId: payload.sub, + username: String(payload.username ?? ""), + deviceId: typeof payload.device_id === "string" ? payload.device_id : undefined, + epoch: payload.ver, + scopes: payload.scope.split(" ").filter(Boolean), + audiences: payload.aud.filter((a: unknown): a is string => typeof a === "string"), + }; + } catch { + return null; + } +} + +/** The GitHub ids allowed to sign in. Empty means nobody. */ +export function allowedGithubIds(env: Env): Set { + return new Set( + (env.ALLOWED_GITHUB_IDS ?? "") + .split(",") + .map((id) => id.trim()) + .filter((id) => /^\d+$/.test(id)), + ); +} + +export function isAllowedUser(env: Env, userId: string): boolean { + const match = /^github:(\d+)$/.exec(userId); + return match !== null && allowedGithubIds(env).has(match[1]); +} + +/** + * Whether an account may still use a token minted at `epoch`: the account + * exists, the epoch has not moved since (logout and delete-my-data move it), + * and, unless `ignoreAllowlist`, its GitHub id is still on the allowlist. + * Removing someone from ALLOWED_GITHUB_IDS ends their access at once; they + * keep the ability to sign out and delete their data. + */ +export async function isAccountCurrent( + env: Env, + userId: string, + epoch: number, + options: { ignoreAllowlist?: boolean } = {}, +): Promise { + if (!options.ignoreAllowlist && !isAllowedUser(env, userId)) return false; + const state = await getAccountState(env, userId); + return state.exists && state.epoch === epoch; +} + +/** + * Authenticate one of this server's own JWTs (the CLI's, or a pasted read + * token) from the Authorization header, and only from there: a token in the + * URL ends up in access logs and browser history. + * + * The token must name `audience`, and its account must be current; see + * `isAccountCurrent`. Scope checks are the caller's. + */ +export async function authenticateToken( + request: Request, + env: Env, + audience: string, + options: { ignoreAllowlist?: boolean } = {}, +): Promise { + if (!env.JWT_SECRET) return null; + const header = request.headers.get("Authorization"); + const match = header ? /^Bearer[\t ]+(\S+)$/i.exec(header) : null; + if (!match) return null; + return verifyBearer(match[1], env, audience, options); +} + +export async function verifyBearer( + token: string, + env: Env, + audience: string, + options: { ignoreAllowlist?: boolean } = {}, +): Promise { + if (!env.JWT_SECRET) return null; + const verified = await verifyJwt(token, env.JWT_SECRET); + if (!verified || !verified.audiences.includes(audience)) return null; + if (!(await isAccountCurrent(env, verified.userId, verified.epoch, options))) return null; + return verified; +} + +/** + * RFC 6750 challenge for the routes this file guards. The MCP ones (`/sse`, + * `/message`) point at the protected-resource metadata so a client can find + * the authorization server; `/api/*` has no OAuth resource of its own. + */ +export function bearerChallenge( + env: Env, + options: { mcp?: boolean; error?: "invalid_token" | "insufficient_scope"; scope?: string[] } = {}, +): string { + const parts = [`Bearer realm="xtctx-cloud"`]; + if (options.mcp) parts.push(`resource_metadata="${publicOrigin(env)}/.well-known/oauth-protected-resource/mcp"`); + if (options.error) parts.push(`error="${options.error}"`); + if (options.scope?.length) parts.push(`scope="${options.scope.join(" ")}"`); + return parts.join(", "); +} diff --git a/cloud/src/db.ts b/cloud/src/db.ts new file mode 100644 index 0000000..1b68acc --- /dev/null +++ b/cloud/src/db.ts @@ -0,0 +1,456 @@ +import type { Env, AuthUser, SessionRecord, MessageRecord } from "./types.js"; + +/** + * Limits on what one upload may carry. + * + * Every session in a request is written in ONE D1 batch, so a request is + * all-or-nothing. The numbers keep that batch inside D1's limits: messages go + * in as one JSON parameter per session (a bound value may be up to 2 MB, and + * the body cap keeps every one well under), and the statement count stays + * under the 50 queries a Worker invocation may make on the free plan + * (at most 4 per session + 2 in the batch, and 2 reads around it; see + * `ingestUpload`). + */ +export const LIMITS = { + /** Whole request body, bytes. */ + maxBodyBytes: 1024 * 1024, + /** One message's `content`, UTF-8 bytes. The client truncates to fit with a marker. */ + maxMessageBytes: 64 * 1024, + /** One message's `metadataJson`, bytes. */ + maxMetadataBytes: 4 * 1024, + maxSessionsPerRequest: 10, + maxMessagesPerRequest: 500, +} as const; + +const ROLES = new Set(["user", "assistant", "system", "tool"]); +const TOOL_ID = /^[a-z0-9][a-z0-9-]{0,39}$/; +const LOCAL_ID = /^[0-9a-f]{16,64}$/; + +export interface UploadMessage { + /** The uploading index's own message id; the cloud id is `${sessionRef}:${id}`. */ + id: string; + timestamp: string; + role: "user" | "assistant" | "system" | "tool"; + content: string; + messageIndex: number; + contentHash: string; + metadataJson: string; +} + +export interface UploadSession { + tool: string; + sourceSessionId: string; + gitBranch: string | null; + gitCommit: string | null; + startedAt: string; + lastActivityAt: string; + preview: string | null; + messages: UploadMessage[]; + /** + * The complete set of this session's message ids, when the client sends it. + * Every stored message of the session not in it is deleted, in the same + * batch, so the cloud copy ends up exactly the local one. + */ + keepIds?: string[]; +} + +export interface UploadBody { + device: { id: string; name: string }; + project: { repoUrl: string; name: string }; + sessions: UploadSession[]; +} + +export type ParseResult = + | { ok: true; body: UploadBody } + | { ok: false; status: 400 | 413 | 426; error: Record }; + +const byteLength = (text: string) => new TextEncoder().encode(text).byteLength; + +/** + * Validate an upload body. Anything malformed is a 400 that never reaches a + * query; anything over a limit is a 413 that names the message, so the client + * can skip exactly that message and carry on. + */ +export function parseUpload(input: unknown): ParseResult { + const bad = (detail: string): ParseResult => ({ ok: false, status: 400, error: { error: "invalid_request", detail } }); + // The body of every client before this version: a flat list of turns. + if (Array.isArray(input)) { + return { ok: false, status: 426, error: { error: "client_outdated", detail: "Update xtctx: this server takes the v2 upload format." } }; + } + if (!isObject(input)) return bad("body must be an object"); + + const text = (v: unknown, max = 512): v is string => typeof v === "string" && v.length > 0 && v.length <= max; + const optionalText = (v: unknown, max = 512) => v === undefined || v === null || text(v, max); + + const { device, project, sessions } = input; + if (!isObject(device) || !text(device.id, 128) || !text(device.name, 128)) return bad("device"); + if (!isObject(project) || !text(project.repoUrl, 512) || !text(project.name, 256)) return bad("project"); + if (!Array.isArray(sessions) || sessions.length > LIMITS.maxSessionsPerRequest) return bad("sessions"); + + let messageTotal = 0; + const out: UploadSession[] = []; + for (const s of sessions) { + if (!isObject(s)) return bad("session"); + if (!text(s.tool, 40) || !TOOL_ID.test(s.tool) || !text(s.sourceSessionId, 256)) return bad("session id"); + if (!text(s.startedAt, 64) || !text(s.lastActivityAt, 64)) return bad("session times"); + if (!optionalText(s.gitBranch) || !optionalText(s.gitCommit) || !optionalText(s.preview, 4096)) return bad("session fields"); + if (!Array.isArray(s.messages)) return bad("messages"); + messageTotal += s.messages.length; + if (messageTotal > LIMITS.maxMessagesPerRequest) return bad("too many messages in one request"); + + const messages: UploadMessage[] = []; + for (const m of s.messages) { + if (!isObject(m)) return bad("message"); + if (!text(m.id, 64) || !LOCAL_ID.test(m.id)) return bad("message id"); + if (!text(m.timestamp, 64) || !ROLES.has(m.role as string) || typeof m.content !== "string") return bad("message fields"); + if (!Number.isInteger(m.messageIndex) || !text(m.contentHash, 128)) return bad("message fields"); + const metadataJson = m.metadataJson === undefined ? "{}" : m.metadataJson; + if (typeof metadataJson !== "string") return bad("metadataJson"); + if (byteLength(m.content) > LIMITS.maxMessageBytes || byteLength(metadataJson) > LIMITS.maxMetadataBytes) { + return { + ok: false, + status: 413, + error: { + error: "message_too_large", + limit: LIMITS.maxMessageBytes, + tool: s.tool, + sourceSessionId: s.sourceSessionId, + messageId: m.id, + }, + }; + } + messages.push({ + id: m.id, + timestamp: m.timestamp as string, + role: m.role as UploadMessage["role"], + content: m.content, + messageIndex: m.messageIndex as number, + contentHash: m.contentHash as string, + metadataJson, + }); + } + + let keepIds: string[] | undefined; + if (s.keepIds !== undefined) { + if (!Array.isArray(s.keepIds) || !s.keepIds.every((id) => typeof id === "string" && LOCAL_ID.test(id))) { + return bad("keepIds"); + } + keepIds = s.keepIds as string[]; + } + + out.push({ + tool: s.tool, + sourceSessionId: s.sourceSessionId, + gitBranch: (s.gitBranch as string | undefined) ?? null, + gitCommit: (s.gitCommit as string | undefined) ?? null, + startedAt: s.startedAt, + lastActivityAt: s.lastActivityAt, + preview: typeof s.preview === "string" ? s.preview.slice(0, 160) : null, + messages, + keepIds, + }); + } + + return { + ok: true, + body: { + device: { id: device.id as string, name: device.name as string }, + project: { repoUrl: project.repoUrl as string, name: project.name as string }, + sessions: out, + }, + }; +} + +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +export function sessionRefFor(userId: string, tool: string, sourceSessionId: string): string { + return `${userId}:${tool}:${sourceSessionId}`; +} + +export interface IngestResult { + success: true; + sessions: Array<{ tool: string; sourceSessionId: string; messageCount: number }>; +} + +/** + * Write one upload as a single D1 batch: every session in it, or none. + * + * Idempotent: messages are keyed by the client's own message id, so a replay + * rewrites the same rows, and `message_count` is recounted from the rows + * rather than incremented. A session's `keepIds`, when sent, deletes every + * stored message not in it, which is how a local re-read that removed or + * replaced messages reaches the cloud. + */ +export async function ingestUpload( + env: Env, + user: AuthUser, + body: UploadBody, + clientVersion: string | null, +): Promise { + const nowIso = new Date().toISOString(); + const nowMs = Date.now(); + // Device ids come from the client and are the table's primary key, so two + // users picking the same one would otherwise share a row. Scoped to the user. + const deviceId = `${user.userId}:${body.device.id}`; + const statements: D1PreparedStatement[] = []; + + statements.push( + env.DB.prepare( + `INSERT INTO devices (id, user_id, device_name, last_seen_at, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET device_name = excluded.device_name, last_seen_at = excluded.last_seen_at + WHERE devices.user_id = excluded.user_id`, + ).bind(deviceId, user.userId, body.device.name, nowMs, nowMs), + ); + + const refs: string[] = []; + for (const s of body.sessions) { + const sessionRef = sessionRefFor(user.userId, s.tool, s.sourceSessionId); + refs.push(sessionRef); + + // Earliest start, latest activity, newest known branch: a replay or an + // out-of-order upload can only move these forward. + statements.push( + env.DB.prepare( + `INSERT INTO sessions ( + session_ref, user_id, device_id, tool, source_session_id, repo_url, project_root, + git_branch, git_commit, started_at, last_activity_at, message_count, preview, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + ON CONFLICT(session_ref) DO UPDATE SET + device_id = excluded.device_id, + repo_url = excluded.repo_url, + project_root = excluded.project_root, + git_branch = COALESCE(excluded.git_branch, sessions.git_branch), + git_commit = COALESCE(excluded.git_commit, sessions.git_commit), + started_at = MIN(sessions.started_at, excluded.started_at), + last_activity_at = MAX(sessions.last_activity_at, excluded.last_activity_at), + preview = COALESCE(excluded.preview, sessions.preview), + updated_at = excluded.updated_at + WHERE sessions.user_id = excluded.user_id`, + ).bind( + sessionRef, + user.userId, + deviceId, + s.tool, + s.sourceSessionId, + body.project.repoUrl, + body.project.name, + s.gitBranch, + s.gitCommit, + s.startedAt, + s.lastActivityAt, + s.preview, + nowIso, + ), + ); + + if (s.messages.length > 0) { + // One statement for the whole session, however many messages: they + // travel as a single JSON parameter. + statements.push( + env.DB.prepare( + `INSERT INTO messages ( + id, session_ref, tool, source_session_id, timestamp, role, content, + message_index, content_hash, metadata_json, indexed_at + ) + SELECT ? || ':' || json_extract(value, '$.id'), ?, ?, ?, + json_extract(value, '$.timestamp'), json_extract(value, '$.role'), + json_extract(value, '$.content'), json_extract(value, '$.messageIndex'), + json_extract(value, '$.contentHash'), json_extract(value, '$.metadataJson'), ? + FROM json_each(?) WHERE true + ON CONFLICT(id) DO UPDATE SET + timestamp = excluded.timestamp, + role = excluded.role, + content = excluded.content, + message_index = excluded.message_index, + content_hash = excluded.content_hash, + metadata_json = excluded.metadata_json`, + ).bind(sessionRef, sessionRef, s.tool, s.sourceSessionId, nowIso, JSON.stringify(s.messages)), + ); + } + + if (s.keepIds) { + statements.push( + env.DB.prepare( + `DELETE FROM messages + WHERE session_ref = ? + AND id NOT IN (SELECT ? || ':' || value FROM json_each(?))`, + ).bind(sessionRef, sessionRef, JSON.stringify(s.keepIds)), + ); + } + + statements.push( + env.DB.prepare( + `UPDATE sessions + SET message_count = (SELECT COUNT(*) FROM messages WHERE messages.session_ref = sessions.session_ref) + WHERE session_ref = ? AND user_id = ?`, + ).bind(sessionRef, user.userId), + ); + } + + statements.push( + env.DB.prepare( + `INSERT INTO uploads (user_id, device_id, repo_url, project_name, last_upload_at, client_version) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, device_id, repo_url) DO UPDATE SET + project_name = excluded.project_name, + last_upload_at = excluded.last_upload_at, + client_version = excluded.client_version`, + ).bind(user.userId, deviceId, body.project.repoUrl, body.project.name, nowIso, clientVersion), + ); + + await env.DB.batch(statements); + + if (refs.length === 0) return { success: true, sessions: [] }; + const counted = await env.DB.prepare( + `SELECT tool, source_session_id, message_count FROM sessions + WHERE user_id = ? AND session_ref IN (SELECT value FROM json_each(?))`, + ) + .bind(user.userId, JSON.stringify(refs)) + .all<{ tool: string; source_session_id: string; message_count: number }>(); + return { + success: true, + sessions: (counted.results ?? []).map((r) => ({ + tool: r.tool, + sourceSessionId: r.source_session_id, + messageCount: r.message_count, + })), + }; +} + +/** Sessions for a user, newest first, with the name of the device that uploaded each. */ +export async function getRecentSessions( + env: Env, + userId: string, + options: { repoUrl?: string; branchFilter?: string[]; toolFilter?: string[]; limit: number }, +): Promise { + let query = `SELECT s.*, d.device_name FROM sessions s LEFT JOIN devices d ON d.id = s.device_id WHERE s.user_id = ?`; + const bindings: unknown[] = [userId]; + + if (options.repoUrl) { + query += ` AND s.repo_url = ?`; + bindings.push(options.repoUrl); + } + if (options.toolFilter && options.toolFilter.length > 0) { + query += ` AND s.tool IN (${options.toolFilter.map(() => "?").join(", ")})`; + bindings.push(...options.toolFilter); + } + if (options.branchFilter && options.branchFilter.length > 0) { + query += ` AND s.git_branch IN (${options.branchFilter.map(() => "?").join(", ")})`; + bindings.push(...options.branchFilter); + } + query += ` ORDER BY s.last_activity_at DESC, s.session_ref ASC LIMIT ?`; + bindings.push(options.limit); + + const result = await env.DB.prepare(query).bind(...bindings).all(); + return result.results ?? []; +} + +/** One session and a page of its messages, in conversation order. Null when it is not this user's. */ +export async function getSessionMessages( + env: Env, + userId: string, + sessionRef: string, + options: { offset: number; limit: number }, +): Promise<{ session: SessionRecord | null; messages: MessageRecord[] }> { + const session = await env.DB.prepare( + `SELECT s.*, d.device_name FROM sessions s LEFT JOIN devices d ON d.id = s.device_id + WHERE s.session_ref = ? AND s.user_id = ?`, + ) + .bind(sessionRef, userId) + .first(); + if (!session) return { session: null, messages: [] }; + + const messages = await env.DB.prepare( + `SELECT id, session_ref, tool, source_session_id, timestamp, role, content, + message_index, content_hash, metadata_json, indexed_at + FROM messages + WHERE session_ref = ? + ORDER BY timestamp ASC, message_index ASC, id ASC + LIMIT ? OFFSET ?`, + ) + .bind(sessionRef, options.limit, options.offset) + .all(); + return { session, messages: messages.results ?? [] }; +} + +export interface UploadStatusRow { + repo_url: string; + project_name: string; + device_id: string; + device_name: string | null; + last_upload_at: string; + client_version: string | null; + sessions: number; +} + +/** When each of the user's devices last uploaded each project. */ +export async function getUploadStatus(env: Env, userId: string, repoUrl?: string): Promise { + const result = await env.DB.prepare( + `SELECT u.repo_url, u.project_name, u.device_id, d.device_name, u.last_upload_at, u.client_version, + (SELECT COUNT(*) FROM sessions s + WHERE s.user_id = u.user_id AND s.repo_url = u.repo_url AND s.device_id = u.device_id) AS sessions + FROM uploads u LEFT JOIN devices d ON d.id = u.device_id + WHERE u.user_id = ? AND (? IS NULL OR u.repo_url = ?) + ORDER BY u.last_upload_at DESC`, + ) + .bind(userId, repoUrl ?? null, repoUrl ?? null) + .all(); + return result.results ?? []; +} + +/** Create or rename the account; returns its current token epoch. */ +export async function upsertUser(env: Env, userId: string, username: string): Promise { + const now = Date.now(); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO users (id, username, created_at, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET username = excluded.username, updated_at = excluded.updated_at`, + ).bind(userId, username, now, now), + env.DB.prepare(`INSERT OR IGNORE INTO token_epochs (user_id, epoch, updated_at) VALUES (?, 0, ?)`).bind(userId, now), + ]); + return (await getAccountState(env, userId)).epoch; +} + +/** Whether the account exists, and the epoch its tokens must carry. */ +export async function getAccountState(env: Env, userId: string): Promise<{ exists: boolean; epoch: number }> { + const row = await env.DB.prepare( + `SELECT (SELECT 1 FROM users WHERE id = ?) AS user_exists, + (SELECT epoch FROM token_epochs WHERE user_id = ?) AS epoch`, + ) + .bind(userId, userId) + .first<{ user_exists: number | null; epoch: number | null }>(); + return { exists: row?.user_exists === 1, epoch: row?.epoch ?? 0 }; +} + +const bumpEpochStatement = (env: Env, userId: string) => + env.DB.prepare( + `INSERT INTO token_epochs (user_id, epoch, updated_at) VALUES (?, 1, ?) + ON CONFLICT(user_id) DO UPDATE SET epoch = token_epochs.epoch + 1, updated_at = excluded.updated_at`, + ).bind(userId, Date.now()); + +/** Invalidate every token issued to this user so far. */ +export async function bumpTokenEpoch(env: Env, userId: string): Promise { + await bumpEpochStatement(env, userId).run(); +} + +/** + * Remove everything held for a user, children first, then the account row, + * and move the token epoch in the same batch. The epoch row itself is kept: + * it is what stops a token from before the deletion working again after the + * same GitHub account signs in later. + */ +export async function deleteUserData(env: Env, userId: string): Promise { + const owned = `(SELECT session_ref FROM sessions WHERE user_id = ?)`; + await env.DB.batch([ + bumpEpochStatement(env, userId), + env.DB.prepare(`DELETE FROM retrieval_units WHERE session_ref IN ${owned}`).bind(userId), + env.DB.prepare(`DELETE FROM messages WHERE session_ref IN ${owned}`).bind(userId), + env.DB.prepare(`DELETE FROM sessions WHERE user_id = ?`).bind(userId), + env.DB.prepare(`DELETE FROM uploads WHERE user_id = ?`).bind(userId), + env.DB.prepare(`DELETE FROM devices WHERE user_id = ?`).bind(userId), + env.DB.prepare(`DELETE FROM users WHERE id = ?`).bind(userId), + ]); +} diff --git a/cloud/src/index.ts b/cloud/src/index.ts new file mode 100644 index 0000000..38713e9 --- /dev/null +++ b/cloud/src/index.ts @@ -0,0 +1,190 @@ +import { OAuthError, OAuthProvider, insufficientScope } from "@cloudflare/workers-oauth-provider"; +import type { Env, AuthUser } from "./types.js"; +import { SCOPE_READ } from "./types.js"; +import { isAccountCurrent, mcpResource, publicOrigin, verifyBearer } from "./auth.js"; +import { MAX_MCP_BODY_BYTES, allowedOrigins, handleApp, readBodyCapped } from "./app.js"; +import { handleMcpPost, LEGACY_VERSIONS, MODERN_VERSIONS } from "./mcp.js"; +import type { OAuthGrantProps } from "./oauth.js"; +import { SERVER_NAME, SERVER_VERSION } from "./version.js"; + +export { SseSession } from "./sse-session.js"; + +/** OAuth access tokens are short-lived; the refresh token carries the grant. */ +const ACCESS_TOKEN_TTL_SECONDS = 60 * 60; +const REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30; + +interface CliTokenProps { + kind: "cli"; + userId: string; + username: string; + deviceId?: string; + epoch: number; + scopes: string[]; +} + +type McpProps = OAuthGrantProps | CliTokenProps; + +/** + * `POST /mcp`, reached only with a token the provider accepted for the MCP + * resource: one it issued through `/authorize`, or one of this server's own + * (see `resolveExternalToken`). + */ +const mcpApiHandler = { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const { props, auth } = ctx as unknown as { + props: McpProps; + auth: { scope: string[]; audience: string; token: string }; + }; + const scopes = props.kind === "oauth" ? auth.scope : props.scopes; + // The provider checked the token; whether the account behind it still may + // read is ours: logout and delete-my-data move the epoch, and the + // allowlist can drop someone. + if (props.kind === "oauth" && !(await isAccountCurrent(env, props.userId, props.epoch))) { + return new Response(JSON.stringify({ error: "invalid_token" }), { + status: 401, + headers: { + "Content-Type": "application/json", + "WWW-Authenticate": `Bearer realm="OAuth", resource_metadata="${publicOrigin(env)}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`, + }, + }); + } + if (!scopes.includes(SCOPE_READ)) return insufficientScope(auth as never, [SCOPE_READ]); + + const text = await readBodyCapped(request, MAX_MCP_BODY_BYTES); + if (text === null) { + return Response.json({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Request too large" } }, { status: 413 }); + } + const user: AuthUser = { userId: props.userId, username: props.username }; + return handleMcpPost(env, user, request, text); + }, +}; + +const providers = new Map>(); + +/** One provider per public origin: its endpoints and resource are absolute URLs. */ +function providerFor(env: Env): OAuthProvider { + const origin = publicOrigin(env); + let provider = providers.get(origin); + if (provider) return provider; + + provider = new OAuthProvider({ + apiRoute: "/mcp", + apiHandler: mcpApiHandler as never, + defaultHandler: { fetch: (request: Request, env: Env) => handleApp(request, env) } as never, + authorizeEndpoint: `${origin}/authorize`, + tokenEndpoint: `${origin}/oauth/token`, + // Deprecated by MCP 2026-07-28 in favour of Client ID Metadata Documents + // (enabled below), and kept because clients in use today still register + // this way. + clientRegistrationEndpoint: `${origin}/oauth/register`, + clientIdMetadataDocumentEnabled: true, + scopesSupported: [SCOPE_READ], + requiredScopes: [SCOPE_READ], + accessTokenTTL: ACCESS_TOKEN_TTL_SECONDS, + refreshTokenTTL: REFRESH_TOKEN_TTL_SECONDS, + resourceMetadata: { + resource: mcpResource(env), + authorization_servers: [origin], + bearer_methods_supported: ["header"], + resource_name: "xtctx cloud", + }, + // This server's own JWTs at /mcp: the CLI's login, or a pasted read + // token. Both name the MCP resource in `aud`, so this is the same + // audience check the provider applies to its own tokens. + resolveExternalToken: async ({ token, env }) => { + const verified = await verifyBearer(token, env, mcpResource(env)); + if (!verified) return null; + const props: CliTokenProps = { + kind: "cli", + userId: verified.userId, + username: verified.username, + deviceId: verified.deviceId, + epoch: verified.epoch, + scopes: verified.scopes, + }; + return { props, audience: mcpResource(env) }; + }, + // A refresh after logout or delete-my-data, or after the account left the + // allowlist, ends the grant instead of minting a token that /mcp refuses. + tokenExchangeCallback: async ({ grantType, props, env }) => { + if (grantType !== "refresh_token") return; + const p = props as OAuthGrantProps; + if (!(await isAccountCurrent(env, p.userId, p.epoch))) { + throw new OAuthError("invalid_grant", { description: "This sign-in has been revoked" }); + } + }, + onError: ({ code, status, internal }) => { + if (status >= 500) console.error(JSON.stringify({ event: "oauth_error", code, status, internal })); + }, + }); + providers.set(origin, provider); + return provider; +} + +function withServerHeaders(response: Response): Response { + const headers = new Headers(response.headers); + headers.set("X-Xtctx-Server-Version", SERVER_VERSION); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + +const PROTECTED_RESOURCE_ROOT = "/.well-known/oauth-protected-resource"; + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const json = (body: unknown, status = 200, extra: Record = {}) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", ...extra } }); + + try { + const url = new URL(request.url); + + if (url.pathname === "/" || url.pathname === "/health") { + return withServerHeaders( + json({ + status: "ok", + service: SERVER_NAME, + version: SERVER_VERSION, + mcp: { + supportedVersions: [...MODERN_VERSIONS, ...LEGACY_VERSIONS], + endpoints: { streamableHttp: "/mcp", sse: "/sse" }, + }, + }), + ); + } + + // Without a signing secret nothing below can be trusted, so none of it + // runs. There used to be a built-in default, which made every token + // forgeable on a deployment that forgot to set one. + if (!env.JWT_SECRET || !env.PUBLIC_URL) { + console.error(JSON.stringify({ event: "misconfigured", jwtSecret: !!env.JWT_SECRET, publicUrl: !!env.PUBLIC_URL })); + return withServerHeaders(json({ error: "server_misconfigured" }, 500)); + } + + if (url.pathname === "/mcp" || url.pathname.startsWith("/mcp/")) { + // Streamable HTTP: a present Origin that is not ours is refused + // (DNS rebinding). CLI and agent clients send none. + const origin = request.headers.get("Origin"); + if (origin && origin !== publicOrigin(env) && !allowedOrigins(env).includes(origin)) { + return withServerHeaders(json({ jsonrpc: "2.0", error: { code: -32600, message: "Origin not allowed" } }, 403)); + } + // No GET stream and no sessions to DELETE: both eras get 405. + if (request.method !== "POST" && request.method !== "OPTIONS") { + return withServerHeaders(new Response(null, { status: 405, headers: { Allow: "POST" } })); + } + } + + // RFC 9728 puts this resource's metadata at the /mcp form; some clients + // (Claude Code among them) also try the root form, so it answers too, + // with the same document. + if (url.pathname === PROTECTED_RESOURCE_ROOT && (request.method === "GET" || request.method === "HEAD")) { + const aliased = new Request(`${publicOrigin(env)}${PROTECTED_RESOURCE_ROOT}/mcp`, { method: request.method }); + return withServerHeaders(await providerFor(env).fetch(aliased, env, ctx)); + } + + return withServerHeaders(await providerFor(env).fetch(request, env, ctx)); + } catch (err: unknown) { + // The detail is for whoever reads the Worker's logs, not for the caller. + console.error(JSON.stringify({ event: "unhandled_error", error: err instanceof Error ? err.stack ?? err.message : String(err) })); + return withServerHeaders(json({ error: "internal_error" }, 500)); + } + }, +}; diff --git a/cloud/src/mcp.ts b/cloud/src/mcp.ts new file mode 100644 index 0000000..c645ef8 --- /dev/null +++ b/cloud/src/mcp.ts @@ -0,0 +1,428 @@ +import type { Env, AuthUser, MessageRecord, SessionRecord } from "./types.js"; +import { getRecentSessions, getSessionMessages, getUploadStatus } from "./db.js"; +import { SERVER_NAME, SERVER_VERSION } from "./version.js"; + +/** + * MCP over JSON-RPC, for two eras of client on one endpoint. + * + * Modern (2026-07-28): no handshake; every request carries its protocol + * version in `params._meta` and, over HTTP, mirrors it and the method into + * headers that must agree with the body. Legacy (2025-11-25, 2025-06-18, and + * 2024-11-05 over the `/sse` transport): an `initialize` handshake. Neither + * keeps session state here, so nothing depends on which isolate answers. + * + * JSON-RPC batches are refused: 2025-06-18 removed them and 2026-07-28 allows + * one message per POST. + */ + +export const MODERN_VERSIONS = ["2026-07-28"]; +export const LEGACY_VERSIONS = ["2025-11-25", "2025-06-18", "2024-11-05"]; + +const META_VERSION = "io.modelcontextprotocol/protocolVersion"; +const META_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities"; +const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"; + +export const ERR = { + parse: -32700, + invalidRequest: -32600, + methodNotFound: -32601, + invalidParams: -32602, + internal: -32603, + headerMismatch: -32020, + unsupportedVersion: -32022, +} as const; + +const SERVER_INFO = { name: SERVER_NAME, version: SERVER_VERSION }; + +/** + * 2026-07-28 caching hints, required on server/discover and tools/list. The + * tool list and the discovery result are the same for every user, so they + * are public; they change only with a deploy. + */ +const CACHE_PUBLIC = { ttlMs: 60 * 60 * 1000, cacheScope: "public" } as const; + +const INSTRUCTIONS = + "Transcripts you uploaded to xtctx cloud from your other machines. " + + "Call xtctx_cloud_status first to see which projects and devices have uploaded and when; " + + "then xtctx_cloud_recent_sessions and xtctx_cloud_session_detail. " + + "For this machine's own sessions use the local xtctx tools."; + +export const MCP_TOOLS = [ + { + name: "xtctx_cloud_recent_sessions", + description: + "List sessions uploaded to your xtctx cloud account, newest first. Covers every device and every " + + "project you have uploaded unless repo_url is given, which restricts it to one repository. " + + "Use it to pick up work done on another machine; this machine's sessions are in the local xtctx tools.", + inputSchema: { + type: "object", + properties: { + repo_url: { + type: "string", + description: "Only sessions from this repository, as xtctx names it (e.g. github.com/user/repo).", + }, + limit: { type: "integer", minimum: 1, maximum: 25, description: "Max sessions to return, 1-25. Default 5." }, + tool_filter: { + type: "array", + items: { type: "string" }, + description: "Only these tools: claude-code, antigravity, cursor, copilot, codex, opencode.", + }, + branch_filter: { type: "array", items: { type: "string" }, description: "Only these git branches." }, + format: { type: "string", enum: ["markdown", "json"], description: "Default markdown." }, + }, + }, + }, + { + name: "xtctx_cloud_session_detail", + description: "Return the messages of one uploaded session, in conversation order, by the session_ref that xtctx_cloud_recent_sessions gave.", + inputSchema: { + type: "object", + required: ["session_ref"], + properties: { + session_ref: { type: "string", description: "A session_ref from xtctx_cloud_recent_sessions." }, + offset: { type: "integer", minimum: 0, description: "Messages to skip, for paging. Default 0." }, + limit: { type: "integer", minimum: 1, maximum: 100, description: "Max messages to return, 1-100. Default 50." }, + format: { type: "string", enum: ["markdown", "json"], description: "Default markdown." }, + }, + }, + }, + { + name: "xtctx_cloud_status", + description: + "When each of your devices last uploaded each project to xtctx cloud, and how many sessions it holds. " + + "Tells a project that synced and has nothing new apart from one that never synced.", + inputSchema: { + type: "object", + properties: { + repo_url: { type: "string", description: "Only this repository." }, + format: { type: "string", enum: ["markdown", "json"], description: "Default markdown." }, + }, + }, + }, +]; + +/** Thrown for arguments a tool cannot use; reported to the model as a tool error so it can correct them. */ +class ArgumentError extends Error {} + +function optionalString(args: Record, key: string): string | undefined { + const v = args[key]; + if (v === undefined || v === null) return undefined; + if (typeof v !== "string") throw new ArgumentError(`${key} must be a string`); + return v; +} + +function optionalStrings(args: Record, key: string): string[] | undefined { + const v = args[key]; + if (v === undefined || v === null) return undefined; + if (!Array.isArray(v) || !v.every((x) => typeof x === "string") || v.length > 20) { + throw new ArgumentError(`${key} must be a list of at most 20 strings`); + } + return v as string[]; +} + +/** An integer clamped into [min, max]; anything that is not a number is an error, not "unlimited". */ +function clampInt(args: Record, key: string, min: number, max: number, fallback: number): number { + const v = args[key]; + if (v === undefined || v === null) return fallback; + if (typeof v !== "number" || !Number.isFinite(v)) throw new ArgumentError(`${key} must be a number`); + return Math.min(max, Math.max(min, Math.trunc(v))); +} + +function format(args: Record): "markdown" | "json" { + const v = optionalString(args, "format") ?? "markdown"; + if (v !== "markdown" && v !== "json") throw new ArgumentError(`format must be "markdown" or "json"`); + return v; +} + +const text = (body: string) => ({ content: [{ type: "text", text: body }] }); +const oneLine = (s: string | null | undefined) => (s ?? "").replace(/\s+/g, " ").trim(); +const deviceLabel = (s: { device_name?: string | null; device_id: string }) => s.device_name || s.device_id; + +function publicSession(s: SessionRecord) { + return { + session_ref: s.session_ref, + tool: s.tool, + repo_url: s.repo_url, + project: s.project_root, + device: deviceLabel(s), + git_branch: s.git_branch, + git_commit: s.git_commit, + started_at: s.started_at, + last_activity_at: s.last_activity_at, + message_count: s.message_count, + preview: s.preview, + }; +} + +function publicMessage(m: MessageRecord) { + let metadata: unknown = {}; + try { + metadata = JSON.parse(m.metadata_json); + } catch { + // stored as sent; a bad one is shown as empty rather than failing the call + } + return { message_index: m.message_index, timestamp: m.timestamp, role: m.role, content: m.content, metadata }; +} + +/** Unknown tool: null, which the caller reports as a protocol error (-32602). */ +export async function handleToolCall( + env: Env, + user: AuthUser, + name: string, + args: Record, +): Promise | null> { + try { + if (name === "xtctx_cloud_recent_sessions") { + const fmt = format(args); + const sessions = await getRecentSessions(env, user.userId, { + repoUrl: optionalString(args, "repo_url"), + limit: clampInt(args, "limit", 1, 25, 5), + toolFilter: optionalStrings(args, "tool_filter"), + branchFilter: optionalStrings(args, "branch_filter"), + }); + if (fmt === "json") return text(JSON.stringify(sessions.map(publicSession), null, 2)); + if (sessions.length === 0) { + return text( + "No uploaded sessions match. Call xtctx_cloud_status to see whether this project has uploaded at all.", + ); + } + const lines = ["## Sessions in xtctx cloud", ""]; + for (const s of sessions) { + const branch = s.git_branch ? ` on branch \`${s.git_branch}\`` : ""; + lines.push( + `- **${s.tool}** from **${deviceLabel(s)}** in ${s.repo_url}${branch}`, + ` - Last active ${s.last_activity_at} (${s.message_count} messages)`, + ` - Preview: ${oneLine(s.preview) || "none"}`, + ` - session_ref: \`${s.session_ref}\``, + ); + } + return text(lines.join("\n")); + } + + if (name === "xtctx_cloud_session_detail") { + const sessionRef = optionalString(args, "session_ref"); + if (!sessionRef) throw new ArgumentError("session_ref is required"); + const fmt = format(args); + const offset = clampInt(args, "offset", 0, Number.MAX_SAFE_INTEGER, 0); + const limit = clampInt(args, "limit", 1, 100, 50); + const { session, messages } = await getSessionMessages(env, user.userId, sessionRef, { offset, limit }); + if (!session) { + return { isError: true, ...text(`Session '${sessionRef}' not found. Use a session_ref from xtctx_cloud_recent_sessions.`) }; + } + const page = { offset, returned: messages.length, total: session.message_count }; + if (fmt === "json") { + return text(JSON.stringify({ session: publicSession(session), page, messages: messages.map(publicMessage) }, null, 2)); + } + const lines = [ + `## ${session.tool} session from ${deviceLabel(session)}`, + `${session.repo_url}${session.git_branch ? ` on \`${session.git_branch}\`` : ""}, ${session.started_at} to ${session.last_activity_at}`, + `Messages ${offset + 1}-${offset + messages.length} of ${session.message_count}.`, + "", + ]; + for (const m of messages) { + lines.push(`### ${m.role} (#${m.message_index}, ${m.timestamp})`, "", m.content, ""); + } + if (offset + messages.length < session.message_count) { + lines.push(`More: call again with offset ${offset + messages.length}.`); + } + return text(lines.join("\n")); + } + + if (name === "xtctx_cloud_status") { + const fmt = format(args); + const rows = await getUploadStatus(env, user.userId, optionalString(args, "repo_url")); + if (fmt === "json") return text(JSON.stringify(rows, null, 2)); + if (rows.length === 0) { + return text( + "Nothing has been uploaded to this xtctx cloud account" + + (args.repo_url ? " for that repository" : "") + + ". On the machine that has the sessions, run `xtctx sync enable` in the project, then `xtctx sync`.", + ); + } + const lines = ["## xtctx cloud uploads", "", "| Project | Device | Last upload | Sessions |", "|---|---|---|---|"]; + for (const r of rows) { + lines.push(`| ${r.project_name} (${r.repo_url}) | ${r.device_name ?? r.device_id} | ${r.last_upload_at} | ${r.sessions} |`); + } + return text(lines.join("\n")); + } + } catch (err) { + if (err instanceof ArgumentError) return { isError: true, ...text(`Invalid arguments: ${err.message}`) }; + throw err; + } + return null; +} + +export interface RpcOutcome { + /** HTTP status for the HTTP transport. */ + status: number; + /** JSON-RPC message to send back, or null for none (a notification). */ + body: Record | null; +} + +const error = (status: number, id: unknown, code: number, message: string, data?: unknown): RpcOutcome => ({ + status, + body: { jsonrpc: "2.0", id: id ?? null, error: data === undefined ? { code, message } : { code, message, data } }, +}); + +/** Base64 sentinel decoding for mirrored header values (`=?base64?...?=`). */ +function decodeHeaderValue(value: string): string { + const m = /^=\?base64\?([A-Za-z0-9+/=]*)\?=$/.exec(value); + if (!m) return value; + try { + return new TextDecoder().decode(Uint8Array.from(atob(m[1]), (c) => c.charCodeAt(0))); + } catch { + return "\u0000invalid"; + } +} + +const isObject = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); + +/** + * Handle one parsed JSON-RPC message. `headers` is given for Streamable HTTP, + * whose modern-era header checks it enables; the SSE transport passes none. + */ +export async function processMessage( + env: Env, + user: AuthUser, + message: unknown, + headers?: Headers, +): Promise { + if (!isObject(message) || message.jsonrpc !== "2.0") { + return error(400, null, ERR.invalidRequest, "Invalid Request: expected a single JSON-RPC 2.0 object"); + } + const { id, method } = message; + + if (typeof method !== "string") { + // A response from the client (to nothing we sent) is accepted and dropped. + if ("result" in message || "error" in message) return { status: 202, body: null }; + return error(400, id, ERR.invalidRequest, "Invalid Request: missing method"); + } + // Any notification: accepted, never answered. + if (!("id" in message)) return { status: 202, body: null }; + if (!(typeof id === "string" || (typeof id === "number" && Number.isInteger(id)))) { + return error(400, null, ERR.invalidRequest, "Invalid Request: id must be a string or an integer"); + } + if (message.params !== undefined && !isObject(message.params)) { + return error(400, id, ERR.invalidRequest, "Invalid Request: params must be an object"); + } + const params = (message.params ?? {}) as Record; + const meta = isObject(params._meta) ? params._meta : {}; + const metaVersion = meta[META_VERSION]; + const headerVersion = headers?.get("MCP-Protocol-Version") ?? null; + + const modern = + method !== "initialize" && + (metaVersion !== undefined || (headerVersion !== null && !LEGACY_VERSIONS.includes(headerVersion))); + + if (modern) { + if (typeof metaVersion !== "string") { + return error(400, id, ERR.invalidParams, `Missing _meta["${META_VERSION}"]`); + } + if (headers && headerVersion === null) { + return error(400, id, ERR.headerMismatch, "Header mismatch: MCP-Protocol-Version header is required"); + } + if (headers && headerVersion !== metaVersion) { + return error(400, id, ERR.headerMismatch, `Header mismatch: MCP-Protocol-Version '${headerVersion}' does not match body '${metaVersion}'`); + } + if (!MODERN_VERSIONS.includes(metaVersion)) { + return error(400, id, ERR.unsupportedVersion, "Unsupported protocol version", { + supported: [...MODERN_VERSIONS, ...LEGACY_VERSIONS], + requested: metaVersion, + }); + } + if (!isObject(meta[META_CAPABILITIES])) { + return error(400, id, ERR.invalidParams, `Missing _meta["${META_CAPABILITIES}"]`); + } + if (headers) { + const headerMethod = headers.get("Mcp-Method"); + if (headerMethod !== method) { + return error(400, id, ERR.headerMismatch, `Header mismatch: Mcp-Method '${headerMethod ?? ""}' does not match body '${method}'`); + } + if (method === "tools/call") { + const headerName = headers.get("Mcp-Name"); + if (headerName === null || decodeHeaderValue(headerName) !== params.name) { + return error(400, id, ERR.headerMismatch, "Header mismatch: Mcp-Name does not match params.name"); + } + } + } + } else if (headerVersion !== null && !LEGACY_VERSIONS.includes(headerVersion)) { + return error(400, id, ERR.invalidRequest, `Unsupported MCP-Protocol-Version '${headerVersion}'`); + } + + const reply = (result: Record): RpcOutcome => ({ + status: 200, + body: { + jsonrpc: "2.0", + id, + result: modern ? { resultType: "complete", ...result, _meta: { [META_SERVER_INFO]: SERVER_INFO } } : result, + }, + }); + + switch (method) { + case "initialize": { + const requested = typeof params.protocolVersion === "string" ? params.protocolVersion : ""; + return reply({ + protocolVersion: LEGACY_VERSIONS.includes(requested) ? requested : LEGACY_VERSIONS[0], + capabilities: { tools: { listChanged: false } }, + serverInfo: SERVER_INFO, + instructions: INSTRUCTIONS, + }); + } + case "server/discover": + return reply({ + supportedVersions: MODERN_VERSIONS, + capabilities: { tools: {} }, + instructions: INSTRUCTIONS, + ...CACHE_PUBLIC, + }); + case "ping": + return reply({}); + case "tools/list": + return reply(modern ? { tools: MCP_TOOLS, ...CACHE_PUBLIC } : { tools: MCP_TOOLS }); + case "tools/call": { + const name = params.name; + if (typeof name !== "string") return error(modern ? 400 : 200, id, ERR.invalidParams, "params.name is required"); + if (params.arguments !== undefined && !isObject(params.arguments)) { + return error(modern ? 400 : 200, id, ERR.invalidParams, "params.arguments must be an object"); + } + try { + const result = await handleToolCall(env, user, name, (params.arguments ?? {}) as Record); + if (!result) return error(modern ? 400 : 200, id, ERR.invalidParams, `Unknown tool: ${name}`); + return reply(result); + } catch (err) { + // Detail goes to the Worker's log; the caller gets nothing that names + // a table or a query. + console.error(JSON.stringify({ event: "tool_call_failed", tool: name, userId: user.userId, error: String(err) })); + return error(200, id, ERR.internal, "Internal error"); + } + } + default: + // Modern HTTP answers an unknown method with 404, which tells it apart + // from a legacy server's 404 for a missing endpoint. + return error(modern && headers ? 404 : 200, id, ERR.methodNotFound, `Method not found: ${method}`); + } +} + +/** Streamable HTTP (`POST /mcp`): one JSON-RPC message per request. */ +export async function handleMcpPost(env: Env, user: AuthUser, request: Request, bodyText: string): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(bodyText); + } catch { + return json(400, { jsonrpc: "2.0", id: null, error: { code: ERR.parse, message: "Parse error" } }); + } + if (Array.isArray(parsed)) { + return json(400, { + jsonrpc: "2.0", + id: null, + error: { code: ERR.invalidRequest, message: "Invalid Request: JSON-RPC batches are not supported" }, + }); + } + const outcome = await processMessage(env, user, parsed, request.headers); + if (!outcome.body) return new Response(null, { status: 202 }); + return json(outcome.status, outcome.body); +} + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} diff --git a/cloud/src/oauth.ts b/cloud/src/oauth.ts new file mode 100644 index 0000000..8e0d82d --- /dev/null +++ b/cloud/src/oauth.ts @@ -0,0 +1,250 @@ +import { + AuthorizationError, + CimdFetchError, + authorizationErrorRedirect, + type ConsentDescription, + type OAuthHelpers, +} from "@cloudflare/workers-oauth-provider"; +import type { Env } from "./types.js"; +import { SCOPE_READ } from "./types.js"; +import { isAllowedUser, publicOrigin } from "./auth.js"; +import { upsertUser } from "./db.js"; + +/** + * Browser sign-in for MCP clients: this Worker is the OAuth 2.1 authorization + * server for its own `/mcp` resource, and GitHub is only the identity step. + * + * GET /authorize validate the client's request (PKCE S256 + * required), show a consent page naming it + * POST /authorize Allow: on to GitHub; Deny: back to the client + * GET /oauth/github/callback GitHub says who it is; check the allowlist, + * finish the grant, back to the client + * + * workers-oauth-provider owns everything else: metadata, client registration + * (Client ID Metadata Documents and dynamic registration), the token endpoint, + * refresh rotation and revocation, and the cookies that bind each step to the + * browser that started it. + * + * An OAuth client is only ever granted `read`, and its tokens are bound to the + * `/mcp` resource: an agent connected this way can read uploaded transcripts, + * never upload or delete. Those stay with the CLI's own login. + */ + +export const GITHUB_CALLBACK_PATH = "/oauth/github/callback"; + +/** The provider's user id may not contain ':' (it delimits its tokens). */ +export function grantUserId(userId: string): string { + return userId.replace(/:/g, "-"); +} + +export interface OAuthGrantProps { + kind: "oauth"; + userId: string; + username: string; + epoch: number; +} + +const escape = (value: string) => value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); + +function page(title: string, body: string, status = 200, headers = new Headers()): Response { + headers.set("Content-Type", "text/html; charset=utf-8"); + headers.set("Cache-Control", "no-store"); + headers.set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'"); + headers.set("X-Frame-Options", "DENY"); + return new Response( + `` + + `${escape(title)}` + + `${body}`, + { status, headers }, + ); +} + +function consentPage(details: ConsentDescription, handle: string): string { + const name = escape(details.clientName); + const who = details.clientDomain + ? `Published by ${escape(details.clientDomain)}.` + : "This app registered itself; its name is not verified."; + return `

Allow ${name} to read your xtctx cloud?

+

${who} Access will be sent to ${escape(details.redirectHost)}.

+${ + details.redirectIsLoopback + ? `

This sends access to an app on your computer. Continue only if you just started connecting from it.

` + : "" +} +

It will be able to:

+
  • read: list and read the transcripts uploaded to your xtctx cloud account, from every device and project.
+

It will not be able to upload, change or delete anything. You sign in with GitHub next.

+
+ + + +
`; +} + +function renderAuthError(err: unknown): Response { + if (err instanceof AuthorizationError && err.redirectTo) return Response.redirect(err.redirectTo, 302); + if (err instanceof AuthorizationError || err instanceof CimdFetchError) { + const message = err instanceof AuthorizationError ? err.description : "This app could not be verified."; + return page("Sign-in failed", `

Sign-in failed

${escape(message ?? "Invalid request.")}

Start again from the app.

`, 400); + } + throw err; +} + +async function s256(verifier: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + let binary = ""; + for (const b of new Uint8Array(digest)) binary += String.fromCharCode(b); + return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +} + +function randomVerifier(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +} + +export async function handleAuthorize(request: Request, env: Env): Promise { + const oauth = env.OAUTH_PROVIDER as OAuthHelpers; + if (!env.GITHUB_CLIENT_SECRET) { + console.error(JSON.stringify({ event: "authorize_unconfigured", reason: "GITHUB_CLIENT_SECRET is not set" })); + return page("Not available", "

Browser sign-in is not configured on this server.

", 503); + } + + try { + if (request.method === "GET") { + const authRequest = await oauth.parseAuthRequest(request); + // The library leaves PKCE optional for confidential clients; here it is + // required of every client, and only S256. + if (authRequest.codeChallengeMethod !== "S256" || !authRequest.codeChallenge) { + return Response.redirect( + authorizationErrorRedirect(authRequest, "invalid_request", "PKCE with code_challenge_method=S256 is required"), + 302, + ); + } + // Whatever was asked for, an OAuth client is offered read and nothing else. + const offered = { ...authRequest, scope: [SCOPE_READ] }; + const details = await oauth.describeConsent(offered); + const consent = await oauth.beginConsent(offered); + return page(`Authorize ${details.clientName}`, consentPage(details, consent.handle), 200, consent.headers); + } + + if (request.method === "POST") { + const form = await request.formData(); + const handle = String(form.get("handle") ?? ""); + if (form.get("decision") !== "approve") { + const denied = await oauth.denyConsent(request, handle); + return new Response(null, { status: 302, headers: denied.headers }); + } + const approved = await oauth.approveConsent(request, handle, { scope: [SCOPE_READ] }); + const verifier = randomVerifier(); + const { state, headers } = await oauth.beginUpstream(approved.request, { + data: { verifier }, + headers: approved.headers, + }); + const github = new URL("https://github.com/login/oauth/authorize"); + github.searchParams.set("client_id", env.GITHUB_CLIENT_ID); + github.searchParams.set("redirect_uri", `${publicOrigin(env)}${GITHUB_CALLBACK_PATH}`); + github.searchParams.set("scope", "read:user"); + github.searchParams.set("state", state); + github.searchParams.set("code_challenge", await s256(verifier)); + github.searchParams.set("code_challenge_method", "S256"); + github.searchParams.set("allow_signup", "false"); + headers.set("Location", github.toString()); + return new Response(null, { status: 302, headers }); + } + + return new Response(null, { status: 405, headers: { Allow: "GET, POST" } }); + } catch (err) { + return renderAuthError(err); + } +} + +interface GitHubUser { + id: number; + login: string; +} + +/** Exchange a web-flow code for the GitHub user it belongs to. Null on any failure. */ +async function githubUserFromCode(env: Env, code: string, verifier: string): Promise { + const tokenRes = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: env.GITHUB_CLIENT_ID, + client_secret: env.GITHUB_CLIENT_SECRET, + code, + code_verifier: verifier, + redirect_uri: `${publicOrigin(env)}${GITHUB_CALLBACK_PATH}`, + }), + }); + const token = (await tokenRes.json().catch(() => ({}))) as { access_token?: string }; + if (!tokenRes.ok || !token.access_token) return null; + return githubUser(token.access_token); +} + +/** Who a GitHub access token belongs to. Null on any failure. */ +export async function githubUser(accessToken: string): Promise { + const res = await fetch("https://api.github.com/user", { + headers: { Authorization: `Bearer ${accessToken}`, "User-Agent": "xtctx-cloud", Accept: "application/vnd.github+json" }, + }); + const user = (await res.json().catch(() => ({}))) as Partial; + if (!res.ok || typeof user.id !== "number" || typeof user.login !== "string" || !user.login) return null; + return { id: user.id, login: user.login }; +} + +export async function handleGithubCallback(request: Request, env: Env): Promise { + const oauth = env.OAUTH_PROVIDER as OAuthHelpers; + try { + const { request: original, data, headers } = await oauth.finishUpstream<{ verifier: string }>(request); + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const fail = (description: string) => { + headers.set("Location", authorizationErrorRedirect(original, "access_denied", description)); + return new Response(null, { status: 302, headers }); + }; + if (url.searchParams.get("error") || !code) return fail("GitHub sign-in was cancelled"); + + const user = await githubUserFromCode(env, code, data.verifier); + if (!user) return fail("GitHub sign-in failed"); + + const userId = `github:${user.id}`; + // Before anything is written for this account. + if (!isAllowedUser(env, userId)) return fail("This GitHub account is not allowed on this server"); + + const epoch = await upsertUser(env, userId, user.login); + const props: OAuthGrantProps = { kind: "oauth", userId, username: user.login, epoch }; + const { redirectTo } = await oauth.completeAuthorization({ + request: original, + userId: grantUserId(userId), + metadata: { login: user.login }, + scope: original.scope.filter((s) => s === SCOPE_READ), + props, + }); + headers.set("Location", redirectTo); + return new Response(null, { status: 302, headers }); + } catch (err) { + return renderAuthError(err); + } +} + +/** + * End every OAuth grant the user holds. Best effort: the token epoch, which + * the caller has already moved, is what actually refuses those tokens. + */ +export async function revokeOAuthGrants(env: Env, userId: string): Promise { + const oauth = env.OAUTH_PROVIDER; + if (!oauth) return; + try { + let cursor: string | undefined; + do { + const page = await oauth.listUserGrants(grantUserId(userId), { cursor }); + for (const grant of page.items) await oauth.revokeGrant(grant.id, grantUserId(userId)); + cursor = page.cursor; + } while (cursor); + } catch (err) { + console.error(JSON.stringify({ event: "grant_revocation_failed", userId, error: String(err) })); + } +} diff --git a/cloud/src/sse-session.ts b/cloud/src/sse-session.ts new file mode 100644 index 0000000..08a5c40 --- /dev/null +++ b/cloud/src/sse-session.ts @@ -0,0 +1,57 @@ +import type { Env } from "./types.js"; + +/** + * One baseline-MCP SSE stream, held in a Durable Object. + * + * `GET /sse` and the later `POST /message` are separate requests, and a + * Worker may serve them from different isolates, so the open stream cannot + * live in a module-level Map: a message that landed elsewhere found no stream. + * Both requests address the same object by session id instead, and the object + * is where the writer lives. + * + * The Worker does the authentication and runs the JSON-RPC call; this object + * only owns the stream and delivers what it is handed. + */ +export class SseSession { + private writer: WritableStreamDefaultWriter | undefined; + private userId: string | undefined; + + // The platform constructs this with (state, env); neither is needed. + constructor(_state?: DurableObjectState, _env?: Env) {} + + async fetch(request: Request): Promise { + const path = new URL(request.url).pathname; + const body = (await request.json()) as { userId: string; endpoint?: string; payload?: unknown }; + + if (path === "/connect") { + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + this.writer = writer; + this.userId = body.userId; + // The client going away closes the stream; forget it so a late message + // is refused instead of written to nothing. + writer.closed.catch(() => undefined).finally(() => { + if (this.writer === writer) this.writer = undefined; + }); + void writer.write(new TextEncoder().encode(`event: endpoint\ndata: ${body.endpoint}\n\n`)).catch(() => undefined); + return new Response(readable, { + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" }, + }); + } + + if (path === "/send") { + // The user check is the isolation between tenants: a session id alone + // must not let someone else write into this stream. + if (!this.writer || this.userId !== body.userId) return new Response("Session not found", { status: 404 }); + // Not awaited: a write completes when the client reads it, and one slow + // reader must not hold up the POST that delivered the message. + const writer = this.writer; + writer.write(new TextEncoder().encode(`event: message\ndata: ${JSON.stringify(body.payload)}\n\n`)).catch(() => { + if (this.writer === writer) this.writer = undefined; + }); + return new Response(null, { status: 202 }); + } + + return new Response("Not Found", { status: 404 }); + } +} diff --git a/cloud/src/types.ts b/cloud/src/types.ts new file mode 100644 index 0000000..de31116 --- /dev/null +++ b/cloud/src/types.ts @@ -0,0 +1,74 @@ +import type { OAuthHelpers } from "@cloudflare/workers-oauth-provider"; + +export interface Env { + DB: D1Database; + /** Grants, OAuth tokens and registered clients; owned by workers-oauth-provider. */ + OAUTH_KV: KVNamespace; + /** Set by workers-oauth-provider before a handler runs. */ + OAUTH_PROVIDER?: OAuthHelpers; + /** Holds the open baseline-MCP SSE streams; see sse-session.ts. */ + SSE: DurableObjectNamespace; + /** + * The canonical origin MCP clients reach, e.g. "https://mcp.xtctx.com". + * The MCP resource is `${PUBLIC_URL}/mcp`, and every token is bound to it. + */ + PUBLIC_URL: string; + GITHUB_CLIENT_ID: string; + /** Needed only for the browser sign-in (`/authorize`); the CLI's device flow works without it. */ + GITHUB_CLIENT_SECRET?: string; + /** Required. Requests are refused with a 500 while it is unset. */ + JWT_SECRET?: string; + /** + * Comma-separated numeric GitHub user ids allowed to sign in. Unset or empty + * means nobody: the server fails closed. + */ + ALLOWED_GITHUB_IDS?: string; + /** Comma-separated browser origins to send CORS headers to. Unset means none. */ + ALLOWED_ORIGINS?: string; + ENVIRONMENT?: string; +} + +export interface AuthUser { + userId: string; + username: string; + deviceId?: string; +} + +/** The MCP tools. */ +export const SCOPE_READ = "read"; +/** POST /api/stream. Only the CLI's own login carries it. */ +export const SCOPE_SYNC_WRITE = "sync:write"; +/** DELETE /api/me. Only the CLI's own login carries it. */ +export const SCOPE_ACCOUNT_DELETE = "account:delete"; + +export interface SessionRecord { + session_ref: string; + user_id: string; + device_id: string; + device_name?: string | null; + tool: string; + source_session_id: string; + repo_url: string; + project_root: string; + git_branch: string | null; + git_commit: string | null; + started_at: string; + last_activity_at: string; + message_count: number; + preview: string | null; + updated_at: string; +} + +export interface MessageRecord { + id: string; + session_ref: string; + tool: string; + source_session_id: string; + timestamp: string; + role: string; + content: string; + message_index: number; + content_hash: string; + metadata_json: string; + indexed_at: string; +} diff --git a/cloud/src/version.ts b/cloud/src/version.ts new file mode 100644 index 0000000..6b87279 --- /dev/null +++ b/cloud/src/version.ts @@ -0,0 +1,5 @@ +import pkg from "../package.json"; + +/** Reported in /health, the X-Xtctx-Server-Version header, and MCP serverInfo. */ +export const SERVER_NAME = "xtctx-cloud"; +export const SERVER_VERSION: string = pkg.version; diff --git a/cloud/test/cloudflare-workers-stub.ts b/cloud/test/cloudflare-workers-stub.ts new file mode 100644 index 0000000..c76bcfb --- /dev/null +++ b/cloud/test/cloudflare-workers-stub.ts @@ -0,0 +1,6 @@ +/** + * `cloudflare:workers` exists only inside workerd. workers-oauth-provider + * imports WorkerEntrypoint from it to tell handler classes from handler + * objects; this Worker passes objects, so an empty class is all it needs. + */ +export class WorkerEntrypoint {} diff --git a/cloud/test/fake-env.ts b/cloud/test/fake-env.ts new file mode 100644 index 0000000..20b916b --- /dev/null +++ b/cloud/test/fake-env.ts @@ -0,0 +1,154 @@ +import { DatabaseSync } from "node:sqlite"; +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { SseSession } from "../src/sse-session.js"; +import type { Env } from "../src/types.js"; + +const MIGRATIONS = fileURLToPath(new URL("../migrations/", import.meta.url)); + +/** Every file in migrations/, in order, the way `wrangler d1 migrations apply` runs them. */ +export function applyMigrations(db: DatabaseSync, upTo = Infinity): void { + const files = readdirSync(MIGRATIONS).filter((f) => f.endsWith(".sql")).sort(); + for (const file of files.slice(0, upTo)) db.exec(readFileSync(MIGRATIONS + file, "utf8")); +} + +/** + * Just enough of D1 over an in-memory SQLite for the Worker's own queries. + * The real migrations are applied, so a column the code needs and the schema + * lacks fails here the way it would in production. + */ +class FakeStatement { + constructor(private db: DatabaseSync, readonly sql: string, readonly params: unknown[] = []) {} + bind(...params: unknown[]) { + return new FakeStatement(this.db, this.sql, params); + } + async first() { + return ((this.db.prepare(this.sql).get(...(this.params as never[])) as T | undefined) ?? null) as T | null; + } + async all() { + return { results: this.db.prepare(this.sql).all(...(this.params as never[])) as T[] }; + } + async run() { + this.db.prepare(this.sql).run(...(this.params as never[])); + return { success: true }; + } +} + +export interface FakeD1Stats { + batches: number[]; +} + +function fakeD1(db: DatabaseSync, stats: FakeD1Stats) { + return { + prepare: (sql: string) => new FakeStatement(db, sql), + async batch(statements: FakeStatement[]) { + stats.batches.push(statements.length); + db.exec("BEGIN"); + try { + for (const s of statements) await s.run(); + db.exec("COMMIT"); + } catch (err) { + db.exec("ROLLBACK"); + throw err; + } + return statements.map(() => ({ success: true })); + }, + }; +} + +/** KV in a Map: the calls workers-oauth-provider makes, with TTLs and metadata. */ +export function fakeKV() { + const store = new Map(); + const live = (key: string) => { + const entry = store.get(key); + if (entry?.expiresAt !== undefined && entry.expiresAt <= Date.now()) { + store.delete(key); + return undefined; + } + return entry; + }; + const decode = (value: string, type: unknown) => { + const t = typeof type === "string" ? type : (type as { type?: string } | undefined)?.type; + return t === "json" ? JSON.parse(value) : value; + }; + return { + store, + async get(key: string, type?: unknown) { + const entry = live(key); + return entry ? decode(entry.value, type) : null; + }, + async getWithMetadata(key: string, type?: unknown) { + const entry = live(key); + return { value: entry ? decode(entry.value, type) : null, metadata: entry?.metadata ?? null }; + }, + async put(key: string, value: string, options: { expirationTtl?: number; expiration?: number; metadata?: unknown } = {}) { + const expiresAt = options.expirationTtl + ? Date.now() + options.expirationTtl * 1000 + : options.expiration + ? options.expiration * 1000 + : undefined; + store.set(key, { value: String(value), expiresAt, metadata: options.metadata }); + }, + async delete(key: string) { + store.delete(key); + }, + async list(options: { prefix?: string; limit?: number; cursor?: string } = {}) { + const keys = [...store.keys()].filter((k) => live(k) && k.startsWith(options.prefix ?? "")).sort(); + const start = options.cursor ? Number(options.cursor) : 0; + const limit = options.limit ?? 1000; + const page = keys.slice(start, start + limit); + const done = start + limit >= keys.length; + return { + keys: page.map((name) => { + const entry = store.get(name)!; + return { + name, + metadata: entry.metadata, + expiration: entry.expiresAt ? Math.floor(entry.expiresAt / 1000) : undefined, + }; + }), + list_complete: done, + cursor: done ? undefined : String(start + limit), + }; + }, + }; +} + +/** Durable Object namespace: one object per name, shared by everyone holding the namespace. */ +function fakeNamespace() { + const objects = new Map(); + return { + idFromName: (name: string) => name, + get: (id: string) => { + if (!objects.has(id)) objects.set(id, new SseSession()); + return { fetch: (input: string, init?: RequestInit) => objects.get(id)!.fetch(new Request(input, init)) }; + }, + }; +} + +export const PUBLIC_URL = "https://mcp.test"; + +export function createEnv(overrides: Partial> = {}) { + const db = new DatabaseSync(":memory:"); + db.exec("PRAGMA foreign_keys = ON"); + applyMigrations(db); + const stats: FakeD1Stats = { batches: [] }; + const kv = fakeKV(); + const env = { + DB: fakeD1(db, stats), + OAUTH_KV: kv, + SSE: fakeNamespace(), + PUBLIC_URL, + GITHUB_CLIENT_ID: "test-client", + GITHUB_CLIENT_SECRET: "test-client-secret", + JWT_SECRET: "test-secret-for-the-worker-tests", + ALLOWED_GITHUB_IDS: "1,2,3", + ...overrides, + } as unknown as Env; + return { env, db, kv, stats }; +} + +/** What the runtime hands `fetch` as its third argument. */ +export function fakeCtx(): ExecutionContext { + return { waitUntil() {}, passThroughOnException() {}, props: {} } as unknown as ExecutionContext; +} diff --git a/cloud/test/helpers.ts b/cloud/test/helpers.ts new file mode 100644 index 0000000..d1cbfd5 --- /dev/null +++ b/cloud/test/helpers.ts @@ -0,0 +1,102 @@ +import { expect, vi } from "vitest"; +import worker from "../src/index.js"; +import { fakeCtx, PUBLIC_URL } from "./fake-env.js"; + +export function call(env: unknown, path: string, init: RequestInit & { token?: string; base?: string } = {}) { + const { token, base, ...rest } = init; + const headers = new Headers(rest.headers); + if (token) headers.set("Authorization", `Bearer ${token}`); + return worker.fetch(new Request(`${base ?? PUBLIC_URL}${path}`, { ...rest, headers }), env as never, fakeCtx()); +} + +/** GitHub's side of both sign-in flows, answered as a user with this id. Records what was asked. */ +export function stubGitHub(id: number, login: string, options: { tokenError?: string; userStatus?: number } = {}) { + const seen: Array<{ url: string; body?: string }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const u = String(url instanceof Request ? url.url : url); + seen.push({ url: u, body: typeof init?.body === "string" ? init.body : undefined }); + if (u.includes("login/device/code")) { + return Response.json({ device_code: "dc", user_code: "ABCD-1234", verification_uri: "https://github.com/login/device", expires_in: 900, interval: 5 }); + } + if (u.includes("oauth/access_token")) { + return options.tokenError ? Response.json({ error: options.tokenError }) : Response.json({ access_token: "gh-token" }); + } + if (u.includes("api.github.com/user")) { + return options.userStatus ? new Response("nope", { status: options.userStatus }) : Response.json({ id, login, name: login }); + } + throw new Error(`unexpected fetch ${u}`); + }), + ); + return seen; +} + +/** Sign in through the CLI's device flow; returns the CLI token. */ +export async function login(env: unknown, id: number, name = `user${id}`): Promise { + stubGitHub(id, name); + const res = await call(env, "/auth/device/poll", { method: "POST", body: JSON.stringify({ device_code: "dc" }) }); + expect(res.status).toBe(200); + vi.unstubAllGlobals(); + return ((await res.json()) as { token: string }).token; +} + +export const hex = (n: number) => n.toString(16).padStart(16, "0"); + +export function message(i: number, over: Record = {}) { + return { + id: hex(i), + timestamp: `2026-10-01T00:00:${String(i % 60).padStart(2, "0")}.000Z`, + role: "user", + content: `message ${i}`, + messageIndex: i, + contentHash: `hash${i}`, + metadataJson: "{}", + ...over, + }; +} + +export function session(over: Record = {}, messages = [message(0)]) { + return { + tool: "claude-code", + sourceSessionId: "s1", + gitBranch: "main", + gitCommit: null, + startedAt: "2026-10-01T00:00:00.000Z", + lastActivityAt: "2026-10-01T00:01:00.000Z", + preview: "hello", + messages, + ...over, + }; +} + +export function upload(sessions: unknown[], over: Record = {}) { + return { + device: { id: "laptop", name: "Laptop" }, + project: { repoUrl: "github.com/a/b", name: "b" }, + sessions, + ...over, + }; +} + +export function post(env: unknown, token: string, body: unknown) { + return call(env, "/api/stream", { method: "POST", token, body: JSON.stringify(body) }); +} + +/** A legacy-era JSON-RPC request to /mcp. */ +export async function rpc(env: unknown, token: string, method: string, params?: Record) { + const res = await call(env, "/mcp", { + method: "POST", + token, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, ...(params ? { params } : {}) }), + }); + return { status: res.status, body: (await res.json()) as Record }; +} + +export async function callTool(env: unknown, token: string, name: string, args: Record = {}) { + const { body } = await rpc(env, token, "tools/call", { name, arguments: args }); + return body; +} + +export const toolText = (body: Record) => String(body.result?.content?.[0]?.text ?? ""); diff --git a/cloud/test/mcp.test.ts b/cloud/test/mcp.test.ts new file mode 100644 index 0000000..3606b99 --- /dev/null +++ b/cloud/test/mcp.test.ts @@ -0,0 +1,212 @@ +/** + * Streamable HTTP and JSON-RPC behaviour of POST /mcp, for both eras: + * legacy (initialize handshake, 2025-06-18 / 2025-11-25) and modern + * (per-request _meta and mirrored headers, 2026-07-28). + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createEnv } from "./fake-env.js"; +import { call, login } from "./helpers.js"; + +afterEach(() => vi.unstubAllGlobals()); + +const MODERN = "2026-07-28"; +const meta = { "io.modelcontextprotocol/protocolVersion": MODERN, "io.modelcontextprotocol/clientCapabilities": {} }; + +async function setup() { + const { env } = createEnv(); + return { env, token: await login(env, 1) }; +} + +function send(env: unknown, token: string, body: unknown, headers: Record = {}) { + return call(env, "/mcp", { + method: "POST", + token, + headers: { "Content-Type": "application/json", ...headers }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +function modern(env: unknown, token: string, method: string, params: Record = {}, headers: Record = {}) { + return send( + env, + token, + { jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: meta } }, + { "MCP-Protocol-Version": MODERN, "Mcp-Method": method, ...(params.name ? { "Mcp-Name": String(params.name) } : {}), ...headers }, + ); +} + +describe("legacy era", () => { + it("negotiates a supported version on initialize and answers ping with {}", async () => { + const { env, token } = await setup(); + const init = await send(env, token, { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {} } }); + expect(((await init.json()) as any).result.protocolVersion).toBe("2025-06-18"); + const old = await send(env, token, { jsonrpc: "2.0", id: 2, method: "initialize", params: { protocolVersion: "1999-01-01" } }); + expect(((await old.json()) as any).result.protocolVersion).toBe("2025-11-25"); + + const ping = await send(env, token, { jsonrpc: "2.0", id: 3, method: "ping" }, { "MCP-Protocol-Version": "2025-11-25" }); + expect(await ping.json()).toEqual({ jsonrpc: "2.0", id: 3, result: {} }); + }); + + it("answers every notification with 202 and no body", async () => { + const { env, token } = await setup(); + for (const method of ["notifications/initialized", "notifications/cancelled", "notifications/whatever"]) { + const res = await send(env, token, { jsonrpc: "2.0", method }); + expect(res.status, method).toBe(202); + expect(await res.text()).toBe(""); + } + }); + + it("answers an unknown method with -32601", async () => { + const { env, token } = await setup(); + const res = await send(env, token, { jsonrpc: "2.0", id: 1, method: "resources/list" }); + expect(((await res.json()) as any).error.code).toBe(-32601); + }); + + it("refuses an unsupported MCP-Protocol-Version header with 400", async () => { + const { env, token } = await setup(); + const res = await send(env, token, { jsonrpc: "2.0", id: 1, method: "tools/list" }, { "MCP-Protocol-Version": "2024-01-01" }); + expect(res.status).toBe(400); + }); +}); + +describe("malformed input", () => { + it("answers bad JSON with 400 and -32700", async () => { + const { env, token } = await setup(); + const res = await send(env, token, "{ not json"); + expect(res.status).toBe(400); + expect(((await res.json()) as any).error.code).toBe(-32700); + }); + + it("answers a missing method, a bad id or a non-2.0 message with 400 and -32600", async () => { + const { env, token } = await setup(); + for (const body of [{ jsonrpc: "2.0", id: 1 }, { jsonrpc: "2.0", id: null, method: "ping" }, { jsonrpc: "1.0", id: 1, method: "ping" }, 42]) { + const res = await send(env, token, body); + expect(res.status, JSON.stringify(body)).toBe(400); + expect(((await res.json()) as any).error.code).toBe(-32600); + } + }); + + it("rejects JSON-RPC batches with -32600", async () => { + const { env, token } = await setup(); + const res = await send(env, token, [{ jsonrpc: "2.0", id: 1, method: "ping" }]); + expect(res.status).toBe(400); + expect(((await res.json()) as any).error.code).toBe(-32600); + }); + + it("accepts a JSON-RPC response from the client with 202", async () => { + const { env, token } = await setup(); + expect((await send(env, token, { jsonrpc: "2.0", id: 5, result: {} })).status).toBe(202); + }); +}); + +describe("transport methods", () => { + it("answers GET and DELETE /mcp with 405", async () => { + const { env, token } = await setup(); + for (const method of ["GET", "DELETE"]) { + const res = await call(env, "/mcp", { method, token }); + expect(res.status, method).toBe(405); + expect(res.headers.get("Allow")).toBe("POST"); + } + }); +}); + +describe("modern era (2026-07-28)", () => { + it("implements server/discover", async () => { + const { env, token } = await setup(); + const res = await modern(env, token, "server/discover"); + expect(res.status).toBe(200); + const result = ((await res.json()) as any).result; + expect(result.resultType).toBe("complete"); + expect(result.supportedVersions).toEqual([MODERN]); + expect(result.capabilities).toHaveProperty("tools"); + expect(result._meta["io.modelcontextprotocol/serverInfo"].name).toBe("xtctx-cloud"); + expect(result.ttlMs).toBeGreaterThan(0); + expect(result.cacheScope).toBe("public"); + }); + + it("puts the required caching hints on tools/list", async () => { + // Claude Code 2.1.278 refused a modern tools/list without them. + const { env, token } = await setup(); + const result = ((await (await modern(env, token, "tools/list")).json()) as any).result; + expect(result.tools).toHaveLength(3); + expect(Number.isInteger(result.ttlMs) && result.ttlMs >= 0).toBe(true); + expect(result.cacheScope).toBe("public"); + }); + + it("calls tools statelessly with resultType on every result", async () => { + const { env, token } = await setup(); + const res = await modern(env, token, "tools/call", { name: "xtctx_cloud_status", arguments: {} }); + expect(res.status).toBe(200); + const result = ((await res.json()) as any).result; + expect(result.resultType).toBe("complete"); + expect(result.content[0].text).toContain("Nothing has been uploaded"); + const ping = await modern(env, token, "ping"); + expect(((await ping.json()) as any).result.resultType).toBe("complete"); + }); + + it("answers an unknown method with 404 and -32601", async () => { + const { env, token } = await setup(); + const res = await modern(env, token, "prompts/list"); + expect(res.status).toBe(404); + expect(((await res.json()) as any).error.code).toBe(-32601); + }); + + it("answers an unsupported version with 400 -32022 listing what is supported", async () => { + const { env, token } = await setup(); + const res = await send( + env, + token, + { jsonrpc: "2.0", id: 1, method: "ping", params: { _meta: { ...meta, "io.modelcontextprotocol/protocolVersion": "2099-01-01" } } }, + { "MCP-Protocol-Version": "2099-01-01", "Mcp-Method": "ping" }, + ); + expect(res.status).toBe(400); + const error = ((await res.json()) as any).error; + expect(error.code).toBe(-32022); + expect(error.data.supported).toContain(MODERN); + expect(error.data.requested).toBe("2099-01-01"); + }); + + it("refuses headers that disagree with the body with 400 -32020", async () => { + const { env, token } = await setup(); + const cases: Array> = [ + { "MCP-Protocol-Version": "2025-11-25" }, + { "Mcp-Method": "tools/list" }, + { "Mcp-Name": "xtctx_cloud_recent_sessions" }, + ]; + for (const headers of cases) { + const res = await modern(env, token, "tools/call", { name: "xtctx_cloud_status", arguments: {} }, headers); + expect(res.status, JSON.stringify(headers)).toBe(400); + expect(((await res.json()) as any).error.code).toBe(-32020); + } + }); + + it("decodes a base64 Mcp-Name before comparing it", async () => { + const { env, token } = await setup(); + const encoded = `=?base64?${btoa("xtctx_cloud_status")}?=`; + const res = await modern(env, token, "tools/call", { name: "xtctx_cloud_status", arguments: {} }, { "Mcp-Name": encoded }); + expect(res.status).toBe(200); + }); + + it("answers a request missing required _meta fields with 400 -32602", async () => { + const { env, token } = await setup(); + const res = await send( + env, + token, + { jsonrpc: "2.0", id: 1, method: "ping", params: { _meta: { "io.modelcontextprotocol/protocolVersion": MODERN } } }, + { "MCP-Protocol-Version": MODERN, "Mcp-Method": "ping" }, + ); + expect(res.status).toBe(400); + expect(((await res.json()) as any).error.code).toBe(-32602); + }); +}); + +describe("unauthenticated", () => { + it("gets a 401 whose challenge names the resource metadata and the read scope", async () => { + const { env } = createEnv(); + const res = await call(env, "/mcp", { method: "POST", body: "{}" }); + expect(res.status).toBe(401); + const challenge = res.headers.get("WWW-Authenticate") ?? ""; + expect(challenge).toContain('resource_metadata="https://mcp.test/.well-known/oauth-protected-resource/mcp"'); + expect(challenge).toContain('scope="read"'); + }); +}); diff --git a/cloud/test/migrations.test.ts b/cloud/test/migrations.test.ts new file mode 100644 index 0000000..913a4a9 --- /dev/null +++ b/cloud/test/migrations.test.ts @@ -0,0 +1,49 @@ +import { DatabaseSync } from "node:sqlite"; +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { applyMigrations } from "./fake-env.js"; + +const dir = fileURLToPath(new URL("../migrations/", import.meta.url)); +const columns = (db: DatabaseSync, table: string) => + (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map((c) => c.name); + +describe("migrations", () => { + it("bring a database created before tracked migrations forward without losing data or signing anyone out", () => { + const db = new DatabaseSync(":memory:"); + db.exec("PRAGMA foreign_keys = ON"); + // What production has: the old schema.sql (now the baseline), in use. + applyMigrations(db, 1); + db.exec(` + INSERT INTO users (id, username, created_at, updated_at, token_version) VALUES ('github:1', 'alice', 1, 1, 3); + INSERT INTO devices (id, user_id, device_name, last_seen_at, created_at) VALUES ('github:1:d', 'github:1', 'host', 1, 1); + INSERT INTO sessions (session_ref, user_id, device_id, tool, source_session_id, repo_url, project_root, + started_at, last_activity_at, message_count, source_path, status, updated_at) + VALUES ('github:1:claude-code:s', 'github:1', 'github:1:d', 'claude-code', 's', 'r', 'p', 't', 't', 1, '/home/alice/x.jsonl', 'active', 't'); + INSERT INTO messages (id, session_ref, tool, source_session_id, timestamp, role, content, message_index, + content_hash, metadata_json, source_pointer, indexed_at) + VALUES ('m', 'github:1:claude-code:s', 'claude-code', 's', 't', 'user', 'hello', 0, 'h', '{}', '/home/alice/x.jsonl', 't'); + `); + + // The rest, in order, the way `wrangler d1 migrations apply` runs them; + // the baseline again too, which must be a no-op on a database that has it. + const files = readdirSync(dir).filter((f) => f.endsWith(".sql")).sort(); + db.exec(readFileSync(dir + files[0], "utf8")); + for (const file of files.slice(1)) db.exec(readFileSync(dir + file, "utf8")); + + expect(db.prepare("SELECT epoch FROM token_epochs WHERE user_id = 'github:1'").get()).toEqual({ epoch: 3 }); + expect(db.prepare("SELECT content FROM messages").get()).toEqual({ content: "hello" }); + expect(db.prepare("SELECT message_count FROM sessions").get()).toEqual({ message_count: 1 }); + expect(columns(db, "users")).not.toContain("token_version"); + expect(columns(db, "sessions")).not.toContain("source_path"); + expect(columns(db, "sessions")).not.toContain("status"); + expect(columns(db, "messages")).not.toContain("source_pointer"); + }); + + it("create the full schema on an empty database", () => { + const db = new DatabaseSync(":memory:"); + applyMigrations(db); + const tables = (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as Array<{ name: string }>).map((t) => t.name); + expect(tables).toEqual(expect.arrayContaining(["users", "devices", "sessions", "messages", "token_epochs", "uploads"])); + }); +}); diff --git a/cloud/test/oauth.test.ts b/cloud/test/oauth.test.ts new file mode 100644 index 0000000..82e9b2a --- /dev/null +++ b/cloud/test/oauth.test.ts @@ -0,0 +1,339 @@ +/** + * The browser sign-in MCP clients use, end to end against a stubbed GitHub: + * discovery, client registration, /authorize with consent, GitHub's callback, + * the token endpoint, and the resulting token at /mcp. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createEnv, PUBLIC_URL } from "./fake-env.js"; +import { call, login, stubGitHub } from "./helpers.js"; + +afterEach(() => vi.unstubAllGlobals()); + +const REDIRECT = "http://127.0.0.1:33418/callback"; +const RESOURCE = `${PUBLIC_URL}/mcp`; + +/** Cookies set by one response, as a Cookie header for the next. */ +class Jar { + private cookies = new Map(); + take(res: Response) { + for (const line of res.headers.getSetCookie()) { + const [pair] = line.split(";"); + const eq = pair.indexOf("="); + this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); + } + return res; + } + header() { + return [...this.cookies].map(([k, v]) => `${k}=${v}`).join("; "); + } +} + +function b64url(bytes: ArrayBuffer | Uint8Array) { + return Buffer.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)).toString("base64url"); +} + +async function pkce() { + const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); + const challenge = b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))); + return { verifier, challenge }; +} + +async function register(env: unknown) { + const res = await call(env, "/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Test Agent", + redirect_uris: [REDIRECT], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + }), + }); + expect(res.status).toBe(201); + return ((await res.json()) as { client_id: string }).client_id; +} + +/** + * Walk the browser through /authorize, consent and GitHub, as the GitHub user + * `githubId`. Returns where the browser lands back at the client. + */ +async function authorize(env: unknown, clientId: string, githubId: number, options: { challenge?: string; method?: string; decision?: string } = {}) { + const jar = new Jar(); + const { challenge } = options.challenge ? { challenge: options.challenge } : await pkce(); + const params = new URLSearchParams({ + response_type: "code", + client_id: clientId, + redirect_uri: REDIRECT, + state: "client-state", + scope: "read", + resource: RESOURCE, + code_challenge: challenge, + code_challenge_method: options.method ?? "S256", + }); + const page = jar.take(await call(env, `/authorize?${params}`)); + if (page.status !== 200) return { landing: page.headers.get("Location"), page }; + const html = await page.text(); + const handle = /name="handle" value="([^"]+)"/.exec(html)![1]; + + const form = new URLSearchParams({ handle, decision: options.decision ?? "approve" }); + const approved = jar.take( + await call(env, "/authorize", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Cookie: jar.header() }, + body: form.toString(), + }), + ); + expect(approved.status).toBe(302); + const toGithub = new URL(approved.headers.get("Location")!); + if (toGithub.origin !== "https://github.com") return { landing: toGithub.toString(), html }; + + const seen = stubGitHub(githubId, `user${githubId}`); + const back = await call(env, `/oauth/github/callback?code=gh-code&state=${toGithub.searchParams.get("state")}`, { + headers: { Cookie: jar.header() }, + }); + vi.unstubAllGlobals(); + expect(back.status).toBe(302); + return { landing: back.headers.get("Location")!, html, toGithub, seen }; +} + +async function exchange(env: unknown, form: Record) { + const res = await call(env, "/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(form).toString(), + }); + return { status: res.status, body: (await res.json()) as Record }; +} + +async function signIn(env: unknown, githubId = 1) { + const clientId = await register(env); + const { verifier, challenge } = await pkce(); + const { landing } = await authorize(env, clientId, githubId, { challenge }); + const code = new URL(landing!).searchParams.get("code")!; + const tokens = await exchange(env, { + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT, + client_id: clientId, + code_verifier: verifier, + resource: RESOURCE, + }); + expect(tokens.status).toBe(200); + return { clientId, ...tokens.body } as { clientId: string; access_token: string; refresh_token: string; scope: string; expires_in: number }; +} + +const statusCall = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "xtctx_cloud_status", arguments: {} } }); + +describe("discovery", () => { + it("serves protected-resource metadata at the /mcp and root forms, unauthenticated, naming the MCP URL", async () => { + const { env } = createEnv(); + for (const path of ["/.well-known/oauth-protected-resource/mcp", "/.well-known/oauth-protected-resource"]) { + const res = await call(env, path); + expect(res.status, path).toBe(200); + const doc = (await res.json()) as Record; + expect(doc.resource).toBe(RESOURCE); + expect(doc.authorization_servers).toEqual([PUBLIC_URL]); + expect(doc.scopes_supported).toEqual(["read"]); + } + }); + + it("serves authorization-server metadata with PKCE S256, CIMD and registration", async () => { + const { env } = createEnv(); + const res = await call(env, "/.well-known/oauth-authorization-server"); + expect(res.status).toBe(200); + const doc = (await res.json()) as Record; + expect(doc.issuer).toBe(PUBLIC_URL); + expect(doc.authorization_endpoint).toBe(`${PUBLIC_URL}/authorize`); + expect(doc.token_endpoint).toBe(`${PUBLIC_URL}/oauth/token`); + expect(doc.registration_endpoint).toBe(`${PUBLIC_URL}/oauth/register`); + expect(doc.code_challenge_methods_supported).toEqual(["S256"]); + expect(doc.scopes_supported).toEqual(["read"]); + expect(doc.authorization_response_iss_parameter_supported).toBe(true); + }); +}); + +describe("authorize", () => { + it("shows a consent page naming the client and where access goes, and sends the browser to GitHub with PKCE", async () => { + const { env } = createEnv(); + const clientId = await register(env); + const jar = new Jar(); + const { challenge } = await pkce(); + const page = jar.take( + await call(env, `/authorize?${new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: REDIRECT, state: "s", resource: RESOURCE, code_challenge: challenge, code_challenge_method: "S256" })}`), + ); + expect(page.status).toBe(200); + expect(page.headers.get("X-Frame-Options")).toBe("DENY"); + const html = await page.text(); + expect(html).toContain("Allow Test Agent to read your xtctx cloud?"); + expect(html).toContain("127.0.0.1"); + expect(html).toContain("app on your computer"); + + const handle = /name="handle" value="([^"]+)"/.exec(html)![1]; + const res = await call(env, "/authorize", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Cookie: jar.header() }, + body: new URLSearchParams({ handle, decision: "approve" }).toString(), + }); + const github = new URL(res.headers.get("Location")!); + expect(github.origin + github.pathname).toBe("https://github.com/login/oauth/authorize"); + expect(github.searchParams.get("client_id")).toBe("test-client"); + expect(github.searchParams.get("redirect_uri")).toBe(`${PUBLIC_URL}/oauth/github/callback`); + expect(github.searchParams.get("code_challenge_method")).toBe("S256"); + expect(github.searchParams.get("state")).toBeTruthy(); + }); + + it("escapes a hostile client name", async () => { + const { env } = createEnv(); + const res = await call(env, "/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_name: "", redirect_uris: [REDIRECT], token_endpoint_auth_method: "none" }), + }); + const clientId = ((await res.json()) as { client_id: string }).client_id; + const { challenge } = await pkce(); + const page = await call(env, `/authorize?${new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: REDIRECT, state: "s", code_challenge: challenge, code_challenge_method: "S256" })}`); + const html = await page.text(); + expect(html).not.toContain(""); + expect(html).toContain("<script>"); + }); + + it("requires PKCE with S256, even from a client that registered with a secret", async () => { + const { env } = createEnv(); + const res = await call(env, "/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_name: "Confidential", redirect_uris: [REDIRECT], token_endpoint_auth_method: "client_secret_post" }), + }); + const clientId = ((await res.json()) as { client_id: string }).client_id; + const page = await call(env, `/authorize?${new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: REDIRECT, state: "s" })}`); + expect(page.status).toBe(302); + const back = new URL(page.headers.get("Location")!); + expect(back.searchParams.get("error")).toBe("invalid_request"); + expect(back.searchParams.get("state")).toBe("s"); + }); + + it("sends a Deny back to the client as access_denied", async () => { + const { env } = createEnv(); + const clientId = await register(env); + const { landing } = await authorize(env, clientId, 1, { decision: "deny" }); + const back = new URL(landing!); + expect(back.searchParams.get("error")).toBe("access_denied"); + expect(back.searchParams.get("iss")).toBe(PUBLIC_URL); + }); + + it("refuses a GitHub account that is not on the allowlist, without creating it", async () => { + const { env, db } = createEnv({ ALLOWED_GITHUB_IDS: "1" }); + const clientId = await register(env); + const { landing } = await authorize(env, clientId, 99); + expect(new URL(landing!).searchParams.get("error")).toBe("access_denied"); + expect(db.prepare("SELECT count(*) n FROM users").get()).toEqual({ n: 0 }); + }); + + it("answers 503 when browser sign-in is not configured", async () => { + const { env } = createEnv({ GITHUB_CLIENT_SECRET: undefined }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + expect((await call(env, "/authorize?client_id=x")).status).toBe(503); + }); +}); + +describe("tokens from the authorization flow", () => { + it("are short-lived, scoped read, bound to /mcp, and work there", async () => { + const { env } = createEnv(); + const tokens = await signIn(env); + expect(tokens.scope).toBe("read"); + expect(tokens.expires_in).toBeLessThanOrEqual(3600); + expect(tokens.refresh_token).toBeTruthy(); + + const res = await call(env, "/mcp", { method: "POST", token: tokens.access_token, body: statusCall }); + expect(res.status).toBe(200); + expect(((await res.json()) as any).result.content[0].text).toContain("Nothing has been uploaded"); + }); + + it("cannot upload, delete or mint tokens: those need the CLI's own login", async () => { + const { env } = createEnv(); + const { access_token } = await signIn(env); + expect((await call(env, "/api/stream", { method: "POST", token: access_token, body: "{}" })).status).toBe(401); + expect((await call(env, "/api/me", { method: "DELETE", token: access_token })).status).toBe(401); + expect((await call(env, "/api/tokens", { method: "POST", token: access_token })).status).toBe(401); + }); + + it("are not accepted at a different resource", async () => { + const { env } = createEnv(); + const { access_token } = await signIn(env); + const elsewhere = await call(env, "/mcp", { method: "POST", token: access_token, body: statusCall, base: "https://sync.test" }); + expect(elsewhere.status).toBe(401); + }); + + it("refresh, and stop working everywhere after the CLI logs out", async () => { + const { env } = createEnv(); + const tokens = await signIn(env); + const refreshed = await exchange(env, { grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: tokens.clientId }); + expect(refreshed.status).toBe(200); + + const cli = await login(env, 1); + expect((await call(env, "/auth/logout", { method: "POST", token: cli })).status).toBe(200); + + expect((await call(env, "/mcp", { method: "POST", token: refreshed.body.access_token, body: statusCall })).status).toBe(401); + const again = await exchange(env, { grant_type: "refresh_token", refresh_token: refreshed.body.refresh_token, client_id: tokens.clientId }); + expect(again.status).toBe(400); + expect(again.body.error).toBe("invalid_grant"); + }); + + it("stop working after delete-my-data, even if the grant survived in KV", async () => { + const { env, kv } = createEnv(); + const tokens = await signIn(env); + const snapshot = new Map(kv.store); + const cli = await login(env, 1); + expect((await call(env, "/api/me", { method: "DELETE", token: cli })).status).toBe(200); + // Put the grant and token records back, as if KV revocation had failed. + for (const [k, v] of snapshot) kv.store.set(k, v); + await login(env, 1); // the account exists again, with a newer epoch + expect((await call(env, "/mcp", { method: "POST", token: tokens.access_token, body: statusCall })).status).toBe(401); + }); + + it("refuse a code exchanged without the right PKCE verifier", async () => { + const { env } = createEnv(); + const clientId = await register(env); + const { challenge } = await pkce(); + const { landing } = await authorize(env, clientId, 1, { challenge }); + const code = new URL(landing!).searchParams.get("code")!; + const res = await exchange(env, { grant_type: "authorization_code", code, redirect_uri: REDIRECT, client_id: clientId, code_verifier: "wrong".repeat(10), resource: RESOURCE }); + expect(res.status).toBe(400); + }); +}); + +describe("Client ID Metadata Documents", () => { + it("is advertised when the runtime has global_fetch_strictly_public", async () => { + vi.stubGlobal("Cloudflare", { compatibilityFlags: { global_fetch_strictly_public: true } }); + const { env } = createEnv(); + const doc = (await (await call(env, "/.well-known/oauth-authorization-server")).json()) as Record; + expect(doc.client_id_metadata_document_supported).toBe(true); + }); + + it("accepts a client identified by an HTTPS metadata URL and shows its domain", async () => { + // What wrangler.toml's compatibility flag gives the Worker in production. + vi.stubGlobal("Cloudflare", { compatibilityFlags: { global_fetch_strictly_public: true } }); + const { env } = createEnv(); + const clientId = "https://client.example/oauth/metadata.json"; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string | URL | Request) => { + const u = String(url instanceof Request ? url.url : url); + if (u === clientId) { + return Response.json( + { client_id: clientId, client_name: "Example Agent", redirect_uris: [REDIRECT], token_endpoint_auth_method: "none" }, + { headers: { "Cache-Control": "max-age=60" } }, + ); + } + throw new Error(`unexpected fetch ${u}`); + }), + ); + const { challenge } = await pkce(); + const page = await call(env, `/authorize?${new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: REDIRECT, state: "s", resource: RESOURCE, code_challenge: challenge, code_challenge_method: "S256" })}`); + expect(page.status).toBe(200); + const html = await page.text(); + expect(html).toContain("Example Agent"); + expect(html).toContain("Published by client.example"); + }); +}); diff --git a/cloud/test/worker.test.ts b/cloud/test/worker.test.ts new file mode 100644 index 0000000..78b6423 --- /dev/null +++ b/cloud/test/worker.test.ts @@ -0,0 +1,510 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createJwt, mintToken } from "../src/auth.js"; +import { LIMITS } from "../src/db.js"; +import { SERVER_VERSION } from "../src/version.js"; +import { createEnv } from "./fake-env.js"; +import { call, callTool, login, message, post, rpc, session, stubGitHub, toolText, upload } from "./helpers.js"; + +const OLD_DEFAULT_SECRET = "xtctx-cloud-default-secret-change-in-production"; +const listTools = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("without a signing secret", () => { + it("refuses everything but the health check, even for a token signed with the old built-in default", async () => { + const { env } = createEnv({ JWT_SECRET: undefined }); + const forged = await createJwt({ sub: "github:1", username: "x", ver: 0 }, OLD_DEFAULT_SECRET); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + for (const path of ["/api/stream", "/mcp", "/sse", "/auth/device/code", "/authorize"]) { + const res = + path === "/sse" || path === "/authorize" + ? await call(env, path, { token: forged }) + : await call(env, path, { method: "POST", token: forged, body: "{}" }); + expect(res.status, path).toBe(500); + expect(await res.json()).toEqual({ error: "server_misconfigured" }); + } + expect((await call(env, "/health")).status).toBe(200); + }); +}); + +describe("health", () => { + it("reports the package version, in the body and a header on every response", async () => { + const { env } = createEnv(); + const res = await call(env, "/health"); + expect(((await res.json()) as { version: string }).version).toBe(SERVER_VERSION); + expect(res.headers.get("X-Xtctx-Server-Version")).toBe(SERVER_VERSION); + expect((await call(env, "/nowhere")).headers.get("X-Xtctx-Server-Version")).toBe(SERVER_VERSION); + }); +}); + +describe("sign-in allowlist", () => { + it("refuses a GitHub account that is not listed, before writing anything for it", async () => { + const { env, db } = createEnv({ ALLOWED_GITHUB_IDS: "1" }); + stubGitHub(99, "mallory"); + const res = await call(env, "/auth/device/poll", { method: "POST", body: JSON.stringify({ device_code: "dc" }) }); + expect(res.status).toBe(403); + expect(((await res.json()) as { error: string }).error).toBe("not_allowed"); + expect(db.prepare("SELECT count(*) n FROM users").get()).toEqual({ n: 0 }); + }); + + it("lets nobody in when the list is empty or unset", async () => { + for (const value of ["", undefined, " , ,abc"]) { + const { env } = createEnv({ ALLOWED_GITHUB_IDS: value }); + stubGitHub(1, "alice"); + const res = await call(env, "/auth/device/poll", { method: "POST", body: JSON.stringify({ device_code: "dc" }) }); + expect(res.status, String(value)).toBe(403); + } + }); + + it("stops a signed-in account's access once it is taken off the list", async () => { + const { env } = createEnv({ ALLOWED_GITHUB_IDS: "1" }); + const token = await login(env, 1); + expect((await call(env, "/mcp", { method: "POST", token, body: listTools })).status).toBe(200); + (env as { ALLOWED_GITHUB_IDS: string }).ALLOWED_GITHUB_IDS = "2"; + expect((await call(env, "/mcp", { method: "POST", token, body: listTools })).status).toBe(401); + }); +}); + +describe("/auth/device/poll error paths", () => { + const poll = (env: unknown, body: string) => call(env, "/auth/device/poll", { method: "POST", body }); + + it("answers 400 to a body that is not JSON or has no device code", async () => { + const { env } = createEnv(); + stubGitHub(1, "alice"); + expect((await poll(env, "{ not json")).status).toBe(400); + expect((await poll(env, "{}")).status).toBe(400); + expect((await poll(env, JSON.stringify({ device_code: 7 }))).status).toBe(400); + }); + + it("passes GitHub's pending and slow-down answers through for the client to act on", async () => { + const { env } = createEnv(); + for (const error of ["authorization_pending", "slow_down", "expired_token", "access_denied"]) { + stubGitHub(1, "alice", { tokenError: error }); + const res = await poll(env, JSON.stringify({ device_code: "dc" })); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBe(error); + } + }); + + it("answers 502 when GitHub will not say who the user is", async () => { + const { env, db } = createEnv(); + stubGitHub(1, "alice", { userStatus: 500 }); + expect((await poll(env, JSON.stringify({ device_code: "dc" }))).status).toBe(502); + expect(db.prepare("SELECT count(*) n FROM users").get()).toEqual({ n: 0 }); + }); + + it("answers 502 when GitHub cannot be reached", async () => { + const { env } = createEnv(); + vi.stubGlobal("fetch", vi.fn(async () => { throw new TypeError("network down"); })); + expect((await poll(env, JSON.stringify({ device_code: "dc" }))).status).toBe(502); + }); +}); + +describe("tokens", () => { + it("does not take a token from the query string", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + expect((await call(env, "/mcp", { method: "POST", token, body: listTools })).status).toBe(200); + expect((await call(env, `/mcp?token=${token}`, { method: "POST", body: listTools })).status).toBe(401); + }); + + it("refuses an expired token", async () => { + const { env } = createEnv(); + await login(env, 1); + const expired = await mintToken(env, { userId: "github:1", username: "u", epoch: 0, scopes: ["read", "sync:write"] }, -1); + expect((await call(env, "/mcp", { method: "POST", token: expired, body: listTools })).status).toBe(401); + expect((await post(env, expired, upload([session()]))).status).toBe(401); + }); + + it("refuses a token from before scopes existed", async () => { + const { env } = createEnv(); + await login(env, 1); + const legacy = await createJwt({ sub: "github:1", username: "u", ver: 0 }, "test-secret-for-the-worker-tests"); + expect((await call(env, "/mcp", { method: "POST", token: legacy, body: listTools })).status).toBe(401); + }); + + it("stops accepting a token after logout, and a fresh login works", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + expect((await call(env, "/auth/logout", { method: "POST", token })).status).toBe(200); + expect((await call(env, "/mcp", { method: "POST", token, body: listTools })).status).toBe(401); + const fresh = await login(env, 1); + expect((await call(env, "/mcp", { method: "POST", token: fresh, body: listTools })).status).toBe(200); + }); + + it("a pasted read-only token reads but cannot upload, delete or mint more", async () => { + const { env } = createEnv(); + const cli = await login(env, 1); + const res = await call(env, "/api/tokens", { method: "POST", token: cli }); + expect(res.status).toBe(200); + const { token: read, scope } = (await res.json()) as { token: string; scope: string }; + expect(scope).toBe("read"); + + expect((await call(env, "/mcp", { method: "POST", token: read, body: listTools })).status).toBe(200); + expect((await post(env, read, upload([session()]))).status).toBe(401); + expect((await call(env, "/api/me", { method: "DELETE", token: read })).status).toBe(401); + expect((await call(env, "/api/tokens", { method: "POST", token: read })).status).toBe(401); + + // And logout ends it too. + await call(env, "/auth/logout", { method: "POST", token: cli }); + expect((await call(env, "/mcp", { method: "POST", token: read, body: listTools })).status).toBe(401); + }); + + it("answers a missing scope with 403 insufficient_scope", async () => { + const { env } = createEnv(); + await login(env, 1); + const noDelete = await mintToken(env, { userId: "github:1", username: "u", epoch: 0, scopes: ["read", "sync:write"] }, 60); + const res = await call(env, "/api/me", { method: "DELETE", token: noDelete }); + expect(res.status).toBe(403); + expect(res.headers.get("WWW-Authenticate")).toContain('error="insufficient_scope"'); + }); +}); + +describe("delete my data", () => { + it("removes the caller's rows and tokens and leaves other users alone", async () => { + const { env, db } = createEnv(); + const alice = await login(env, 1); + const bob = await login(env, 2); + for (const token of [alice, bob]) expect((await post(env, token, upload([session()]))).status).toBe(200); + const owned = (table: string, user: string) => + (db.prepare(`SELECT count(*) n FROM ${table} WHERE user_id = ?`).get(user) as { n: number }).n; + const messages = (user: string) => + (db.prepare("SELECT count(*) n FROM messages WHERE session_ref LIKE ?").get(`${user}:%`) as { n: number }).n; + + expect((await call(env, "/api/me", { method: "DELETE", token: alice })).status).toBe(200); + + expect(messages("github:1")).toBe(0); + for (const table of ["sessions", "devices", "uploads"]) expect(owned(table, "github:1"), table).toBe(0); + expect(db.prepare("SELECT count(*) n FROM users WHERE id = 'github:1'").get()).toEqual({ n: 0 }); + expect(messages("github:2")).toBe(1); + expect((await call(env, "/mcp", { method: "POST", token: alice, body: listTools })).status).toBe(401); + }); + + it("does not revive a token from before the deletion when the same account signs in again", async () => { + const { env } = createEnv(); + const old = await login(env, 1); + expect((await call(env, "/api/me", { method: "DELETE", token: old })).status).toBe(200); + + const fresh = await login(env, 1); + + expect((await call(env, "/mcp", { method: "POST", token: old, body: listTools })).status).toBe(401); + expect((await post(env, old, upload([session()]))).status).toBe(401); + expect((await call(env, "/mcp", { method: "POST", token: fresh, body: listTools })).status).toBe(200); + }); +}); + +describe("ingest", () => { + it("answers 400 to a malformed body, 426 to the old format, and 200 to a good one", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + expect((await post(env, token, upload([session({}, [message(0, { role: "wizard" })])]))).status).toBe(400); + expect((await post(env, token, upload([session({}, [message(0, { messageIndex: "0" })])]))).status).toBe(400); + expect((await post(env, token, upload([session({}, [message(0, { id: "not-hex" })])]))).status).toBe(400); + expect((await post(env, token, upload([session({ tool: "Bad Tool" })]))).status).toBe(400); + expect((await post(env, token, { sessions: [] })).status).toBe(400); + expect((await call(env, "/api/stream", { method: "POST", token, body: "{ nope" })).status).toBe(400); + expect((await post(env, token, [{ deviceId: "x" }])).status).toBe(426); + expect((await post(env, token, upload([session()]))).status).toBe(200); + }); + + it("rejects a body over the byte limit with 413, without reading it all", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + const huge = "x".repeat(LIMITS.maxBodyBytes + 1); + const res = await call(env, "/api/stream", { method: "POST", token, body: huge }); + expect(res.status).toBe(413); + expect(((await res.json()) as { error: string }).error).toBe("body_too_large"); + }); + + it("rejects an oversized message with 413 naming it, and writes nothing from that request", async () => { + const { env, db } = createEnv(); + const token = await login(env, 1); + const big = message(1, { content: "é".repeat(LIMITS.maxMessageBytes / 2 + 1) }); // over the limit in bytes, not chars + const res = await post(env, token, upload([session({}, [message(0), big])])); + expect(res.status).toBe(413); + expect(await res.json()).toMatchObject({ error: "message_too_large", messageId: big.id, sourceSessionId: "s1" }); + expect(db.prepare("SELECT count(*) n FROM messages").get()).toEqual({ n: 0 }); + }); + + it("refuses more sessions or messages per request than one batch may hold", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + const sessions = Array.from({ length: LIMITS.maxSessionsPerRequest + 1 }, (_, i) => session({ sourceSessionId: `s${i}` })); + expect((await post(env, token, upload(sessions))).status).toBe(400); + const many = Array.from({ length: LIMITS.maxMessagesPerRequest + 1 }, (_, i) => message(i)); + expect((await post(env, token, upload([session({}, many)]))).status).toBe(400); + }); + + it("writes a whole request as one batch inside D1's per-invocation query limit", async () => { + const { env, stats } = createEnv(); + const token = await login(env, 1); + stats.batches.length = 0; + const sessions = Array.from({ length: LIMITS.maxSessionsPerRequest }, (_, i) => + session({ sourceSessionId: `s${i}`, keepIds: [] }, Array.from({ length: 50 }, (_, j) => message(j))), + ); + expect((await post(env, token, upload(sessions))).status).toBe(200); + expect(stats.batches).toHaveLength(1); + // Free plan: 50 queries per invocation. Plus auth (1) and the recount read (1). + expect(stats.batches[0] + 2).toBeLessThanOrEqual(50); + }); + + it("is idempotent: replaying an upload does not inflate counts or duplicate rows", async () => { + const { env, db } = createEnv(); + const token = await login(env, 1); + const body = upload([session({}, [message(0), message(1), message(2)])]); + for (let i = 0; i < 3; i++) { + const res = await post(env, token, body); + expect(((await res.json()) as { sessions: Array<{ messageCount: number }> }).sessions[0].messageCount).toBe(3); + } + expect(db.prepare("SELECT message_count n FROM sessions").get()).toEqual({ n: 3 }); + expect(db.prepare("SELECT count(*) n FROM messages").get()).toEqual({ n: 3 }); + }); + + it("makes the cloud copy match the local one when keepIds is sent", async () => { + const { env, db } = createEnv(); + const token = await login(env, 1); + await post(env, token, upload([session({}, [message(0), message(1), message(2)])])); + // A local re-read replaced message 1 (new id) and dropped message 2. + const replaced = message(9, { messageIndex: 1, content: "message 1, edited" }); + const res = await post(env, token, upload([session({ keepIds: [message(0).id, replaced.id] }, [replaced])])); + expect(((await res.json()) as { sessions: Array<{ messageCount: number }> }).sessions[0].messageCount).toBe(2); + const contents = db.prepare("SELECT content FROM messages ORDER BY message_index").all().map((r) => (r as { content: string }).content); + expect(contents).toEqual(["message 0", "message 1, edited"]); + expect(db.prepare("SELECT message_count n FROM sessions").get()).toEqual({ n: 2 }); + }); + + it("keeps the earliest start and latest activity, and updates branch and device", async () => { + const { env, db } = createEnv(); + const token = await login(env, 1); + await post(env, token, upload([session({ startedAt: "2026-10-01T00:00:10.000Z", lastActivityAt: "2026-10-01T00:05:00.000Z" })])); + await post( + env, + token, + upload([session({ startedAt: "2026-10-01T00:00:00.000Z", lastActivityAt: "2026-10-01T00:01:00.000Z", gitBranch: "feat" })], { + device: { id: "desktop", name: "Desktop" }, + }), + ); + expect(db.prepare("SELECT started_at, last_activity_at, git_branch, device_id FROM sessions").get()).toEqual({ + started_at: "2026-10-01T00:00:00.000Z", + last_activity_at: "2026-10-01T00:05:00.000Z", + git_branch: "feat", + device_id: "github:1:desktop", + }); + }); + + it("keeps two users who pick the same device id apart", async () => { + const { env, db } = createEnv(); + for (const token of [await login(env, 1), await login(env, 2)]) await post(env, token, upload([session()])); + expect(db.prepare("SELECT id, user_id FROM devices ORDER BY user_id").all()).toEqual([ + { id: "github:1:laptop", user_id: "github:1" }, + { id: "github:2:laptop", user_id: "github:2" }, + ]); + }); + + it("logs a failed write with sizes and counts but no content", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); + (env.DB as unknown as { batch: () => Promise }).batch = async () => { + throw new Error("D1_ERROR: boom"); + }; + const res = await post(env, token, upload([session({}, [message(0, { content: "very secret words" })])])); + expect(res.status).toBe(500); + const logged = String(spy.mock.calls[0][0]); + expect(JSON.parse(logged)).toMatchObject({ event: "ingest_failed", userId: "github:1", sessions: 1, messages: 1 }); + expect(logged).not.toContain("very secret words"); + }); +}); + +describe("tool calls are isolated between tenants", () => { + it("another user's session_ref is not found, and their sessions are never listed", async () => { + const { env } = createEnv(); + const alice = await login(env, 1); + const bob = await login(env, 2); + await post(env, alice, upload([session({}, [message(0, { content: "alice's secret" })])])); + + const detail = await callTool(env, bob, "xtctx_cloud_session_detail", { session_ref: "github:1:claude-code:s1" }); + expect(detail.result.isError).toBe(true); + expect(JSON.stringify(detail)).not.toContain("alice's secret"); + + const recent = await callTool(env, bob, "xtctx_cloud_recent_sessions", { format: "json" }); + expect(JSON.parse(toolText(recent))).toEqual([]); + const status = await callTool(env, bob, "xtctx_cloud_status", { format: "json" }); + expect(JSON.parse(toolText(status))).toEqual([]); + + // And alice does see hers. + const own = await callTool(env, alice, "xtctx_cloud_session_detail", { session_ref: "github:1:claude-code:s1" }); + expect(toolText(own)).toContain("alice's secret"); + }); +}); + +describe("errors", () => { + it("returns a generic 500 with no message or stack", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); + (env.DB as unknown as { prepare: () => never }).prepare = () => { + throw new Error("no such table: secret_table"); + }; + const res = await call(env, "/mcp", { method: "POST", token, body: listTools }); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: "internal_error" }); + expect(spy).toHaveBeenCalled(); + }); +}); + +describe("CORS", () => { + it("sends no CORS headers by default, and only to a listed origin when configured", async () => { + const plain = createEnv().env; + const res = await call(plain, "/nowhere", { headers: { Origin: "https://evil.example" } }); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + + const configured = createEnv({ ALLOWED_ORIGINS: "https://app.example" }).env; + const ok = await call(configured, "/nowhere", { headers: { Origin: "https://app.example" } }); + expect(ok.headers.get("Access-Control-Allow-Origin")).toBe("https://app.example"); + const other = await call(configured, "/nowhere", { headers: { Origin: "https://evil.example" } }); + expect(other.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); + + it("refuses /mcp from a foreign browser origin", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + const res = await call(env, "/mcp", { method: "POST", token, body: listTools, headers: { Origin: "https://evil.example" } }); + expect(res.status).toBe(403); + }); +}); + +describe("SSE transport", () => { + async function openStream(env: unknown, token: string) { + const res = await call(env, "/sse", { token }); + expect(res.status).toBe(200); + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + const next = async () => decoder.decode((await reader.read()).value); + const endpoint = /data: (\S+)/.exec(await next())![1]; + return { endpoint: new URL(endpoint), next, reader }; + } + + it("delivers a message posted through a separately loaded copy of the Worker", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + const { endpoint, next, reader } = await openStream(env, token); + + // Another isolate: a fresh module instance, sharing only the bindings. + vi.resetModules(); + const other = (await import("../src/index.js")).default; + const { fakeCtx } = await import("./fake-env.js"); + const res = await other.fetch( + new Request(`https://mcp.test${endpoint.pathname}${endpoint.search}`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ jsonrpc: "2.0", id: 7, method: "tools/list" }), + }), + env as never, + fakeCtx(), + ); + expect(res.status).toBe(202); + + const event = await next(); + expect(event).toContain("event: message"); + expect(JSON.parse(/data: (.*)/.exec(event)![1]).id).toBe(7); + await reader.cancel(); + }); + + it("will not let another user write into someone's stream", async () => { + const { env } = createEnv(); + const alice = await login(env, 1); + const bob = await login(env, 2); + const { endpoint, reader } = await openStream(env, alice); + const res = await call(env, `${endpoint.pathname}${endpoint.search}`, { method: "POST", token: bob, body: listTools }); + expect(res.status).toBe(404); + await reader.cancel(); + }); + + it("answers an unauthenticated stream with a challenge naming the resource metadata", async () => { + const { env } = createEnv(); + const res = await call(env, "/sse"); + expect(res.status).toBe(401); + expect(res.headers.get("WWW-Authenticate")).toContain("resource_metadata="); + }); +}); + +describe("tool results", () => { + it("recent sessions name the device, and session detail pages in conversation order", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + // Out of order on purpose: same timestamp for 1 and 2, index decides. + const msgs = [ + message(2, { timestamp: "2026-10-01T00:00:05.000Z" }), + message(0, { timestamp: "2026-10-01T00:00:01.000Z" }), + message(1, { timestamp: "2026-10-01T00:00:05.000Z" }), + ]; + await post(env, token, upload([session({}, msgs)])); + + const recent = toolText(await callTool(env, token, "xtctx_cloud_recent_sessions")); + expect(recent).toContain("Laptop"); + + const page = JSON.parse( + toolText(await callTool(env, token, "xtctx_cloud_session_detail", { session_ref: "github:1:claude-code:s1", format: "json" })), + ); + expect(page.session.device).toBe("Laptop"); + expect(page.messages.map((m: { message_index: number }) => m.message_index)).toEqual([0, 1, 2]); + + const md = toolText(await callTool(env, token, "xtctx_cloud_session_detail", { session_ref: "github:1:claude-code:s1", limit: 1, offset: 1 })); + expect(md).toContain("message 1"); + expect(md).toContain("offset 2"); + }); + + it("status says when each device last uploaded each project", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + expect(toolText(await callTool(env, token, "xtctx_cloud_status"))).toContain("Nothing has been uploaded"); + await post(env, token, upload([])); + const rows = JSON.parse(toolText(await callTool(env, token, "xtctx_cloud_status", { format: "json" }))); + expect(rows).toEqual([ + expect.objectContaining({ repo_url: "github.com/a/b", project_name: "b", device_name: "Laptop", sessions: 0 }), + ]); + }); + + it("clamps limit to 1..25, and a negative one is not unlimited", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + for (let batch = 0; batch < 3; batch++) { + const sessions = Array.from({ length: 10 }, (_, i) => session({ sourceSessionId: `s${batch}-${i}` })); + await post(env, token, upload(sessions)); + } + const count = async (limit: unknown) => + JSON.parse(toolText(await callTool(env, token, "xtctx_cloud_recent_sessions", { limit, format: "json" }))).length; + expect(await count(-1)).toBe(1); + expect(await count(0)).toBe(1); + expect(await count(2.7)).toBe(2); + expect(await count(1000)).toBe(25); + const bad = await callTool(env, token, "xtctx_cloud_recent_sessions", { limit: "lots" }); + expect(bad.result.isError).toBe(true); + }); + + it("session_detail requires session_ref, and an unknown tool is -32602", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + const missing = await callTool(env, token, "xtctx_cloud_session_detail", {}); + expect(missing.result.isError).toBe(true); + expect(toolText(missing)).toContain("session_ref is required"); + const unknown = await callTool(env, token, "xtctx_recent_sessions", {}); + expect(unknown.error.code).toBe(-32602); + }); + + it("names the tools so they cannot collide with the local server's", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + const { body } = await rpc(env, token, "tools/list"); + expect(body.result.tools.map((t: { name: string }) => t.name)).toEqual([ + "xtctx_cloud_recent_sessions", + "xtctx_cloud_session_detail", + "xtctx_cloud_status", + ]); + }); +}); diff --git a/cloud/tsconfig.json b/cloud/tsconfig.json new file mode 100644 index 0000000..ad79127 --- /dev/null +++ b/cloud/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ESNext"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/cloud/vitest.config.ts b/cloud/vitest.config.ts new file mode 100644 index 0000000..8849c2a --- /dev/null +++ b/cloud/vitest.config.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { "cloudflare:workers": fileURLToPath(new URL("./test/cloudflare-workers-stub.ts", import.meta.url)) }, + }, + test: { + include: ["test/**/*.test.ts"], + // Run the library through Vite so the alias above applies to it too. + server: { deps: { inline: ["@cloudflare/workers-oauth-provider"] } }, + }, +}); diff --git a/cloud/wrangler.toml b/cloud/wrangler.toml new file mode 100644 index 0000000..4cd01b7 --- /dev/null +++ b/cloud/wrangler.toml @@ -0,0 +1,58 @@ +name = "xtctx-cloud" +main = "src/index.ts" +compatibility_date = "2026-07-28" +# global_fetch_strictly_public: the OAuth provider fetches Client ID Metadata +# Documents from URLs a client names; this keeps those fetches on the public +# internet (SSRF protection), and the provider only advertises CIMD with it. +compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"] +# Only the custom domains below serve this Worker; no *.workers.dev alias that +# would answer with a different origin than the one tokens are bound to. +workers_dev = false + +routes = [ + { pattern = "mcp.xtctx.com", custom_domain = true }, + { pattern = "sync.xtctx.com", custom_domain = true } +] + +[observability] +enabled = true + +# D1: sessions, messages, accounts, revocation epochs. +# Schema: `npm run d1:migrate:remote` (wrangler d1 migrations apply). +[[d1_databases]] +binding = "DB" +database_name = "xtctx-db" +database_id = "f64a7fc9-60a3-41be-964f-efbca6897f88" +migrations_dir = "migrations" + +# KV: OAuth grants, tokens and registered clients (workers-oauth-provider). +# Create it with `npx wrangler kv namespace create OAUTH_KV` and put the id here. +[[kv_namespaces]] +binding = "OAUTH_KV" +id = "REPLACE_WITH_OAUTH_KV_NAMESPACE_ID" + +# Holds the open SSE streams for the baseline MCP transport. +[[durable_objects.bindings]] +name = "SSE" +class_name = "SseSession" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["SseSession"] + +[vars] +# The origin MCP clients connect to; the MCP resource is PUBLIC_URL + "/mcp" +# and every token is bound to it. +PUBLIC_URL = "https://mcp.xtctx.com" +# Public GitHub OAuth App client id (device flow for the CLI, web flow for /authorize). +GITHUB_CLIENT_ID = "Ov23liFUTodqfoH9mgHA" +# Comma-separated numeric GitHub user ids allowed to sign in. Empty means +# nobody can (fail closed). Find yours: https://api.github.com/users/ -> "id". +ALLOWED_GITHUB_IDS = "" +ENVIRONMENT = "production" + +# Secrets, set with `npx wrangler secret put `: +# - JWT_SECRET: REQUIRED. Signs the CLI's tokens; every route but /health answers 500 until it is set. +# - GITHUB_CLIENT_SECRET: needed for browser sign-in at /authorize (MCP clients). Without it the +# CLI's device flow still works and /authorize answers 503. +# Optional var: ALLOWED_ORIGINS (comma-separated browser origins to send CORS headers to; none by default). diff --git a/docs/architecture.md b/docs/architecture.md index 253c237..34eb0da 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,8 +8,17 @@ detail on demand. xtctx does not run a background service, web UI, generated-summary pipeline, or durable memory writeback layer. The architecture is intentionally scoped to one local developer switching -between coding tools. Shared team memory, cloud sync, telemetry, and hosted -retrieval are outside the product surface. +between coding tools. Shared team memory and telemetry are outside the product +surface. + +Cloud sync is the one optional part that leaves the machine, and it is opt-in +per project: only when the user has logged in (`xtctx login`) and opted the +project in (`xtctx sync enable`) does the MCP server upload that project's +index to the Worker in `cloud/`, every 10 seconds while it runs and once on +shutdown. The Worker serves the uploads back over MCP to the same user's +agents on other machines. It is not on the handoff path: everything above +works with it off, which is the default. See +[cloud-sync.md](cloud-sync.md) and [`cloud/README.md`](../cloud/README.md). ## Runtime Shape @@ -19,6 +28,7 @@ tool transcript/artifact stores v scrapers -> .xtctx/state/xtctx.db -> MCP tools -> setup/status diagnostics + -> cloud upload (optional, opted-in projects only) ``` `.xtctx/state/xtctx.db` is built from the transcripts, which remain @@ -75,8 +85,10 @@ raw-detail pointers; it does not persist task state. Startup hooks are lightweight handoff openers. They do not update the local index unless a future hook explicitly calls a bounded scan path. -Semantic search uses local embeddings over sliding windows of raw transcript -turns. Each embedded window includes the session reference, message range, turn +Semantic search is an optional add-on: until `xtctx embeddings enable` +installs the local model (or a project names an external endpoint), every +search is keyword-only, and status says so. Once on, it embeds sliding windows +of raw transcript turns. Each embedded window includes the session reference, message range, turn order, message index, role, timestamp, and raw message content. Retrieval ranks semantic similarity together with keyword, recency, and continuity signals, then returns the matched message range so the agent can drill into the raw session. diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md new file mode 100644 index 0000000..7c400a6 --- /dev/null +++ b/docs/cloud-sync.md @@ -0,0 +1,86 @@ +# Cloud sync + +Optional, and opt-in per project. xtctx stays local unless you do both of these: + +1. **Log in:** `xtctx login` signs you in with GitHub and stores a token. It uploads nothing. +2. **Opt a project in:** `xtctx sync enable`, run in that project. Each project is its own decision. + +Either one alone sends nothing. `XTCTX_TOKEN` in the environment counts as logging in, not as opting in. + +What it is for: reading, from an agent on another machine, what your agents did here. The cloud serves the uploads back over MCP (see [Reading it back](#reading-it-back)). + +## What is uploaded + +For an opted-in project, only the sessions **that project's own index** (`.xtctx/state/xtctx.db`) attributes to that project, compared the way the index's own reads compare project roots. Sessions an index holds for another folder (a copied `.xtctx/`, a renamed folder) are not sent. + +Per session: + +- the tool (`claude-code`, `codex`, ...) and the tool's session id +- git branch and commit, when recorded +- first and last activity time +- the preview: the start of the session's first message, up to 160 characters + +Per message: + +- the message text, role, timestamp and position +- a content hash, and the index's own message id +- these metadata fields and no others: `messageIndex`, `tokenEstimate`, `toolCalls`, `toolName`, `model`, `stepType`, `artifactType`, `artifactName`, `sessionType`, `approvalMode`, `sandboxed`, `layer`, `costUsd`, `gitBranch`, `gitCommit`, `subagent`, `subagentType`. A value among them that looks like an absolute path is dropped. + +Per upload: + +- a project identity: the normalised git remote (`github.com/you/repo`), or a hash of the folder's location when there is no remote +- the project's folder name (not its path) +- this device's id (random, made at login) and its name, which is a random label like `device-3f9a1c` unless you set one +- the client version (`X-Xtctx-Client: xtctx/`) + +**Not uploaded:** the absolute path of the project or of any transcript file (the index's `source_pointer`, Antigravity's `sourcePath`), `referencedFiles`, Copilot CLI's `parentToolCallId`, your hostname, and any metadata field not listed above. + +**Message text is uploaded as written**, and it can contain anything your agents saw or printed: file paths, command output, a secret pasted into a chat. Opt in only projects whose transcripts you would put on that server. A message over 64 KB is cut to 64 KB and ends with a `[xtctx: truncated for upload ...]` marker. + +The opt-in list is `~/.xtctx/cloud-projects.json` and the login is `~/.xtctx/credentials.json` (readable by you only). Both live in your home directory, not in `.xtctx/config.yaml`, so a repository cannot opt its readers in by committing a file. Nothing is written into your repository for sync. + +## When it uploads + +- While an agent runs xtctx in an opted-in project, its MCP server uploads every 10 seconds, and once more when it shuts down. Nothing runs when no agent does: there is no daemon. With two agents open in one project, a lock in `~/.xtctx/sync/` lets one server upload at a time. +- `xtctx sync` uploads once, and exits non-zero if that failed. `xtctx sync --watch` repeats every 10 seconds in the foreground until Ctrl+C, and prints a repeated failure once. + +Each session's cloud copy is kept equal to the local one. New and changed messages are sent under their local ids, and the server answers with how many messages it holds for the session. When that differs from the local count (a re-read here removed or replaced messages), the whole session is sent with its full list of ids and the server deletes the rest. Uploads are idempotent, so sending something twice changes nothing. + +What has been sent is tracked per project and per account in `~/.xtctx/sync/`, not in the index, so logging in as someone else uploads the project to them in full. Rebuilding the index sends everything once more, which changes nothing in the cloud for the same reason. A failed upload is retried in full on the next run. + +If the server refuses one message as too large, that message is skipped from then on and recorded, and the rest of the project keeps uploading. `xtctx sync status` lists skipped messages. + +## Environment variables + +`XTCTX_TOKEN` and `XTCTX_SYNC_URL` override the saved login. An MCP config's `env` block can set them, and a repository can ship such a config, so when they name anything other than your saved login (another token, another server, or no saved login at all) xtctx **refuses to upload** and says why. Set `XTCTX_ALLOW_ENV_CREDENTIALS=1` as well when that is intended, such as on a machine nobody logs in on. `XTCTX_DEVICE_ID` and `XTCTX_DEVICE_NAME` name the device for environment credentials. + +## Commands + +| Command | What it does | +|---|---| +| `xtctx login [--device ]` | Sign in with GitHub (device code). This login can read, upload and delete. | +| `xtctx sync enable` / `disable` | Choose whether this project uploads. | +| `xtctx sync status` | Whether this project uploads, as whom, the last upload, the last failure, and skipped messages. `xtctx status` shows the same under `Cloud`. | +| `xtctx sync` | Upload now. `--watch` repeats every 10 seconds. Exits non-zero on failure. | +| `xtctx sync device []` | Show or set the name this device uploads as. | +| `xtctx sync token` | Print a read-only token (valid 90 days) for an MCP client that cannot sign in by itself. | +| `xtctx logout` | Revoke every token and every client sign-in for your account, then forget the local login. | +| `xtctx logout --delete-data` | Delete everything uploaded for your account, then log out. If the server no longer accepts your login it deletes nothing and says so; run `xtctx login` and try again. | + +`disable` stops further uploads but does not delete what was already sent; use `logout --delete-data` for that. Deleting also revokes every token issued before it, and they stay revoked when you sign in again. + +## Reading it back + +The server is an MCP server at `https://mcp.xtctx.com/mcp` with three tools, named so they cannot collide with the local server's when both are connected: + +- `xtctx_cloud_status`: when each of your devices last uploaded each project, and how many sessions it holds. Tells "synced, nothing new" from "never synced". +- `xtctx_cloud_recent_sessions`: sessions from every device and every project, newest first; `repo_url` restricts it to one repository. +- `xtctx_cloud_session_detail`: one session's messages in order, as `markdown` (default) or `json`. + +**MCP clients sign in with OAuth.** Add the URL to the client, for Claude Code `claude mcp add --transport http xtctx-cloud https://mcp.xtctx.com/mcp`, and it finds the sign-in itself, registers, and opens a browser where you approve it and sign in with GitHub. A client signed in this way can only read (scope `read`). Its access token lasts an hour and is refreshed for up to 30 days; it cannot upload or delete. Uploading and deleting need the CLI's own login. + +A client without OAuth support can send a token from `xtctx sync token` as an `Authorization: Bearer ...` header. It is read-only as well, and `xtctx logout` revokes it. + +## Server + +The service is the Worker in [`cloud/`](../cloud/README.md), which also describes running your own. The sync URL must be `https`; plain `http` is accepted only for `localhost`. Only the GitHub accounts the server's operator lists can sign in. diff --git a/docs/embedding-providers.md b/docs/embedding-providers.md index 2d5ccf8..90501e9 100644 --- a/docs/embedding-providers.md +++ b/docs/embedding-providers.md @@ -34,7 +34,8 @@ local-only. It does not upload transcripts or run telemetry" is true of what xtctx does on its own and should say so: > xtctx is local-only by default: it never uploads transcripts and runs no -> telemetry. A project can opt into an external embedding endpoint, in which +> telemetry; cloud sync sends nothing until you log in and opt a project in. +> A project can opt into an external embedding endpoint, in which > case window text is sent there for vectorizing — `xtctx status` reports the > endpoint whenever one is configured. diff --git a/package-lock.json b/package-lock.json index 109ca06..d86cb21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1755,6 +1755,7 @@ "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" diff --git a/package.json b/package.json index e0dac4f..30062b8 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,11 @@ "demo:public": "node scripts/public-demo-smoke.mjs", "landing:dev": "npm --prefix landing run dev", "landing:build": "npm --prefix landing run build", + "test:cloud": "npm --prefix cloud run typecheck && npm --prefix cloud test", "landing:preview": "npm --prefix landing run preview", "prepare": "npm run build", "smoke:cli": "node dist/src/cli/index.js --help", - "verify:release": "npm run lint && npm run typecheck && npm test && npm run test:security && npm run security:checklist && npm run check:review-coverage && npm run check:workflows && npm run audit:production && npm --prefix landing audit && npm run test:drift && npm run test:integration && npm run test:smoke && npm run test:eval && npm pack --dry-run && npm run demo:public && npm run landing:build && npm run smoke:cli", + "verify:release": "npm run lint && npm run typecheck && npm test && npm run test:cloud && npm run test:security && npm run security:checklist && npm run check:review-coverage && npm run check:workflows && npm run audit:production && npm --prefix landing audit && npm run test:drift && npm run test:integration && npm run test:smoke && npm run test:eval && npm pack --dry-run && npm run demo:public && npm run landing:build && npm run smoke:cli", "sync:version": "node scripts/sync-version.mjs", "version": "node scripts/sync-version.mjs", "test:eval": "vitest run tests/eval", diff --git a/scripts/check-review-coverage.mjs b/scripts/check-review-coverage.mjs index 08d0016..ca3678f 100644 --- a/scripts/check-review-coverage.mjs +++ b/scripts/check-review-coverage.mjs @@ -46,7 +46,7 @@ const LAYERS = [ name: "product-source", question: "Is it correct? What input makes it do the wrong thing?", why: "The ordinary review. It is listed first so it is visibly not the only one.", - match: [/^src\//], + match: [/^src\//, /^cloud\/src\//], }, { name: "test-suite", @@ -55,7 +55,7 @@ const LAYERS = [ why: "Reading tests does not find this. Two were found only by hardcoding a value in the source " + "and seeing the suite stay green — one of them covering a feature that was broken at the time.", - match: [/^tests\//, /^vitest\.config\.ts$/], + match: [/^tests\//, /^cloud\/test\//, /^vitest\.config\.ts$/, /^cloud\/vitest\.config\.ts$/], }, { name: "gates", @@ -91,6 +91,7 @@ const LAYERS = [ // same blank region as no layer at all. /^(?!design-direction\.md$|ux-walkthrough\.md$)[^/]+\.md$/, /^docs\//, + /^cloud\/README\.md$/, /^LICENSE$/, /^\.xtctx\//, // Written by xtctx into its own repository, and read by an agent — a @@ -120,6 +121,8 @@ const LAYERS = [ /^\.gitignore$/, /^\.gitattributes$/, /^\.github\/dependabot\.yml$/, + // The Worker's deploy surface: what it binds, what its database looks like. + /^cloud\/(wrangler\.toml|package(-lock)?\.json|tsconfig\.json|schema\.sql|migrations\/.*)$/, // What `xtctx embeddings enable` installs. If `files` stops shipping it, // enable fails for every user, and nothing else notices. /^embeddings-runtime\//, diff --git a/src/cli/index.ts b/src/cli/index.ts index b9e91f8..75dd1f0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -7,6 +7,9 @@ import { runHook } from "./hook.js"; import { runScan } from "./scan.js"; import { runSetup } from "./setup.js"; import { runStatus } from "./status.js"; +import { runLogin, runLogout } from "./login.js"; +import { runSync, runSyncSetting } from "./sync.js"; +import { startAutoSync, type AutoSync } from "../sync/auto-sync.js"; import { createProjectServices } from "../runtime/services.js"; import { startMcpServer } from "../mcp/server.js"; import { readXtctxPackage } from "../utils/package-info.js"; @@ -24,6 +27,7 @@ export async function main(argv = process.argv): Promise { // like a configured project with no history. const unconfiguredProjectRoot = services.config.present ? undefined : services.projectRoot; let closed = false; + let autoSync: AutoSync | undefined; const shutdown = (exit: boolean) => { if (closed) return; closed = true; @@ -44,15 +48,22 @@ export async function main(argv = process.argv): Promise { // scan lease and empties the write-ahead log on the way out. The timer // is the backstop for work that cannot be interrupted. It used to be // the usual way out, which also meant the index was never closed. - const graceMs = 2_000; + // + // The cloud flush has its own bound, added on top: it is a network call, + // not the scan this grace window exists to cut short. + const graceMs = 2_000 + (autoSync ? 1_500 : 0); const timer = setTimeout(() => { if (exit) process.exit(0); }, graceMs); timer.unref?.(); - void services.sessions - .close() - .catch(() => {}) + // Upload what the last interval left while the index closes. Side by + // side, not one after the other: the upload reads the index through its + // own read-only connection, so closing this one does not disturb it, and + // a slow upload must not hold back the close that stops the scan and + // releases the scan lease. Each statement the upload runs is its own + // short read, so it does not keep the close from emptying the log. + void Promise.allSettled([autoSync?.stop() ?? Promise.resolve(), services.sessions.close()]) .finally(() => { clearTimeout(timer); if (exit) process.exit(0); @@ -109,6 +120,11 @@ export async function main(argv = process.argv): Promise { sessions: services.sessions, localEmbeddings: localEmbeddingsActive(services.config.embedding), }); + // Uploads only when logged in and this project is opted in; see auto-sync.ts. + autoSync = startAutoSync({ + projectRoot: services.projectRoot, + log: (line) => process.stderr.write(`${line}\n`), + }); } return; } @@ -168,6 +184,40 @@ export async function main(argv = process.argv): Promise { await runStatus({ projectPath: options.project ?? globalOptions.project, verbose: options.verbose }); }); + program + .command("login") + .option("--sync-url ", "Sync server URL (default: https://sync.xtctx.com)") + .option("--device ", "Name this device shows as in the cloud (default: a random label)") + .description("Sign in to xtctx cloud with GitHub (uploads nothing by itself)") + .action(async (options: { syncUrl?: string; device?: string }) => { + await runLogin({ syncUrl: options.syncUrl, deviceName: options.device }); + }); + + program + .command("logout") + .option("--delete-data", "Also delete everything uploaded to your cloud account", false) + .description("Sign out of xtctx cloud and revoke this account's tokens") + .action(async (options: { deleteData?: boolean }) => { + await runLogout({ deleteData: options.deleteData }); + }); + + program + .command("sync") + .argument("[action]", "enable, disable or status for this project; device [name]; token; omit to upload once") + .argument("[value]", "the new name, for `sync device `") + .option("-p, --project ", "Project root (defaults to cwd)") + .option("-w, --watch", "Keep uploading every few seconds until interrupted", false) + .description("Cloud sync: choose whether this project uploads, or upload now") + .action(async (action: string | undefined, value: string | undefined, options: { project?: string; watch?: boolean }) => { + const globalOptions = program.opts<{ project?: string }>(); + const projectDir = options.project ?? globalOptions.project; + if (action) { + await runSyncSetting(action, { projectDir, value }); + } else { + await runSync({ projectDir, watch: options.watch }); + } + }); + program .command("scan") .option("-p, --project ", "Project root (defaults to cwd)") diff --git a/src/cli/login.ts b/src/cli/login.ts new file mode 100644 index 0000000..523e455 --- /dev/null +++ b/src/cli/login.ts @@ -0,0 +1,154 @@ +import { randomUUID } from "node:crypto"; +import { + DEFAULT_SYNC_URL, + assertSecureSyncUrl, + callCloud, + clientHeader, + NO_KEEP_ALIVE, + deleteCredentials, + getCredentialsPath, + loadSavedCredentials, + randomDeviceName, + saveCredentials, +} from "../sync/client.js"; + +interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; +} + +interface PollResponse { + token?: string; + user?: { id: string; username: string; name?: string }; + error?: string; + detail?: string; + interval?: number; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** + * Sign in with GitHub's device flow. Signing in is only that: it stores a + * token. Nothing is uploaded until a project is opted in with + * `xtctx sync enable`. + */ +export async function runLogin(options: { + syncUrl?: string; + deviceName?: string; + /** Test seam; the real flow waits as long as GitHub says. */ + wait?: (ms: number) => Promise; +}): Promise { + const syncUrl = options.syncUrl || process.env.XTCTX_SYNC_URL || DEFAULT_SYNC_URL; + // Not the hostname: see randomDeviceName. + const deviceName = options.deviceName || randomDeviceName(); + const wait = options.wait ?? sleep; + const base = syncUrl.replace(/\/$/, ""); + + assertSecureSyncUrl(syncUrl); + + const codeRes = await fetch(`${base}/auth/device/code`, { method: "POST", headers: { "X-Xtctx-Client": clientHeader(), ...NO_KEEP_ALIVE } }); + if (!codeRes.ok) { + throw new Error(`Could not start login (${codeRes.status}).`); + } + const code = (await codeRes.json()) as DeviceCodeResponse; + + console.log(`\n 1. Copy your one-time code: ${code.user_code}`); + console.log(` 2. Open in your browser: ${code.verification_uri}\n`); + console.log("Waiting for authorization in the browser..."); + + // GitHub says how long the code lives and how often to ask; ignoring either + // gets the client rate-limited or leaves it waiting on a code that is dead. + const deadline = Date.now() + (code.expires_in || 900) * 1000; + let intervalMs = (code.interval || 5) * 1000; + + while (Date.now() < deadline) { + await wait(intervalMs); + + let poll: PollResponse; + try { + const res = await fetch(`${base}/auth/device/poll`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Xtctx-Client": clientHeader(), ...NO_KEEP_ALIVE }, + body: JSON.stringify({ device_code: code.device_code }), + }); + poll = (await res.json()) as PollResponse; + } catch { + continue; // a network blip; the deadline bounds how long this goes on + } + + if (poll.token && poll.user) { + await saveCredentials({ + token: poll.token, + user: poll.user, + deviceId: `dev_${randomUUID()}`, + deviceName, + syncUrl, + }); + console.log(`\nLogged in as ${poll.user.username}. Saved to ${getCredentialsPath()}.`); + console.log("Nothing is uploaded yet. To upload a project's transcripts, run in it: xtctx sync enable"); + return; + } + + if (poll.error === "slow_down") { + intervalMs = poll.interval ? poll.interval * 1000 : intervalMs + 5000; + } else if (poll.error === "not_allowed") { + throw new Error( + "Login failed: this GitHub account is not allowed on this xtctx cloud server (its ALLOWED_GITHUB_IDS does not list it).", + ); + } else if (poll.error && poll.error !== "authorization_pending") { + throw new Error(`Login failed: ${poll.error}`); + } + } + throw new Error("The login code expired before it was authorized. Run `xtctx login` again."); +} + +/** + * Sign out: ask the server to revoke this account's tokens, then forget ours. + * With deleteData it first deletes everything the account has uploaded, and + * keeps the local login whenever that did not happen, so it can be retried. + */ +export async function runLogout(options: { deleteData?: boolean }): Promise { + // The saved login only: logout is about what `xtctx login` stored. + const creds = await loadSavedCredentials(); + if (!creds) { + console.log("Not logged in."); + return; + } + + if (options.deleteData) { + let res: Response; + try { + res = await callCloud(creds, "DELETE", "/api/me"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Could not delete your cloud data (${message}). Nothing was deleted and you are still logged in; try again.`); + } + // A 401 here means the server no longer accepts this login, so it deleted + // nothing: reporting success would leave the data there while saying it is gone. + if (res.status === 401) { + throw new Error( + "Your xtctx cloud login has expired or was signed out, so nothing was deleted. " + + "Run `xtctx login`, then `xtctx logout --delete-data` again.", + ); + } + if (!res.ok) { + throw new Error(`Could not delete your cloud data (server answered ${res.status}). Nothing was deleted and you are still logged in; try again.`); + } + console.log("Deleted everything xtctx cloud held for your account, and signed out every device."); + } else { + try { + const res = await callCloud(creds, "POST", "/auth/logout"); + // 401: the server already refuses this token, which is the state we want. + if (!res.ok && res.status !== 401) throw new Error(`server answered ${res.status}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn(`Could not reach the server to revoke your token (${message}). It stays valid until it expires.`); + } + } + + await deleteCredentials(); + console.log("Logged out."); +} diff --git a/src/cli/status.ts b/src/cli/status.ts index a1fc82c..8eabb06 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -13,6 +13,7 @@ import { import { readDriftLog, type DriftLogFile } from "../scrapers/drift-log.js"; import { SUPPORTED_TOOLS } from "../tools/sources.js"; import { readXtctxPackage } from "../utils/package-info.js"; +import { describeCloudSync } from "../sync/report.js"; import { ENABLE_SEMANTIC_HINT } from "../handoff/embedding-runtime.js"; interface StatusOptions { @@ -215,6 +216,13 @@ export async function renderStatusBlock( const endpoint = status.vector_model.slice("openai:".length); lines.push(`Embedding external endpoint — window text is sent to ${endpoint}`); } + // Cloud upload is the other thing that sends transcripts off this machine, + // so whether it is on here, and whether it is working, is always stated. + const cloud = await describeCloudSync(services.projectRoot).catch((err: unknown) => [ + `unknown (${err instanceof Error ? err.message : String(err)})`, + ]); + lines.push(`Cloud ${cloud[0]}`); + for (const line of cloud.slice(1)) lines.push(` ${line}`); lines.push(""); lines.push("Tools:"); diff --git a/src/cli/sync.ts b/src/cli/sync.ts new file mode 100644 index 0000000..0861307 --- /dev/null +++ b/src/cli/sync.ts @@ -0,0 +1,123 @@ +import { resolve } from "node:path"; +import { callCloud, loadSavedCredentials, saveCredentials } from "../sync/client.js"; +import { setOptedIn } from "../sync/consent.js"; +import { runDiffSync, type DiffSyncResult } from "../sync/diff-sync.js"; +import { describeCloudSync } from "../sync/report.js"; + +const WATCH_INTERVAL_MS = 10_000; + +function describe(result: DiffSyncResult): string { + if (result.busy) return "Another xtctx process is uploading this project right now; it will pick up these changes."; + const lines = [result.syncedCount === 0 ? "Cloud sync is up to date." : `Sent ${result.syncedCount} message(s) from ${result.sessionCount} session(s) to xtctx cloud.`]; + for (const s of result.skipped) lines.push(`Skipped message ${s.messageId} in ${s.sessionRef}: ${s.reason}.`); + return lines.join("\n"); +} + +/** + * Upload this project's changes once, or with --watch keep doing it in the + * foreground until interrupted. Same upload as the MCP server's own. + * + * Exits non-zero when the upload failed (once: that run; --watch: the last + * run before Ctrl+C). The outcome is also recorded for `xtctx sync status`. + */ +export async function runSync(options: { projectDir?: string; watch?: boolean }): Promise { + const projectDir = resolve(options.projectDir ?? process.cwd()); + let lastError = ""; + + const once = async (): Promise => { + try { + const result = await runDiffSync({ projectDir }); + // Quiet when watching and there is nothing to say. + if (!options.watch || !result.upToDate || lastError) console.log(describe(result)); + lastError = ""; + process.exitCode = 0; + return true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // Said once per distinct failure, not every ten seconds. + if (message !== lastError) console.error(`Cloud sync failed: ${message}`); + lastError = message; + process.exitCode = 1; + return false; + } + }; + + const ok = await once(); + if (!options.watch) return; + if (!ok && /Not logged in|not opted in/.test(lastError)) return; + + console.log(`Watching ${projectDir}; uploading every ${WATCH_INTERVAL_MS / 1000}s. Ctrl+C to stop.`); + await new Promise((done) => { + let busy = false; + const timer = setInterval(() => { + if (busy) return; + busy = true; + void once().finally(() => { + busy = false; + }); + }, WATCH_INTERVAL_MS); + process.once("SIGINT", () => { + clearInterval(timer); + done(); + }); + }); +} + +/** `xtctx sync enable | disable | status | device | token`. */ +export async function runSyncSetting(action: string, options: { projectDir?: string; value?: string }): Promise { + const projectDir = resolve(options.projectDir ?? process.cwd()); + + if (action === "enable") { + await setOptedIn(projectDir, true); + console.log(`Cloud sync is ON for ${projectDir}.`); + console.log("Its transcripts are uploaded to your xtctx cloud account while an agent runs xtctx here."); + if (!(await loadSavedCredentials())) console.log("You are not logged in yet: run `xtctx login`."); + return; + } + + if (action === "disable") { + await setOptedIn(projectDir, false); + console.log(`Cloud sync is OFF for ${projectDir}. Nothing more is uploaded from it.`); + console.log("Already-uploaded data stays until you run `xtctx logout --delete-data`."); + return; + } + + if (action === "status") { + for (const [i, line] of (await describeCloudSync(projectDir)).entries()) { + console.log(i === 0 ? `Cloud sync for ${projectDir}: ${line}` : ` ${line}`); + } + return; + } + + if (action === "device") { + const creds = await loadSavedCredentials(); + if (!creds) throw new Error("Not logged in. Run `xtctx login --device ` instead."); + const name = options.value?.trim(); + if (!name) { + console.log(`This device uploads as "${creds.deviceName}". Rename it with: xtctx sync device `); + return; + } + if (name.length > 64) throw new Error("A device name is at most 64 characters."); + await saveCredentials({ ...creds, deviceName: name }); + console.log(`This device now uploads as "${name}" (from its next upload).`); + return; + } + + if (action === "token") { + const creds = await loadSavedCredentials(); + if (!creds) throw new Error("Not logged in. Run `xtctx login` first."); + const res = await callCloud(creds, "POST", "/api/tokens"); + if (res.status === 401) throw new Error("Your xtctx cloud login is no longer valid. Run `xtctx login`."); + if (!res.ok) throw new Error(`Could not create a token (server answered ${res.status}).`); + const body = (await res.json()) as { token: string; mcp_url: string; expires_at: string }; + console.error( + `A read-only token for ${body.mcp_url}, valid until ${body.expires_at}. It can read every transcript in your account;\n` + + "keep it out of files you commit. `xtctx logout` revokes it. Prefer the client's own sign-in where it has one.", + ); + console.log(body.token); + return; + } + + console.error(`Unknown action "${action}". Use enable, disable, status, device or token, or no action to upload once.`); + process.exitCode = 1; +} diff --git a/src/sync/auto-sync.ts b/src/sync/auto-sync.ts new file mode 100644 index 0000000..4875310 --- /dev/null +++ b/src/sync/auto-sync.ts @@ -0,0 +1,84 @@ +import { NotLoggedInError, NotOptedInError, runDiffSync, type DiffSyncResult } from "./diff-sync.js"; + +export interface AutoSync { + /** Stop the timer, let a sync in flight finish, then upload once more. */ + stop(): Promise; +} + +export interface AutoSyncOptions { + projectRoot: string; + log: (line: string) => void; + intervalMs?: number; + /** How long the final upload on shutdown may take before it is abandoned. */ + flushTimeoutMs?: number; + sync?: (options: { projectDir: string }) => Promise; +} + +/** + * Keep an opted-in project's uploads current while the MCP server runs. + * + * Transcripts only change while an agent is running, and an agent with xtctx + * wired runs this server for the whole session, so syncing from here is as + * fresh as a separate daemon was without leaving a process behind. A crash + * loses at most the last interval, which the next start uploads. + * + * Each tick asks `runDiffSync`, which refuses unless someone is logged in and + * the project is opted in, so a project that is neither costs two small file + * reads per tick and sends nothing. Ticks never overlap: the next is + * scheduled when the previous one finishes. Across processes (two agents + * open in one project each run a server) a lock in ~/.xtctx lets one upload + * at a time; the others find it busy and wait for their next tick. + */ +export function startAutoSync(options: AutoSyncOptions): AutoSync { + const { projectRoot, log } = options; + const intervalMs = options.intervalMs ?? 10_000; + const flushTimeoutMs = options.flushTimeoutMs ?? 1_500; + const sync = options.sync ?? runDiffSync; + + let timer: NodeJS.Timeout | undefined; + let inFlight: Promise = Promise.resolve(); + let stopped = false; + let lastError = ""; + + const tick = async (): Promise => { + try { + const result = await sync({ projectDir: projectRoot }); + if (result.busy) return; // another process is uploading this project + if (result.syncedCount > 0) log(`xtctx: sent ${result.syncedCount} message(s) to xtctx cloud`); + for (const s of result.skipped) { + log(`xtctx: cloud sync skipped message ${s.messageId} in ${s.sessionRef}: ${s.reason}`); + } + lastError = ""; + } catch (err) { + if (err instanceof NotLoggedInError || err instanceof NotOptedInError) return; + // Said once per distinct failure, not every ten seconds. The failure is + // also recorded for `xtctx sync status`. + const message = err instanceof Error ? err.message : String(err); + if (message !== lastError) log(`xtctx: cloud sync failed: ${message}`); + lastError = message; + } + }; + + const schedule = (): void => { + if (stopped) return; + timer = setTimeout(() => { + inFlight = tick().then(schedule); + }, intervalMs); + timer.unref?.(); + }; + schedule(); + + return { + async stop() { + stopped = true; + clearTimeout(timer); + await inFlight; + let abandon: NodeJS.Timeout | undefined; + const deadline = new Promise((resolve) => { + abandon = setTimeout(resolve, flushTimeoutMs); + }); + await Promise.race([tick(), deadline]); + clearTimeout(abandon); + }, + }; +} diff --git a/src/sync/client.ts b/src/sync/client.ts new file mode 100644 index 0000000..5fbb24a --- /dev/null +++ b/src/sync/client.ts @@ -0,0 +1,172 @@ +import { createHash, randomBytes } from "node:crypto"; +import { hostname } from "node:os"; +import { join } from "node:path"; +import { readFile, rm } from "node:fs/promises"; +import { writeFileAtomic } from "../utils/atomic-file.js"; +import { readXtctxPackage } from "../utils/package-info.js"; +import { xtctxHome } from "./consent.js"; + +export interface SyncCredentials { + token: string; + user: { + id: string; + username: string; + name?: string; + }; + deviceId: string; + deviceName: string; + syncUrl: string; +} + +export const DEFAULT_SYNC_URL = "https://sync.xtctx.com"; + +export function getCredentialsPath(): string { + return join(xtctxHome(), "credentials.json"); +} + +/** + * A name for this device that says nothing about it. The hostname used to be + * the default, and it often carries a person's name or an employer's asset + * tag; `xtctx login --device ` or `xtctx sync device ` sets one. + */ +export function randomDeviceName(): string { + return `device-${randomBytes(3).toString("hex")}`; +} + +/** The saved login from `xtctx login`, or null. */ +export async function loadSavedCredentials(): Promise { + try { + return JSON.parse(await readFile(getCredentialsPath(), "utf-8")) as SyncCredentials; + } catch { + return null; + } +} + +/** Credentials described by XTCTX_TOKEN / XTCTX_SYNC_URL, or null when neither is set. */ +function credentialsFromEnv(saved: SyncCredentials | null): SyncCredentials | null { + const envToken = process.env.XTCTX_TOKEN || undefined; + const envUrl = process.env.XTCTX_SYNC_URL || undefined; + if (!envToken && !envUrl) return null; + if (!envToken) return saved ? { ...saved, syncUrl: envUrl! } : null; + + let userId = "env-user"; + let username = "developer"; + try { + const payload = JSON.parse(Buffer.from(envToken.split(".")[1] ?? "", "base64url").toString("utf-8")); + if (typeof payload.sub === "string") userId = payload.sub; + if (typeof payload.username === "string") username = payload.username; + } catch { + // Not a decodable JWT; the server is what judges the token. + } + // Stable across runs without saying anything about the machine. + const stableId = `device-${createHash("sha256").update(hostname()).digest("hex").slice(0, 12)}`; + return { + token: envToken, + user: { id: userId, username }, + deviceId: process.env.XTCTX_DEVICE_ID || saved?.deviceId || stableId, + deviceName: process.env.XTCTX_DEVICE_NAME || saved?.deviceName || stableId, + syncUrl: envUrl || saved?.syncUrl || DEFAULT_SYNC_URL, + }; +} + +/** + * The login in effect: XTCTX_TOKEN / XTCTX_SYNC_URL when set, otherwise the + * saved one. For showing who is logged in; uploads go through + * `resolveUploadCredentials`, which is stricter about the environment. + */ +export async function loadCredentials(): Promise { + const saved = await loadSavedCredentials(); + return credentialsFromEnv(saved) ?? saved; +} + +export class EnvCredentialsRefusedError extends Error { + constructor() { + super( + "XTCTX_TOKEN or XTCTX_SYNC_URL is set and differs from your saved login, so this project's uploads " + + "would go to another account or server than the one you signed in to. Refusing to upload. " + + "Unset them, or set XTCTX_ALLOW_ENV_CREDENTIALS=1 if that is what you want.", + ); + this.name = "EnvCredentialsRefusedError"; + } +} + +const sameUrl = (a: string, b: string) => a.replace(/\/+$/, "") === b.replace(/\/+$/, ""); + +/** + * The credentials an upload may use. + * + * Environment variables reach a process from places the user does not see, + * such as an MCP config's `env` block, which a repository can ship. One that + * sets XTCTX_TOKEN or XTCTX_SYNC_URL could otherwise send an opted-in + * project's transcripts to someone else's account or server. So when they + * name anything other than the saved login (including when there is no saved + * login), uploading also needs XTCTX_ALLOW_ENV_CREDENTIALS=1. Null when there + * is no login at all. + */ +export async function resolveUploadCredentials(): Promise { + const saved = await loadSavedCredentials(); + const fromEnv = credentialsFromEnv(saved); + if (!fromEnv) return saved; + const matchesSaved = saved !== null && fromEnv.token === saved.token && sameUrl(fromEnv.syncUrl, saved.syncUrl); + if (!matchesSaved && process.env.XTCTX_ALLOW_ENV_CREDENTIALS !== "1") throw new EnvCredentialsRefusedError(); + return fromEnv; +} + +/** Written 0600 and atomically: it holds a bearer token. */ +export async function saveCredentials(creds: SyncCredentials): Promise { + await writeFileAtomic(getCredentialsPath(), JSON.stringify(creds, null, 2), { mode: 0o600 }); +} + +export async function deleteCredentials(): Promise { + await rm(getCredentialsPath(), { force: true }); +} + +/** + * A token must not travel in the clear. Plain http is for a server on this + * machine, which is what development uses. + */ +export function assertSecureSyncUrl(syncUrl: string): void { + const url = new URL(syncUrl); + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) { + throw new Error(`Refusing ${syncUrl}: the sync server must be https (http is allowed only for localhost).`); + } +} + +/** + * Every cloud request closes its connection when it is done. + * + * On Windows, Node 24.14 aborts with `Assertion failed: !(handle->flags & + * UV_HANDLE_CLOSING)` (exit 0xC0000409) when `process.exit` runs while fetch + * still holds a pooled keep-alive socket after a POST. The MCP server does + * exactly that: an upload tick, then the final upload on shutdown, then exit. + * It crashed in 10 of 10 such runs; with this header, 0 of 8 in a standalone + * reproduction. Uploads are seconds apart, so reusing the connection saves + * nothing worth the crash. + */ +export const NO_KEEP_ALIVE = { Connection: "close" } as const; + +/** Sent on every request, so the server can tell which client versions are out there. */ +export function clientHeader(): string { + return `xtctx/${readXtctxPackage(import.meta.url).version}`; +} + +export async function callCloud( + creds: SyncCredentials, + method: string, + path: string, + body?: unknown, +): Promise { + assertSecureSyncUrl(creds.syncUrl); + const headers: Record = { + Authorization: `Bearer ${creds.token}`, + "X-Xtctx-Client": clientHeader(), + ...NO_KEEP_ALIVE, + }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + return fetch(`${creds.syncUrl.replace(/\/$/, "")}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} diff --git a/src/sync/consent.ts b/src/sync/consent.ts new file mode 100644 index 0000000..188870e --- /dev/null +++ b/src/sync/consent.ts @@ -0,0 +1,49 @@ +import { realpath, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { writeFileAtomic } from "../utils/atomic-file.js"; + +/** Where xtctx keeps what belongs to the user rather than to one project. */ +export function xtctxHome(): string { + return join(homedir(), ".xtctx"); +} + +function consentPath(): string { + return join(xtctxHome(), "cloud-projects.json"); +} + +/** + * Which projects may upload to xtctx cloud. + * + * A list in the user's home, not a setting in the project: `.xtctx/config.yaml` + * is the file a repository can commit, and a clone must not be able to opt + * its reader in to sending their transcripts anywhere. Logging in does not + * add to it either; the two are separate decisions. + */ +export async function projectKey(projectRoot: string): Promise { + const real = await realpath(projectRoot).catch(() => projectRoot); + return process.platform === "win32" ? real.toLowerCase() : real; +} + +async function readList(): Promise { + try { + const parsed = JSON.parse(await readFile(consentPath(), "utf-8")) as { projects?: unknown }; + return Array.isArray(parsed.projects) ? parsed.projects.filter((p): p is string => typeof p === "string") : []; + } catch { + return []; + } +} + +async function writeList(projects: string[]): Promise { + await writeFileAtomic(consentPath(), JSON.stringify({ projects }, null, 2), { mode: 0o600 }); +} + +export async function isOptedIn(projectRoot: string): Promise { + return (await readList()).includes(await projectKey(projectRoot)); +} + +export async function setOptedIn(projectRoot: string, optedIn: boolean): Promise { + const key = await projectKey(projectRoot); + const rest = (await readList()).filter((p) => p !== key); + await writeList(optedIn ? [...rest, key] : rest); +} diff --git a/src/sync/diff-sync.ts b/src/sync/diff-sync.ts new file mode 100644 index 0000000..13a5bc8 --- /dev/null +++ b/src/sync/diff-sync.ts @@ -0,0 +1,422 @@ +import Database, { type Database as DatabaseHandle } from "better-sqlite3"; +import { existsSync, realpathSync } from "node:fs"; +import { basename, join } from "node:path"; +import { assertSecureSyncUrl, callCloud, loadCredentials, resolveUploadCredentials, type SyncCredentials } from "./client.js"; +import { isOptedIn } from "./consent.js"; +import { getCanonicalRepoId } from "./normalizer.js"; +import { + accountKey, + acquireUploadLock, + readAccountState, + updateAccountState, + type SkippedMessage, + type UploadLock, +} from "./state.js"; +import { UPLOAD_LIMITS, fitContent, sanitizeMetadata } from "./upload-format.js"; +import { PROJECT_ROOT_SQL, normalizeRootForCompare } from "../handoff/queries.js"; + +export class NotLoggedInError extends Error { + constructor() { + super("Not logged in to xtctx cloud. Run `xtctx login` first."); + } +} + +export class NotOptedInError extends Error { + constructor() { + super("This project is not opted in to cloud sync. Run `xtctx sync enable` in it first."); + } +} + +/** The server no longer accepts this login: expired, signed out, or deleted. */ +export class CloudAuthError extends Error { + constructor(status: number) { + super( + status === 403 + ? "xtctx cloud refused this login (403): the account is not allowed on this server, or the token lacks the sync:write scope. Run `xtctx login` again." + : "Your xtctx cloud login is no longer valid (expired or signed out). Run `xtctx login`.", + ); + } +} + +export interface DiffSyncResult { + /** Messages sent this run. */ + syncedCount: number; + /** Sessions sent this run. */ + sessionCount: number; + /** Messages the server refused as too large this run; they are skipped from now on. */ + skipped: SkippedMessage[]; + upToDate: boolean; + /** Another process was uploading this project; nothing was done. */ + busy: boolean; +} + +/** + * Changes from the last minute are sent again on the next run. A write to the + * index can be stamped before this run's read and committed after it; going + * back a minute picks those up, and re-sending is harmless because uploads are + * idempotent. + */ +const CURSOR_LAG_MS = 60_000; +/** With nothing to send, still tell the server the project is in sync this often. */ +const REPORT_EVERY_MS = 15 * 60_000; +/** Above this many message ids the per-session reconcile does not fit in one request. */ +const MAX_KEEP_IDS = Math.floor((UPLOAD_LIMITS.targetBodyBytes - 4096) / 70); + +interface SessionRow { + session_ref: string; + tool: string; + source_session_id: string; + git_branch: string | null; + git_commit: string | null; + started_at: string; + last_activity_at: string; + preview: string | null; +} + +interface MessageRow { + id: string; + timestamp: string; + role: string; + content: string; + message_index: number; + content_hash: string; + metadata_json: string; +} + +interface UploadMessage { + id: string; + timestamp: string; + role: string; + content: string; + messageIndex: number; + contentHash: string; + metadataJson: string; +} + +/** One session's slice of an upload. A session too big for one request goes as several. */ +interface Part { + session: SessionRow; + messages: UploadMessage[]; + /** On a session's final part when reconciling: everything else the cloud holds for it is deleted. */ + keepIds?: string[]; +} + +function canonicalRoot(projectDir: string): string { + try { + return realpathSync(projectDir); + } catch { + return projectDir; + } +} + +const skipKey = (sessionRef: string, messageId: string) => `${sessionRef}\u0000${messageId}`; +const sessionKey = (tool: string, sourceSessionId: string) => `${tool}:${sourceSessionId}`; + +function toUpload(row: MessageRow): UploadMessage { + return { + id: row.id, + timestamp: row.timestamp, + role: row.role, + content: fitContent(row.content), + messageIndex: row.message_index, + contentHash: row.content_hash, + metadataJson: sanitizeMetadata(row.metadata_json), + }; +} + +const messageBytes = (m: UploadMessage) => Buffer.byteLength(JSON.stringify(m)) + 1; +const SESSION_OVERHEAD = 1024; + +/** Split one session's messages into parts that each fit a request. */ +function partsFor(session: SessionRow, messages: UploadMessage[], keepIds?: string[]): Part[] { + const parts: Part[] = []; + let current: UploadMessage[] = []; + let bytes = SESSION_OVERHEAD; + for (const m of messages) { + const size = messageBytes(m); + if (current.length > 0 && (bytes + size > UPLOAD_LIMITS.targetBodyBytes || current.length >= UPLOAD_LIMITS.maxMessagesPerRequest)) { + parts.push({ session, messages: current }); + current = []; + bytes = SESSION_OVERHEAD; + } + current.push(m); + bytes += size; + } + if (current.length > 0 || parts.length === 0) parts.push({ session, messages: current }); + if (keepIds) parts.push({ session, messages: [], keepIds }); + return parts; +} + +const partBytes = (p: Part) => + SESSION_OVERHEAD + p.messages.reduce((n, m) => n + messageBytes(m), 0) + (p.keepIds?.length ?? 0) * 70; + +/** Group parts into requests, in order, each under every per-request limit. */ +function packRequests(parts: Part[]): Part[][] { + const requests: Part[][] = []; + let current: Part[] = []; + let bytes = 0; + let messages = 0; + for (const p of parts) { + const size = partBytes(p); + if ( + current.length > 0 && + (bytes + size > UPLOAD_LIMITS.targetBodyBytes || + current.length >= UPLOAD_LIMITS.maxSessionsPerRequest || + messages + p.messages.length > UPLOAD_LIMITS.maxMessagesPerRequest) + ) { + requests.push(current); + current = []; + bytes = 0; + messages = 0; + } + current.push(p); + bytes += size; + messages += p.messages.length; + } + if (current.length > 0) requests.push(current); + return requests; +} + +/** + * Upload what this project's index has gained or changed since the last + * upload, for the logged-in account. + * + * Only sessions the index attributes to THIS project are read, compared the + * same way the index's own reads compare roots: an index can hold rows for + * another root (a copied `.xtctx/`, a renamed folder), and those stay here. + * + * Each session's upload is authoritative. New and changed messages are sent + * keyed by their local id, and the server answers with how many it now holds + * for the session. When that differs from the local count (a re-read here + * deleted or replaced messages, or an earlier version uploaded under other + * ids), the whole session is sent again with the full list of its ids, and + * the server deletes everything else. Every request is idempotent. + * + * Refuses unless someone is logged in AND this project is on the user's + * opt-in list. Only one process uploads a project at a time; another one + * finds it busy and does nothing. The position only moves after a run that + * sent everything, so a failure resends rather than skips. + */ +export async function runDiffSync(options: { projectDir: string }): Promise { + const { projectDir } = options; + if (!(await loadCredentials())) throw new NotLoggedInError(); + if (!(await isOptedIn(projectDir))) throw new NotOptedInError(); + const credentials = await resolveUploadCredentials(); + if (!credentials) throw new NotLoggedInError(); + assertSecureSyncUrl(credentials.syncUrl); + + const account = accountKey(credentials.user.id, credentials.syncUrl); + const lock = await acquireUploadLock(projectDir); + if (!lock) return { syncedCount: 0, sessionCount: 0, skipped: [], upToDate: false, busy: true }; + + const attemptAt = new Date().toISOString(); + try { + const outcome = await upload(projectDir, credentials, account, lock); + await updateAccountState(projectDir, account, (s) => ({ + ...s, + cursor: outcome.cursor, + lastAttemptAt: attemptAt, + lastSuccessAt: new Date().toISOString(), + lastError: undefined, + lastErrorAt: undefined, + lastReportedAt: outcome.reported ? new Date().toISOString() : s.lastReportedAt, + })); + return outcome.result; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await updateAccountState(projectDir, account, (s) => ({ + ...s, + lastAttemptAt: attemptAt, + lastError: message, + lastErrorAt: new Date().toISOString(), + })).catch(() => undefined); + throw err; + } finally { + await lock.release(); + } +} + +async function upload( + projectDir: string, + credentials: SyncCredentials, + account: string, + lock: UploadLock, +): Promise<{ result: DiffSyncResult; cursor: string; reported: boolean }> { + const result: DiffSyncResult = { syncedCount: 0, sessionCount: 0, skipped: [], upToDate: true, busy: false }; + const state = await readAccountState(projectDir, account); + const cursor = state.cursor ?? ""; + const nextCursor = new Date(Date.now() - CURSOR_LAG_MS).toISOString(); + + const dbPath = join(projectDir, ".xtctx", "state", "xtctx.db"); + if (!existsSync(dbPath)) return { result, cursor: nextCursor, reported: false }; + + const skipped = new Set(state.skipped.map((s) => skipKey(s.sessionRef, s.messageId))); + const { repoId } = await getCanonicalRepoId(projectDir); + const envelope = { + device: { id: credentials.deviceId, name: credentials.deviceName }, + project: { repoUrl: repoId, name: basename(projectDir) }, + }; + + const db: DatabaseHandle = new Database(dbPath, { readonly: true, fileMustExist: true }); + try { + const scopedRoot = normalizeRootForCompare(canonicalRoot(projectDir)); + const changed = db + .prepare( + `SELECT s.session_ref, s.tool, s.source_session_id, s.git_branch, s.git_commit, + s.started_at, s.last_activity_at, s.preview + FROM sessions s + WHERE ${PROJECT_ROOT_SQL.replace("project_root", "s.project_root")} = ? + AND (s.updated_at > ? + OR EXISTS (SELECT 1 FROM messages m WHERE m.session_ref = s.session_ref AND m.indexed_at > ?)) + ORDER BY s.last_activity_at ASC, s.session_ref ASC`, + ) + .all(scopedRoot, cursor, cursor) as SessionRow[]; + + const messagesSince = db.prepare( + `SELECT id, timestamp, role, content, message_index, content_hash, metadata_json + FROM messages WHERE session_ref = ? AND indexed_at > ? + ORDER BY timestamp ASC, message_index ASC, id ASC`, + ); + const localIds = (sessionRef: string) => + (db.prepare(`SELECT id FROM messages WHERE session_ref = ?`).all(sessionRef) as Array<{ id: string }>) + .map((r) => r.id) + .filter((id) => !skipped.has(skipKey(sessionRef, id))); + const unskipped = (sessionRef: string, rows: MessageRow[]) => + rows.filter((r) => !skipped.has(skipKey(sessionRef, r.id))).map(toUpload); + + let reported = false; + const send = async (parts: Part[]): Promise> => { + const counts = new Map(); + for (const request of packRequests(parts)) { + await sendRequest(request, counts); + reported = true; + await lock.touch(); + } + return counts; + }; + + /** + * Send one request, skipping exactly the message the server refuses as + * too large and splitting a request it refuses as too big overall, so one + * oversized message never blocks the rest of the project. + */ + const sendRequest = async (parts: Part[], counts: Map): Promise => { + for (;;) { + const res = await callCloud(credentials, "POST", "/api/stream", { + ...envelope, + sessions: parts.map((p) => ({ + tool: p.session.tool, + sourceSessionId: p.session.source_session_id, + gitBranch: p.session.git_branch, + gitCommit: p.session.git_commit, + startedAt: p.session.started_at, + lastActivityAt: p.session.last_activity_at, + // What the server keeps; no more is sent than it stores. + preview: p.session.preview?.slice(0, 160) ?? null, + messages: p.messages, + ...(p.keepIds ? { keepIds: p.keepIds } : {}), + })), + }); + + if (res.ok) { + const body = (await res.json().catch(() => ({}))) as { + sessions?: Array<{ tool: string; sourceSessionId: string; messageCount: number }>; + }; + for (const s of body.sessions ?? []) counts.set(sessionKey(s.tool, s.sourceSessionId), s.messageCount); + for (const p of parts) result.syncedCount += p.messages.length; + return; + } + + const detail = (await res.json().catch(() => ({}))) as Record; + if (res.status === 401 || res.status === 403) throw new CloudAuthError(res.status); + if (res.status === 426) { + throw new Error(`xtctx cloud needs a newer client: ${String(detail.detail ?? "update xtctx")}`); + } + if (res.status === 413 && detail.error === "message_too_large" && typeof detail.messageId === "string") { + const part = parts.find( + (p) => + p.session.tool === detail.tool && + p.session.source_session_id === detail.sourceSessionId && + p.messages.some((m) => m.id === detail.messageId), + ); + if (part) { + await skip(part, detail.messageId, `larger than the server accepts (${String(detail.limit)} bytes)`); + continue; + } + } + if (res.status === 413) { + if (parts.length > 1) { + const half = Math.ceil(parts.length / 2); + await sendRequest(parts.slice(0, half), counts); + await sendRequest(parts.slice(half), counts); + return; + } + const [only] = parts; + if (only.messages.length > 1) { + const half = Math.ceil(only.messages.length / 2); + await sendRequest([{ ...only, messages: only.messages.slice(0, half), keepIds: undefined }], counts); + await sendRequest([{ ...only, messages: only.messages.slice(half) }], counts); + return; + } + if (only.messages.length === 1) { + await skip(only, only.messages[0].id, "the request carrying it alone was larger than the server accepts"); + continue; + } + } + throw new Error(`Upload failed (${res.status}): ${String(detail.error ?? res.statusText)}`); + } + }; + + const skip = async (part: Part, messageId: string, reason: string) => { + const entry: SkippedMessage = { + sessionRef: part.session.session_ref, + messageId, + reason, + at: new Date().toISOString(), + }; + part.messages = part.messages.filter((m) => m.id !== messageId); + if (part.keepIds) part.keepIds = part.keepIds.filter((id) => id !== messageId); + skipped.add(skipKey(entry.sessionRef, messageId)); + result.skipped.push(entry); + await updateAccountState(projectDir, account, (s) => ({ ...s, skipped: [...s.skipped, entry] })); + }; + + // Pass 1: what changed, per session. + const firstPass: Part[] = []; + for (const session of changed) { + const rows = messagesSince.all(session.session_ref, cursor) as MessageRow[]; + firstPass.push(...partsFor(session, unskipped(session.session_ref, rows))); + } + const counts = await send(firstPass); + + // Pass 2: sessions whose cloud copy does not match, sent whole with their ids. + const reconcile: Part[] = []; + for (const session of changed) { + const ids = localIds(session.session_ref); + if (counts.get(sessionKey(session.tool, session.source_session_id)) === ids.length) continue; + const rows = db + .prepare( + `SELECT id, timestamp, role, content, message_index, content_hash, metadata_json + FROM messages WHERE session_ref = ? ORDER BY timestamp ASC, message_index ASC, id ASC`, + ) + .all(session.session_ref) as MessageRow[]; + // A session too large to name in one request is sent but not pruned. + reconcile.push(...partsFor(session, unskipped(session.session_ref, rows), ids.length <= MAX_KEEP_IDS ? ids : undefined)); + } + if (reconcile.length > 0) await send(reconcile); + + // Nothing to send: still let the server know this project is in sync, + // now and then, so a reader can tell "synced, nothing new" from "never". + const stale = !state.lastReportedAt || Date.now() - Date.parse(state.lastReportedAt) > REPORT_EVERY_MS; + if (changed.length === 0 && stale) { + await sendRequest([], new Map()); + reported = true; + } + + result.sessionCount = changed.length; + result.upToDate = result.syncedCount === 0 && result.skipped.length === 0; + return { result, cursor: nextCursor, reported }; + } finally { + db.close(); + } +} diff --git a/src/sync/normalizer.ts b/src/sync/normalizer.ts new file mode 100644 index 0000000..051b9d9 --- /dev/null +++ b/src/sync/normalizer.ts @@ -0,0 +1,45 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import { realpath } from "node:fs/promises"; +import { createHash } from "node:crypto"; + +const execAsync = promisify(exec); + +/** + * Identify a project for the cloud: the normalised git remote when there is + * one ("github.com/fstubner/xtctx"), otherwise a hash of where it lives. + * + * Nothing is written into the project for this. An earlier version minted a + * `.xtctx/project.id` file in any folder that was not a git repository, which + * is a change to the user's tree made by an upload feature. + */ +export async function getCanonicalRepoId(projectDir: string): Promise<{ repoId: string; repoRoot: string }> { + try { + const { stdout: rootStdout } = await execAsync("git rev-parse --show-toplevel", { cwd: projectDir }); + const repoRoot = rootStdout.trim(); + const { stdout: originStdout } = await execAsync("git config --get remote.origin.url", { cwd: repoRoot }); + const origin = originStdout.trim(); + if (origin) return { repoId: normalizeRemote(origin), repoRoot }; + return { repoId: await pathId(repoRoot), repoRoot }; + } catch { + // Not a git repository, or one with no origin. + return { repoId: await pathId(projectDir), repoRoot: projectDir }; + } +} + +/** + * "git@github.com:user/repo.git" and "https://github.com/user/repo.git" both + * become "github.com/user/repo". Credentials in a URL are dropped. + */ +export function normalizeRemote(remote: string): string { + return remote + .replace(/\.git$/i, "") + .replace(/^git@([^:]+):/, "$1/") + .replace(/^[a-z+]+:\/\//i, "") + .replace(/^[^@/]+@/, ""); +} + +async function pathId(dir: string): Promise { + const real = await realpath(dir).catch(() => dir); + return `local:${createHash("sha256").update(real).digest("hex").slice(0, 16)}`; +} diff --git a/src/sync/report.ts b/src/sync/report.ts new file mode 100644 index 0000000..e9492fd --- /dev/null +++ b/src/sync/report.ts @@ -0,0 +1,39 @@ +import { accountKey, readAccountState } from "./state.js"; +import { EnvCredentialsRefusedError, loadSavedCredentials, resolveUploadCredentials } from "./client.js"; +import { isOptedIn } from "./consent.js"; + +/** + * Cloud sync, as lines for a status report: whether this project uploads, + * as whom, and how the last attempt went. Reads files in ~/.xtctx only; it + * never calls the server. + */ +export async function describeCloudSync(projectRoot: string): Promise { + const optedIn = await isOptedIn(projectRoot); + const saved = await loadSavedCredentials(); + let effective = saved; + let envProblem: string | undefined; + try { + effective = await resolveUploadCredentials(); + } catch (err) { + if (err instanceof EnvCredentialsRefusedError) envProblem = err.message; + else throw err; + } + + if (!optedIn) { + return [`off for this project${effective ? ` (logged in as ${effective.user.username})` : ""}; \`xtctx sync enable\` turns it on`]; + } + if (envProblem) return ["on, but NOT uploading:", envProblem]; + if (!effective) return ["on, but NOT uploading: not logged in (run `xtctx login`)"]; + + const state = await readAccountState(projectRoot, accountKey(effective.user.id, effective.syncUrl)); + const lines = [`on; uploading as ${effective.user.username} to ${effective.syncUrl}, device "${effective.deviceName}"`]; + lines.push(`last upload: ${state.lastSuccessAt ?? "never"}`); + if (state.lastError && (!state.lastSuccessAt || (state.lastErrorAt ?? "") > state.lastSuccessAt)) { + lines.push(`last attempt FAILED at ${state.lastErrorAt}: ${state.lastError}`); + } + if (state.skipped.length > 0) { + lines.push(`${state.skipped.length} message(s) skipped as too large for the server:`); + for (const s of state.skipped.slice(-5)) lines.push(` ${s.sessionRef} ${s.messageId}: ${s.reason}`); + } + return lines; +} diff --git a/src/sync/state.ts b/src/sync/state.ts new file mode 100644 index 0000000..bb23078 --- /dev/null +++ b/src/sync/state.ts @@ -0,0 +1,176 @@ +import { createHash } from "node:crypto"; +import { mkdir, open, readFile, stat, unlink, utimes } from "node:fs/promises"; +import { join } from "node:path"; +import { writeFileAtomic } from "../utils/atomic-file.js"; +import { projectKey, xtctxHome } from "./consent.js"; + +/** + * Upload bookkeeping, per project and per account, in the user's home. + * + * Not in the project's index: the index is derived data that gets set aside + * and rebuilt, and a `.xtctx/` copied into another folder would carry another + * project's position with it. Keyed by account (user and server) so that + * logging in as someone else uploads the project to them in full. + */ + +export interface SkippedMessage { + /** The local session_ref, "tool:sourceSessionId". */ + sessionRef: string; + messageId: string; + reason: string; + at: string; +} + +export interface AccountSyncState { + /** Messages and sessions changed after this local `indexed_at`/`updated_at` are still to upload. */ + cursor?: string; + lastAttemptAt?: string; + lastSuccessAt?: string; + lastError?: string; + lastErrorAt?: string; + /** When the server was last told this project is in sync, even with nothing to send. */ + lastReportedAt?: string; + /** Messages the server refused as too large; never sent again, and left out of the count. */ + skipped: SkippedMessage[]; +} + +interface ProjectSyncState { + projectRoot: string; + accounts: Record; +} + +function syncDir(): string { + return join(xtctxHome(), "sync"); +} + +async function fileStem(projectRoot: string): Promise { + return createHash("sha256").update(await projectKey(projectRoot)).digest("hex").slice(0, 24); +} + +export function accountKey(userId: string, syncUrl: string): string { + return `${userId}@${syncUrl.replace(/\/+$/, "")}`; +} + +async function readProjectState(projectRoot: string): Promise { + try { + const parsed = JSON.parse(await readFile(join(syncDir(), `${await fileStem(projectRoot)}.json`), "utf-8")); + if (parsed && typeof parsed === "object" && parsed.accounts && typeof parsed.accounts === "object") { + return parsed as ProjectSyncState; + } + } catch { + // none yet, or unreadable: start clean, which at worst re-uploads (harmless) + } + return { projectRoot, accounts: {} }; +} + +export async function readAccountState(projectRoot: string, account: string): Promise { + const state = await readProjectState(projectRoot); + const found = state.accounts[account]; + return { ...found, skipped: Array.isArray(found?.skipped) ? found.skipped : [] }; +} + +/** Every account's state for this project, for status output. */ +export async function readAllAccountStates(projectRoot: string): Promise> { + return (await readProjectState(projectRoot)).accounts; +} + +/** + * Change one account's state. The cursor only ever moves forward, whatever + * the update says: two writers racing must not hand a later run an earlier + * position (which would re-upload) or, worse, a run with stale data a later + * one (which would skip). + */ +export async function updateAccountState( + projectRoot: string, + account: string, + update: (current: AccountSyncState) => AccountSyncState, +): Promise { + const state = await readProjectState(projectRoot); + const current = { ...state.accounts[account], skipped: state.accounts[account]?.skipped ?? [] }; + const next = update(current); + if (current.cursor && (!next.cursor || next.cursor < current.cursor)) next.cursor = current.cursor; + state.projectRoot = projectRoot; + state.accounts[account] = next; + await mkdir(syncDir(), { recursive: true }); + await writeFileAtomic(join(syncDir(), `${await fileStem(projectRoot)}.json`), JSON.stringify(state, null, 2), { + mode: 0o600, + }); + return next; +} + +/** A lock not touched for this long belongs to a process that died mid-upload. */ +export const LOCK_STALE_MS = 10 * 60 * 1000; + +export interface UploadLock { + /** Mark the lock as still in use, during a long upload. */ + touch(): Promise; + release(): Promise; +} + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + // EPERM: it exists, it is just not ours to signal. + return (err as NodeJS.ErrnoException).code === "EPERM"; + } +} + +/** + * One uploader per project at a time, across every process on the machine. + * + * Every MCP server process for an opted-in project syncs on a timer, and two + * agents open in one project means two servers. Null when another live + * process holds the lock; a lock left by a process that is gone, or not + * touched in LOCK_STALE_MS, is taken over. + */ +export async function acquireUploadLock(projectRoot: string): Promise { + await mkdir(syncDir(), { recursive: true }); + const path = join(syncDir(), `${await fileStem(projectRoot)}.lock`); + const token = `${process.pid}:${Date.now()}:${Math.random()}`; + + const create = async (): Promise => { + try { + const handle = await open(path, "wx", 0o600); + await handle.writeFile(JSON.stringify({ pid: process.pid, token })); + await handle.close(); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") return false; + throw err; + } + }; + + if (!(await create())) { + let stale = false; + try { + const [info, raw] = await Promise.all([stat(path), readFile(path, "utf-8")]); + const holder = JSON.parse(raw) as { pid?: number }; + stale = Date.now() - info.mtimeMs > LOCK_STALE_MS || (typeof holder.pid === "number" && !alive(holder.pid)); + } catch { + // Unreadable or half-written by a creator that just won: leave it; the + // next tick looks again. + return null; + } + if (!stale) return null; + await unlink(path).catch(() => undefined); + // Two processes taking over at once: one create wins, the other is busy. + if (!(await create())) return null; + } + + return { + async touch() { + const now = new Date(); + await utimes(path, now, now).catch(() => undefined); + }, + async release() { + try { + const holder = JSON.parse(await readFile(path, "utf-8")) as { token?: string }; + if (holder.token === token) await unlink(path); + } catch { + // already gone + } + }, + }; +} diff --git a/src/sync/upload-format.ts b/src/sync/upload-format.ts new file mode 100644 index 0000000..610f272 --- /dev/null +++ b/src/sync/upload-format.ts @@ -0,0 +1,91 @@ +/** + * What a message looks like on its way to xtctx cloud. + * + * The limits mirror the server's (cloud/src/db.ts `LIMITS`). The client stays + * under them on its own, so a refusal only happens when the two disagree, + * and then the sync skips the one message and carries on. + */ +export const UPLOAD_LIMITS = { + maxBodyBytes: 1024 * 1024, + maxMessageBytes: 64 * 1024, + maxMetadataBytes: 4 * 1024, + maxSessionsPerRequest: 10, + maxMessagesPerRequest: 500, + /** Requests are packed to this, leaving room for the envelope under maxBodyBytes. */ + targetBodyBytes: 768 * 1024, +} as const; + +/** + * Metadata fields that may leave the machine. Everything else is dropped: + * `sourcePath` (an absolute transcript or working-directory path), + * `referencedFiles` (paths), and anything a scraper adds later until it is + * looked at and listed here. + * + * Looked at and decided: `subagent` (a flag) and `subagentType` (the kind of + * subagent Cursor names, such as "explore") are sent, so a reader of the + * cloud copy can tell a subagent's turns from the main agent's, as the local + * index can. `parentToolCallId` (Copilot CLI) is not: it is an id into the + * tool's own records, which the cloud does not have, so it says nothing there. + */ +const UPLOADED_METADATA_KEYS = [ + "messageIndex", + "tokenEstimate", + "toolCalls", + "toolName", + "model", + "stepType", + "artifactType", + "artifactName", + "sessionType", + "approvalMode", + "sandboxed", + "layer", + "costUsd", + "gitBranch", + "gitCommit", + "subagent", + "subagentType", +] as const; + +export const UPLOADED_METADATA_FIELDS: readonly string[] = UPLOADED_METADATA_KEYS; + +const ABSOLUTE_PATH = /^(?:[a-zA-Z]:[\\/]|\\\\|\/|~[\\/]|file:)/; + +function safeValue(value: unknown): unknown { + if (typeof value === "string") return ABSOLUTE_PATH.test(value.trim()) ? undefined : value; + if (typeof value === "number" || typeof value === "boolean") return value; + if (Array.isArray(value)) { + const kept = value.map(safeValue).filter((v) => v !== undefined && typeof v !== "object"); + return kept.length > 0 ? kept : undefined; + } + return undefined; // nested objects are not uploaded +} + +/** The stored metadata JSON reduced to UPLOADED_METADATA_FIELDS, with no absolute paths. */ +export function sanitizeMetadata(json: string | null | undefined): string { + let parsed: unknown; + try { + parsed = JSON.parse(json ?? "{}"); + } catch { + return "{}"; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "{}"; + const out: Record = {}; + for (const key of UPLOADED_METADATA_KEYS) { + const value = safeValue((parsed as Record)[key]); + if (value !== undefined) out[key] = value; + } + const text = JSON.stringify(out); + return Buffer.byteLength(text) > UPLOAD_LIMITS.maxMetadataBytes ? "{}" : text; +} + +/** Content cut to the server's per-message limit, with a marker saying so. */ +export function fitContent(content: string): string { + const bytes = Buffer.byteLength(content); + if (bytes <= UPLOAD_LIMITS.maxMessageBytes) return content; + const marker = `\n\n[xtctx: truncated for upload; the original is ${bytes} bytes]`; + // A few bytes of slack: cutting inside a multi-byte character leaves a + // replacement character, which can be longer than what it replaced. + const keep = UPLOAD_LIMITS.maxMessageBytes - Buffer.byteLength(marker) - 4; + return Buffer.from(content, "utf-8").subarray(0, keep).toString("utf-8") + marker; +} diff --git a/src/utils/atomic-file.ts b/src/utils/atomic-file.ts index 5716a47..806d7fd 100644 --- a/src/utils/atomic-file.ts +++ b/src/utils/atomic-file.ts @@ -3,6 +3,8 @@ import { mkdir, realpath, rename, rm, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; interface WriteFileAtomicOptions { + /** File mode for a secret, e.g. 0o600. Ignored on Windows, which has no such bits. */ + mode?: number; /** * Directory the write must stay inside, after symlinks are resolved. * @@ -88,7 +90,7 @@ export async function writeFileAtomic( // Random suffix, and `wx` so the open fails rather than following a // symlink someone pre-created at a guessable temp path. const tmpPath = `${filePath}.${randomBytes(6).toString("hex")}.xtctx-tmp`; - await writeFile(tmpPath, content, { encoding: "utf-8", flag: "wx" }); + await writeFile(tmpPath, content, { encoding: "utf-8", flag: "wx", mode: options.mode }); try { await rename(tmpPath, filePath); } catch (err) { diff --git a/tests/sync/auto-sync.test.ts b/tests/sync/auto-sync.test.ts new file mode 100644 index 0000000..6f40a2e --- /dev/null +++ b/tests/sync/auto-sync.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { startAutoSync } from "@xtctx/sync/auto-sync"; +import { NotOptedInError, type DiffSyncResult } from "@xtctx/sync/diff-sync"; + +const result = (n: number): DiffSyncResult => ({ syncedCount: n, sessionCount: n ? 1 : 0, skipped: [], upToDate: n === 0, busy: false }); + +describe("auto sync", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("syncs on an interval and uploads once more on stop", async () => { + const sync = vi.fn(async () => result(0)); + const sink = startAutoSync({ projectRoot: "/p", log: () => {}, intervalMs: 1000, sync }); + + expect(sync).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(3000); + expect(sync).toHaveBeenCalledTimes(3); + + await sink.stop(); + expect(sync).toHaveBeenCalledTimes(4); // the flush + await vi.advanceTimersByTimeAsync(5000); + expect(sync).toHaveBeenCalledTimes(4); // and then nothing + }); + + it("never runs two syncs at once", async () => { + let running = 0; + let overlapped = false; + const sync = vi.fn(async () => { + running++; + overlapped ||= running > 1; + await new Promise((r) => setTimeout(r, 2500)); // slower than the interval + running--; + return result(0); + }); + const sink = startAutoSync({ projectRoot: "/p", log: () => {}, intervalMs: 1000, sync }); + + await vi.advanceTimersByTimeAsync(10_000); + const stopped = sink.stop(); + await vi.advanceTimersByTimeAsync(10_000); + await stopped; + expect(overlapped).toBe(false); + }); + + it("is quiet when the project is not opted in, and says a real failure once", async () => { + const lines: string[] = []; + let mode: "optout" | "boom" = "optout"; + const sync = vi.fn(async () => { + throw mode === "optout" ? new NotOptedInError() : new Error("boom"); + }); + const sink = startAutoSync({ projectRoot: "/p", log: (l) => lines.push(l), intervalMs: 1000, sync }); + + await vi.advanceTimersByTimeAsync(3000); + expect(lines).toEqual([]); + + mode = "boom"; + await vi.advanceTimersByTimeAsync(3000); + expect(lines).toEqual(["xtctx: cloud sync failed: boom"]); + await sink.stop(); + }); + + it("gives up on a flush that hangs instead of holding shutdown", async () => { + const sync = vi.fn(() => new Promise(() => {})); + const sink = startAutoSync({ projectRoot: "/p", log: () => {}, intervalMs: 60_000, flushTimeoutMs: 500, sync }); + + const stopped = sink.stop(); + await vi.advanceTimersByTimeAsync(500); + await expect(stopped).resolves.toBeUndefined(); + }); +}); diff --git a/tests/sync/credentials.test.ts b/tests/sync/credentials.test.ts new file mode 100644 index 0000000..b1905ef --- /dev/null +++ b/tests/sync/credentials.test.ts @@ -0,0 +1,41 @@ +import { readFileSync, statSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { deleteCredentials, getCredentialsPath, saveCredentials } from "@xtctx/sync/client"; +import { isOptedIn, setOptedIn } from "@xtctx/sync/consent"; +import { sandbox } from "./helpers"; + +const creds = { + token: "secret-token", + user: { id: "github:1", username: "u" }, + deviceId: "dev", + deviceName: "laptop", + syncUrl: "https://sync.test", +}; + +describe("credentials and consent files", () => { + let box: ReturnType; + beforeEach(() => { + box = sandbox(); + }); + afterEach(() => box.cleanup()); + + it.skipIf(process.platform === "win32")("writes credentials readable by the owner only", async () => { + await saveCredentials(creds); + expect(statSync(getCredentialsPath()).mode & 0o777).toBe(0o600); + }); + + it("round-trips and deletes credentials", async () => { + await saveCredentials(creds); + expect(JSON.parse(readFileSync(getCredentialsPath(), "utf-8")).token).toBe("secret-token"); + await deleteCredentials(); + expect(() => statSync(getCredentialsPath())).toThrow(); + }); + + it("keeps the opt-in per project, in the home directory", async () => { + expect(await isOptedIn(box.project)).toBe(false); + await setOptedIn(box.project, true); + expect(await isOptedIn(box.project)).toBe(true); + await setOptedIn(box.project, false); + expect(await isOptedIn(box.project)).toBe(false); + }); +}); diff --git a/tests/sync/diff-sync.test.ts b/tests/sync/diff-sync.test.ts new file mode 100644 index 0000000..f48ce24 --- /dev/null +++ b/tests/sync/diff-sync.test.ts @@ -0,0 +1,368 @@ +/** + * What leaves the machine, and when. + * + * Every case runs against the real index schema in a throwaway home. The first + * version of this feature selected a column the index does not have, so it + * could never have uploaded anything; only a real database shows that. + */ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { hostname } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EnvCredentialsRefusedError, saveCredentials, type SyncCredentials } from "@xtctx/sync/client"; +import { setOptedIn } from "@xtctx/sync/consent"; +import { CloudAuthError, NotOptedInError, runDiffSync } from "@xtctx/sync/diff-sync"; +import { accountKey, acquireUploadLock, readAccountState, updateAccountState } from "@xtctx/sync/state"; +import { UPLOAD_LIMITS } from "@xtctx/sync/upload-format"; +import { editIndex, fakeCloud, localId, sandbox, seedIndex } from "./helpers"; + +const creds = (id = "github:1", over: Partial = {}): SyncCredentials => ({ + token: "tok", + user: { id, username: "u" }, + deviceId: "dev", + deviceName: "device-abc123", + syncUrl: "https://sync.test", + ...over, +}); + +describe("diff sync", () => { + let box: ReturnType; + let cloud: ReturnType; + + beforeEach(() => { + box = sandbox(); + cloud = fakeCloud(); + vi.stubGlobal("fetch", cloud.fetch); + }); + afterEach(() => { + vi.unstubAllGlobals(); + box.cleanup(); + }); + + const optedIn = async (c = creds()) => { + await saveCredentials(c); + await setOptedIn(box.project, true); + }; + const state = () => readAccountState(box.project, accountKey("github:1", "https://sync.test")); + + it("uploads nothing for a logged-in user whose project is not opted in", async () => { + await saveCredentials(creds()); + seedIndex(box.project, 3); + await expect(runDiffSync({ projectDir: box.project })).rejects.toBeInstanceOf(NotOptedInError); + expect(cloud.requests).toEqual([]); + }); + + it("uploads nothing when only XTCTX_TOKEN is set", async () => { + vi.stubEnv("XTCTX_TOKEN", "a.b.c"); + seedIndex(box.project, 3); + await expect(runDiffSync({ projectDir: box.project })).rejects.toBeInstanceOf(NotOptedInError); + expect(cloud.requests).toEqual([]); + }); + + it("uploads an opted-in project, and only what is new on the next run", async () => { + await optedIn(); + seedIndex(box.project, 3); + + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(3); + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(0); + + // Stamped when written, as the scanner does. + seedIndex(box.project, 2, new Date().toISOString(), 3); + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(2); + expect(cloud.stored()).toHaveLength(5); + expect(cloud.uploads()[0].headers.get("X-Xtctx-Client")).toMatch(/^xtctx\/\d/); + }); + + it("does not advance past a failed upload: the next run sends it all", async () => { + await optedIn(); + seedIndex(box.project, 3); + cloud.state.failStatus = 500; + + await expect(runDiffSync({ projectDir: box.project })).rejects.toThrow(/500/); + const failed = await state(); + expect(failed.cursor).toBeUndefined(); + expect(failed.lastError).toMatch(/500/); + + cloud.state.failStatus = undefined; + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(3); + const ok = await state(); + expect(ok.lastError).toBeUndefined(); + expect(ok.lastSuccessAt).toBeTruthy(); + }); + + it("makes a local deletion reach the cloud", async () => { + await optedIn(); + seedIndex(box.project, 3); + await runDiffSync({ projectDir: box.project }); + expect(cloud.stored()).toHaveLength(3); + + // A re-read dropped a message and touched the session. + editIndex(box.project, (db) => { + db.prepare("DELETE FROM messages WHERE id = ?").run(localId(2)); + db.prepare("UPDATE sessions SET updated_at = ?").run(new Date().toISOString()); + }); + await runDiffSync({ projectDir: box.project }); + + expect(cloud.stored().map((m) => m.id).sort()).toEqual([localId(0), localId(1)]); + }); + + it("replaces a message whose content changed at the same position instead of duplicating it", async () => { + await optedIn(); + seedIndex(box.project, 2); + await runDiffSync({ projectDir: box.project }); + + // The scanner's re-read: the old row goes, the new one (new id) comes in. + editIndex(box.project, (db) => db.prepare("DELETE FROM messages WHERE id = ?").run(localId(1))); + seedIndex(box.project, 1, { from: 1, indexedAt: new Date().toISOString(), content: () => "message 1, edited" }); + // Same index 1 but a fresh id would collide with localId(1); give it another. + editIndex(box.project, (db) => db.prepare("UPDATE messages SET id = ? WHERE content = 'message 1, edited'").run(localId(99))); + + await runDiffSync({ projectDir: box.project }); + expect(cloud.stored().map((m) => m.content).sort()).toEqual(["message 0", "message 1, edited"]); + }); + + it("uploads only the sessions the index attributes to this project, compared the way the index compares roots", async () => { + await optedIn(); + // Stored with a trailing separator and in another case: the same root to the index. + const sameRoot = (process.platform === "win32" ? box.project.toUpperCase() : box.project) + "/"; + seedIndex(box.project, 2, { projectRoot: sameRoot }); + seedIndex(box.project, 2, { sessionId: "elsewhere", projectRoot: join(box.root, "some-other-project") }); + + await runDiffSync({ projectDir: box.project }); + + expect(cloud.stored("s1")).toHaveLength(2); + expect(cloud.stored("elsewhere")).toHaveLength(0); + expect(JSON.stringify(cloud.requests)).not.toContain("elsewhere"); + }); + + it("sends no absolute paths, source pointers or hostname", async () => { + await optedIn(); + seedIndex(box.project, 1, { + sourcePointer: "/home/alice/.gemini/antigravity/brain/x/task.md", + metadata: { + messageIndex: 0, + toolName: "run_command", + sourcePath: "C:\\Users\\alice\\repo", + referencedFiles: ["/home/alice/repo/a.ts"], + artifactName: "/home/alice/notes.md", + model: "gemini", + }, + }); + + await runDiffSync({ projectDir: box.project }); + + const sent = JSON.stringify(cloud.uploads()); + expect(sent).not.toContain("alice"); + expect(sent).not.toContain("sourcePointer"); + expect(sent).not.toContain(hostname()); + expect(JSON.parse(cloud.stored()[0].metadataJson)).toEqual({ messageIndex: 0, toolName: "run_command", model: "gemini" }); + expect(cloud.uploads()[0].body.project.name).toBe("project"); + expect(cloud.uploads()[0].body.device.name).toBe("device-abc123"); + }); + + it("sends the subagent markers the scrapers add, and not Copilot CLI's parent tool-call id", async () => { + await optedIn(); + seedIndex(box.project, 1, { + metadata: { messageIndex: 0, subagent: true, subagentType: "explore", parentToolCallId: "call_8f2a" }, + }); + + await runDiffSync({ projectDir: box.project }); + + expect(JSON.parse(cloud.stored()[0].metadataJson)).toEqual({ messageIndex: 0, subagent: true, subagentType: "explore" }); + }); + + it("truncates a message over the server's limit, with a marker", async () => { + await optedIn(); + seedIndex(box.project, 1, { content: () => "x".repeat(UPLOAD_LIMITS.maxMessageBytes * 2) }); + await runDiffSync({ projectDir: box.project }); + const content = cloud.stored()[0].content; + expect(Buffer.byteLength(content)).toBeLessThanOrEqual(UPLOAD_LIMITS.maxMessageBytes); + expect(content).toContain("[xtctx: truncated for upload"); + }); + + it("skips and records a message the server refuses as too large, and the rest still uploads", async () => { + await optedIn(); + seedIndex(box.project, 3); + cloud.state.tooLarge.add(localId(1)); + + const result = await runDiffSync({ projectDir: box.project }); + + expect(result.skipped.map((s) => s.messageId)).toEqual([localId(1)]); + expect(cloud.stored().map((m) => m.id).sort()).toEqual([localId(0), localId(2)]); + expect((await state()).skipped).toEqual([expect.objectContaining({ messageId: localId(1), sessionRef: "claude-code:s1" })]); + + // Not offered again, and not counted against the session. + const before = cloud.sentMessages().length; + editIndex(box.project, (db) => db.prepare("UPDATE sessions SET updated_at = ?").run(new Date().toISOString())); + await runDiffSync({ projectDir: box.project }); + expect(cloud.sentMessages().slice(before).map((m) => m.id)).not.toContain(localId(1)); + }); + + it("splits a large backlog across requests that stay inside the limits", async () => { + await optedIn(); + seedIndex(box.project, 1200, { content: (i) => `message ${i} ${"y".repeat(1500)}` }); + await runDiffSync({ projectDir: box.project }); + expect(cloud.stored()).toHaveLength(1200); + for (const r of cloud.uploads()) { + expect(Buffer.byteLength(JSON.stringify(r.body))).toBeLessThanOrEqual(UPLOAD_LIMITS.maxBodyBytes); + const messages = r.body.sessions.reduce((n, s) => n + s.messages.length, 0); + expect(messages).toBeLessThanOrEqual(UPLOAD_LIMITS.maxMessagesPerRequest); + } + }); + + it("starts over for a different account", async () => { + await setOptedIn(box.project, true); + seedIndex(box.project, 3); + await saveCredentials(creds("github:1")); + await runDiffSync({ projectDir: box.project }); + + await saveCredentials(creds("github:2")); + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(3); + }); + + it("writes nothing into the project, and keeps its position outside the index", async () => { + await optedIn(); + seedIndex(box.project, 1); + await runDiffSync({ projectDir: box.project }); + expect(existsSync(join(box.project, ".xtctx", "project.id"))).toBe(false); + expect(String(cloud.uploads()[0].body.project.repoUrl)).toMatch(/^local:[0-9a-f]{16}$/); + expect((await state()).cursor).toBeTruthy(); + }); + + it("tells the server a project with nothing new is in sync, but not on every run", async () => { + await optedIn(); + seedIndex(box.project, 1); + await runDiffSync({ projectDir: box.project }); + const after = cloud.uploads().length; + await runDiffSync({ projectDir: box.project }); + expect(cloud.uploads().length).toBe(after); // reported moments ago + + await updateAccountState(box.project, accountKey("github:1", "https://sync.test"), (s) => ({ + ...s, + lastReportedAt: "2026-01-01T00:00:00.000Z", + })); + await runDiffSync({ projectDir: box.project }); + expect(cloud.uploads().length).toBe(after + 1); + expect(cloud.uploads().at(-1)!.body.sessions).toEqual([]); + }); + + it("refuses to send a token over plain http to a remote host", async () => { + await optedIn(creds("github:1", { syncUrl: "http://example.com" })); + seedIndex(box.project, 1); + await expect(runDiffSync({ projectDir: box.project })).rejects.toThrow(/https/); + expect(cloud.requests).toEqual([]); + }); + + it("says to log in again when the server refuses the token", async () => { + await optedIn(); + seedIndex(box.project, 1); + cloud.state.failStatus = 401; + await expect(runDiffSync({ projectDir: box.project })).rejects.toBeInstanceOf(CloudAuthError); + expect((await state()).lastError).toMatch(/xtctx login/); + }); + + it("an opt-in for one project does not cover another", async () => { + await saveCredentials(creds()); + const other = join(box.project, "..", "other"); + mkdirSync(other); + writeFileSync(join(other, "marker"), ""); + await setOptedIn(other, true); + seedIndex(box.project, 1); + await expect(runDiffSync({ projectDir: box.project })).rejects.toBeInstanceOf(NotOptedInError); + }); +}); + +describe("environment credentials", () => { + let box: ReturnType; + let cloud: ReturnType; + beforeEach(async () => { + box = sandbox(); + cloud = fakeCloud(); + vi.stubGlobal("fetch", cloud.fetch); + await saveCredentials(creds()); + await setOptedIn(box.project, true); + seedIndex(box.project, 1); + }); + afterEach(() => { + vi.unstubAllGlobals(); + box.cleanup(); + }); + + it("refuses to upload with an XTCTX_TOKEN that differs from the saved login", async () => { + vi.stubEnv("XTCTX_TOKEN", "someone-elses.token.x"); + await expect(runDiffSync({ projectDir: box.project })).rejects.toBeInstanceOf(EnvCredentialsRefusedError); + expect(cloud.requests).toEqual([]); + }); + + it("refuses to send the saved token to an XTCTX_SYNC_URL that differs from the saved one", async () => { + vi.stubEnv("XTCTX_SYNC_URL", "https://collector.example"); + await expect(runDiffSync({ projectDir: box.project })).rejects.toBeInstanceOf(EnvCredentialsRefusedError); + expect(cloud.requests).toEqual([]); + }); + + it("uploads with them when XTCTX_ALLOW_ENV_CREDENTIALS=1 says that is intended", async () => { + vi.stubEnv("XTCTX_SYNC_URL", "https://other.example"); + vi.stubEnv("XTCTX_ALLOW_ENV_CREDENTIALS", "1"); + await runDiffSync({ projectDir: box.project }); + expect(cloud.uploads()[0].url).toBe("https://other.example/api/stream"); + }); + + it("is fine with env values that match the saved login", async () => { + vi.stubEnv("XTCTX_TOKEN", "tok"); + vi.stubEnv("XTCTX_SYNC_URL", "https://sync.test/"); + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(1); + }); +}); + +describe("one uploader per project", () => { + let box: ReturnType; + let cloud: ReturnType; + beforeEach(async () => { + box = sandbox(); + cloud = fakeCloud(); + vi.stubGlobal("fetch", cloud.fetch); + await saveCredentials(creds()); + await setOptedIn(box.project, true); + seedIndex(box.project, 2); + }); + afterEach(() => { + vi.unstubAllGlobals(); + box.cleanup(); + }); + + it("does nothing while another live process holds the lock", async () => { + const held = await acquireUploadLock(box.project); + expect(held).not.toBeNull(); + const result = await runDiffSync({ projectDir: box.project }); + expect(result.busy).toBe(true); + expect(cloud.requests).toEqual([]); + await held!.release(); + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(2); + }); + + it("takes over a lock left by a process that is gone", async () => { + const { join: j } = await import("node:path"); + const { createHash } = await import("node:crypto"); + const { realpathSync } = await import("node:fs"); + const key = process.platform === "win32" ? realpathSync(box.project).toLowerCase() : realpathSync(box.project); + const lockPath = j(box.home, ".xtctx", "sync", `${createHash("sha256").update(key).digest("hex").slice(0, 24)}.lock`); + mkdirSync(j(box.home, ".xtctx", "sync"), { recursive: true }); + writeFileSync(lockPath, JSON.stringify({ pid: 2 ** 22 + 12345, token: "dead" })); + + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(2); + expect(existsSync(lockPath)).toBe(false); + }); + + it("never moves the position backwards", async () => { + const account = accountKey("github:1", "https://sync.test"); + await updateAccountState(box.project, account, (s) => ({ ...s, cursor: "2026-10-01T12:00:00.000Z" })); + await updateAccountState(box.project, account, (s) => ({ ...s, cursor: "2026-10-01T11:00:00.000Z" })); + expect((await readAccountState(box.project, account)).cursor).toBe("2026-10-01T12:00:00.000Z"); + }); + + it("runs two concurrent syncs as one upload", async () => { + const [a, b] = await Promise.all([runDiffSync({ projectDir: box.project }), runDiffSync({ projectDir: box.project })]); + expect([a.busy, b.busy].filter(Boolean)).toHaveLength(1); + expect(cloud.stored()).toHaveLength(2); + }); +}); diff --git a/tests/sync/helpers.ts b/tests/sync/helpers.ts new file mode 100644 index 0000000..1185f13 --- /dev/null +++ b/tests/sync/helpers.ts @@ -0,0 +1,168 @@ +import { mkdirSync, mkdtempSync, rmSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { vi } from "vitest"; +import { openDatabase } from "@xtctx/handoff/schema"; + +/** + * A throwaway home and project, with every variable os.homedir() could read + * pointed at the first. Nothing here may touch the real ~/.xtctx. + */ +export function sandbox() { + // Resolved, as the index stores project roots: on macOS the temp dir is + // behind /var -> /private/var, and a root seeded unresolved never matches. + const root = realpathSync(mkdtempSync(join(tmpdir(), "xtctx-sync-"))); + const home = join(root, "home"); + const project = join(root, "project"); + mkdirSync(home); + mkdirSync(project); + for (const name of ["HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"]) vi.stubEnv(name, home); + for (const name of ["XTCTX_TOKEN", "XTCTX_SYNC_URL", "XTCTX_ALLOW_ENV_CREDENTIALS", "XTCTX_DEVICE_ID", "XTCTX_DEVICE_NAME"]) { + vi.stubEnv(name, ""); + } + return { + root, + home, + project, + cleanup() { + vi.unstubAllEnvs(); + rmSync(root, { recursive: true, force: true }); + }, + }; +} + +export const localId = (i: number) => i.toString(16).padStart(64, "0"); + +interface SeedOptions { + indexedAt?: string; + from?: number; + sessionId?: string; + projectRoot?: string; + metadata?: Record; + sourcePointer?: string | null; + content?: (i: number) => string; +} + +/** Add `count` messages to one session of the project's real index, all stamped with the same time. */ +export function seedIndex(project: string, count: number, indexedAtOrOptions: string | SeedOptions = {}, fromArg?: number) { + const options: SeedOptions = + typeof indexedAtOrOptions === "string" ? { indexedAt: indexedAtOrOptions, from: fromArg } : indexedAtOrOptions; + const indexedAt = options.indexedAt ?? "2026-10-01T00:00:00.000Z"; + const from = options.from ?? 0; + const sessionId = options.sessionId ?? "s1"; + const sessionRef = `claude-code:${sessionId}`; + mkdirSync(join(project, ".xtctx", "state"), { recursive: true }); + const db = openDatabase(join(project, ".xtctx", "state", "xtctx.db")); + try { + db.prepare( + `INSERT INTO sessions (session_ref, tool, source_session_id, project_root, started_at, last_activity_at, updated_at) + VALUES (?, 'claude-code', ?, ?, ?, ?, ?) + ON CONFLICT(session_ref) DO UPDATE SET updated_at = excluded.updated_at, last_activity_at = excluded.last_activity_at`, + ).run(sessionRef, sessionId, options.projectRoot ?? project, indexedAt, indexedAt, indexedAt); + const insert = db.prepare( + `INSERT INTO messages (id, session_ref, tool, source_session_id, timestamp, role, content, message_index, content_hash, metadata_json, source_pointer, indexed_at) + VALUES (?, ?, 'claude-code', ?, ?, 'user', ?, ?, ?, ?, ?, ?)`, + ); + for (let i = from; i < from + count; i++) { + insert.run( + localId(i + (sessionId === "s1" ? 0 : 1_000_000)), + sessionRef, + sessionId, + indexedAt, + options.content ? options.content(i) : `message ${i}`, + i, + `hash${i}`, + JSON.stringify(options.metadata ?? {}), + options.sourcePointer ?? null, + indexedAt, + ); + } + } finally { + db.close(); + } +} + +/** Run SQL against the project's index, as a re-read by the scanner would. */ +export function editIndex(project: string, fn: (db: ReturnType) => void) { + const db = openDatabase(join(project, ".xtctx", "state", "xtctx.db")); + try { + fn(db); + } finally { + db.close(); + } +} + +interface StoredMessage { + id: string; + content: string; + metadataJson: string; +} + +interface UploadSession { + tool: string; + sourceSessionId: string; + messages: StoredMessage[]; + keepIds?: string[]; +} + +interface UploadBody { + device: { id: string; name: string }; + project: { repoUrl: string; name: string }; + sessions: UploadSession[]; +} + +/** + * The server's upload contract, in memory: messages upserted by id per + * session, `keepIds` deleting the rest, and the session's count in reply. + * Mirrors cloud/src/db.ts `ingestUpload`; the Worker's own tests cover that. + */ +export function fakeCloud() { + const sessions = new Map>(); + const requests: Array<{ url: string; method: string; headers: Headers; body: UploadBody }> = []; + const state = { + failStatus: undefined as number | undefined, + tooLarge: new Set(), + }; + + const fetchImpl = vi.fn(async (url: string, init: RequestInit = {}) => { + const headers = new Headers(init.headers); + const body = (init.body ? JSON.parse(String(init.body)) : undefined) as UploadBody; + requests.push({ url: String(url), method: init.method ?? "GET", headers, body }); + if (state.failStatus) return Response.json({ error: "boom" }, { status: state.failStatus }); + + for (const s of body?.sessions ?? []) { + for (const m of s.messages) { + if (state.tooLarge.has(m.id)) { + return Response.json( + { error: "message_too_large", limit: 65536, tool: s.tool, sourceSessionId: s.sourceSessionId, messageId: m.id }, + { status: 413 }, + ); + } + } + } + const counts = []; + for (const s of body?.sessions ?? []) { + const key = `${s.tool}:${s.sourceSessionId}`; + const stored = sessions.get(key) ?? new Map(); + sessions.set(key, stored); + for (const m of s.messages) stored.set(m.id, m); + if (s.keepIds) for (const id of [...stored.keys()]) if (!s.keepIds.includes(id)) stored.delete(id); + counts.push({ tool: s.tool, sourceSessionId: s.sourceSessionId, messageCount: stored.size }); + } + return Response.json({ success: true, sessions: counts }); + }); + + return { + fetch: fetchImpl, + sessions, + requests, + state, + uploads: () => requests.filter((r) => r.url.endsWith("/api/stream")), + sentMessages: () => requests.flatMap((r) => (r.body?.sessions ?? []).flatMap((s) => s.messages)), + stored: (sessionId = "s1") => [...(sessions.get(`claude-code:${sessionId}`)?.values() ?? [])], + }; +} + +export function loginFile(home: string): string { + return join(home, ".xtctx", "credentials.json"); +} diff --git a/tests/sync/login.test.ts b/tests/sync/login.test.ts new file mode 100644 index 0000000..8abf9a2 --- /dev/null +++ b/tests/sync/login.test.ts @@ -0,0 +1,112 @@ +import { existsSync, readFileSync } from "node:fs"; +import { hostname } from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { runLogin, runLogout } from "@xtctx/cli/login"; +import { getCredentialsPath, saveCredentials } from "@xtctx/sync/client"; +import { isOptedIn } from "@xtctx/sync/consent"; +import { sandbox } from "./helpers"; + +const code = { device_code: "dc", user_code: "ABCD-1234", verification_uri: "https://github.com/login/device", expires_in: 60, interval: 5 }; +const user = { id: "github:1", username: "u" }; + +describe("login and logout", () => { + let box: ReturnType; + beforeEach(() => { + box = sandbox(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.useFakeTimers({ toFake: ["Date"] }); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + box.cleanup(); + }); + + /** A clock the injected wait advances, so deadlines can be reached without sleeping. */ + const clock = () => { + const waits: number[] = []; + return { waits, wait: async (ms: number) => void (waits.push(ms), vi.setSystemTime(Date.now() + ms)) }; + }; + + it("signs in without opting any project in", async () => { + const polls = [{ error: "authorization_pending" }, { token: "tok", user }]; + vi.stubGlobal("fetch", vi.fn(async (url: string) => + Response.json(String(url).endsWith("/code") ? code : polls.shift()), + )); + const { wait } = clock(); + + await runLogin({ syncUrl: "https://sync.test", wait }); + + expect(existsSync(getCredentialsPath())).toBe(true); + expect(await isOptedIn(box.project)).toBe(false); + }); + + it("slows down when told to, and gives up when the code expires", async () => { + vi.stubGlobal("fetch", vi.fn(async (url: string) => + Response.json(String(url).endsWith("/code") ? { ...code, expires_in: 30 } : { error: "slow_down" }), + )); + const { wait, waits } = clock(); + + await expect(runLogin({ syncUrl: "https://sync.test", wait })).rejects.toThrow(/expired/); + + expect(waits[0]).toBe(5000); + expect(waits[1]).toBe(10_000); // slow_down adds five seconds + expect(waits.reduce((a, b) => a + b, 0)).toBeGreaterThanOrEqual(30_000); + expect(waits.length).toBeLessThan(10); // bounded, not a forever loop + }); + + it("logout revokes at the server and forgets the token", async () => { + await saveCredentials({ token: "tok", user, deviceId: "d", deviceName: "n", syncUrl: "https://sync.test" }); + const fetchMock = vi.fn(async () => Response.json({ success: true })); + vi.stubGlobal("fetch", fetchMock); + + await runLogout({}); + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("https://sync.test/auth/logout"); + expect(init.method).toBe("POST"); + expect(existsSync(getCredentialsPath())).toBe(false); + }); + + it("names the device with a random label, not the hostname, unless told otherwise", async () => { + vi.stubGlobal("fetch", vi.fn(async (url: string) => Response.json(String(url).endsWith("/code") ? code : { token: "tok", user }))); + const { wait } = clock(); + await runLogin({ syncUrl: "https://sync.test", wait }); + const saved = JSON.parse(readFileSync(getCredentialsPath(), "utf-8")); + expect(saved.deviceName).toMatch(/^device-[0-9a-f]{6}$/); + expect(saved.deviceName).not.toContain(hostname()); + + await runLogin({ syncUrl: "https://sync.test", wait, deviceName: "work laptop" }); + expect(JSON.parse(readFileSync(getCredentialsPath(), "utf-8")).deviceName).toBe("work laptop"); + }); + + it("says plainly when the account is not on the server's allowlist", async () => { + vi.stubGlobal("fetch", vi.fn(async (url: string) => + String(url).endsWith("/code") ? Response.json(code) : Response.json({ error: "not_allowed" }, { status: 403 }), + )); + const { wait } = clock(); + await expect(runLogin({ syncUrl: "https://sync.test", wait })).rejects.toThrow(/not allowed on this xtctx cloud server/); + expect(existsSync(getCredentialsPath())).toBe(false); + }); + + it("delete-data with a login the server no longer accepts deletes nothing, says so, and keeps the login", async () => { + await saveCredentials({ token: "tok", user, deviceId: "d", deviceName: "n", syncUrl: "https://sync.test" }); + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ error: "unauthorized" }, { status: 401 }))); + const log = vi.spyOn(console, "log"); + + await expect(runLogout({ deleteData: true })).rejects.toThrow(/nothing was deleted.*xtctx login/s); + + expect(existsSync(getCredentialsPath())).toBe(true); + expect(log.mock.calls.flat().join(" ")).not.toMatch(/Deleted/); + }); + + it("delete-data keeps the login when the server could not delete", async () => { + await saveCredentials({ token: "tok", user, deviceId: "d", deviceName: "n", syncUrl: "https://sync.test" }); + vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 500 }))); + + await expect(runLogout({ deleteData: true })).rejects.toThrow(/still logged in/); + expect(existsSync(getCredentialsPath())).toBe(true); + }); +}); diff --git a/tests/sync/no-keep-alive.test.ts b/tests/sync/no-keep-alive.test.ts new file mode 100644 index 0000000..3aefc21 --- /dev/null +++ b/tests/sync/no-keep-alive.test.ts @@ -0,0 +1,23 @@ +/** + * Cloud requests close their connection. Without it the MCP server crashed on + * exit on Windows (Node 24.14: `Assertion failed: !(handle->flags & + * UV_HANDLE_CLOSING)`, exit 0xC0000409) after an upload tick followed by the + * shutdown upload; see NO_KEEP_ALIVE in src/sync/client.ts. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { callCloud } from "@xtctx/sync/client"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("cloud requests", () => { + it("ask the server to close the connection", async () => { + const fetchMock = vi.fn(async () => Response.json({})); + vi.stubGlobal("fetch", fetchMock); + const creds = { token: "t", user: { id: "github:1", username: "u" }, deviceId: "d", deviceName: "n", syncUrl: "https://sync.test" }; + + await callCloud(creds, "POST", "/api/stream", { sessions: [] }); + + const init = (fetchMock.mock.calls[0] as unknown as [string, RequestInit])[1]; + expect(new Headers(init.headers).get("connection")).toBe("close"); + }); +}); diff --git a/tests/sync/reread.test.ts b/tests/sync/reread.test.ts new file mode 100644 index 0000000..cc9acea --- /dev/null +++ b/tests/sync/reread.test.ts @@ -0,0 +1,137 @@ +/** + * A scraper upgrade re-reads sessions and replaces their rows under new ids. + * + * The cloud copy was uploaded under the old ids, so sending only the new rows + * would leave both versions there. The per-session reconcile (`keepIds`) is + * what removes the old ones; this runs a real scan, a real upgrade re-read and + * real uploads to show the cloud ends equal to the index. + */ +import Database from "better-sqlite3"; +import { mkdirSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { hashParts } from "@xtctx/handoff/hash"; +import { SqliteHandoffIndex } from "@xtctx/handoff/sqlite-index"; +import { ClaudeCodeScraper } from "@xtctx/scrapers/claude-code"; +import { saveCredentials } from "@xtctx/sync/client"; +import { setOptedIn } from "@xtctx/sync/consent"; +import { runDiffSync } from "@xtctx/sync/diff-sync"; +import { fakeCloud, sandbox } from "./helpers"; + +const records = [ + { type: "user", message: { role: "user", content: "fix the build" }, timestamp: "2026-02-24T10:00:00Z" }, + { + type: "assistant", + message: { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "Bash", input: { command: "npm run build" } }], + }, + timestamp: "2026-02-24T10:00:01Z", + }, + { + type: "user", + message: { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "build ok" }] }, + timestamp: "2026-02-24T10:00:02Z", + }, + { type: "assistant", message: { role: "assistant", content: "Done." }, timestamp: "2026-02-24T10:00:03Z" }, +]; + +describe("cloud copy across a scraper-version re-read", () => { + let box: ReturnType; + let cloud: ReturnType; + let projects = ""; + let state = ""; + let dbPath = ""; + + beforeEach(async () => { + box = sandbox(); + cloud = fakeCloud(); + vi.stubGlobal("fetch", cloud.fetch); + projects = join(box.root, "claude-projects"); + state = join(box.project, ".xtctx", "state"); + dbPath = join(state, "xtctx.db"); + mkdirSync(join(projects, "proj"), { recursive: true }); + mkdirSync(state, { recursive: true }); + await writeFile(join(projects, "proj", "sess.jsonl"), records.map((r) => JSON.stringify(r)).join("\n") + "\n"); + await saveCredentials({ + token: "tok", + user: { id: "github:1", username: "u" }, + deviceId: "dev", + deviceName: "device-abc123", + syncUrl: "https://sync.test", + }); + await setOptedIn(box.project, true); + }); + afterEach(() => { + vi.unstubAllGlobals(); + box.cleanup(); + }); + + async function scan(): Promise { + const index = new SqliteHandoffIndex(dbPath, box.project, [ + { tool: "claude-code", scraper: new ClaudeCodeScraper(projects, state) }, + ]); + try { + await index.listRecentSessions(5); + } finally { + await index.close(); + } + } + + const local = () => { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare("SELECT id, role, content FROM messages WHERE session_ref = 'claude-code:sess' ORDER BY message_index, id") + .all() as Array<{ id: string; role: string; content: string }>; + } finally { + db.close(); + } + }; + const stored = () => cloud.stored("sess"); + + /** Rewrite the rows into what scraper version 1 wrote, and forget the version. */ + async function downgrade(): Promise { + const db = new Database(dbPath); + try { + db.prepare("DELETE FROM messages WHERE content LIKE 'ran Bash:%'").run(); + const rows = db + .prepare("SELECT id, timestamp, content, message_index FROM messages WHERE role = 'tool'") + .all() as Array<{ id: string; timestamp: string; content: string; message_index: number }>; + for (const row of rows) { + const oldId = hashParts(["claude-code", "sess", row.timestamp, "user", String(row.message_index), row.content]); + db.prepare("UPDATE messages SET id = ?, role = 'user' WHERE id = ?").run(oldId, row.id); + } + } finally { + db.close(); + } + const statePath = join(state, "claude-code-state.json"); + const saved = JSON.parse(await readFile(statePath, "utf-8")) as Record; + delete saved.scraperVersion; + await writeFile(statePath, JSON.stringify(saved)); + } + + it("ends with the cloud holding exactly the re-read rows, none of the replaced ones", async () => { + await scan(); + await downgrade(); + await runDiffSync({ projectDir: box.project }); + + // The cloud holds the old shape: three rows, the tool result filed as the user. + const before = local(); + expect(before.map((m) => m.role)).toEqual(["user", "user", "assistant"]); + expect(stored().map((m) => m.id).sort()).toEqual(before.map((m) => m.id).sort()); + + await scan(); // the upgrade re-read: rows replaced under new ids + const after = local(); + expect(after.map((m) => m.role)).toEqual(["user", "tool", "tool", "assistant"]); + expect(after.filter((m) => !before.some((b) => b.id === m.id)).length).toBeGreaterThan(0); + + await runDiffSync({ projectDir: box.project }); + + expect(stored().map((m) => m.id).sort()).toEqual(after.map((m) => m.id).sort()); + expect(stored().map((m) => m.content).sort()).toEqual(after.map((m) => m.content).sort()); + const sent = cloud.uploads().flatMap((u) => u.body.sessions.flatMap((s) => s.messages)) as Array<{ role?: string }>; + expect(sent.some((m) => m.role === "tool")).toBe(true); + }); +}); diff --git a/tests/sync/sync-cli.test.ts b/tests/sync/sync-cli.test.ts new file mode 100644 index 0000000..91631ff --- /dev/null +++ b/tests/sync/sync-cli.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { runSync, runSyncSetting } from "@xtctx/cli/sync"; +import { saveCredentials } from "@xtctx/sync/client"; +import { setOptedIn } from "@xtctx/sync/consent"; +import { editIndex, fakeCloud, sandbox, seedIndex } from "./helpers"; + +const creds = { token: "tok", user: { id: "github:1", username: "alice" }, deviceId: "d", deviceName: "device-abc123", syncUrl: "https://sync.test" }; + +describe("xtctx sync", () => { + let box: ReturnType; + let cloud: ReturnType; + let out: string[]; + beforeEach(async () => { + box = sandbox(); + cloud = fakeCloud(); + vi.stubGlobal("fetch", cloud.fetch); + out = []; + vi.spyOn(console, "log").mockImplementation((...a) => void out.push(a.join(" "))); + vi.spyOn(console, "error").mockImplementation((...a) => void out.push(a.join(" "))); + process.exitCode = 0; + await saveCredentials(creds); + await setOptedIn(box.project, true); + seedIndex(box.project, 2); + }); + afterEach(() => { + process.exitCode = 0; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + box.cleanup(); + }); + + it("exits non-zero when the upload fails, and zero when it works", async () => { + cloud.state.failStatus = 500; + await runSync({ projectDir: box.project }); + expect(process.exitCode).toBe(1); + expect(out.join("\n")).toMatch(/Cloud sync failed/); + + cloud.state.failStatus = undefined; + await runSync({ projectDir: box.project }); + expect(process.exitCode).toBe(0); + }); + + it("status shows the last upload and the last failure", async () => { + await runSync({ projectDir: box.project }); + cloud.state.failStatus = 503; + editIndex(box.project, (db) => db.prepare("UPDATE sessions SET updated_at = ?").run(new Date().toISOString())); + await runSync({ projectDir: box.project }); + out.length = 0; + + await runSyncSetting("status", { projectDir: box.project }); + + const text = out.join("\n"); + expect(text).toMatch(/on; uploading as alice to https:\/\/sync.test, device "device-abc123"/); + expect(text).toMatch(/last upload: 20\d\d-/); + expect(text).toMatch(/last attempt FAILED at .*503/); + }); + + it("renames this device", async () => { + await runSyncSetting("device", { projectDir: box.project, value: "studio mac" }); + await runSync({ projectDir: box.project }); + expect(cloud.uploads()[0].body.device.name).toBe("studio mac"); + }); +});