diff --git a/CHANGELOG.md b/CHANGELOG.md index 2459e00..b6b7410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### 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 diff --git a/FEATURES.md b/FEATURES.md index cd525a8..fa98549 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 diff --git a/README.md b/README.md index d1ca9d9..0f5dbc8 100644 --- a/README.md +++ b/README.md @@ -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:///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` diff --git a/SETTINGS.md b/SETTINGS.md index 8139743..319752c 100644 --- a/SETTINGS.md +++ b/SETTINGS.md @@ -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 | diff --git a/backend/.env.example b/backend/.env.example index a35a1a6..2874154 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index 6bc7869..a1e38d3 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -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 | diff --git a/backend/prisma/migrations/20261026101625_hub_connected/migration.sql b/backend/prisma/migrations/20261026101625_hub_connected/migration.sql new file mode 100644 index 0000000..b4e7212 --- /dev/null +++ b/backend/prisma/migrations/20261026101625_hub_connected/migration.sql @@ -0,0 +1,3 @@ +-- AlterEnum +ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_connected'; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f968b4b..4fde725 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/controllers/hubController.js b/backend/src/controllers/hubController.js new file mode 100644 index 0000000..4eebd18 --- /dev/null +++ b/backend/src/controllers/hubController.js @@ -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 }; diff --git a/backend/src/controllers/settingsController.js b/backend/src/controllers/settingsController.js index 20d11aa..6fbed76 100644 --- a/backend/src/controllers/settingsController.js +++ b/backend/src/controllers/settingsController.js @@ -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); diff --git a/backend/src/hub/client.js b/backend/src/hub/client.js new file mode 100644 index 0000000..9a3aaec --- /dev/null +++ b/backend/src/hub/client.js @@ -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 }; diff --git a/backend/src/hub/licence.js b/backend/src/hub/licence.js new file mode 100644 index 0000000..efb23d1 --- /dev/null +++ b/backend/src/hub/licence.js @@ -0,0 +1,93 @@ +/** + * 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) { + publicKeyObject = crypto.createPublicKey(pem); + 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 }; diff --git a/backend/src/hub/publicKey.js b/backend/src/hub/publicKey.js new file mode 100644 index 0000000..e0d672d --- /dev/null +++ b/backend/src/hub/publicKey.js @@ -0,0 +1,17 @@ +/** + * 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; + +function hubPublicKey() { + const fromEnv = (process.env.HUB_PUBLIC_KEY || '').replace(/\\n/g, '\n').trim(); + return fromEnv || PRODUCTION_PUBLIC_KEY; +} + +module.exports = { hubPublicKey, PRODUCTION_PUBLIC_KEY }; diff --git a/backend/src/hub/readOnly.js b/backend/src/hub/readOnly.js new file mode 100644 index 0000000..c229f1c --- /dev/null +++ b/backend/src/hub/readOnly.js @@ -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 }; diff --git a/backend/src/hub/report.js b/backend/src/hub/report.js new file mode 100644 index 0000000..d365519 --- /dev/null +++ b/backend/src/hub/report.js @@ -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 }; diff --git a/backend/src/index.js b/backend/src/index.js index 129bcc5..66bbbce 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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:[] }}]}, diff --git a/backend/src/jobs/index.js b/backend/src/jobs/index.js index 08a9ab3..5087f1d 100644 --- a/backend/src/jobs/index.js +++ b/backend/src/jobs/index.js @@ -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()), diff --git a/backend/src/routes/hubRoutes.js b/backend/src/routes/hubRoutes.js new file mode 100644 index 0000000..2fa2a19 --- /dev/null +++ b/backend/src/routes/hubRoutes.js @@ -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; diff --git a/backend/src/utils/errorCounter.js b/backend/src/utils/errorCounter.js new file mode 100644 index 0000000..8ef67f3 --- /dev/null +++ b/backend/src/utils/errorCounter.js @@ -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 }; diff --git a/backend/tests/hub.test.js b/backend/tests/hub.test.js new file mode 100644 index 0000000..724345f --- /dev/null +++ b/backend/tests/hub.test.js @@ -0,0 +1,108 @@ +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)); +}); + +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); +}); diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 1fd2600..c6494bd 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -150,6 +150,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 diff --git a/docs/hub-protocol.md b/docs/hub-protocol.md new file mode 100644 index 0000000..65779b6 --- /dev/null +++ b/docs/hub-protocol.md @@ -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": "", + "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: +{ + "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": "", "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` is base64url JSON, `signature` is the base64url +Ed25519 signature of the ASCII string `hel1.`. + +```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`. diff --git a/frontend/src/app/dashboard/admin/audit-log/page.tsx b/frontend/src/app/dashboard/admin/audit-log/page.tsx index 1fd71ee..768444c 100644 --- a/frontend/src/app/dashboard/admin/audit-log/page.tsx +++ b/frontend/src/app/dashboard/admin/audit-log/page.tsx @@ -51,6 +51,7 @@ const ACTIONS = [ "retention_run", "breach_recorded", "breach_updated", + "hub_connected", ] as const; const ACTION_LABELS: Record = { @@ -94,6 +95,7 @@ const ACTION_LABELS: Record = { 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 { diff --git a/frontend/src/app/dashboard/admin/settings/page.tsx b/frontend/src/app/dashboard/admin/settings/page.tsx index 1b56844..ddfc803 100644 --- a/frontend/src/app/dashboard/admin/settings/page.tsx +++ b/frontend/src/app/dashboard/admin/settings/page.tsx @@ -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" && } {activeTab === "jobs" && } + {activeTab === "licence" && } {activeTab === "branding" && ( diff --git a/frontend/src/app/dashboard/layout.tsx b/frontend/src/app/dashboard/layout.tsx index f3ab285..b9b88ec 100644 --- a/frontend/src/app/dashboard/layout.tsx +++ b/frontend/src/app/dashboard/layout.tsx @@ -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 (
+
@@ -103,6 +105,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod return (
+
{children}
diff --git a/frontend/src/app/setup/page.tsx b/frontend/src/app/setup/page.tsx index a2e8408..91cd194 100644 --- a/frontend/src/app/setup/page.tsx +++ b/frontend/src/app/setup/page.tsx @@ -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(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.

)} + {/* Licence: connect to the Hope Events Hub now, or later in Site settings → Licence. */} +
+

