Files
hope-events/docs/hub-protocol.md
joshuaandClaude Opus 5.5 dd537a1a16 Fix: update request for an older version than the latest (1.47.4)
After asking for an update, a newer version coming out made the Licence
page claim the newer version was requested, with no way to ask for it.
Now it shows the version asked for, that a newer one is out, and "Ask
for X instead"; the banner shows again. Hub 1.5.0 moves the open request
on (`changed`); with an older Hub the site withdraws and asks again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:16:56 +02:00

11 KiB
Raw Permalink Blame History

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",
  "releases": [ … ],               // as in the heartbeat reply (§3); Hub 1.4.0+
  "notices": [ … ]
}
  • 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",
  "releases": [                                        // Hub 1.4.0+: newer than the version sent, newest first, at most 20
    { "version": "1.42.0", "releasedAt": "2026-10-27T10:00:00.000Z", "urgent": false, "notes": "- Event series\n- Fixed: …" }
  ],
  "notices": [                                         // Hub 1.4.0+: the provider's banners for this install
    { "id": "…", "level": "maintenance", "title": "Planned maintenance", "message": "…",
      "startsAt": "2026-11-01T16:00:00.000Z", "endsAt": "2026-11-01T16:30:00.000Z", "updatedAt": "…" }
  ],
  "serverTime": "…"
}
  • releases — what's new in each version, so the church can decide whether to ask for the update. The Hub takes the versions from the Cross Events repository's v* tags and the notes from its WHATS-NEW.md. notes is plain text; lines starting with - are a list. urgent marks security or serious bug fixes: the install shows those in red and doesn't let admins put them off. The highest release is latestVersion.
  • notices — banners Joshua posts on the Hub's Notices page (level: info, maintenance or warning). Sent until endsAt, including ones that haven't started, so the install shows each one from startsAt without waiting for a heartbeat. The install shows them to site admins and supervisors. Notices can also be emailed; that happens on the Hub, not through the install.
  • A Hub older than 1.4.0 sends neither field; the install then shows no notes and no notices.
  • 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, "changed": false }
→ 200 { …, "alreadyRequested": true, "changed": false }    // a request for the latest is already open: not emailed again
→ 200 { …, "alreadyRequested": true, "changed": true, "previousVersion": "1.42.0" }
                                         // the open request was for an older version: it now asks for the latest
  • 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.
  • Changing a request (Hub 1.5.0): when a request is open for a version that has since been overtaken, asking again moves it on to the latest version — the alert says "latest is 1.47.3 (changed from 1.47.0)" and the alert address is emailed once about the change. version is always the version now asked for. A Hub before 1.5.0 doesn't send changed; installs then withdraw the request and ask again (two emails, same result).
  • 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).

Withdrawing it

Sent when a site admin presses Withdraw request. Signed like the heartbeat.

POST /api/installs/update-request/cancel
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature   (as in §3)
{ "protocolVersion": 1 }
→ 200 { "cancelled": true }     // the open request was resolved, and Joshua is emailed
→ 200 { "cancelled": false }    // there was no open request
  • The Hub resolves the update_requested alert and records update_request_withdrawn in the install's history. Rate-limited with update requests. A Hub older than 1.4.0 answers 404 NOT_FOUND.
  • "Not now" (no reminders until the next version) stays on the install; the Hub isn't told.

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 (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.