Phase 6's foundation: the fan-out shape the existing pipeline could not express.
`pushDispatch.publish` answers "everyone subscribed to a stream" and "this one
owner". Team notifications need "these N users", because Team scoping cannot live
in a stream id: the catalog is a static registration validated at boot against a
namespaced pattern, so a stream per Team is unexpressible, and stream ids are
stored in `notification_subscriptions` rows that would need collecting every time
a Team archived. So there are FOUR fixed core streams and the Team lives entirely
in the recipient set.
`team_notification_prefs` is opt-out for push and opt-IN for email — the two sinks
default opposite ways, and the asymmetry lives in the column defaults so no
condition anywhere has to remember it.
One recipient query serves all four streams, because §6.2's two populations are
the same set written twice: "active members with a user_id plus active grants" IS
"everyone with resolved forum access". Mutes are subtracted in SQL rather than by
the caller — there is no function here that returns an unfiltered set.
Co-Authored-By: Claude <noreply@anthropic.com>
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>
Additive, v1-only backend contract for the Android app's opt-in push (Part 1 of
M7; docs/android/PLAN.md §11). The app is a pure consumer — this lands the
endpoints, fan-out, and relay it needs.
- Schema: push_devices (per-device endpoint) + notification_subscriptions
(per-user opted-in streams), FK→users ON DELETE CASCADE.
- Stream catalog + event→stream mapping (config/notificationStreams.js): public
streams (news.post, server.status, idoc.warning, champ.start, governor.election)
drawn ONLY from the SSE PUBLIC_KINDS allowlist; personal owner-keyed streams
(vendor.sale, house.idoc, account.login). Full-state upserts (champ/city) fire
only on a real transition via an injectable tracker.
- Fan-out (utils/pushDispatch.js): content-free tickles ({ stream, ref }) POSTed
to each subscribed device; never throws. Two producers — shardIngest.ingest
(beside the SSE broadcast) and the create/publish-post path (news.post).
Personal events resolve to the owner via shardLinks. SSRF guard: endpoints must
be HTTPS, non-private, and on the NTFY_BASE_URL/NTFY_ALLOWED_ORIGINS allow-set —
enforced at registration and every publish.
- Routes under the role-agnostic self surface (never /admin): POST|GET
/auth/me/devices, DELETE /auth/me/devices/:id, GET
/auth/me/notifications/streams, GET|PUT /auth/me/notifications/subscriptions.
Swagger regenerated (4 paths, PushDevice/NotificationStreams/etc. schemas).
- ntfy service in docker-compose.yml: pinned image, declarative ./ntfy/server.yml,
no published host port, anonymous unguessable topics (no accounts) — zero
interactive setup. No publish token required (content-free design); optional
NTFY_PUBLISH_TOKEN honored.
- Tests: pushDispatch (mapping, PUBLIC_KINDS gate, owner-keying, SSRF guard,
content-free payload) + notifications route auth gate. Full suite green (247).
Co-Authored-By: Claude <noreply@anthropic.com>