feat(modules): the three de-entanglement registries, with core as the registrant
All checks were successful
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 1m39s
PR Checks / bot-install (pull_request) Successful in 8m49s

Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. Adds server/src/modules/registries.js
and moves core's own notification streams, announce leg and users-detail routes
behind it, so the three seams §1.8 and §1.9 named are exercised on every boot
before any module depends on them.

Registering is validate-then-commit per registrant: the loader stages what a
module claims and the second pass commits it, so a module that throws halfway
through register() — or fails checkDeclared after it — leaves nothing behind.
That is the registry-side twin of PR 2's second-pass mount rule.

Four decisions, all the recommended option:

- announce legs became a child table. `announce_job_legs` replaces the
  towncrier_*/discord_* column groups, so the leg set is data: core registers
  `discord`, module-uo will register `towncrier`, and a module cannot ALTER a
  core table to add its own. Backfill is guarded on information_schema (a
  SELECT of a dropped column is a parse error, not a runtime one) and the
  columns go with DROP COLUMN IF EXISTS. Verified against the live dev DB:
  three legacy jobs migrated faithfully, three replays, no duplicates.
- `mapEvent` dropped from registerNotificationStreams. §1.8 already inverts the
  push path so a module owns fromShardEvent and calls core's publish() with a
  stream id it resolved; a second mapping mechanism was a leftover. The public
  safety filter, the kinds it reads and the streams it protects now live in one
  file and move together.
- core registers through the same staging area a module uses, via an explicit
  registries.registerCore() in app.js before modules.load().
- core's six /admin/users/:id/shard/* paths now go through the
  `admin.users.detail` slot, and getUser moved back to admin.controller.js.

Found on the way, and the reason two build tools changed:

- scripts/routeManifest.js could not decode a parameterised mount. Its
  unwinder expected `(?:([^\/]+?))`; express 4.22 emits `(?:\/([^/]+?))` with
  the separator inside the group. The branch had never run. It threw rather
  than guessing, which is what it is for.
- swagger-autogen cannot follow a route into an extension slot — the slot's
  router is created by declareSlot() and filled later, so there is no literal
  mount for a static parse. Regenerating deleted 407 lines and printed
  `Swagger-autogen: Success`, the spike's exact failure (MODULE_API.md §7.4).
  swagger/slotSpecs.js generates a fragment per filled slot and re-roots it at
  the prefix the router actually hangs at in the live app — read from the
  express stack via routeManifest's own mountPath, so the manifest and the spec
  cannot disagree. swagger/mergeSpec.js is the merge helper core owes for
  module fragments anyway (§6.1a), proved here against core's own slot first.

884 tests pass (856 before). routes.manifest.json is unchanged at 229 routes.
The OpenAPI spec diff is two lines of intent: the retry endpoint's summary, and
its `leg` no longer being a fixed enum.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-10 17:47:59 -05:00
parent bd749d4f1f
commit 6195c76d61
36 changed files with 1948 additions and 465 deletions

View File

