Skip to content

feat(cloud): opt-in cloud sync with OAuth sign-in, hardened Worker, no daemon - #404

Merged
fstubner merged 24 commits into
mainfrom
fix/cloud-production
Oct 2, 2026
Merged

fstubner merged 24 commits into
mainfrom
fix/cloud-production

Conversation

@fstubner

@fstubner fstubner commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Optional cloud sync, made safe to run. Builds on #403 (merge that first; this PR then shows only the cloud changes).

Nothing is uploaded unless a user both logs in (xtctx login) and opts a project in (xtctx sync enable); the opt-in list lives in ~/.xtctx, so a repository cannot opt its readers in. There is no daemon: the MCP server uploads every 10 s while an agent runs and once on shutdown; xtctx sync --watch is the foreground version. Full description in docs/cloud-sync.md.

Worker (cloud/)

  • Fails closed without JWT_SECRET; tokens only from the Authorization header; generic error bodies; CORS only for listed origins; workers_dev = false; observability on.
  • Sign-in restricted to ALLOWED_GITHUB_IDS (empty = nobody).
  • Revocation that survives account deletion (token_epochs), POST /auth/logout, DELETE /api/me.
  • Size limits (1 MiB body, 64 KiB per message), one atomic batch per request, idempotent per-session uploads that keep the cloud copy equal to the local index (keepIds), counts from COUNT(*).
  • MCP: 2025-11-25 and 2026-07-28 compliance (server/discover, ping, 202 for notifications, 405, -32700/-32600/-32602), tools renamed xtctx_cloud_* so they cannot collide with the local ones, plus xtctx_cloud_status.
  • OAuth 2.1 sign-in for MCP clients via @cloudflare/workers-oauth-provider 1.2.1 (CIMD + dynamic registration, PKCE S256, read-only 1 h access tokens with 30-day rotating refresh), GitHub as the identity provider. SSE streams in a Durable Object.
  • Tracked D1 migrations (0000_baseline, 0001_sync_v2).

Client

  • Uploads only sessions the index attributes to this project; no absolute paths, source_pointer or hostname leave the machine (metadata allowlist); random device name by default.
  • One uploader per project across processes; position kept per project and account in ~/.xtctx/sync; oversize messages skipped and recorded instead of blocking sync.
  • XTCTX_TOKEN / XTCTX_SYNC_URL that differ from the saved login are refused unless XTCTX_ALLOW_ENV_CREDENTIALS=1.
  • credentials.json 0600 and atomic; logout [--delete-data]; device-flow polling honours expires_in and slow_down; sync status, a Cloud line in xtctx status, non-zero exit on failure.
  • Every cloud request sends Connection: close: on Windows, Node 24.14 aborted on exit (UV_HANDLE_CLOSING, 0xC0000409) after a POST on a kept-alive socket; 10/10 server runs crashed before, the built client exited cleanly 10/10 after.

Verification

  • npm test 1007 passed, test:cloud 75 passed, lint, typecheck, security, smoke, drift, integration, pack, demo, smoke:cli, audit clean.
  • Real MCP clients against wrangler dev --local: Claude Code, Codex, the MCP SDK and Inspector list and call the tools; OAuth discovery, registration, consent and the redirect to GitHub verified; the GitHub login itself was not exercised (needs a browser).
  • End-to-end with the built CLI against the local Worker: uploads include tool rows and no paths; after a scraper upgrade re-read the cloud copy ends equal to the local index.

Deploy (owner, in order)

  1. cd cloud && npm install
  2. wrangler kv namespace create OAUTH_KV; put its id in wrangler.toml.
  3. Existing database: confirm users.token_version exists; if not, run the old ALTER TABLE first (see cloud/README.md).
  4. npm run d1:migrate:remote
  5. GitHub OAuth app: keep Device Flow on, callback https://mcp.xtctx.com/oauth/github/callback, generate a client secret.
  6. wrangler secret put GITHUB_CLIENT_SECRET
  7. Set ALLOWED_GITHUB_IDS in wrangler.toml.
  8. npm run deploy
  9. Everyone runs xtctx login again.

