Self-hosted Evolu CRDT relay for getbased sync, with structured logging, metrics, quota management, owner self-service endpoints, and the optional Agent Access context gateway.
Wraps @evolu/nodejs — all sync protocol and CRDT logic is from Evolu. This project adds the operational layer for running a relay in production.
Built for getbased, a blood work dashboard that uses Evolu for cross-device sync.
Evolu is an npm dependency here, not vendored source. The current checkout locks @evolu/common 8.18.0 and @evolu/nodejs 4.2.0. The ranges in package.json permit compatible updates within those majors; package-lock.json pins the exact versions used by npm ci and the Docker build. The table below records the dependency versions for each release.
| Relay | @evolu/common |
@evolu/nodejs |
Node.js |
|---|---|---|---|
| v2.0.2 | ^8.18.0 (locked to 8.18.0) |
^4.2.0 (locked to 4.2.0) |
>= 24.20.0 |
| v2.0.1 | ^8.14.0 (locked to 8.14.0) |
^4.1.0 (locked to 4.1.0) |
>= 24.20.0 |
| v2.0.0 | ^8.9.0 (locked to 8.9.0) |
^3.2.0 (locked to 3.2.0) |
>= 24.20.0 |
| v1.2.3 | ^7.4.0 |
^2.4.0 |
>= 22.0.0 |
The package version streams are independent: Evolu 8 uses the 8.x common package; the current Node adapter is 4.x. This relay upgrade preserves the existing WebSocket protocol, owner keys, relay tables, self-service response fields, and Agent Access proof format. It does not require an application or browser Evolu upgrade.
The mirrored WebSocket adapter retains GetBased's storage decorator and incorporates upstream's 30-second heartbeats, 16 MiB unsent broadcast limit, and shutdown guards. Explicit optional tooling overrides avoid the Node adapter's conflicting lint peer resolutions; SQLite install scripts are allowed for npm versions that require an allowlist.
Evolu 8.18 rejects malformed requests, including encrypted changes shorter than 41 bytes, before applying them. SQLite write failures roll back the batch, return a protocol write error, and do not broadcast the failed changes. The service keeps running so writes can resume after the storage problem is repaired; /health returns 503 when required database tables are missing. Other fatal task defects remain subject to runMain's shutdown and failure exit handling.
Upstream now provides configurable per-owner quotas and leveled console output. This wrapper adds global payload admission checks, JSON operational events, database readiness and metrics, replay-protected compaction, signed owner self-service endpoints, and the optional Agent Access gateway without forking the Evolu monorepo.
See evoluhq/evolu#661 for the full writeup.
git clone https://github.com/elkimek/getbased-relay.git
cd getbased-relay
docker build -t getbased-relay:latest .
docker run -d \
--name relay \
-p 127.0.0.1:4000:4000 \
-p 127.0.0.1:4003:4003 \
-v relay-data:/data \
-e SELF_BIND=0.0.0.0 \
-e QUOTA_PER_OWNER_MB=10 \
-e ADMIN_TOKEN=your-secret \
getbased-relay:latestOr run the relay and Context Gateway together with Docker Compose. Copy .env.example, replace the blank CONTEXT_VERIFIER_TOKEN with the output of openssl rand -hex 32, then start the stack:
cp .env.example .env
docker compose up -d --buildRequires Node.js >= 24.20.0. TypeScript is installed as a development dependency.
Both Docker images pin Node.js 24.21.0 LTS by tag and digest. Dependabot keeps updates within the current Node major; a future runtime major requires a separate LTS and compatibility review. CI runs the regression suite inside the built relay image and checks the built gateway image's startup, verified context upload, persistence, read, and revocation.
npm ci
npm run build
npm startAll settings via environment variables. See .env.example for the full list.
| Variable | Default | Description |
|---|---|---|
RELAY_PORT |
4000 |
Evolu WebSocket relay port |
ADMIN_PORT |
4001 |
Health/metrics HTTP port (the supplied Compose stack overrides this to internal port 4002) |
SELF_PORT |
4003 |
Owner-scoped self-service HTTP port |
SELF_BIND |
127.0.0.1 |
Bind address for self-service port (set to 0.0.0.0 to expose directly without a reverse proxy) |
SELF_ENABLED |
true |
Set false to disable /self/* endpoints |
CONTEXT_VERIFIER_PORT |
4004 |
Private Agent Access proof-verification port |
CONTEXT_VERIFIER_BIND |
127.0.0.1 |
Verifier TCP bind address when a Unix socket is not configured |
CONTEXT_VERIFIER_SOCKET |
(none) | Unix socket path; takes precedence over TCP and is used by the supplied compose deployment |
CONTEXT_VERIFIER_ENABLED |
false |
Enables the private verifier; compose enables it for Context Gateway |
CONTEXT_VERIFIER_TOKEN |
(none) | Required separate bearer when the private verifier is enabled |
RELAY_NAME |
evolu-relay |
SQLite database filename, without .db |
DATA_DIR |
./data |
Relay database and metadata directory |
QUOTA_PER_OWNER_MB |
10 |
Max stored bytes per identity |
QUOTA_GLOBAL_MB |
1000 |
Max total live encrypted payload bytes; excludes SQLite overhead, compaction tombstones, and Context Gateway files |
OWNER_TTL_DAYS |
90 |
Days before owner flagged as stale |
LOG_LEVEL |
info |
debug, info, warn, error |
LOG_FORMAT |
json |
json or text |
ENABLE_EVOLU_LOGGING |
false |
Enables Evolu's very verbose raw CRDT/SQL logs |
ADMIN_TOKEN |
(none) | Bearer token for /metrics; required for /compact-owner |
Relay port (default 4000) — Evolu WebSocket sync. Connect your Evolu client with:
transports: [{ type: "WebSocket", url: "wss://your-relay.example.com" }]Admin port (default 4001, localhost only) — HTTP endpoints:
GET /health— Always public. Returns{"status":"ok","uptime":...,"version":...}when the relay Run is active and its database has the required tables; returns503withstatus: "unhealthy"otherwise. It is a readiness check, not an end-to-end sync probe.GET /metrics— Requires the admin bearer token whenADMIN_TOKENis configured. Returns owner count, per-owner storage, DB size, connection count, and quota settings.POST /compact-owner?ownerId=<base64url-22-char>— Requires the admin bearer token. Replaces every relay message with a small exact-timestamp replay tombstone and clears the owner's live usage. A stale or offline paired client can reconnect safely: replayed history is acknowledged but not stored, while timestamps the relay has never compacted remain valid new writes. Response body:{ownerId, deletedMessages, protectedTimestamps, beforeStoredBytes, afterStoredBytes}.
Self-service port (default 4003) — owner-scoped HTTP endpoints, signed with the client's own writeKey. No admin token; one user can never act on another user's owner. Intended to be exposed via the same reverse proxy as the relay port.
POST /self/compact-owner— Body:{ownerId, timestamp, signature}. Same replay-protected compaction as/compact-owner, but client-driven. Sync each device's latest changes first so the chosen canonical device has current application data; offline devices no longer need to discard their local Evolu history before reconnecting.GET /self/owner-storage?ownerId=...×tamp=...&signature=...— Returns{ownerId, storedBytes, quotaBytes, messageCount, lastWriteToken}from the live relay database. Besides an accurate quota readout,messageCountandlastWriteTokenlet a client confirm that a push was actually persisted.
Auth scheme. signature = HMAC-SHA256(writeKey, "{context}:{ownerId}:{timestamp}").hex() where context is "compact" or "storage". The relay looks up the writeKey in its evolu_writeKey table (the same secret the Evolu client already holds for pushes), recomputes the HMAC, and timing-safe-compares. The timestamp must be within ±5 minutes of server time. All auth failures return a uniform 401 unauthorized to avoid an owner-existence oracle.
Rate limit. Per-IP token bucket caps /self/compact-owner at 10 requests/minute and /self/owner-storage at 60 requests/minute. Excess returns 429 with a Retry-After header. When the relay is behind a reverse proxy on the same host (peer = loopback), the limiter trusts the leftmost X-Forwarded-For entry; otherwise it uses the socket peer. Caddy's reverse_proxy directive sets X-Forwarded-For automatically, so no extra config is needed.
Log coalescing. Repeated unauthorized requests with the same (ownerId, IP, reason) are logged once on first occurrence; further hits within 60 s suppress, and a self.coalesced_unauthorized summary fires on window expiry if the count exceeded 1. Stops a flood from filling the log without losing the first signal of any abuse pattern.
The relay port serves WebSocket only. Use Caddy or nginx for TLS termination:
# Caddyfile — option A: dedicated subdomain per surface
sync.example.com {
reverse_proxy localhost:4000
}
self.example.com {
reverse_proxy localhost:4003
}
# Caddyfile — option B: single hostname, path-routing (what sync.getbased.health uses)
sync.example.com {
handle /self/* {
reverse_proxy 127.0.0.1:4003
}
handle {
reverse_proxy 127.0.0.1:4000
}
}
Both patterns work; the client (get-based) derives https://<relay-hostname>/self/... from the WebSocket URL by default. For self-hosters who want to skip the reverse proxy entirely (expose port 4003 directly to the internet), set SELF_BIND=0.0.0.0 and have clients hard-code their own URL via the labcharts-self-url localStorage override.
The admin port always binds to process-local 127.0.0.1. A native Node.js deployment can reach it through an SSH tunnel; in the supplied Compose stack it stays container-only on port 4002 and can be queried with docker compose exec relay. The self-service port defaults to 127.0.0.1 too; container deployments explicitly bind it to 0.0.0.0 inside the container while publishing only to host loopback for the reverse proxy. Every /self/* endpoint is HMAC-authenticated against per-owner write keys.
Evolu clients ──WSS──▶ :4000 relay + replay filter ─────────▶ relay SQLite DB
Owner clients ─HTTPS─▶ :4003 /self/* ───────────────────────▶ owner-scoped DB operations
Operator ──HTTP─▶ :4001 admin (Compose: internal :4002)▶ health, metrics, compaction
AI assistants ─HTTPS─▶ :4001 Context Gateway ──Unix socket─▶ private owner-proof verifier
│ │
└── encrypted context files └── yes/no only; no key export
- src/index.ts — Entry point, wiring, signal handlers
- src/lib/config.ts — Env var parsing with defaults and typed
RelayConfiginterface - src/lib/logger.ts — Custom Console that intercepts Evolu's 17 relay events, emits structured JSON at configurable levels
- src/lib/quota.ts — Per-owner + global disk quota via
isOwnerWithinQuotacallback - src/lib/compact-owner.ts — Atomic owner compaction shared by admin and self-service routes
- src/lib/compaction-replay.ts — Exact-timestamp tombstones that prevent stale devices from refilling compacted relay history
- src/lib/replay-protected-relay.ts — Evolu Node/WebSocket adapter with the replay-filtered storage wrapper
- src/lib/owner-write-lock.ts — Same-owner serialization between relay writes and compaction
- src/lib/owner-tracker.ts — Last-seen tracking via relay subscribe events, persisted to sidecar file
- src/lib/metrics.ts — Read-only SQLite queries against the relay DB
- src/lib/admin-server.ts — Local HTTP server for health, metrics, and admin compaction
- src/lib/self-server.ts — HMAC-authenticated, owner-scoped storage and compaction endpoints
- src/lib/context-verifier-server.ts — Private yes/no owner-proof verifier for Agent Access
- src/lib/startup-check.ts — DB integrity validation on boot (magic bytes, PRAGMA check, table audit)
- context-gateway/server.js — Encrypted Agent Access storage, quotas, and token-to-owner mapping
HTTP API for Agent Access. MCP servers and bot plugins use it to fetch encrypted, browser-rendered getbased context for an external AI assistant. Runs alongside the Evolu relay as a separate service.
Agent Access storage is owner-bound, not token-bound:
- the browser encrypts context locally before upload;
- the bearer token is only a read capability;
- every write is HMAC-signed with the Evolu owner's
writeKey; - the gateway sends the proof to a private relay verifier that returns only yes/no; the gateway never mounts the relay database and never receives a write key;
- token hashes map to owner IDs in
agent-token-map.json; - per-owner limits cap profiles, active tokens, per-profile payload size, and total Agent Access storage.
This prevents users from bypassing relay quota by generating unlimited random Agent Access tokens. Legacy token-hash files remain readable during rollout, but new writes require owner proof.
The verifier listens on a shared Unix socket and requires a
separate CONTEXT_VERIFIER_TOKEN. Generate that deployment token with
openssl rand -hex 32; it is an internal caller credential, not an Evolu
owner key. The public Agent Access protocol and browser HMAC format are
unchanged.
The supplied Context Gateway limiter keys on a dedicated
X-Getbased-Client-IP header rather than X-Forwarded-For. The reverse proxy
must overwrite it from the actual socket peer:
handle /api/* {
reverse_proxy 127.0.0.1:4001 {
header_up X-Getbased-Client-IP {http.request.remote.host}
}
}All endpoints require an Agent Access bearer token in the Authorization header.
| Method | Path | Description |
|---|---|---|
POST /api/context |
— | Push encrypted context. Body: { ownerId, timestamp, signature, context, profileId }. Signature is HMAC-SHA256(writeKey, "agent-context:{ownerId}:{timestamp}:{sha256(token)}:{profileId}:{sha256(context)}"). |
GET /api/context |
— | Get the default profile's encrypted context for this token's owner mapping |
GET /api/context?profile=<id> |
— | Get a specific profile's encrypted context |
DELETE /api/context |
— | Revoke this token's owner mapping, signed with the same owner proof over an empty context |
The docker-compose.yml runs both services and keeps private surfaces off the public interface:
| Service | Port | Purpose |
|---|---|---|
| Evolu relay | host 127.0.0.1:4000 |
CRDT sync (WebSocket), intended for reverse proxying |
| Context gateway | host 127.0.0.1:4001 |
Agent Access context API, intended for reverse proxying |
| Relay self-service | host 127.0.0.1:4003 |
Owner-scoped storage and compaction API |
| Relay admin | container-only :4002 |
Health, metrics, and operator compaction |
| Private verifier | Unix socket | Internal yes/no owner-proof verification; no listening TCP port |
docker compose up -dFor the v2.0.1 upgrade, back up the relay volume before rebuilding. Preserve the current environment variables and proxy routes; no database migration, owner-key rotation, or browser package update is needed. Global quota admission now checks the projected payload total on every write and rejects writes when usage cannot be read. Run one relay process per database; the in-process compaction locks and admission checks do not coordinate multiple relay processes.
npm test builds and runs storage, WebSocket, HTTP, quota, and process-lifecycle regression tests. CI runs the same suite. The application can be validated separately against the upgraded relay before deployment; the upstream browser package update remains a separate change.
See CHANGELOG.md for the versioned history and upgrade notes. GitHub release pages should be cut from the matching version commit; the package version, changelog entry, and tag must agree.
Built on Evolu by Daniel Steigerwald. All sync protocol, CRDT logic, and SQLite storage are from Evolu — this project only adds the operational wrapper.
AGPL-3.0-or-later. See LICENSE.
If you run a modified version of this relay as a network service, AGPLv3 §13 requires you to offer your users the corresponding source.