Licence

+ {licenceConnected ? ( +

✓ Connected — licensed to {licenceConnected}.

+ ) : ( + <> +

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.

+ setLicenceConnected(s.licence?.customer || "your organisation")} /> + + )} +
+
+ {error &&

{error}

} + + ); +} + +/** 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(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("/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("/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 ?

{msg.text}

:

Loading…

; + const st = STATE[status.state]; + + return ( +
+
+

Licence

+

This site checks in with the Hope Events Hub every 15 minutes: version, health and totals only — never anyone's personal information.

+
+ +
+
+ {status.state === "active" ? : } + {st.label} + {status.state === "grace" && status.daysLeft != null && — {status.daysLeft} day{status.daysLeft === 1 ? "" : "s"} left} +
+

{st.text}

+
+ + {!status.keyConfigured && ( +

+ 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. +

+ )} + + {status.licence && ( +
+
Licensed to
{status.licence.customer}
+
Plan
{status.licence.plan}
+
Licence ends
{status.licence.licenceExpiresAt ? fmt(status.licence.licenceExpiresAt) : "No end date"}
+
Grace period
{status.licence.graceDays} days
+
+ )} + +
+
Hub connection
+
+
Hub
{status.hubUrl}
+
Last check-in
{fmt(status.lastHeartbeatAt)}
+
+ {status.lastError &&

Last problem: {status.lastError}

} + {status.connected && ( + + )} +
+

{status.connected ? "New licence key, or moved to a new address? Connect again:" : "Enter the licence key you received:"}

+ { setStatus(s); setMsg({ type: "ok", text: "Connected to the Hub." }); }} /> +
+
+ +
+
Version
+

This site runs Hope Events {status.version}.{status.latestVersion && !status.updateAvailable && " It's up to date."}

+ {status.updateAvailable && ( +

+ Version {status.latestVersion} is available. On the server: bash scripts/deploy.sh v{status.latestVersion} +

+ )} +
+ + {msg &&
{msg.text}
} +
+ ); +} diff --git a/frontend/src/components/shared/LicenceBanner.tsx b/frontend/src/components/shared/LicenceBanner.tsx new file mode 100644 index 0000000..3c532f0 --- /dev/null +++ b/frontend/src/components/shared/LicenceBanner.tsx @@ -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(null); + const isAdmin = user?.role === "admin"; + + useEffect(() => { + if (!isAdmin || !token) return; + apiFetch("/api/hub/status", { authToken: token }).then(setStatus).catch(() => {}); + }, [isAdmin, token]); + + if (!isAdmin || !status) return null; + const link = Licence settings; + + if (status.state === "grace") { + return ( +
+ + Licence needs renewing — {status.daysLeft} day{status.daysLeft === 1 ? "" : "s"} left. {link} +
+ ); + } + if (status.state === "suspended" || status.state === "revoked") { + return ( +
+ + + 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} + +
+ ); + } + if (status.state === "unlicensed" && status.keyConfigured) { + return ( +
+ + This site isn't connected to the Hope Events Hub yet. {link} +
+ ); + } + if (status.updateAvailable) { + return ( +
+ + Hope Events {status.latestVersion} is available (this site runs {status.version}). {link} +
+ ); + } + return null; +} diff --git a/frontend/src/content/help/site-settings.tsx b/frontend/src/content/help/site-settings.tsx index 10813f2..bbaa16b 100644 --- a/frontend/src/content/help/site-settings.tsx +++ b/frontend/src/content/help/site-settings.tsx @@ -139,5 +139,26 @@ export const siteSettingsHelpContent: HelpContent = {
), }, + { + key: "licence", + label: "Licence", + icon: KeyRound, + content: ( +
+

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.

+
+ + 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. + + + After the licence's end date everything keeps working for the grace period (usually 14 days); only site admins see a reminder. + + + 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. + +
+
+ ), + }, ], }; diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..8acba9b --- /dev/null +++ b/scripts/install.sh @@ -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 < "$DIR/backend/.env" < "$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" </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"