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
joshuaandClaude Opus 5.5 2d4bc5c38d Roadmap: AI helpers as the 2.0 headline, and later AI stages
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
- §15 AI helpers (2.0): one-button helpers (draft an event, summarise
  reports and feedback, draft messages, improve text) on Claude Haiku 5.5,
  called through the Hub, which holds the key, meters usage in rand and
  enforces a monthly cap per licence; admin usage view; POPIA notes
- §16 AI later stages (unversioned): Ask AI, help/navigation assistant,
  actions with confirmation, more helpers, long-term self-hosted AI
- Hub: AI cap as the first per-licence limit, AiUsage model; decisions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-24 14:36:25 +02:00
37 changed files with 1630 additions and 29 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
+151 -11
View File
@@ -8,7 +8,8 @@ previous round (1.12.0 – 1.23.0). Where to pick up: [HANDOVER.md](./HANDOVER.m
**Theme for 2.0:** turn a feature-rich church app into a product that can be
sold — trustworthy (POPIA), flexible (multi-day events, several payment
providers), and maintainable (tests, CI, typed backend, versioned API).
providers), maintainable (tests, CI, typed backend, versioned API), and with
**AI helpers** as the headline feature (§15).
**Status (2026-10-23):** all features in this document are approved. Shipped
so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events
@@ -21,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 |
---
@@ -48,8 +50,10 @@ notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
13. [Central management platform (Hope Events Hub)](#13-central-management-platform-hope-events-hub)
14. [Foundations (platform work)](#14-foundations-platform-work)
15. [Suggested build order](#suggested-build-order)
16. [Decisions](#decisions-2026-10-22)
15. [AI helpers (2.0 headline)](#15-ai-helpers-20-headline)
16. [AI: later stages](#16-ai-later-stages)
17. [Suggested build order](#suggested-build-order)
18. [Decisions](#decisions-2026-10-22)
---
@@ -590,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.
@@ -665,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 |
@@ -679,7 +683,8 @@ All later calls are signed with the install secret (HMAC) and go over HTTPS.
events people already paid for, and get its own data out (POPIA access
rights). Suspension blocks *new* activity only.
- **One plan for now.** Plan limits (e.g. number of campuses, WhatsApp) can
be carried in the token later.
be carried in the token later. The first per-licence limit comes with 2.0: the
**monthly AI cap in rand** (§15), kept and enforced on the Hub, since the Hub meters AI.
### Things to be aware of
@@ -703,6 +708,8 @@ model Install { id, customerId, licenceId, name, url, secretHash, version,
model Heartbeat { id, installId, receivedAt, version, health Json, stats Json }
// rolled up to daily after 30 days
model InstallEvent { id, installId, type, message, createdAt } // alerts, version changes
// 2.0, AI (§15): Licence gets aiMonthlyCapCents; usage per call, no prompt/response text
model AiUsage { id, installId, helper, model, inputTokens, outputTokens, costCents, createdAt }
```
---
@@ -724,6 +731,134 @@ keep adding to.
---
## 15. AI helpers (2.0 headline)
Small, one-button AI helpers in the places people already work. **No chat** in 2.0:
each helper is one request with a predictable cost, and a person always reviews
the result before anything is saved or sent.
**Who:** Supervisors and admins (the people who create events and read reports).
### The helpers
| Helper | Where | What it does |
|--------|-------|--------------|
| **Draft an event** | Event wizard, first step: "Describe your event" | *"Youth camp 14–16 March at Camp Wartburg, R450, 60 places, kids need consent"* → fills in title, description, dates/sessions, location, ticket options and prices, capacity, consent settings. The supervisor checks every field and saves as usual |
| **Summarise this report** | Every report (Reports page) | A few plain-language insights under the report: trends, outliers, comparisons with the previous period |
| **Draft a message** | Email / WhatsApp attendees, broadcasts | Turns a short note (*"remind them to bring a sleeping bag, bus leaves 7:30"*) into a friendly message in the right tone and length (shorter for WhatsApp), with placeholders like `{{name}}` |
| **Summarise feedback** | Post-event feedback results | Themes, what people liked, what to improve, notable comments |
| **Improve this text** | Event description, banner, message boxes | Rewrite for clarity, fix spelling, shorten |
Every helper shows **"AI suggestion — check before saving"**, can be undone, and
never saves, sends or publishes by itself.
### Provider and model
- **One provider: Claude (Anthropic), model Claude Haiku 5.5** — the cheapest
current model ($0.10 per million input tokens, $0.50 per million output, for
prompts under 100K tokens; prices as of 2026-10). Good enough for drafting and
summarising; a helper can move to a bigger model later if quality needs it.
- **Rough cost:** a helper call is ~2,000–10,000 tokens in and ~500 out, so well under
US$0.002 per click — about R1.50–R3 per thousand clicks. Fixed instructions are
prompt-cached, which makes repeat calls cheaper still.
- Structured output (a JSON schema) for "Draft an event", so the reply maps straight
onto the event form and is validated before it's shown.
### Key, caps and billing — through the Hub
Joshua provides the API key; churches don't need their own. The key must never sit on
an install (one leaked install would leak it for everyone), so:
- **Installs call AI through the Hub:** `POST /api/ai/complete` on the Hub, signed with
the install secret like the heartbeat. The Hub adds the key, calls Claude, and returns
the result. Still **install → Hub only** — the Hub never reaches into an install.
- **The Hub meters every call** in rand: tokens used × the model's price × an exchange rate
Joshua sets in the Hub (with an optional margin). Usage is kept per install per month.
- **Monthly rand cap per licence**, set by Joshua in the Hub to match what the church pays
(e.g. R0 = AI off, R50, R200). The licence's cap is the first per-plan limit. At 80% the
install's admins get a warning email; at 100% the helpers are switched off until the next
month (or until Joshua raises the cap). Optional per-user daily limit against runaway use.
- **Admins see their usage** in Site settings → AI: used this month (R and number of uses),
the cap, a daily chart, use per helper and per person, and an on/off switch per helper.
- If the Hub or Claude is unavailable, the buttons say so; nothing else in the app is affected.
### POPIA
- Anthropic is added to the **operator register** (and Joshua's Hub, which relays the
requests) and to the privacy notice: processing outside South Africa (POPIA s72),
for drafting and summarising only.
- **Send as little personal information as possible:** reports and feedback are sent as
counts, totals and anonymised comments — no names, contact details, medical or
children's information. Drafting an event or a message needs none.
- The Hub stores **no prompt or response text** — only the install, helper, tokens, cost
and time. Check Anthropic's commercial terms on retention and training before launch.
- Uses that include personal information (e.g. feedback comments) go in the access log.
### Plumbing
- Install: `backend/src/ai/` — one client for the Hub's AI endpoint, one prompt + schema
per helper, usage shown from the Hub. Feature flag per helper, all off when the licence
has no AI or the Hub isn't connected.
- Hub: the AI proxy, metering, caps, warnings, an exchange-rate setting, and a per-install
usage view for Joshua (alongside the existing install detail page).
- A small **eval set** (example event descriptions, reports, notes, with what a good answer
looks like) to check quality when changing the prompt or model.
- Help content, audit log entries (AI settings changed), CHANGELOG, FEATURES.md, SETTINGS.md.
---
## 16. AI: later stages
Ideas for after 2.0, in rough order. **Not tied to versions** — each is decided when it's
picked up, and all of them reuse the Hub proxy, metering and caps from §15.
### Ask AI (read-only assistant)
A chat panel for supervisors and admins: *"How many paid for Camp compared with last
year?"*, *"Which events this month are under half full?"*, *"Who hasn't paid yet for
Men's Breakfast?"*.
- The AI can only call **tools the app defines** — the existing stats and report queries,
"find an event", "registrations by week" — never its own SQL. It runs with the
logged-in person's permissions, so it sees nothing they couldn't.
- Answers show the numbers they're based on and link to the matching report.
- Costs more per question than a button (several tool calls), so it counts against the same
cap; may need a bigger model for harder questions.
### Help and navigation assistant
*"How do I add a coupon?"*, *"Where do I see who checked in?"* → a short answer from the
in-app help content (`frontend/src/content/help/`) with a link straight to the right page.
No personal information involved, so it's cheap and simple — could also be offered to
ordinary users.
### Actions with confirmation
The Ask assistant can also *do* things — create an event draft, schedule a reminder, offer
waitlist places, record a cash payment — always as **"here's what I'll do → Confirm"**, with
each confirmed action in the audit log.
### More helpers
Smaller one-button additions as they come up: suggest an event's price tiers from past
events, write a post-event thank-you, draft certificate wording, translate a message into
Afrikaans (ties in with §10), flag unusual payments for the cashup.
### Self-hosted AI (long term)
Run an open-weight model on Joshua's own GPU server instead of a paid API, once usage and
income justify the hardware:
- Because every install already talks to the **Hub's** AI endpoint, moving to self-hosting
is a change on the Hub only — the Hub gets a provider adapter (Claude now, self-hosted
later, or both: simple helpers self-hosted, harder questions on Claude).
- **POPIA bonus:** the data can stay in South Africa — no cross-border transfer.
- **When it makes sense:** compare the monthly API bill with the cost of a GPU server
(hardware or cloud rental, power, upkeep), and check the self-hosted model against the
eval set from §15 before switching any helper over.
---
## Suggested build order
| Phase | Version | Features | Why |
@@ -734,13 +869,13 @@ keep adding to.
| 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 |
| 10 | 1.48.0 – 1.50.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.51.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 12 | **2.0.0** | v1 API removed, migration guide, marketing site/docs for selling | The release |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
Each phase should add matching help content (`frontend/src/content/help/`),
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
@@ -761,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" |
@@ -771,8 +906,13 @@ 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) |
| — | AI provider and key | One provider, **Claude** (Haiku 5.5 to start, for cost). Joshua provides the key; installs call AI **through the Hub**, which holds the key and meters usage (2026-10-24) |
| — | AI cost limits | A **monthly rand cap per install**, set in the Hub per licence to match what the church pays; admins see their usage in Site settings → AI (2026-10-24) |
| — | AI later | Ask AI (read-only, tools only), help/navigation assistant, actions with confirmation, more helpers, and long-term **self-hosted** AI behind the same Hub endpoint — not tied to versions (2026-10-24) |
| — | POPIA: message kinds | Bulk messages are either **Event updates** (to registrants — still sent after unsubscribing) or **News & announcements** (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
| — | POPIA: existing users | Kept opted in to news (logged as "existing account"), asked once at their next login to confirm or opt out. New sign-ups choose with an unticked box (2026-10-23) |
| — | POPIA: deleting an account | A request: the account is deactivated at once, personal info is anonymised when an admin approves. Waits while the person has an open registration for an upcoming event (2026-10-23) |
+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"