…atcher

- Add Cloudflare Edge package (cloud/) with D1 multi-tenant SQLite schema
- Implement dual-version MCP server (2026-07-28 stateless + 2024-11-05 SSE)
- Add GitHub Device Flow auth with RFC 9728 discovery and Web Crypto JWTs
- Add real-time byte-offset transcript tailer watching Antigravity and Claude Code
- Add repo-anchored path normalizer for cross-device relative paths
- Register xtctx login and xtctx watch CLI commands
…ens, logout and delete-my-data, SSE in a Durable Object

No built-in signing secret: without JWT_SECRET every route but /health answers 500.
Tokens come from the Authorization header only, last 30 days, and carry the user's
token_version, so POST /auth/logout revokes them. DELETE /api/me removes a user's
rows and account. Errors return a generic body and log the detail. CORS is off
unless ALLOWED_ORIGINS lists the origin. Ingest validates its body and scopes
device ids to the user. SSE streams live in a SseSession Durable Object so
/message works from any isolate. Needs migrations/0001 applied before deploy.
…erver, and the daemon is gone

Logging in no longer uploads or starts anything. A project uploads only when it is on
~/.xtctx/cloud-projects.json (xtctx sync enable) AND someone is logged in; XTCTX_TOKEN
alone does nothing. The MCP server syncs every 10s while it runs and once on shutdown;
xtctx sync --watch is the foreground version. The machine-wide transcript tailer and
the self-resurrecting daemon are removed, and uploads come from the project's own index.

Also: credentials.json written 0600 and atomically; xtctx logout [--delete-data];
device-flow polling honours expires_in and slow_down; no .xtctx/project.id written;
sync URL must be https (loopback excepted); the sync query selected sessions.status,
which the index does not have, so it could never have uploaded; a (indexed_at, id)
cursor so a batch boundary inside a same-timestamp run skips nothing; per-account cursor;
folder name sent rather than the absolute path. Cloud Worker tests join verify:release and CI.
…, OAuth sign-in for MCP clients

Data integrity
- POST /api/stream takes a v2 body ({device, project, sessions[]}) and writes
  a request as ONE D1 batch. Messages are keyed by the client's local id,
  message_count is recounted from the rows, and a session's keepIds deletes
  everything else, so replays are idempotent and local deletions/rewrites
  reach the cloud. started_at/last_activity_at use MIN/MAX, branch and device
  update, the constant 'active' status is gone.
- Limits: 1 MiB body (read capped, 413 body_too_large), 64 KiB per message
  and 4 KiB metadata (413 message_too_large naming the message), 10 sessions
  and 500 messages per request, which keeps a batch under the free plan's
  50 queries. The pre-0.2 array body gets 426 client_outdated.

Auth
- Token epochs live in token_epochs, which delete-my-data never deletes, so a
  token from before a deletion stays dead after the same account signs in
  again (it used to restart at version 0 and revive).
- ALLOWED_GITHUB_IDS gates both sign-in routes before any row is written;
  empty means nobody. Removal ends access at the next request.
- The Worker is an OAuth 2.1 authorization server for ${PUBLIC_URL}/mcp via
  @cloudflare/workers-oauth-provider: protected-resource metadata at the /mcp
  and root forms, AS metadata, CIMD and DCR registration, /authorize with a
  consent page, GitHub web flow, PKCE S256 required, 1 h read-only access
  tokens bound to the resource, 30-day rotating refresh tokens. Refresh after
  logout/deletion fails with invalid_grant.
- CLI tokens carry scope (read sync:write account:delete) and aud; pasted
  read-only tokens from POST /api/tokens. Tokens without scope are refused.

MCP
- Dual-era /mcp: legacy initialize (2025-11-25, 2025-06-18) and stateless
  2026-07-28 with header/_meta validation (-32020, -32022, -32602),
  server/discover, resultType, and ttlMs/cacheScope on list results (Claude
  Code 2.1.278 rejected tools/list without them). ping answers {},
  notifications 202 with no body, unknown methods -32601 (404 when modern),
  bad JSON 400 -32700, missing method / batches 400 -32600, GET/DELETE 405,
  foreign Origin 403.
