Compare commits

...
2 Commits
Author SHA1 Message Date
joshua be4f341e42 Merge branch 'feat/request-update'
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
2026-10-27 13:55:06 +02:00
joshuaandClaude Opus 5.5 1caa0f0ea7 Request update button instead of the deploy command (1.42.0)
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Site settings -> Licence sends a signed update request to the Hub
(docs/hub-protocol.md 3a) instead of showing bash scripts/deploy.sh. Shows
"Update requested" until the site runs that version and hides the update
banner meanwhile. Audit-logged as hub_update_requested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:55:05 +02:00
19 changed files with 157 additions and 34 deletions
+12
View File
@@ -7,6 +7,18 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.42.0] - 2026-10-27
### Changed
- **"Request update" instead of the deploy command.** When a new version is out, Site settings →
Licence shows a **Request update** button instead of `bash scripts/deploy.sh …`. It sends a
signed request to the Hub (`docs/hub-protocol.md` §3a), which tells the provider; the site never
updates itself. Afterwards it shows "Update requested on …" until the site runs that version, and
the "update available" banner for admins goes away. Logged in the audit log
(`hub_update_requested`). Needs Cross Events Hub 1.1.0; an older Hub answers "The Hub doesn't take
update requests yet".
## [1.41.3] - 2026-10-27
### Changed
+2 -1
View File
@@ -659,7 +659,8 @@ can't start, the same jobs run on in-process timers.
never switches it off: past the end date there's a grace period (banner for site admins only),
then — or when suspended, revoked or not licensed — the site is read-only (events can't be
created or edited; no new registrations or payments; tickets still scan, data can be exported).
The site checks in 30 seconds after starting and after a licence key is connected. Admins see when an update is available.
The site checks in 30 seconds after starting and after a licence key is connected. Admins see when an update is available and press **Request update**,
which tells the provider through the Hub (the site never updates itself).
See `docs/hub-protocol.md`.
- **`/health`** — status, version, database, background jobs and last backup.
- **PM2** (`ecosystem.config.js`) runs backend and frontend, restarting either
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.41.3",
"version": "1.42.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.41.3",
"version": "1.42.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.41.3",
"version": "1.42.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+24 -1
View File
@@ -13,6 +13,12 @@ function compareVersions(a, b) {
return 0;
}
/** An update request still waiting: until this site runs the version that was asked for. */
function pendingUpdateRequest(version, requestedAt, requestedVersion) {
if (!requestedAt || !requestedVersion || compareVersions(version, requestedVersion) >= 0) return null;
return { requestedAt, version: requestedVersion };
}
/** What the admin pages show: licence state, Hub connection, version and update. */
async function statusPayload({ fresh = false } = {}) {
const s = await licenceStatus({ fresh });
@@ -40,6 +46,7 @@ async function statusPayload({ fresh = false } = {}) {
version: APP_VERSION,
latestVersion: s.latestVersion,
updateAvailable: !!(s.latestVersion && compareVersions(APP_VERSION, s.latestVersion) < 0),
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion),
};
}
@@ -84,4 +91,20 @@ const heartbeatNow = async (req, res) => {
}
};
module.exports = { getStatus, connect, heartbeatNow, statusPayload, compareVersions };
// @desc Ask the Hub for this site to be updated (the provider is told; nothing runs here)
// @route POST /api/hub/update-request
// @access Private/Admin
const requestUpdate = async (req, res) => {
try {
const result = await hub.requestUpdate();
if (!result.alreadyRequested) {
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_requested', targetType: 'hub', metadata: { from: APP_VERSION, to: result.version }, ip: getClientIp(req) });
}
res.json(await statusPayload({ fresh: true }));
} catch (e) {
const status = e.statusCode && e.statusCode < 500 && e.code !== 'INSTALL_NOT_FOUND' ? e.statusCode : 502;
res.status(status).json({ message: e.message || safeErrorMessage(e), code: e.code });
}
};
module.exports = { getStatus, connect, heartbeatNow, requestUpdate, statusPayload, compareVersions, pendingUpdateRequest };
+38 -13
View File
@@ -1,8 +1,10 @@
/**
* Talking to the Cross Events Hub (docs/hub-protocol.md): register with a licence key, then a
* signed heartbeat every 15 minutes. Install → Hub only. Stored in AppSetting:
* signed heartbeat every 15 minutes, and a signed update request when an admin asks for one.
* 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.
* hub_last_heartbeat_at, hub_last_error, hub_latest_version,
* hub_update_requested_at, hub_update_requested_version.
* These keys are kept out of the generic settings endpoints (settingsController).
*/
@@ -14,7 +16,7 @@ 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'];
const HUB_KEYS = ['hub_install_id', 'hub_install_secret', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version'];
function hubUrl() {
return String(process.env.HUB_URL || 'https://events-hub.crosscode.co.za').replace(/\/$/, '');
@@ -59,6 +61,21 @@ async function post(path, body, headers = {}) {
return data;
}
/** POST signed with the install secret (§3). Throws NOT_CONNECTED when there is no connection. */
async function signedPost(path, body) {
const s = await read(['hub_install_id', 'hub_install_secret']);
if (!s.hub_install_id || !s.hub_install_secret) throw new HubError('This site isn\'t connected to the Hub yet.', 'NOT_CONNECTED', 400);
const secret = decrypt(s.hub_install_secret);
return post(path, 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'),
};
});
}
/** Register this install with a licence key. Returns the licence claims. */
async function connect(licenceKey, adminContact) {
if (!require('./publicKey').hubPublicKey()) {
@@ -92,17 +109,9 @@ async function connect(licenceKey, adminContact) {
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 data = await signedPost('/api/installs/heartbeat', body);
const claims = verifyToken(data.licence);
await save({
// Keep the old token if the new one doesn't check out (never accept an unverified one).
@@ -124,6 +133,22 @@ async function heartbeat() {
}
}
/**
* Ask the Hub for this site to be updated (§3a). Joshua is told; the site never updates itself.
* Returns { requestedAt, version, alreadyRequested }.
*/
async function requestUpdate() {
let data;
try {
data = await signedPost('/api/installs/update-request', { protocolVersion: PROTOCOL_VERSION, version: report.APP_VERSION });
} catch (e) {
if (e.code === 'NOT_FOUND') throw new HubError('The Hub doesn\'t take update requests yet. Please contact your provider.', 'NOT_SUPPORTED', 502);
throw e;
}
await save({ hub_update_requested_at: data.requestedAt, hub_update_requested_version: data.version || null });
return data;
}
/**
* A heartbeat soon (on startup, and after connecting), so a licence change on the Hub shows
* without waiting for the next 15-minute run. Never throws; skipped when the job is switched off.
@@ -139,4 +164,4 @@ function heartbeatSoon(delayMs = 30 * 1000) {
soonTimer.unref?.();
}
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon };
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate };
+3 -1
View File
@@ -80,7 +80,7 @@ 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 rows = await prisma.appSetting.findMany({ where: { key: { in: ['hub_install_id', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version'] } } });
const s = Object.fromEntries(rows.map(r => [r.key, r.value]));
const claims = verifyToken(s.hub_licence);
const value = {
@@ -93,6 +93,8 @@ async function licenceStatus({ fresh = false } = {}) {
lastHeartbeatAt: s.hub_last_heartbeat_at || null,
lastError: s.hub_last_error || null,
latestVersion: s.hub_latest_version || null,
updateRequestedAt: s.hub_update_requested_at || null,
updateRequestedVersion: s.hub_update_requested_version || null,
};
cache = { at: Date.now(), value };
return value;
+2
View File
@@ -921,6 +921,8 @@ app.get('/docs', async (req, res) => {
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' }]},
{ method:'POST', path:'/api/hub/update-request', auth:'admin', desc:'Ask the Hub for this site to be updated to the latest version (the provider is told; the site never updates itself)',
responses:[{ status:200, desc:'Status, with updateRequest', body:{ updateAvailable:true, updateRequest:{ requestedAt:'2026-10-27T09:00:00.000Z', version:'1.42.0' } }}, { status:409, desc:'Already on the latest version', body:{ code:'UP_TO_DATE', message:'This site already runs the latest version.' }}, { status:502, desc:'Hub unreachable' }]},
]},
{ title: 'Privacy (POPIA)', base: '/api/privacy', endpoints: [
+2 -1
View File
@@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
const { getStatus, connect, heartbeatNow } = require('../controllers/hubController');
const { getStatus, connect, heartbeatNow, requestUpdate } = require('../controllers/hubController');
const { protect, admin } = require('../middleware/authMiddleware');
// Licence and the Cross Events Hub (docs/hub-protocol.md). Admins only; the setup wizard's
@@ -8,5 +8,6 @@ const { protect, admin } = require('../middleware/authMiddleware');
router.get('/status', protect, admin, getStatus);
router.post('/connect', protect, admin, connect);
router.post('/heartbeat', protect, admin, heartbeatNow);
router.post('/update-request', protect, admin, requestUpdate);
module.exports = router;
+9 -1
View File
@@ -4,7 +4,7 @@ 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 { compareVersions, pendingUpdateRequest } = require('../src/controllers/hubController');
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
function sign(payload, key = privateKey) {
@@ -131,3 +131,11 @@ test('version comparison for "update available"', () => {
expect(compareVersions('1.41.0', '1.41.0')).toBe(0);
expect(compareVersions('2.0.0', '1.50.3')).toBeGreaterThan(0);
});
test('an update request shows until the site runs the version asked for', () => {
const at = '2026-10-27T09:00:00.000Z';
expect(pendingUpdateRequest('1.41.2', at, '1.42.0')).toEqual({ requestedAt: at, version: '1.42.0' });
expect(pendingUpdateRequest('1.42.0', at, '1.42.0')).toBeNull();
expect(pendingUpdateRequest('1.43.0', at, '1.42.0')).toBeNull();
expect(pendingUpdateRequest('1.41.2', null, null)).toBeNull();
});
+21
View File
@@ -110,6 +110,27 @@ X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
`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.
## 3a. Update request
Sent when a site admin presses **Request update** (Site settings → Licence). Installs never update
themselves and never show the deploy command; the request tells Joshua, who updates the server.
Signed exactly like the heartbeat.
```
POST /api/installs/update-request
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
{ "protocolVersion": 1, "version": "1.41.2" }
→ 201 { "requestedAt": "2026-10-27T09:00:00.000Z", "version": "1.42.0", "alreadyRequested": false }
→ 200 { …, "alreadyRequested": true } // a request is already open: not emailed again
```
- The Hub opens an `update_requested` alert on the install (shown under Alerts) and emails the
alert address once. It is resolved when a heartbeat reports a newer version than before.
- `409 UP_TO_DATE` — the install already runs the Hub's latest version (or none is set).
- Errors as in §3 (`INSTALL_NOT_FOUND`, `STALE_REQUEST`, `BAD_SIGNATURE`). Rate-limited (10 an
hour per IP). A Hub older than this section answers `404 NOT_FOUND`.
- No personal information: not who pressed the button (the Hub already has the admin contact).
## 4. Licence token
`hel1.<payload>.<signature>` — `payload` is base64url JSON, `signature` is the base64url
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.41.3",
"version": "1.42.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.41.3",
"version": "1.42.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.41.3",
"version": "1.42.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -96,6 +96,7 @@ const ACTION_LABELS: Record<string, string> = {
breach_recorded: "Security compromise recorded",
breach_updated: "Security compromise updated",
hub_connected: "Connected to the Cross Events Hub",
hub_update_requested: "Asked the Hub for an update",
};
interface AuditLogEntry {
+27 -4
View File
@@ -21,6 +21,7 @@ export type HubStatus = {
version: string;
latestVersion: string | null;
updateAvailable: boolean;
updateRequest: { requestedAt: string; version: string } | null;
};
const STATE: Record<HubStatus["state"], { label: string; tone: string; text: string }> = {
@@ -93,6 +94,19 @@ export function LicenceTab({ active }: { active: boolean }) {
}
};
const requestUpdate = async () => {
setBusy(true);
setMsg(null);
try {
setStatus(await apiFetch<HubStatus>("/api/hub/update-request", { method: "POST", authToken: token }));
setMsg({ type: "ok", text: "Update requested. Your provider has been told and will update the site." });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "The Hub could not be reached." });
} 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];
@@ -148,11 +162,20 @@ export function LicenceTab({ active }: { active: boolean }) {
<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 Cross 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>
{status.updateAvailable && (status.updateRequest ? (
<p className="mt-1 text-gray-600">
Version {status.latestVersion} is available. Update requested on {fmt(status.updateRequest.requestedAt)} — your provider will install it.
</p>
)}
) : (
<div className="mt-2 flex flex-wrap items-center gap-3">
<p className="text-brand-700">Version {status.latestVersion} is available.</p>
{status.connected && (
<button onClick={requestUpdate} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
<ArrowUpCircle className="w-4 h-4" /> {busy ? "Requesting…" : "Request update"}
</button>
)}
</div>
))}
</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>}
@@ -50,11 +50,12 @@ export function LicenceBanner() {
</div>
);
}
if (status.updateAvailable) {
// Once an update has been requested there's nothing more for the admin to do.
if (status.updateAvailable && !status.updateRequest) {
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>Cross Events {status.latestVersion} is available (this site runs {status.version}). {link}</span>
<span>Cross Events {status.latestVersion} is available (this site runs {status.version}). Request the update in {link}</span>
</div>
);
}
+4 -1
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -156,6 +156,9 @@ export const siteSettingsHelpContent: HelpContent = {
<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>
<GuideItem icon={ArrowUpCircle} title="Updates" tone="brand">
When a new version is out, press Request update. Your provider is told and updates the site for you — there&apos;s nothing to run yourself.
</GuideItem>
</div>
</div>
),
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.41.3",
"version": "1.42.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.41.3",
"version": "1.42.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.41.3",
"version": "1.42.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",