Files

18 KiB
Raw Permalink Blame History

Hub protocol (versions 1 and 2)

The contract between an install (one of a church's products: Cross Events, Accounts, Church Website, Church App) and Cross Code Hub (hub.crosscode.co.za; events-hub.crosscode.co.za keeps working as an alias). Sections 1–5 describe version 1, which Cross Events 2.1 speaks; version 2 (every product, Hub 2.0) is section 6. A copy of this file lives in both repositories (joshua/crosscode-platform → docs/hub-protocol.md, for every product; joshua/events-hub → docs/hub-protocol.md); change both together. (Until 2026-11-05 the install's copy was in hope-events/docs/.) From Hub 2.0 the formats are defined once, in @crosscode/contracts (crosscode-platform/packages/contracts, src/hub.ts, with JSON Schema and samples).

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": "R150 001–R250 000",
                "eventIncome12m": "R100 001–R150 000" }   // eventIncome12m: Cross Events 2.0.2+
  }
}
→ 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, R100 001–R150 000, R150 001–R250 000, R250 001–R400 000, R400 001–R700 000, R700 001–R1 000 000, R1 000 001–R2 500 000, R2 500 001–R5 000 000, R5 000 001–R10 000 000, over R10 000 000. Before Cross Events 2.0.2 the bands stopped at over R100 000, and donations applied to a registration were counted twice.
  • eventIncome12m (Cross Events 2.0.2+) is the band of the last 365 days' event income: payments less refunds, without donations or sponsorships. Cross Code's licence tiers are billed on it, so every tier edge is a band edge. Older installs don't send it.
  • 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.

6. Protocol version 2 (Hub 2.0)

The formats are in @crosscode/contracts (hub.registerRequestSchema, hub.heartbeatRequestSchema, … with JSON Schema in schemas/hub-*.json and samples in samples/hub-*.json). The Hub accepts versions 1 and 2 (/api/public-key lists [1, 2]); a v1 request is treated as Cross Events and answered exactly as in sections 1–5. Signing, offline licence checks, update requests and AI calls are as in version 1.

What version 2 changes:

Version 1 Version 2
Who Cross Events Every product: product is events, accounts, website or app
Licence keys CEH-… CEH- Events, CCA- Accounts, CCW- Website, CCP- App
Register as §2 adds product (must match the licence: 409 WRONG_PRODUCT) and install.publicKey (the install's Ed25519 public key, PEM)
Heartbeat as §3 adds product (must match the install: 409 WRONG_PRODUCT); stats per product (Events as v1; Website: pages, messages, events, editors, storage; App: activeMembers90d band, modules, prayer coverage; Accounts: accounts, 2FA methods)
Replies as §2, §3 add linkedProducts: the church's other installs in use that have a public key, as { product, installId, url, publicKey }, nothing else. Releases carry requires (below)
Bodies checked by hand checked against the contract schemas: 400 INVALID_REQUEST with the field that's wrong; unknown fields are dropped
Licence token as §4 also customerId and product (still v: 1; a v1 install ignores them). limits.aiMonthlyCapCents is the church's cap

Releases per product. An install is told its own product's releases and latest version. A release can need a minimum version of a sibling (— requires accounts 1.2.0 in its WHATS-NEW.md heading): v2 replies carry it as requires: [{ product, version }], and an update request answers 409 REQUIREMENT_NOT_MET (with requirements: [{ product, version, current }] and a message saying what's needed) until the church's install of that product runs that version or later.

AI. The monthly cap and markup are the church's, shared by all its installs: a call from any of them counts what all of them used this month.

Moving an install to v2 (e.g. Cross Events 2.4.0): register again with protocolVersion: 2, the product and a public key (same licence and address: same install id, new secret), then send v2 heartbeats. Nothing on the Hub needs changing.