diff --git a/client/src/routes/admin/views/PostEditor.jsx b/client/src/routes/admin/views/PostEditor.jsx
index fb7e68c..61b90b5 100644
--- a/client/src/routes/admin/views/PostEditor.jsx
+++ b/client/src/routes/admin/views/PostEditor.jsx
@@ -177,14 +177,15 @@ const delStyle = {
}
// ── Announcement status panel ────────────────────────────────────────────────
-// Shows the town-crier + Discord delivery state for a published news post and
-// offers a per-leg retry (useful after fixing the sidecar / news channel without
-// re-publishing). Only rendered for news posts in edit mode; renders nothing
-// until the post has actually been announced (no job row yet → nothing to show).
-const LEG_META = {
- towncrier: { label: 'In-game town crier' },
- discord: { label: 'Discord #news' },
-}
+// Shows each delivery leg's state for a published news post and offers a per-leg
+// retry (useful after fixing the sidecar / news channel without re-publishing).
+// Only rendered for news posts in edit mode; renders nothing until the post has
+// actually been announced (no job row yet → nothing to show).
+//
+// The legs and their labels come from the JOB, not from a constant here: which
+// legs exist is decided by what the server has registered, so an installed module
+// brings its own leg and this panel renders it with no client change
+// (docs/website/MODULE_SYSTEM.md §1.8).
const STATUS_STYLE = {
done: { color: '#7bbf8f', label: 'delivered' },
pending: { color: '#d9b84a', label: 'pending' },
@@ -227,14 +228,12 @@ function AnnouncePanel({ postId }) {
return (
Announcement
- {['towncrier', 'discord'].map((leg) => {
- const status = job[`${leg}_status`]
- const err = job[`${leg}_last_error`]
+ {(job.legs || []).map(({ leg, label, status, last_error: err }) => {
const s = STATUS_STYLE[status] || STATUS_STYLE.pending
return (
-
{LEG_META[leg].label}
+
{label}
● {s.label}
{status !== 'done' && (
0,
+ 'INSERT IGNORE INTO announce_job_legs (job_id, leg, status, attempts, last_error, next_attempt_at)
+ SELECT id, ''towncrier'', towncrier_status, towncrier_attempts, towncrier_last_error, towncrier_next_attempt_at FROM announce_jobs
+ UNION ALL
+ SELECT id, ''discord'', discord_status, discord_attempts, discord_last_error, discord_next_attempt_at FROM announce_jobs',
+ 'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- MariaDB's IF EXISTS makes this idempotent, so it replays cleanly like the rest
+-- of the file. It is the one DROP in core's schema, and it is deliberate: leaving
+-- the columns would leave `towncrier` in a core file, which Phase 3's acceptance
+-- grep forbids (MODULE_SYSTEM.md §2.7).
+ALTER TABLE announce_jobs
+ DROP COLUMN IF EXISTS towncrier_status,
+ DROP COLUMN IF EXISTS towncrier_attempts,
+ DROP COLUMN IF EXISTS towncrier_last_error,
+ DROP COLUMN IF EXISTS towncrier_next_attempt_at,
+ DROP COLUMN IF EXISTS discord_status,
+ DROP COLUMN IF EXISTS discord_attempts,
+ DROP COLUMN IF EXISTS discord_last_error,
+ DROP COLUMN IF EXISTS discord_next_attempt_at,
+ DROP INDEX IF EXISTS idx_announce_due,
+ DROP INDEX IF EXISTS idx_announce_due_discord;
+
-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
-- Static shard CONTENT, not live shard state: what spawns where, which regions
-- and landmarks exist, and which champion altars are configured. Nothing here
diff --git a/server/routes.guards.json b/server/routes.guards.json
index 3b32255..effc46b 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -1062,7 +1062,7 @@
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id/shard/link/:account",
- "handlers": 5,
+ "handlers": 4,
"gates": [
"noindex",
"requireAuth",
diff --git a/server/scripts/routeManifest.js b/server/scripts/routeManifest.js
index 19522cb..cdc958a 100644
--- a/server/scripts/routeManifest.js
+++ b/server/scripts/routeManifest.js
@@ -64,9 +64,16 @@ const PUBLIC_PREFIXES = ['/api/', '/.well-known/']
*
* Express keeps no copy of the mount string, only the compiled regexp. For a
* literal mount (`/api/v1`) that is `^\/api\/v1\/?(?=\/|$)`; a parameterised mount
- * contributes one `(?:([^\/]+?))` group per entry in `layer.keys`. Unwinding both
- * gets us back to `/api/v1` and `/thing/:id` respectively. `fast_slash` is
- * express's marker for a router mounted at the root, which contributes nothing.
+ * contributes one group per entry in `layer.keys`, and the separator before the
+ * parameter lives INSIDE that group — express 4.22 compiles `use('/:id', r)` to
+ * `^(?:\/([^/]+?))\/?(?=\/|$)`. Unwinding both gets us back to `/api/v1` and
+ * `/:id` respectively. `fast_slash` is express's marker for a router mounted at
+ * the root, which contributes nothing.
+ *
+ * The parameterised branch went unexercised until the `admin.users.detail`
+ * extension slot mounted a router at `/:id` (MODULE_SYSTEM.md §1.9), and it was
+ * wrong: it expected the group as `(?:([^\/]+?))`, with the slash outside and the
+ * class escaped. It threw rather than guessing, which is exactly what it is for.
*/
function mountPath(layer) {
const re = layer.regexp
@@ -79,9 +86,11 @@ function mountPath(layer) {
const keys = layer.keys || []
let i = 0
- src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => {
+ // `\/` optional and the `/` in the class optionally escaped, so this survives a
+ // path-to-regexp that emits either shape.
+ src = src.replace(/\((?:\?:)?(\\\/)?\(\[\^\\?\/\]\+\?\)\)/g, (_m, slash) => {
const key = keys[i++]
- return key ? `:${key.name}` : ':param'
+ return `${slash ? '/' : ''}:${key ? key.name : 'param'}`
})
// Whatever is left should be a literal path with regexp-escaped separators.
diff --git a/server/src/app.js b/server/src/app.js
index 0f21a1e..9d79377 100644
--- a/server/src/app.js
+++ b/server/src/app.js
@@ -11,6 +11,7 @@ const swaggerUi = require('swagger-ui-express')
const apiRouter = require('./router/api.router')
const modules = require('./modules/loader')
+const registries = require('./modules/registries')
const wellKnown = require('./router/wellKnown.controller')
const cspReport = require('./router/cspReport.controller')
const brand = require('./config/brand')
@@ -171,6 +172,12 @@ app.use('/api', apiRouter)
//
// The three requires resolve from cache to the very routers v1.router.js
// mounted; this is a reference to them, not a second copy.
+//
+// registerCore() first, and for the same reason the loader runs after `/api`: a
+// module's collision checks are asked against what is ALREADY registered, so
+// core's streams, its announce leg and its extension-slot fill have to be there
+// before the first module registers anything (MODULE_SYSTEM.md §1.8).
+registries.registerCore()
modules.load({
public: require('./router/v1/public'),
admin: require('./router/v1/admin'),
diff --git a/server/src/config/coreStreams.js b/server/src/config/coreStreams.js
new file mode 100644
index 0000000..5c839e1
--- /dev/null
+++ b/server/src/config/coreStreams.js
@@ -0,0 +1,26 @@
+// ── Core's own push-notification streams ───────────────────────────────────
+//
+// What is left of config/notificationStreams.js once the shard-derived catalog
+// moved to config/shardStreams.js (MODULE_SYSTEM.md §1.8: push INFRASTRUCTURE is
+// core, the CATALOG is content). Exactly one stream is core's: `news.post` is
+// produced by the website's own posts path, not by any game feed.
+//
+// Registered through modules/registries.js like any module's, and read back
+// through it — nothing imports this file to get "the catalog", because the
+// catalog is core's plus every module's.
+//
+// The payload that ever leaves the server is a CONTENT-FREE tickle
+// ({ stream, ref }); the app wakes and PULLS the real, ownership-checked content
+// over the authenticated API (docs/android/PLAN.md §11).
+
+const STREAMS = [
+ {
+ id: 'news.post',
+ label: 'News posts',
+ description: 'New news / Five-on-Friday / newsletter posts.',
+ personal: false,
+ requiresLinkedAccount: false,
+ },
+]
+
+module.exports = { STREAMS }
diff --git a/server/src/config/notificationStreams.js b/server/src/config/shardStreams.js
similarity index 81%
rename from server/src/config/notificationStreams.js
rename to server/src/config/shardStreams.js
index c0c4784..35add2d 100644
--- a/server/src/config/notificationStreams.js
+++ b/server/src/config/shardStreams.js
@@ -1,7 +1,14 @@
-// ── Push-notification stream catalog + event → stream mapping ───────────────
+// ── Shard-derived push streams + event → stream mapping ────────────────────
//
-// The single source of truth for which streams a user can subscribe to, and how
-// a shard event maps onto them. Two families:
+// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8 named
+// config/notificationStreams.js as one of the three genuinely entangled files:
+// most of its catalog and all of `mapShardEvent` are shard-derived, and it reads
+// `PUBLIC_KINDS` out of utils/shardBroadcast. PR 4 split it — core's one stream
+// is config/coreStreams.js, and everything shard-shaped is here, in a file that
+// moves to module-uo whole in Phase 3. Nothing in core imports it except
+// modules/registries.js's registerCore(), which is the one line Phase 3 deletes.
+//
+// Two families:
// • public / opt-in — no linked game account required; delivered to every
// subscriber. Drawn ONLY from the SSE public allowlist
// (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind
@@ -17,17 +24,7 @@
const { PUBLIC_KINDS } = require('../utils/shardBroadcast')
-// The subscribable catalog. `news.post` is produced by the website's own posts
-// path (not the shard feed) — see utils/pushDispatch — so it has no mapShardEvent
-// case; every other stream is shard-derived below.
const STREAMS = [
- {
- id: 'news.post',
- label: 'News posts',
- description: 'New news / Five-on-Friday / newsletter posts.',
- personal: false,
- requiresLinkedAccount: false,
- },
{
id: 'server.status',
label: 'Server up / down',
@@ -79,8 +76,10 @@ const STREAMS = [
},
]
-const STREAM_IDS = new Set(STREAMS.map((s) => s.id))
-const isValidStream = (id) => STREAM_IDS.has(id)
+// The owner-keyed subset, needed by mapShardEvent's public-safety filter below.
+// Derived from this file's own catalog rather than read back out of the registry:
+// the filter is about THESE streams, and a module must not be able to weaken it
+// by registering something that happens to share an id.
const PERSONAL_STREAMS = new Set(STREAMS.filter((s) => s.personal).map((s) => s.id))
// Per-process transition state so full-state upserts (champ.update / city.update
@@ -162,7 +161,12 @@ function mapShardEvent(event, tracker = defaultTracker) {
// they are exempt from the public allowlist (that is the whole point of the
// owner-keyed split). This guarantees a sensitive kind can never leak publicly
// even if a future mapping case is added carelessly.
+ //
+ // This filter, the kinds it reads and the streams it protects now all live in
+ // one file and move together — the reason PR 4 dropped the contract's
+ // `mapEvent` half rather than leaving the mapping in core and the catalog in a
+ // module (MODULE_API.md §2.4).
return out.filter((t) => (PERSONAL_STREAMS.has(t.streamId) ? true : PUBLIC_KINDS.has(kind)))
}
-module.exports = { STREAMS, isValidStream, mapShardEvent, createTracker, PERSONAL_STREAMS }
+module.exports = { STREAMS, mapShardEvent, createTracker, PERSONAL_STREAMS }
diff --git a/server/src/model/announceJobs/announceJobs.db.js b/server/src/model/announceJobs/announceJobs.db.js
index ab7f0f2..7c51a20 100644
--- a/server/src/model/announceJobs/announceJobs.db.js
+++ b/server/src/model/announceJobs/announceJobs.db.js
@@ -1,26 +1,68 @@
+// ── Announcement pipeline: SQL ─────────────────────────────────────────────
+//
+// Two tables since PR 4 (MODULE_SYSTEM.md §1.8): `announce_jobs` is one row per
+// publish event, `announce_job_legs` one row per delivery leg of that job. The
+// leg set is registered rather than fixed, so a leg is a stored VALUE now instead
+// of a group of leg-prefixed columns — which is what lets a module bring its own
+// leg without altering a core table.
+//
+// Every read returns the job with a `legs` array attached, so a caller never has
+// to remember to fetch the second table.
+
const { query } = require('../../utils/db')
-const COLS =
- 'id, post_id, status, ' +
- 'towncrier_status, towncrier_attempts, towncrier_last_error, towncrier_next_attempt_at, ' +
- 'discord_status, discord_attempts, discord_last_error, discord_next_attempt_at, ' +
- 'created_at, updated_at'
+const COLS = 'id, post_id, status, created_at, updated_at'
+const LEG_COLS = 'job_id, leg, status, attempts, last_error, next_attempt_at'
-// Whitelist so a `leg` value can be interpolated into a column name safely — it
-// never comes from raw user input, but keep the guard explicit.
-const LEGS = ['towncrier', 'discord']
-function assertLeg(leg) {
- if (!LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`)
+async function legsFor(jobIds) {
+ if (jobIds.length === 0) return new Map()
+ const marks = jobIds.map(() => '?').join(', ')
+ const rows = await query(
+ `SELECT ${LEG_COLS} FROM announce_job_legs WHERE job_id IN (${marks}) ORDER BY job_id, leg`,
+ jobIds,
+ )
+ const byJob = new Map(jobIds.map((id) => [id, []]))
+ for (const row of rows) byJob.get(row.job_id).push(row)
+ return byJob
}
-async function create(postId) {
+async function attachLegs(jobs) {
+ const byJob = await legsFor(jobs.map((j) => j.id))
+ for (const job of jobs) job.legs = byJob.get(job.id) || []
+ return jobs
+}
+
+// Create a job and its leg rows in one go. `legs` is the registered leg id list —
+// an empty list is legal and yields a job with nothing to deliver.
+async function create(postId, legs = []) {
const res = await query('INSERT INTO announce_jobs (post_id) VALUES (?)', [postId])
- return res.insertId
+ const jobId = Number(res.insertId)
+ if (legs.length > 0) {
+ const values = legs.map(() => '(?, ?)').join(', ')
+ await query(
+ `INSERT INTO announce_job_legs (job_id, leg) VALUES ${values}`,
+ legs.flatMap((leg) => [jobId, leg]),
+ )
+ }
+ return jobId
+}
+
+// Add any registered legs this job is missing. A job enqueued before a module was
+// installed has no row for that module's leg, and without this it could never
+// deliver one — the worker only ever sees rows that exist.
+async function ensureLegs(jobId, legs = []) {
+ if (legs.length === 0) return
+ const values = legs.map(() => '(?, ?)').join(', ')
+ await query(
+ `INSERT IGNORE INTO announce_job_legs (job_id, leg) VALUES ${values}`,
+ legs.flatMap((leg) => [jobId, leg]),
+ )
}
async function findById(id) {
const rows = await query(`SELECT ${COLS} FROM announce_jobs WHERE id = ? LIMIT 1`, [id])
- return rows[0] || null
+ if (rows.length === 0) return null
+ return (await attachLegs(rows))[0]
}
async function findByPostId(postId) {
@@ -28,36 +70,38 @@ async function findByPostId(postId) {
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
[postId],
)
- return rows[0] || null
+ if (rows.length === 0) return null
+ return (await attachLegs(rows))[0]
}
// Jobs with at least one leg that is due now: pending and either never scheduled
-// (next_attempt_at IS NULL — a fresh enqueue) or past its backoff time.
+// (next_attempt_at IS NULL — a fresh enqueue) or past its backoff time. Returns
+// whole jobs with every leg attached; the worker decides which legs to run, so
+// this stays one query regardless of how many legs are registered.
async function findDue(now = new Date(), limit = 25) {
- return query(
- `SELECT ${COLS} FROM announce_jobs
- WHERE (towncrier_status = 'pending'
- AND (towncrier_next_attempt_at IS NULL OR towncrier_next_attempt_at <= ?))
- OR (discord_status = 'pending'
- AND (discord_next_attempt_at IS NULL OR discord_next_attempt_at <= ?))
- ORDER BY id ASC
+ const rows = await query(
+ `SELECT ${COLS} FROM announce_jobs j
+ WHERE EXISTS (
+ SELECT 1 FROM announce_job_legs l
+ WHERE l.job_id = j.id
+ AND l.status = 'pending'
+ AND (l.next_attempt_at IS NULL OR l.next_attempt_at <= ?))
+ ORDER BY j.id ASC
LIMIT ?`,
- [now, now, limit],
+ [now, limit],
)
+ return attachLegs(rows)
}
-// Update one leg's columns. `fields` uses leg-agnostic keys (status, attempts,
-// lastError, nextAttemptAt); we map them onto the leg-prefixed columns.
-async function updateLeg(id, leg, { status, attempts, lastError, nextAttemptAt }) {
- assertLeg(leg)
+// Update one leg's row. `leg` is a bound VALUE, not an interpolated column name —
+// the reason the old leg allowlist that guarded that interpolation is gone. A
+// module's leg id could not have passed it anyway.
+async function updateLeg(jobId, leg, { status, attempts, lastError, nextAttemptAt }) {
await query(
- `UPDATE announce_jobs SET
- ${leg}_status = ?,
- ${leg}_attempts = ?,
- ${leg}_last_error = ?,
- ${leg}_next_attempt_at = ?
- WHERE id = ?`,
- [status, attempts, lastError ?? null, nextAttemptAt ?? null, id],
+ `UPDATE announce_job_legs SET
+ status = ?, attempts = ?, last_error = ?, next_attempt_at = ?
+ WHERE job_id = ? AND leg = ?`,
+ [status, attempts, lastError ?? null, nextAttemptAt ?? null, jobId, leg],
)
}
@@ -65,4 +109,4 @@ async function setStatus(id, status) {
await query('UPDATE announce_jobs SET status = ? WHERE id = ?', [status, id])
}
-module.exports = { LEGS, create, findById, findByPostId, findDue, updateLeg, setStatus }
+module.exports = { create, ensureLegs, findById, findByPostId, findDue, updateLeg, setStatus }
diff --git a/server/src/model/announceJobs/announceJobs.logic.js b/server/src/model/announceJobs/announceJobs.logic.js
index b1d0f64..ee79eb2 100644
--- a/server/src/model/announceJobs/announceJobs.logic.js
+++ b/server/src/model/announceJobs/announceJobs.logic.js
@@ -1,87 +1,38 @@
// ── Announcement pipeline: pure logic ──────────────────────────────────────
//
-// No DB, no network — just the decisions the worker and model make, kept here so
-// they are unit-testable in isolation (server/test/announceJobs.test.js):
-// • buildTownCrierText — turn a post into sidecar-safe town-crier lines
-// • classifyTownCrier / classifyDiscord — map a dispatch result to done / retry
-// / terminal, so a data problem fails fast and a transient outage retries
+// No DB, no network — just the LEG-AGNOSTIC decisions the worker and model make,
+// kept here so they are unit-testable in isolation (server/test/announceJobs.test.js):
// • scheduleAfter — exponential backoff schedule + the attempt cap
-// • rollupStatus — derive the parent job status from the two legs
-
-const { deriveExcerpt } = require('../../utils/sanitizeHtml')
-
-// Sidecar town-crier caps, mirrored from the admin route validation
-// (admin/uoLink.router.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })).
-// We pre-truncate to these so a published post never bounces with towncrier.error.
-const MAX_LINES = 8
-const MAX_LINE_LEN = 200
+// • rollupStatus — derive the parent job status from its legs
+// • legError — squeeze a client result into one error line
+// • baseUrl / articleUrl — the public link an announcement carries
+//
+// What used to be here and is not any more: `buildTownCrierText`,
+// `classifyTownCrier` and `classifyDiscord`. A leg's own text-building and result
+// classification belong to the leg, and a leg is a registration now
+// (MODULE_SYSTEM.md §1.8) — they live in utils/shardAnnounce.js and
+// utils/discordAnnounce.js. This file is what every leg shares.
// Backoff between retries, indexed by attempts-so-far. Six attempts spread over
// ~a couple of hours; after the last one a leg is marked failed and surfaced in
-// the post's admin panel. Shared by both legs.
+// the post's admin panel. Shared by every leg.
const BACKOFF_MS = [30_000, 120_000, 600_000, 1_800_000, 3_600_000, 7_200_000]
const MAX_ATTEMPTS = BACKOFF_MS.length
-// Trim to a hard length, appending an ellipsis only when something was cut.
-function clamp(value, max) {
- const s = String(value == null ? '' : value)
- .replace(/\s+/g, ' ')
- .trim()
- if (s.length <= max) return s
- return `${s.slice(0, max - 1).trimEnd()}…`
+// The site's public base, used to build the link an announcement carries.
+function baseUrl() {
+ return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
}
// The public link that goes in the announcement. News has no per-post route
// (App.jsx only has the /site/news list), so we link the list — matches the
// pre-pipeline Discord announce behavior.
-function articleUrl(baseUrl) {
- return `${String(baseUrl || '').replace(/\/+$/, '')}/site/news`
-}
-
-// Build the town-crier lines: title, a one-line excerpt, then the URL. Each line
-// is clamped to the sidecar's per-line cap and the whole thing to the line-count
-// cap. Falls back to a stripped body excerpt when the post has no excerpt.
-function buildTownCrierText(post, { baseUrl } = {}) {
- const title = clamp(post.title, MAX_LINE_LEN)
- const excerptSource = post.excerpt || deriveExcerpt(post.body, MAX_LINE_LEN) || ''
- const lines = [title]
- const excerpt = clamp(excerptSource, MAX_LINE_LEN)
- if (excerpt) lines.push(excerpt)
- const url = clamp(articleUrl(baseUrl), MAX_LINE_LEN)
- if (url) lines.push(url)
- return lines.filter(Boolean).slice(0, MAX_LINES)
-}
-
-// ── Result classification ──────────────────────────────────────────────────
-// Both clients return { ok, status, error }. Map that to one of:
-// done — delivered, mark the leg done
-// retry — transient (shard restarting, bot down, network); back off + retry
-// terminal — will never succeed as-is (over caps, bad auth/config); fail now
-
-function classifyTownCrier(result) {
- if (result && result.ok) return { outcome: 'done' }
- const status = result ? result.status : 0
- // 400 = over the line/duration caps (a data problem — do NOT retry).
- // 401 = token mismatch, 409 = protocol mismatch (both config problems).
- if (status === 400 || status === 401 || status === 409) {
- return { outcome: 'terminal', error: legError(result) }
- }
- // 503 (shard not connected), 504 (shard timeout), 0 (network/timeout / not
- // configured yet), and any other 5xx are transient — retry.
- return { outcome: 'retry', error: legError(result) }
-}
-
-function classifyDiscord(result) {
- if (result && result.ok) return { outcome: 'done' }
- // The bot's /internal/announce collapses failures (503 = not connected,
- // 400 = no news channel configured) without surfacing Discord's own
- // retry_after, so there is no reliable terminal signal to key on here. Retry
- // every failure on the shared backoff; a genuine config problem simply
- // exhausts its attempts and lands as `failed` in the admin panel, where the
- // per-leg retry button re-runs it after the channel is set.
- return { outcome: 'retry', error: legError(result) }
+function articleUrl(base) {
+ return `${String(base || '').replace(/\/+$/, '')}/site/news`
}
+// Every leg's client returns { ok, status, data, error }. Squeeze a failure into
+// the one line stored in announce_job_legs.last_error and shown in the panel.
function legError(result) {
if (!result) return 'no response'
if (result.status) {
@@ -99,29 +50,32 @@ function scheduleAfter(attempts) {
return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)]
}
-// Parent job status derived from the two leg statuses:
-// done — both legs delivered
-// failed — both legs gave up
-// partial — at least one leg reached a terminal state while the other has not
-// matched it (still pending/retrying, or the opposite terminal state)
-// pending — neither leg is terminal yet
-function rollupStatus(towncrierStatus, discordStatus) {
- if (towncrierStatus === 'done' && discordStatus === 'done') return 'done'
- if (towncrierStatus === 'failed' && discordStatus === 'failed') return 'failed'
+// Parent job status derived from its leg statuses:
+// done — every leg delivered
+// failed — every leg gave up
+// partial — at least one leg reached a terminal state without all of them
+// agreeing (some still pending/retrying, or a mix of done and failed)
+// pending — no leg is terminal yet
+//
+// Takes the list of leg statuses rather than two named arguments, because the leg
+// set is registered rather than fixed (MODULE_SYSTEM.md §1.8). No legs at all
+// rolls up `done`: with nothing registered there is nothing left to deliver, and
+// leaving such jobs `pending` would pile up rows the worker never touches.
+function rollupStatus(statuses) {
+ const list = Array.isArray(statuses) ? statuses : []
const terminal = (s) => s === 'done' || s === 'failed'
- if (terminal(towncrierStatus) || terminal(discordStatus)) return 'partial'
+ if (list.every((s) => s === 'done')) return 'done'
+ if (list.every((s) => s === 'failed')) return 'failed'
+ if (list.some(terminal)) return 'partial'
return 'pending'
}
module.exports = {
- MAX_LINES,
- MAX_LINE_LEN,
MAX_ATTEMPTS,
BACKOFF_MS,
- buildTownCrierText,
+ baseUrl,
articleUrl,
- classifyTownCrier,
- classifyDiscord,
+ legError,
scheduleAfter,
rollupStatus,
}
diff --git a/server/src/model/announceJobs/announceJobs.model.js b/server/src/model/announceJobs/announceJobs.model.js
index 635c57a..a82ffcd 100644
--- a/server/src/model/announceJobs/announceJobs.model.js
+++ b/server/src/model/announceJobs/announceJobs.model.js
@@ -2,19 +2,21 @@
//
// Sits between the DB rows and the worker: creates jobs on publish, records each
// leg's outcome, keeps the parent `status` rollup in sync, stamps the post's
-// announced_at when both legs land, and resets a leg for the admin retry button.
-// The pure decisions (backoff, rollup, classification) live in .logic.js.
+// announced_at when every leg lands, and resets a leg for the admin retry button.
+// The pure decisions (backoff, rollup) live in .logic.js; which legs exist at all
+// is modules/registries.js's answer, not this file's (MODULE_SYSTEM.md §1.8).
const db = require('./announceJobs.db')
const logic = require('./announceJobs.logic')
+const registries = require('../../modules/registries')
const posts = require('../posts/posts.model')
const log = require('../../utils/logger')('announce')
-// Enqueue an announcement for a freshly-published news post: one job row (both
-// legs pending, due immediately) plus a back-pointer on the post so the admin
-// panel can find it. Returns the new job id.
+// Enqueue an announcement for a freshly-published news post: one job row, one leg
+// row per registered leg (all pending, due immediately), plus a back-pointer on
+// the post so the admin panel can find it. Returns the new job id.
async function enqueue(postId) {
- const jobId = await db.create(postId)
+ const jobId = await db.create(postId, registries.announceLegIds())
await posts.linkAnnounceJob(postId, jobId)
log.info('announce job enqueued', { jobId, postId })
return jobId
@@ -43,12 +45,13 @@ async function enqueueIfNeeded(post, transition) {
}
}
-// Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of
-// logic.classify*'s results: 'done' | 'retry' | 'terminal'. For 'retry' we bump
-// the attempt count and schedule the next run (or fail the leg once the cap is
-// hit). Returns the updated job row.
+// Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of a
+// leg's classify() results: 'done' | 'retry' | 'terminal'. For 'retry' we bump the
+// attempt count and schedule the next run (or fail the leg once the cap is hit).
+// Returns the updated job row.
async function recordOutcome(job, leg, { outcome, error }) {
- const attempts = Number(job[`${leg}_attempts`]) || 0
+ const row = (job.legs || []).find((l) => l.leg === leg)
+ const attempts = Number(row && row.attempts) || 0
if (outcome === 'done') {
await db.updateLeg(job.id, leg, { status: 'done', attempts, lastError: null, nextAttemptAt: null })
@@ -71,12 +74,12 @@ async function recordOutcome(job, leg, { outcome, error }) {
return refreshStatus(job.id)
}
-// Recompute and persist the parent status from the two legs; stamp the post's
-// announced_at the moment both legs have delivered.
+// Recompute and persist the parent status from the legs; stamp the post's
+// announced_at the moment every leg has delivered.
async function refreshStatus(jobId) {
const job = await db.findById(jobId)
if (!job) return null
- const status = logic.rollupStatus(job.towncrier_status, job.discord_status)
+ const status = logic.rollupStatus(job.legs.map((l) => l.status))
if (status !== job.status) await db.setStatus(jobId, status)
job.status = status
if (status === 'done') {
@@ -93,16 +96,33 @@ async function refreshStatus(jobId) {
// the worker pick it up on the next tick. Resets the attempt count so a retry
// after a config fix gets a full budget again.
async function resetLeg(postId, leg) {
- if (!db.LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`)
+ if (!registries.announceLeg(leg)) throw new Error(`unknown announce leg: ${leg}`)
const job = await db.findByPostId(postId)
if (!job) return null
+ // A job enqueued before this leg was registered has no row for it; create it so
+ // the retry button works on an existing post after a module is installed.
+ await db.ensureLegs(job.id, [leg])
await db.updateLeg(job.id, leg, { status: 'pending', attempts: 0, lastError: null, nextAttemptAt: null })
log.info('announce leg reset for retry', { jobId: job.id, postId, leg })
- return refreshStatus(job.id)
+ // Labelled, because this is the response body the admin panel re-renders from.
+ return withLabels(await refreshStatus(job.id))
+}
+
+// Decorate a job's legs with the label their registration carries, so the admin
+// panel renders a module's leg with a real name and no client change
+// (MODULE_SYSTEM.md §1.8). An unregistered leg — a stale row from a module that
+// was since removed — keeps its id as the label rather than disappearing.
+function withLabels(job) {
+ if (!job) return job
+ job.legs = (job.legs || []).map((l) => {
+ const registered = registries.announceLeg(l.leg)
+ return { ...l, label: registered ? registered.label : l.leg }
+ })
+ return job
}
async function getByPostId(postId) {
- return db.findByPostId(postId)
+ return withLabels(await db.findByPostId(postId))
}
module.exports = {
@@ -113,4 +133,5 @@ module.exports = {
refreshStatus,
resetLeg,
getByPostId,
+ withLabels,
}
diff --git a/server/src/model/notificationSubs/notificationSubs.model.js b/server/src/model/notificationSubs/notificationSubs.model.js
index cff2872..9311954 100644
--- a/server/src/model/notificationSubs/notificationSubs.model.js
+++ b/server/src/model/notificationSubs/notificationSubs.model.js
@@ -1,8 +1,10 @@
// Per-user push-notification subscriptions (which streams a user opted into;
-// applied to every device they register). The catalog is config/notificationStreams.
+// applied to every device they register). The catalog is core's plus every
+// installed module's, so it is read back through modules/registries rather than
+// from a config file (MODULE_SYSTEM.md §1.8).
const db = require('./notificationSubs.db')
-const { isValidStream } = require('../../config/notificationStreams')
+const { isValidStream } = require('../../modules/registries')
const getForUser = async (userId) => (await db.listByUser(userId)).map((r) => r.stream_id)
diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js
index 6b1d958..d223959 100644
--- a/server/src/modules/loader.js
+++ b/server/src/modules/loader.js
@@ -33,6 +33,7 @@ const path = require('path')
const { MODULE_API_VERSION } = require('./version')
const semver = require('./semver')
+const registries = require('./registries')
const { splitStatements } = require('../utils/sqlStatements')
const log = require('../utils/logger')('modules')
@@ -51,10 +52,10 @@ const MANIFEST_KEYS = new Set([
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
])
-// Extension slots core declares (§2.4). Only core may declare one; a module may
-// only fill one. Validation rejects a manifest naming a slot that does not
-// exist — `registerExtension` itself arrives with PR 4.
-const CORE_SLOTS = new Set(['admin.users.detail'])
+// Extension slots are declared by core, at require time, in the router that owns
+// the resource (registries.declareSlot). The loader asks the registry which exist
+// rather than keeping a list, for the same reason the prefix check probes the
+// live tier routers: a second copy of the answer is a copy that drifts.
// id → record. Populated by load(), read by list().
const modules = new Map()
@@ -144,9 +145,9 @@ function buildApi(record) {
if (record.called.has(name)) throw new Error(`${name}() called twice`)
record.called.add(name)
}
- // PR 4 brings the three de-entanglement registries and PR 5 the boot hooks.
- // They throw rather than no-op: an accepting stub would let a module believe
- // it had registered something and fail silently at the far end.
+ // PR 5 brings the boot hooks. They throw rather than no-op: an accepting stub
+ // would let a module believe it had registered something and fail silently at
+ // the far end.
const notYet = (name, pr) => () => {
throw new Error(`${name}: not available until phase 2 PR ${pr}`)
}
@@ -163,9 +164,22 @@ function buildApi(record) {
}
}
},
- registerExtension: notYet('registerExtension', 4),
- registerNotificationStreams: notYet('registerNotificationStreams', 4),
- registerAnnounceLeg: notYet('registerAnnounceLeg', 4),
+ // The three de-entanglement registries (§2.4). They live in registries.js
+ // rather than here because core registers through the same staging area, and
+ // core has no `api` object.
+ //
+ // These STAGE. Nothing a module registers is visible to core until the
+ // second pass commits it, for the reason the second pass exists at all: a
+ // module that throws halfway through register(), or fails checkDeclared
+ // after it, must leave nothing behind. A half-registered stream catalog
+ // would be worse than a missing one — it would be a subscribable stream
+ // nothing will ever publish to.
+ registerExtension: record.staged.registerExtension,
+ registerNotificationStreams(streams) {
+ once('registerNotificationStreams')
+ record.staged.registerNotificationStreams(streams)
+ },
+ registerAnnounceLeg: record.staged.registerAnnounceLeg,
onBoot: notYet('onBoot', 5),
onShutdown: notYet('onShutdown', 5),
}
@@ -317,7 +331,7 @@ function readManifest(dir, id, tierRouters) {
}
for (const slot of manifest.extensions || []) {
- if (!CORE_SLOTS.has(slot)) throw new Error(`unknown extension slot "${slot}"`)
+ if (!registries.hasSlot(slot)) throw new Error(`unknown extension slot "${slot}"`)
}
if (manifest.schema && !manifest.purge) {
@@ -392,6 +406,7 @@ function load(tierRouters) {
dir,
manifest: null,
routes: { public: new Map(), admin: new Map(), player: new Map() },
+ staged: registries.stage(id),
tables: new Set(),
called: new Set(),
state: 'installed',
@@ -433,7 +448,20 @@ function load(tierRouters) {
// prefix would be told it collided with core, naming the wrong culprit, and
// the module-versus-module check below it could never be reached.
for (const record of modules.values()) {
- if (record.state === 'registered') mount(record, tierRouters)
+ if (record.state !== 'registered') continue
+ try {
+ // Commit what this module staged. Collisions with core or with an earlier
+ // module surface here, in scan order, and cost only this module.
+ registries.apply(record.staged.staged)
+ } catch (err) {
+ record.state = 'startup_failed'
+ record.reason = err.message
+ log.error(`module "${record.id}" failed to register — continuing without it`, {
+ reason: err.message,
+ })
+ continue // unmounted, exactly like a validation failure in the first pass
+ }
+ mount(record, tierRouters)
}
}
diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js
new file mode 100644
index 0000000..cb96a0e
--- /dev/null
+++ b/server/src/modules/registries.js
@@ -0,0 +1,356 @@
+// ── The de-entanglement registries ─────────────────────────────────────────
+//
+// Phase 2, PR 4 of docs/website/MODULE_SYSTEM.md §2.7 — the three seams §1.8 and
+// §1.9 identified, where core code and game-specific content are tangled in one
+// file and a folder move cannot separate them. The normative contract is
+// docs/website/MODULE_API.md §2.4.
+//
+// The three:
+//
+// 1. `registerExtension(slot, router)` — §1.9. Module routes hanging off a
+// CORE resource (`/admin/users/:id`), so all six shard sub-paths keep their
+// URLs while core never learns what "shard" means.
+// 2. `registerNotificationStreams(streams)` — §1.8. The push-stream catalog:
+// push INFRASTRUCTURE is core, this CATALOG is content.
+// 3. `registerAnnounceLeg({ leg, label, dispatch, classify })` — §1.8. The news
+// dispatcher's delivery legs; Discord is core, town crier is content.
+//
+// **Core registers through these functions too, and is the only registrant until
+// Phase 3.** `registerCore()` below is called explicitly from app.js before
+// `modules.load()` — explicit, never lazy, the same decision the loader's trigger
+// took (MODULE_API.md §7.6). Core going through the same door is the point: a
+// registry only core's hardcoded base bypasses is a registry whose first real
+// exercise is a module, which is the drift this PR exists to prevent.
+//
+// **Registering is validate-then-commit, per registrant.** `apply()` checks every
+// claim in a batch before it writes any of them, so a module that registers two
+// streams and then throws — or fails a later validation step in the loader — has
+// left nothing behind. That is the registry-side twin of the loader's second-pass
+// mount rule: nothing a module claims takes effect until the module as a whole is
+// known good.
+//
+// Nothing here reaches the database or the network. It is a require-time-safe
+// collection of what core and modules have declared, read at request time.
+
+const express = require('express')
+
+const log = require('../utils/logger')('modules')
+
+// ── State ──────────────────────────────────────────────────────────────────
+
+// slot → { router, filledBy }. `router` is created when CORE DECLARES the slot
+// and mounted immediately; registrants `use()` into it later. That indirection is
+// not optional: users.router.js is required while app.js is being built, long
+// before any module has been scanned, so the thing core mounts has to be a stable
+// object that can still be empty.
+const slots = new Map()
+
+// Registration order, which is display order in the app's notifications screen.
+const streams = []
+const streamOwners = new Map() // stream id → owner id, for the collision message
+
+// leg id → { owner, leg, label, dispatch, classify }
+const legs = new Map()
+
+let coreRegistered = false
+
+// Stream ids that predate the module system and may not carry their owner's
+// prefix — the exact counterpart of the loader's LEGACY_TABLE_PREFIXES, for the
+// exact same reason. These seven ids are stored in `notification_subs` rows and
+// are read by a shipped Android client; renaming them in Phase 3 would be a data
+// migration and a client break, so `uo` keeps them and the prefix rule stays real
+// for every module written after it.
+const LEGACY_STREAM_IDS = {
+ uo: [
+ 'server.status', 'idoc.warning', 'champ.start', 'governor.election',
+ 'vendor.sale', 'house.idoc', 'account.login',
+ ],
+}
+
+// Likewise for announce legs: `towncrier` is a stored value in
+// announce_job_legs.leg and the body of the admin retry endpoint.
+const LEGACY_LEGS = { uo: ['towncrier'] }
+
+const STREAM_ID = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/
+const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/
+
+// A module's claim must carry its id. Core's ids are its own namespace, and the
+// grandfathered names are the ones that predate all of this.
+function namespaced(owner, name, legacy) {
+ return owner === 'core' || name.startsWith(`${owner}.`) || (legacy[owner] || []).includes(name)
+}
+
+// ── Extension slots (§1.9) ─────────────────────────────────────────────────
+
+/**
+ * Core declares an extension slot and gets the router to mount for it.
+ *
+ * ONLY core may declare a slot; a module may only fill one (MODULE_API.md §2.4).
+ * That asymmetry is why this is not on the `api` object handed to a module.
+ *
+ * `mergeParams` so the slot's router sees the parent's `:id`. Core's own routes
+ * on the resource are declared before the slot is mounted, so first-match-wins
+ * gives core the path conflict, as the contract requires.
+ *
+ * @returns {import('express').Router} mount this at the resource, once.
+ */
+function declareSlot(slot) {
+ if (slots.has(slot)) throw new Error(`extension slot "${slot}" already declared`)
+ const router = express.Router({ mergeParams: true })
+ slots.set(slot, { router, filledBy: null })
+ return router
+}
+
+/** Does this slot exist? The loader asks, to validate `extensions` in a manifest. */
+const hasSlot = (slot) => slots.has(slot)
+
+/** Who filled a slot, or null. */
+const slotFilledBy = (slot) => (slots.get(slot) || {}).filledBy || null
+
+/**
+ * Every FILLED slot, for the OpenAPI build step (swagger/slotSpecs.js).
+ *
+ * `router` is the slot's own stable router — the object mounted on the resource —
+ * so the build can find it in the live express stack and recover the prefix it
+ * hangs at without a hardcoded table.
+ */
+const filledSlots = () =>
+ [...slots.entries()]
+ .filter(([, e]) => e.filledBy)
+ .map(([slot, e]) => ({ slot, filledBy: e.filledBy, router: e.router, specFile: e.specFile || null }))
+
+// ── Notification streams (§1.8) ────────────────────────────────────────────
+
+/** The whole catalog, core's entries first, in registration order. */
+const allStreams = () => streams.slice()
+
+/** Is this a stream anyone registered? Gates a subscription write. */
+const isValidStream = (id) => streamOwners.has(id)
+
+/** Ids of the owner-keyed streams — those needing a linked game account. */
+const personalStreams = () => new Set(streams.filter((s) => s.personal).map((s) => s.id))
+
+// ── Announce legs (§1.8) ───────────────────────────────────────────────────
+
+/** Every registered leg, in registration order. */
+const announceLegs = () => [...legs.values()]
+
+/** Just the ids — the enqueue order and the retry endpoint's allowlist. */
+const announceLegIds = () => [...legs.keys()]
+
+/** One leg, or null. */
+const announceLeg = (leg) => legs.get(leg) || null
+
+// ── Shape checks, run the moment a registrant calls ────────────────────────
+//
+// Split from the collision checks below on the same line PR 3 drew through
+// schema-fragment validation: what can be decided from the argument alone is
+// decided AT THE CALL, so the error carries the registrant's own stack. What
+// depends on other registrants has to wait for the batch to be complete.
+
+function checkStreamShape(entry) {
+ if (!entry || !STREAM_ID.test(entry.id || '')) {
+ throw new Error(`registerNotificationStreams: bad stream id "${entry && entry.id}"`)
+ }
+ if (!entry.label) throw new Error(`registerNotificationStreams: stream "${entry.id}" has no label`)
+ return {
+ id: entry.id,
+ label: entry.label,
+ description: entry.description || '',
+ personal: Boolean(entry.personal),
+ requiresLinkedAccount: Boolean(entry.requiresLinkedAccount),
+ }
+}
+
+function checkLegShape(entry) {
+ const { leg, label, dispatch, classify } = entry || {}
+ if (!LEG_ID.test(leg || '')) throw new Error(`registerAnnounceLeg: bad leg id "${leg}"`)
+ if (typeof dispatch !== 'function') throw new Error(`announce leg "${leg}" has no dispatch()`)
+ if (typeof classify !== 'function') throw new Error(`announce leg "${leg}" has no classify()`)
+ return { leg, label: label || leg, dispatch, classify }
+}
+
+// `specFile` is CORE-ONLY and is not on the module-facing signature. A slot's
+// router reaches the app through declareSlot(), which no static parse of app.js
+// can follow, so swagger-autogen would silently drop every route in it — the
+// spike's exact failure (MODULE_API.md §7.4). Core names the file so
+// `npm run swagger` can generate a fragment from it and merge it into the
+// committed spec. A MODULE has no equivalent need: it ships a prebuilt
+// `swagger-fragment.json` in its bundle (§6.1a), because core never has its
+// sources to analyse.
+function checkExtensionShape(slot, router, specFile) {
+ if (!slots.has(slot)) throw new Error(`unknown extension slot "${slot}"`)
+ if (typeof router !== 'function') throw new Error(`registerExtension: ${slot} is not a router`)
+ return { slot, router, specFile: specFile || null }
+}
+
+// ── Staging + commit ───────────────────────────────────────────────────────
+
+/**
+ * A registrant's staging area: shape-checked claims, not yet visible to anyone.
+ *
+ * The loader hands one of these to a module through `api`, and `registerCore()`
+ * builds one for core. Nothing a registrant says is readable through
+ * `allStreams()` / `announceLeg()` / the slot routers until `apply()`.
+ */
+function stage(owner) {
+ const staged = { owner, streams: [], legs: [], extensions: [] }
+ return {
+ staged,
+ registerNotificationStreams(entries) {
+ if (!Array.isArray(entries)) throw new Error('registerNotificationStreams: expected an array')
+ for (const e of entries) staged.streams.push(checkStreamShape(e))
+ },
+ registerAnnounceLeg(entry) {
+ staged.legs.push(checkLegShape(entry))
+ },
+ registerExtension(slot, router, specFile) {
+ staged.extensions.push(checkExtensionShape(slot, router, specFile))
+ },
+ }
+}
+
+/**
+ * Validate a staged batch against everything already registered, then commit it.
+ *
+ * Validation is TOTAL before the first write, so this either takes all of a
+ * registrant's claims or none of them. Throws on the first collision, naming who
+ * holds the thing already — which is the message an operator needs and the one
+ * PR 2 learned to protect (mounting inside the scan loop made every collision
+ * look like it was with core).
+ */
+function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExtensions }) {
+ // ── validate ──
+ const seenStreams = new Set()
+ for (const s of newStreams) {
+ const held = streamOwners.get(s.id)
+ if (held) throw new Error(`stream "${s.id}" is already registered by "${held}"`)
+ if (seenStreams.has(s.id)) throw new Error(`stream "${s.id}" registered twice`)
+ if (!namespaced(owner, s.id, LEGACY_STREAM_IDS)) {
+ throw new Error(`stream "${s.id}" is not namespaced "${owner}."`)
+ }
+ seenStreams.add(s.id)
+ }
+
+ const seenLegs = new Set()
+ for (const l of newLegs) {
+ const held = legs.get(l.leg)
+ if (held) throw new Error(`announce leg "${l.leg}" is already registered by "${held.owner}"`)
+ if (seenLegs.has(l.leg)) throw new Error(`announce leg "${l.leg}" registered twice`)
+ if (!namespaced(owner, l.leg, LEGACY_LEGS)) {
+ throw new Error(`announce leg "${l.leg}" is not namespaced "${owner}."`)
+ }
+ seenLegs.add(l.leg)
+ }
+
+ const seenSlots = new Set()
+ for (const x of newExtensions) {
+ const entry = slots.get(x.slot)
+ if (entry.filledBy) {
+ throw new Error(`extension slot "${x.slot}" is already filled by "${entry.filledBy}"`)
+ }
+ if (seenSlots.has(x.slot)) throw new Error(`extension slot "${x.slot}" filled twice`)
+ seenSlots.add(x.slot)
+ }
+
+ // ── commit — nothing below can fail ──
+ for (const s of newStreams) {
+ streamOwners.set(s.id, owner)
+ streams.push(s)
+ }
+ for (const l of newLegs) legs.set(l.leg, { owner, ...l })
+ for (const x of newExtensions) {
+ const entry = slots.get(x.slot)
+ entry.filledBy = owner
+ entry.specFile = x.specFile
+ entry.router.use(x.router)
+ }
+}
+
+// ── Core's own registrations ───────────────────────────────────────────────
+
+/**
+ * Register everything CORE owns, through the same staging area a module uses.
+ *
+ * Called once from app.js, before `modules.load()` — before, because a module's
+ * collision checks are asked against what is already registered, and core's
+ * claims must be the ones already there.
+ *
+ * What is here is what survives Phase 3. Everything after the boundary comment is
+ * shard content and leaves with module-uo, registered rather than hardcoded so
+ * the seam is exercised on every boot long before a module first uses it.
+ */
+function registerCore() {
+ if (coreRegistered) return
+
+ /* eslint-disable global-require */
+ const coreStreams = require('../config/coreStreams')
+ const discordLeg = require('../utils/discordAnnounce')
+ const shardStreams = require('../config/shardStreams')
+ const townCrierLeg = require('../utils/shardAnnounce')
+ const shardExtension = require('../router/v1/admin/usersShard.router')
+ /* eslint-enable global-require */
+
+ const api = stage('core')
+ api.registerNotificationStreams(coreStreams.STREAMS)
+ api.registerAnnounceLeg(discordLeg.leg)
+
+ // ── Phase 3 boundary ────────────────────────────────────────────────────
+ // These three lines become module-uo's register() body, with 'core' becoming
+ // 'uo'. Nothing else in core has to change for that to happen — which is the
+ // whole claim PR 4 is making.
+ api.registerNotificationStreams(shardStreams.STREAMS)
+ api.registerAnnounceLeg(townCrierLeg.leg)
+ api.registerExtension('admin.users.detail', shardExtension, require.resolve('../router/v1/admin/usersShard.router'))
+
+ apply(api.staged)
+ coreRegistered = true
+
+ log.info('core registrations complete', {
+ streams: streams.length,
+ announceLegs: legs.size,
+ extensions: [...slots.keys()].filter(slotFilledBy),
+ })
+}
+
+/** Has registerCore() run? Read by tests, and by the loader's ordering assertion. */
+const isCoreRegistered = () => coreRegistered
+
+// Test-only: hand the process back. Registries are process-global by design
+// (there is one core), so a test that registers has to be able to undo it.
+//
+// Slot DECLARATIONS survive, and only their fills are cleared: a slot is declared
+// at require time by the router that owns the resource, and that require has
+// already happened and will not happen again in this process. Clearing the map
+// would leave a slot that nothing can re-declare. The cost is that a test filling
+// the same slot twice stacks two routers inside it; no test reads through a slot
+// router, so that is left rather than papered over with a rebuilt router that
+// would no longer be the object users.router.js mounted.
+function _reset() {
+ for (const entry of slots.values()) {
+ entry.filledBy = null
+ entry.specFile = null
+ }
+ streams.length = 0
+ streamOwners.clear()
+ legs.clear()
+ coreRegistered = false
+}
+
+module.exports = {
+ declareSlot,
+ hasSlot,
+ slotFilledBy,
+ filledSlots,
+ allStreams,
+ isValidStream,
+ personalStreams,
+ announceLegs,
+ announceLegIds,
+ announceLeg,
+ stage,
+ apply,
+ registerCore,
+ isCoreRegistered,
+ _reset,
+}
diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js
index 1a05099..18f618e 100644
--- a/server/src/router/v1/admin/admin.controller.js
+++ b/server/src/router/v1/admin/admin.controller.js
@@ -728,6 +728,23 @@ async function listUsers(req, res) {
}
}
+// GET /admin/users/:id — the sanitized user (so the detail page is refresh-safe).
+//
+// Lived in usersShard.controller.js until PR 4, purely because the detail page it
+// backs is mostly shard panels — MODULE_SYSTEM.md §1.9 called that out as core
+// semantics that ended up in the UO controller by proximity. Reading a user is
+// core's, and it stays here when the shard panels leave.
+async function getUser(req, res) {
+ try {
+ const user = await users.getById(Number(req.params.id))
+ if (!user) return res.status(404).json({ message: 'Not found' })
+ return res.json(user)
+ } catch (err) {
+ log.error('getUser', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
async function createUser(req, res) {
try {
if (await users.getRawByUsername(req.body.username)) {
@@ -938,6 +955,7 @@ module.exports = {
ASSET_RULES,
listActivity,
listUsers,
+ getUser,
createUser,
updateUser,
deleteUser,
diff --git a/server/src/router/v1/admin/posts.router.js b/server/src/router/v1/admin/posts.router.js
index e201502..1fca7a4 100644
--- a/server/src/router/v1/admin/posts.router.js
+++ b/server/src/router/v1/admin/posts.router.js
@@ -1,5 +1,5 @@
// Admin · Posts — news, five-on-friday, newsletter and screenshot posts, plus
-// the announcement pipeline (town crier + Discord) status and retry.
+// the announcement pipeline status and retry.
//
// Mounted at /api/v1/admin/posts by admin/index.js, which already applied
// `noindex, isLoggedIn, staffOnly`. No extra gate: managing content is the
@@ -13,6 +13,7 @@ const { body, param } = require('express-validator')
const ctrl = require('./admin.controller')
const { upload } = require('./imageUpload')
const validate = require('../../../middleware/validate')
+const registries = require('../../../modules/registries')
const postsRouter = express.Router()
@@ -124,14 +125,17 @@ postsRouter.get(
postsRouter.post(
'/:id/announce/retry',
// #swagger.tags = ['Admin · Posts']
- // #swagger.summary = 'Retry one announcement delivery leg (town crier or Discord)'
+ // #swagger.summary = 'Retry one announcement delivery leg'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
- /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { leg: { type: "string", enum: ["towncrier", "discord"] } }, required: ["leg"] } } } } */
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { leg: { type: "string", description: "A registered delivery leg id, as returned by GET /announce." } }, required: ["leg"] } } } } */
/* #swagger.responses[200] = { description: 'Updated announce job', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'No announcement job for this post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
- body('leg').isIn(['towncrier', 'discord']),
+ // The allowlist is the REGISTERED leg set, read per request rather than
+ // captured at require time: this file is required while app.js is being built,
+ // before registerCore() and modules.load() have run (MODULE_SYSTEM.md §1.8).
+ body('leg').custom((leg) => registries.announceLeg(leg) != null).withMessage('unknown announce leg'),
validate,
ctrl.retryAnnounceLeg,
)
diff --git a/server/src/router/v1/admin/users.router.js b/server/src/router/v1/admin/users.router.js
index 5fc7700..2a915aa 100644
--- a/server/src/router/v1/admin/users.router.js
+++ b/server/src/router/v1/admin/users.router.js
@@ -4,20 +4,18 @@
// `noindex, isLoggedIn, staffOnly`. The whole capability is admin-only: editors
// and moderators manage content and reports, never accounts.
//
-// Handlers still live in admin.controller.js (users) and usersShard.controller.js
-// (uo-link footprint); this PR re-wires routes, not logic.
+// Handlers live in admin.controller.js. The shard footprint that used to be
+// wired here is now an EXTENSION SLOT (MODULE_SYSTEM.md §1.9) — see the bottom of
+// this file.
const express = require('express')
const { body, param } = require('express-validator')
const ctrl = require('./admin.controller')
-const usersShard = require('./usersShard.controller')
+const registries = require('../../../modules/registries')
const { requireRole } = require('../../../utils/auth')
const validate = require('../../../middleware/validate')
-// Same shape the shard routes validate account names with.
-const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
-
const usersRouter = express.Router()
const adminOnly = requireRole('admin')
@@ -151,11 +149,6 @@ usersRouter.post(
ctrl.resetUserMfa,
)
-// ── User → shard (uo-link) footprint (admin only) ─────────────────────
-// Backs the /admin/users/:id detail page: a user's linked game accounts and,
-// scoped to those accounts, their vendor sales / houses / online characters.
-// Live character rosters are fetched by the client through /admin/shard/* (which
-// already grants admins a bypass to any account), so no routes for them here.
usersRouter.get(
'/:id',
// #swagger.tags = ['Admin · Users']
@@ -166,84 +159,21 @@ usersRouter.get(
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
- usersShard.getUser,
-)
-usersRouter.get(
- '/:id/shard/accounts',
- // #swagger.tags = ['Admin · Users']
- // #swagger.summary = 'A user’s linked game accounts (admin only)'
- // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
- // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
- /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
- /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
- param('id').isInt(),
- validate,
- usersShard.listAccounts,
-)
-usersRouter.get(
- '/:id/shard/sales',
- // #swagger.tags = ['Admin · Users']
- // #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
- // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
- // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
- /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
- /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
- param('id').isInt(),
- validate,
- usersShard.getSales,
-)
-usersRouter.get(
- '/:id/shard/houses',
- // #swagger.tags = ['Admin · Users']
- // #swagger.summary = 'Houses owned by a user’s accounts (admin only)'
- // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
- // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
- /* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
- /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
- param('id').isInt(),
- validate,
- usersShard.getHouses,
-)
-usersRouter.get(
- '/:id/shard/online',
- // #swagger.tags = ['Admin · Users']
- // #swagger.summary = 'A user’s characters currently online (admin only)'
- // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
- // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
- /* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
- /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
- param('id').isInt(),
- validate,
- usersShard.getOnline,
-)
-usersRouter.get(
- '/:id/shard/standing',
- // #swagger.tags = ['Admin · Users']
- // #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)'
- // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
- // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
- /* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
- /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
- param('id').isInt(),
- validate,
- usersShard.getStanding,
-)
-usersRouter.delete(
- '/:id/shard/link/:account',
- // #swagger.tags = ['Admin · Users']
- // #swagger.summary = 'Unlink a game account from this user (admin only)'
- // #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
- // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
- // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
- // #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
- /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
- /* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
- /* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
- adminOnly,
- param('id').isInt(),
- param('account').matches(SHARD_ACCOUNT_RE),
- validate,
- usersShard.unlinkAccount,
+ ctrl.getUser,
)
+// ── The `admin.users.detail` extension slot (MODULE_SYSTEM.md §1.9) ────────
+//
+// A module may hang routes off this core resource. Core DECLARES the slot; only
+// core may, and a module may only fill one (MODULE_API.md §2.4). What fills it
+// today is core's own usersShard.router.js, registered in registries.js's
+// registerCore() — the shard footprint that used to be wired inline right here.
+// Phase 3 changes the registrant, not this line.
+//
+// LAST, deliberately: every core route on the resource is already declared, so
+// first-match-wins means core owns any path conflict. The router is created at
+// declare time and filled later, because this file is required while app.js is
+// still being built — long before a module has been scanned.
+usersRouter.use('/:id', registries.declareSlot('admin.users.detail'))
+
module.exports = usersRouter
diff --git a/server/src/router/v1/admin/usersShard.controller.js b/server/src/router/v1/admin/usersShard.controller.js
index c620b3e..291067a 100644
--- a/server/src/router/v1/admin/usersShard.controller.js
+++ b/server/src/router/v1/admin/usersShard.controller.js
@@ -25,18 +25,6 @@ async function accountsForUser(id) {
return { user, links, accounts: links.map((l) => l.account) }
}
-// GET /admin/users/:id — the sanitized user (so the detail page is refresh-safe).
-async function getUser(req, res) {
- try {
- const user = await users.getById(Number(req.params.id))
- if (!user) return res.status(404).json({ message: 'Not found' })
- return res.json(user)
- } catch (err) {
- log.error('getUser', err)
- return res.status(500).json({ message: 'Internal Server Error' })
- }
-}
-
// GET /admin/users/:id/shard/accounts — the user's linked game accounts.
async function listAccounts(req, res) {
try {
@@ -139,4 +127,4 @@ async function unlinkAccount(req, res) {
}
}
-module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }
+module.exports = { listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }
diff --git a/server/src/router/v1/admin/usersShard.router.js b/server/src/router/v1/admin/usersShard.router.js
new file mode 100644
index 0000000..13ee1ae
--- /dev/null
+++ b/server/src/router/v1/admin/usersShard.router.js
@@ -0,0 +1,111 @@
+// ── The `admin.users.detail` extension slot's contents ─────────────────────
+//
+// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.9 named the
+// fourth mount shape: module routes hanging off a CORE resource. These six paths
+// are shard reads on `/admin/users/:id`, a user-management URL core owns, so
+// they cannot move with a prefix and cannot stay where they are either.
+//
+// The resolution is an extension SLOT. `users.router.js` declares
+// `admin.users.detail` and mounts its router at `/:id`; this file is what fills
+// it, registered through modules/registries.js like a module would
+// (registerCore() → `api.registerExtension('admin.users.detail', …)`). Phase 3
+// moves this file to module-uo and changes nothing else — the six URLs are
+// identical either way, and core never learns what "shard" means.
+//
+// `mergeParams` comes from the slot's router, so `req.params.id` is the parent's
+// user id. Core's own routes on the resource are declared BEFORE the slot is
+// mounted, so core always wins a path conflict (MODULE_API.md §2.4).
+
+const express = require('express')
+const { param } = require('express-validator')
+
+const usersShard = require('./usersShard.controller')
+const validate = require('../../../middleware/validate')
+
+// Same shape the shard routes validate account names with.
+const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
+
+const shardRouter = express.Router({ mergeParams: true })
+
+// Backs the /admin/users/:id detail page: a user's linked game accounts and,
+// scoped to those accounts, their vendor sales / houses / online characters.
+// Live character rosters are fetched by the client through /admin/shard/* (which
+// already grants admins a bypass to any account), so no routes for them here.
+shardRouter.get(
+ '/shard/accounts',
+ // #swagger.tags = ['Admin · Users']
+ // #swagger.summary = 'A user’s linked game accounts (admin only)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
+ /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
+ /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ param('id').isInt(),
+ validate,
+ usersShard.listAccounts,
+)
+shardRouter.get(
+ '/shard/sales',
+ // #swagger.tags = ['Admin · Users']
+ // #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
+ /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
+ /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ param('id').isInt(),
+ validate,
+ usersShard.getSales,
+)
+shardRouter.get(
+ '/shard/houses',
+ // #swagger.tags = ['Admin · Users']
+ // #swagger.summary = 'Houses owned by a user’s accounts (admin only)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
+ /* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
+ /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ param('id').isInt(),
+ validate,
+ usersShard.getHouses,
+)
+shardRouter.get(
+ '/shard/online',
+ // #swagger.tags = ['Admin · Users']
+ // #swagger.summary = 'A user’s characters currently online (admin only)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
+ /* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
+ /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ param('id').isInt(),
+ validate,
+ usersShard.getOnline,
+)
+shardRouter.get(
+ '/shard/standing',
+ // #swagger.tags = ['Admin · Users']
+ // #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
+ /* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
+ /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ param('id').isInt(),
+ validate,
+ usersShard.getStanding,
+)
+shardRouter.delete(
+ '/shard/link/:account',
+ // #swagger.tags = ['Admin · Users']
+ // #swagger.summary = 'Unlink a game account from this user (admin only)'
+ // #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
+ // #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
+ /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
+ /* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ param('id').isInt(),
+ param('account').matches(SHARD_ACCOUNT_RE),
+ validate,
+ usersShard.unlinkAccount,
+)
+
+module.exports = shardRouter
diff --git a/server/src/router/v1/auth/notifications.controller.js b/server/src/router/v1/auth/notifications.controller.js
index 67a3eee..a05e19d 100644
--- a/server/src/router/v1/auth/notifications.controller.js
+++ b/server/src/router/v1/auth/notifications.controller.js
@@ -5,7 +5,7 @@
const pushDevices = require('../../../model/pushDevices/pushDevices.model')
const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model')
-const { STREAMS } = require('../../../config/notificationStreams')
+const registries = require('../../../modules/registries')
const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
const log = require('../../../utils/logger')('notifications')
@@ -49,9 +49,11 @@ async function removeDevice(req, res) {
}
}
-// GET /auth/me/notifications/streams — the subscribable catalog (static).
+// GET /auth/me/notifications/streams — the subscribable catalog: core's streams
+// plus every installed module's, in registration order. Fixed for the lifetime of
+// a process (registration is boot-time), not a static constant.
function getStreams(req, res) {
- return res.json({ streams: STREAMS })
+ return res.json({ streams: registries.allStreams() })
}
// GET /auth/me/notifications/subscriptions — the caller's opted-in stream ids.
diff --git a/server/src/utils/announceWorker.js b/server/src/utils/announceWorker.js
index cd9fa9c..3736c34 100644
--- a/server/src/utils/announceWorker.js
+++ b/server/src/utils/announceWorker.js
@@ -2,59 +2,35 @@
//
// A lightweight, in-process table poller (no Redis/BullMQ in the stack). Every
// ANNOUNCE_POLL_MS it sweeps announce_jobs for legs that are due — freshly
-// enqueued or past their backoff — and dispatches each one:
-// • town crier → uoLinkClient.postTownCrier (sidecar → in-game)
-// • discord → botInternalClient.announce (bot → #news channel)
-// Both clients never throw (they return { ok, status, error }); the model turns
-// each result into done / retry / terminal and owns the backoff + rollup. One
-// leg failing never touches the other. Same setInterval + unref + stop() shape
-// as middleware/botScore's sweeper, wired into server.js start/shutdown.
+// enqueued or past their backoff — and dispatches each one through the leg that
+// registered itself for that id (modules/registries.js). Core registers
+// `discord`; module-uo registers `towncrier`; another game's module registers its
+// own, and nothing in this file changes.
+//
+// A leg's client never throws (they return { ok, status, error }) and its
+// classify() turns that into done / retry / terminal, which the model converts to
+// backoff + rollup. One leg failing never touches another. Same setInterval +
+// unref + stop() shape as middleware/botScore's sweeper, wired into server.js
+// start/shutdown.
const announceJobs = require('../model/announceJobs/announceJobs.model')
const announceJobsDb = require('../model/announceJobs/announceJobs.db')
-const logic = require('../model/announceJobs/announceJobs.logic')
const posts = require('../model/posts/posts.model')
-const uoLinkClient = require('./uoLinkClient')
-const botInternalClient = require('./botInternalClient')
+const registries = require('../modules/registries')
const log = require('./logger')('announce-worker')
const POLL_MS = Number(process.env.ANNOUNCE_POLL_MS) || 15_000
-const TOWNCRIER_DURATION_SEC = Number(process.env.TOWNCRIER_DURATION_SEC) || 3600
-
-function baseUrl() {
- return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
-}
-
-// ── Leg dispatchers ─────────────────────────────────────────────────────────
-// Return the raw client result ({ ok, status, data, error }); classification is
-// the model/logic's job.
-
-async function dispatchTownCrier(post) {
- const lines = logic.buildTownCrierText(post, { baseUrl: baseUrl() })
- // Stable id: re-posting `post-` REPLACES the prior town-crier entry rather
- // than stacking a duplicate, so a retry after a partial failure is safe.
- return uoLinkClient.postTownCrier({
- id: `post-${post.id}`,
- lines,
- durationSec: TOWNCRIER_DURATION_SEC,
- })
-}
-
-async function dispatchDiscord(post) {
- const base = baseUrl()
- // Stored image paths are relative ("/uploads/x.png"); Discord embeds need an
- // absolute URL.
- const imageUrl = post.image_url ? new URL(post.image_url, base).toString() : null
- return botInternalClient.announce({
- title: post.title,
- excerpt: post.excerpt,
- url: `${base}/site/news`,
- imageUrl,
- })
-}
// Process a single due leg of a job: fetch the post, dispatch, classify, record.
async function processLeg(job, leg) {
+ const registered = registries.announceLeg(leg)
+ if (!registered) {
+ // A row for a leg nobody registers any more (its module was removed). Leave
+ // it alone: failing it would make the job roll up terminal on the strength of
+ // a leg that no longer exists, and reinstalling the module should resume it.
+ return
+ }
+
const post = await posts.getById(job.post_id)
if (!post) {
// Post was deleted between enqueue and dispatch (the CASCADE usually reaps
@@ -63,16 +39,9 @@ async function processLeg(job, leg) {
return
}
- let result
let classification
try {
- if (leg === 'towncrier') {
- result = await dispatchTownCrier(post)
- classification = logic.classifyTownCrier(result)
- } else {
- result = await dispatchDiscord(post)
- classification = logic.classifyDiscord(result)
- }
+ classification = registered.classify(await registered.dispatch(post))
} catch (err) {
// Clients shouldn't throw, but if one does, treat it as a transient failure
// rather than crashing the tick.
@@ -83,11 +52,10 @@ async function processLeg(job, leg) {
await announceJobs.recordOutcome(job, leg, classification)
}
-// One sweep: find due jobs and process each due leg. A job may have both legs due
-// (a fresh enqueue) — process the ones that are actually pending. `job` is a
-// snapshot from the SELECT; recordOutcome re-reads for the rollup, so processing
-// the two legs sequentially off the same snapshot is fine (each leg only writes
-// its own columns).
+// One sweep: find due jobs and process each due leg. A job may have several legs
+// due at once (a fresh enqueue). `job` is a snapshot from the SELECT;
+// recordOutcome re-reads for the rollup, so processing the legs sequentially off
+// the same snapshot is fine (each leg only writes its own row).
async function tick(now = new Date()) {
let jobs
try {
@@ -99,15 +67,15 @@ async function tick(now = new Date()) {
if (!jobs || jobs.length === 0) return
for (const job of jobs) {
- if (isLegDue(job, 'towncrier', now)) await processLeg(job, 'towncrier')
- if (isLegDue(job, 'discord', now)) await processLeg(job, 'discord')
+ for (const row of job.legs || []) {
+ if (isLegDue(row, now)) await processLeg(job, row.leg)
+ }
}
}
-function isLegDue(job, leg, now) {
- if (job[`${leg}_status`] !== 'pending') return false
- const next = job[`${leg}_next_attempt_at`]
- return next == null || new Date(next) <= now
+function isLegDue(row, now) {
+ if (!row || row.status !== 'pending') return false
+ return row.next_attempt_at == null || new Date(row.next_attempt_at) <= now
}
let timer = null
@@ -118,7 +86,7 @@ function start() {
tick().catch((err) => log.error('announce tick failed', { message: err.message }))
}, POLL_MS)
if (timer.unref) timer.unref() // don't keep the event loop alive (tests, shutdown)
- log.info('announcement dispatcher started', { pollMs: POLL_MS })
+ log.info('announcement dispatcher started', { pollMs: POLL_MS, legs: registries.announceLegIds() })
return timer
}
@@ -129,4 +97,4 @@ function stop() {
}
}
-module.exports = { start, stop, tick, processLeg, dispatchTownCrier, dispatchDiscord }
+module.exports = { start, stop, tick, processLeg, isLegDue }
diff --git a/server/src/utils/discordAnnounce.js b/server/src/utils/discordAnnounce.js
new file mode 100644
index 0000000..1dc08ca
--- /dev/null
+++ b/server/src/utils/discordAnnounce.js
@@ -0,0 +1,45 @@
+// ── The Discord announce leg ───────────────────────────────────────────────
+//
+// CORE content — the Discord bot has no game logic (MODULE_SYSTEM.md §1.10), so
+// this leg stays in core when module-uo leaves with the town crier. It is written
+// in the same shape as a module's leg and registered through the same function
+// (modules/registries.js registerAnnounceLeg), because a registry only core's
+// hardcoded base bypasses is not exercised until a module arrives.
+
+const botInternalClient = require('./botInternalClient')
+const { articleUrl, baseUrl, legError } = require('../model/announceJobs/announceJobs.logic')
+
+// Deliver. Returns the raw client result ({ ok, status, data, error }) — the
+// client never throws, and classification is `classify`'s job.
+async function dispatch(post) {
+ const base = baseUrl()
+ // Stored image paths are relative ("/uploads/x.png"); Discord embeds need an
+ // absolute URL.
+ const imageUrl = post.image_url ? new URL(post.image_url, base).toString() : null
+ return botInternalClient.announce({
+ title: post.title,
+ excerpt: post.excerpt,
+ url: articleUrl(base),
+ imageUrl,
+ })
+}
+
+// The bot's /internal/announce collapses failures (503 = not connected,
+// 400 = no news channel configured) without surfacing Discord's own retry_after,
+// so there is no reliable terminal signal to key on here. Retry every failure on
+// the shared backoff; a genuine config problem simply exhausts its attempts and
+// lands as `failed` in the admin panel, where the per-leg retry button re-runs it
+// after the channel is set.
+function classify(result) {
+ if (result && result.ok) return { outcome: 'done' }
+ return { outcome: 'retry', error: legError(result) }
+}
+
+const leg = {
+ leg: 'discord',
+ label: 'Discord #news',
+ dispatch,
+ classify,
+}
+
+module.exports = { leg, dispatch, classify }
diff --git a/server/src/utils/pushDispatch.js b/server/src/utils/pushDispatch.js
index 9a4a47b..5a96ccc 100644
--- a/server/src/utils/pushDispatch.js
+++ b/server/src/utils/pushDispatch.js
@@ -1,10 +1,14 @@
// ── Push-notification fan-out (content-free tickles) ───────────────────────
//
-// The transport-agnostic publisher that turns an event into opt-in push
-// notifications. Two producers call in:
-// • utils/shardIngest.js → fromShardEvent(event) for shard-derived streams
-// (beside the existing SSE broadcast — same event source, same allowlist).
-// • the admin create/publish-post path → publish('news.post', …).
+// The transport-agnostic publisher that turns a stream id into opt-in push
+// notifications. It knows nothing about where the stream came from: the admin
+// create/publish-post path calls publish('news.post', …), and utils/shardPush.js
+// resolves a shard event to a stream and an owner and calls the same function.
+//
+// That split is MODULE_SYSTEM.md §1.8's second entanglement, inverted. This file
+// used to own `fromShardEvent()`, which required the shardLinks model and the
+// shard event mapper — core infrastructure reaching into game content. Now the
+// content side calls in, and a module reaches this through `ctx.push.publish`.
//
// What actually leaves the server is a CONTENT-FREE tickle — `{ stream, ref }`,
// no sensitive data — POSTed to each subscribed device's UnifiedPush/ntfy
@@ -18,9 +22,7 @@
// registration AND every publish: HTTPS only, never a private/loopback host, and
// (when configured) the origin must be in the shard's ntfy allow-set.
-const shardLinks = require('../model/shardLinks/shardLinks.model')
const pushDevicesModel = require('../model/pushDevices/pushDevices.model')
-const { mapShardEvent } = require('../config/notificationStreams')
const log = require('./logger')('push-dispatch')
const TIMEOUT_MS = 5000
@@ -106,30 +108,4 @@ async function publish(streamId, { ref, ownerUserId } = {}, deps = {}) {
await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps)))
}
-// Fan a shard event out to push. Resolves personal (owner-keyed) targets to the
-// owning website user via shardLinks (an unlinked account → nobody to notify).
-// Never throws — a dead relay must never affect ingest.
-async function fromShardEvent(event, deps = {}) {
- const links = deps.shardLinks || shardLinks
- const targets = mapShardEvent(event, deps.tracker)
- for (const t of targets) {
- try {
- if (t.ownerAccount) {
- let owner = null
- try {
- owner = await links.getByAccount(t.ownerAccount)
- } catch {
- owner = null
- }
- if (!owner || owner.userId == null) continue
- await publish(t.streamId, { ref: t.ref, ownerUserId: owner.userId }, deps)
- } else {
- await publish(t.streamId, { ref: t.ref }, deps)
- }
- } catch (err) {
- log.warn('push dispatch target failed', { streamId: t.streamId, message: err.message })
- }
- }
-}
-
-module.exports = { publish, fromShardEvent, isAllowedEndpoint }
+module.exports = { publish, isAllowedEndpoint }
diff --git a/server/src/utils/shardAnnounce.js b/server/src/utils/shardAnnounce.js
new file mode 100644
index 0000000..34d735a
--- /dev/null
+++ b/server/src/utils/shardAnnounce.js
@@ -0,0 +1,77 @@
+// ── The in-game town-crier announce leg ────────────────────────────────────
+//
+// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8's third
+// entangled file: utils/announceWorker.js is core's news dispatcher, but one of
+// its two delivery legs goes to the shard through uoLinkClient.postTownCrier.
+// PR 4 turned the legs into registrations, and this file is what module-uo will
+// register in Phase 3 — it moves whole, with `'core'` becoming `'uo'` and the
+// leg id staying `towncrier` (grandfathered in registries.js: the id is a stored
+// value in announce_job_legs.leg).
+
+const uoLinkClient = require('./uoLinkClient')
+const { deriveExcerpt } = require('./sanitizeHtml')
+const { articleUrl, baseUrl, legError } = require('../model/announceJobs/announceJobs.logic')
+
+const TOWNCRIER_DURATION_SEC = Number(process.env.TOWNCRIER_DURATION_SEC) || 3600
+
+// Sidecar town-crier caps, mirrored from the admin route validation
+// (admin/uoLink.router.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })).
+// We pre-truncate to these so a published post never bounces with an error.
+const MAX_LINES = 8
+const MAX_LINE_LEN = 200
+
+// Trim to a hard length, appending an ellipsis only when something was cut.
+function clamp(value, max) {
+ const s = String(value == null ? '' : value)
+ .replace(/\s+/g, ' ')
+ .trim()
+ if (s.length <= max) return s
+ return `${s.slice(0, max - 1).trimEnd()}…`
+}
+
+// Build the town-crier lines: title, a one-line excerpt, then the URL. Each line
+// is clamped to the sidecar's per-line cap and the whole thing to the line-count
+// cap. Falls back to a stripped body excerpt when the post has no excerpt.
+function buildTownCrierText(post, { baseUrl: base } = {}) {
+ const title = clamp(post.title, MAX_LINE_LEN)
+ const excerptSource = post.excerpt || deriveExcerpt(post.body, MAX_LINE_LEN) || ''
+ const lines = [title]
+ const excerpt = clamp(excerptSource, MAX_LINE_LEN)
+ if (excerpt) lines.push(excerpt)
+ const url = clamp(articleUrl(base), MAX_LINE_LEN)
+ if (url) lines.push(url)
+ return lines.filter(Boolean).slice(0, MAX_LINES)
+}
+
+async function dispatch(post) {
+ const lines = buildTownCrierText(post, { baseUrl: baseUrl() })
+ // Stable id: re-posting `post-` REPLACES the prior town-crier entry rather
+ // than stacking a duplicate, so a retry after a partial failure is safe.
+ return uoLinkClient.postTownCrier({
+ id: `post-${post.id}`,
+ lines,
+ durationSec: TOWNCRIER_DURATION_SEC,
+ })
+}
+
+function classify(result) {
+ if (result && result.ok) return { outcome: 'done' }
+ const status = result ? result.status : 0
+ // 400 = over the line/duration caps (a data problem — do NOT retry).
+ // 401 = token mismatch, 409 = protocol mismatch (both config problems).
+ if (status === 400 || status === 401 || status === 409) {
+ return { outcome: 'terminal', error: legError(result) }
+ }
+ // 503 (shard not connected), 504 (shard timeout), 0 (network/timeout / not
+ // configured yet), and any other 5xx are transient — retry.
+ return { outcome: 'retry', error: legError(result) }
+}
+
+const leg = {
+ leg: 'towncrier',
+ label: 'In-game town crier',
+ dispatch,
+ classify,
+}
+
+module.exports = { leg, dispatch, classify, buildTownCrierText, MAX_LINES, MAX_LINE_LEN }
diff --git a/server/src/utils/shardIngest.js b/server/src/utils/shardIngest.js
index 13b62cc..6b58986 100644
--- a/server/src/utils/shardIngest.js
+++ b/server/src/utils/shardIngest.js
@@ -19,7 +19,7 @@ const shardMarketModel = require('../model/shardMarket/shardMarket.model')
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
const settingsModel = require('../model/settings/settings.model')
const broadcaster = require('./shardBroadcast')
-const pushDispatch = require('./pushDispatch')
+const shardPush = require('./shardPush')
const defaultLog = require('./logger')('shard-ingest')
// Notable kinds appended to the shard_events log. High-frequency/session kinds
@@ -262,7 +262,7 @@ function resolveDeps(deps) {
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
settings: deps.settings || settingsModel,
broadcast: deps.broadcast || broadcaster.broadcast,
- pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent,
+ pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
log: deps.log || defaultLog,
}
}
diff --git a/server/src/utils/shardPush.js b/server/src/utils/shardPush.js
new file mode 100644
index 0000000..f228fcf
--- /dev/null
+++ b/server/src/utils/shardPush.js
@@ -0,0 +1,47 @@
+// ── Shard event → push fan-out ─────────────────────────────────────────────
+//
+// MODULE-UO CONTENT, still living in core — the inverted half of
+// MODULE_SYSTEM.md §1.8's second entangled file. `utils/pushDispatch.js` is core
+// infrastructure, but its `fromShardEvent()` required the shardLinks model and
+// the shard event mapper, which is a core file importing content. PR 4 inverted
+// it: `publish()` stays core, and this — the thing that knows what a shard event
+// is — moved out to call it. Phase 3 moves this file to module-uo whole, where it
+// will reach `publish` through `ctx.push.publish` instead of a require.
+//
+// Owner resolution is the reason this cannot just be a mapper: a personal
+// (owner-keyed) target names a GAME account, and turning that into a website user
+// needs the shardLinks model. An unlinked account is simply nobody to notify.
+
+const shardLinks = require('../model/shardLinks/shardLinks.model')
+const { mapShardEvent } = require('../config/shardStreams')
+const { publish } = require('./pushDispatch')
+const log = require('./logger')('shard-push')
+
+// Fan a shard event out to push. Resolves personal (owner-keyed) targets to the
+// owning website user via shardLinks (an unlinked account → nobody to notify).
+// Never throws — a dead relay must never affect ingest.
+async function fromShardEvent(event, deps = {}) {
+ const links = deps.shardLinks || shardLinks
+ const doPublish = deps.publish || publish
+ const targets = mapShardEvent(event, deps.tracker)
+ for (const t of targets) {
+ try {
+ if (t.ownerAccount) {
+ let owner = null
+ try {
+ owner = await links.getByAccount(t.ownerAccount)
+ } catch {
+ owner = null
+ }
+ if (!owner || owner.userId == null) continue
+ await doPublish(t.streamId, { ref: t.ref, ownerUserId: owner.userId }, deps)
+ } else {
+ await doPublish(t.streamId, { ref: t.ref }, deps)
+ }
+ } catch (err) {
+ log.warn('push dispatch target failed', { streamId: t.streamId, message: err.message })
+ }
+ }
+}
+
+module.exports = { fromShardEvent }
diff --git a/server/swagger/mergeSpec.js b/server/swagger/mergeSpec.js
new file mode 100644
index 0000000..7f27005
--- /dev/null
+++ b/server/swagger/mergeSpec.js
@@ -0,0 +1,115 @@
+// ── Merging an OpenAPI fragment into a spec ────────────────────────────────
+//
+// The merge half of docs/website/MODULE_API.md §6.1's settled decision: routes
+// that reach the app through something swagger-autogen cannot statically follow
+// contribute a FRAGMENT, and core merges it.
+//
+// Two callers, one function, deliberately:
+// • build time — swagger/slotSpecs.js, for core's own extension-slot routers
+// (§1.9). Those are core routes that a static parse of app.js cannot see,
+// because the slot's router is created by registries.declareSlot() and filled
+// later. They belong in the committed `swagger-output.json`.
+// • request time (Phase 2 PR 6+) — an installed module's `swagger-fragment.json`,
+// merged over the committed spec for `/api/docs.json`.
+//
+// **Core always wins a key collision** (§6.1a). A fragment cannot redefine a path,
+// a tag or a schema core already declares; the collision is reported and the
+// fragment's version dropped. Merging is shallow-per-section — `paths`, `tags`
+// and `components.schemas` — because those are the only three sections a fragment
+// is allowed to carry, and a deeper merge would let a fragment reach into
+// `info`, `servers` or the security schemes.
+
+/**
+ * Merge `fragment` into `spec`, in place, with core winning every collision.
+ *
+ * @param {object} spec the base spec — mutated
+ * @param {object} fragment `{ paths?, tags?, components?: { schemas? } }`
+ * @param {string} source who the fragment came from, for the collision message
+ * @returns {string[]} the collisions that were dropped (empty when clean)
+ */
+function mergeFragment(spec, fragment, source) {
+ const dropped = []
+
+ for (const [path, item] of Object.entries(fragment.paths || {})) {
+ if (spec.paths[path]) {
+ // Not a merge of the two path items: a fragment adding a METHOD to a core
+ // path is the same overreach as replacing it, and the extension-slot
+ // contract already says core owns the resource (§2.4).
+ dropped.push(`path ${path}`)
+ continue
+ }
+ spec.paths[path] = item
+ }
+
+ const tagNames = new Set((spec.tags || []).map((t) => t.name))
+ for (const tag of fragment.tags || []) {
+ if (tagNames.has(tag.name)) continue // same tag, not a collision worth reporting
+ spec.tags.push(tag)
+ tagNames.add(tag.name)
+ }
+
+ const schemas = (fragment.components && fragment.components.schemas) || {}
+ for (const [name, schema] of Object.entries(schemas)) {
+ if (spec.components.schemas[name]) {
+ dropped.push(`schema ${name}`)
+ continue
+ }
+ spec.components.schemas[name] = schema
+ }
+
+ if (dropped.length > 0) {
+ process.stderr.write(
+ `swagger: dropped ${dropped.length} colliding key(s) from ${source} — core wins: ${dropped.join(', ')}\n`,
+ )
+ }
+ return dropped
+}
+
+/**
+ * Re-root a fragment's paths under the prefix its router is actually mounted at.
+ *
+ * A fragment generated by pointing swagger-autogen at a router file alone has
+ * paths relative to that router (`/shard/accounts`), because nothing in the file
+ * says where it hangs. The prefix comes from the LIVE express stack rather than a
+ * table, so it cannot drift the way a hand-written mount list would.
+ *
+ * Express path params (`:id`) become OpenAPI's (`{id}`) — swagger-autogen already
+ * does that for the paths it generates, so the prefix has to match.
+ */
+function prefixPaths(fragment, prefix) {
+ const oas = prefix.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\/+$/, '')
+ const outer = [...oas.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((m) => m[1])
+ const paths = {}
+ for (const [p, item] of Object.entries(fragment.paths || {})) {
+ paths[`${oas}${p}`] = orderParams(item, outer)
+ }
+ return { ...fragment, paths }
+}
+
+/**
+ * Put the prefix's own path parameters first, in prefix order.
+ *
+ * swagger-autogen orders parameters by where they appear in the path it saw, and
+ * the fragment's path is only the tail — so `/{id}/shard/link/{account}` comes
+ * out as (account, id) rather than (id, account). Re-rooting the path has to
+ * re-root the parameter order with it, or every slot route churns the committed
+ * spec by a reorder that means nothing.
+ */
+function orderParams(item, outer) {
+ for (const operation of Object.values(item)) {
+ const params = operation && operation.parameters
+ if (!Array.isArray(params)) continue
+ const rank = (p) => {
+ const i = outer.indexOf(p && p.name)
+ return i === -1 ? outer.length : i
+ }
+ // Stable: only the prefix params move, and only ahead of the rest.
+ operation.parameters = params
+ .map((p, i) => ({ p, i }))
+ .sort((a, b) => rank(a.p) - rank(b.p) || a.i - b.i)
+ .map(({ p }) => p)
+ }
+ return item
+}
+
+module.exports = { mergeFragment, prefixPaths }
diff --git a/server/swagger/slotSpecs.js b/server/swagger/slotSpecs.js
new file mode 100644
index 0000000..9e95f41
--- /dev/null
+++ b/server/swagger/slotSpecs.js
@@ -0,0 +1,120 @@
+// ── OpenAPI for core's extension-slot routers ──────────────────────────────
+//
+// The second half of `npm run swagger`. It exists because of a failure mode that
+// announces itself as a success.
+//
+// `swagger/swagger.js` is STATIC analysis: swagger-autogen parses `src/app.js` as
+// text and follows the literal `app.use(...)` mount chain. An extension slot
+// (MODULE_SYSTEM.md §1.9) breaks that chain on purpose — the slot's router is
+// created by `registries.declareSlot()` and filled later, so there is no literal
+// require for the parser to follow. When PR 4 moved the six `/admin/users/:id/shard/*`
+// routes behind the `admin.users.detail` slot, regenerating the spec printed
+// `Swagger-autogen: Success` and deleted 407 lines. Nothing failed. The spike hit
+// the identical thing (MODULE_API.md §7.4) and it is why the fragment merge is
+// the settled answer (§6.1).
+//
+// So: generate a fragment per filled slot by pointing swagger-autogen at that
+// router's own file, re-root its paths at the prefix the router is ACTUALLY
+// mounted at in the live app, and merge. Two things are deliberately derived
+// rather than written down, because a written-down copy is a copy that drifts:
+//
+// • WHICH slots — from `registries.filledSlots()`, not a list here.
+// • WHERE each hangs — by finding the slot's own router object in the live
+// express stack and accumulating the mount prefixes above it, using
+// `scripts/routeManifest.js`'s `mountPath` so the manifest and the spec can
+// never disagree about what a mount decodes to.
+//
+// This is core's own slot fill only. A MODULE ships a prebuilt
+// `swagger-fragment.json` in its bundle and core merges it at request time
+// (§6.1a) — core never has a module's sources to analyse.
+
+const fs = require('fs')
+const os = require('os')
+const path = require('path')
+
+const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
+
+const { mergeFragment, prefixPaths } = require('./mergeSpec')
+const { mountPath } = require('../scripts/routeManifest')
+
+const SERVER_ROOT = path.join(__dirname, '..')
+
+/**
+ * Find `target` in an express stack and return the path prefix it is mounted at.
+ *
+ * Depth-first, accumulating each enclosing mount. Returns null when the router is
+ * not on the stack at all — which for a filled slot means core declared it and
+ * never mounted it, a bug worth failing the build over rather than papering over
+ * with an unprefixed path.
+ */
+function findMountPrefix(stack, target, prefix = '') {
+ for (const layer of stack || []) {
+ if (!layer.handle || !Array.isArray(layer.handle.stack)) continue
+ const here = prefix + mountPath(layer)
+ if (layer.handle === target) return here
+ const found = findMountPrefix(layer.handle.stack, target, here)
+ if (found !== null) return found
+ }
+ return null
+}
+
+/**
+ * Generate one fragment by running swagger-autogen over a single router file.
+ *
+ * Its paths come out relative to that router (`/shard/accounts`) because nothing
+ * in the file says where it hangs; `prefixPaths` supplies the rest.
+ */
+async function fragmentFor(specFile) {
+ const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'rg-swagger-')), 'fragment.json')
+ await swaggerAutogen(out, [path.relative(SERVER_ROOT, specFile).split(path.sep).join('/')], {
+ info: { title: 'slot fragment', version: '0' },
+ })
+ const fragment = JSON.parse(fs.readFileSync(out, 'utf8'))
+ fs.rmSync(path.dirname(out), { recursive: true, force: true })
+ return fragment
+}
+
+/**
+ * Merge every filled core slot's routes into the generated spec file, in place.
+ *
+ * @param {string} outputFile the swagger-output.json swagger.js just wrote
+ * @returns {Promise} how many paths were added
+ */
+async function mergeSlotSpecs(outputFile) {
+ /* eslint-disable global-require */
+ const app = require('../src/app') // builds the app: declares and fills the slots
+ const registries = require('../src/modules/registries')
+ /* eslint-enable global-require */
+
+ const filled = registries.filledSlots().filter((s) => s.specFile)
+ if (filled.length === 0) return 0
+
+ const spec = JSON.parse(fs.readFileSync(outputFile, 'utf8'))
+ let added = 0
+
+ for (const slot of filled) {
+ const prefix = findMountPrefix(app._router.stack, slot.router)
+ if (prefix === null) {
+ throw new Error(
+ `swagger: extension slot "${slot.slot}" is filled but its router is not mounted on the app — ` +
+ 'declareSlot() returned a router nobody use()d.',
+ )
+ }
+ const fragment = prefixPaths(await fragmentFor(slot.specFile), prefix)
+ const paths = Object.keys(fragment.paths || {}).length
+ if (paths === 0) {
+ throw new Error(
+ `swagger: extension slot "${slot.slot}" generated an EMPTY fragment from ${slot.specFile}. ` +
+ 'That is the silent-drop failure this step exists to catch, not a slot with no routes.',
+ )
+ }
+ mergeFragment(spec, fragment, `slot ${slot.slot}`)
+ added += paths
+ process.stdout.write(`merged ${paths} path(s) from slot ${slot.slot} at ${prefix}\n`)
+ }
+
+ fs.writeFileSync(outputFile, `${JSON.stringify(spec, null, 2)}\n`)
+ return added
+}
+
+module.exports = { mergeSlotSpecs, findMountPrefix }
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 60e9a52..982bffc 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -3249,7 +3249,7 @@
"tags": [
"Admin · Posts"
],
- "summary": "Retry one announcement delivery leg (town crier or Discord)",
+ "summary": "Retry one announcement delivery leg",
"description": "",
"parameters": [
{
@@ -3308,10 +3308,7 @@
"properties": {
"leg": {
"type": "string",
- "enum": [
- "towncrier",
- "discord"
- ]
+ "description": "A registered delivery leg id, as returned by GET /announce."
}
},
"required": [
diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js
index 7395c79..6431e82 100644
--- a/server/swagger/swagger.js
+++ b/server/swagger/swagger.js
@@ -1513,9 +1513,31 @@ function normalizePaths(spec) {
return spec
}
-swaggerAutogen(outputFile, routes, doc).then(() => {
- const written = JSON.parse(fs.readFileSync(outputFile, 'utf8'))
- fs.writeFileSync(outputFile, `${JSON.stringify(normalizePaths(written), null, 2)}\n`)
- // eslint-disable-next-line no-console
- console.log('swagger-output.json generated.')
-})
+// Static analysis cannot follow a route into an extension slot, so the slot
+// routers contribute a generated fragment afterwards — see swagger/slotSpecs.js
+// for what goes wrong without it. Merged BEFORE normalizePaths, so the merged-in
+// paths are sorted and trailing-slash-checked with everything else.
+//
+// The pool is pointed at a closed port here for the same reason
+// scripts/routeManifest.js does it: the merge step requires src/app.js to find
+// where each slot router is mounted, and requiring app.js builds the models. No
+// query is ever run.
+process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1'
+process.env.DB_PORT = process.env.DB_PORT || '59999'
+
+/* eslint-disable global-require */
+swaggerAutogen(outputFile, routes, doc)
+ .then(() => require('./slotSpecs').mergeSlotSpecs(outputFile))
+ .then(() => {
+ const written = JSON.parse(fs.readFileSync(outputFile, 'utf8'))
+ fs.writeFileSync(outputFile, `${JSON.stringify(normalizePaths(written), null, 2)}\n`)
+ // eslint-disable-next-line no-console
+ console.log('swagger-output.json generated.')
+ // The mariadb pool keeps the loop alive even pointed at a dead port.
+ return require('../src/utils/db').close()
+ })
+ .catch((err) => {
+ process.stderr.write(`${err.stack || err.message}\n`)
+ process.exit(1)
+ })
+/* eslint-enable global-require */
diff --git a/server/test/adminUserShard.test.js b/server/test/adminUserShard.test.js
index f54edb2..8187c0b 100644
--- a/server/test/adminUserShard.test.js
+++ b/server/test/adminUserShard.test.js
@@ -75,7 +75,7 @@ test('salesForAccounts keeps only sales owned by the given accounts, newest 50',
})
// ── Controller: unknown user → 404 ─────────────────────────────────────────
-for (const handler of ['getUser', 'listAccounts', 'getSales', 'getHouses', 'getOnline']) {
+for (const handler of ['listAccounts', 'getSales', 'getHouses', 'getOnline']) {
test(`${handler} returns 404 when the user does not exist`, async () => {
users.getById = async () => null
const res = mockRes()
@@ -144,10 +144,7 @@ test('a user with no linked accounts yields empty sales/houses/online', async ()
assert.deepEqual(online.body, [])
})
-test('getUser returns the sanitized user row', async () => {
- users.getById = async () => ({ id: 7, username: 'bob', role: 'player', status: 'active' })
- const res = mockRes()
- await ctrl.getUser({ params: { id: '7' } }, res)
- assert.equal(res.statusCode, 200)
- assert.equal(res.body.username, 'bob')
-})
+// getUser is NOT here any more: reading a user is core semantics that had ended
+// up in this controller by proximity, and PR 4 moved it back to
+// admin.controller.js behind the extension slot (MODULE_SYSTEM.md §1.9). It is
+// covered by test/adminUsers.test.js.
diff --git a/server/test/adminUsers.test.js b/server/test/adminUsers.test.js
new file mode 100644
index 0000000..86afbeb
--- /dev/null
+++ b/server/test/adminUsers.test.js
@@ -0,0 +1,65 @@
+// ── Admin · Users: the core half of /admin/users/:id ───────────────────────
+//
+// `getUser` lived in usersShard.controller.js until Phase 2 PR 4, purely because
+// the detail page it backs is mostly shard panels. MODULE_SYSTEM.md §1.9 called
+// that out as core semantics that ended up in the UO controller by proximity, and
+// it moved back to admin.controller.js — the shard panels around it are now an
+// extension slot, so this handler has to stand on its own when they leave.
+//
+// Point the DB at a closed port BEFORE requiring anything that builds the pool,
+// so any stray query fails fast instead of hanging the runner.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, after, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const ctrl = require('../src/router/v1/admin/admin.controller')
+const users = require('../src/model/users/users.model')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+function mockRes() {
+ return {
+ statusCode: 200,
+ body: null,
+ status(c) {
+ this.statusCode = c
+ return this
+ },
+ json(b) {
+ this.body = b
+ return this
+ },
+ }
+}
+
+const originalGetById = users.getById
+afterEach(() => {
+ users.getById = originalGetById
+})
+
+test('getUser returns the sanitized user row', async () => {
+ users.getById = async () => ({ id: 7, username: 'bob', role: 'player', status: 'active' })
+ const res = mockRes()
+ await ctrl.getUser({ params: { id: '7' } }, res)
+ assert.equal(res.statusCode, 200)
+ assert.equal(res.body.username, 'bob')
+})
+
+test('getUser returns 404 when the user does not exist', async () => {
+ users.getById = async () => null
+ const res = mockRes()
+ await ctrl.getUser({ params: { id: '404' } }, res)
+ assert.equal(res.statusCode, 404)
+})
+
+test('getUser 500s rather than throwing when the model fails', async () => {
+ users.getById = async () => {
+ throw new Error('pool down')
+ }
+ const res = mockRes()
+ await ctrl.getUser({ params: { id: '7' } }, res)
+ assert.equal(res.statusCode, 500)
+})
diff --git a/server/test/announceJobs.test.js b/server/test/announceJobs.test.js
index 6a62eb4..c49b58d 100644
--- a/server/test/announceJobs.test.js
+++ b/server/test/announceJobs.test.js
@@ -2,10 +2,14 @@ const { test } = require('node:test')
const assert = require('node:assert/strict')
const logic = require('../src/model/announceJobs/announceJobs.logic')
+// Each leg owns its own text-building and result classification since PR 4 — a
+// leg is a registration now, not a branch in the worker (MODULE_SYSTEM.md §1.8).
+const townCrier = require('../src/utils/shardAnnounce')
+const discord = require('../src/utils/discordAnnounce')
// ── buildTownCrierText ───────────────────────────────────────────────────────
test('buildTownCrierText produces title, excerpt, and URL lines', () => {
- const lines = logic.buildTownCrierText(
+ const lines = townCrier.buildTownCrierText(
{ id: 7, title: 'Server Update', excerpt: 'Big things afoot.', body: null },
{ baseUrl: 'https://uom.example' },
)
@@ -13,7 +17,7 @@ test('buildTownCrierText produces title, excerpt, and URL lines', () => {
})
test('buildTownCrierText falls back to a stripped body when excerpt is empty', () => {
- const lines = logic.buildTownCrierText(
+ const lines = townCrier.buildTownCrierText(
{ id: 1, title: 'T', excerpt: '', body: 'Hello world
' },
{ baseUrl: 'https://uom.example' },
)
@@ -22,17 +26,17 @@ test('buildTownCrierText falls back to a stripped body when excerpt is empty', (
test('buildTownCrierText clamps each line to the sidecar per-line cap', () => {
const longTitle = 'x'.repeat(500)
- const lines = logic.buildTownCrierText(
+ const lines = townCrier.buildTownCrierText(
{ id: 1, title: longTitle, excerpt: 'y'.repeat(500), body: null },
{ baseUrl: 'https://uom.example' },
)
- for (const line of lines) assert.ok(line.length <= logic.MAX_LINE_LEN, `line too long: ${line.length}`)
+ for (const line of lines) assert.ok(line.length <= townCrier.MAX_LINE_LEN, `line too long: ${line.length}`)
assert.ok(lines[0].endsWith('…'))
- assert.ok(lines.length <= logic.MAX_LINES)
+ assert.ok(lines.length <= townCrier.MAX_LINES)
})
test('buildTownCrierText omits the excerpt line when there is no excerpt or body', () => {
- const lines = logic.buildTownCrierText(
+ const lines = townCrier.buildTownCrierText(
{ id: 1, title: 'Only a title', excerpt: null, body: null },
{ baseUrl: 'https://uom.example' },
)
@@ -40,27 +44,27 @@ test('buildTownCrierText omits the excerpt line when there is no excerpt or body
})
// ── classifyTownCrier ────────────────────────────────────────────────────────
-test('classifyTownCrier: 2xx is done', () => {
- assert.equal(logic.classifyTownCrier({ ok: true, status: 200 }).outcome, 'done')
+test('town crier classify: 2xx is done', () => {
+ assert.equal(townCrier.classify({ ok: true, status: 200 }).outcome, 'done')
})
-test('classifyTownCrier: over-cap / auth / protocol errors are terminal (no retry)', () => {
+test('town crier classify: over-cap / auth / protocol errors are terminal (no retry)', () => {
for (const status of [400, 401, 409]) {
- assert.equal(logic.classifyTownCrier({ ok: false, status }).outcome, 'terminal', `status ${status}`)
+ assert.equal(townCrier.classify({ ok: false, status }).outcome, 'terminal', `status ${status}`)
}
})
-test('classifyTownCrier: shard-transient and network errors retry', () => {
+test('town crier classify: shard-transient and network errors retry', () => {
for (const status of [503, 504, 500, 0]) {
- assert.equal(logic.classifyTownCrier({ ok: false, status }).outcome, 'retry', `status ${status}`)
+ assert.equal(townCrier.classify({ ok: false, status }).outcome, 'retry', `status ${status}`)
}
})
// ── classifyDiscord ──────────────────────────────────────────────────────────
-test('classifyDiscord: ok is done, every failure retries', () => {
- assert.equal(logic.classifyDiscord({ ok: true, status: 200 }).outcome, 'done')
+test('discord classify: ok is done, every failure retries', () => {
+ assert.equal(discord.classify({ ok: true, status: 200 }).outcome, 'done')
for (const status of [400, 503, 0]) {
- assert.equal(logic.classifyDiscord({ ok: false, status }).outcome, 'retry', `status ${status}`)
+ assert.equal(discord.classify({ ok: false, status }).outcome, 'retry', `status ${status}`)
}
})
@@ -75,12 +79,27 @@ test('scheduleAfter returns increasing delays then null at the attempt cap', ()
})
// ── rollupStatus ─────────────────────────────────────────────────────────────
-test('rollupStatus derives the parent status from the two legs', () => {
- assert.equal(logic.rollupStatus('done', 'done'), 'done')
- assert.equal(logic.rollupStatus('failed', 'failed'), 'failed')
- assert.equal(logic.rollupStatus('pending', 'pending'), 'pending')
- // One terminal, the other not matching → partial.
- assert.equal(logic.rollupStatus('done', 'pending'), 'partial')
- assert.equal(logic.rollupStatus('pending', 'failed'), 'partial')
- assert.equal(logic.rollupStatus('done', 'failed'), 'partial')
+// Takes the LIST of leg statuses, not two named legs: which legs exist is what a
+// module decides (MODULE_SYSTEM.md §1.8).
+test('rollupStatus derives the parent status from its legs', () => {
+ assert.equal(logic.rollupStatus(['done', 'done']), 'done')
+ assert.equal(logic.rollupStatus(['failed', 'failed']), 'failed')
+ assert.equal(logic.rollupStatus(['pending', 'pending']), 'pending')
+ // One terminal, the others not matching → partial.
+ assert.equal(logic.rollupStatus(['done', 'pending']), 'partial')
+ assert.equal(logic.rollupStatus(['pending', 'failed']), 'partial')
+ assert.equal(logic.rollupStatus(['done', 'failed']), 'partial')
+})
+
+test('rollupStatus generalises past two legs', () => {
+ assert.equal(logic.rollupStatus(['done', 'done', 'done']), 'done')
+ assert.equal(logic.rollupStatus(['done', 'done', 'pending']), 'partial')
+ assert.equal(logic.rollupStatus(['pending', 'pending', 'pending']), 'pending')
+ assert.equal(logic.rollupStatus(['failed', 'failed', 'failed']), 'failed')
+ // A single leg is not a special case.
+ assert.equal(logic.rollupStatus(['pending']), 'pending')
+ assert.equal(logic.rollupStatus(['done']), 'done')
+ // No legs registered at all: nothing is left to deliver, so the job is done
+ // rather than pending forever on a leg that does not exist.
+ assert.equal(logic.rollupStatus([]), 'done')
})
diff --git a/server/test/announceLegs.test.js b/server/test/announceLegs.test.js
new file mode 100644
index 0000000..173e2d5
--- /dev/null
+++ b/server/test/announceLegs.test.js
@@ -0,0 +1,206 @@
+// ── The announcement pipeline, once legs became registrations ──────────────
+//
+// Phase 2 PR 4 (docs/website/MODULE_SYSTEM.md §1.8): `announce_jobs`' two
+// hardcoded leg column groups became `announce_job_legs` rows, and which legs
+// exist is what modules/registries.js answers. These tests are about that
+// property specifically — that nothing in the worker or the model knows the word
+// "towncrier", and a leg nobody registered is handled rather than assumed away.
+//
+// Point the DB at a closed port BEFORE requiring anything; every DB call the
+// model makes is stubbed.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const registries = require('../src/modules/registries')
+const announceJobs = require('../src/model/announceJobs/announceJobs.model')
+const announceDb = require('../src/model/announceJobs/announceJobs.db')
+const worker = require('../src/utils/announceWorker')
+const posts = require('../src/model/posts/posts.model')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+const originals = {
+ create: announceDb.create,
+ findById: announceDb.findById,
+ findByPostId: announceDb.findByPostId,
+ findDue: announceDb.findDue,
+ updateLeg: announceDb.updateLeg,
+ ensureLegs: announceDb.ensureLegs,
+ setStatus: announceDb.setStatus,
+ linkAnnounceJob: posts.linkAnnounceJob,
+ markAnnounced: posts.markAnnounced,
+ getById: posts.getById,
+}
+afterEach(() => Object.assign(announceDb, {
+ create: originals.create,
+ findById: originals.findById,
+ findByPostId: originals.findByPostId,
+ findDue: originals.findDue,
+ updateLeg: originals.updateLeg,
+ ensureLegs: originals.ensureLegs,
+ setStatus: originals.setStatus,
+}) && Object.assign(posts, {
+ linkAnnounceJob: originals.linkAnnounceJob,
+ markAnnounced: originals.markAnnounced,
+ getById: originals.getById,
+}))
+
+// Two legs that record what they were asked to do, standing in for core's
+// discord and a module's own.
+function fakeLegs() {
+ const calls = []
+ return {
+ calls,
+ a: { leg: 'discord', label: 'Discord #news', dispatch: async (p) => { calls.push(['discord', p.id]); return { ok: true } }, classify: (r) => (r.ok ? { outcome: 'done' } : { outcome: 'retry', error: 'x' }) },
+ b: { leg: 'rust.motd', label: 'Server MOTD', dispatch: async (p) => { calls.push(['rust.motd', p.id]); return { ok: false, status: 503 } }, classify: (r) => (r.ok ? { outcome: 'done' } : { outcome: 'retry', error: 'down' }) },
+ }
+}
+
+beforeEach(() => registries._reset())
+
+function register(...legs) {
+ const api = registries.stage('core')
+ for (const l of legs) api.registerAnnounceLeg(l)
+ registries.apply(api.staged)
+}
+
+// ── Enqueue ────────────────────────────────────────────────────────────────
+
+test('enqueue creates one leg row per REGISTERED leg, whatever they are', () => {
+ const legs = fakeLegs()
+ register(legs.a, legs.b)
+
+ let created = null
+ announceDb.create = async (postId, ids) => { created = { postId, ids }; return 42 }
+ posts.linkAnnounceJob = async () => {}
+
+ return announceJobs.enqueue(9).then((jobId) => {
+ assert.equal(jobId, 42)
+ assert.deepEqual(created, { postId: 9, ids: ['discord', 'rust.motd'] })
+ })
+})
+
+test('with no legs registered, a job is created with none — and rolls up done', async () => {
+ let created = null
+ announceDb.create = async (postId, ids) => { created = { postId, ids }; return 1 }
+ posts.linkAnnounceJob = async () => {}
+ await announceJobs.enqueue(9)
+ assert.deepEqual(created.ids, [])
+
+ announceDb.findById = async () => ({ id: 1, post_id: 9, status: 'pending', legs: [] })
+ const statuses = []
+ announceDb.setStatus = async (_id, s) => statuses.push(s)
+ posts.markAnnounced = async () => {}
+ const job = await announceJobs.refreshStatus(1)
+ assert.equal(job.status, 'done')
+ assert.deepEqual(statuses, ['done'])
+})
+
+// ── The worker dispatches through the registration ─────────────────────────
+
+test('the worker dispatches each due leg through whatever registered it', async () => {
+ const legs = fakeLegs()
+ register(legs.a, legs.b)
+ posts.getById = async () => ({ id: 5, title: 't', excerpt: 'e', body: null })
+
+ const updates = []
+ announceDb.findDue = async () => [{
+ id: 1,
+ post_id: 5,
+ status: 'pending',
+ legs: [
+ { leg: 'discord', status: 'pending', attempts: 0, next_attempt_at: null },
+ { leg: 'rust.motd', status: 'pending', attempts: 0, next_attempt_at: null },
+ ],
+ }]
+ announceDb.updateLeg = async (jobId, leg, fields) => updates.push([leg, fields.status])
+ announceDb.findById = async () => ({ id: 1, post_id: 5, status: 'pending', legs: [] })
+ announceDb.setStatus = async () => {}
+ posts.markAnnounced = async () => {}
+
+ await worker.tick(new Date())
+
+ assert.deepEqual(legs.calls, [['discord', 5], ['rust.motd', 5]])
+ // discord delivered; rust.motd got a 503, so it is rescheduled, not failed.
+ assert.deepEqual(updates, [['discord', 'done'], ['rust.motd', 'pending']])
+})
+
+test('a leg row nobody registers any more is left alone, not failed', async () => {
+ // Its module was uninstalled. Failing it would roll the job up terminal on the
+ // strength of a leg that no longer exists, and reinstalling should resume it.
+ register(fakeLegs().a)
+ let touched = false
+ announceDb.updateLeg = async () => { touched = true }
+ posts.getById = async () => { throw new Error('must not even look up the post') }
+
+ await worker.processLeg({ id: 1, post_id: 5, legs: [{ leg: 'gone.leg', status: 'pending', attempts: 0 }] }, 'gone.leg')
+ assert.equal(touched, false)
+})
+
+test('isLegDue reads the row, not a leg-prefixed column', () => {
+ const past = new Date(Date.now() - 1000)
+ const future = new Date(Date.now() + 60_000)
+ assert.equal(worker.isLegDue({ status: 'pending', next_attempt_at: null }, new Date()), true)
+ assert.equal(worker.isLegDue({ status: 'pending', next_attempt_at: past }, new Date()), true)
+ assert.equal(worker.isLegDue({ status: 'pending', next_attempt_at: future }, new Date()), false)
+ assert.equal(worker.isLegDue({ status: 'done', next_attempt_at: null }, new Date()), false)
+ assert.equal(worker.isLegDue({ status: 'failed', next_attempt_at: null }, new Date()), false)
+})
+
+// ── Retry, and the label the panel renders ─────────────────────────────────
+
+test('resetLeg refuses a leg nobody registered', async () => {
+ register(fakeLegs().a)
+ await assert.rejects(() => announceJobs.resetLeg(5, 'rust.motd'), /unknown announce leg/)
+})
+
+test('resetLeg creates the row when a module was installed after the job', async () => {
+ // Otherwise the retry button could never deliver a newly-installed module's
+ // leg on an already-announced post: the worker only sees rows that exist.
+ const legs = fakeLegs()
+ register(legs.a, legs.b)
+ const ensured = []
+ announceDb.findByPostId = async () => ({ id: 1, post_id: 5, status: 'partial', legs: [{ leg: 'discord', status: 'done' }] })
+ announceDb.ensureLegs = async (jobId, ids) => ensured.push([jobId, ids])
+ announceDb.updateLeg = async () => {}
+ announceDb.findById = async () => ({ id: 1, post_id: 5, status: 'partial', legs: [{ leg: 'discord', status: 'done' }, { leg: 'rust.motd', status: 'pending' }] })
+ announceDb.setStatus = async () => {}
+
+ const job = await announceJobs.resetLeg(5, 'rust.motd')
+ assert.deepEqual(ensured, [[1, ['rust.motd']]])
+ assert.deepEqual(job.legs.map((l) => l.label), ['Discord #news', 'Server MOTD'])
+})
+
+test('a leg’s label comes from its registration, and an orphan keeps its id', async () => {
+ register(fakeLegs().a)
+ announceDb.findByPostId = async () => ({
+ id: 1,
+ post_id: 5,
+ status: 'partial',
+ legs: [{ leg: 'discord', status: 'done' }, { leg: 'gone.leg', status: 'pending' }],
+ })
+ const job = await announceJobs.getByPostId(5)
+ assert.deepEqual(job.legs.map((l) => [l.leg, l.label]), [
+ ['discord', 'Discord #news'],
+ ['gone.leg', 'gone.leg'],
+ ])
+})
+
+// ── recordOutcome reads attempts off the row ───────────────────────────────
+
+test('recordOutcome takes the attempt count from the leg row', async () => {
+ register(fakeLegs().a)
+ const updates = []
+ announceDb.updateLeg = async (jobId, leg, fields) => updates.push(fields)
+ announceDb.findById = async () => ({ id: 1, post_id: 5, status: 'pending', legs: [{ leg: 'discord', status: 'pending' }] })
+ announceDb.setStatus = async () => {}
+
+ const job = { id: 1, post_id: 5, legs: [{ leg: 'discord', status: 'pending', attempts: 2 }] }
+ await announceJobs.recordOutcome(job, 'discord', { outcome: 'retry', error: 'nope' })
+ assert.equal(updates[0].attempts, 3)
+ assert.ok(updates[0].nextAttemptAt instanceof Date)
+})
diff --git a/server/test/moduleLoader.test.js b/server/test/moduleLoader.test.js
index e6212f0..dd5b09f 100644
--- a/server/test/moduleLoader.test.js
+++ b/server/test/moduleLoader.test.js
@@ -27,8 +27,16 @@ const assert = require('node:assert/strict')
const express = require('express')
const db = require('../src/utils/db')
+const registries = require('../src/modules/registries')
const { startApp } = require('./_helper')
+// Requiring the real admin router declares the `admin.users.detail` extension
+// slot exactly the way production does (users.router.js, at require time). Doing
+// it here rather than calling declareSlot by hand matters: one test below builds
+// the real tier routers, and a hand-declared slot would collide with that
+// require's own declaration.
+require('../src/router/v1/admin')
+
after(() => db.close())
let tmpRoot
@@ -42,6 +50,11 @@ const emptyTiers = () => ({
function freshLoader(dir, tiers = emptyTiers()) {
process.env.MODULES_DIR = dir
+ // The registries are process-global (there is one core), so hand the process
+ // back between tests. Without this a module's staged registrations from a
+ // previous test would still be committed, and every collision assertion below
+ // would be asserting against the wrong history.
+ registries._reset()
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
const loader = require('../src/modules/loader')
@@ -493,13 +506,10 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
assert.equal(probe.mutable, false, 'ctx members must be frozen')
})
-test('the register calls PR 4 and PR 5 own throw rather than silently accepting', () => {
- // An accepting no-op would let a module believe it had registered a
- // notification stream or a boot hook and fail silently at the far end.
+test('the register calls PR 5 owns throw rather than silently accepting', () => {
+ // An accepting no-op would let a module believe it had registered a boot hook
+ // and fail silently at the far end.
for (const [call, pr] of [
- ['registerExtension', 4],
- ['registerNotificationStreams', 4],
- ['registerAnnounceLeg', 4],
['onBoot', 5],
['onShutdown', 5],
]) {
@@ -511,3 +521,56 @@ test('the register calls PR 4 and PR 5 own throw rather than silently accepting'
)
}
})
+
+// ── Staged registrations are committed only for a module that survives ─────
+
+test('a module that fails AFTER registering leaves nothing in the registries', () => {
+ // The registry-side twin of the second-pass mount rule. register() runs before
+ // checkDeclared, so a module can stage a stream catalog and then be rejected —
+ // and a half-registered catalog is worse than a missing one, because it is a
+ // subscribable stream nothing will ever publish to.
+ writeModule('halfway', {
+ manifest: { mounts: { public: ['/declared'] } },
+ server: `module.exports = (ctx, api) => {
+ api.registerNotificationStreams([{ id: 'halfway.thing', label: 'Thing' }])
+ api.registerAnnounceLeg({ leg: 'halfway.leg', label: 'L', dispatch: async () => ({}), classify: () => ({}) })
+ // declared /declared and never registered it → rejected by checkDeclared
+ }`,
+ })
+ const loader = freshLoader(tmpRoot)
+
+ assert.match(stateOf(loader, 'halfway').reason, /declared public\/declared but never registered it/)
+ assert.equal(registries.isValidStream('halfway.thing'), false)
+ assert.equal(registries.announceLeg('halfway.leg'), null)
+})
+
+test('a module colliding with an already-registered name fails alone, unmounted', () => {
+ const tiers = emptyTiers()
+ writeModule('first', {
+ manifest: { mounts: { public: ['/first'] } },
+ server: `module.exports = (ctx, api) => {
+ api.registerRoutes({ public: { '/first': ctx.express.Router() } })
+ api.registerNotificationStreams([{ id: 'first.shared', label: 'Shared' }])
+ }`,
+ })
+ writeModule('second', {
+ manifest: { mounts: { public: ['/second'] } },
+ server: `module.exports = (ctx, api) => {
+ api.registerRoutes({ public: { '/second': ctx.express.Router() } })
+ api.registerNotificationStreams([{ id: 'second.ok', label: 'Ok' }, { id: 'first.shared', label: 'Mine' }])
+ }`,
+ })
+ const loader = freshLoader(tmpRoot, tiers)
+
+ assert.equal(stateOf(loader, 'first').state, 'registered')
+ assert.match(stateOf(loader, 'second').reason, /already registered by "first"/)
+ // Not even the claim that did not collide.
+ assert.equal(registries.isValidStream('second.ok'), false)
+ // And the loser is not mounted at all. Asked of the live router the way the
+ // prefix-ownership check asks it, rather than by counting layers — one mount
+ // produces two (the dispatch guard, then the module's router).
+ const claims = (prefix) =>
+ tiers.public.stack.some((l) => l.regexp && !l.regexp.fast_slash && l.match(prefix))
+ assert.equal(claims('/first'), true)
+ assert.equal(claims('/second'), false)
+})
diff --git a/server/test/moduleRegistries.test.js b/server/test/moduleRegistries.test.js
new file mode 100644
index 0000000..c2a9f26
--- /dev/null
+++ b/server/test/moduleRegistries.test.js
@@ -0,0 +1,216 @@
+// ── The three de-entanglement registries ───────────────────────────────────
+//
+// Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. The properties worth a test
+// are the ones nobody exercises by hand: what happens when two registrants want
+// the same name, and what is left behind when one of them fails halfway.
+//
+// Point the DB at a closed port BEFORE requiring anything — registerCore() pulls
+// in the announce legs, which pull in models that build a pool at require time.
+// No query is ever run.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const registries = require('../src/modules/registries')
+const db = require('../src/utils/db')
+
+// Declares `admin.users.detail` the way production does — at require time, in the
+// router that owns the resource.
+require('../src/router/v1/admin')
+
+after(() => db.close())
+
+beforeEach(() => registries._reset())
+
+const stream = (id, over = {}) => ({ id, label: id, ...over })
+const leg = (id, over = {}) => ({ leg: id, label: id, dispatch: async () => ({ ok: true }), classify: () => ({ outcome: 'done' }), ...over })
+
+/** Register a batch as `owner` and return the error message, or null on success. */
+function tryApply(owner, build) {
+ const api = registries.stage(owner)
+ try {
+ build(api)
+ registries.apply(api.staged)
+ return null
+ } catch (err) {
+ return err.message
+ }
+}
+
+// ── Core goes through the same door ────────────────────────────────────────
+
+test('registerCore registers core AND the not-yet-extracted shard content', () => {
+ registries.registerCore()
+
+ const ids = registries.allStreams().map((s) => s.id)
+ // Core's one stream first, then the seven that leave with module-uo.
+ assert.equal(ids[0], 'news.post')
+ assert.equal(ids.length, 8)
+ assert.ok(ids.includes('vendor.sale'))
+
+ assert.deepEqual(registries.announceLegIds(), ['discord', 'towncrier'])
+ assert.equal(registries.slotFilledBy('admin.users.detail'), 'core')
+ assert.equal(registries.isCoreRegistered(), true)
+})
+
+test('registerCore is idempotent — a second call registers nothing twice', () => {
+ registries.registerCore()
+ const before = registries.allStreams().length
+ registries.registerCore()
+ assert.equal(registries.allStreams().length, before)
+})
+
+test('the wire shape of a stream survives registration', () => {
+ registries.registerCore()
+ const personal = registries.allStreams().find((s) => s.id === 'vendor.sale')
+ // Two booleans, not the contract's single `scope`: this object is the body of
+ // GET /auth/me/notifications/streams and a shipped Android client reads both.
+ assert.equal(personal.personal, true)
+ assert.equal(personal.requiresLinkedAccount, true)
+ assert.ok(personal.description.length > 0)
+ assert.ok(registries.personalStreams().has('vendor.sale'))
+ assert.equal(registries.isValidStream('vendor.sale'), true)
+ assert.equal(registries.isValidStream('nope.nope'), false)
+})
+
+// ── Namespacing ────────────────────────────────────────────────────────────
+
+test('a module’s stream ids must carry its module id', () => {
+ assert.equal(tryApply('rust', (api) => api.registerNotificationStreams([stream('rust.raid')])), null)
+ assert.match(
+ tryApply('rust', (api) => api.registerNotificationStreams([stream('raid.started')])),
+ /not namespaced "rust\."/,
+ )
+})
+
+test('the seven pre-module-system stream ids are grandfathered to uo alone', () => {
+ // Renaming them would be a data migration (notification_subs rows) and a break
+ // for a shipped Android client — the same reasoning as the loader's legacy
+ // table prefixes.
+ assert.equal(tryApply('uo', (api) => api.registerNotificationStreams([stream('vendor.sale')])), null)
+ registries._reset()
+ assert.match(
+ tryApply('rust', (api) => api.registerNotificationStreams([stream('vendor.sale')])),
+ /not namespaced "rust\."/,
+ )
+})
+
+test('an announce leg must be namespaced too, with towncrier grandfathered to uo', () => {
+ assert.equal(tryApply('uo', (api) => api.registerAnnounceLeg(leg('towncrier'))), null)
+ registries._reset()
+ assert.equal(tryApply('rust', (api) => api.registerAnnounceLeg(leg('rust.motd'))), null)
+ registries._reset()
+ assert.match(
+ tryApply('rust', (api) => api.registerAnnounceLeg(leg('towncrier'))),
+ /not namespaced "rust\."/,
+ )
+})
+
+// ── Collisions name the holder ─────────────────────────────────────────────
+
+test('a stream core already registered is refused, naming core', () => {
+ registries.registerCore()
+ assert.match(
+ tryApply('uo', (api) => api.registerNotificationStreams([stream('news.post')])),
+ /already registered by "core"/,
+ )
+})
+
+test('two modules cannot register the same stream or leg', () => {
+ assert.equal(tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.thing')])), null)
+ // A second module can only reach it via its own namespace, so collide on a
+ // grandfathered id, which is the realistic case.
+ assert.match(
+ tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.thing')])),
+ /already registered by "aaa"/,
+ )
+ assert.equal(tryApply('bbb', (api) => api.registerAnnounceLeg(leg('bbb.x'))), null)
+ assert.match(tryApply('bbb', (api) => api.registerAnnounceLeg(leg('bbb.x'))), /already registered by "bbb"/)
+})
+
+test('a batch cannot claim the same name twice', () => {
+ assert.match(
+ tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.x'), stream('aaa.x')])),
+ /registered twice/,
+ )
+})
+
+// ── Validate-then-commit ───────────────────────────────────────────────────
+
+test('a batch whose LAST claim collides commits none of the earlier ones', () => {
+ // The property the whole staging design exists for. A half-registered catalog
+ // is worse than a missing one: a subscribable stream nothing will publish to.
+ registries.registerCore()
+ const before = registries.allStreams().length
+
+ const err = tryApply('uo', (api) => {
+ api.registerNotificationStreams([stream('uo.first'), stream('uo.second')])
+ api.registerAnnounceLeg(leg('uo.leg'))
+ api.registerNotificationStreams([stream('news.post')]) // collides with core
+ })
+
+ assert.match(err, /already registered by "core"/)
+ assert.equal(registries.allStreams().length, before, 'uo.first / uo.second must not be registered')
+ assert.equal(registries.isValidStream('uo.first'), false)
+ assert.equal(registries.announceLeg('uo.leg'), null)
+})
+
+test('staging alone changes nothing — only apply() commits', () => {
+ const api = registries.stage('uo')
+ api.registerNotificationStreams([stream('uo.staged')])
+ assert.equal(registries.isValidStream('uo.staged'), false)
+ registries.apply(api.staged)
+ assert.equal(registries.isValidStream('uo.staged'), true)
+})
+
+// ── Shape checks fire at the call ──────────────────────────────────────────
+
+test('a malformed claim throws where the registrant made it, not at apply()', () => {
+ const api = registries.stage('uo')
+ assert.throws(() => api.registerNotificationStreams([stream('nodots')]), /bad stream id/)
+ assert.throws(() => api.registerNotificationStreams([{ id: 'uo.x' }]), /has no label/)
+ assert.throws(() => api.registerNotificationStreams('not an array'), /expected an array/)
+ assert.throws(() => api.registerAnnounceLeg(leg('uo.x', { dispatch: null })), /has no dispatch/)
+ assert.throws(() => api.registerAnnounceLeg(leg('uo.x', { classify: null })), /has no classify/)
+ assert.throws(() => api.registerAnnounceLeg({ leg: 'NOPE' }), /bad leg id/)
+})
+
+// ── Extension slots ────────────────────────────────────────────────────────
+
+test('only a declared slot can be filled, and only once', () => {
+ const router = () => {}
+ const api = registries.stage('uo')
+ assert.throws(() => api.registerExtension('admin.invented', router), /unknown extension slot/)
+ assert.throws(() => api.registerExtension('admin.users.detail', 'not a router'), /is not a router/)
+
+ registries.registerCore() // core fills it
+ assert.match(
+ tryApply('uo', (a) => a.registerExtension('admin.users.detail', router)),
+ /already filled by "core"/,
+ )
+})
+
+test('a slot cannot be declared twice', () => {
+ assert.throws(() => registries.declareSlot('admin.users.detail'), /already declared/)
+})
+
+// ── The slot's spec, which static analysis cannot see ──────────────────────
+
+test('the filled slot’s router is findable in the live app, at the resource path', () => {
+ // Guards swagger/slotSpecs.js: it recovers each slot's mount prefix from the
+ // live stack rather than a hardcoded table. If this stops working, the six
+ // slot routes vanish from swagger-output.json with `Success` printed — the
+ // exact silent failure the spike hit (MODULE_API.md §7.4).
+ /* eslint-disable global-require */
+ const app = require('../src/app')
+ const { findMountPrefix } = require('../swagger/slotSpecs')
+ /* eslint-enable global-require */
+
+ const [slot] = registries.filledSlots()
+ assert.ok(slot, 'app.js must have registered core, filling the slot')
+ assert.equal(slot.slot, 'admin.users.detail')
+ assert.ok(slot.specFile, 'core names the file its slot router is generated from')
+ assert.equal(findMountPrefix(app._router.stack, slot.router), '/api/v1/admin/users/:id')
+})
diff --git a/server/test/pushDispatch.test.js b/server/test/pushDispatch.test.js
index e0ac441..795cfde 100644
--- a/server/test/pushDispatch.test.js
+++ b/server/test/pushDispatch.test.js
@@ -6,8 +6,11 @@ process.env.DB_PORT = '59999'
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
-const { mapShardEvent, createTracker } = require('../src/config/notificationStreams')
+const { mapShardEvent, createTracker } = require('../src/config/shardStreams')
const pushDispatch = require('../src/utils/pushDispatch')
+// fromShardEvent moved out of pushDispatch in PR 4: core publishes, the shard
+// side resolves an event to a stream and an owner (MODULE_SYSTEM.md §1.8).
+const shardPush = require('../src/utils/shardPush')
const db = require('../src/utils/db')
after(() => db.close())
@@ -202,14 +205,14 @@ test('fromShardEvent resolves a personal event to the owning user, or drops it i
}
const deps = { shardLinks, pushDevices, fetchImpl, tracker: createTracker() }
- await pushDispatch.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps)
+ await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps)
assert.deepEqual(userStreamCalls, [[42, 'vendor.sale']])
assert.equal(calls.length, 1)
// Unlinked account → nobody to notify → no publish.
userStreamCalls.length = 0
calls.length = 0
- await pushDispatch.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps)
+ await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps)
assert.equal(userStreamCalls.length, 0)
assert.equal(calls.length, 0)
})
@@ -226,7 +229,7 @@ test('fromShardEvent fans a public shard event to the stream’s subscribers', a
},
endpointsForUserStream: async () => [],
}
- await pushDispatch.fromShardEvent(
+ await shardPush.fromShardEvent(
{ kind: 'server.hello', bootId: 'b1' },
{ pushDevices, fetchImpl, tracker: createTracker() },
)