Commit Graph

9 Commits

Author SHA1 Message Date
cfd1cb3c3c feat(engagement): let a module ship its own templates and rules (Phase 11b)
Phase 11a declared 24 triggers and stopped where the plan said it would. Standing
11b up found that the next sentence — "24 rules, all enabled = 0; bespoke template
bodies" — described work with no mechanism to land in: templateSeeds.js and
coreRules.js are core files with core arrays in them, and there was no
registerTemplates or registerRules anywhere in registries.js.

So a module could say what an event's payload was and could never say what the
mail should read like. That is tolerable for one trigger and not for a catalogue,
and it is decisive once the bodies carry domain prose core must not contain (§5.2).

Adds api.registerEngagementSeeds({ templates, ruleGroups }) — MODULE_API 1.9.0.
The module supplies data; core keeps seedOne's customized skip, its seed_version
comparison and the block registry's validation, which is the whole argument for a
registry over the ctx.query a module already holds: a copy of any of those living
outside engagement/ would drift the first time core improved the original, and the
drift would surface as a mail somebody already received.

The two halves behave differently, deliberately:

  - Templates re-ensure on every boot, so a bumped seedVersion reaches every
    deployment except the ones where an operator edited that row.
  - Rule groups are ONE-SHOT, each under its own settings guard — re-ensuring
    would resurrect a rule an operator deleted and reset one they enabled. This is
    11a's seed-key finding stated as an API rather than as a warning: a rule
    appended to an existing group reaches fresh installs only, and one that must
    reach stamped deployments takes a new group key.

Three prohibitions, each a shipped mistake that would only surface as mail: a
seeded rule is always enabled = 0 (Q3's invariant, ignored rather than refused so
a typo cannot take a module offline at boot); a module may not mark a template
protected; and a rule may only name its own trigger ids and its own or core's
template keys, with template keys namespaced because the key column is UNIQUE.

Runs from modules/lifecycle.js boot() rather than seedDefaults(), and that is
forced rather than chosen: server.js seeds before it requires app.js, and
requiring app.js is what runs the loader — at the moment core seeds, no module has
registered anything. Placed after the installed_modules reconcile (so a disabled
or failed module is skipped) and before the onBoot dispatch (so a module warming a
cache may assume its rules exist).

16 new tests; 1549 core tests green; check:modules clean.

Refs docs#/ENGAGEMENT.md Phase 11b decision 7.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 00:46:15 -05:00
563199a096 feat(modules): event triggers, audiences and the ceiling lattice (engagement Phase 2)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 25s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 10m29s
The contract half of the engagement system: a module (and core) can DECLARE an
event with a payload contract and fire it. Nothing delivers yet — `emit`
validates, logs and stops, and Phase 4 replaces that log line with the engine.

`api.registerEventTriggers` and `api.registerAudiences` ride the existing
stage()/apply() validate-then-commit discipline, so a registrant that throws
halfway leaves nothing behind. `ctx.events.emit` is fire-and-forget and binds
the owner from the calling module — a module fires its own triggers and no one
else's. `ctx.inbox.push` is present and throws until Phase 7, the shape 1.6.0
settled on for a member that arrives a phase late.

MODULE_API_VERSION 1.7.0 on both halves. Additions only; module-uo's
`coreApi: "^1.3.0"` still resolves.

Three design decisions, approved by the org lead before any code:

ONE NAMESPACE for trigger ids and notification-stream ids (ENGAGEMENT.md §7.2,
against the recommendation in the text). A trigger is a payload contract
attached to an id that may also carry a subscription toggle, so an id has
exactly one owner across both facets, checked in both directions. Core's five
trigger ids ARE its five stream ids, so the same-owner upgrade case is
exercised on every boot rather than only by a module. It keeps
notification_channel_prefs single-keyed in Phase 3, where two namespaces would
have forced a `kind` discriminator into its primary key.

Two knock-on effects appeared only once it was implemented. The id grammar had
to be RELAXED to admit `_` inside a segment — §4.3's own worked example is
`uo.house.idoc_warning`, and two grammars over one namespace would mean an id
legal as a trigger and illegal as the stream it is the same event as. And the
seven grandfathered `uo.*` ids had to share their legacy allowlist with
triggers, because under one namespace `idoc.warning` is a single id. The push
catalog is untouched either way: allStreams() still serves the stream facet
only, so the shipped Android client sees exactly what it saw before.

THE CEILING LATTICE (G24), which the plan named everywhere and defined nowhere.
It is containment, not size: everyone ⊃ authenticated ⊃ {subscribers, members,
staff, owner}, with the four leaves mutually incomparable. The flat total order
the plan's wording invites would let a `staff`-ceilinged trigger be given an
`owner` audience — a rule that mails cheat detection to the player it detected.
Fewer people is not less exposure. Two incomparable ceilings have no meet at
all, so a composition is refused rather than guessed; union-widens is the
intuitive implementation and it is the wrong one.

