Files
hope-events/docs/hub-protocol.md
T
joshuaandClaude Opus 5.5 311fe8b6f1 WIP 2.0.0: AI helper plumbing (status, usage, Site settings -> AI)
The site calls AI through the Hub (protocol 3b, Hub 1.6.0): src/ai
works out whether the AI buttons show (licence token first, then the
Hub, cached a minute), runs a helper call with per-helper switches and
a per-person daily limit, logs AiCall rows, and emails admins at 80%.
v2 /ai/status, /ai/usage, /ai/settings; useAiStatus, AiButton, AiTab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:25:36 +02:00

14 KiB
Raw 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.

3b. AI (Cross Events 2.0, Hub 1.6.0)

Installs never hold the Claude API key. Each AI helper call goes through the Hub, which adds the key, checks the licence's monthly cap, calls Claude and meters the call. Both requests are signed exactly like the heartbeat. The install keeps its prompts; the Hub stores no prompt or response text — only the install, helper name, tokens, cost and time.

Status

POST /api/installs/ai/status
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature   (as in §3)
{ "protocolVersion": 1 }
→ 200 { "enabled": true, "capReached": false, "capCents": 5000, "usedCents": 812.4,
        "usedPercent": 16, "renewsOn": "2026-12-01" }
  • enabled: false — hide the AI buttons everywhere: the licence's cap is R0, the licence isn't active (suspended, revoked, or past its grace days), or the Hub's AI isn't set up.
  • capReached: true — show the buttons disabled ("This month's AI allowance is used up — it renews on 1 December"). Reached means there isn't room left for even a small call.
  • Amounts are cents (rand) as charged to the church, with fractions (a call costs a fraction of a cent). The month is South African time; renewsOn is the 1st of the next month.

A helper call

POST /api/installs/ai/complete
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature   (as in §3)
{
  "protocolVersion": 1,
  "helper": "draft_event",          // lower-case name, for usage reports
  "system": "…",                    // the helper's fixed instructions (cached by Claude)
  "input": "…",                     // this request's text
  "schema": { … },                  // optional JSON schema: the reply is JSON in that shape
  "maxTokens": 2000,                // 1–8000
  "effort": "low"                   // optional: low (default) | medium | high
}
→ 200 { "output": "…" | { … },      // text, or the parsed JSON when a schema was given
        "usage": { "inputTokens": 1840, "outputTokens": 420 },
        "chargedCents": 0.31,
        "status": { …as above… } }
  • Errors are { code, message, status } — status is the new AI status, so buttons can disable straight after the call that used up the allowance:
    • 402 AI_CAP_REACHED — this call could pass the cap (checked before calling Claude).
    • 403 AI_OFF (cap R0) · 403 LICENCE_INACTIVE · 503 AI_NOT_CONFIGURED (Hub not set up).
    • 422 AI_REFUSED — Claude declined. 502 AI_INCOMPLETE — the answer hit maxTokens. 502 AI_FAILED — not usable (e.g. JSON in the wrong shape). These still cost what was used.
    • 503 AI_BUSY / 503 AI_UNAVAILABLE — Claude unreachable or rate-limited; nothing is charged.
    • 400 BAD_REQUEST, 413 AI_INPUT_TOO_LARGE (about 90 000 tokens), and the §3 errors.
  • Metering: real cost = tokens × the model's price × the exchange rate set in the Hub; charged = real × (1 + markup). The cap counts charged rand. Before calling Claude the Hub holds the worst case (an over-estimate of the prompt, plus maxTokens) against the cap in one transaction per install, so calls at the same moment can't pass it; afterwards the hold becomes the real amount.
  • Rate-limited to 60 a minute per IP (against runaway loops; money is limited by the cap).
  • A Hub older than 1.6.0 answers 404 NOT_FOUND: the install treats that as "AI off".

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 }      // the AI cap (§3b); 0 = AI off
}

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.