# Hub protocol (version 1) The contract between a **Cross Events install** (the church's site) and the **Cross Events Hub** (`events-hub.crosscode.co.za`). A copy of this file lives in both repositories (`joshua/hope-events` → `docs/hub-protocol.md`, `joshua/events-hub` → `docs/hub-protocol.md`); change both together. Principles: - **Install → Hub only.** The install always starts the conversation; the Hub never calls into an install. Works behind firewalls, and the Hub has no access to church data. - **No personal information** is ever sent — counts, versions and health only. Income is sent as a range, never an amount. - **A Hub outage never switches a church off.** Licences are signed tokens the install verifies offline with the Hub's public key. - Every request carries `protocolVersion`. The Hub accepts the current and the previous version, so installs waiting to update keep working. All bodies are JSON (`Content-Type: application/json`). Times are ISO 8601 UTC strings. Errors are `{ "message": "...", "code": "..." }` with a 4xx/5xx status. --- ## 1. Public key ``` GET /api/public-key → 200 { "protocolVersions": [1], "keyId": "k1", "algorithm": "Ed25519", "publicKey": "-----BEGIN PUBLIC KEY-----\n…" } ``` Installs ship with the Hub's public key built in (and may refresh it from here). The private key exists only on the Hub server. ## 2. Register an install Sent once, at the end of `/setup` (licence step) or from Site settings → "Connect to Hub". ``` POST /api/installs/register { "protocolVersion": 1, "licenceKey": "CEH-7K2M-9QXA-P4RT-WD8C", "install": { "name": "Hope Family Church", "url": "https://events.hopefamily.co.za", "adminContact": { "name": "Jane Admin", "email": "jane@hopefamily.co.za" }, "version": "1.41.0", "timezone": "Africa/Johannesburg", "server": { "node": "20.18.0", "postgres": "16.4", "os": "linux 6.8" }, "features": { "whatsapp": true, "paymentProviders": ["yoco", "payfast"], "campuses": false } } } → 201 { "installId": "inst_…", "installSecret": "…", // shown once; the install stores it encrypted "licence": "", "heartbeatIntervalMinutes": 15, "latestVersion": "1.41.0" } ``` - `404 LICENCE_NOT_FOUND` — unknown key. `403 LICENCE_REVOKED` — revoked licences can't register. - One licence = one install. Registering again **from the same URL** re-registers (new secret, same `installId`). From a different URL → `409 LICENCE_IN_USE` until Joshua releases the licence in the Hub (e.g. after a move to a new domain). - The `adminContact` is the one piece of personal information: a work contact for the church's admin, so Joshua can reach them. It is not used for anything else. - Rate-limited per IP. ## 3. Heartbeat Every `heartbeatIntervalMinutes` (15) from the install's job queue. ``` POST /api/installs/heartbeat X-Hub-Install: inst_… X-Hub-Timestamp: 2026-10-24T08:15:00.000Z X-Hub-Signature: { "protocolVersion": 1, "version": "1.41.0", "health": { "uptimeSeconds": 86400, "db": true, "jobs": { "mode": "queue", "failedLast24h": 0 }, "lastBackupAt": "2026-10-24T00:00:12.000Z", // null if none "disk": { "freeBytes": 21474836480, "totalBytes": 53687091200 }, // null if unknown "errorsLast24h": 0, // null if not tracked "pendingMigrations": 0 }, "stats": { "users": 412, "events": { "active": 3, "upcoming": 5, "total": 61 }, "registrations": { "last30d": 88, "total": 2310 }, "tickets": { "last30d": 140, "total": 3901 }, "checkIns": { "last30d": 96 }, "messages": { "email30d": 610, "whatsapp30d": 233 }, "paymentProviders": ["yoco", "payfast"], "campuses": false, // Site settings → Campuses on/off "campusCount": 0, // active campuses (0 while off; Cross Events 1.43.0+) "income": { "last30d": "R5 001–R20 000", "allTime": "over R100 000" } } } → 200 { "licence": "", "latestVersion": "1.42.0", "serverTime": "…" } ``` - The signature covers the exact bytes sent. Requests more than **5 minutes** off the Hub's clock are refused (`401 STALE_REQUEST`); a bad signature is `401 BAD_SIGNATURE`; an unknown or released install is `404 INSTALL_NOT_FOUND` (the install should show "Reconnect to Hub"). - **Income bands** (worked out on the install; the amount never leaves it): `R0–R1 000`, `R1 001–R5 000`, `R5 001–R20 000`, `R20 001–R50 000`, `R50 001–R100 000`, `over R100 000`. - The Hub keeps every heartbeat for 30 days, then one per install per day. ## 3a. Update request Sent when a site admin presses **Request update** (Site settings → Licence). Installs never update themselves and never show the deploy command; the request tells Joshua, who updates the server. Signed exactly like the heartbeat. ``` POST /api/installs/update-request X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3) { "protocolVersion": 1, "version": "1.41.2" } → 201 { "requestedAt": "2026-10-27T09:00:00.000Z", "version": "1.42.0", "alreadyRequested": false } → 200 { …, "alreadyRequested": true } // a request is already open: not emailed again ``` - The Hub opens an `update_requested` alert on the install (shown under Alerts) and emails the alert address once. It is resolved when a heartbeat reports a newer version than before. - `409 UP_TO_DATE` — the install already runs the Hub's latest version (or none is set). - Errors as in §3 (`INSTALL_NOT_FOUND`, `STALE_REQUEST`, `BAD_SIGNATURE`). Rate-limited (10 an hour per IP). A Hub older than this section answers `404 NOT_FOUND`. - No personal information: not who pressed the button (the Hub already has the admin contact). ## 4. Licence token `hel1..` — `payload` is base64url JSON, `signature` is the base64url Ed25519 signature of the ASCII string `hel1.`. ```json { "v": 1, "licenceId": "lic_…", "installId": "inst_…", "customer": "Hope Family Church", "plan": "standard", "status": "active", // active | suspended | revoked "licenceExpiresAt": "2027-10-31T21:59:59.000Z", // null = no end date "graceDays": 14, "issuedAt": "2026-10-24T08:15:00.000Z", "validUntil": "2026-11-23T08:15:00.000Z", // issuedAt + 30 days "limits": { "aiMonthlyCapCents": 0 } } ``` ### What the install does with it The install keeps the newest valid token (signature checks out, `installId` matches) and works out its state: | State | When | What happens | |-------|------|--------------| | **active** | `status` is active and now is before `min(licenceExpiresAt, validUntil)` | Everything works | | **grace** | after that date, for up to `graceDays` | Everything works; **only site admins** see a banner "Licence needs renewing — X days left" | | **suspended** | `status` is suspended, or grace has run out | Read-only: events can't be created or edited; no new registrations or payments. Tickets still scan; admins can view and export all data; privacy requests still work | | **revoked** | `status` is revoked | Same as suspended, with a permanent banner | | **unlicensed** | never connected, or the Hub answered `INSTALL_NOT_FOUND` (the install drops its token) | Same as suspended (events can't be created or edited either); admins see "Connect to Hub" | `validUntil` is refreshed by every heartbeat, so a Hub outage only matters after 30 days (then grace starts). Blocking the Hub can't keep a suspended licence active for more than 30 days. ## 5. Versioning - `protocolVersion` goes up only for changes an older install can't handle. Adding optional fields to a heartbeat or a token doesn't need a new version — both sides ignore fields they don't know. - The Hub accepts protocol `N` and `N-1`; anything else is `400 UNSUPPORTED_PROTOCOL`. ## 6. Later (not in v1) - **AI proxy** (Cross Events 2.0): `POST /api/ai/complete`, signed like the heartbeat; the Hub holds the Claude API key, meters each call in rand and enforces `limits.aiMonthlyCapCents`.