`kind: 'event' | 'scheduled'` is declarable now and no evaluator exists (§7.1
Q6). Registration accepts `scheduled` and emit refuses to fire one, so `kind`
means something from the moment it can be written rather than from the moment
it is honoured.

Also: `GET /admin/engagement/{triggers,audiences}`, served from the registries
rather than a table so an uninstalled module simply stops appearing;
`npm run engagement:manifest` plus its CI `--check`, the twin of the route
manifest, because renaming a variable breaks stored templates silently, at send
time, in mail someone already received.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 06:40:28 -05:00
cecd72915f feat(teams): the slash-command seam, and the first command through it
Phase 7 of TEAMS.md. `api.registerSlashCommands` stops throwing: a module
registers a command's DEFINITION and its HANDLER together, the bot pulls the
definitions over the internal listener and runs none of our code, and the
handler executes here — forced by the bot container having no `modules` volume,
and the right boundary anyway.

Registration validates what Discord would reject as a batch (names, description
lengths, the four option types, required-before-optional), because the bot
registers the whole set in one PUT and a single bad entry costs every command
including the bot's own. Commands are not namespaced under their owner — there
is no dot in Discord's name grammar — so collisions are first-come with the
holder named.

The dispatcher is the access boundary: `linked` has no Discord equivalent, so
the platform-side permission default can only ever be advertising. It resolves
the actor by `auth_providers.kind` rather than the id slug, treats a banned
account as unlinked, bounds a handler under the bot's own timeout, and keeps
`ok` outside the envelope so a handler cannot forge it.

Liveness is asked at both the pull and the dispatch. The registries have no
removal path, so a module an operator disables at runtime would otherwise keep
a live handler behind a command Discord still advertises.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:53:34 -05:00
2a56cbf22a feat(teams): fire the four events, and the routes that configure them
The roster sync tickles at most ONCE per stream per run, not once per member: a
tickle is content-free, so five people joining in one sweep is five identical
notifications and one piece of information. Suppressed on a Team's FIRST roster,
the same condition the activity feed uses and the half where it matters more —
importing a 155-member guild would otherwise wake every one of their phones.

Forum notifications fire from the CONTROLLER, not from the forum model. That file
takes an already-resolved access decision and reads no membership table by design;
the fan-out reads both to compute its recipients, so calling it from inside would
make the forum model transitively depend on exactly what its header says it must
not touch. The model returns a `notify` key the controller destructures out before
the response, so the API's answer to "did my post save" is unchanged.

`pageUrlTemplate` joins the team provider — the one thing phase 6 found that the
design of record had not anticipated. Phase 3 left core with no Team page and
therefore no way to LINK to one, so a notification email could name a Team and not
take you to it. It is data rather than a callback: a function would put a module
hook on the mail path to produce a string that never varies. Relative paths only,
and protocol-relative is refused with absolute.

The unsubscribe endpoint is the only write in the public tier and the only route
with no `siteMode` — the reader is in their mail client, and the mail went out
before the site went into maintenance. POST always answers 200, valid token or
forged: distinguishing them would be an oracle for which (user, Team) pairs exist.
GET redirects and acts on nothing, so a mail client's link scanner cannot mute
Teams nobody asked to leave.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:34:54 -05:00
03631d7d40 feat(teams): the roster's audience projection, and optionalAuth to resolve it
TEAMS.md §3.3, as the eighth member of MODULE_API 1.6.0 — amended in place per
the org lead, on the rule Protocol 4 was given in phase 2: a contract owes a
bump only once it has landed on `main`.

Two questions meet on the roster and they belong to different owners. WHICH
ROWS a viewer may see is the module's, because the audience rungs and their
configuration live there and core does not know what a rung is. WHAT A ROW
LOOKS LIKE stays core's.

So `projectRoster` answers with member KEYS, not rows. §3.3 said rows, and rows
would let a module widen what is published — handing back a `userId` core had
withheld — leaving core's field guarantee resting on every module's good
behaviour. Core asks which rows and re-normalises the answer through its own
public shape, so a module can narrow and cannot widen.

"The module declines" needed splitting before it could be implemented. No
module at all and a module whose rungs could not be consulted are opposite
situations: the first withholds nothing and must serve the roster whole, the
second must serve none of it. The refusal carries `projects`, and only
`projects: true` fails closed. Without the split, bare core serves an empty
roster on every Team page.

This is also the first public route whose CONTENT depends on identity, which
needed a middleware core did not have. `attachSession` only decodes a token, so
a banned account, a password change or a logout would have kept working against
the private half of a feed until the JWT expired. `optionalAuth` runs
requireAuth's full database re-validation and, on any failure, continues
ANONYMOUSLY rather than rejecting — a caller whose session is no longer good
sees the public view, which is what they are entitled to.

`GET /public/teams/:slug/activity` lands here for the same reason: §2.11's route
table had no activity endpoint though §4.3 describes a filtered feed. Paged,
with the visibility resolved from the session and never from a parameter.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:15:36 -05:00
8b63ffc725 feat(modules): registerTeamProvider, and a call path that cannot answer "empty"
The registration a module uses to become the authoritative source of Teams
(docs/website/TEAMS.md §2.3), plus the wrapper core calls it through.

registerTeamProvider is the first registration where core CALLS THE MODULE and
waits for an answer. Every existing one is either the module claiming a mount or
core notifying it; the closest precedent is registerAnnounceLeg's dispatch, and
this is modelled on it rather than invented. It also holds a single value rather
than a map, unlike every other registry: Teams have one authoritative source by
construction, and two modules answering "what teams exist" would produce two
disjoint sets under one `teams` table with no rule for merging them. A second
registration is therefore a collision, named against the module that holds it.

teamProvider.js is where invariant 1 -- module unavailability is staleness,
never emptiness -- is actually enforced. It is deliberately generous about what
counts as a failure: a rejected promise, a synchronous throw, a timeout, a
non-object, a bare array, a missing `ok`, or a structurally malformed row all
leave as the same `{ ok: false }` a module would have sent on purpose. There is
no shape a broken provider can produce that arrives at the reconciler looking
like an authoritative empty list -- which is the entire argument for the
envelope, since a bare array has exactly one such shape and it is the one a
module returns while its sidecar is still connecting.

A malformed row fails the whole call rather than being dropped. Salvaging is the
dangerous option: one unreadable member quietly omitted from a roster is
indistinguishable, downstream, from that member having left, and the sync would
mark them departed on the strength of a broken payload. Refusing costs one stale
interval.

The deadline timer is unreffed as well as cleared. Clearing covers the case
where the race settles; it cannot cover a module promise that never settles at
all, where nothing exists to clear until the deadline fires. Caught by the test
file taking 10.2s to run 265ms of assertions -- the same class of bug as the
mariadb pool that used to hold the suite open (test/_setup.js). 292ms now.

28 tests. Full suite 770 passed, 0 failed.

Refs docs/website/TEAMS.md §2.3, Part 12 phase 2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 14:44:45 -05:00
f50541f374 feat(modules): ctx additions and the post-hook registry (API 1.1.0)
Everything the extraction needed from core that ctx did not already offer.
Additions only, so minor.

ctx.activity.log, because an admin action a module performs has to land in
core's one audit log or the trail has a hole exactly where a module operates the
game -- a module keeping its own log would be a second place to look, which in
practice means a place nobody looks. Write-only; reading the log is the admin
panel's job and it spans every actor.

ctx.users.getById, one function for one caller: the admin.users.detail slot
router needs the user its prefix names. ctx.site.baseUrl, because a module has
to build absolute links and §2.7 forbids it reading core's APP_BASE_URL -- a
getter, not a captured string, so it cannot go stale against the env.

ctx.middleware.rateLimit is core's makeLimiter, plus accountChangeLimiter handed
over whole. The split is deliberate: a module states its own window and cap
because it knows what its endpoints cost, and takes the plumbing from core so
there is one express-rate-limit in the process and one place a breach is logged.
accountChangeLimiter is shared policy -- core's /auth/me and /player/account sit
behind the same counter -- so a module's account-change route has to land IN it
rather than beside it. marketLimiter was UO policy living in core's file and
leaves with the route it guards.

registerPostHook is the fourth registry, and the last thing binding core to the
module. Core's post controller called newsGump.syncPost directly: core's CMS
naming a UO file. It now publishes what it already knows and a subscriber
decides what to do with it. Not folded into registerAnnounceLeg, which fires on
the same transition, because a leg is a one-shot DELIVERY with retry and
classification while a post hook maintains idempotent STATE, runs on delete as
well as save, and refreshes silently on an edit.

Also fixes a real loader defect the extraction exposed: schema table names were
matched against the RAW file, so a fragment whose header says "every CREATE
TABLE carries IF NOT EXISTS" was rejected for a prefix violation on a table
called `carries`. module-uo's fragment hit exactly that. Both scans now read
split statements, which strip comments -- the same class of bug as a boundary
check failing on its own documentation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:07:45 -05:00
97f19b4221 docs(modules): note that registerExtension's spec-file argument is core-only
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 1m40s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 17:53:59 -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