@zykeco/sync-server
v0.12.0
Published
Self-hosted Zyke sync server (Hono + Drizzle + libsql). Imports to auto-start; ships a migrate CLI.
Readme
@zykeco/sync-server
Self-hosted sync server for the Zyke mobile app. A small Hono HTTP service backed by libsql and Drizzle ORM, validated end-to-end with TypeBox schemas from @zykeco/sync-protocol.
- Single-tenant, secret-gated. One server per deployment; two bearer secrets (
READ_SECRET,WRITE_SECRET). - Writes + hard-deletes only. No read API surface — the mobile client is the source of truth, the server is a durable buffer for backup/restore.
- Schemaless
sleep_sessions. Domain fields live inside a JSONpayload, so the mobile app evolves the shape without a server release. - Three CLI bins.
zyke-sync-startup,zyke-sync-upgrade, andzyke-sync-migratecover first-run setup, version bumps, and ad-hoc migrations.
Requires Node ≥ 24 and npm ≥ 11.
Install
npm install @zykeco/sync-serverFor a deployment-ready starter that already wires up startup / start / upgrade, see the @zykeco/sync-self-host template — copy it onto your server and you're done.
Quick start (manual)
# 1. Create a project directory with @zykeco/sync-server installed.
mkdir my-sync && cd my-sync
npm init -y && npm pkg set type=module
npm install @zykeco/sync-server
# 2. Generate .env (READ_SECRET / WRITE_SECRET) and run migrations.
npx zyke-sync-startup
# 3. Create index.js with one line, then start.
echo "import '@zykeco/sync-server';" > index.js
node index.js
# → zyke-sync listening on http://localhost:3000Bins
| Command | Purpose |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| zyke-sync-startup | First-run setup. Runs npm install, generates/updates .env with crypto-random READ_SECRET and WRITE_SECRET (mode 0600), creates the libsql data dir, then runs migrations. Idempotent. |
| zyke-sync-upgrade | Upgrades the package and migrates. zyke-sync-upgrade --to 0.5.0 for a specific version; no arg defaults to latest. |
| zyke-sync-migrate | Applies all pending Drizzle migrations against DATABASE_URL. Run by startup / upgrade; useful on its own for ad-hoc migrations. |
Environment
The server reads these via process.env (and .env if present):
| Var | Default | Purpose |
| -------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| PORT | 3000 | HTTP listen port. |
| DATABASE_URL | file:./data/sync.db | libsql connection string. Accepts local SQLite (file:) or remote libsql/Turso. |
| READ_SECRET | required | Bearer token for read endpoints (currently unused — reads are not exposed). |
| WRITE_SECRET | required | Bearer token for write + delete + wipe endpoints. |
| PUBLIC_URL | unset | Public base URL, e.g. https://sync.example.com. Used as the OAuth issuer for MCP discovery + redirect URLs. Set this when running behind a TLS-terminating reverse proxy (see MCP server). |
zyke-sync-startup generates 32-byte hex values for both secrets if they're missing, writes the file with mode 0600, and leaves existing values alone on subsequent runs.
HTTP API
All write endpoints take Authorization: Bearer ${WRITE_SECRET} and respond with JSON.
Health
GET /v1/health → { ok: true }Generic batch upsert
Storage is schemaless: every synced entity is a generic envelope stored in one records
table keyed by (collection, id); the domain shape lives entirely in an opaque JSON payload.
POST /v1/sync/{collection}/batch
body: { rows: RecordEnvelope[] } // max 500
reply: { stored: string[], serverNowMs: number }A RecordEnvelope is { id: string, updatedAt: number, payloadVersion: number, payload: object, attachment?: object | null }.
Upserts by (collection, id) with a last-write-wins guard on updatedAt — older writes are
silently ignored. attachment holds an optional heavy secondary blob (e.g. an activity's
heart-rate samples) and is excluded from list-style MCP reads.
Known collections (anything else → 404): daily_metrics, weekly_metrics, sleep_sessions,
user_timezones, user_profile, daily_stress_burden, heart_rate_minute, hr_zone_history,
activities.
Generic hard-delete
POST /v1/sync/{collection}/delete
body: { ids: string[] } // max 500
reply: { deleted: string[], serverNowMs: number }Missing ids are silent successes — safe to retry.
Wipe everything (destructive)
POST /v1/sync/wipe
body: { confirm: "wipe-all-data" }
reply: { deleted: number, serverNowMs: number }Deletes every record across all collections (blobs untouched). The literal confirm string is enforced at the protocol layer.
MCP server
The server ships an MCP endpoint at POST /mcp that exposes read-only tools over your synced data. Auth supports two OAuth 2.1 flows:
authorization_code+ PKCE — for interactive clients like the Claude.ai web app and Claude Desktop "Custom Connectors". Dynamic Client Registration (RFC 7591) is supported.client_credentials— for headless agents / CLIs that hold theREAD_SECRETdirectly.
Required env vars
| Var | Default | Purpose |
| ----------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| MCP_ENABLED | true | Set false to disable the /mcp mount entirely. |
| MCP_CLIENT_ID | req. | Bootstrap client ID for the headless client_credentials grant. Public — pick anything memorable. |
| MCP_TOKEN_SECRET | req. | HMAC key for signing access + refresh tokens. Must be ≥ 32 chars (enforced at startup) — generate with openssl rand -hex 32. Treat as a secret. |
| MCP_TOKEN_TTL_SECONDS | 3600 | Bearer (access) token lifetime. |
| MCP_REFRESH_ENABLED | true | Issue refresh tokens on the auth-code grant so clients renew without re-login. false to disable. |
| MCP_REFRESH_TTL_SECONDS | 2592000 | Sliding refresh-token lifetime (30 d). Each refresh re-issues with a fresh window. |
| MCP_REFRESH_MAX_TTL_SECONDS | 7776000 | Absolute cap on a refresh chain from first issue (90 d). After this, re-auth is required. |
HTTPS requirement & PUBLIC_URL
The MCP authorization-code flow (OAuth 2.1) requires HTTPS in production — claude.ai and other MCP clients refuse to follow http:// authorization endpoints. The sync server itself terminates plain HTTP; run it behind a reverse proxy (Caddy / Traefik / nginx) that terminates TLS.
Set PUBLIC_URL to your public HTTPS URL so discovery + redirect URLs are correct:
PUBLIC_URL=https://sync.example.comWhen PUBLIC_URL is set, it always wins. Without it, the server falls back to X-Forwarded-Proto + X-Forwarded-Host from the proxy, then to the request URL as last resort. The fallback is fragile (depends on the proxy setting the headers correctly), so the explicit env var is recommended for any production deployment.
Endpoints
| Method | Path | Purpose |
| ------ | ----------------------------------------- | ----------------------------------------------------------------------------- |
| GET | /.well-known/oauth-authorization-server | RFC 8414 metadata (root-level, what MCP clients probe). |
| GET | /.well-known/oauth-protected-resource | RFC 9728 resource metadata pointing back to the AS. |
| POST | /mcp/register | Dynamic Client Registration (RFC 7591). |
| GET | /mcp/authorize | Consent screen (PKCE flow). |
| POST | /mcp/authorize | Consent form submit — issues an authorization code. |
| POST | /mcp/oauth/token | Token endpoint (authorization_code, client_credentials, refresh_token). |
| POST | /mcp | MCP JSON-RPC. Requires Authorization: Bearer <token>. |
Connecting Claude.ai (web or desktop) as a custom connector
- Deploy your server with
PUBLIC_URL+MCP_*env vars set behind a TLS-terminating proxy. Confirm:https://YOUR_HOST/.well-known/oauth-authorization-serverreturns JSON- The
issuerfield in that JSON is yourhttps://URL, nothttp://. (If it'shttp, setPUBLIC_URLand restart.)
- Open Claude.ai → Settings → Connectors → Add custom connector.
- Server URL: enter
https://YOUR_HOST/mcp(the/mcppath matters — that's the JSON-RPC endpoint, not the host root). - Click Connect. Claude.ai will:
- Fetch the discovery doc at
/.well-known/oauth-authorization-server. - Self-register via
POST /mcp/register. - Open a new tab to
https://YOUR_HOST/mcp/authorize?....
- Fetch the discovery doc at
- On the consent screen: verify the "Redirects to" line shows
https://claude.ai/api/mcp/auth_callback. Paste yourREAD_SECRETinto the form and click Authorize. - You're done. Claude.ai redirects back, exchanges the code for a bearer token, and the connector goes online. The read tools (
query_collection,list_daily_metrics,list_sleep_sessions,list_activities,get_activity,get_user_profile, …) become available in the chat tool picker.
Claude Desktop's "Add custom connector" flow is identical — same form, same URL.
What if Claude.ai shows a 404 at /authorize?
That means it's hitting the host root instead of the discovered path. Two causes:
- The discovery doc isn't reachable. Curl
https://YOUR_HOST/.well-known/oauth-authorization-serverand confirm you get JSON, not your reverse proxy's 404 page. Add the path to your proxy rules. - You're on
@zykeco/sync-server < 0.7.0. Upgrade — the auth-code flow ships from 0.7.0 onward.
Token / client lifetime
- Bearer (access) tokens expire after
MCP_TOKEN_TTL_SECONDS(default 1 h). - When
MCP_REFRESH_ENABLEDis on (default), the auth-code grant also returns a refresh token. Clients (claude.ai, Claude Code) silently exchange it for a new access token viagrant_type=refresh_token, so you no longer re-run the consent flow every time the access token expires. Refresh tokens are stateless and signed: they keep working across server restarts and slide forward on each use, up to theMCP_REFRESH_MAX_TTL_SECONDScap. Because they are stateless, this is sliding re-issuance, not reuse-detected rotation: the refresh token you just exchanged stays valid until its own expiry (it is not invalidated by the re-issue), and individual tokens cannot be revoked before then — rotateMCP_TOKEN_SECRETto invalidate all of them at once. - DCR client registrations are in-memory — they don't survive a server restart. Refresh tokens do (the client binding is in the signature), so a restart no longer forces a re-login; only brand-new connections need DCR to re-run, which claude.ai does automatically.
Connecting from headless agents (client_credentials)
# 1. Get a token using your READ_SECRET as the client secret.
curl -X POST https://YOUR_HOST/mcp/oauth/token \
-d grant_type=client_credentials \
-d client_id=$MCP_CLIENT_ID \
-d client_secret=$READ_SECRET
# 2. Use the access_token to call MCP.
curl -X POST https://YOUR_HOST/mcp \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'The authorization_code grant additionally returns a refresh_token. To renew
an access token without re-running the consent flow:
curl -X POST https://YOUR_HOST/mcp/oauth/token \
-d grant_type=refresh_token \
-d client_id=$CLIENT_ID \
-d refresh_token=$REFRESH_TOKEN
# -> { access_token, token_type, expires_in, refresh_token } (a new refresh_token is re-issued)(The headless client_credentials grant does not return a refresh token — it
just re-requests with the static secret.)
Data model
daily_metrics,weekly_metrics—{ id (PK), isoDate / weekStartIsoDate, metricKey, value, createdAt, updatedAt }. Composite unique on(date, metricKey).sleep_sessions— schemaless envelope:{ id (PK), isoDate, updatedAt, payloadVersion, payload (JSON) }.
All timestamps are epoch milliseconds. ids are client-generated integers (no autoincrement on the server).
Programmatic use
import '@zykeco/sync-server'; // auto-starts on import using process.envThere is no module API to embed the server in another process — the package is import-to-run by design.
Compatibility
| Runtime | Status | | ---------- | -------------------------- | | Node ≥ 24 | ✅ | | Bun / Deno | ❌ (libsql native binding) |
For embedded/edge runtimes, point a stock Node deployment at a remote libsql (Turso) URL.
License
AGPL-3.0-or-later — see LICENSE.
