Optional field, so no protocol version change; the Hub stores stats as-is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
8.0 KiB
Markdown
183 lines
8.0 KiB
Markdown
# 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": "<licence token>",
|
||
"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: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
|
||
{
|
||
"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": "<fresh licence token>", "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>.<signature>` — `payload` is base64url JSON, `signature` is the base64url
|
||
Ed25519 signature of the ASCII string `hel1.<payload>`.
|
||
|
||
```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`.
|