feat(modules): the three de-entanglement registries, with core as the registrant
Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. Adds server/src/modules/registries.js and moves core's own notification streams, announce leg and users-detail routes behind it, so the three seams §1.8 and §1.9 named are exercised on every boot before any module depends on them. Registering is validate-then-commit per registrant: the loader stages what a module claims and the second pass commits it, so a module that throws halfway through register() — or fails checkDeclared after it — leaves nothing behind. That is the registry-side twin of PR 2's second-pass mount rule. Four decisions, all the recommended option: - announce legs became a child table. `announce_job_legs` replaces the towncrier_*/discord_* column groups, so the leg set is data: core registers `discord`, module-uo will register `towncrier`, and a module cannot ALTER a core table to add its own. Backfill is guarded on information_schema (a SELECT of a dropped column is a parse error, not a runtime one) and the columns go with DROP COLUMN IF EXISTS. Verified against the live dev DB: three legacy jobs migrated faithfully, three replays, no duplicates. - `mapEvent` dropped from registerNotificationStreams. §1.8 already inverts the push path so a module owns fromShardEvent and calls core's publish() with a stream id it resolved; a second mapping mechanism was a leftover. The public safety filter, the kinds it reads and the streams it protects now live in one file and move together. - core registers through the same staging area a module uses, via an explicit registries.registerCore() in app.js before modules.load(). - core's six /admin/users/:id/shard/* paths now go through the `admin.users.detail` slot, and getUser moved back to admin.controller.js. Found on the way, and the reason two build tools changed: - scripts/routeManifest.js could not decode a parameterised mount. Its unwinder expected `(?:([^\/]+?))`; express 4.22 emits `(?:\/([^/]+?))` with the separator inside the group. The branch had never run. It threw rather than guessing, which is what it is for. - swagger-autogen cannot follow a route into an extension slot — the slot's router is created by declareSlot() and filled later, so there is no literal mount for a static parse. Regenerating deleted 407 lines and printed `Swagger-autogen: Success`, the spike's exact failure (MODULE_API.md §7.4). swagger/slotSpecs.js generates a fragment per filled slot and re-roots it at the prefix the router actually hangs at in the live app — read from the express stack via routeManifest's own mountPath, so the manifest and the spec cannot disagree. swagger/mergeSpec.js is the merge helper core owes for module fragments anyway (§6.1a), proved here against core's own slot first. 884 tests pass (856 before). routes.manifest.json is unchanged at 229 routes. The OpenAPI spec diff is two lines of intent: the retry endpoint's summary, and its `leg` no longer being a fixed enum. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||
Reference in New Issue
Block a user