Compare commits

...
Author SHA1 Message Date
joshuaandClaude Opus 5.5 3c5246424b Bump to 1.41.0
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-26 13:03:53 +02:00
joshuaandClaude Opus 5.5 bbdceb38b6 Handover and roadmap: 1.41.0 and Hub 1.0.0; next steps to go live
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-26 13:03:50 +02:00
joshuaandClaude Opus 5.5 c0965bfcf5 Hub client: accept the public key pasted on one line; clearer error for a bad key
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-26 12:58:44 +02:00
joshuaandClaude Opus 5.5 eb2159fa33 Hub client: licence, heartbeat, read-only mode, /health, install script
- Connect with a licence key (/setup last screen, Site settings -> Licence)
- hub-heartbeat job every 15 min: version, health, totals (income as a band)
- Licence tokens verified with the Hub's public key; states active / grace
  (banner for site admins only) / suspended / revoked (read-only) /
  unlicensed; Hub keys kept out of the generic settings endpoints
- /health; "update available" for admins; scripts/install.sh
- docs/hub-protocol.md shared with the events-hub repo
- Unit tests (hub.test.js); docs

The production Hub public key (backend/src/hub/publicKey.js) must be set
before this is released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-26 12:25:52 +02:00
joshua 0551226de5 Merge branch 'docs/hub-repo' 2026-10-24 16:03:43 +02:00
joshuaandClaude Opus 5.5 1ae9c45eed Roadmap: Hub repo is joshua/events-hub; grace banner for site admins only
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-24 16:03:43 +02:00
joshua fd2bb07047 Merge branch 'docs/ai-roadmap'
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
2026-10-24 14:36:25 +02:00
37 changed files with 1487 additions and 24 deletions
+30
View File
@@ -7,6 +7,36 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.41.0] - 2026-10-26
### Added
- **Hope Events Hub connection** (`docs/hub-protocol.md`). Each install connects to the Hub
(`events-hub.crosscode.co.za`) with a licence key:
- **Licence step in `/setup`** (on the last screen), and **Site settings → Licence** for existing
installs: connect, see the licence (licensed to, plan, end date, grace period), the last check-in,
and "Check now".
- A **heartbeat every 15 minutes** (`hub-heartbeat` job; `HUB_HEARTBEAT_ENABLED=false` to switch off):
version, health (database, background jobs, last backup, free disk, server errors, pending
migrations) and totals (users, events, registrations, tickets, check-ins, payment providers, income
as a range only). Never anyone's personal information.
- **Licences are checked on the site** with the Hub's public key, so a Hub outage never switches a
site off and a licence can't be faked by editing the database:
- **Grace period** (licence past its end date, default 14 days): everything works; **only site
admins** see "Licence needs renewing — X days left".
- **Suspended or revoked**: read-only — no new events, registrations, tickets, payments, waitlist
places, coupons or bundles. Tickets still scan; all data can still be viewed and exported;
refunds, cancellations and privacy requests still work.
- Not connected yet: works as before; admins see "Connect to the Hub".
- **Update available**: admins see when a newer version is out, with the command to run.
- **`/health`** — status, version, database, background jobs and last backup, for monitoring.
- **Install script** (`scripts/install.sh`): a fresh Ubuntu server to a running site in one command —
Node 20, PostgreSQL, PM2, nginx, HTTPS, configuration, migrations, build; then `/setup`.
### Changed
- The generic settings endpoints never read or write the Hub connection (install id, secret, licence).
## [1.40.0] - 2026-10-23
### Added
+11
View File
@@ -642,6 +642,7 @@ can't start, the same jobs run on in-process timers.
| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) |
| `privacy` — remind admins of privacy requests due within a week or overdue | 08:00 daily (`PRIVACY_JOBS_ENABLED=false`) |
| `retention` — delete/anonymise personal information past its retention period (Admin → Privacy → Retention) | 03:30 daily (`RETENTION_JOBS_ENABLED=false`) |
| `hub-heartbeat` — check in with the Hope Events Hub and pick up licence changes (only once connected) | Every 15 minutes (`HUB_HEARTBEAT_ENABLED=false`) |
| `analytics-rollup` — roll raw visit rows older than 90 days into daily totals | 04:15 daily (`ANALYTICS_ROLLUP_ENABLED=false`) |
| `backup` — database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false`) |
| `temp-cleanup` — temp files older than 7 days in `/temp` | 03:00 daily |
@@ -650,6 +651,16 @@ can't start, the same jobs run on in-process timers.
## Operations
- **Install script** (`scripts/install.sh`) — a fresh Ubuntu server to a running site: Node 20,
PostgreSQL, PM2, nginx, HTTPS, configuration, migrations and build; then `/setup`.
- **Hope Events Hub** — every install connects with a licence key (`/setup`, or Site settings →
Licence) and checks in every 15 minutes with its version, health and totals (no personal
information; income as a range). Licences are signed tokens checked on the site, so a Hub outage
never switches it off: past the end date there's a grace period (banner for site admins only),
then — or when suspended or revoked — the site is read-only (no new events, registrations or
payments; tickets still scan, data can be exported). Admins see when an update is available.
See `docs/hub-protocol.md`.
- **`/health`** — status, version, database, background jobs and last backup.
- **PM2** (`ecosystem.config.js`) runs backend and frontend, restarting either
one above 500 MB of memory.
- **Sentry** error monitoring when `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` are
+18
View File
@@ -98,6 +98,24 @@ Documentation: see Frontend README for environment and scripts.
## Deployment
### A new install
On a fresh Ubuntu 22.04/24.04 server, with the domain's DNS already pointing at it:
```bash
curl -fsSLO https://c-git.crosscode.co.za/joshua/hope-events/raw/branch/main/scripts/install.sh
sudo bash install.sh --domain events.yourchurch.org --email you@yourchurch.org [--version v1.41.0]
```
It installs Node 20, PostgreSQL (with its own database and a random password), PM2, nginx (the
API, uploads, `/health` and `/docs` to the backend, everything else to the site) and an HTTPS
certificate, writes `backend/.env` and `frontend/.env.production`, migrates, builds and starts the
app. Then open `https://<domain>/setup`: create the admin account and enter the **licence key**
to connect the site to the Hope Events Hub. Several installs can share a server with different
`--name`, `--dir`, `--web-port` and `--api-port`. Run `--help` for all options.
### Updating
To update a running install, on the server in the repo folder:
- `bash scripts/deploy.sh` — the latest `main`
+9
View File
@@ -84,6 +84,15 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
## Hope Events Hub (licence)
| Setting | Default when unset | Notes |
|---|---|---|
| `HUB_URL` (env) | `https://events-hub.crosscode.co.za` | Where the Hub runs. |
| `HUB_PUBLIC_KEY` (env) | The production key built into `backend/src/hub/publicKey.js` | Only for a local or test Hub. Licence tokens are checked with this key. |
| `HUB_HEARTBEAT_ENABLED` (env) | `true` | The 15-minute `hub-heartbeat` job. Off means the licence isn't refreshed — after 30 days the site goes into grace, then read-only. |
| `hub_install_id`, `hub_install_secret` (encrypted), `hub_licence`, `hub_last_heartbeat_at`, `hub_last_error`, `hub_latest_version` | — | Written only by `src/hub/client.js` when connecting and on each heartbeat. Never readable or writable through the generic settings endpoints. Deleting them disconnects the site (it becomes "not connected"). |
## WhatsApp
| Setting | Default when unset | Notes |
+7
View File
@@ -76,6 +76,13 @@ PRIVACY_JOBS_ENABLED=true
# Privacy (POPIA): nightly 03:30 retention job (Admin → Privacy → Retention). Note: inactive
# accounts are anonymised after 5 years by default (with a warning 30 days before).
RETENTION_JOBS_ENABLED=true
# ─── Hope Events Hub (licence and health) ─────────────────────────────────────
# Where the Hub runs. The site checks in every 15 minutes once connected (Site settings → Licence).
# HUB_URL=https://events-hub.crosscode.co.za
# For a local or test Hub only: its public key (the release has the production key built in).
# HUB_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----"
HUB_HEARTBEAT_ENABLED=true
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
# Retention count is set via Admin → Site Settings → Backups (default 14).
BACKUP_ENABLED=true
+14
View File
@@ -44,6 +44,7 @@ Tokens are issued by `POST /api/users/login`.
- [Forms](#forms)
- [Provider Transactions](#provider-transactions)
- [WhatsApp Admin](#whatsapp-admin)
- [Hub and licence](#hub-and-licence)
- [Webhooks](#webhooks)
- [Settings](#settings)
- [Setup](#setup)
@@ -836,6 +837,19 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
---
## Hub and licence
The connection to the Hope Events Hub (`src/hub/`, protocol in `docs/hub-protocol.md`).
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | public | `{ status, version, db, jobs, lastBackupAt }` — 503 when the database is down |
| GET | `/api/hub/status` | admin | `{ state: active\|grace\|suspended\|revoked\|unlicensed, readOnly, daysLeft, endsAt, graceEndsAt, licence, connected, keyConfigured, hubUrl, lastHeartbeatAt, lastError, version, latestVersion, updateAvailable }` |
| POST | `/api/hub/connect` | admin (incl. the `/setup` token) | `{ licenceKey }` — registers with the Hub; stores the install id, encrypted secret and licence token. Hub errors pass through with their `code` (`LICENCE_NOT_FOUND`, `LICENCE_IN_USE`, `LICENCE_REVOKED`, `HUB_UNREACHABLE`, …). Audited as `hub_connected` |
| POST | `/api/hub/heartbeat` | admin | Check in now; returns the status |
**Read-only mode** (licence suspended, revoked, or past its grace period): `403 { code: "LICENCE_READ_ONLY" }` on `POST /api/events`, event options and variants, `POST /api/registrations` (and `/manual`), `PUT /api/registrations/:id/options`, `POST /api/payments`, `/payments/checkout` (`/send`), `/payments/yoco-checkout` (`/send`), `/payments/pay/:id`, `POST /api/waitlist`, `POST /api/coupons`, `POST /api/bundles/event/:id`. Everything else — scanning, exports, refunds, cancellations, privacy requests, settings — keeps working, and payment webhooks for payments already started are still processed.
## Webhooks
| Method | Path | Auth | Description |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.40.0",
"version": "1.41.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.40.0",
"version": "1.41.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.40.0",
"version": "1.41.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,3 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_connected';
+1
View File
@@ -602,6 +602,7 @@ enum AdminAuditAction {
retention_run
breach_recorded
breach_updated
hub_connected
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
+86
View File
@@ -0,0 +1,86 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { getClientIp } = require('../utils/requestUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { licenceStatus } = require('../hub/licence');
const hub = require('../hub/client');
const { APP_VERSION } = require('../hub/report');
function compareVersions(a, b) {
const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0);
const pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0);
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
return 0;
}
/** What the admin pages show: licence state, Hub connection, version and update. */
async function statusPayload({ fresh = false } = {}) {
const s = await licenceStatus({ fresh });
return {
state: s.state,
readOnly: s.readOnly,
daysLeft: s.daysLeft,
endsAt: s.endsAt,
graceEndsAt: s.graceEndsAt,
licence: s.claims ? {
customer: s.claims.customer,
plan: s.claims.plan,
status: s.claims.status,
licenceExpiresAt: s.claims.licenceExpiresAt,
graceDays: s.claims.graceDays,
issuedAt: s.claims.issuedAt,
validUntil: s.claims.validUntil,
limits: s.claims.limits || {},
} : null,
connected: s.connected,
keyConfigured: s.keyConfigured,
hubUrl: hub.hubUrl(),
lastHeartbeatAt: s.lastHeartbeatAt,
lastError: s.lastError,
version: APP_VERSION,
latestVersion: s.latestVersion,
updateAvailable: !!(s.latestVersion && compareVersions(APP_VERSION, s.latestVersion) < 0),
};
}
// @desc Licence and Hub status (for the admin banner and Site settings → Licence)
// @route GET /api/hub/status
// @access Private/Admin
const getStatus = async (req, res) => {
try {
res.json(await statusPayload());
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc Connect this install to the Hub with a licence key (also the /setup licence step)
// @route POST /api/hub/connect body: { licenceKey }
// @access Private/Admin
const connect = async (req, res) => {
try {
const licenceKey = String(req.body?.licenceKey || '').trim();
if (!licenceKey) { res.status(400); throw new Error('Enter your licence key.'); }
const me = await prisma.user.findUnique({ where: { id: req.user.id }, select: { name: true, email: true } });
const claims = await hub.connect(licenceKey, me);
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_connected', targetType: 'hub', metadata: { customer: claims.customer, hubUrl: hub.hubUrl() }, ip: getClientIp(req) });
res.json(await statusPayload({ fresh: true }));
} catch (e) {
const status = e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode);
res.status(status >= 500 && e.code ? 502 : status).json({ message: e.message || safeErrorMessage(e), code: e.code });
}
};
// @desc Send a heartbeat now (checks the connection and picks up licence changes)
// @route POST /api/hub/heartbeat
// @access Private/Admin
const heartbeatNow = async (req, res) => {
try {
await hub.heartbeat();
res.json(await statusPayload({ fresh: true }));
} catch (e) {
res.status(502).json({ message: e.message || safeErrorMessage(e), code: e.code, status: await statusPayload({ fresh: true }).catch(() => null) });
}
};
module.exports = { getStatus, connect, heartbeatNow, statusPayload, compareVersions };
@@ -15,6 +15,11 @@ const PAYMENT_PRIVATE_KEYS = new Set([
'payfast_merchant_id', 'payfast_merchant_key', 'payfast_passphrase', 'payfast_mode',
]);
// The Hub connection (install id and secret, licence token) is only written by src/hub —
// an admin editing it here could borrow another install's licence.
const HUB_PRIVATE_KEYS = new Set(require('../hub/client').HUB_KEYS);
const PRIVATE_KEYS = new Set([...PAYMENT_PRIVATE_KEYS, ...HUB_PRIVATE_KEYS]);
const PUBLIC_KEYS = [
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
// Branding — primary_color/secondary_color/accent_color are the 3-color brand
@@ -53,7 +58,7 @@ const getAllSettings = async (req, res) => {
const rows = await prisma.appSetting.findMany();
const settings = {};
for (const r of rows) {
if (PAYMENT_PRIVATE_KEYS.has(r.key)) continue;
if (PRIVATE_KEYS.has(r.key)) continue;
if (ENCRYPTED_KEYS.has(r.key)) {
// Return a sentinel so the UI knows the value is set, without exposing it.
// smtp_user (email address) we can safely return as-is after decryption so
@@ -89,7 +94,7 @@ const updateSettings = async (req, res) => {
}
const ops = Object.entries(updates)
.filter(([k, v]) => v !== undefined && v !== null && !PAYMENT_PRIVATE_KEYS.has(k))
.filter(([k, v]) => v !== undefined && v !== null && !PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
@@ -128,7 +133,7 @@ const updateSettings = async (req, res) => {
actorRole: req.user.role,
action: 'settings_updated',
targetType: 'AppSetting',
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !PAYMENT_PRIVATE_KEYS.has(k)) },
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !PRIVATE_KEYS.has(k)) },
ip: getClientIp(req),
});
}
@@ -205,7 +210,7 @@ const runSetup = async (req, res) => {
const toSave = { ...settings, setup_complete: 'true' };
const ops = Object.entries(toSave)
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !PAYMENT_PRIVATE_KEYS.has(k))
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue);
+123
View File
@@ -0,0 +1,123 @@
/**
* Talking to the Hope Events Hub (docs/hub-protocol.md): register with a licence key, then a
* signed heartbeat every 15 minutes. Install → Hub only. Stored in AppSetting:
* hub_install_id, hub_install_secret (encrypted), hub_licence (signed token),
* hub_last_heartbeat_at, hub_last_error, hub_latest_version.
* These keys are kept out of the generic settings endpoints (settingsController).
*/
const crypto = require('crypto');
const prisma = require('../config/db');
const { encrypt, decrypt } = require('../utils/encryption');
const { verifyToken, clearLicenceCache } = require('./licence');
const report = require('./report');
const PROTOCOL_VERSION = 1;
const TIMEOUT_MS = 15000;
const HUB_KEYS = ['hub_install_id', 'hub_install_secret', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version'];
function hubUrl() {
return String(process.env.HUB_URL || 'https://events-hub.crosscode.co.za').replace(/\/$/, '');
}
class HubError extends Error {
constructor(message, code, status) {
super(message);
this.code = code;
this.statusCode = status;
}
}
async function save(values) {
for (const [key, value] of Object.entries(values)) {
if (value === null) await prisma.appSetting.deleteMany({ where: { key } });
else await prisma.appSetting.upsert({ where: { key }, update: { value: String(value) }, create: { key, value: String(value) } });
}
clearLicenceCache();
}
async function read(keys) {
const rows = await prisma.appSetting.findMany({ where: { key: { in: keys } } });
return Object.fromEntries(rows.map(r => [r.key, r.value]));
}
async function post(path, body, headers = {}) {
const raw = JSON.stringify(body);
let res;
try {
res = await fetch(`${hubUrl()}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(typeof headers === 'function' ? headers(raw) : headers) },
body: raw,
signal: AbortSignal.timeout(TIMEOUT_MS),
});
} catch (e) {
throw new HubError(`The Hub (${hubUrl()}) could not be reached: ${e?.message || e}`, 'HUB_UNREACHABLE', 502);
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new HubError(data.message || `The Hub answered ${res.status}`, data.code || 'HUB_ERROR', res.status);
return data;
}
/** Register this install with a licence key. Returns the licence claims. */
async function connect(licenceKey, adminContact) {
if (!require('./publicKey').hubPublicKey()) {
throw new HubError('This version of Hope Events has no Hub key built in, so it can\'t check licences yet.', 'NO_PUBLIC_KEY', 500);
}
const install = await report.installInfo();
if (!/^https?:\/\//.test(install.url)) {
throw new HubError('Set the site\'s web address first (FRONTEND_URL on the server, or Site settings → Organisation).', 'NO_SITE_URL', 400);
}
const data = await post('/api/installs/register', {
protocolVersion: PROTOCOL_VERSION,
licenceKey: String(licenceKey || '').trim(),
install: { ...install, adminContact: adminContact ? { name: adminContact.name || null, email: adminContact.email || null } : null },
});
const claims = verifyToken(data.licence);
if (!claims || claims.installId !== data.installId) {
throw new HubError('The Hub answered with a licence this install can\'t verify. Is HUB_URL pointing at the right Hub?', 'BAD_LICENCE', 502);
}
await save({
hub_install_id: data.installId,
hub_install_secret: encrypt(data.installSecret),
hub_licence: data.licence,
hub_last_heartbeat_at: new Date().toISOString(),
hub_last_error: null,
hub_latest_version: data.latestVersion || null,
});
return claims;
}
/** Send a heartbeat (the `hub-heartbeat` job). Skips quietly when not connected. */
async function heartbeat() {
const s = await read(['hub_install_id', 'hub_install_secret']);
if (!s.hub_install_id || !s.hub_install_secret) return { skipped: 'not connected' };
const secret = decrypt(s.hub_install_secret);
const body = { protocolVersion: PROTOCOL_VERSION, version: report.APP_VERSION, health: await report.health(), stats: await report.stats() };
try {
const data = await post('/api/installs/heartbeat', body, raw => {
const timestamp = new Date().toISOString();
return {
'X-Hub-Install': s.hub_install_id,
'X-Hub-Timestamp': timestamp,
'X-Hub-Signature': crypto.createHmac('sha256', secret).update(`${timestamp}.${raw}`).digest('hex'),
};
});
const claims = verifyToken(data.licence);
await save({
// Keep the old token if the new one doesn't check out (never accept an unverified one).
...(claims && claims.installId === s.hub_install_id ? { hub_licence: data.licence } : {}),
hub_last_heartbeat_at: new Date().toISOString(),
hub_last_error: null,
hub_latest_version: data.latestVersion || null,
});
return { ok: true, status: claims?.status || null };
} catch (e) {
await save({ hub_last_error: `${new Date().toISOString()} ${e.code || ''} ${e.message}`.slice(0, 500) });
// The Hub doesn't know this install any more (licence released): show "Reconnect".
if (e.code === 'INSTALL_NOT_FOUND') return { ok: false, reconnect: true };
throw e;
}
}
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat };
+97
View File
@@ -0,0 +1,97 @@
/**
* Licence checks (docs/hub-protocol.md §4). The Hub signs a licence token with its private
* key; this install checks it with the public key and works out its state, offline:
*
* active — everything works
* grace — past the end date for up to graceDays: works, site admins see a banner
* suspended — suspended by the Hub, or grace ran out: read-only (no new events,
* registrations or payments); tickets still scan, data can be exported
* revoked — like suspended, with a permanent banner
* unlicensed — never connected to the Hub: works, admins see "Connect to Hub"
*/
const crypto = require('crypto');
const prisma = require('../config/db');
const { hubPublicKey } = require('./publicKey');
const DAY = 24 * 60 * 60 * 1000;
const READ_ONLY_STATES = new Set(['suspended', 'revoked']);
const CACHE_MS = 30 * 1000;
let publicKeyObject = null;
let publicKeyPem = null;
function publicKey() {
const pem = hubPublicKey();
if (!pem) return null;
if (pem !== publicKeyPem) {
try {
publicKeyObject = crypto.createPublicKey(pem);
} catch {
throw new Error("The Hub public key can't be read — check HUB_PUBLIC_KEY in backend/.env (copy it from the Hub's Settings page).");
}
publicKeyPem = pem;
}
return publicKeyObject;
}
/** The token's claims if the Hub signed it, else null. */
function verifyToken(token, key = publicKey()) {
if (!key) return null;
const parts = String(token || '').split('.');
if (parts.length !== 3 || parts[0] !== 'hel1') return null;
try {
const ok = crypto.verify(null, Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], 'base64url'));
return ok ? JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) : null;
} catch {
return null;
}
}
/**
* The state for verified claims (or null = never connected). Pure, for tests.
* Returns { state, readOnly, endsAt, graceEndsAt, daysLeft }.
*/
function stateFor(claims, installId, now = new Date()) {
if (!claims || (installId && claims.installId !== installId)) {
return { state: 'unlicensed', readOnly: false, endsAt: null, graceEndsAt: null, daysLeft: null };
}
const ends = [claims.licenceExpiresAt, claims.validUntil].filter(Boolean).map(d => new Date(d));
const endsAt = ends.length ? new Date(Math.min(...ends)) : null;
const graceEndsAt = endsAt ? new Date(endsAt.getTime() + (claims.graceDays || 0) * DAY) : null;
let state;
if (claims.status === 'revoked') state = 'revoked';
else if (claims.status === 'suspended') state = 'suspended';
else if (!endsAt || now < endsAt) state = 'active';
else if (now < graceEndsAt) state = 'grace';
else state = 'suspended';
const daysLeft = state === 'grace' ? Math.max(0, Math.ceil((graceEndsAt - now) / DAY)) : null;
return { state, readOnly: READ_ONLY_STATES.has(state), endsAt, graceEndsAt, daysLeft, expired: state === 'suspended' && claims.status === 'active' };
}
let cache = null;
/** This install's licence: claims, state and Hub connection details. Cached for 30 s. */
async function licenceStatus({ fresh = false } = {}) {
if (!fresh && cache && Date.now() - cache.at < CACHE_MS) return cache.value;
const rows = await prisma.appSetting.findMany({ where: { key: { in: ['hub_install_id', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version'] } } });
const s = Object.fromEntries(rows.map(r => [r.key, r.value]));
const claims = verifyToken(s.hub_licence);
const value = {
keyConfigured: !!publicKey(),
connected: !!s.hub_install_id,
installId: s.hub_install_id || null,
claims: claims && claims.installId === s.hub_install_id ? claims : null,
...stateFor(claims, s.hub_install_id || null),
lastHeartbeatAt: s.hub_last_heartbeat_at || null,
lastError: s.hub_last_error || null,
latestVersion: s.hub_latest_version || null,
};
cache = { at: Date.now(), value };
return value;
}
function clearLicenceCache() {
cache = null;
}
module.exports = { verifyToken, stateFor, licenceStatus, clearLicenceCache, READ_ONLY_STATES };
+32
View File
@@ -0,0 +1,32 @@
/**
* The Hope Events Hub's public licence key (Ed25519). Installs check every licence token with
* it, so a licence can't be faked by editing the database. The matching private key exists
* only on the Hub server (events-hub repo, `npm run generate-keys`).
*
* Set PRODUCTION_PUBLIC_KEY to the key the production Hub prints, before the release that ships
* the Hub connection. HUB_PUBLIC_KEY (env) overrides it — for a local Hub while developing.
*/
const PRODUCTION_PUBLIC_KEY = null;
/**
* A public key as proper PEM, however it was pasted: with real line breaks, with "\n", all on
* one line ("-----BEGIN PUBLIC KEY-----MCow…-----END PUBLIC KEY-----"), or just the base64.
*/
function normalisePem(input) {
const text = String(input || '').replace(/\\n/g, '\n').trim();
if (!text) return null;
const body = text
.replace(/-----BEGIN PUBLIC KEY-----/, '')
.replace(/-----END PUBLIC KEY-----/, '')
.replace(/\s+/g, '');
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) return text; // not base64 — let the caller's parse fail clearly
const lines = body.match(/.{1,64}/g).join('\n');
return `-----BEGIN PUBLIC KEY-----\n${lines}\n-----END PUBLIC KEY-----\n`;
}
function hubPublicKey() {
return normalisePem(process.env.HUB_PUBLIC_KEY) || PRODUCTION_PUBLIC_KEY;
}
module.exports = { hubPublicKey, normalisePem, PRODUCTION_PUBLIC_KEY };
+48
View File
@@ -0,0 +1,48 @@
/**
* Read-only mode while the licence is suspended or revoked: no *new* activity — new events,
* registrations, tickets added, payments started or recorded, waitlist places, coupons or bundles.
* Everything else keeps working on purpose: scanning tickets for events people already paid for,
* viewing and exporting data, refunds and cancellations, privacy requests, logging in, and the
* settings needed to renew the licence. Payment webhooks for payments already started also still
* arrive (they're mounted before this middleware).
*/
const { licenceStatus } = require('./licence');
const BLOCKED = [
['POST', /^\/api\/events\/?$/],
['POST', /^\/api\/events\/[^/]+\/options\/?$/],
['POST', /^\/api\/events\/options\/[^/]+\/variants\/?$/],
['POST', /^\/api\/registrations\/?$/],
['POST', /^\/api\/registrations\/manual\/?$/],
['PUT', /^\/api\/registrations\/[^/]+\/options\/?$/],
['POST', /^\/api\/payments\/?$/],
['POST', /^\/api\/payments\/(checkout|yoco-checkout)(\/send)?\/?$/],
['POST', /^\/api\/payments\/pay\/[^/]+\/?$/],
['POST', /^\/api\/waitlist\/?$/],
['POST', /^\/api\/coupons\/?$/],
['POST', /^\/api\/bundles\/event\/[^/]+\/?$/],
];
function isBlocked(method, path) {
return BLOCKED.some(([m, re]) => m === method && re.test(path));
}
async function licenceReadOnly(req, res, next) {
if (!isBlocked(req.method, req.path)) return next();
try {
const status = await licenceStatus();
if (!status.readOnly) return next();
return res.status(403).json({
code: 'LICENCE_READ_ONLY',
message: status.state === 'revoked'
? "This site's licence has been revoked, so nothing new can be added. Tickets still scan and all data can still be viewed and exported."
: "This site's licence is suspended, so nothing new can be added for now. Tickets still scan and all data can still be viewed and exported. Please contact the site's administrator.",
});
} catch {
// Never let a licence check failure take the site down.
return next();
}
}
module.exports = { licenceReadOnly, isBlocked, BLOCKED };
+148
View File
@@ -0,0 +1,148 @@
/**
* What this install tells the Hub (docs/hub-protocol.md §3): health, and aggregate counts —
* no personal information. Income goes as a range, never an amount.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const prisma = require('../config/db');
const errorCounter = require('../utils/errorCounter');
const DAY = 24 * 60 * 60 * 1000;
const APP_VERSION = require('../../package.json').version;
const STARTED_AT = Date.now();
const INCOME_BANDS = [
[1000, 'R0–R1 000'],
[5000, 'R1 001–R5 000'],
[20000, 'R5 001–R20 000'],
[50000, 'R20 001–R50 000'],
[100000, 'R50 001–R100 000'],
];
/** The range an amount falls in: "R5 001–R20 000", or "over R100 000". */
function incomeBand(amount) {
const rand = Math.max(0, Math.round(Number(amount) || 0));
for (const [max, label] of INCOME_BANDS) if (rand <= max) return label;
return 'over R100 000';
}
async function postgresVersion() {
try {
const rows = await prisma.$queryRawUnsafe('SHOW server_version');
return String(rows[0]?.server_version || '').split(' ')[0] || null;
} catch {
return null;
}
}
/** Migrations in the repo that the database hasn't applied. */
async function pendingMigrations() {
try {
const dir = path.join(__dirname, '..', '..', 'prisma', 'migrations');
const folders = fs.readdirSync(dir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name);
const rows = await prisma.$queryRawUnsafe('SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL');
const applied = new Set(rows.map(r => r.migration_name));
return folders.filter(f => !applied.has(f)).length;
} catch {
return null;
}
}
async function disk() {
try {
const s = await fs.promises.statfs(path.join(__dirname, '..', '..'));
return { freeBytes: s.bavail * s.bsize, totalBytes: s.blocks * s.bsize };
} catch {
return null;
}
}
async function jobsHealth() {
try {
const { jobStatus } = require('../jobs');
const status = await jobStatus(prisma);
const since = Date.now() - DAY;
const failed = status.jobs.flatMap(j => j.recent).filter(r => r.state === 'failed' && new Date(r.completedOn || r.startedOn || 0).getTime() > since).length;
return { mode: status.mode, failedLast24h: failed };
} catch {
return null;
}
}
async function lastBackupAt() {
try {
const backups = await require('../utils/backupUtils').listBackups();
return backups[0]?.createdAt ? new Date(backups[0].createdAt).toISOString() : null;
} catch {
return null;
}
}
async function health() {
let db = true;
try { await prisma.$queryRawUnsafe('SELECT 1'); } catch { db = false; }
const [jobs, backupAt, diskInfo, pending] = await Promise.all([jobsHealth(), lastBackupAt(), disk(), pendingMigrations()]);
return {
uptimeSeconds: Math.round((Date.now() - STARTED_AT) / 1000),
db,
jobs,
lastBackupAt: backupAt,
disk: diskInfo,
errorsLast24h: errorCounter.countLast24h(),
pendingMigrations: pending,
};
}
async function stats(now = new Date()) {
const since = new Date(now.getTime() - 30 * DAY);
const realUser = { isActive: true, NOT: [{ email: { endsWith: '@deleted.invalid' } }, { email: { endsWith: '@guest.local' } }] };
const [users, activeEvents, upcomingEvents, totalEvents, regs30, regsTotal, tickets30, ticketsTotal, checkIns30, income30, incomeAll, providers] = await Promise.all([
prisma.user.count({ where: realUser }),
prisma.event.count({ where: { isActive: true, startDate: { lte: now }, endDate: { gte: now } } }),
prisma.event.count({ where: { isActive: true, startDate: { gt: now } } }),
prisma.event.count(),
prisma.registration.count({ where: { createdAt: { gte: since } } }),
prisma.registration.count(),
prisma.ticket.count({ where: { createdAt: { gte: since } } }),
prisma.ticket.count(),
prisma.ticketUsage.count({ where: { scannedAt: { gte: since } } }),
prisma.payment.aggregate({ where: { createdAt: { gte: since } }, _sum: { amount: true } }),
prisma.payment.aggregate({ _sum: { amount: true } }),
require('../payments').availableProviders().then(list => list.map(p => p.id)).catch(() => []),
]);
return {
users,
events: { active: activeEvents, upcoming: upcomingEvents, total: totalEvents },
registrations: { last30d: regs30, total: regsTotal },
tickets: { last30d: tickets30, total: ticketsTotal },
checkIns: { last30d: checkIns30 },
// Messages aren't logged one by one, so there's nothing to count yet.
messages: null,
paymentProviders: providers,
campuses: false,
income: { last30d: incomeBand(income30._sum.amount), allTime: incomeBand(incomeAll._sum.amount) },
};
}
/** What the install says about itself when registering. */
async function installInfo() {
const { getSetting } = require('../utils/settingsCache');
const [name, url, waConfigured, providers] = await Promise.all([
getSetting('org_name', ''),
getSetting('app_base_url', process.env.FRONTEND_URL || ''),
require('../utils/whatsapp').isConfigured().catch(() => false),
require('../payments').availableProviders().then(list => list.map(p => p.id)).catch(() => []),
]);
return {
name: name || 'Hope Events',
url: String(url || process.env.FRONTEND_URL || '').split(',')[0].trim().replace(/\/$/, ''),
version: APP_VERSION,
timezone: process.env.APP_TIMEZONE || 'Africa/Johannesburg',
server: { node: process.versions.node, postgres: await postgresVersion(), os: `${os.platform()} ${os.release()}` },
features: { whatsapp: !!waConfigured, paymentProviders: providers, campuses: false },
};
}
module.exports = { APP_VERSION, incomeBand, health, stats, installInfo };
+37
View File
@@ -156,7 +156,32 @@ app.use('/api/webhooks', webhookRoutes);
// Apply JSON body parser for all non-webhook routes
app.use(express.json());
// Count server errors (any 5xx response) for the Hub heartbeat.
app.use((req, res, next) => {
res.on('finish', () => { if (res.statusCode >= 500) require('./utils/errorCounter').recordError(); });
next();
});
// Licence suspended or revoked: no new events, registrations or payments (src/hub/readOnly.js).
app.use(require('./hub/readOnly').licenceReadOnly);
// Health for monitoring (and the Hub): no secrets, no personal information.
app.get('/health', async (req, res) => {
const report = require('./hub/report');
let db = true;
try { await prisma.$queryRaw`SELECT 1`; } catch { db = false; }
const health = db ? await report.health().catch(() => null) : null;
res.status(db ? 200 : 503).json({
status: db ? 'ok' : 'error',
version: report.APP_VERSION,
db,
jobs: health?.jobs || null,
lastBackupAt: health?.lastBackupAt || null,
});
});
// Use routes
app.use('/api/hub', require('./routes/hubRoutes'));
app.use('/api/users', userRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/registrations', registrationRoutes);
@@ -886,6 +911,18 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Success', body:[{ eventId:'ev-uuid-...', title:'Camp 2025', totalTickets:50, scanned:38, remaining:12 }]}]},
]},
{ title: 'Hub and licence', base: '/api/hub', endpoints: [
{ method:'GET', path:'/health', auth:'public', desc:'Status, version, database, background jobs and last backup (503 if the database is down)',
responses:[{ status:200, desc:'Healthy', body:{ status:'ok', version:'1.41.0', db:true, jobs:{ mode:'queue', failedLast24h:0 }, lastBackupAt:'2026-10-26T00:00:12.000Z' }}]},
{ method:'GET', path:'/api/hub/status', auth:'admin', desc:'Licence state (active, grace, suspended, revoked, unlicensed), Hub connection, version and update',
responses:[{ status:200, desc:'Success', body:{ state:'grace', readOnly:false, daysLeft:9, connected:true, version:'1.41.0', latestVersion:'1.42.0', updateAvailable:true }}]},
{ method:'POST', path:'/api/hub/connect', auth:'admin', desc:'Connect to the Hope Events Hub with a licence key (also the /setup licence step)',
request:{ body:{ licenceKey:'CEH-7K2M-9QXA-P4RT-WD8C' } },
responses:[{ status:200, desc:'Connected (status as above)' }, { status:404, desc:'Unknown key', body:{ code:'LICENCE_NOT_FOUND', message:'That licence key was not found.' }}]},
{ method:'POST', path:'/api/hub/heartbeat', auth:'admin', desc:'Check in with the Hub now (picks up licence changes)',
responses:[{ status:200, desc:'Status' }, { status:502, desc:'Hub unreachable' }]},
]},
{ title: 'Privacy (POPIA)', base: '/api/privacy', endpoints: [
{ method:'GET', path:'/api/privacy/me', auth:'user', desc:'My news opt-in, its history, and my privacy requests',
responses:[{ status:200, desc:'Success', body:{ news:{ email:true, whatsapp:false }, newsAskedAt:'2026-10-23T08:00:00.000Z', canWhatsApp:false, notificationPreference:'email', history:[{ channel:'email', granted:true, source:'signup', createdAt:'2026-10-23T08:00:00.000Z' }], requests:[] }}]},
+4
View File
@@ -56,6 +56,10 @@ const JOBS = [
name: 'privacy', label: 'Privacy requests: remind admins of requests due soon', cron: '0 8 * * *', env: 'PRIVACY_JOBS_ENABLED',
run: () => require('../utils/privacy').remindDueRequests(new Date()),
},
{
name: 'hub-heartbeat', label: 'Hope Events Hub: heartbeat and licence refresh', cron: '*/15 * * * *', env: 'HUB_HEARTBEAT_ENABLED',
run: () => require('../hub/client').heartbeat(),
},
{
name: 'retention', label: 'Retention: delete or anonymise personal information past its retention period', cron: '30 3 * * *', env: 'RETENTION_JOBS_ENABLED',
run: () => require('../utils/retention').runRetention(new Date()),
+12
View File
@@ -0,0 +1,12 @@
const express = require('express');
const router = express.Router();
const { getStatus, connect, heartbeatNow } = require('../controllers/hubController');
const { protect, admin } = require('../middleware/authMiddleware');
// Licence and the Hope Events Hub (docs/hub-protocol.md). Admins only; the setup wizard's
// admin token counts, so the licence step can run during /setup.
router.get('/status', protect, admin, getStatus);
router.post('/connect', protect, admin, connect);
router.post('/heartbeat', protect, admin, heartbeatNow);
module.exports = router;
+17
View File
@@ -0,0 +1,17 @@
// Server errors (5xx responses) in the last 24 hours, for the Hub heartbeat. In memory per
// process: a restart starts again from zero, which is fine for spotting a spike.
const WINDOW_MS = 24 * 60 * 60 * 1000;
const MAX_KEPT = 10000;
let times = [];
function recordError(now = Date.now()) {
times.push(now);
if (times.length > MAX_KEPT) times = times.slice(-MAX_KEPT);
}
function countLast24h(now = Date.now()) {
times = times.filter(t => now - t < WINDOW_MS);
return times.length;
}
module.exports = { recordError, countLast24h };
+129
View File
@@ -0,0 +1,129 @@
jest.mock('../src/config/db', () => ({}));
const crypto = require('crypto');
const { verifyToken, stateFor } = require('../src/hub/licence');
const { incomeBand } = require('../src/hub/report');
const { isBlocked } = require('../src/hub/readOnly');
const { compareVersions } = require('../src/controllers/hubController');
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
function sign(payload, key = privateKey) {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `hel1.${body}.${crypto.sign(null, Buffer.from(`hel1.${body}`), key).toString('base64url')}`;
}
const NOW = new Date('2026-10-26T10:00:00Z');
const base = {
v: 1, installId: 'inst_1', status: 'active', graceDays: 14,
licenceExpiresAt: '2027-10-31T21:59:59.000Z',
issuedAt: '2026-10-26T09:45:00.000Z', validUntil: '2026-11-25T09:45:00.000Z',
};
describe('licence tokens', () => {
test('a token the Hub signed verifies; edited or foreign ones do not', () => {
expect(verifyToken(sign(base), publicKey)).toMatchObject({ installId: 'inst_1', status: 'active' });
const [p, , s] = sign(base).split('.');
const edited = `${p}.${Buffer.from(JSON.stringify({ ...base, status: 'active', licenceExpiresAt: '2099-01-01' })).toString('base64url')}.${s}`;
expect(verifyToken(edited, publicKey)).toBeNull();
const other = crypto.generateKeyPairSync('ed25519');
expect(verifyToken(sign(base, other.privateKey), publicKey)).toBeNull();
expect(verifyToken('', publicKey)).toBeNull();
expect(verifyToken(sign(base), null)).toBeNull();
});
});
describe('licence states', () => {
test('never connected → unlicensed, not read-only', () => {
expect(stateFor(null, null, NOW)).toMatchObject({ state: 'unlicensed', readOnly: false });
});
test('a token for another install counts as none', () => {
expect(stateFor(base, 'inst_other', NOW).state).toBe('unlicensed');
});
test('active until the earlier of the licence end and validUntil', () => {
expect(stateFor(base, 'inst_1', NOW)).toMatchObject({ state: 'active', readOnly: false });
expect(stateFor(base, 'inst_1', NOW).endsAt.toISOString()).toBe('2026-11-25T09:45:00.000Z');
});
test('grace after the end date for graceDays, with days left — then read-only', () => {
const expired = { ...base, licenceExpiresAt: '2026-10-20T00:00:00.000Z' };
expect(stateFor(expired, 'inst_1', NOW)).toMatchObject({ state: 'grace', readOnly: false, daysLeft: 8 });
expect(stateFor(expired, 'inst_1', new Date('2026-11-05T00:00:00Z'))).toMatchObject({ state: 'suspended', readOnly: true, expired: true });
});
test('Hub unreachable: still active for 30 days, then grace, then read-only', () => {
expect(stateFor({ ...base, licenceExpiresAt: null }, 'inst_1', new Date('2026-11-20T00:00:00Z')).state).toBe('active');
expect(stateFor({ ...base, licenceExpiresAt: null }, 'inst_1', new Date('2026-12-01T00:00:00Z')).state).toBe('grace');
expect(stateFor({ ...base, licenceExpiresAt: null }, 'inst_1', new Date('2026-12-15T00:00:00Z')).state).toBe('suspended');
});
test('suspended and revoked are read-only straight away', () => {
expect(stateFor({ ...base, status: 'suspended' }, 'inst_1', NOW)).toMatchObject({ state: 'suspended', readOnly: true });
expect(stateFor({ ...base, status: 'revoked' }, 'inst_1', NOW)).toMatchObject({ state: 'revoked', readOnly: true });
});
});
describe('heartbeat report', () => {
test('income is sent as a band, never an amount', () => {
expect(incomeBand(0)).toBe('R0–R1 000');
expect(incomeBand(1000)).toBe('R0–R1 000');
expect(incomeBand(1000.4)).toBe('R0–R1 000');
expect(incomeBand(1001)).toBe('R1 001–R5 000');
expect(incomeBand(19999)).toBe('R5 001–R20 000');
expect(incomeBand(100000)).toBe('R50 001–R100 000');
expect(incomeBand(250000)).toBe('over R100 000');
expect(incomeBand(null)).toBe('R0–R1 000');
});
});
describe('read-only mode', () => {
test.each([
['POST', '/api/events'],
['POST', '/api/registrations'],
['POST', '/api/registrations/manual'],
['PUT', '/api/registrations/abc/options'],
['POST', '/api/payments'],
['POST', '/api/payments/checkout'],
['POST', '/api/payments/pay/chk_1'],
['POST', '/api/waitlist'],
])('%s %s is blocked', (m, p) => expect(isBlocked(m, p)).toBe(true));
test.each([
['POST', '/api/tickets/scan/QR123'],
['GET', '/api/events'],
['PUT', '/api/registrations/abc'],
['POST', '/api/payments/refund'],
['POST', '/api/privacy/me/requests'],
['POST', '/api/hub/connect'],
['POST', '/api/users/login'],
['POST', '/api/reports/pdf'],
])('%s %s keeps working', (m, p) => expect(isBlocked(m, p)).toBe(false));
});
describe('pasting the Hub public key', () => {
const { normalisePem } = require('../src/hub/publicKey');
const pem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
const base64 = pem.replace(/-----(BEGIN|END) PUBLIC KEY-----|\s/g, '');
test.each([
['proper PEM', pem],
['with \\n', pem.replace(/\n/g, '\\n')],
['all on one line', `-----BEGIN PUBLIC KEY-----${base64}-----END PUBLIC KEY-----`],
['just the base64', base64],
])('%s works', (label, input) => {
expect(() => crypto.createPublicKey(normalisePem(input))).not.toThrow();
expect(verifyToken(sign(base), crypto.createPublicKey(normalisePem(input)))).not.toBeNull();
});
test('empty means none', () => {
expect(normalisePem('')).toBeNull();
expect(normalisePem(undefined)).toBeNull();
});
});
test('version comparison for "update available"', () => {
expect(compareVersions('1.41.0', '1.41.1')).toBeLessThan(0);
expect(compareVersions('1.41.0', '1.41.0')).toBe(0);
expect(compareVersions('2.0.0', '1.50.3')).toBeGreaterThan(0);
});
+27 -3
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-23, after **1.40.0**. The plan itself is in
Last updated: 2026-10-26, after **1.41.0** (and Hub 1.0.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -8,6 +8,7 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
| Version | What |
|---------|------|
| 1.41.0 | Hope Events Hub connection — licence step in `/setup` and Site settings → Licence, 15-minute heartbeat (health, totals, income bands), licence states checked offline (grace banner for site admins only, read-only when suspended/revoked), `/health`, "update available", `scripts/install.sh`. The Hub itself is `joshua/events-hub` 1.0.0 |
| 1.40.0 | POPIA toolkit part 3 — readiness checklist with score, retention policies + nightly `retention` job (inactive accounts anonymised after 5 years with a 30-day warning, on by default), breach register with notification drafts, children's/medical flag in the access log, `{retention}` in the notice |
| 1.39.0 | POPIA toolkit part 2 — Admin → Privacy: privacy notice editor (built-in text as the start, placeholders, drafts, numbered versions, re-acceptance after login), operator register, consent log, access log; Information Officer on the contact page |
| 1.38.0 | POPIA toolkit part 1 — Privacy page (news opt-in + history, download my data, correction and deletion requests), Event update vs News & announcements on every bulk send, unsubscribe links + one-click headers, WhatsApp STOP, one-time news question, Admin → Data requests, thorough anonymising |
@@ -25,8 +26,13 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
## Next up
1. **1.41.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
`joshua/hope-events-hub`).
1. **Go live with the Hub:** generate the production signing key on the Hub server
(`npm run generate-keys` in `events-hub/backend`), put the **public** key in
`backend/src/hub/publicKey.js` and release it as **1.41.1** — until then installs can't
connect (they work as "not connected"). Deploy the Hub to `events-hub.crosscode.co.za`, create
the admin (`npm run create-admin`), generate Hope Family Church's licence and connect it (install #1).
Try `scripts/install.sh` on a test server.
2. **1.42.0 — Multi-campus** (§12).
## Working on this repo
@@ -150,6 +156,24 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
(`prepare-db.js` → `retention`); `privacy-notice.spec.ts` publishes a version that needs accepting, then accepts it for all
seeded users in `afterAll` so later spec files don't meet the prompt.
## Where the Hub client lives (1.41.0)
- Protocol: `docs/hub-protocol.md` — the same file is in the Hub repo (`joshua/events-hub`,
developed at `../events-hub`); change both together.
- `backend/src/hub/`: `publicKey.js` (the production Hub key — **must be set before releasing**;
`HUB_PUBLIC_KEY` env overrides it for a local Hub), `licence.js` (verify tokens, work out
active / grace / suspended / revoked / unlicensed, cached 30 s), `client.js` (connect, heartbeat;
AppSetting `hub_*` keys, which the generic settings endpoints never touch), `report.js` (health,
totals, income bands), `readOnly.js` (the routes blocked in read-only mode — add any new
"create" route there).
- Job `hub-heartbeat` (every 15 min), routes `/api/hub/*`, `/health`.
- Frontend: `components/admin/LicenceTab.tsx` (Site settings → Licence; its `ConnectForm` is also on
the last `/setup` screen), `components/shared/LicenceBanner.tsx` (site admins only).
- Local testing: run the Hub (`../events-hub`, ports 5200/3200), create a licence there, and start
Hope Events with `HUB_URL=http://localhost:5200` and `HUB_PUBLIC_KEY` = the key on Hub → Settings.
- `scripts/install.sh` — fresh Ubuntu server to a running site (written and syntax-checked; not yet
run on a real server).
## Known gaps / follow-ups
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
+8 -6
View File
@@ -22,14 +22,15 @@ picker at the door and session choice on "All sessions" tickets; 1.36.0 payment
1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23);
1.38.0 POPIA toolkit part 1 (privacy page, unsubscribe, WhatsApp STOP, data requests); 1.39.0 part 2 (privacy notice editor and versions, operator register, consent and access logs); 1.40.0 part 3 (retention policies and job, breach register, children’s-data flags, readiness page).
**Next: 1.41.0 install/update tooling + the Hope Events Hub (§13).**
1.41.0 the Hub connection (licence, heartbeat, read-only mode, `/health`, install script) with Hub 1.0.0 (`joshua/events-hub`).
**Next: 1.42.0 Multi-campus.**
**Repositories:**
| Repo | What |
|------|------|
| `joshua/hope-events` (this repo) | The church app. Everything in this document except the Hub app itself — including the Hub *client* (licence step, "Connect to Hub", heartbeat, licence checks) |
| `joshua/hope-events-hub` (new) | The Hub app (§13): its own versions, CHANGELOG, releases and server |
| `joshua/events-hub` (new) | The Hub app (§13): its own versions, CHANGELOG, releases and server |
---
@@ -593,7 +594,7 @@ whether they're healthy, basic usage stats, and their **licence** status.
### Separate app, separate repo, separate server
- **Its own git repository:** `joshua/hope-events-hub` on the same Gitea
- **Its own git repository:** `joshua/events-hub` (https://c-git.crosscode.co.za/joshua/events-hub.git) on the same Gitea
server (c-git.crosscode.co.za), with its own semver tags, CHANGELOG and
deploys. It is never shipped to churches, so none of its code (customers,
licence signing key, other churches' data) ends up on a customer's server.
@@ -668,7 +669,7 @@ All later calls are signed with the install secret (HMAC) and go over HTTPS.
| State | What the install does |
|-------|----------------------|
| **Active** | Everything works |
| **Grace** (expired or Hub unreachable) | Everything works; admins see a banner "Licence needs renewing — X days left". Default 14 days |
| **Grace** (expired or Hub unreachable) | Everything works; **only site admins** see a banner "Licence needs renewing — X days left" (on their dashboard pages — never supervisors, staff, users or the public site). Default 14 days |
| **Suspended** | **Read-only:** no new events, no new registrations or payments. Existing tickets **still scan**, and admins can still view and **export all data** |
| **Revoked** | Same as suspended, with a permanent banner |
@@ -868,7 +869,7 @@ income justify the hardware:
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
| 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
| 6 | 1.41.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
| 6 | 1.41.0 ✅ + Hub v1 ✅ | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
| 7 | 1.42.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
| 8 | 1.43.0 – 1.44.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.45.0 – 1.47.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
@@ -895,7 +896,7 @@ feature.
| 6 | Selling model | **One install per church** — data separation, any server. No multi-tenant. **Multi-campus** inside an install is in scope |
| — | Pro-rata rounding | Nearest R10 |
| — | Multi-campus | In scope (§12), inside one install |
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/hope-events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
| 7 | Payment provider order | Yoco · PayFast. Joshua can supply documentation links |
| — | SnapScan, Zapper | Dropped (2026-10-23): Yoco and PayFast are enough. Later versions moved up by two |
| — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" |
@@ -905,6 +906,7 @@ feature.
| — | Hub domain | `events-hub.crosscode.co.za` (2026-10-23) |
| — | Licence keys | Generated by the Hub when Joshua clicks "Generate" on a customer (2026-10-23) |
| — | Grace period / suspension | 14-day grace; suspended = read-only (tickets still scan, data export still works) (2026-10-23) |
| — | Grace banner, Hub repo, Hub development | The grace-period banner shows **only to site admins**. The Hub repo is `joshua/events-hub` (https://c-git.crosscode.co.za/joshua/events-hub.git); it is developed locally first, then deployed by Joshua (2026-10-24) |
| — | Income in the heartbeat | Included, as **ranges** only (R0–R1 000, R1 001–R5 000, …), never exact amounts (2026-10-23) |
| — | Licence plans | One plan for now (2026-10-23) |
| — | AI in 2.0 | The **headline feature of 2.0**: one-button helpers only (draft an event, summarise reports and feedback, draft messages, improve text) — no chat yet (2026-10-24) |
+160
View File
@@ -0,0 +1,160 @@
# 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>`.
```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: no new events, 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 | Works; 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`.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.40.0",
"version": "1.41.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.40.0",
"version": "1.41.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.40.0",
"version": "1.41.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -51,6 +51,7 @@ const ACTIONS = [
"retention_run",
"breach_recorded",
"breach_updated",
"hub_connected",
] as const;
const ACTION_LABELS: Record<string, string> = {
@@ -94,6 +95,7 @@ const ACTION_LABELS: Record<string, string> = {
retention_run: "Retention job deleted/anonymised data",
breach_recorded: "Security compromise recorded",
breach_updated: "Security compromise updated",
hub_connected: "Connected to the Hope Events Hub",
};
interface AuditLogEntry {
@@ -7,18 +7,19 @@ import Link from "next/link";
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, type LucideIcon } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, type LucideIcon } from "lucide-react";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { BackupsTab } from "@/components/admin/BackupsTab";
import { JobsTab } from "@/components/admin/JobsTab";
import { PaymentsTab } from "@/components/admin/PaymentsTab";
import { LicenceTab } from "@/components/admin/LicenceTab";
import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "backups" | "jobs";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "backups" | "jobs" | "licence";
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "organisation", label: "Organisation", icon: Building2 },
@@ -30,6 +31,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "payments", label: "Payments", icon: CreditCard },
{ id: "backups", label: "Backups", icon: DatabaseBackup },
{ id: "jobs", label: "Background jobs", icon: Timer },
{ id: "licence", label: "Licence", icon: KeyRound },
];
const inputCls =
@@ -713,6 +715,7 @@ function SiteSettingsPageInner() {
{/* ── Backups ───────────────────────────────────────────────────────── */}
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
{activeTab === "jobs" && <JobsTab active={activeTab === "jobs"} />}
{activeTab === "licence" && <LicenceTab active={activeTab === "licence"} />}
</div>
{activeTab === "branding" && (
+3
View File
@@ -7,6 +7,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar";
import { PrivacyPrompts } from "@/components/shared/PrivacyPrompts";
import { LicenceBanner } from "@/components/shared/LicenceBanner";
// The sidebar shows only on these exact routes — each role's dashboard root,
// plus Profile & Security and Site Settings (matching the redesign
@@ -87,6 +88,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return (
<div className="h-screen flex flex-col overflow-hidden">
<Navbar />
<LicenceBanner />
<PrivacyPrompts />
<MobileSidebar />
<div className="flex-1 flex min-h-0">
@@ -103,6 +105,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<LicenceBanner />
<PrivacyPrompts />
<main className="flex-1 p-6 bg-gray-50">{children}</main>
<Footer />
+16
View File
@@ -7,6 +7,7 @@ import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { extractDominantColors } from "@/lib/extractColors";
import { errorMessage } from "@/lib/utils";
import { ConnectForm } from "@/components/admin/LicenceTab";
type Step = 1 | 2 | 3 | 4 | 5 | 6;
@@ -96,6 +97,9 @@ export default function SetupPage() {
const [smtpUser, setSmtpUser] = useState("");
const [smtpPass, setSmtpPass] = useState("");
// Done screen — licence (optional)
const [licenceConnected, setLicenceConnected] = useState<string | null>(null);
// Check setup isn't already done
useEffect(() => {
apiFetch<{ needsSetup: boolean }>("/api/settings/needs-setup")
@@ -590,6 +594,18 @@ export default function SetupPage() {
Email delivery is not configured yet. Set it up under Admin → Site Settings after logging in.
</p>
)}
{/* Licence: connect to the Hope Events Hub now, or later in Site settings → Licence. */}
<div className="text-left border rounded-xl p-4 space-y-2">
<h3 className="text-sm font-semibold text-gray-900">Licence</h3>
{licenceConnected ? (
<p className="text-sm text-green-700">✓ Connected — licensed to {licenceConnected}.</p>
) : (
<>
<p className="text-xs text-gray-500">Enter the licence key you received to connect this site to the Hope Events Hub. You can also do this later under Admin → Site Settings → Licence.</p>
<ConnectForm token={setupToken} compact onConnected={s => setLicenceConnected(s.licence?.customer || "your organisation")} />
</>
)}
</div>
<button
onClick={() => router.push("/login")}
className="mt-4 px-6 py-2.5 bg-brand-600 hover:bg-brand-700 text-white rounded-lg text-sm font-medium"
@@ -0,0 +1,161 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { KeyRound, RefreshCw, ShieldCheck, ShieldAlert, ArrowUpCircle } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { errorMessage } from "@/lib/utils";
export type HubStatus = {
state: "active" | "grace" | "suspended" | "revoked" | "unlicensed";
readOnly: boolean;
daysLeft: number | null;
endsAt: string | null;
graceEndsAt: string | null;
licence: { customer: string; plan: string; status: string; licenceExpiresAt: string | null; graceDays: number; issuedAt: string; validUntil: string } | null;
connected: boolean;
keyConfigured: boolean;
hubUrl: string;
lastHeartbeatAt: string | null;
lastError: string | null;
version: string;
latestVersion: string | null;
updateAvailable: boolean;
};
const STATE: Record<HubStatus["state"], { label: string; tone: string; text: string }> = {
active: { label: "Active", tone: "bg-green-50 text-green-800 border-green-200", text: "Everything works." },
grace: { label: "Needs renewing", tone: "bg-amber-50 text-amber-900 border-amber-200", text: "Everything still works for now — renew before the grace period ends." },
suspended: { label: "Suspended", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only: no new events, registrations or payments. Tickets still scan and all data can be exported." },
revoked: { label: "Revoked", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only: no new events, registrations or payments. Tickets still scan and all data can be exported." },
unlicensed: { label: "Not connected", tone: "bg-gray-50 text-gray-700 border-gray-200", text: "Connect this site to the Hope Events Hub with your licence key." },
};
const fmt = (d: string | null) => (d ? new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—");
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
/** Enter a licence key and connect. Used in Site settings → Licence and the /setup wizard. */
export function ConnectForm({ token, onConnected, compact = false }: { token: string | null; onConnected: (s: HubStatus) => void; compact?: boolean }) {
const [key, setKey] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
onConnected(await apiFetch<HubStatus>("/api/hub/connect", { method: "POST", authToken: token, body: { licenceKey: key } }));
setKey("");
} catch (err) {
setError(errorMessage(err) || "Could not connect.");
} finally {
setBusy(false);
}
};
return (
<form onSubmit={submit} className="space-y-2">
<label className="block text-sm font-medium text-gray-700" htmlFor="licenceKey">Licence key</label>
<div className={`flex gap-2 ${compact ? "flex-col sm:flex-row" : ""}`}>
<input id="licenceKey" className={`${inputCls} font-mono uppercase`} placeholder="CEH-XXXX-XXXX-XXXX-XXXX" value={key} onChange={e => setKey(e.target.value)} required autoComplete="off" />
<button disabled={busy || !key.trim()} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50 whitespace-nowrap">
{busy ? "Connecting…" : "Connect"}
</button>
</div>
{error && <p className="text-sm text-red-700">{error}</p>}
</form>
);
}
/** Site settings → Licence: licence state, the Hub connection, and this site's version. */
export function LicenceTab({ active }: { active: boolean }) {
const { token } = useAuth();
const [status, setStatus] = useState<HubStatus | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const load = useCallback(async () => {
if (!token) return;
try { setStatus(await apiFetch<HubStatus>("/api/hub/status", { authToken: token })); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Could not load the licence." }); }
}, [token]);
useEffect(() => { if (active) load(); }, [active, load]);
const checkNow = async () => {
setBusy(true);
setMsg(null);
try {
setStatus(await apiFetch<HubStatus>("/api/hub/heartbeat", { method: "POST", authToken: token }));
setMsg({ type: "ok", text: "Checked with the Hub." });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "The Hub could not be reached." });
load();
} finally {
setBusy(false);
}
};
if (!status) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
const st = STATE[status.state];
return (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-gray-900">Licence</h2>
<p className="text-sm text-gray-500">This site checks in with the Hope Events Hub every 15 minutes: version, health and totals only — never anyone&apos;s personal information.</p>
</div>
<div className={`border rounded-xl p-4 ${st.tone}`}>
<div className="flex items-center gap-2 font-semibold">
{status.state === "active" ? <ShieldCheck className="w-5 h-5" /> : <ShieldAlert className="w-5 h-5" />}
{st.label}
{status.state === "grace" && status.daysLeft != null && <span className="font-normal">— {status.daysLeft} day{status.daysLeft === 1 ? "" : "s"} left</span>}
</div>
<p className="text-sm mt-1">{st.text}</p>
</div>
{!status.keyConfigured && (
<p className="text-sm text-amber-800 bg-amber-50 border border-amber-200 rounded-lg p-3">
This version has no Hub key built in yet, so it can&apos;t check licences. Update Hope Events, or set HUB_PUBLIC_KEY on the server for testing.
</p>
)}
{status.licence && (
<dl className="grid sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div><dt className="text-gray-500">Licensed to</dt><dd className="font-medium">{status.licence.customer}</dd></div>
<div><dt className="text-gray-500">Plan</dt><dd className="capitalize">{status.licence.plan}</dd></div>
<div><dt className="text-gray-500">Licence ends</dt><dd>{status.licence.licenceExpiresAt ? fmt(status.licence.licenceExpiresAt) : "No end date"}</dd></div>
<div><dt className="text-gray-500">Grace period</dt><dd>{status.licence.graceDays} days</dd></div>
</dl>
)}
<div className="border rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2 font-medium text-gray-900"><KeyRound className="w-4 h-4" /> Hub connection</div>
<dl className="grid sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div><dt className="text-gray-500">Hub</dt><dd className="break-all">{status.hubUrl}</dd></div>
<div><dt className="text-gray-500">Last check-in</dt><dd>{fmt(status.lastHeartbeatAt)}</dd></div>
</dl>
{status.lastError && <p className="text-xs text-red-700 break-words">Last problem: {status.lastError}</p>}
{status.connected && (
<button onClick={checkNow} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
<RefreshCw className={`w-4 h-4 ${busy ? "animate-spin" : ""}`} /> Check now
</button>
)}
<div className="pt-2 border-t">
<p className="text-sm text-gray-600 mb-2">{status.connected ? "New licence key, or moved to a new address? Connect again:" : "Enter the licence key you received:"}</p>
<ConnectForm token={token} onConnected={s => { setStatus(s); setMsg({ type: "ok", text: "Connected to the Hub." }); }} />
</div>
</div>
<div className="border rounded-xl p-4 text-sm">
<div className="flex items-center gap-2 font-medium text-gray-900 mb-1"><ArrowUpCircle className="w-4 h-4" /> Version</div>
<p>This site runs Hope Events <strong>{status.version}</strong>.{status.latestVersion && !status.updateAvailable && " It's up to date."}</p>
{status.updateAvailable && (
<p className="mt-1 text-brand-700">
Version {status.latestVersion} is available. On the server: <code className="bg-gray-100 px-1 rounded">bash scripts/deploy.sh v{status.latestVersion}</code>
</p>
)}
</div>
{msg && <div className={`p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
</div>
);
}
@@ -0,0 +1,62 @@
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { ShieldAlert, KeyRound, ArrowUpCircle } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import type { HubStatus } from "@/components/admin/LicenceTab";
/**
* Licence and update notices for **site admins only** (never supervisors, staff, users or the
* public site): grace period, suspended/revoked, not connected, update available.
*/
export function LicenceBanner() {
const { user, token } = useAuth();
const [status, setStatus] = useState<HubStatus | null>(null);
const isAdmin = user?.role === "admin";
useEffect(() => {
if (!isAdmin || !token) return;
apiFetch<HubStatus>("/api/hub/status", { authToken: token }).then(setStatus).catch(() => {});
}, [isAdmin, token]);
if (!isAdmin || !status) return null;
const link = <Link href="/dashboard/admin/settings?tab=licence" className="font-medium underline">Licence settings</Link>;
if (status.state === "grace") {
return (
<div role="status" className="bg-amber-50 border-b border-amber-200 text-amber-900 text-sm px-4 py-2 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 shrink-0" />
<span>Licence needs renewing — {status.daysLeft} day{status.daysLeft === 1 ? "" : "s"} left. {link}</span>
</div>
);
}
if (status.state === "suspended" || status.state === "revoked") {
return (
<div role="alert" className="bg-red-50 border-b border-red-200 text-red-800 text-sm px-4 py-2 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 shrink-0" />
<span>
The licence is {status.state === "revoked" ? "revoked" : "suspended"}: the site is read-only — no new events, registrations or payments. Tickets still scan and all data can be exported. {link}
</span>
</div>
);
}
if (status.state === "unlicensed" && status.keyConfigured) {
return (
<div role="status" className="bg-gray-50 border-b text-gray-700 text-sm px-4 py-2 flex items-center gap-2">
<KeyRound className="w-4 h-4 shrink-0" />
<span>This site isn&apos;t connected to the Hope Events Hub yet. {link}</span>
</div>
);
}
if (status.updateAvailable) {
return (
<div role="status" className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm px-4 py-2 flex items-center gap-2">
<ArrowUpCircle className="w-4 h-4 shrink-0" />
<span>Hope Events {status.latestVersion} is available (this site runs {status.version}). {link}</span>
</div>
);
}
return null;
}
@@ -139,5 +139,26 @@ export const siteSettingsHelpContent: HelpContent = {
</div>
),
},
{
key: "licence",
label: "Licence",
icon: KeyRound,
content: (
<div className="space-y-4">
<p>The site&apos;s licence and its connection to the Hope Events Hub. The site checks in every 15 minutes with its version, health and totals — never anyone&apos;s personal information.</p>
<div className="space-y-4">
<GuideItem icon={KeyRound} title="Connecting" tone="brand">
Enter the licence key you received (CEH-XXXX-XXXX-XXXX-XXXX). If the site moves to a new address, ask your provider to release the licence, then connect again.
</GuideItem>
<GuideItem icon={Timer} title="Grace period" tone="amber">
After the licence&apos;s end date everything keeps working for the grace period (usually 14 days); only site admins see a reminder.
</GuideItem>
<GuideItem icon={Scale} title="Read-only" tone="gray">
When a licence is suspended, revoked or past its grace period, nothing new can be added — no events, registrations or payments. Tickets for events people already paid for still scan, and all data can still be viewed and exported.
</GuideItem>
</div>
</div>
),
},
],
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.40.0",
"version": "1.41.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.40.0",
"version": "1.41.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.40.0",
"version": "1.41.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env bash
# Installs Hope Events on a fresh Ubuntu server (22.04 or 24.04): Node 20, PostgreSQL, PM2,
# nginx and an HTTPS certificate, then the app itself. Finish in the browser at /setup.
#
# Usage (as root, or with sudo):
# sudo bash install.sh --domain events.yourchurch.org --email you@yourchurch.org
#
# Options:
# --domain the site's address (required); DNS must already point at this server
# --email for the HTTPS certificate (Let's Encrypt) notices (required unless --no-ssl)
# --repo git URL (default: https://c-git.crosscode.co.za/joshua/hope-events.git)
# --version tag to install, e.g. v1.41.0 (default: the latest main)
# --dir where to install (default: /opt/hope-events)
# --user Linux user that owns and runs the app (default: hopeevents, created if needed)
# --name PM2 / nginx name, unique per install on a server (default: hope-events)
# --web-port frontend port (default: 3000) --api-port backend port (default: 5000)
# --no-ssl skip the certificate (e.g. behind another proxy that does HTTPS)
#
# Re-running is safe: existing database credentials, .env files and PM2 config are kept.
# Updates after that: `bash scripts/deploy.sh vX.Y.Z` from the install folder.
set -Eeuo pipefail
DOMAIN=""; EMAIL=""; REPO="https://c-git.crosscode.co.za/joshua/hope-events.git"; VERSION="main"
DIR="/opt/hope-events"; APP_USER="hopeevents"; NAME="hope-events"; WEB_PORT=3000; API_PORT=5000; SSL=1
while [ $# -gt 0 ]; do
case "$1" in
--domain) DOMAIN="$2"; shift 2 ;;
--email) EMAIL="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--version) VERSION="$2"; shift 2 ;;
--dir) DIR="$2"; shift 2 ;;
--user) APP_USER="$2"; shift 2 ;;
--name) NAME="$2"; shift 2 ;;
--web-port) WEB_PORT="$2"; shift 2 ;;
--api-port) API_PORT="$2"; shift 2 ;;
--no-ssl) SSL=0; shift ;;
-h|--help) sed -n '2,22p' "$0"; exit 0 ;;
*) echo "Unknown option: $1 (see --help)"; exit 1 ;;
esac
done
fail() { echo "ERROR: $*" >&2; exit 1; }
step() { echo; echo "==> $*"; }
[ "$(id -u)" = 0 ] || fail "Run as root (sudo bash install.sh …)."
[ -n "$DOMAIN" ] || fail "--domain is required."
[ "$SSL" = 0 ] || [ -n "$EMAIL" ] || fail "--email is required for the HTTPS certificate (or use --no-ssl)."
[[ "$NAME" =~ ^[a-z0-9-]+$ ]] || fail "--name may only use a-z, 0-9 and -."
DB_NAME="$(echo "$NAME" | tr '-' '_')"
SCHEME="https"; [ "$SSL" = 1 ] || SCHEME="http"
SITE_URL="$SCHEME://$DOMAIN"
as_app() { sudo -u "$APP_USER" -H bash -lc "$*"; }
random() { openssl rand -base64 48 | tr -d '/+=\n' | head -c "${1:-48}"; }
step "System packages"
export DEBIAN_FRONTEND=noninteractive
apt-get update -q
apt-get install -y -q curl git nginx postgresql postgresql-client openssl build-essential ca-certificates gnupg
[ "$SSL" = 0 ] || apt-get install -y -q certbot python3-certbot-nginx
step "Node.js 20"
if ! command -v node >/dev/null || [ "$(node -p 'process.versions.node.split(".")[0]')" -lt 20 ]; then
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y -q nodejs
fi
node -v
command -v pm2 >/dev/null || npm install -g pm2
step "App user ($APP_USER)"
id "$APP_USER" >/dev/null 2>&1 || useradd --create-home --shell /bin/bash "$APP_USER"
step "Database ($DB_NAME)"
CRED_FILE="/root/.${NAME}-db"
if [ -f "$CRED_FILE" ]; then
DB_PASS="$(cat "$CRED_FILE")"
else
DB_PASS="$(random 32)"
echo "$DB_PASS" > "$CRED_FILE"; chmod 600 "$CRED_FILE"
fi
sudo -u postgres psql -v ON_ERROR_STOP=1 -q <<SQL
DO \$\$ BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '$DB_NAME') THEN
CREATE ROLE "$DB_NAME" LOGIN PASSWORD '$DB_PASS';
ELSE
ALTER ROLE "$DB_NAME" WITH LOGIN PASSWORD '$DB_PASS';
END IF;
END \$\$;
SQL
sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname = '$DB_NAME'" | grep -q 1 \
|| sudo -u postgres createdb -O "$DB_NAME" "$DB_NAME"
DATABASE_URL="postgresql://$DB_NAME:$DB_PASS@localhost:5432/$DB_NAME"
step "Code ($VERSION → $DIR)"
if [ ! -d "$DIR/.git" ]; then
mkdir -p "$DIR"; chown "$APP_USER:$APP_USER" "$DIR"
as_app "git clone --quiet '$REPO' '$DIR'"
fi
as_app "cd '$DIR' && git fetch --tags --quiet origin && if [ '$VERSION' = main ]; then git checkout -q --detach origin/main; else git checkout -q '$VERSION'; fi"
step "Configuration"
if [ ! -f "$DIR/backend/.env" ]; then
cat > "$DIR/backend/.env" <<ENV
NODE_ENV=production
PORT=$API_PORT
DATABASE_URL=$DATABASE_URL
JWT_SECRET=$(random 64)
FRONTEND_URL=$SITE_URL
APP_BASE_URL=$SITE_URL
BACKEND_URL=$SITE_URL
TRUST_PROXY=1
APP_TIMEZONE=Africa/Johannesburg
ENV
echo " wrote backend/.env (keep JWT_SECRET: it also encrypts stored secrets)"
else
echo " backend/.env exists — kept"
fi
if [ ! -f "$DIR/frontend/.env.production" ]; then
echo "NEXT_PUBLIC_API_URL=$SITE_URL" > "$DIR/frontend/.env.production"
fi
if [ ! -f "$DIR/ecosystem.config.js" ]; then
sed -e "s/'hope-events-backend'/'$NAME-backend'/" -e "s/'hope-events-frontend'/'$NAME-frontend'/" -e "s/-p 3000/-p $WEB_PORT/" \
"$DIR/ecosystem.config.example.js" > "$DIR/ecosystem.config.js"
fi
chown "$APP_USER:$APP_USER" "$DIR/backend/.env" "$DIR/frontend/.env.production" "$DIR/ecosystem.config.js"
chmod 600 "$DIR/backend/.env"
step "Install, migrate, build"
as_app "cd '$DIR/backend' && npm ci --no-audit --no-fund && npx prisma migrate deploy && npx prisma generate"
as_app "cd '$DIR/frontend' && npm ci --no-audit --no-fund && npm run build"
step "Start with PM2"
as_app "cd '$DIR' && pm2 startOrReload ecosystem.config.js && pm2 save"
env PATH="$PATH:/usr/bin" pm2 startup systemd -u "$APP_USER" --hp "$(eval echo "~$APP_USER")" >/dev/null
step "nginx ($DOMAIN)"
cat > "/etc/nginx/sites-available/$NAME" <<NGINX
server {
listen 80;
server_name $DOMAIN;
client_max_body_size 25m;
# The API, uploaded files, health and API docs go to the backend; everything else to the site.
location ~ ^/(api|uploads|health|docs)(/|\$) {
proxy_pass http://127.0.0.1:$API_PORT;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
location / {
proxy_pass http://127.0.0.1:$WEB_PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
NGINX
ln -sf "/etc/nginx/sites-available/$NAME" "/etc/nginx/sites-enabled/$NAME"
nginx -t && systemctl reload nginx
if [ "$SSL" = 1 ]; then
step "HTTPS certificate"
certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos -m "$EMAIL" --redirect
fi
step "Checking"
for i in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:$API_PORT/health" >/dev/null 2>&1; then break; fi
sleep 2
done
curl -fsS "http://127.0.0.1:$API_PORT/health" && echo || echo " The backend isn't answering yet — check: sudo -u $APP_USER pm2 logs"
echo
echo "Done. Open $SITE_URL/setup to create the admin account, then enter the licence key."
echo "Database password: $CRED_FILE. Updates: cd $DIR && sudo -u $APP_USER bash scripts/deploy.sh vX.Y.Z"