feat(modules): the three de-entanglement registries, with core as the registrant #131
@@ -177,14 +177,15 @@ const delStyle = {
|
||||
}
|
||||
|
||||
// ── Announcement status panel ────────────────────────────────────────────────
|
||||
// Shows the town-crier + Discord delivery state for a published news post and
|
||||
// offers a per-leg retry (useful after fixing the sidecar / news channel without
|
||||
// re-publishing). Only rendered for news posts in edit mode; renders nothing
|
||||
// until the post has actually been announced (no job row yet → nothing to show).
|
||||
const LEG_META = {
|
||||
towncrier: { label: 'In-game town crier' },
|
||||
discord: { label: 'Discord #news' },
|
||||
}
|
||||
// Shows each delivery leg's state for a published news post and offers a per-leg
|
||||
// retry (useful after fixing the sidecar / news channel without re-publishing).
|
||||
// Only rendered for news posts in edit mode; renders nothing until the post has
|
||||
// actually been announced (no job row yet → nothing to show).
|
||||
//
|
||||
// The legs and their labels come from the JOB, not from a constant here: which
|
||||
// legs exist is decided by what the server has registered, so an installed module
|
||||
// brings its own leg and this panel renders it with no client change
|
||||
// (docs/website/MODULE_SYSTEM.md §1.8).
|
||||
const STATUS_STYLE = {
|
||||
done: { color: '#7bbf8f', label: 'delivered' },
|
||||
pending: { color: '#d9b84a', label: 'pending' },
|
||||
@@ -227,14 +228,12 @@ function AnnouncePanel({ postId }) {
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<span className="field-label" style={{ marginBottom: 2 }}>Announcement</span>
|
||||
{['towncrier', 'discord'].map((leg) => {
|
||||
const status = job[`${leg}_status`]
|
||||
const err = job[`${leg}_last_error`]
|
||||
{(job.legs || []).map(({ leg, label, status, last_error: err }) => {
|
||||
const s = STATUS_STYLE[status] || STATUS_STYLE.pending
|
||||
return (
|
||||
<div key={leg} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<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>
|
||||
{status !== 'done' && (
|
||||
<button
|
||||
|
||||
@@ -1106,35 +1106,79 @@ CREATE TABLE IF NOT EXISTS pages (
|
||||
|
||||
-- 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
|
||||
-- — sweeps it for due legs). Two INDEPENDENT delivery legs so a Discord outage
|
||||
-- never blocks or retries the in-game town-crier leg and vice versa. `status` is
|
||||
-- a derived rollup of the two legs (see announceJobs.logic.js): done when both
|
||||
-- legs done, failed when both exhausted, partial in between. Each leg tracks its
|
||||
-- own attempt count, last error, and next-due time for exponential backoff.
|
||||
-- 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.
|
||||
-- — sweeps it for due legs). `status` is a derived rollup of the legs (see
|
||||
-- announceJobs.logic.js): done when every leg is done, failed when every leg is
|
||||
-- exhausted, partial in between. 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 (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
post_id INT NOT NULL,
|
||||
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,
|
||||
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,
|
||||
INDEX idx_announce_due (towncrier_status, towncrier_next_attempt_at),
|
||||
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
|
||||
CONSTRAINT fk_announce_jobs_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
|
||||
) 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) ───────────────────────────────────────
|
||||
-- Static shard CONTENT, not live shard state: what spawns where, which regions
|
||||
-- and landmarks exist, and which champion altars are configured. Nothing here
|
||||
|
||||
@@ -1062,7 +1062,7 @@
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/shard/link/:account",
|
||||
"handlers": 5,
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
|
||||
@@ -64,9 +64,16 @@ const PUBLIC_PREFIXES = ['/api/', '/.well-known/']
|
||||
*
|
||||
* Express keeps no copy of the mount string, only the compiled regexp. For a
|
||||
* literal mount (`/api/v1`) that is `^\/api\/v1\/?(?=\/|$)`; a parameterised mount
|
||||
* contributes one `(?:([^\/]+?))` group per entry in `layer.keys`. Unwinding both
|
||||
* gets us back to `/api/v1` and `/thing/:id` respectively. `fast_slash` is
|
||||
* express's marker for a router mounted at the root, which contributes nothing.
|
||||
* contributes one group per entry in `layer.keys`, and the separator before the
|
||||
* parameter lives INSIDE that group — express 4.22 compiles `use('/:id', r)` to
|
||||
* `^(?:\/([^/]+?))\/?(?=\/|$)`. Unwinding both gets us back to `/api/v1` and
|
||||
* `/:id` respectively. `fast_slash` is express's marker for a router mounted at
|
||||
* the root, which contributes nothing.
|
||||
*
|
||||
* The parameterised branch went unexercised until the `admin.users.detail`
|
||||
* extension slot mounted a router at `/:id` (MODULE_SYSTEM.md §1.9), and it was
|
||||
* wrong: it expected the group as `(?:([^\/]+?))`, with the slash outside and the
|
||||
* class escaped. It threw rather than guessing, which is exactly what it is for.
|
||||
*/
|
||||
function mountPath(layer) {
|
||||
const re = layer.regexp
|
||||
@@ -79,9 +86,11 @@ function mountPath(layer) {
|
||||
|
||||
const keys = layer.keys || []
|
||||
let i = 0
|
||||
src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => {
|
||||
// `\/` optional and the `/` in the class optionally escaped, so this survives a
|
||||
// path-to-regexp that emits either shape.
|
||||
src = src.replace(/\((?:\?:)?(\\\/)?\(\[\^\\?\/\]\+\?\)\)/g, (_m, slash) => {
|
||||
const key = keys[i++]
|
||||
return key ? `:${key.name}` : ':param'
|
||||
return `${slash ? '/' : ''}:${key ? key.name : 'param'}`
|
||||
})
|
||||
|
||||
// Whatever is left should be a literal path with regexp-escaped separators.
|
||||
|
||||
@@ -11,6 +11,7 @@ const swaggerUi = require('swagger-ui-express')
|
||||
|
||||
const apiRouter = require('./router/api.router')
|
||||
const modules = require('./modules/loader')
|
||||
const registries = require('./modules/registries')
|
||||
const wellKnown = require('./router/wellKnown.controller')
|
||||
const cspReport = require('./router/cspReport.controller')
|
||||
const brand = require('./config/brand')
|
||||
@@ -171,6 +172,12 @@ app.use('/api', apiRouter)
|
||||
//
|
||||
// The three requires resolve from cache to the very routers v1.router.js
|
||||
// mounted; this is a reference to them, not a second copy.
|
||||
//
|
||||
// registerCore() first, and for the same reason the loader runs after `/api`: a
|
||||
// module's collision checks are asked against what is ALREADY registered, so
|
||||
// core's streams, its announce leg and its extension-slot fill have to be there
|
||||
// before the first module registers anything (MODULE_SYSTEM.md §1.8).
|
||||
registries.registerCore()
|
||||
modules.load({
|
||||
public: require('./router/v1/public'),
|
||||
admin: require('./router/v1/admin'),
|
||||
|
||||
26
server/src/config/coreStreams.js
Normal file
26
server/src/config/coreStreams.js
Normal 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 }
|
||||
@@ -1,7 +1,14 @@
|
||||
// ── Push-notification stream catalog + event → stream mapping ───────────────
|
||||
// ── Shard-derived push streams + event → stream mapping ────────────────────
|
||||
//
|
||||
// The single source of truth for which streams a user can subscribe to, and how
|
||||
// a shard event maps onto them. Two families:
|
||||
// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8 named
|
||||
// config/notificationStreams.js as one of the three genuinely entangled files:
|
||||
// most of its catalog and all of `mapShardEvent` are shard-derived, and it reads
|
||||
// `PUBLIC_KINDS` out of utils/shardBroadcast. PR 4 split it — core's one stream
|
||||
// is config/coreStreams.js, and everything shard-shaped is here, in a file that
|
||||
// moves to module-uo whole in Phase 3. Nothing in core imports it except
|
||||
// modules/registries.js's registerCore(), which is the one line Phase 3 deletes.
|
||||
//
|
||||
// Two families:
|
||||
// • public / opt-in — no linked game account required; delivered to every
|
||||
// subscriber. Drawn ONLY from the SSE public allowlist
|
||||
// (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind
|
||||
@@ -17,17 +24,7 @@
|
||||
|
||||
const { PUBLIC_KINDS } = require('../utils/shardBroadcast')
|
||||
|
||||
// The subscribable catalog. `news.post` is produced by the website's own posts
|
||||
// path (not the shard feed) — see utils/pushDispatch — so it has no mapShardEvent
|
||||
// case; every other stream is shard-derived below.
|
||||
const STREAMS = [
|
||||
{
|
||||
id: 'news.post',
|
||||
label: 'News posts',
|
||||
description: 'New news / Five-on-Friday / newsletter posts.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'server.status',
|
||||
label: 'Server up / down',
|
||||
@@ -79,8 +76,10 @@ const STREAMS = [
|
||||
},
|
||||
]
|
||||
|
||||
const STREAM_IDS = new Set(STREAMS.map((s) => s.id))
|
||||
const isValidStream = (id) => STREAM_IDS.has(id)
|
||||
// The owner-keyed subset, needed by mapShardEvent's public-safety filter below.
|
||||
// Derived from this file's own catalog rather than read back out of the registry:
|
||||
// the filter is about THESE streams, and a module must not be able to weaken it
|
||||
// by registering something that happens to share an id.
|
||||
const PERSONAL_STREAMS = new Set(STREAMS.filter((s) => s.personal).map((s) => s.id))
|
||||
|
||||
// Per-process transition state so full-state upserts (champ.update / city.update
|
||||
@@ -162,7 +161,12 @@ function mapShardEvent(event, tracker = defaultTracker) {
|
||||
// they are exempt from the public allowlist (that is the whole point of the
|
||||
// owner-keyed split). This guarantees a sensitive kind can never leak publicly
|
||||
// even if a future mapping case is added carelessly.
|
||||
//
|
||||
// This filter, the kinds it reads and the streams it protects now all live in
|
||||
// one file and move together — the reason PR 4 dropped the contract's
|
||||
// `mapEvent` half rather than leaving the mapping in core and the catalog in a
|
||||
// module (MODULE_API.md §2.4).
|
||||
return out.filter((t) => (PERSONAL_STREAMS.has(t.streamId) ? true : PUBLIC_KINDS.has(kind)))
|
||||
}
|
||||
|
||||
module.exports = { STREAMS, isValidStream, mapShardEvent, createTracker, PERSONAL_STREAMS }
|
||||
module.exports = { STREAMS, mapShardEvent, createTracker, PERSONAL_STREAMS }
|
||||
@@ -1,26 +1,68 @@
|
||||
// ── Announcement pipeline: SQL ─────────────────────────────────────────────
|
||||
//
|
||||
// Two tables since PR 4 (MODULE_SYSTEM.md §1.8): `announce_jobs` is one row per
|
||||
// publish event, `announce_job_legs` one row per delivery leg of that job. The
|
||||
// leg set is registered rather than fixed, so a leg is a stored VALUE now instead
|
||||
// of a group of leg-prefixed columns — which is what lets a module bring its own
|
||||
// leg without altering a core table.
|
||||
//
|
||||
// Every read returns the job with a `legs` array attached, so a caller never has
|
||||
// to remember to fetch the second table.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS =
|
||||
'id, post_id, status, ' +
|
||||
'towncrier_status, towncrier_attempts, towncrier_last_error, towncrier_next_attempt_at, ' +
|
||||
'discord_status, discord_attempts, discord_last_error, discord_next_attempt_at, ' +
|
||||
'created_at, updated_at'
|
||||
const COLS = 'id, post_id, status, created_at, updated_at'
|
||||
const LEG_COLS = 'job_id, leg, status, attempts, last_error, next_attempt_at'
|
||||
|
||||
// Whitelist so a `leg` value can be interpolated into a column name safely — it
|
||||
// never comes from raw user input, but keep the guard explicit.
|
||||
const LEGS = ['towncrier', 'discord']
|
||||
function assertLeg(leg) {
|
||||
if (!LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`)
|
||||
async function legsFor(jobIds) {
|
||||
if (jobIds.length === 0) return new Map()
|
||||
const marks = jobIds.map(() => '?').join(', ')
|
||||
const rows = await query(
|
||||
`SELECT ${LEG_COLS} FROM announce_job_legs WHERE job_id IN (${marks}) ORDER BY job_id, leg`,
|
||||
jobIds,
|
||||
)
|
||||
const byJob = new Map(jobIds.map((id) => [id, []]))
|
||||
for (const row of rows) byJob.get(row.job_id).push(row)
|
||||
return byJob
|
||||
}
|
||||
|
||||
async function create(postId) {
|
||||
async function attachLegs(jobs) {
|
||||
const byJob = await legsFor(jobs.map((j) => j.id))
|
||||
for (const job of jobs) job.legs = byJob.get(job.id) || []
|
||||
return jobs
|
||||
}
|
||||
|
||||
// Create a job and its leg rows in one go. `legs` is the registered leg id list —
|
||||
// an empty list is legal and yields a job with nothing to deliver.
|
||||
async function create(postId, legs = []) {
|
||||
const res = await query('INSERT INTO announce_jobs (post_id) VALUES (?)', [postId])
|
||||
return res.insertId
|
||||
const jobId = Number(res.insertId)
|
||||
if (legs.length > 0) {
|
||||
const values = legs.map(() => '(?, ?)').join(', ')
|
||||
await query(
|
||||
`INSERT INTO announce_job_legs (job_id, leg) VALUES ${values}`,
|
||||
legs.flatMap((leg) => [jobId, leg]),
|
||||
)
|
||||
}
|
||||
return jobId
|
||||
}
|
||||
|
||||
// Add any registered legs this job is missing. A job enqueued before a module was
|
||||
// installed has no row for that module's leg, and without this it could never
|
||||
// deliver one — the worker only ever sees rows that exist.
|
||||
async function ensureLegs(jobId, legs = []) {
|
||||
if (legs.length === 0) return
|
||||
const values = legs.map(() => '(?, ?)').join(', ')
|
||||
await query(
|
||||
`INSERT IGNORE INTO announce_job_legs (job_id, leg) VALUES ${values}`,
|
||||
legs.flatMap((leg) => [jobId, leg]),
|
||||
)
|
||||
}
|
||||
|
||||
async function findById(id) {
|
||||
const rows = await query(`SELECT ${COLS} FROM announce_jobs WHERE id = ? LIMIT 1`, [id])
|
||||
return rows[0] || null
|
||||
if (rows.length === 0) return null
|
||||
return (await attachLegs(rows))[0]
|
||||
}
|
||||
|
||||
async function findByPostId(postId) {
|
||||
@@ -28,36 +70,38 @@ async function findByPostId(postId) {
|
||||
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
|
||||
[postId],
|
||||
)
|
||||
return rows[0] || null
|
||||
if (rows.length === 0) return null
|
||||
return (await attachLegs(rows))[0]
|
||||
}
|
||||
|
||||
// Jobs with at least one leg that is due now: pending and either never scheduled
|
||||
// (next_attempt_at IS NULL — a fresh enqueue) or past its backoff time.
|
||||
// (next_attempt_at IS NULL — a fresh enqueue) or past its backoff time. Returns
|
||||
// whole jobs with every leg attached; the worker decides which legs to run, so
|
||||
// this stays one query regardless of how many legs are registered.
|
||||
async function findDue(now = new Date(), limit = 25) {
|
||||
return query(
|
||||
`SELECT ${COLS} FROM announce_jobs
|
||||
WHERE (towncrier_status = 'pending'
|
||||
AND (towncrier_next_attempt_at IS NULL OR towncrier_next_attempt_at <= ?))
|
||||
OR (discord_status = 'pending'
|
||||
AND (discord_next_attempt_at IS NULL OR discord_next_attempt_at <= ?))
|
||||
ORDER BY id ASC
|
||||
const rows = await query(
|
||||
`SELECT ${COLS} FROM announce_jobs j
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM announce_job_legs l
|
||||
WHERE l.job_id = j.id
|
||||
AND l.status = 'pending'
|
||||
AND (l.next_attempt_at IS NULL OR l.next_attempt_at <= ?))
|
||||
ORDER BY j.id ASC
|
||||
LIMIT ?`,
|
||||
[now, now, limit],
|
||||
[now, limit],
|
||||
)
|
||||
return attachLegs(rows)
|
||||
}
|
||||
|
||||
// Update one leg's columns. `fields` uses leg-agnostic keys (status, attempts,
|
||||
// lastError, nextAttemptAt); we map them onto the leg-prefixed columns.
|
||||
async function updateLeg(id, leg, { status, attempts, lastError, nextAttemptAt }) {
|
||||
assertLeg(leg)
|
||||
// Update one leg's row. `leg` is a bound VALUE, not an interpolated column name —
|
||||
// the reason the old leg allowlist that guarded that interpolation is gone. A
|
||||
// module's leg id could not have passed it anyway.
|
||||
async function updateLeg(jobId, leg, { status, attempts, lastError, nextAttemptAt }) {
|
||||
await query(
|
||||
`UPDATE announce_jobs SET
|
||||
${leg}_status = ?,
|
||||
${leg}_attempts = ?,
|
||||
${leg}_last_error = ?,
|
||||
${leg}_next_attempt_at = ?
|
||||
WHERE id = ?`,
|
||||
[status, attempts, lastError ?? null, nextAttemptAt ?? null, id],
|
||||
`UPDATE announce_job_legs SET
|
||||
status = ?, attempts = ?, last_error = ?, next_attempt_at = ?
|
||||
WHERE job_id = ? AND leg = ?`,
|
||||
[status, attempts, lastError ?? null, nextAttemptAt ?? null, jobId, leg],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,4 +109,4 @@ async function setStatus(id, status) {
|
||||
await query('UPDATE announce_jobs SET status = ? WHERE id = ?', [status, id])
|
||||
}
|
||||
|
||||
module.exports = { LEGS, create, findById, findByPostId, findDue, updateLeg, setStatus }
|
||||
module.exports = { create, ensureLegs, findById, findByPostId, findDue, updateLeg, setStatus }
|
||||
|
||||
@@ -1,87 +1,38 @@
|
||||
// ── Announcement pipeline: pure logic ──────────────────────────────────────
|
||||
//
|
||||
// No DB, no network — just the decisions the worker and model make, kept here so
|
||||
// they are unit-testable in isolation (server/test/announceJobs.test.js):
|
||||
// • buildTownCrierText — turn a post into sidecar-safe town-crier lines
|
||||
// • classifyTownCrier / classifyDiscord — map a dispatch result to done / retry
|
||||
// / terminal, so a data problem fails fast and a transient outage retries
|
||||
// No DB, no network — just the LEG-AGNOSTIC decisions the worker and model make,
|
||||
// kept here so they are unit-testable in isolation (server/test/announceJobs.test.js):
|
||||
// • scheduleAfter — exponential backoff schedule + the attempt cap
|
||||
// • rollupStatus — derive the parent job status from the two legs
|
||||
|
||||
const { deriveExcerpt } = require('../../utils/sanitizeHtml')
|
||||
|
||||
// Sidecar town-crier caps, mirrored from the admin route validation
|
||||
// (admin/uoLink.router.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })).
|
||||
// We pre-truncate to these so a published post never bounces with towncrier.error.
|
||||
const MAX_LINES = 8
|
||||
const MAX_LINE_LEN = 200
|
||||
// • rollupStatus — derive the parent job status from its legs
|
||||
// • legError — squeeze a client result into one error line
|
||||
// • baseUrl / articleUrl — the public link an announcement carries
|
||||
//
|
||||
// What used to be here and is not any more: `buildTownCrierText`,
|
||||
// `classifyTownCrier` and `classifyDiscord`. A leg's own text-building and result
|
||||
// classification belong to the leg, and a leg is a registration now
|
||||
// (MODULE_SYSTEM.md §1.8) — they live in utils/shardAnnounce.js and
|
||||
// utils/discordAnnounce.js. This file is what every leg shares.
|
||||
|
||||
// Backoff between retries, indexed by attempts-so-far. Six attempts spread over
|
||||
// ~a couple of hours; after the last one a leg is marked failed and surfaced in
|
||||
// the post's admin panel. Shared by both legs.
|
||||
// the post's admin panel. Shared by every leg.
|
||||
const BACKOFF_MS = [30_000, 120_000, 600_000, 1_800_000, 3_600_000, 7_200_000]
|
||||
const MAX_ATTEMPTS = BACKOFF_MS.length
|
||||
|
||||
// Trim to a hard length, appending an ellipsis only when something was cut.
|
||||
function clamp(value, max) {
|
||||
const s = String(value == null ? '' : value)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (s.length <= max) return s
|
||||
return `${s.slice(0, max - 1).trimEnd()}…`
|
||||
// The site's public base, used to build the link an announcement carries.
|
||||
function baseUrl() {
|
||||
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// The public link that goes in the announcement. News has no per-post route
|
||||
// (App.jsx only has the /site/news list), so we link the list — matches the
|
||||
// pre-pipeline Discord announce behavior.
|
||||
function articleUrl(baseUrl) {
|
||||
return `${String(baseUrl || '').replace(/\/+$/, '')}/site/news`
|
||||
}
|
||||
|
||||
// Build the town-crier lines: title, a one-line excerpt, then the URL. Each line
|
||||
// is clamped to the sidecar's per-line cap and the whole thing to the line-count
|
||||
// cap. Falls back to a stripped body excerpt when the post has no excerpt.
|
||||
function buildTownCrierText(post, { baseUrl } = {}) {
|
||||
const title = clamp(post.title, MAX_LINE_LEN)
|
||||
const excerptSource = post.excerpt || deriveExcerpt(post.body, MAX_LINE_LEN) || ''
|
||||
const lines = [title]
|
||||
const excerpt = clamp(excerptSource, MAX_LINE_LEN)
|
||||
if (excerpt) lines.push(excerpt)
|
||||
const url = clamp(articleUrl(baseUrl), MAX_LINE_LEN)
|
||||
if (url) lines.push(url)
|
||||
return lines.filter(Boolean).slice(0, MAX_LINES)
|
||||
}
|
||||
|
||||
// ── Result classification ──────────────────────────────────────────────────
|
||||
// Both clients return { ok, status, error }. Map that to one of:
|
||||
// done — delivered, mark the leg done
|
||||
// retry — transient (shard restarting, bot down, network); back off + retry
|
||||
// terminal — will never succeed as-is (over caps, bad auth/config); fail now
|
||||
|
||||
function classifyTownCrier(result) {
|
||||
if (result && result.ok) return { outcome: 'done' }
|
||||
const status = result ? result.status : 0
|
||||
// 400 = over the line/duration caps (a data problem — do NOT retry).
|
||||
// 401 = token mismatch, 409 = protocol mismatch (both config problems).
|
||||
if (status === 400 || status === 401 || status === 409) {
|
||||
return { outcome: 'terminal', error: legError(result) }
|
||||
}
|
||||
// 503 (shard not connected), 504 (shard timeout), 0 (network/timeout / not
|
||||
// configured yet), and any other 5xx are transient — retry.
|
||||
return { outcome: 'retry', error: legError(result) }
|
||||
}
|
||||
|
||||
function classifyDiscord(result) {
|
||||
if (result && result.ok) return { outcome: 'done' }
|
||||
// The bot's /internal/announce collapses failures (503 = not connected,
|
||||
// 400 = no news channel configured) without surfacing Discord's own
|
||||
// retry_after, so there is no reliable terminal signal to key on here. Retry
|
||||
// every failure on the shared backoff; a genuine config problem simply
|
||||
// exhausts its attempts and lands as `failed` in the admin panel, where the
|
||||
// per-leg retry button re-runs it after the channel is set.
|
||||
return { outcome: 'retry', error: legError(result) }
|
||||
function articleUrl(base) {
|
||||
return `${String(base || '').replace(/\/+$/, '')}/site/news`
|
||||
}
|
||||
|
||||
// Every leg's client returns { ok, status, data, error }. Squeeze a failure into
|
||||
// the one line stored in announce_job_legs.last_error and shown in the panel.
|
||||
function legError(result) {
|
||||
if (!result) return 'no response'
|
||||
if (result.status) {
|
||||
@@ -99,29 +50,32 @@ function scheduleAfter(attempts) {
|
||||
return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)]
|
||||
}
|
||||
|
||||
// Parent job status derived from the two leg statuses:
|
||||
// done — both legs delivered
|
||||
// failed — both legs gave up
|
||||
// partial — at least one leg reached a terminal state while the other has not
|
||||
// matched it (still pending/retrying, or the opposite terminal state)
|
||||
// pending — neither leg is terminal yet
|
||||
function rollupStatus(towncrierStatus, discordStatus) {
|
||||
if (towncrierStatus === 'done' && discordStatus === 'done') return 'done'
|
||||
if (towncrierStatus === 'failed' && discordStatus === 'failed') return 'failed'
|
||||
// Parent job status derived from its leg statuses:
|
||||
// done — every leg delivered
|
||||
// failed — every leg gave up
|
||||
// partial — at least one leg reached a terminal state without all of them
|
||||
// agreeing (some still pending/retrying, or a mix of done and failed)
|
||||
// pending — no leg is terminal yet
|
||||
//
|
||||
// Takes the list of leg statuses rather than two named arguments, because the leg
|
||||
// set is registered rather than fixed (MODULE_SYSTEM.md §1.8). No legs at all
|
||||
// rolls up `done`: with nothing registered there is nothing left to deliver, and
|
||||
// leaving such jobs `pending` would pile up rows the worker never touches.
|
||||
function rollupStatus(statuses) {
|
||||
const list = Array.isArray(statuses) ? statuses : []
|
||||
const terminal = (s) => s === 'done' || s === 'failed'
|
||||
if (terminal(towncrierStatus) || terminal(discordStatus)) return 'partial'
|
||||
if (list.every((s) => s === 'done')) return 'done'
|
||||
if (list.every((s) => s === 'failed')) return 'failed'
|
||||
if (list.some(terminal)) return 'partial'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_LINES,
|
||||
MAX_LINE_LEN,
|
||||
MAX_ATTEMPTS,
|
||||
BACKOFF_MS,
|
||||
buildTownCrierText,
|
||||
baseUrl,
|
||||
articleUrl,
|
||||
classifyTownCrier,
|
||||
classifyDiscord,
|
||||
legError,
|
||||
scheduleAfter,
|
||||
rollupStatus,
|
||||
}
|
||||
|
||||
@@ -2,19 +2,21 @@
|
||||
//
|
||||
// Sits between the DB rows and the worker: creates jobs on publish, records each
|
||||
// leg's outcome, keeps the parent `status` rollup in sync, stamps the post's
|
||||
// announced_at when both legs land, and resets a leg for the admin retry button.
|
||||
// The pure decisions (backoff, rollup, classification) live in .logic.js.
|
||||
// announced_at when every leg lands, and resets a leg for the admin retry button.
|
||||
// The pure decisions (backoff, rollup) live in .logic.js; which legs exist at all
|
||||
// is modules/registries.js's answer, not this file's (MODULE_SYSTEM.md §1.8).
|
||||
|
||||
const db = require('./announceJobs.db')
|
||||
const logic = require('./announceJobs.logic')
|
||||
const registries = require('../../modules/registries')
|
||||
const posts = require('../posts/posts.model')
|
||||
const log = require('../../utils/logger')('announce')
|
||||
|
||||
// Enqueue an announcement for a freshly-published news post: one job row (both
|
||||
// legs pending, due immediately) plus a back-pointer on the post so the admin
|
||||
// panel can find it. Returns the new job id.
|
||||
// Enqueue an announcement for a freshly-published news post: one job row, one leg
|
||||
// row per registered leg (all pending, due immediately), plus a back-pointer on
|
||||
// the post so the admin panel can find it. Returns the new job id.
|
||||
async function enqueue(postId) {
|
||||
const jobId = await db.create(postId)
|
||||
const jobId = await db.create(postId, registries.announceLegIds())
|
||||
await posts.linkAnnounceJob(postId, jobId)
|
||||
log.info('announce job enqueued', { jobId, postId })
|
||||
return jobId
|
||||
@@ -43,12 +45,13 @@ async function enqueueIfNeeded(post, transition) {
|
||||
}
|
||||
}
|
||||
|
||||
// Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of
|
||||
// logic.classify*'s results: 'done' | 'retry' | 'terminal'. For 'retry' we bump
|
||||
// the attempt count and schedule the next run (or fail the leg once the cap is
|
||||
// hit). Returns the updated job row.
|
||||
// Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of a
|
||||
// leg's classify() results: 'done' | 'retry' | 'terminal'. For 'retry' we bump the
|
||||
// attempt count and schedule the next run (or fail the leg once the cap is hit).
|
||||
// Returns the updated job row.
|
||||
async function recordOutcome(job, leg, { outcome, error }) {
|
||||
const attempts = Number(job[`${leg}_attempts`]) || 0
|
||||
const row = (job.legs || []).find((l) => l.leg === leg)
|
||||
const attempts = Number(row && row.attempts) || 0
|
||||
|
||||
if (outcome === 'done') {
|
||||
await db.updateLeg(job.id, leg, { status: 'done', attempts, lastError: null, nextAttemptAt: null })
|
||||
@@ -71,12 +74,12 @@ async function recordOutcome(job, leg, { outcome, error }) {
|
||||
return refreshStatus(job.id)
|
||||
}
|
||||
|
||||
// Recompute and persist the parent status from the two legs; stamp the post's
|
||||
// announced_at the moment both legs have delivered.
|
||||
// Recompute and persist the parent status from the legs; stamp the post's
|
||||
// announced_at the moment every leg has delivered.
|
||||
async function refreshStatus(jobId) {
|
||||
const job = await db.findById(jobId)
|
||||
if (!job) return null
|
||||
const status = logic.rollupStatus(job.towncrier_status, job.discord_status)
|
||||
const status = logic.rollupStatus(job.legs.map((l) => l.status))
|
||||
if (status !== job.status) await db.setStatus(jobId, status)
|
||||
job.status = status
|
||||
if (status === 'done') {
|
||||
@@ -93,16 +96,33 @@ async function refreshStatus(jobId) {
|
||||
// the worker pick it up on the next tick. Resets the attempt count so a retry
|
||||
// after a config fix gets a full budget again.
|
||||
async function resetLeg(postId, leg) {
|
||||
if (!db.LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`)
|
||||
if (!registries.announceLeg(leg)) throw new Error(`unknown announce leg: ${leg}`)
|
||||
const job = await db.findByPostId(postId)
|
||||
if (!job) return null
|
||||
// A job enqueued before this leg was registered has no row for it; create it so
|
||||
// the retry button works on an existing post after a module is installed.
|
||||
await db.ensureLegs(job.id, [leg])
|
||||
await db.updateLeg(job.id, leg, { status: 'pending', attempts: 0, lastError: null, nextAttemptAt: null })
|
||||
log.info('announce leg reset for retry', { jobId: job.id, postId, leg })
|
||||
return refreshStatus(job.id)
|
||||
// Labelled, because this is the response body the admin panel re-renders from.
|
||||
return withLabels(await refreshStatus(job.id))
|
||||
}
|
||||
|
||||
// Decorate a job's legs with the label their registration carries, so the admin
|
||||
// panel renders a module's leg with a real name and no client change
|
||||
// (MODULE_SYSTEM.md §1.8). An unregistered leg — a stale row from a module that
|
||||
// was since removed — keeps its id as the label rather than disappearing.
|
||||
function withLabels(job) {
|
||||
if (!job) return job
|
||||
job.legs = (job.legs || []).map((l) => {
|
||||
const registered = registries.announceLeg(l.leg)
|
||||
return { ...l, label: registered ? registered.label : l.leg }
|
||||
})
|
||||
return job
|
||||
}
|
||||
|
||||
async function getByPostId(postId) {
|
||||
return db.findByPostId(postId)
|
||||
return withLabels(await db.findByPostId(postId))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -113,4 +133,5 @@ module.exports = {
|
||||
refreshStatus,
|
||||
resetLeg,
|
||||
getByPostId,
|
||||
withLabels,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Per-user push-notification subscriptions (which streams a user opted into;
|
||||
// applied to every device they register). The catalog is config/notificationStreams.
|
||||
// applied to every device they register). The catalog is core's plus every
|
||||
// installed module's, so it is read back through modules/registries rather than
|
||||
// from a config file (MODULE_SYSTEM.md §1.8).
|
||||
|
||||
const db = require('./notificationSubs.db')
|
||||
const { isValidStream } = require('../../config/notificationStreams')
|
||||
const { isValidStream } = require('../../modules/registries')
|
||||
|
||||
const getForUser = async (userId) => (await db.listByUser(userId)).map((r) => r.stream_id)
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ const path = require('path')
|
||||
|
||||
const { MODULE_API_VERSION } = require('./version')
|
||||
const semver = require('./semver')
|
||||
const registries = require('./registries')
|
||||
const { splitStatements } = require('../utils/sqlStatements')
|
||||
|
||||
const log = require('../utils/logger')('modules')
|
||||
@@ -51,10 +52,10 @@ const MANIFEST_KEYS = new Set([
|
||||
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
|
||||
])
|
||||
|
||||
// Extension slots core declares (§2.4). Only core may declare one; a module may
|
||||
// only fill one. Validation rejects a manifest naming a slot that does not
|
||||
// exist — `registerExtension` itself arrives with PR 4.
|
||||
const CORE_SLOTS = new Set(['admin.users.detail'])
|
||||
// Extension slots are declared by core, at require time, in the router that owns
|
||||
// the resource (registries.declareSlot). The loader asks the registry which exist
|
||||
// rather than keeping a list, for the same reason the prefix check probes the
|
||||
// live tier routers: a second copy of the answer is a copy that drifts.
|
||||
|
||||
// id → record. Populated by load(), read by list().
|
||||
const modules = new Map()
|
||||
@@ -144,9 +145,9 @@ function buildApi(record) {
|
||||
if (record.called.has(name)) throw new Error(`${name}() called twice`)
|
||||
record.called.add(name)
|
||||
}
|
||||
// PR 4 brings the three de-entanglement registries and PR 5 the boot hooks.
|
||||
// They throw rather than no-op: an accepting stub would let a module believe
|
||||
// it had registered something and fail silently at the far end.
|
||||
// PR 5 brings the boot hooks. They throw rather than no-op: an accepting stub
|
||||
// would let a module believe it had registered something and fail silently at
|
||||
// the far end.
|
||||
const notYet = (name, pr) => () => {
|
||||
throw new Error(`${name}: not available until phase 2 PR ${pr}`)
|
||||
}
|
||||
@@ -163,9 +164,22 @@ function buildApi(record) {
|
||||
}
|
||||
}
|
||||
},
|
||||
registerExtension: notYet('registerExtension', 4),
|
||||
registerNotificationStreams: notYet('registerNotificationStreams', 4),
|
||||
registerAnnounceLeg: notYet('registerAnnounceLeg', 4),
|
||||
// The three de-entanglement registries (§2.4). They live in registries.js
|
||||
// rather than here because core registers through the same staging area, and
|
||||
// core has no `api` object.
|
||||
//
|
||||
// These STAGE. Nothing a module registers is visible to core until the
|
||||
// second pass commits it, for the reason the second pass exists at all: a
|
||||
// module that throws halfway through register(), or fails checkDeclared
|
||||
// after it, must leave nothing behind. A half-registered stream catalog
|
||||
// would be worse than a missing one — it would be a subscribable stream
|
||||
// nothing will ever publish to.
|
||||
registerExtension: record.staged.registerExtension,
|
||||
registerNotificationStreams(streams) {
|
||||
once('registerNotificationStreams')
|
||||
record.staged.registerNotificationStreams(streams)
|
||||
},
|
||||
registerAnnounceLeg: record.staged.registerAnnounceLeg,
|
||||
onBoot: notYet('onBoot', 5),
|
||||
onShutdown: notYet('onShutdown', 5),
|
||||
}
|
||||
@@ -317,7 +331,7 @@ function readManifest(dir, id, tierRouters) {
|
||||
}
|
||||
|
||||
for (const slot of manifest.extensions || []) {
|
||||
if (!CORE_SLOTS.has(slot)) throw new Error(`unknown extension slot "${slot}"`)
|
||||
if (!registries.hasSlot(slot)) throw new Error(`unknown extension slot "${slot}"`)
|
||||
}
|
||||
|
||||
if (manifest.schema && !manifest.purge) {
|
||||
@@ -392,6 +406,7 @@ function load(tierRouters) {
|
||||
dir,
|
||||
manifest: null,
|
||||
routes: { public: new Map(), admin: new Map(), player: new Map() },
|
||||
staged: registries.stage(id),
|
||||
tables: new Set(),
|
||||
called: new Set(),
|
||||
state: 'installed',
|
||||
@@ -433,7 +448,20 @@ function load(tierRouters) {
|
||||
// prefix would be told it collided with core, naming the wrong culprit, and
|
||||
// the module-versus-module check below it could never be reached.
|
||||
for (const record of modules.values()) {
|
||||
if (record.state === 'registered') mount(record, tierRouters)
|
||||
if (record.state !== 'registered') continue
|
||||
try {
|
||||
// Commit what this module staged. Collisions with core or with an earlier
|
||||
// module surface here, in scan order, and cost only this module.
|
||||
registries.apply(record.staged.staged)
|
||||
} catch (err) {
|
||||
record.state = 'startup_failed'
|
||||
record.reason = err.message
|
||||
log.error(`module "${record.id}" failed to register — continuing without it`, {
|
||||
reason: err.message,
|
||||
})
|
||||
continue // unmounted, exactly like a validation failure in the first pass
|
||||
}
|
||||
mount(record, tierRouters)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
356
server/src/modules/registries.js
Normal file
356
server/src/modules/registries.js
Normal 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,
|
||||
}
|
||||
@@ -728,6 +728,23 @@ async function listUsers(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id — the sanitized user (so the detail page is refresh-safe).
|
||||
//
|
||||
// Lived in usersShard.controller.js until PR 4, purely because the detail page it
|
||||
// backs is mostly shard panels — MODULE_SYSTEM.md §1.9 called that out as core
|
||||
// semantics that ended up in the UO controller by proximity. Reading a user is
|
||||
// core's, and it stays here when the shard panels leave.
|
||||
async function getUser(req, res) {
|
||||
try {
|
||||
const user = await users.getById(Number(req.params.id))
|
||||
if (!user) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(user)
|
||||
} catch (err) {
|
||||
log.error('getUser', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(req, res) {
|
||||
try {
|
||||
if (await users.getRawByUsername(req.body.username)) {
|
||||
@@ -938,6 +955,7 @@ module.exports = {
|
||||
ASSET_RULES,
|
||||
listActivity,
|
||||
listUsers,
|
||||
getUser,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Admin · Posts — news, five-on-friday, newsletter and screenshot posts, plus
|
||||
// the announcement pipeline (town crier + Discord) status and retry.
|
||||
// the announcement pipeline status and retry.
|
||||
//
|
||||
// Mounted at /api/v1/admin/posts by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. No extra gate: managing content is the
|
||||
@@ -13,6 +13,7 @@ const { body, param } = require('express-validator')
|
||||
const ctrl = require('./admin.controller')
|
||||
const { upload } = require('./imageUpload')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const registries = require('../../../modules/registries')
|
||||
|
||||
const postsRouter = express.Router()
|
||||
|
||||
@@ -124,14 +125,17 @@ postsRouter.get(
|
||||
postsRouter.post(
|
||||
'/:id/announce/retry',
|
||||
// #swagger.tags = ['Admin · Posts']
|
||||
// #swagger.summary = 'Retry one announcement delivery leg (town crier or Discord)'
|
||||
// #swagger.summary = 'Retry one announcement delivery leg'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { leg: { type: "string", enum: ["towncrier", "discord"] } }, required: ["leg"] } } } } */
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { leg: { type: "string", description: "A registered delivery leg id, as returned by GET /announce." } }, required: ["leg"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated announce job', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No announcement job for this post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
body('leg').isIn(['towncrier', 'discord']),
|
||||
// The allowlist is the REGISTERED leg set, read per request rather than
|
||||
// captured at require time: this file is required while app.js is being built,
|
||||
// before registerCore() and modules.load() have run (MODULE_SYSTEM.md §1.8).
|
||||
body('leg').custom((leg) => registries.announceLeg(leg) != null).withMessage('unknown announce leg'),
|
||||
validate,
|
||||
ctrl.retryAnnounceLeg,
|
||||
)
|
||||
|
||||
@@ -4,20 +4,18 @@
|
||||
// `noindex, isLoggedIn, staffOnly`. The whole capability is admin-only: editors
|
||||
// and moderators manage content and reports, never accounts.
|
||||
//
|
||||
// Handlers still live in admin.controller.js (users) and usersShard.controller.js
|
||||
// (uo-link footprint); this PR re-wires routes, not logic.
|
||||
// Handlers live in admin.controller.js. The shard footprint that used to be
|
||||
// wired here is now an EXTENSION SLOT (MODULE_SYSTEM.md §1.9) — see the bottom of
|
||||
// this file.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const ctrl = require('./admin.controller')
|
||||
const usersShard = require('./usersShard.controller')
|
||||
const registries = require('../../../modules/registries')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
// Same shape the shard routes validate account names with.
|
||||
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||
|
||||
const usersRouter = express.Router()
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
@@ -151,11 +149,6 @@ usersRouter.post(
|
||||
ctrl.resetUserMfa,
|
||||
)
|
||||
|
||||
// ── User → shard (uo-link) footprint (admin only) ─────────────────────
|
||||
// Backs the /admin/users/:id detail page: a user's linked game accounts and,
|
||||
// scoped to those accounts, their vendor sales / houses / online characters.
|
||||
// Live character rosters are fetched by the client through /admin/shard/* (which
|
||||
// already grants admins a bypass to any account), so no routes for them here.
|
||||
usersRouter.get(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
@@ -166,84 +159,21 @@ usersRouter.get(
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getUser,
|
||||
)
|
||||
usersRouter.get(
|
||||
'/:id/shard/accounts',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s linked game accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.listAccounts,
|
||||
)
|
||||
usersRouter.get(
|
||||
'/:id/shard/sales',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getSales,
|
||||
)
|
||||
usersRouter.get(
|
||||
'/:id/shard/houses',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Houses owned by a user’s accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getHouses,
|
||||
)
|
||||
usersRouter.get(
|
||||
'/:id/shard/online',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s characters currently online (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getOnline,
|
||||
)
|
||||
usersRouter.get(
|
||||
'/:id/shard/standing',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getStanding,
|
||||
)
|
||||
usersRouter.delete(
|
||||
'/:id/shard/link/:account',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Unlink a game account from this user (admin only)'
|
||||
// #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
|
||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').isInt(),
|
||||
param('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
usersShard.unlinkAccount,
|
||||
ctrl.getUser,
|
||||
)
|
||||
|
||||
// ── The `admin.users.detail` extension slot (MODULE_SYSTEM.md §1.9) ────────
|
||||
//
|
||||
// A module may hang routes off this core resource. Core DECLARES the slot; only
|
||||
// core may, and a module may only fill one (MODULE_API.md §2.4). What fills it
|
||||
// today is core's own usersShard.router.js, registered in registries.js's
|
||||
// registerCore() — the shard footprint that used to be wired inline right here.
|
||||
// Phase 3 changes the registrant, not this line.
|
||||
//
|
||||
// LAST, deliberately: every core route on the resource is already declared, so
|
||||
// first-match-wins means core owns any path conflict. The router is created at
|
||||
// declare time and filled later, because this file is required while app.js is
|
||||
// still being built — long before a module has been scanned.
|
||||
usersRouter.use('/:id', registries.declareSlot('admin.users.detail'))
|
||||
|
||||
module.exports = usersRouter
|
||||
|
||||
@@ -25,18 +25,6 @@ async function accountsForUser(id) {
|
||||
return { user, links, accounts: links.map((l) => l.account) }
|
||||
}
|
||||
|
||||
// GET /admin/users/:id — the sanitized user (so the detail page is refresh-safe).
|
||||
async function getUser(req, res) {
|
||||
try {
|
||||
const user = await users.getById(Number(req.params.id))
|
||||
if (!user) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(user)
|
||||
} catch (err) {
|
||||
log.error('getUser', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/shard/accounts — the user's linked game accounts.
|
||||
async function listAccounts(req, res) {
|
||||
try {
|
||||
@@ -139,4 +127,4 @@ async function unlinkAccount(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }
|
||||
module.exports = { listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }
|
||||
|
||||
111
server/src/router/v1/admin/usersShard.router.js
Normal file
111
server/src/router/v1/admin/usersShard.router.js
Normal 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 user’s linked game accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.listAccounts,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/shard/sales',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getSales,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/shard/houses',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Houses owned by a user’s accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getHouses,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/shard/online',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s characters currently online (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getOnline,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/shard/standing',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getStanding,
|
||||
)
|
||||
shardRouter.delete(
|
||||
'/shard/link/:account',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Unlink a game account from this user (admin only)'
|
||||
// #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
|
||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
param('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
usersShard.unlinkAccount,
|
||||
)
|
||||
|
||||
module.exports = shardRouter
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
const pushDevices = require('../../../model/pushDevices/pushDevices.model')
|
||||
const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model')
|
||||
const { STREAMS } = require('../../../config/notificationStreams')
|
||||
const registries = require('../../../modules/registries')
|
||||
const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
|
||||
|
||||
const log = require('../../../utils/logger')('notifications')
|
||||
@@ -49,9 +49,11 @@ async function removeDevice(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /auth/me/notifications/streams — the subscribable catalog (static).
|
||||
// GET /auth/me/notifications/streams — the subscribable catalog: core's streams
|
||||
// plus every installed module's, in registration order. Fixed for the lifetime of
|
||||
// a process (registration is boot-time), not a static constant.
|
||||
function getStreams(req, res) {
|
||||
return res.json({ streams: STREAMS })
|
||||
return res.json({ streams: registries.allStreams() })
|
||||
}
|
||||
|
||||
// GET /auth/me/notifications/subscriptions — the caller's opted-in stream ids.
|
||||
|
||||
@@ -2,59 +2,35 @@
|
||||
//
|
||||
// A lightweight, in-process table poller (no Redis/BullMQ in the stack). Every
|
||||
// ANNOUNCE_POLL_MS it sweeps announce_jobs for legs that are due — freshly
|
||||
// enqueued or past their backoff — and dispatches each one:
|
||||
// • town crier → uoLinkClient.postTownCrier (sidecar → in-game)
|
||||
// • discord → botInternalClient.announce (bot → #news channel)
|
||||
// Both clients never throw (they return { ok, status, error }); the model turns
|
||||
// each result into done / retry / terminal and owns the backoff + rollup. One
|
||||
// leg failing never touches the other. Same setInterval + unref + stop() shape
|
||||
// as middleware/botScore's sweeper, wired into server.js start/shutdown.
|
||||
// enqueued or past their backoff — and dispatches each one through the leg that
|
||||
// registered itself for that id (modules/registries.js). Core registers
|
||||
// `discord`; module-uo registers `towncrier`; another game's module registers its
|
||||
// own, and nothing in this file changes.
|
||||
//
|
||||
// A leg's client never throws (they return { ok, status, error }) and its
|
||||
// classify() turns that into done / retry / terminal, which the model converts to
|
||||
// backoff + rollup. One leg failing never touches another. Same setInterval +
|
||||
// unref + stop() shape as middleware/botScore's sweeper, wired into server.js
|
||||
// start/shutdown.
|
||||
|
||||
const announceJobs = require('../model/announceJobs/announceJobs.model')
|
||||
const announceJobsDb = require('../model/announceJobs/announceJobs.db')
|
||||
const logic = require('../model/announceJobs/announceJobs.logic')
|
||||
const posts = require('../model/posts/posts.model')
|
||||
const uoLinkClient = require('./uoLinkClient')
|
||||
const botInternalClient = require('./botInternalClient')
|
||||
const registries = require('../modules/registries')
|
||||
const log = require('./logger')('announce-worker')
|
||||
|
||||
const POLL_MS = Number(process.env.ANNOUNCE_POLL_MS) || 15_000
|
||||
const TOWNCRIER_DURATION_SEC = Number(process.env.TOWNCRIER_DURATION_SEC) || 3600
|
||||
|
||||
function baseUrl() {
|
||||
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// ── Leg dispatchers ─────────────────────────────────────────────────────────
|
||||
// Return the raw client result ({ ok, status, data, error }); classification is
|
||||
// the model/logic's job.
|
||||
|
||||
async function dispatchTownCrier(post) {
|
||||
const lines = logic.buildTownCrierText(post, { baseUrl: baseUrl() })
|
||||
// Stable id: re-posting `post-<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.
|
||||
async function processLeg(job, leg) {
|
||||
const registered = registries.announceLeg(leg)
|
||||
if (!registered) {
|
||||
// A row for a leg nobody registers any more (its module was removed). Leave
|
||||
// it alone: failing it would make the job roll up terminal on the strength of
|
||||
// a leg that no longer exists, and reinstalling the module should resume it.
|
||||
return
|
||||
}
|
||||
|
||||
const post = await posts.getById(job.post_id)
|
||||
if (!post) {
|
||||
// Post was deleted between enqueue and dispatch (the CASCADE usually reaps
|
||||
@@ -63,16 +39,9 @@ async function processLeg(job, leg) {
|
||||
return
|
||||
}
|
||||
|
||||
let result
|
||||
let classification
|
||||
try {
|
||||
if (leg === 'towncrier') {
|
||||
result = await dispatchTownCrier(post)
|
||||
classification = logic.classifyTownCrier(result)
|
||||
} else {
|
||||
result = await dispatchDiscord(post)
|
||||
classification = logic.classifyDiscord(result)
|
||||
}
|
||||
classification = registered.classify(await registered.dispatch(post))
|
||||
} catch (err) {
|
||||
// Clients shouldn't throw, but if one does, treat it as a transient failure
|
||||
// rather than crashing the tick.
|
||||
@@ -83,11 +52,10 @@ async function processLeg(job, leg) {
|
||||
await announceJobs.recordOutcome(job, leg, classification)
|
||||
}
|
||||
|
||||
// One sweep: find due jobs and process each due leg. A job may have both legs due
|
||||
// (a fresh enqueue) — process the ones that are actually pending. `job` is a
|
||||
// snapshot from the SELECT; recordOutcome re-reads for the rollup, so processing
|
||||
// the two legs sequentially off the same snapshot is fine (each leg only writes
|
||||
// its own columns).
|
||||
// One sweep: find due jobs and process each due leg. A job may have several legs
|
||||
// due at once (a fresh enqueue). `job` is a snapshot from the SELECT;
|
||||
// recordOutcome re-reads for the rollup, so processing the legs sequentially off
|
||||
// the same snapshot is fine (each leg only writes its own row).
|
||||
async function tick(now = new Date()) {
|
||||
let jobs
|
||||
try {
|
||||
@@ -99,15 +67,15 @@ async function tick(now = new Date()) {
|
||||
if (!jobs || jobs.length === 0) return
|
||||
|
||||
for (const job of jobs) {
|
||||
if (isLegDue(job, 'towncrier', now)) await processLeg(job, 'towncrier')
|
||||
if (isLegDue(job, 'discord', now)) await processLeg(job, 'discord')
|
||||
for (const row of job.legs || []) {
|
||||
if (isLegDue(row, now)) await processLeg(job, row.leg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isLegDue(job, leg, now) {
|
||||
if (job[`${leg}_status`] !== 'pending') return false
|
||||
const next = job[`${leg}_next_attempt_at`]
|
||||
return next == null || new Date(next) <= now
|
||||
function isLegDue(row, now) {
|
||||
if (!row || row.status !== 'pending') return false
|
||||
return row.next_attempt_at == null || new Date(row.next_attempt_at) <= now
|
||||
}
|
||||
|
||||
let timer = null
|
||||
@@ -118,7 +86,7 @@ function start() {
|
||||
tick().catch((err) => log.error('announce tick failed', { message: err.message }))
|
||||
}, POLL_MS)
|
||||
if (timer.unref) timer.unref() // don't keep the event loop alive (tests, shutdown)
|
||||
log.info('announcement dispatcher started', { pollMs: POLL_MS })
|
||||
log.info('announcement dispatcher started', { pollMs: POLL_MS, legs: registries.announceLegIds() })
|
||||
return timer
|
||||
}
|
||||
|
||||
@@ -129,4 +97,4 @@ function stop() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { start, stop, tick, processLeg, dispatchTownCrier, dispatchDiscord }
|
||||
module.exports = { start, stop, tick, processLeg, isLegDue }
|
||||
|
||||
45
server/src/utils/discordAnnounce.js
Normal file
45
server/src/utils/discordAnnounce.js
Normal 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 }
|
||||
@@ -1,10 +1,14 @@
|
||||
// ── Push-notification fan-out (content-free tickles) ───────────────────────
|
||||
//
|
||||
// The transport-agnostic publisher that turns an event into opt-in push
|
||||
// notifications. Two producers call in:
|
||||
// • utils/shardIngest.js → fromShardEvent(event) for shard-derived streams
|
||||
// (beside the existing SSE broadcast — same event source, same allowlist).
|
||||
// • the admin create/publish-post path → publish('news.post', …).
|
||||
// The transport-agnostic publisher that turns a stream id into opt-in push
|
||||
// notifications. It knows nothing about where the stream came from: the admin
|
||||
// create/publish-post path calls publish('news.post', …), and utils/shardPush.js
|
||||
// resolves a shard event to a stream and an owner and calls the same function.
|
||||
//
|
||||
// That split is MODULE_SYSTEM.md §1.8's second entanglement, inverted. This file
|
||||
// used to own `fromShardEvent()`, which required the shardLinks model and the
|
||||
// shard event mapper — core infrastructure reaching into game content. Now the
|
||||
// content side calls in, and a module reaches this through `ctx.push.publish`.
|
||||
//
|
||||
// What actually leaves the server is a CONTENT-FREE tickle — `{ stream, ref }`,
|
||||
// no sensitive data — POSTed to each subscribed device's UnifiedPush/ntfy
|
||||
@@ -18,9 +22,7 @@
|
||||
// registration AND every publish: HTTPS only, never a private/loopback host, and
|
||||
// (when configured) the origin must be in the shard's ntfy allow-set.
|
||||
|
||||
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||
const pushDevicesModel = require('../model/pushDevices/pushDevices.model')
|
||||
const { mapShardEvent } = require('../config/notificationStreams')
|
||||
const log = require('./logger')('push-dispatch')
|
||||
|
||||
const TIMEOUT_MS = 5000
|
||||
@@ -106,30 +108,4 @@ async function publish(streamId, { ref, ownerUserId } = {}, deps = {}) {
|
||||
await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps)))
|
||||
}
|
||||
|
||||
// Fan a shard event out to push. Resolves personal (owner-keyed) targets to the
|
||||
// owning website user via shardLinks (an unlinked account → nobody to notify).
|
||||
// Never throws — a dead relay must never affect ingest.
|
||||
async function fromShardEvent(event, deps = {}) {
|
||||
const links = deps.shardLinks || shardLinks
|
||||
const targets = mapShardEvent(event, deps.tracker)
|
||||
for (const t of targets) {
|
||||
try {
|
||||
if (t.ownerAccount) {
|
||||
let owner = null
|
||||
try {
|
||||
owner = await links.getByAccount(t.ownerAccount)
|
||||
} catch {
|
||||
owner = null
|
||||
}
|
||||
if (!owner || owner.userId == null) continue
|
||||
await publish(t.streamId, { ref: t.ref, ownerUserId: owner.userId }, deps)
|
||||
} else {
|
||||
await publish(t.streamId, { ref: t.ref }, deps)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('push dispatch target failed', { streamId: t.streamId, message: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { publish, fromShardEvent, isAllowedEndpoint }
|
||||
module.exports = { publish, isAllowedEndpoint }
|
||||
|
||||
77
server/src/utils/shardAnnounce.js
Normal file
77
server/src/utils/shardAnnounce.js
Normal 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 }
|
||||
@@ -19,7 +19,7 @@ const shardMarketModel = require('../model/shardMarket/shardMarket.model')
|
||||
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const settingsModel = require('../model/settings/settings.model')
|
||||
const broadcaster = require('./shardBroadcast')
|
||||
const pushDispatch = require('./pushDispatch')
|
||||
const shardPush = require('./shardPush')
|
||||
const defaultLog = require('./logger')('shard-ingest')
|
||||
|
||||
// Notable kinds appended to the shard_events log. High-frequency/session kinds
|
||||
@@ -262,7 +262,7 @@ function resolveDeps(deps) {
|
||||
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
|
||||
settings: deps.settings || settingsModel,
|
||||
broadcast: deps.broadcast || broadcaster.broadcast,
|
||||
pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent,
|
||||
pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
|
||||
log: deps.log || defaultLog,
|
||||
}
|
||||
}
|
||||
|
||||
47
server/src/utils/shardPush.js
Normal file
47
server/src/utils/shardPush.js
Normal 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
115
server/swagger/mergeSpec.js
Normal 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
120
server/swagger/slotSpecs.js
Normal 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 }
|
||||
@@ -3249,7 +3249,7 @@
|
||||
"tags": [
|
||||
"Admin · Posts"
|
||||
],
|
||||
"summary": "Retry one announcement delivery leg (town crier or Discord)",
|
||||
"summary": "Retry one announcement delivery leg",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -3308,10 +3308,7 @@
|
||||
"properties": {
|
||||
"leg": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"towncrier",
|
||||
"discord"
|
||||
]
|
||||
"description": "A registered delivery leg id, as returned by GET /announce."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -1513,9 +1513,31 @@ function normalizePaths(spec) {
|
||||
return spec
|
||||
}
|
||||
|
||||
swaggerAutogen(outputFile, routes, doc).then(() => {
|
||||
// Static analysis cannot follow a route into an extension slot, so the slot
|
||||
// routers contribute a generated fragment afterwards — see swagger/slotSpecs.js
|
||||
// for what goes wrong without it. Merged BEFORE normalizePaths, so the merged-in
|
||||
// paths are sorted and trailing-slash-checked with everything else.
|
||||
//
|
||||
// The pool is pointed at a closed port here for the same reason
|
||||
// scripts/routeManifest.js does it: the merge step requires src/app.js to find
|
||||
// where each slot router is mounted, and requiring app.js builds the models. No
|
||||
// query is ever run.
|
||||
process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1'
|
||||
process.env.DB_PORT = process.env.DB_PORT || '59999'
|
||||
|
||||
/* eslint-disable global-require */
|
||||
swaggerAutogen(outputFile, routes, doc)
|
||||
.then(() => require('./slotSpecs').mergeSlotSpecs(outputFile))
|
||||
.then(() => {
|
||||
const written = JSON.parse(fs.readFileSync(outputFile, 'utf8'))
|
||||
fs.writeFileSync(outputFile, `${JSON.stringify(normalizePaths(written), null, 2)}\n`)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('swagger-output.json generated.')
|
||||
// The mariadb pool keeps the loop alive even pointed at a dead port.
|
||||
return require('../src/utils/db').close()
|
||||
})
|
||||
.catch((err) => {
|
||||
process.stderr.write(`${err.stack || err.message}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
/* eslint-enable global-require */
|
||||
|
||||
@@ -75,7 +75,7 @@ test('salesForAccounts keeps only sales owned by the given accounts, newest 50',
|
||||
})
|
||||
|
||||
// ── Controller: unknown user → 404 ─────────────────────────────────────────
|
||||
for (const handler of ['getUser', 'listAccounts', 'getSales', 'getHouses', 'getOnline']) {
|
||||
for (const handler of ['listAccounts', 'getSales', 'getHouses', 'getOnline']) {
|
||||
test(`${handler} returns 404 when the user does not exist`, async () => {
|
||||
users.getById = async () => null
|
||||
const res = mockRes()
|
||||
@@ -144,10 +144,7 @@ test('a user with no linked accounts yields empty sales/houses/online', async ()
|
||||
assert.deepEqual(online.body, [])
|
||||
})
|
||||
|
||||
test('getUser returns the sanitized user row', async () => {
|
||||
users.getById = async () => ({ id: 7, username: 'bob', role: 'player', status: 'active' })
|
||||
const res = mockRes()
|
||||
await ctrl.getUser({ params: { id: '7' } }, res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(res.body.username, 'bob')
|
||||
})
|
||||
// getUser is NOT here any more: reading a user is core semantics that had ended
|
||||
// up in this controller by proximity, and PR 4 moved it back to
|
||||
// admin.controller.js behind the extension slot (MODULE_SYSTEM.md §1.9). It is
|
||||
// covered by test/adminUsers.test.js.
|
||||
|
||||
65
server/test/adminUsers.test.js
Normal file
65
server/test/adminUsers.test.js
Normal 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)
|
||||
})
|
||||
@@ -2,10 +2,14 @@ const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const logic = require('../src/model/announceJobs/announceJobs.logic')
|
||||
// Each leg owns its own text-building and result classification since PR 4 — a
|
||||
// leg is a registration now, not a branch in the worker (MODULE_SYSTEM.md §1.8).
|
||||
const townCrier = require('../src/utils/shardAnnounce')
|
||||
const discord = require('../src/utils/discordAnnounce')
|
||||
|
||||
// ── buildTownCrierText ───────────────────────────────────────────────────────
|
||||
test('buildTownCrierText produces title, excerpt, and URL lines', () => {
|
||||
const lines = logic.buildTownCrierText(
|
||||
const lines = townCrier.buildTownCrierText(
|
||||
{ id: 7, title: 'Server Update', excerpt: 'Big things afoot.', body: null },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
@@ -13,7 +17,7 @@ test('buildTownCrierText produces title, excerpt, and URL lines', () => {
|
||||
})
|
||||
|
||||
test('buildTownCrierText falls back to a stripped body when excerpt is empty', () => {
|
||||
const lines = logic.buildTownCrierText(
|
||||
const lines = townCrier.buildTownCrierText(
|
||||
{ id: 1, title: 'T', excerpt: '', body: '<p>Hello <b>world</b></p>' },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
@@ -22,17 +26,17 @@ test('buildTownCrierText falls back to a stripped body when excerpt is empty', (
|
||||
|
||||
test('buildTownCrierText clamps each line to the sidecar per-line cap', () => {
|
||||
const longTitle = 'x'.repeat(500)
|
||||
const lines = logic.buildTownCrierText(
|
||||
const lines = townCrier.buildTownCrierText(
|
||||
{ id: 1, title: longTitle, excerpt: 'y'.repeat(500), body: null },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
for (const line of lines) assert.ok(line.length <= logic.MAX_LINE_LEN, `line too long: ${line.length}`)
|
||||
for (const line of lines) assert.ok(line.length <= townCrier.MAX_LINE_LEN, `line too long: ${line.length}`)
|
||||
assert.ok(lines[0].endsWith('…'))
|
||||
assert.ok(lines.length <= logic.MAX_LINES)
|
||||
assert.ok(lines.length <= townCrier.MAX_LINES)
|
||||
})
|
||||
|
||||
test('buildTownCrierText omits the excerpt line when there is no excerpt or body', () => {
|
||||
const lines = logic.buildTownCrierText(
|
||||
const lines = townCrier.buildTownCrierText(
|
||||
{ id: 1, title: 'Only a title', excerpt: null, body: null },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
@@ -40,27 +44,27 @@ test('buildTownCrierText omits the excerpt line when there is no excerpt or body
|
||||
})
|
||||
|
||||
// ── classifyTownCrier ────────────────────────────────────────────────────────
|
||||
test('classifyTownCrier: 2xx is done', () => {
|
||||
assert.equal(logic.classifyTownCrier({ ok: true, status: 200 }).outcome, 'done')
|
||||
test('town crier classify: 2xx is done', () => {
|
||||
assert.equal(townCrier.classify({ ok: true, status: 200 }).outcome, 'done')
|
||||
})
|
||||
|
||||
test('classifyTownCrier: over-cap / auth / protocol errors are terminal (no retry)', () => {
|
||||
test('town crier classify: over-cap / auth / protocol errors are terminal (no retry)', () => {
|
||||
for (const status of [400, 401, 409]) {
|
||||
assert.equal(logic.classifyTownCrier({ ok: false, status }).outcome, 'terminal', `status ${status}`)
|
||||
assert.equal(townCrier.classify({ ok: false, status }).outcome, 'terminal', `status ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyTownCrier: shard-transient and network errors retry', () => {
|
||||
test('town crier classify: shard-transient and network errors retry', () => {
|
||||
for (const status of [503, 504, 500, 0]) {
|
||||
assert.equal(logic.classifyTownCrier({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||
assert.equal(townCrier.classify({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── classifyDiscord ──────────────────────────────────────────────────────────
|
||||
test('classifyDiscord: ok is done, every failure retries', () => {
|
||||
assert.equal(logic.classifyDiscord({ ok: true, status: 200 }).outcome, 'done')
|
||||
test('discord classify: ok is done, every failure retries', () => {
|
||||
assert.equal(discord.classify({ ok: true, status: 200 }).outcome, 'done')
|
||||
for (const status of [400, 503, 0]) {
|
||||
assert.equal(logic.classifyDiscord({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||
assert.equal(discord.classify({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -75,12 +79,27 @@ test('scheduleAfter returns increasing delays then null at the attempt cap', ()
|
||||
})
|
||||
|
||||
// ── rollupStatus ─────────────────────────────────────────────────────────────
|
||||
test('rollupStatus derives the parent status from the two legs', () => {
|
||||
assert.equal(logic.rollupStatus('done', 'done'), 'done')
|
||||
assert.equal(logic.rollupStatus('failed', 'failed'), 'failed')
|
||||
assert.equal(logic.rollupStatus('pending', 'pending'), 'pending')
|
||||
// One terminal, the other not matching → partial.
|
||||
assert.equal(logic.rollupStatus('done', 'pending'), 'partial')
|
||||
assert.equal(logic.rollupStatus('pending', 'failed'), 'partial')
|
||||
assert.equal(logic.rollupStatus('done', 'failed'), 'partial')
|
||||
// Takes the LIST of leg statuses, not two named legs: which legs exist is what a
|
||||
// module decides (MODULE_SYSTEM.md §1.8).
|
||||
test('rollupStatus derives the parent status from its legs', () => {
|
||||
assert.equal(logic.rollupStatus(['done', 'done']), 'done')
|
||||
assert.equal(logic.rollupStatus(['failed', 'failed']), 'failed')
|
||||
assert.equal(logic.rollupStatus(['pending', 'pending']), 'pending')
|
||||
// One terminal, the others not matching → partial.
|
||||
assert.equal(logic.rollupStatus(['done', 'pending']), 'partial')
|
||||
assert.equal(logic.rollupStatus(['pending', 'failed']), 'partial')
|
||||
assert.equal(logic.rollupStatus(['done', 'failed']), 'partial')
|
||||
})
|
||||
|
||||
test('rollupStatus generalises past two legs', () => {
|
||||
assert.equal(logic.rollupStatus(['done', 'done', 'done']), 'done')
|
||||
assert.equal(logic.rollupStatus(['done', 'done', 'pending']), 'partial')
|
||||
assert.equal(logic.rollupStatus(['pending', 'pending', 'pending']), 'pending')
|
||||
assert.equal(logic.rollupStatus(['failed', 'failed', 'failed']), 'failed')
|
||||
// A single leg is not a special case.
|
||||
assert.equal(logic.rollupStatus(['pending']), 'pending')
|
||||
assert.equal(logic.rollupStatus(['done']), 'done')
|
||||
// No legs registered at all: nothing is left to deliver, so the job is done
|
||||
// rather than pending forever on a leg that does not exist.
|
||||
assert.equal(logic.rollupStatus([]), 'done')
|
||||
})
|
||||
|
||||
206
server/test/announceLegs.test.js
Normal file
206
server/test/announceLegs.test.js
Normal 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 leg’s label comes from its registration, and an orphan keeps its id', async () => {
|
||||
register(fakeLegs().a)
|
||||
announceDb.findByPostId = async () => ({
|
||||
id: 1,
|
||||
post_id: 5,
|
||||
status: 'partial',
|
||||
legs: [{ leg: 'discord', status: 'done' }, { leg: 'gone.leg', status: 'pending' }],
|
||||
})
|
||||
const job = await announceJobs.getByPostId(5)
|
||||
assert.deepEqual(job.legs.map((l) => [l.leg, l.label]), [
|
||||
['discord', 'Discord #news'],
|
||||
['gone.leg', 'gone.leg'],
|
||||
])
|
||||
})
|
||||
|
||||
// ── recordOutcome reads attempts off the row ───────────────────────────────
|
||||
|
||||
test('recordOutcome takes the attempt count from the leg row', async () => {
|
||||
register(fakeLegs().a)
|
||||
const updates = []
|
||||
announceDb.updateLeg = async (jobId, leg, fields) => updates.push(fields)
|
||||
announceDb.findById = async () => ({ id: 1, post_id: 5, status: 'pending', legs: [{ leg: 'discord', status: 'pending' }] })
|
||||
announceDb.setStatus = async () => {}
|
||||
|
||||
const job = { id: 1, post_id: 5, legs: [{ leg: 'discord', status: 'pending', attempts: 2 }] }
|
||||
await announceJobs.recordOutcome(job, 'discord', { outcome: 'retry', error: 'nope' })
|
||||
assert.equal(updates[0].attempts, 3)
|
||||
assert.ok(updates[0].nextAttemptAt instanceof Date)
|
||||
})
|
||||
@@ -27,8 +27,16 @@ const assert = require('node:assert/strict')
|
||||
const express = require('express')
|
||||
|
||||
const db = require('../src/utils/db')
|
||||
const registries = require('../src/modules/registries')
|
||||
const { startApp } = require('./_helper')
|
||||
|
||||
// Requiring the real admin router declares the `admin.users.detail` extension
|
||||
// slot exactly the way production does (users.router.js, at require time). Doing
|
||||
// it here rather than calling declareSlot by hand matters: one test below builds
|
||||
// the real tier routers, and a hand-declared slot would collide with that
|
||||
// require's own declaration.
|
||||
require('../src/router/v1/admin')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
let tmpRoot
|
||||
@@ -42,6 +50,11 @@ const emptyTiers = () => ({
|
||||
|
||||
function freshLoader(dir, tiers = emptyTiers()) {
|
||||
process.env.MODULES_DIR = dir
|
||||
// The registries are process-global (there is one core), so hand the process
|
||||
// back between tests. Without this a module's staged registrations from a
|
||||
// previous test would still be committed, and every collision assertion below
|
||||
// would be asserting against the wrong history.
|
||||
registries._reset()
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
// eslint-disable-next-line global-require
|
||||
const loader = require('../src/modules/loader')
|
||||
@@ -493,13 +506,10 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
|
||||
assert.equal(probe.mutable, false, 'ctx members must be frozen')
|
||||
})
|
||||
|
||||
test('the register calls PR 4 and PR 5 own throw rather than silently accepting', () => {
|
||||
// An accepting no-op would let a module believe it had registered a
|
||||
// notification stream or a boot hook and fail silently at the far end.
|
||||
test('the register calls PR 5 owns throw rather than silently accepting', () => {
|
||||
// An accepting no-op would let a module believe it had registered a boot hook
|
||||
// and fail silently at the far end.
|
||||
for (const [call, pr] of [
|
||||
['registerExtension', 4],
|
||||
['registerNotificationStreams', 4],
|
||||
['registerAnnounceLeg', 4],
|
||||
['onBoot', 5],
|
||||
['onShutdown', 5],
|
||||
]) {
|
||||
@@ -511,3 +521,56 @@ test('the register calls PR 4 and PR 5 own throw rather than silently accepting'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Staged registrations are committed only for a module that survives ─────
|
||||
|
||||
test('a module that fails AFTER registering leaves nothing in the registries', () => {
|
||||
// The registry-side twin of the second-pass mount rule. register() runs before
|
||||
// checkDeclared, so a module can stage a stream catalog and then be rejected —
|
||||
// and a half-registered catalog is worse than a missing one, because it is a
|
||||
// subscribable stream nothing will ever publish to.
|
||||
writeModule('halfway', {
|
||||
manifest: { mounts: { public: ['/declared'] } },
|
||||
server: `module.exports = (ctx, api) => {
|
||||
api.registerNotificationStreams([{ id: 'halfway.thing', label: 'Thing' }])
|
||||
api.registerAnnounceLeg({ leg: 'halfway.leg', label: 'L', dispatch: async () => ({}), classify: () => ({}) })
|
||||
// declared /declared and never registered it → rejected by checkDeclared
|
||||
}`,
|
||||
})
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
assert.match(stateOf(loader, 'halfway').reason, /declared public\/declared but never registered it/)
|
||||
assert.equal(registries.isValidStream('halfway.thing'), false)
|
||||
assert.equal(registries.announceLeg('halfway.leg'), null)
|
||||
})
|
||||
|
||||
test('a module colliding with an already-registered name fails alone, unmounted', () => {
|
||||
const tiers = emptyTiers()
|
||||
writeModule('first', {
|
||||
manifest: { mounts: { public: ['/first'] } },
|
||||
server: `module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ public: { '/first': ctx.express.Router() } })
|
||||
api.registerNotificationStreams([{ id: 'first.shared', label: 'Shared' }])
|
||||
}`,
|
||||
})
|
||||
writeModule('second', {
|
||||
manifest: { mounts: { public: ['/second'] } },
|
||||
server: `module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ public: { '/second': ctx.express.Router() } })
|
||||
api.registerNotificationStreams([{ id: 'second.ok', label: 'Ok' }, { id: 'first.shared', label: 'Mine' }])
|
||||
}`,
|
||||
})
|
||||
const loader = freshLoader(tmpRoot, tiers)
|
||||
|
||||
assert.equal(stateOf(loader, 'first').state, 'registered')
|
||||
assert.match(stateOf(loader, 'second').reason, /already registered by "first"/)
|
||||
// Not even the claim that did not collide.
|
||||
assert.equal(registries.isValidStream('second.ok'), false)
|
||||
// And the loser is not mounted at all. Asked of the live router the way the
|
||||
// prefix-ownership check asks it, rather than by counting layers — one mount
|
||||
// produces two (the dispatch guard, then the module's router).
|
||||
const claims = (prefix) =>
|
||||
tiers.public.stack.some((l) => l.regexp && !l.regexp.fast_slash && l.match(prefix))
|
||||
assert.equal(claims('/first'), true)
|
||||
assert.equal(claims('/second'), false)
|
||||
})
|
||||
|
||||
216
server/test/moduleRegistries.test.js
Normal file
216
server/test/moduleRegistries.test.js
Normal 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 module’s stream ids must carry its module id', () => {
|
||||
assert.equal(tryApply('rust', (api) => api.registerNotificationStreams([stream('rust.raid')])), null)
|
||||
assert.match(
|
||||
tryApply('rust', (api) => api.registerNotificationStreams([stream('raid.started')])),
|
||||
/not namespaced "rust\."/,
|
||||
)
|
||||
})
|
||||
|
||||
test('the seven pre-module-system stream ids are grandfathered to uo alone', () => {
|
||||
// Renaming them would be a data migration (notification_subs rows) and a break
|
||||
// for a shipped Android client — the same reasoning as the loader's legacy
|
||||
// table prefixes.
|
||||
assert.equal(tryApply('uo', (api) => api.registerNotificationStreams([stream('vendor.sale')])), null)
|
||||
registries._reset()
|
||||
assert.match(
|
||||
tryApply('rust', (api) => api.registerNotificationStreams([stream('vendor.sale')])),
|
||||
/not namespaced "rust\."/,
|
||||
)
|
||||
})
|
||||
|
||||
test('an announce leg must be namespaced too, with towncrier grandfathered to uo', () => {
|
||||
assert.equal(tryApply('uo', (api) => api.registerAnnounceLeg(leg('towncrier'))), null)
|
||||
registries._reset()
|
||||
assert.equal(tryApply('rust', (api) => api.registerAnnounceLeg(leg('rust.motd'))), null)
|
||||
registries._reset()
|
||||
assert.match(
|
||||
tryApply('rust', (api) => api.registerAnnounceLeg(leg('towncrier'))),
|
||||
/not namespaced "rust\."/,
|
||||
)
|
||||
})
|
||||
|
||||
// ── Collisions name the holder ─────────────────────────────────────────────
|
||||
|
||||
test('a stream core already registered is refused, naming core', () => {
|
||||
registries.registerCore()
|
||||
assert.match(
|
||||
tryApply('uo', (api) => api.registerNotificationStreams([stream('news.post')])),
|
||||
/already registered by "core"/,
|
||||
)
|
||||
})
|
||||
|
||||
test('two modules cannot register the same stream or leg', () => {
|
||||
assert.equal(tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.thing')])), null)
|
||||
// A second module can only reach it via its own namespace, so collide on a
|
||||
// grandfathered id, which is the realistic case.
|
||||
assert.match(
|
||||
tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.thing')])),
|
||||
/already registered by "aaa"/,
|
||||
)
|
||||
assert.equal(tryApply('bbb', (api) => api.registerAnnounceLeg(leg('bbb.x'))), null)
|
||||
assert.match(tryApply('bbb', (api) => api.registerAnnounceLeg(leg('bbb.x'))), /already registered by "bbb"/)
|
||||
})
|
||||
|
||||
test('a batch cannot claim the same name twice', () => {
|
||||
assert.match(
|
||||
tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.x'), stream('aaa.x')])),
|
||||
/registered twice/,
|
||||
)
|
||||
})
|
||||
|
||||
// ── Validate-then-commit ───────────────────────────────────────────────────
|
||||
|
||||
test('a batch whose LAST claim collides commits none of the earlier ones', () => {
|
||||
// The property the whole staging design exists for. A half-registered catalog
|
||||
// is worse than a missing one: a subscribable stream nothing will publish to.
|
||||
registries.registerCore()
|
||||
const before = registries.allStreams().length
|
||||
|
||||
const err = tryApply('uo', (api) => {
|
||||
api.registerNotificationStreams([stream('uo.first'), stream('uo.second')])
|
||||
api.registerAnnounceLeg(leg('uo.leg'))
|
||||
api.registerNotificationStreams([stream('news.post')]) // collides with core
|
||||
})
|
||||
|
||||
assert.match(err, /already registered by "core"/)
|
||||
assert.equal(registries.allStreams().length, before, 'uo.first / uo.second must not be registered')
|
||||
assert.equal(registries.isValidStream('uo.first'), false)
|
||||
assert.equal(registries.announceLeg('uo.leg'), null)
|
||||
})
|
||||
|
||||
test('staging alone changes nothing — only apply() commits', () => {
|
||||
const api = registries.stage('uo')
|
||||
api.registerNotificationStreams([stream('uo.staged')])
|
||||
assert.equal(registries.isValidStream('uo.staged'), false)
|
||||
registries.apply(api.staged)
|
||||
assert.equal(registries.isValidStream('uo.staged'), true)
|
||||
})
|
||||
|
||||
// ── Shape checks fire at the call ──────────────────────────────────────────
|
||||
|
||||
test('a malformed claim throws where the registrant made it, not at apply()', () => {
|
||||
const api = registries.stage('uo')
|
||||
assert.throws(() => api.registerNotificationStreams([stream('nodots')]), /bad stream id/)
|
||||
assert.throws(() => api.registerNotificationStreams([{ id: 'uo.x' }]), /has no label/)
|
||||
assert.throws(() => api.registerNotificationStreams('not an array'), /expected an array/)
|
||||
assert.throws(() => api.registerAnnounceLeg(leg('uo.x', { dispatch: null })), /has no dispatch/)
|
||||
assert.throws(() => api.registerAnnounceLeg(leg('uo.x', { classify: null })), /has no classify/)
|
||||
assert.throws(() => api.registerAnnounceLeg({ leg: 'NOPE' }), /bad leg id/)
|
||||
})
|
||||
|
||||
// ── Extension slots ────────────────────────────────────────────────────────
|
||||
|
||||
test('only a declared slot can be filled, and only once', () => {
|
||||
const router = () => {}
|
||||
const api = registries.stage('uo')
|
||||
assert.throws(() => api.registerExtension('admin.invented', router), /unknown extension slot/)
|
||||
assert.throws(() => api.registerExtension('admin.users.detail', 'not a router'), /is not a router/)
|
||||
|
||||
registries.registerCore() // core fills it
|
||||
assert.match(
|
||||
tryApply('uo', (a) => a.registerExtension('admin.users.detail', router)),
|
||||
/already filled by "core"/,
|
||||
)
|
||||
})
|
||||
|
||||
test('a slot cannot be declared twice', () => {
|
||||
assert.throws(() => registries.declareSlot('admin.users.detail'), /already declared/)
|
||||
})
|
||||
|
||||
// ── The slot's spec, which static analysis cannot see ──────────────────────
|
||||
|
||||
test('the filled slot’s router is findable in the live app, at the resource path', () => {
|
||||
// Guards swagger/slotSpecs.js: it recovers each slot's mount prefix from the
|
||||
// live stack rather than a hardcoded table. If this stops working, the six
|
||||
// slot routes vanish from swagger-output.json with `Success` printed — the
|
||||
// exact silent failure the spike hit (MODULE_API.md §7.4).
|
||||
/* eslint-disable global-require */
|
||||
const app = require('../src/app')
|
||||
const { findMountPrefix } = require('../swagger/slotSpecs')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const [slot] = registries.filledSlots()
|
||||
assert.ok(slot, 'app.js must have registered core, filling the slot')
|
||||
assert.equal(slot.slot, 'admin.users.detail')
|
||||
assert.ok(slot.specFile, 'core names the file its slot router is generated from')
|
||||
assert.equal(findMountPrefix(app._router.stack, slot.router), '/api/v1/admin/users/:id')
|
||||
})
|
||||
@@ -6,8 +6,11 @@ process.env.DB_PORT = '59999'
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { mapShardEvent, createTracker } = require('../src/config/notificationStreams')
|
||||
const { mapShardEvent, createTracker } = require('../src/config/shardStreams')
|
||||
const pushDispatch = require('../src/utils/pushDispatch')
|
||||
// fromShardEvent moved out of pushDispatch in PR 4: core publishes, the shard
|
||||
// side resolves an event to a stream and an owner (MODULE_SYSTEM.md §1.8).
|
||||
const shardPush = require('../src/utils/shardPush')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
@@ -202,14 +205,14 @@ test('fromShardEvent resolves a personal event to the owning user, or drops it i
|
||||
}
|
||||
const deps = { shardLinks, pushDevices, fetchImpl, tracker: createTracker() }
|
||||
|
||||
await pushDispatch.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps)
|
||||
await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps)
|
||||
assert.deepEqual(userStreamCalls, [[42, 'vendor.sale']])
|
||||
assert.equal(calls.length, 1)
|
||||
|
||||
// Unlinked account → nobody to notify → no publish.
|
||||
userStreamCalls.length = 0
|
||||
calls.length = 0
|
||||
await pushDispatch.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps)
|
||||
await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps)
|
||||
assert.equal(userStreamCalls.length, 0)
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
@@ -226,7 +229,7 @@ test('fromShardEvent fans a public shard event to the stream’s subscribers', a
|
||||
},
|
||||
endpointsForUserStream: async () => [],
|
||||
}
|
||||
await pushDispatch.fromShardEvent(
|
||||
await shardPush.fromShardEvent(
|
||||
{ kind: 'server.hello', bootId: 'b1' },
|
||||
{ pushDevices, fetchImpl, tracker: createTracker() },
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user