Commit Graph

3 Commits

Author SHA1 Message Date
b13ffd584f feat(notifications): per-channel preferences and the delivery-channel registry (engagement Phase 3)
All checks were successful
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / bot-tests (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 10m29s
`notification_subscriptions` answers one question — which streams a user wants
PUSHED — because that is the only question the shipped Android client can ask.
This adds the general one: which subscribable ids, on which channel, in which
mode. The old table becomes the push projection of the new one and keeps its
exact wire shape, so the shipped APK needs no update and no delivery path is
touched.

What lands:

- `engagement/channels.js` — `registerDeliveryChannel` (ENGAGEMENT.md §3.1), the
  declarative half only: id, label, `carriesContent`, `defaultMode`,
  `supportsDigest`. `addressFor`/`render`/`deliver` wait for Phases 6 and 7, for
  the reason `transports/index.js` deferred this file at all. `coreChannels.js`
  declares push / email / inapp through the subsystem's one door.
- `notification_channel_prefs` + a replay-safe `INSERT IGNORE … SELECT` backfill,
  copying the `announce_jobs → announce_job_legs` precedent.
- `GET · PUT /auth/me/notifications/channels`. The PUT is SPARSE — only the
  `(id, channel)` pairs named are written — deliberately unlike the two whole-set
  PUTs beside it. `off` is a mode rather than an omission, so this endpoint has
  no empty-array case and the kotlinx DTO gotcha cannot arise here.

Three decisions the org lead settled before any code, and one corrects the
phase's own acceptance criterion: push's `defaultMode` is `off`, not `instant`.
The plan borrowed "push is opt-OUT" from `team_notification_prefs`, where no row
does mean notified — but stream subscriptions have never worked that way, so
`instant` would have projected the whole catalog into the legacy GET for every
existing user and switched every toggle on in the shipped app after an upgrade
nobody asked for. A test pins the legacy GET at `{streams:[]}` for a fresh user.

One thing not named by the phase, and it is a G24 consequence rather than scope
creep: a trigger ceilinged at `staff` can never reach a non-staff user, so
offering the toggle would be offering a dead control AND disclosing the event
exists — `uo.cheat.detected` would otherwise appear in every player's screen the
moment Phase 11 declared it. Filtered from the catalog and gated on write. That
gave the `staff` label its first consumer, now written down as
`ceilings.STAFF_CEILING_ROLES` (the admin tier's three, deliberately not
`teamGrants.STAFF_ROLES`, which answers a different question).

15 new tests; swagger, route manifest and guards regenerated. No web or app
surface — those are Phases 7 and 8, where a preference governs something visible.

Refs: docs/website/ENGAGEMENT.md Phase 3, §3.1, §4.5

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 07:08:17 -05:00
6195c76d61 feat(modules): the three de-entanglement registries, with core as the registrant
All checks were successful
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 1m39s
PR Checks / bot-install (pull_request) Successful in 8m49s
Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. Adds server/src/modules/registries.js
and moves core's own notification streams, announce leg and users-detail routes
behind it, so the three seams §1.8 and §1.9 named are exercised on every boot
before any module depends on them.

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

Four decisions, all the recommended option:

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 17:47:59 -05:00
416761f8f7 feat(push): M7 backend — opt-in push notifications via self-hosted ntfy
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m37s
PR Checks / client-build (pull_request) Successful in 9m21s
PR Checks / bot-install (pull_request) Successful in 9m17s
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>
2026-07-20 05:13:48 -05:00