feat(cloud): opt-in cloud sync with OAuth sign-in, hardened Worker, no daemon - #404
Merged
Merged
Conversation
…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
…with auto-resurrection
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 --watchis the foreground version. Full description indocs/cloud-sync.md.Worker (
cloud/)JWT_SECRET; tokens only from theAuthorizationheader; generic error bodies; CORS only for listed origins;workers_dev = false; observability on.ALLOWED_GITHUB_IDS(empty = nobody).token_epochs),POST /auth/logout,DELETE /api/me.keepIds), counts fromCOUNT(*).server/discover, ping, 202 for notifications, 405, -32700/-32600/-32602), tools renamedxtctx_cloud_*so they cannot collide with the local ones, plusxtctx_cloud_status.@cloudflare/workers-oauth-provider1.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.0000_baseline,0001_sync_v2).Client
source_pointeror hostname leave the machine (metadata allowlist); random device name by default.~/.xtctx/sync; oversize messages skipped and recorded instead of blocking sync.XTCTX_TOKEN/XTCTX_SYNC_URLthat differ from the saved login are refused unlessXTCTX_ALLOW_ENV_CREDENTIALS=1.credentials.json0600 and atomic;logout [--delete-data]; device-flow polling honoursexpires_inandslow_down;sync status, aCloudline inxtctx status, non-zero exit on failure.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 test1007 passed,test:cloud75 passed, lint, typecheck, security, smoke, drift, integration, pack, demo, smoke:cli, audit clean.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).Deploy (owner, in order)
cd cloud && npm installwrangler kv namespace create OAUTH_KV; put its id inwrangler.toml.users.token_versionexists; if not, run the oldALTER TABLEfirst (seecloud/README.md).npm run d1:migrate:remotehttps://mcp.xtctx.com/oauth/github/callback, generate a client secret.wrangler secret put GITHUB_CLIENT_SECRETALLOWED_GITHUB_IDSinwrangler.toml.npm run deployxtctx loginagain.