Compare commits

...
Author SHA1 Message Date
joshua 925252a1f2 Merge branch 'fix/licence-checks'
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 10:45:41 +02:00
joshuaandClaude Opus 5.5 60726b71ce Bump to 1.41.1
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:45:40 +02:00
joshuaandClaude Opus 5.5 e58787ae7a Licence: no licence is read-only, editing events blocked, heartbeat 30 s after start
- A site the Hub answers INSTALL_NOT_FOUND for drops its install and licence, so it
  no longer shows "Active" on the old token.
- Unlicensed is read-only like suspended; read-only also blocks editing events,
  options and variants. LICENCE_CHECK_DISABLED (ignored in production) for e2e.
- Heartbeat 30 s after the server starts and after a licence key is connected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:45:38 +02:00
joshua 8f533e1f61 Merge branch 'feat/hub-client'
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-26 13:03:53 +02:00
21 changed files with 103 additions and 39 deletions
+18
View File
@@ -7,6 +7,24 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.41.1] - 2026-10-27
### Changed
- **No licence now means read-only**, the same as a suspended licence: a site that isn't connected
to the Hub can't create events, registrations or payments until a licence key is connected
(admins see a red banner). `LICENCE_CHECK_DISABLED=true` switches this off outside production,
for the end-to-end tests.
- **Read-only mode also blocks editing events**, their options and their variants (suspended,
revoked and unlicensed sites).
- The site **checks in with the Hub 30 seconds after starting**, and 30 seconds after a licence
key is connected, instead of waiting up to 15 minutes.
### Fixed
- A site the Hub no longer knows (`INSTALL_NOT_FOUND`, e.g. the licence was released) kept showing
"Active" on its old licence. It now drops the connection and licence, and shows "No licence".
## [1.41.0] - 2026-10-26
### Added
+3 -2
View File
@@ -657,8 +657,9 @@ can't start, the same jobs run on in-process timers.
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.
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.
See `docs/hub-protocol.md`.
- **`/health`** — status, version, database, background jobs and last backup.
- **PM2** (`ecosystem.config.js`) runs backend and frontend, restarting either
+3 -2
View File
@@ -90,8 +90,9 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|---|---|---|
| `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"). |
| `HUB_HEARTBEAT_ENABLED` (env) | `true` | The 15-minute `hub-heartbeat` job (plus one check-in 30 s after starting and after connecting). Off means the licence isn't refreshed — after 30 days the site goes into grace, then read-only. |
| `LICENCE_CHECK_DISABLED` (env) | `false` | `true` turns read-only mode off — **ignored when `NODE_ENV=production`**. For the end-to-end tests and local development without a Hub. |
| `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 "no licence", which is read-only). The heartbeat deletes the install and licence keys itself when the Hub answers `INSTALL_NOT_FOUND`. |
## WhatsApp
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.41.0",
"version": "1.41.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.41.0",
"version": "1.41.1",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.41.0",
"version": "1.41.1",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+1
View File
@@ -63,6 +63,7 @@ const connect = async (req, res) => {
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);
hub.heartbeatSoon();
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) {
+22 -3
View File
@@ -114,10 +114,29 @@ async function heartbeat() {
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 };
// The Hub doesn't know this install any more (licence released): drop the connection and the
// licence, so the site is unlicensed (read-only) and shows "Connect" until a key is entered.
if (e.code === 'INSTALL_NOT_FOUND') {
await save({ hub_install_id: null, hub_install_secret: null, hub_licence: null });
return { ok: false, reconnect: true };
}
throw e;
}
}
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat };
/**
* 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.
*/
let soonTimer = null;
function heartbeatSoon(delayMs = 30 * 1000) {
if (String(process.env.JOBS_ENABLED || 'true').toLowerCase() === 'false') return;
if (String(process.env.HUB_HEARTBEAT_ENABLED || 'true').toLowerCase() === 'false') return;
clearTimeout(soonTimer);
soonTimer = setTimeout(() => {
heartbeat().catch(e => console.warn('[hub] Heartbeat failed:', e?.message || e));
}, delayMs);
soonTimer.unref?.();
}
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon };
+11 -3
View File
@@ -7,7 +7,10 @@
* 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"
* unlicensed — not connected to the Hub (never, or the Hub released this install): read-only,
* like suspended, until a licence key is connected
*
* LICENCE_CHECK_DISABLED=true turns read-only off outside production (the end-to-end tests).
*/
const crypto = require('crypto');
@@ -15,7 +18,7 @@ const prisma = require('../config/db');
const { hubPublicKey } = require('./publicKey');
const DAY = 24 * 60 * 60 * 1000;
const READ_ONLY_STATES = new Set(['suspended', 'revoked']);
const READ_ONLY_STATES = new Set(['suspended', 'revoked', 'unlicensed']);
const CACHE_MS = 30 * 1000;
let publicKeyObject = null;
@@ -53,7 +56,7 @@ function verifyToken(token, key = publicKey()) {
*/
function stateFor(claims, installId, now = new Date()) {
if (!claims || (installId && claims.installId !== installId)) {
return { state: 'unlicensed', readOnly: false, endsAt: null, graceEndsAt: null, daysLeft: null };
return { state: 'unlicensed', readOnly: true, 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;
@@ -68,6 +71,10 @@ function stateFor(claims, installId, now = new Date()) {
return { state, readOnly: READ_ONLY_STATES.has(state), endsAt, graceEndsAt, daysLeft, expired: state === 'suspended' && claims.status === 'active' };
}
function checkDisabled() {
return process.env.NODE_ENV !== 'production' && String(process.env.LICENCE_CHECK_DISABLED || '').toLowerCase() === 'true';
}
let cache = null;
/** This install's licence: claims, state and Hub connection details. Cached for 30 s. */
@@ -82,6 +89,7 @@ async function licenceStatus({ fresh = false } = {}) {
installId: s.hub_install_id || null,
claims: claims && claims.installId === s.hub_install_id ? claims : null,
...stateFor(claims, s.hub_install_id || null),
...(checkDisabled() ? { readOnly: false } : {}),
lastHeartbeatAt: s.hub_last_heartbeat_at || null,
lastError: s.hub_last_error || null,
latestVersion: s.hub_latest_version || null,
+10 -4
View File
@@ -1,6 +1,7 @@
/**
* 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.
* Read-only mode while the licence is suspended, revoked or missing (not connected to the Hub):
* no new or edited events, and no *new* activity — 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
@@ -11,8 +12,11 @@ const { licenceStatus } = require('./licence');
const BLOCKED = [
['POST', /^\/api\/events\/?$/],
['PUT', /^\/api\/events\/[^/]+\/?$/],
['POST', /^\/api\/events\/[^/]+\/options\/?$/],
['PUT', /^\/api\/events\/options\/[^/]+\/?$/],
['POST', /^\/api\/events\/options\/[^/]+\/variants\/?$/],
['PUT', /^\/api\/events\/variants\/[^/]+\/?$/],
['POST', /^\/api\/registrations\/?$/],
['POST', /^\/api\/registrations\/manual\/?$/],
['PUT', /^\/api\/registrations\/[^/]+\/options\/?$/],
@@ -36,8 +40,10 @@ async function licenceReadOnly(req, res, 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.",
? "This site's licence has been revoked, so nothing new can be added and events can't be changed. Tickets still scan and all data can still be viewed and exported."
: status.state === 'unlicensed'
? "This site has no licence, so nothing new can be added and events can't be changed. A site admin can connect a licence key in Site settings → Licence."
: "This site's licence is suspended, so nothing new can be added and events can't be changed 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.
+4 -1
View File
@@ -162,7 +162,7 @@ app.use((req, res, next) => {
next();
});
// Licence suspended or revoked: no new events, registrations or payments (src/hub/readOnly.js).
// Licence suspended, revoked or missing: no new or edited 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.
@@ -1570,6 +1570,9 @@ app.listen(PORT, () => {
// backup, temp cleanup) run on the pg-boss job queue — see src/jobs/index.js.
require('./jobs').startJobs().catch(e => console.warn('[jobs] Not started:', e?.message || e));
// Check in with the Hub 30 s after starting, so the licence is fresh without waiting 15 minutes.
require('./hub/client').heartbeatSoon();
// Scheduled emails worker (polling)
try {
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
+6 -2
View File
@@ -33,8 +33,8 @@ describe('licence tokens', () => {
});
describe('licence states', () => {
test('never connected → unlicensed, not read-only', () => {
expect(stateFor(null, null, NOW)).toMatchObject({ state: 'unlicensed', readOnly: false });
test('never connected → unlicensed, read-only like suspended', () => {
expect(stateFor(null, null, NOW)).toMatchObject({ state: 'unlicensed', readOnly: true });
});
test('a token for another install counts as none', () => {
@@ -80,6 +80,9 @@ describe('heartbeat report', () => {
describe('read-only mode', () => {
test.each([
['POST', '/api/events'],
['PUT', '/api/events/ev_1'],
['PUT', '/api/events/options/opt_1'],
['PUT', '/api/events/variants/var_1'],
['POST', '/api/registrations'],
['POST', '/api/registrations/manual'],
['PUT', '/api/registrations/abc/options'],
@@ -92,6 +95,7 @@ describe('read-only mode', () => {
test.each([
['POST', '/api/tickets/scan/QR123'],
['GET', '/api/events'],
['PUT', '/api/events/ev_1/notify-recipients'],
['PUT', '/api/registrations/abc'],
['POST', '/api/payments/refund'],
['POST', '/api/privacy/me/requests'],
+3 -2
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-26, after **1.41.0** (and Hub 1.0.0). The plan itself is in
Last updated: 2026-10-27, after **1.41.1** (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.1 | Licence fixes — no licence (never connected, or the Hub answers `INSTALL_NOT_FOUND`, which now drops the old licence) is read-only like suspended; read-only also blocks editing events/options/variants; heartbeat 30 s after start and after connecting; `LICENCE_CHECK_DISABLED` for e2e |
| 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 |
@@ -165,7 +166,7 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
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).
"create" or event-edit route there). Read-only = suspended, revoked or unlicensed.
- 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).
+2 -2
View File
@@ -140,9 +140,9 @@ out its state:
|-------|------|--------------|
| **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 |
| **suspended** | `status` is suspended, or grace has run out | Read-only: events can't be created or edited; no new 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" |
| **unlicensed** | never connected, or the Hub answered `INSTALL_NOT_FOUND` (the install drops its token) | Same as suspended (events can't be created or edited either); 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.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.41.0",
"version": "1.41.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.41.0",
"version": "1.41.1",
"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.0",
"version": "1.41.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
+1 -1
View File
@@ -601,7 +601,7 @@ export default function SetupPage() {
<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>
<p className="text-xs text-gray-500">Enter the licence key you received to connect this site to the Hope Events Hub. Until a licence is connected, events can&apos;t be created or edited. You can also do this later under Admin → Site Settings → Licence.</p>
<ConnectForm token={setupToken} compact onConnected={s => setLicenceConnected(s.licence?.customer || "your organisation")} />
</>
)}
+4 -4
View File
@@ -26,9 +26,9 @@ export type HubStatus = {
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." },
suspended: { label: "Suspended", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only: events can't be created or edited, and no new 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: events can't be created or edited, and no new registrations or payments. Tickets still scan and all data can be exported." },
unlicensed: { label: "No licence", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only until you connect a licence key: events can't be created or edited, and no new registrations or payments." },
};
const fmt = (d: string | null) => (d ? new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—");
@@ -100,7 +100,7 @@ export function LicenceTab({ active }: { active: boolean }) {
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-gray-900">Licence</h2>
<p className="text-sm text-gray-500">This site checks in with the Hope Events Hub every 15 minutes: version, health and totals only — never anyone&apos;s personal information.</p>
<p className="text-sm text-gray-500">This site checks in with the Hope Events Hub 30 seconds after starting and every 15 minutes after that: version, health and totals only — never anyone&apos;s personal information.</p>
</div>
<div className={`border rounded-xl p-4 ${st.tone}`}>
@@ -37,16 +37,16 @@ export function LicenceBanner() {
<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}
The licence is {status.state === "revoked" ? "revoked" : "suspended"}: the site is read-only — events can&apos;t be created or edited, and no new registrations or payments. Tickets still scan and all data can be exported. {link}
</span>
</div>
);
}
if (status.state === "unlicensed" && status.keyConfigured) {
if (status.state === "unlicensed" && status.readOnly) {
return (
<div role="status" className="bg-gray-50 border-b text-gray-700 text-sm px-4 py-2 flex items-center gap-2">
<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">
<KeyRound className="w-4 h-4 shrink-0" />
<span>This site isn&apos;t connected to the Hope Events Hub yet. {link}</span>
<span>This site has no licence, so it&apos;s read-only — events can&apos;t be created or edited. Connect your licence key in {link}</span>
</div>
);
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.41.0",
"version": "1.41.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.41.0",
"version": "1.41.1",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.41.0",
"version": "1.41.1",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",
+2
View File
@@ -46,6 +46,8 @@ export default defineConfig({
FRONTEND_URL: `http://localhost:${WEB_PORT}`,
APP_BASE_URL: `http://localhost:${WEB_PORT}`,
JOBS_ENABLED: "false",
// The test site has no licence; without this it would be read-only.
LICENCE_CHECK_DISABLED: "true",
SCHEDULED_EMAILS_ENABLED: "false",
// The kiosk test signs a supervisor in with a password (no 2FA code by email).
TWO_FACTOR_REQUIRED_ROLES: "false",