@@ -177,14 +177,15 @@ const delStyle = {
} }
// ── Announcement status panel ──────────────────────────────────────────────── // ── Announcement status panel ────────────────────────────────────────────────
// Shows the town-crier + Discord delivery state for a published news post and // Shows each delivery leg's state for a published news post and offers a per-leg
// offers a per-leg retry (useful after fixing the sidecar / news channel without // retry (useful after fixing the sidecar / news channel without re-publishing).
// re-publishing). Only rendered for news posts in edit mode; renders nothing // Only rendered for news posts in edit mode; renders nothing until the post has
// until the post has actually been announced (no job row yet → nothing to show). // actually been announced (no job row yet → nothing to show).
const LEG_META = { //
towncrier: { label: 'In-game town crier' }, // The legs and their labels come from the JOB, not from a constant here: which
discord: { label: 'Discord #news' }, // 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 = { const STATUS_STYLE = {
done: { color: '#7bbf8f', label: 'delivered' }, done: { color: '#7bbf8f', label: 'delivered' },
pending: { color: '#d9b84a', label: 'pending' }, pending: { color: '#d9b84a', label: 'pending' },
@@ -227,14 +228,12 @@ function AnnouncePanel({ postId }) {
return ( return (
<div style={panelStyle}> <div style={panelStyle}>
<span className="field-label" style={{ marginBottom: 2 }}>Announcement</span> <span className="field-label" style={{ marginBottom: 2 }}>Announcement</span>
{['towncrier', 'discord'].map((leg) => { {(job.legs || []).map(({ leg, label, status, last_error: err }) => {
const status = job[`${leg}_status`]
const err = job[`${leg}_last_error`]
const s = STATUS_STYLE[status] || STATUS_STYLE.pending const s = STATUS_STYLE[status] || STATUS_STYLE.pending
return ( return (
<div key={leg} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <div key={leg} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{LEG_META[leg].label}</span> <span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{label}</span>
<span className="sans" style={{ fontSize: '0.8rem', color: s.color, fontWeight: 600 }}> {s.label}</span> <span className="sans" style={{ fontSize: '0.8rem', color: s.color, fontWeight: 600 }}> {s.label}</span>
{status !== 'done' && ( {status !== 'done' && (
<button <button

View File

@@ -1106,35 +1106,79 @@ CREATE TABLE IF NOT EXISTS pages (
-- Announcement pipeline. One row per publish event of a news post; the table -- Announcement pipeline. One row per publish event of a news post; the table
-- doubles as the job queue (a light in-process poller — utils/announceWorker.js -- doubles as the job queue (a light in-process poller — utils/announceWorker.js
-- — sweeps it for due legs). Two INDEPENDENT delivery legs so a Discord outage -- — sweeps it for due legs). `status` is a derived rollup of the legs (see
-- never blocks or retries the in-game town-crier leg and vice versa. `status` is -- announceJobs.logic.js): done when every leg is done, failed when every leg is
-- a derived rollup of the two legs (see announceJobs.logic.js): done when both -- exhausted, partial in between. post_id is INT (matches posts.id) and cascades
-- legs done, failed when both exhausted, partial in between. Each leg tracks its -- so deleting a post reaps its jobs. posts.announce_job_id points back at the
-- own attempt count, last error, and next-due time for exponential backoff. -- latest row for admin lookups.
-- post_id is INT (matches posts.id) and cascades so deleting a post reaps its
-- jobs. posts.announce_job_id points back at the latest row for admin lookups.
CREATE TABLE IF NOT EXISTS announce_jobs ( CREATE TABLE IF NOT EXISTS announce_jobs (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL, post_id INT NOT NULL,
status ENUM('pending','partial','done','failed') NOT NULL DEFAULT 'pending', status ENUM('pending','partial','done','failed') NOT NULL DEFAULT 'pending',
towncrier_status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
towncrier_attempts SMALLINT NOT NULL DEFAULT 0,
towncrier_last_error TEXT NULL,
towncrier_next_attempt_at DATETIME NULL,
discord_status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
discord_attempts SMALLINT NOT NULL DEFAULT 0,
discord_last_error TEXT NULL,
discord_next_attempt_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_announce_jobs_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, CONSTRAINT fk_announce_jobs_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
INDEX idx_announce_due (towncrier_status, towncrier_next_attempt_at),
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- One row per delivery leg per job. INDEPENDENT by design: a Discord outage never
-- blocks or retries another leg, and each leg tracks its own attempt count, last
-- error and next-due time for exponential backoff.
--
-- This is a child table rather than a pair of leg-prefixed column groups on
-- announce_jobs because the leg set is DATA now, not schema: core registers
-- `discord`, module-uo registers `towncrier`, and a module for another game
-- registers its own — through modules/registries.js's registerAnnounceLeg
-- (MODULE_SYSTEM.md §1.8). A module cannot ALTER a core table, so a leg that
-- needed its own columns could never come from a module at all. `leg` is a plain
-- VARCHAR and not an ENUM for the same reason.
CREATE TABLE IF NOT EXISTS announce_job_legs (
job_id INT NOT NULL,
leg VARCHAR(64) NOT NULL,
status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
attempts SMALLINT NOT NULL DEFAULT 0,
last_error TEXT NULL,
next_attempt_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (job_id, leg),
CONSTRAINT fk_announce_job_legs_job FOREIGN KEY (job_id) REFERENCES announce_jobs(id) ON DELETE CASCADE,
INDEX idx_announce_leg_due (status, next_attempt_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Carry the two hardcoded leg column groups over to the child table, once. Guarded
-- on the OLD columns still existing (via information_schema, since a plain SELECT
-- of a dropped column is a parse error, not a runtime one) and on there being no
-- row already, so replaying this file on every boot is a no-op after the first.
-- Deleting this block once every deployment has booted it is safe.
SET @has_legacy_legs := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'announce_jobs'
AND COLUMN_NAME = 'towncrier_status'
);
SET @sql := IF(@has_legacy_legs > 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) ─────────────────────────────────────── -- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
-- Static shard CONTENT, not live shard state: what spawns where, which regions -- Static shard CONTENT, not live shard state: what spawns where, which regions
-- and landmarks exist, and which champion altars are configured. Nothing here -- and landmarks exist, and which champion altars are configured. Nothing here

View File

@@ -1062,7 +1062,7 @@
{ {
"method": "DELETE", "method": "DELETE",
"path": "/api/v1/admin/users/:id/shard/link/:account", "path": "/api/v1/admin/users/:id/shard/link/:account",
"handlers": 5, "handlers": 4,
"gates": [ "gates": [
"noindex", "noindex",
"requireAuth", "requireAuth",

View File

@@ -64,9 +64,16 @@ const PUBLIC_PREFIXES = ['/api/', '/.well-known/']
* *
* Express keeps no copy of the mount string, only the compiled regexp. For a * 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 * literal mount (`/api/v1`) that is `^\/api\/v1\/?(?=\/|$)`; a parameterised mount
* contributes one `(?:([^\/]+?))` group per entry in `layer.keys`. Unwinding both * contributes one group per entry in `layer.keys`, and the separator before the
* gets us back to `/api/v1` and `/thing/:id` respectively. `fast_slash` is * parameter lives INSIDE that group — express 4.22 compiles `use('/:id', r)` to
* express's marker for a router mounted at the root, which contributes nothing. * `^(?:\/([^/]+?))\/?(?=\/|$)`. 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) { function mountPath(layer) {
const re = layer.regexp const re = layer.regexp
@@ -79,9 +86,11 @@ function mountPath(layer) {
const keys = layer.keys || [] const keys = layer.keys || []
let i = 0 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++] 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. // Whatever is left should be a literal path with regexp-escaped separators.

View File

@@ -11,6 +11,7 @@ const swaggerUi = require('swagger-ui-express')
const apiRouter = require('./router/api.router') const apiRouter = require('./router/api.router')
const modules = require('./modules/loader') const modules = require('./modules/loader')
const registries = require('./modules/registries')
const wellKnown = require('./router/wellKnown.controller') const wellKnown = require('./router/wellKnown.controller')
const cspReport = require('./router/cspReport.controller') const cspReport = require('./router/cspReport.controller')
const brand = require('./config/brand') 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 // The three requires resolve from cache to the very routers v1.router.js
// mounted; this is a reference to them, not a second copy. // 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({ modules.load({
public: require('./router/v1/public'), public: require('./router/v1/public'),
admin: require('./router/v1/admin'), admin: require('./router/v1/admin'),

View File

@@ -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 }

View File

@@ -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 // MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8 named
// a shard event maps onto them. Two families: // 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 // • public / opt-in — no linked game account required; delivered to every
// subscriber. Drawn ONLY from the SSE public allowlist // subscriber. Drawn ONLY from the SSE public allowlist
// (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind // (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind
@@ -17,17 +24,7 @@
const { PUBLIC_KINDS } = require('../utils/shardBroadcast') 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 = [ const STREAMS = [
{
id: 'news.post',
label: 'News posts',
description: 'New news / Five-on-Friday / newsletter posts.',
personal: false,
requiresLinkedAccount: false,
},
{ {
id: 'server.status', id: 'server.status',
label: 'Server up / down', label: 'Server up / down',
@@ -79,8 +76,10 @@ const STREAMS = [
}, },
] ]
const STREAM_IDS = new Set(STREAMS.map((s) => s.id)) // The owner-keyed subset, needed by mapShardEvent's public-safety filter below.
const isValidStream = (id) => STREAM_IDS.has(id) // 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)) 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 // 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 // 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 // owner-keyed split). This guarantees a sensitive kind can never leak publicly
// even if a future mapping case is added carelessly. // 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))) 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 }

View File

@@ -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 { query } = require('../../utils/db')
const COLS = const COLS = 'id, post_id, status, created_at, updated_at'
'id, post_id, status, ' + const LEG_COLS = 'job_id, leg, status, attempts, last_error, next_attempt_at'
'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'
// Whitelist so a `leg` value can be interpolated into a column name safely — it async function legsFor(jobIds) {
// never comes from raw user input, but keep the guard explicit. if (jobIds.length === 0) return new Map()
const LEGS = ['towncrier', 'discord'] const marks = jobIds.map(() => '?').join(', ')
function assertLeg(leg) { const rows = await query(
if (!LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`) `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]) 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) { async function findById(id) {
const rows = await query(`SELECT ${COLS} FROM announce_jobs WHERE id = ? LIMIT 1`, [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) { 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`, `SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
[postId], [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 // 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) { async function findDue(now = new Date(), limit = 25) {
return query( const rows = await query(
`SELECT ${COLS} FROM announce_jobs `SELECT ${COLS} FROM announce_jobs j
WHERE (towncrier_status = 'pending' WHERE EXISTS (
AND (towncrier_next_attempt_at IS NULL OR towncrier_next_attempt_at <= ?)) SELECT 1 FROM announce_job_legs l
OR (discord_status = 'pending' WHERE l.job_id = j.id
AND (discord_next_attempt_at IS NULL OR discord_next_attempt_at <= ?)) AND l.status = 'pending'
ORDER BY id ASC AND (l.next_attempt_at IS NULL OR l.next_attempt_at <= ?))
ORDER BY j.id ASC
LIMIT ?`, LIMIT ?`,
[now, now, limit], [now, limit],
) )
return attachLegs(rows)
} }
// Update one leg's columns. `fields` uses leg-agnostic keys (status, attempts, // Update one leg's row. `leg` is a bound VALUE, not an interpolated column name —
// lastError, nextAttemptAt); we map them onto the leg-prefixed columns. // the reason the old leg allowlist that guarded that interpolation is gone. A
async function updateLeg(id, leg, { status, attempts, lastError, nextAttemptAt }) { // module's leg id could not have passed it anyway.
assertLeg(leg) async function updateLeg(jobId, leg, { status, attempts, lastError, nextAttemptAt }) {
await query( await query(
`UPDATE announce_jobs SET `UPDATE announce_job_legs SET
${leg}_status = ?, status = ?, attempts = ?, last_error = ?, next_attempt_at = ?
${leg}_attempts = ?, WHERE job_id = ? AND leg = ?`,
${leg}_last_error = ?, [status, attempts, lastError ?? null, nextAttemptAt ?? null, jobId, leg],
${leg}_next_attempt_at = ?
WHERE id = ?`,
[status, attempts, lastError ?? null, nextAttemptAt ?? null, id],
) )
} }
@@ -65,4 +109,4 @@ async function setStatus(id, status) {
await query('UPDATE announce_jobs SET status = ? WHERE id = ?', [status, id]) 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 }

View File

@@ -1,87 +1,38 @@
// ── Announcement pipeline: pure logic ────────────────────────────────────── // ── Announcement pipeline: pure logic ──────────────────────────────────────
// //
// No DB, no network — just the decisions the worker and model make, kept here so // No DB, no network — just the LEG-AGNOSTIC decisions the worker and model make,
// they are unit-testable in isolation (server/test/announceJobs.test.js): // 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
// • scheduleAfter — exponential backoff schedule + the attempt cap // • scheduleAfter — exponential backoff schedule + the attempt cap
// • rollupStatus — derive the parent job status from the two legs // • rollupStatus — derive the parent job status from its legs
// • legError — squeeze a client result into one error line
const { deriveExcerpt } = require('../../utils/sanitizeHtml') // • baseUrl / articleUrl — the public link an announcement carries
//
// Sidecar town-crier caps, mirrored from the admin route validation // What used to be here and is not any more: `buildTownCrierText`,
// (admin/uoLink.router.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })). // `classifyTownCrier` and `classifyDiscord`. A leg's own text-building and result
// We pre-truncate to these so a published post never bounces with towncrier.error. // classification belong to the leg, and a leg is a registration now
const MAX_LINES = 8 // (MODULE_SYSTEM.md §1.8) — they live in utils/shardAnnounce.js and
const MAX_LINE_LEN = 200 // utils/discordAnnounce.js. This file is what every leg shares.
// Backoff between retries, indexed by attempts-so-far. Six attempts spread over // 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 // ~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 BACKOFF_MS = [30_000, 120_000, 600_000, 1_800_000, 3_600_000, 7_200_000]
const MAX_ATTEMPTS = BACKOFF_MS.length const MAX_ATTEMPTS = BACKOFF_MS.length
// Trim to a hard length, appending an ellipsis only when something was cut. // The site's public base, used to build the link an announcement carries.
function clamp(value, max) { function baseUrl() {
const s = String(value == null ? '' : value) return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
.replace(/\s+/g, ' ')
.trim()
if (s.length <= max) return s
return `${s.slice(0, max - 1).trimEnd()}`
} }
// The public link that goes in the announcement. News has no per-post route // 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 // (App.jsx only has the /site/news list), so we link the list — matches the
// pre-pipeline Discord announce behavior. // pre-pipeline Discord announce behavior.
function articleUrl(baseUrl) { function articleUrl(base) {
return `${String(baseUrl || '').replace(/\/+$/, '')}/site/news` return `${String(base || '').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) }
} }
// 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) { function legError(result) {
if (!result) return 'no response' if (!result) return 'no response'
if (result.status) { if (result.status) {
@@ -99,29 +50,32 @@ function scheduleAfter(attempts) {
return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)] return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)]
} }
// Parent job status derived from the two leg statuses: // Parent job status derived from its leg statuses:
// done — both legs delivered // done — every leg delivered
// failed — both legs gave up // failed — every leg gave up
// partial — at least one leg reached a terminal state while the other has not // partial — at least one leg reached a terminal state without all of them
// matched it (still pending/retrying, or the opposite terminal state) // agreeing (some still pending/retrying, or a mix of done and failed)
// pending — neither leg is terminal yet // pending — no leg is terminal yet
function rollupStatus(towncrierStatus, discordStatus) { //
if (towncrierStatus === 'done' && discordStatus === 'done') return 'done' // Takes the list of leg statuses rather than two named arguments, because the leg
if (towncrierStatus === 'failed' && discordStatus === 'failed') return 'failed' // 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' 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' return 'pending'
} }
module.exports = { module.exports = {
MAX_LINES,
MAX_LINE_LEN,
MAX_ATTEMPTS, MAX_ATTEMPTS,
BACKOFF_MS, BACKOFF_MS,
buildTownCrierText, baseUrl,
articleUrl, articleUrl,
classifyTownCrier, legError,
classifyDiscord,
scheduleAfter, scheduleAfter,
rollupStatus, rollupStatus,
} }

View File

@@ -2,19 +2,21 @@
// //
// Sits between the DB rows and the worker: creates jobs on publish, records each // 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 // 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. // announced_at when every leg lands, and resets a leg for the admin retry button.
// The pure decisions (backoff, rollup, classification) live in .logic.js. // 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 db = require('./announceJobs.db')
const logic = require('./announceJobs.logic') const logic = require('./announceJobs.logic')
const registries = require('../../modules/registries')
const posts = require('../posts/posts.model') const posts = require('../posts/posts.model')
const log = require('../../utils/logger')('announce') const log = require('../../utils/logger')('announce')
// Enqueue an announcement for a freshly-published news post: one job row (both // Enqueue an announcement for a freshly-published news post: one job row, one leg
// legs pending, due immediately) plus a back-pointer on the post so the admin // row per registered leg (all pending, due immediately), plus a back-pointer on
// panel can find it. Returns the new job id. // the post so the admin panel can find it. Returns the new job id.
async function enqueue(postId) { async function enqueue(postId) {
const jobId = await db.create(postId) const jobId = await db.create(postId, registries.announceLegIds())
await posts.linkAnnounceJob(postId, jobId) await posts.linkAnnounceJob(postId, jobId)
log.info('announce job enqueued', { jobId, postId }) log.info('announce job enqueued', { jobId, postId })
return jobId 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 // Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of a
// logic.classify*'s results: 'done' | 'retry' | 'terminal'. For 'retry' we bump // leg's classify() results: 'done' | 'retry' | 'terminal'. For 'retry' we bump the
// the attempt count and schedule the next run (or fail the leg once the cap is // attempt count and schedule the next run (or fail the leg once the cap is hit).
// hit). Returns the updated job row. // Returns the updated job row.
async function recordOutcome(job, leg, { outcome, error }) { 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') { if (outcome === 'done') {
await db.updateLeg(job.id, leg, { status: 'done', attempts, lastError: null, nextAttemptAt: null }) 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) return refreshStatus(job.id)
} }
// Recompute and persist the parent status from the two legs; stamp the post's // Recompute and persist the parent status from the legs; stamp the post's
// announced_at the moment both legs have delivered. // announced_at the moment every leg has delivered.
async function refreshStatus(jobId) { async function refreshStatus(jobId) {
const job = await db.findById(jobId) const job = await db.findById(jobId)
if (!job) return null 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) if (status !== job.status) await db.setStatus(jobId, status)
job.status = status job.status = status
if (status === 'done') { 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 // 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. // after a config fix gets a full budget again.
async function resetLeg(postId, leg) { 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) const job = await db.findByPostId(postId)
if (!job) return null 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 }) 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 }) 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) { async function getByPostId(postId) {
return db.findByPostId(postId) return withLabels(await db.findByPostId(postId))
} }
module.exports = { module.exports = {
@@ -113,4 +133,5 @@ module.exports = {
refreshStatus, refreshStatus,
resetLeg, resetLeg,
getByPostId, getByPostId,
withLabels,
} }

View File

@@ -1,8 +1,10 @@
// Per-user push-notification subscriptions (which streams a user opted into; // 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 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) const getForUser = async (userId) => (await db.listByUser(userId)).map((r) => r.stream_id)

View File

@@ -33,6 +33,7 @@ const path = require('path')
const { MODULE_API_VERSION } = require('./version') const { MODULE_API_VERSION } = require('./version')
const semver = require('./semver') const semver = require('./semver')
const registries = require('./registries')
const { splitStatements } = require('../utils/sqlStatements') const { splitStatements } = require('../utils/sqlStatements')
const log = require('../utils/logger')('modules') const log = require('../utils/logger')('modules')
@@ -51,10 +52,10 @@ const MANIFEST_KEYS = new Set([
'schema', 'purge', 'mounts', 'extensions', 'capabilities', 'schema', 'purge', 'mounts', 'extensions', 'capabilities',
]) ])
// Extension slots core declares (§2.4). Only core may declare one; a module may // Extension slots are declared by core, at require time, in the router that owns
// only fill one. Validation rejects a manifest naming a slot that does not // the resource (registries.declareSlot). The loader asks the registry which exist
// exist — `registerExtension` itself arrives with PR 4. // rather than keeping a list, for the same reason the prefix check probes the
const CORE_SLOTS = new Set(['admin.users.detail']) // live tier routers: a second copy of the answer is a copy that drifts.
// id → record. Populated by load(), read by list(). // id → record. Populated by load(), read by list().
const modules = new Map() const modules = new Map()
@@ -144,9 +145,9 @@ function buildApi(record) {
if (record.called.has(name)) throw new Error(`${name}() called twice`) if (record.called.has(name)) throw new Error(`${name}() called twice`)
record.called.add(name) record.called.add(name)
} }
// PR 4 brings the three de-entanglement registries and PR 5 the boot hooks. // PR 5 brings the boot hooks. They throw rather than no-op: an accepting stub
// They throw rather than no-op: an accepting stub would let a module believe // would let a module believe it had registered something and fail silently at
// it had registered something and fail silently at the far end. // the far end.
const notYet = (name, pr) => () => { const notYet = (name, pr) => () => {
throw new Error(`${name}: not available until phase 2 PR ${pr}`) throw new Error(`${name}: not available until phase 2 PR ${pr}`)
} }
@@ -163,9 +164,22 @@ function buildApi(record) {
} }
} }
}, },
registerExtension: notYet('registerExtension', 4), // The three de-entanglement registries (§2.4). They live in registries.js
registerNotificationStreams: notYet('registerNotificationStreams', 4), // rather than here because core registers through the same staging area, and
registerAnnounceLeg: notYet('registerAnnounceLeg', 4), // 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), onBoot: notYet('onBoot', 5),
onShutdown: notYet('onShutdown', 5), onShutdown: notYet('onShutdown', 5),
} }
@@ -317,7 +331,7 @@ function readManifest(dir, id, tierRouters) {
} }
for (const slot of manifest.extensions || []) { 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) { if (manifest.schema && !manifest.purge) {
@@ -392,6 +406,7 @@ function load(tierRouters) {
dir, dir,
manifest: null, manifest: null,
routes: { public: new Map(), admin: new Map(), player: new Map() }, routes: { public: new Map(), admin: new Map(), player: new Map() },
staged: registries.stage(id),
tables: new Set(), tables: new Set(),
called: new Set(), called: new Set(),
state: 'installed', state: 'installed',
@@ -433,7 +448,20 @@ function load(tierRouters) {
// prefix would be told it collided with core, naming the wrong culprit, and // prefix would be told it collided with core, naming the wrong culprit, and
// the module-versus-module check below it could never be reached. // the module-versus-module check below it could never be reached.
for (const record of modules.values()) { 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)
} }
} }

View File

@@ -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,
}

View File

@@ -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) { async function createUser(req, res) {
try { try {
if (await users.getRawByUsername(req.body.username)) { if (await users.getRawByUsername(req.body.username)) {
@@ -938,6 +955,7 @@ module.exports = {
ASSET_RULES, ASSET_RULES,
listActivity, listActivity,
listUsers, listUsers,
getUser,
createUser, createUser,
updateUser, updateUser,
deleteUser, deleteUser,

View File

@@ -1,5 +1,5 @@
// Admin · Posts — news, five-on-friday, newsletter and screenshot posts, plus // 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 // Mounted at /api/v1/admin/posts by admin/index.js, which already applied
// `noindex, isLoggedIn, staffOnly`. No extra gate: managing content is the // `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 ctrl = require('./admin.controller')
const { upload } = require('./imageUpload') const { upload } = require('./imageUpload')
const validate = require('../../../middleware/validate') const validate = require('../../../middleware/validate')
const registries = require('../../../modules/registries')
const postsRouter = express.Router() const postsRouter = express.Router()
@@ -124,14 +125,17 @@ postsRouter.get(
postsRouter.post( postsRouter.post(
'/:id/announce/retry', '/:id/announce/retry',
// #swagger.tags = ['Admin · Posts'] // #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.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' } // #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[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" } } } } */ /* #swagger.responses[404] = { description: 'No announcement job for this post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(), 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, validate,
ctrl.retryAnnounceLeg, ctrl.retryAnnounceLeg,
) )

View File

@@ -4,20 +4,18 @@
// `noindex, isLoggedIn, staffOnly`. The whole capability is admin-only: editors // `noindex, isLoggedIn, staffOnly`. The whole capability is admin-only: editors
// and moderators manage content and reports, never accounts. // and moderators manage content and reports, never accounts.
// //
// Handlers still live in admin.controller.js (users) and usersShard.controller.js // Handlers live in admin.controller.js. The shard footprint that used to be
// (uo-link footprint); this PR re-wires routes, not logic. // wired here is now an EXTENSION SLOT (MODULE_SYSTEM.md §1.9) — see the bottom of
// this file.
const express = require('express') const express = require('express')
const { body, param } = require('express-validator') const { body, param } = require('express-validator')
const ctrl = require('./admin.controller') const ctrl = require('./admin.controller')
const usersShard = require('./usersShard.controller') const registries = require('../../../modules/registries')
const { requireRole } = require('../../../utils/auth') const { requireRole } = require('../../../utils/auth')
const validate = require('../../../middleware/validate') 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 usersRouter = express.Router()
const adminOnly = requireRole('admin') const adminOnly = requireRole('admin')
@@ -151,11 +149,6 @@ usersRouter.post(
ctrl.resetUserMfa, 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( usersRouter.get(
'/:id', '/:id',
// #swagger.tags = ['Admin · Users'] // #swagger.tags = ['Admin · Users']
@@ -166,84 +159,21 @@ usersRouter.get(
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(), param('id').isInt(),
validate, validate,
usersShard.getUser, ctrl.getUser,
)
usersRouter.get(
'/:id/shard/accounts',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A users 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 users 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 users 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 users 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 users 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 accounts 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,
) )
// ── 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 module.exports = usersRouter

View File

@@ -25,18 +25,6 @@ async function accountsForUser(id) {
return { user, links, accounts: links.map((l) => l.account) } 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. // GET /admin/users/:id/shard/accounts — the user's linked game accounts.
async function listAccounts(req, res) { async function listAccounts(req, res) {
try { 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 }

View File

@@ -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 users 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 users 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 users 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 users 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 users 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 accounts 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

View File

@@ -5,7 +5,7 @@
const pushDevices = require('../../../model/pushDevices/pushDevices.model') const pushDevices = require('../../../model/pushDevices/pushDevices.model')
const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model') const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model')
const { STREAMS } = require('../../../config/notificationStreams') const registries = require('../../../modules/registries')
const { isAllowedEndpoint } = require('../../../utils/pushDispatch') const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
const log = require('../../../utils/logger')('notifications') 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) { 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. // GET /auth/me/notifications/subscriptions — the caller's opted-in stream ids.

View File

@@ -2,59 +2,35 @@
// //
// A lightweight, in-process table poller (no Redis/BullMQ in the stack). Every // 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 // ANNOUNCE_POLL_MS it sweeps announce_jobs for legs that are due — freshly
// enqueued or past their backoff — and dispatches each one: // enqueued or past their backoff — and dispatches each one through the leg that
// • town crier → uoLinkClient.postTownCrier (sidecar → in-game) // registered itself for that id (modules/registries.js). Core registers
// discord → botInternalClient.announce (bot → #news channel) // `discord`; module-uo registers `towncrier`; another game's module registers its
// Both clients never throw (they return { ok, status, error }); the model turns // own, and nothing in this file changes.
// each result into done / retry / terminal and owns the backoff + rollup. One //
// leg failing never touches the other. Same setInterval + unref + stop() shape // A leg's client never throws (they return { ok, status, error }) and its
// as middleware/botScore's sweeper, wired into server.js start/shutdown. // 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 announceJobs = require('../model/announceJobs/announceJobs.model')
const announceJobsDb = require('../model/announceJobs/announceJobs.db') const announceJobsDb = require('../model/announceJobs/announceJobs.db')
const logic = require('../model/announceJobs/announceJobs.logic')
const posts = require('../model/posts/posts.model') const posts = require('../model/posts/posts.model')
const uoLinkClient = require('./uoLinkClient') const registries = require('../modules/registries')
const botInternalClient = require('./botInternalClient')
const log = require('./logger')('announce-worker') const log = require('./logger')('announce-worker')
const POLL_MS = Number(process.env.ANNOUNCE_POLL_MS) || 15_000 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-<id>` 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. // Process a single due leg of a job: fetch the post, dispatch, classify, record.
async function processLeg(job, leg) { 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) const post = await posts.getById(job.post_id)
if (!post) { if (!post) {
// Post was deleted between enqueue and dispatch (the CASCADE usually reaps // Post was deleted between enqueue and dispatch (the CASCADE usually reaps
@@ -63,16 +39,9 @@ async function processLeg(job, leg) {
return return
} }
let result
let classification let classification
try { try {
if (leg === 'towncrier') { classification = registered.classify(await registered.dispatch(post))
result = await dispatchTownCrier(post)
classification = logic.classifyTownCrier(result)
} else {
result = await dispatchDiscord(post)
classification = logic.classifyDiscord(result)
}
} catch (err) { } catch (err) {
// Clients shouldn't throw, but if one does, treat it as a transient failure // Clients shouldn't throw, but if one does, treat it as a transient failure
// rather than crashing the tick. // rather than crashing the tick.
@@ -83,11 +52,10 @@ async function processLeg(job, leg) {
await announceJobs.recordOutcome(job, leg, classification) await announceJobs.recordOutcome(job, leg, classification)
} }
// One sweep: find due jobs and process each due leg. A job may have both legs due // One sweep: find due jobs and process each due leg. A job may have several legs
// (a fresh enqueue) — process the ones that are actually pending. `job` is a // due at once (a fresh enqueue). `job` is a snapshot from the SELECT;
// snapshot from the SELECT; recordOutcome re-reads for the rollup, so processing // recordOutcome re-reads for the rollup, so processing the legs sequentially off
// the two legs sequentially off the same snapshot is fine (each leg only writes // the same snapshot is fine (each leg only writes its own row).
// its own columns).
async function tick(now = new Date()) { async function tick(now = new Date()) {
let jobs let jobs
try { try {
@@ -99,15 +67,15 @@ async function tick(now = new Date()) {
if (!jobs || jobs.length === 0) return if (!jobs || jobs.length === 0) return
for (const job of jobs) { for (const job of jobs) {
if (isLegDue(job, 'towncrier', now)) await processLeg(job, 'towncrier') for (const row of job.legs || []) {
if (isLegDue(job, 'discord', now)) await processLeg(job, 'discord') if (isLegDue(row, now)) await processLeg(job, row.leg)
}
} }
} }
function isLegDue(job, leg, now) { function isLegDue(row, now) {
if (job[`${leg}_status`] !== 'pending') return false if (!row || row.status !== 'pending') return false
const next = job[`${leg}_next_attempt_at`] return row.next_attempt_at == null || new Date(row.next_attempt_at) <= now
return next == null || new Date(next) <= now
} }
let timer = null let timer = null
@@ -118,7 +86,7 @@ function start() {
tick().catch((err) => log.error('announce tick failed', { message: err.message })) tick().catch((err) => log.error('announce tick failed', { message: err.message }))
}, POLL_MS) }, POLL_MS)
if (timer.unref) timer.unref() // don't keep the event loop alive (tests, shutdown) 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 return timer
} }
@@ -129,4 +97,4 @@ function stop() {
} }
} }
module.exports = { start, stop, tick, processLeg, dispatchTownCrier, dispatchDiscord } module.exports = { start, stop, tick, processLeg, isLegDue }

View File

@@ -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 }

View File

@@ -1,10 +1,14 @@
// ── Push-notification fan-out (content-free tickles) ─────────────────────── // ── Push-notification fan-out (content-free tickles) ───────────────────────
// //
// The transport-agnostic publisher that turns an event into opt-in push // The transport-agnostic publisher that turns a stream id into opt-in push
// notifications. Two producers call in: // notifications. It knows nothing about where the stream came from: the admin
// • utils/shardIngest.js → fromShardEvent(event) for shard-derived streams // create/publish-post path calls publish('news.post', …), and utils/shardPush.js
// (beside the existing SSE broadcast — same event source, same allowlist). // resolves a shard event to a stream and an owner and calls the same function.
// • the admin create/publish-post path → publish('news.post', …). //
// 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 }`, // What actually leaves the server is a CONTENT-FREE tickle — `{ stream, ref }`,
// no sensitive data — POSTed to each subscribed device's UnifiedPush/ntfy // 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 // 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. // (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 pushDevicesModel = require('../model/pushDevices/pushDevices.model')
const { mapShardEvent } = require('../config/notificationStreams')
const log = require('./logger')('push-dispatch') const log = require('./logger')('push-dispatch')
const TIMEOUT_MS = 5000 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))) 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 module.exports = { publish, isAllowedEndpoint }
// 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 }

View File

@@ -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-<id>` 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 }

View File

@@ -19,7 +19,7 @@ const shardMarketModel = require('../model/shardMarket/shardMarket.model')
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model') const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
const settingsModel = require('../model/settings/settings.model') const settingsModel = require('../model/settings/settings.model')
const broadcaster = require('./shardBroadcast') const broadcaster = require('./shardBroadcast')
const pushDispatch = require('./pushDispatch') const shardPush = require('./shardPush')
const defaultLog = require('./logger')('shard-ingest') const defaultLog = require('./logger')('shard-ingest')
// Notable kinds appended to the shard_events log. High-frequency/session kinds // Notable kinds appended to the shard_events log. High-frequency/session kinds
@@ -262,7 +262,7 @@ function resolveDeps(deps) {
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel, uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
settings: deps.settings || settingsModel, settings: deps.settings || settingsModel,
broadcast: deps.broadcast || broadcaster.broadcast, broadcast: deps.broadcast || broadcaster.broadcast,
pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent, pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
log: deps.log || defaultLog, log: deps.log || defaultLog,
} }
} }

View File

@@ -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 }

115
server/swagger/mergeSpec.js Normal file
View File

@@ -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 }

120
server/swagger/slotSpecs.js Normal file
View File

@@ -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<number>} 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 }

View File

@@ -3249,7 +3249,7 @@
"tags": [ "tags": [
"Admin · Posts" "Admin · Posts"
], ],
"summary": "Retry one announcement delivery leg (town crier or Discord)", "summary": "Retry one announcement delivery leg",
"description": "", "description": "",
"parameters": [ "parameters": [
{ {
@@ -3308,10 +3308,7 @@
"properties": { "properties": {
"leg": { "leg": {
"type": "string", "type": "string",
"enum": [ "description": "A registered delivery leg id, as returned by GET /announce."
"towncrier",
"discord"
]
} }
}, },
"required": [ "required": [

View File

@@ -1513,9 +1513,31 @@ function normalizePaths(spec) {
return spec return spec
} }
swaggerAutogen(outputFile, routes, doc).then(() => { // Static analysis cannot follow a route into an extension slot, so the slot
const written = JSON.parse(fs.readFileSync(outputFile, 'utf8')) // routers contribute a generated fragment afterwards — see swagger/slotSpecs.js
fs.writeFileSync(outputFile, `${JSON.stringify(normalizePaths(written), null, 2)}\n`) // for what goes wrong without it. Merged BEFORE normalizePaths, so the merged-in
// eslint-disable-next-line no-console // paths are sorted and trailing-slash-checked with everything else.
console.log('swagger-output.json generated.') //
}) // 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 */

View File

@@ -75,7 +75,7 @@ test('salesForAccounts keeps only sales owned by the given accounts, newest 50',
}) })
// ── Controller: unknown user → 404 ───────────────────────────────────────── // ── 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 () => { test(`${handler} returns 404 when the user does not exist`, async () => {
users.getById = async () => null users.getById = async () => null
const res = mockRes() const res = mockRes()
@@ -144,10 +144,7 @@ test('a user with no linked accounts yields empty sales/houses/online', async ()
assert.deepEqual(online.body, []) assert.deepEqual(online.body, [])
}) })
test('getUser returns the sanitized user row', async () => { // getUser is NOT here any more: reading a user is core semantics that had ended
users.getById = async () => ({ id: 7, username: 'bob', role: 'player', status: 'active' }) // up in this controller by proximity, and PR 4 moved it back to
const res = mockRes() // admin.controller.js behind the extension slot (MODULE_SYSTEM.md §1.9). It is
await ctrl.getUser({ params: { id: '7' } }, res) // covered by test/adminUsers.test.js.
assert.equal(res.statusCode, 200)
assert.equal(res.body.username, 'bob')
})

View File

@@ -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)
})

View File

@@ -2,10 +2,14 @@ const { test } = require('node:test')
const assert = require('node:assert/strict') const assert = require('node:assert/strict')
const logic = require('../src/model/announceJobs/announceJobs.logic') 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 ─────────────────────────────────────────────────────── // ── buildTownCrierText ───────────────────────────────────────────────────────
test('buildTownCrierText produces title, excerpt, and URL lines', () => { 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 }, { id: 7, title: 'Server Update', excerpt: 'Big things afoot.', body: null },
{ baseUrl: 'https://uom.example' }, { 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', () => { 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: '<p>Hello <b>world</b></p>' }, { id: 1, title: 'T', excerpt: '', body: '<p>Hello <b>world</b></p>' },
{ baseUrl: 'https://uom.example' }, { 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', () => { test('buildTownCrierText clamps each line to the sidecar per-line cap', () => {
const longTitle = 'x'.repeat(500) const longTitle = 'x'.repeat(500)
const lines = logic.buildTownCrierText( const lines = townCrier.buildTownCrierText(
{ id: 1, title: longTitle, excerpt: 'y'.repeat(500), body: null }, { id: 1, title: longTitle, excerpt: 'y'.repeat(500), body: null },
{ baseUrl: 'https://uom.example' }, { 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[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', () => { 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 }, { id: 1, title: 'Only a title', excerpt: null, body: null },
{ baseUrl: 'https://uom.example' }, { baseUrl: 'https://uom.example' },
) )
@@ -40,27 +44,27 @@ test('buildTownCrierText omits the excerpt line when there is no excerpt or body
}) })
// ── classifyTownCrier ──────────────────────────────────────────────────────── // ── classifyTownCrier ────────────────────────────────────────────────────────
test('classifyTownCrier: 2xx is done', () => { test('town crier classify: 2xx is done', () => {
assert.equal(logic.classifyTownCrier({ ok: true, status: 200 }).outcome, '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]) { 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]) { 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 ────────────────────────────────────────────────────────── // ── classifyDiscord ──────────────────────────────────────────────────────────
test('classifyDiscord: ok is done, every failure retries', () => { test('discord classify: ok is done, every failure retries', () => {
assert.equal(logic.classifyDiscord({ ok: true, status: 200 }).outcome, 'done') assert.equal(discord.classify({ ok: true, status: 200 }).outcome, 'done')
for (const status of [400, 503, 0]) { 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 ───────────────────────────────────────────────────────────── // ── rollupStatus ─────────────────────────────────────────────────────────────
test('rollupStatus derives the parent status from the two legs', () => { // Takes the LIST of leg statuses, not two named legs: which legs exist is what a
assert.equal(logic.rollupStatus('done', 'done'), 'done') // module decides (MODULE_SYSTEM.md §1.8).
assert.equal(logic.rollupStatus('failed', 'failed'), 'failed') test('rollupStatus derives the parent status from its legs', () => {
assert.equal(logic.rollupStatus('pending', 'pending'), 'pending') assert.equal(logic.rollupStatus(['done', 'done']), 'done')
// One terminal, the other not matching → partial. assert.equal(logic.rollupStatus(['failed', 'failed']), 'failed')
assert.equal(logic.rollupStatus('done', 'pending'), 'partial') assert.equal(logic.rollupStatus(['pending', 'pending']), 'pending')
assert.equal(logic.rollupStatus('pending', 'failed'), 'partial') // One terminal, the others not matching → partial.
assert.equal(logic.rollupStatus('done', 'failed'), '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')
}) })

View File

@@ -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 legs 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)
})

View File

@@ -27,8 +27,16 @@ const assert = require('node:assert/strict')
const express = require('express') const express = require('express')
const db = require('../src/utils/db') const db = require('../src/utils/db')
const registries = require('../src/modules/registries')
const { startApp } = require('./_helper') 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()) after(() => db.close())
let tmpRoot let tmpRoot
@@ -42,6 +50,11 @@ const emptyTiers = () => ({
function freshLoader(dir, tiers = emptyTiers()) { function freshLoader(dir, tiers = emptyTiers()) {
process.env.MODULES_DIR = dir 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')] delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require // eslint-disable-next-line global-require
const loader = require('../src/modules/loader') 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') 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', () => { 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 // An accepting no-op would let a module believe it had registered a boot hook
// notification stream or a boot hook and fail silently at the far end. // and fail silently at the far end.
for (const [call, pr] of [ for (const [call, pr] of [
['registerExtension', 4],
['registerNotificationStreams', 4],
['registerAnnounceLeg', 4],
['onBoot', 5], ['onBoot', 5],
['onShutdown', 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)
})

View File

@@ -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 modules 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 slots 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')
})

View File

@@ -6,8 +6,11 @@ process.env.DB_PORT = '59999'
const { test, after } = require('node:test') const { test, after } = require('node:test')
const assert = require('node:assert/strict') 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') 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') const db = require('../src/utils/db')
after(() => db.close()) 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() } 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.deepEqual(userStreamCalls, [[42, 'vendor.sale']])
assert.equal(calls.length, 1) assert.equal(calls.length, 1)
// Unlinked account → nobody to notify → no publish. // Unlinked account → nobody to notify → no publish.
userStreamCalls.length = 0 userStreamCalls.length = 0
calls.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(userStreamCalls.length, 0)
assert.equal(calls.length, 0) assert.equal(calls.length, 0)
}) })
@@ -226,7 +229,7 @@ test('fromShardEvent fans a public shard event to the streams subscribers', a
}, },
endpointsForUserStream: async () => [], endpointsForUserStream: async () => [],
} }
await pushDispatch.fromShardEvent( await shardPush.fromShardEvent(
{ kind: 'server.hello', bootId: 'b1' }, { kind: 'server.hello', bootId: 'b1' },
{ pushDevices, fetchImpl, tracker: createTracker() }, { pushDevices, fetchImpl, tracker: createTracker() },
) )