Files
hope-events/docs/hub-protocol.md
T
joshuaandClaude Opus 5.5 e58787ae7a Licence: no licence is read-only, editing events blocked, heartbeat 30 s after start
- A site the Hub answers INSTALL_NOT_FOUND for drops its install and licence, so it
  no longer shows "Active" on the old token.
- Unlicensed is read-only like suspended; read-only also blocks editing events,
  options and variants. LICENCE_CHECK_DISABLED (ignored in production) for e2e.
- Heartbeat 30 s after the server starts and after a licence key is connected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:45:38 +02:00

6.7 KiB
Raw Blame History

Hub protocol (version 1)

The contract between a Hope Events install (the church's site) and the Hope 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,
    "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.

4. Licence token

hel1.<payload>.<signature> — payload is base64url JSON, signature is the base64url Ed25519 signature of the ASCII string hel1.<payload>.

{
  "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 (Hope 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.