- Tools renamed xtctx_cloud_recent_sessions / _session_detail, plus
  xtctx_cloud_status. limit is clamped to 1..N, session_ref is required,
  detail has a format option and orders by (timestamp, message_index, id),
  device names are joined in. Unknown tool -32602; bad arguments isError.

Ops
- Schema is managed by `wrangler d1 migrations apply`: schema.sql is now
  migrations/0000_baseline.sql (all IF NOT EXISTS) and 0001_sync_v2.sql
  carries the changes, including dropping the columns that held absolute
  paths. workers_dev = false, [observability] on, version in /health and
  X-Xtctx-Server-Version, structured ingest_failed logs without content.
- Removed the unused @modelcontextprotocol/sdk dependency.

BREAKING CHANGE: clients before 0.2 get 426 on upload, and every existing
token is refused; users run `xtctx login` again. Apply migrations before
deploying, and set OAUTH_KV, ALLOWED_GITHUB_IDS and GITHUB_CLIENT_SECRET.
…exact, and say when it fails

- Only sessions the index attributes to this project are read, compared with
  the index's own root canonicalisation (PROJECT_ROOT_SQL).
- Each changed session is sent under local message ids; when the server's
  count differs from the local one, the session is sent whole with keepIds so
  deletions and rewrites reach the cloud. Requests are packed under the
  server's limits, oversized content is truncated with a marker, and a
  message the server still refuses is skipped and recorded instead of
  blocking every later upload.
- The position, last success, last error and skipped list live per project
  and account in ~/.xtctx/sync/, not in the index; a rebuild re-sends once,
  harmlessly. The cursor only moves after a complete run and never backwards.
- A lock in ~/.xtctx/sync/ (stale takeover by dead pid or 10 min) lets one
  MCP server per project upload at a time.
- XTCTX_TOKEN / XTCTX_SYNC_URL that differ from the saved login (or with no
  saved login) refuse to upload unless XTCTX_ALLOW_ENV_CREDENTIALS=1.
- Metadata is reduced to an allowlist with absolute paths dropped;
  source_pointer is never sent; the default device name is a random label,
  settable with `login --device` or `sync device <name>`.
- `xtctx sync` exits non-zero on failure, --watch prints a repeated error
  once, `sync status` and `xtctx status` show the last upload, the last
  failure and skipped messages. `sync token` prints a read-only token.
- `logout --delete-data` treats 401 as failure: nothing was deleted, the
  login is kept, and it says to log in again.
- Every request sends X-Xtctx-Client.

BREAKING CHANGE: speaks the v2 upload format, which servers before 0.2 do not
accept.
…ctly what it sends

docs/cloud-sync.md now lists every uploaded field and what is not sent,
says message text goes as written, and covers the env-credential guard,
skipped messages, status, read-only tokens and OAuth sign-in for MCP
clients. README Limits, PRODUCT.md and docs/architecture.md stop saying
there is no cloud at all and point to it as the one optional, opt-in part.
…on exit on Windows

Node 24.14 on Windows aborts with 'Assertion failed: !(handle->flags &
UV_HANDLE_CLOSING)' (exit 0xC0000409) when process.exit runs while fetch holds
a pooled keep-alive socket after a POST. The MCP server uploads on a tick and
again on shutdown, then exits: 10 of 10 such runs crashed. Sending
Connection: close on every cloud request: the built client exited cleanly in
10 of 10 runs against a server that crashed plain fetch in 5 of 5.
…ct roots

On macOS the temp dir sits behind /var -> /private/var, so roots seeded
unresolved never matched the upload's project filter and nothing was sent.
@fstubner
fstubner merged commit bf35f3d into main Oct 2, 2026
5 checks passed
@fstubner
fstubner deleted the fix/cloud-production branch October 2, 2026 22:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant