Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c5246424b | ||
|
|
bbdceb38b6 | ||
|
|
c0965bfcf5 | ||
|
|
eb2159fa33 | ||
|
|
0551226de5 | ||
|
|
1ae9c45eed | ||
|
|
fd2bb07047 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Generated
+2
-2
@@ -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,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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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:[] }}]},
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-23, after **1.40.0**. The plan itself is in
|
||||
Last updated: 2026-10-26, after **1.41.0** (and Hub 1.0.0). The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
@@ -8,6 +8,7 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.41.0 | Hope Events Hub connection — licence step in `/setup` and Site settings → Licence, 15-minute heartbeat (health, totals, income bands), licence states checked offline (grace banner for site admins only, read-only when suspended/revoked), `/health`, "update available", `scripts/install.sh`. The Hub itself is `joshua/events-hub` 1.0.0 |
|
||||
| 1.40.0 | POPIA toolkit part 3 — readiness checklist with score, retention policies + nightly `retention` job (inactive accounts anonymised after 5 years with a 30-day warning, on by default), breach register with notification drafts, children's/medical flag in the access log, `{retention}` in the notice |
|
||||
| 1.39.0 | POPIA toolkit part 2 — Admin → Privacy: privacy notice editor (built-in text as the start, placeholders, drafts, numbered versions, re-acceptance after login), operator register, consent log, access log; Information Officer on the contact page |
|
||||
| 1.38.0 | POPIA toolkit part 1 — Privacy page (news opt-in + history, download my data, correction and deletion requests), Event update vs News & announcements on every bulk send, unsubscribe links + one-click headers, WhatsApp STOP, one-time news question, Admin → Data requests, thorough anonymising |
|
||||
@@ -25,8 +26,13 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
## Next up
|
||||
|
||||
1. **1.41.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
|
||||
`joshua/hope-events-hub`).
|
||||
1. **Go live with the Hub:** generate the production signing key on the Hub server
|
||||
(`npm run generate-keys` in `events-hub/backend`), put the **public** key in
|
||||
`backend/src/hub/publicKey.js` and release it as **1.41.1** — until then installs can't
|
||||
connect (they work as "not connected"). Deploy the Hub to `events-hub.crosscode.co.za`, create
|
||||
the admin (`npm run create-admin`), generate Hope Family Church's licence and connect it (install #1).
|
||||
Try `scripts/install.sh` on a test server.
|
||||
2. **1.42.0 — Multi-campus** (§12).
|
||||
|
||||
## Working on this repo
|
||||
|
||||
@@ -150,6 +156,24 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
(`prepare-db.js` → `retention`); `privacy-notice.spec.ts` publishes a version that needs accepting, then accepts it for all
|
||||
seeded users in `afterAll` so later spec files don't meet the prompt.
|
||||
|
||||
## Where the Hub client lives (1.41.0)
|
||||
|
||||
- Protocol: `docs/hub-protocol.md` — the same file is in the Hub repo (`joshua/events-hub`,
|
||||
developed at `../events-hub`); change both together.
|
||||
- `backend/src/hub/`: `publicKey.js` (the production Hub key — **must be set before releasing**;
|
||||
`HUB_PUBLIC_KEY` env overrides it for a local Hub), `licence.js` (verify tokens, work out
|
||||
active / grace / suspended / revoked / unlicensed, cached 30 s), `client.js` (connect, heartbeat;
|
||||
AppSetting `hub_*` keys, which the generic settings endpoints never touch), `report.js` (health,
|
||||
totals, income bands), `readOnly.js` (the routes blocked in read-only mode — add any new
|
||||
"create" route there).
|
||||
- Job `hub-heartbeat` (every 15 min), routes `/api/hub/*`, `/health`.
|
||||
- Frontend: `components/admin/LicenceTab.tsx` (Site settings → Licence; its `ConnectForm` is also on
|
||||
the last `/setup` screen), `components/shared/LicenceBanner.tsx` (site admins only).
|
||||
- Local testing: run the Hub (`../events-hub`, ports 5200/3200), create a licence there, and start
|
||||
Hope Events with `HUB_URL=http://localhost:5200` and `HUB_PUBLIC_KEY` = the key on Hub → Settings.
|
||||
- `scripts/install.sh` — fresh Ubuntu server to a running site (written and syntax-checked; not yet
|
||||
run on a real server).
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
|
||||
|
||||
+8
-6
@@ -22,14 +22,15 @@ picker at the door and session choice on "All sessions" tickets; 1.36.0 payment
|
||||
1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
|
||||
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23);
|
||||
1.38.0 POPIA toolkit part 1 (privacy page, unsubscribe, WhatsApp STOP, data requests); 1.39.0 part 2 (privacy notice editor and versions, operator register, consent and access logs); 1.40.0 part 3 (retention policies and job, breach register, children’s-data flags, readiness page).
|
||||
**Next: 1.41.0 install/update tooling + the Hope Events Hub (§13).**
|
||||
1.41.0 the Hub connection (licence, heartbeat, read-only mode, `/health`, install script) with Hub 1.0.0 (`joshua/events-hub`).
|
||||
**Next: 1.42.0 Multi-campus.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
| Repo | What |
|
||||
|------|------|
|
||||
| `joshua/hope-events` (this repo) | The church app. Everything in this document except the Hub app itself — including the Hub *client* (licence step, "Connect to Hub", heartbeat, licence checks) |
|
||||
| `joshua/hope-events-hub` (new) | The Hub app (§13): its own versions, CHANGELOG, releases and server |
|
||||
| `joshua/events-hub` (new) | The Hub app (§13): its own versions, CHANGELOG, releases and server |
|
||||
|
||||
---
|
||||
|
||||
@@ -593,7 +594,7 @@ whether they're healthy, basic usage stats, and their **licence** status.
|
||||
|
||||
### Separate app, separate repo, separate server
|
||||
|
||||
- **Its own git repository:** `joshua/hope-events-hub` on the same Gitea
|
||||
- **Its own git repository:** `joshua/events-hub` (https://c-git.crosscode.co.za/joshua/events-hub.git) on the same Gitea
|
||||
server (c-git.crosscode.co.za), with its own semver tags, CHANGELOG and
|
||||
deploys. It is never shipped to churches, so none of its code (customers,
|
||||
licence signing key, other churches' data) ends up on a customer's server.
|
||||
@@ -668,7 +669,7 @@ All later calls are signed with the install secret (HMAC) and go over HTTPS.
|
||||
| State | What the install does |
|
||||
|-------|----------------------|
|
||||
| **Active** | Everything works |
|
||||
| **Grace** (expired or Hub unreachable) | Everything works; admins see a banner "Licence needs renewing — X days left". Default 14 days |
|
||||
| **Grace** (expired or Hub unreachable) | Everything works; **only site admins** see a banner "Licence needs renewing — X days left" (on their dashboard pages — never supervisors, staff, users or the public site). Default 14 days |
|
||||
| **Suspended** | **Read-only:** no new events, no new registrations or payments. Existing tickets **still scan**, and admins can still view and **export all data** |
|
||||
| **Revoked** | Same as suspended, with a permanent banner |
|
||||
|
||||
@@ -868,7 +869,7 @@ income justify the hardware:
|
||||
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
|
||||
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
|
||||
| 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 6 | 1.41.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 6 | 1.41.0 ✅ + Hub v1 ✅ | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 7 | 1.42.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
|
||||
| 8 | 1.43.0 – 1.44.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.45.0 – 1.47.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
|
||||
@@ -895,7 +896,7 @@ feature.
|
||||
| 6 | Selling model | **One install per church** — data separation, any server. No multi-tenant. **Multi-campus** inside an install is in scope |
|
||||
| — | Pro-rata rounding | Nearest R10 |
|
||||
| — | Multi-campus | In scope (§12), inside one install |
|
||||
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/hope-events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
|
||||
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
|
||||
| 7 | Payment provider order | Yoco · PayFast. Joshua can supply documentation links |
|
||||
| — | SnapScan, Zapper | Dropped (2026-10-23): Yoco and PayFast are enough. Later versions moved up by two |
|
||||
| — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" |
|
||||
@@ -905,6 +906,7 @@ feature.
|
||||
| — | Hub domain | `events-hub.crosscode.co.za` (2026-10-23) |
|
||||
| — | Licence keys | Generated by the Hub when Joshua clicks "Generate" on a customer (2026-10-23) |
|
||||
| — | Grace period / suspension | 14-day grace; suspended = read-only (tickets still scan, data export still works) (2026-10-23) |
|
||||
| — | Grace banner, Hub repo, Hub development | The grace-period banner shows **only to site admins**. The Hub repo is `joshua/events-hub` (https://c-git.crosscode.co.za/joshua/events-hub.git); it is developed locally first, then deployed by Joshua (2026-10-24) |
|
||||
| — | Income in the heartbeat | Included, as **ranges** only (R0–R1 000, R1 001–R5 000, …), never exact amounts (2026-10-23) |
|
||||
| — | Licence plans | One plan for now (2026-10-23) |
|
||||
| — | AI in 2.0 | The **headline feature of 2.0**: one-button helpers only (draft an event, summarise reports and feedback, draft messages, improve text) — no chat yet (2026-10-24) |
|
||||
|
||||
@@ -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`.
|
||||
Generated
+2
-2
@@ -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,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" && (
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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'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'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'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'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'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'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>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user