41 Commits

Author SHA1 Message Date
1d4cd4adae feat(engagement): the admin ceiling and core's news.post emitter (Phase 11a)
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 13m3s
Core's half of ENGAGEMENT.md Phase 11a: the two decisions the org lead settled
before any code that land in core rather than in module-uo. Pairs with
Module-uo#22 and docs#194.

## Decision 1 -- a seventh ceiling, `admin`, as a child of `staff`

Phase 11's operator-facing triggers (uo.audit.staff_action, uo.economy.milestone,
uo.world.saved) are described as admin-audience everywhere, and the narrowest
value the lattice had was `staff` -- which ceilings.js defines as admin, editor
AND moderator. Ceilinging them there would have let an operator save a rule that
mails the staff audit digest to every moderator in it.

`admin` is the ONLY genuine refinement in the tree -- every admin is staff, which
is exactly the containment every other pair of branches lacks -- so it is a child
rather than a seventh leaf, and permits/meet/meetAll needed no change beyond the
new PARENT entry.

**The one non-obvious consequence, and the reason for ROLE_CEILINGS.**
notificationChannelPrefs' `visibleTo` asked `item.ceiling !== 'staff'`. That was
correct while `staff` was the only role-gated value, and the day `admin` arrived
it would have silently published every admin-ceilinged id -- the staff audit
digest, the economy thresholds -- to every player's preferences screen by name.
It now reads a TABLE (`ceilings.reachableBy`), so a ceiling added without an entry
fails closed instead. An EDITOR is the viewer that tells the two rules apart, and
the new tests use one.

MODULE_API_VERSION -> 1.8.0 on both halves. Additive: every declaration valid
under 1.7.0 is valid now and no stored value changes.

## Decision 5 -- 7.1 Q9: news.post gets an emitter, and it REPLACES the tickle

`news.post` has been a declared payload contract with no caller since Phase 2, so
a rule naming it could never fire. utils/newsNotify.js is the caller;
announceIfNewlyPublished now calls it instead of pushDispatch.publish, gated on
the same enqueueIfNeeded job id -- the single "newly published news" transition
signal, not re-derived.

**News push therefore stops on upgrade** until an operator enables the seeded
rule. That is the org lead's decision, taken over keeping the raw call beside the
emit "for one release": an exception with a deadline nobody owns, which Phase 6
already refused for Teams. The Rules screen gains a second migration notice
naming news, and Phase 13's release note carries it as an upgrade step.

**The seed needed its own one-shot key, and this is the trap worth recording.**
`engagement_team_rules_seeded` is already stamped on every deployment that has
booted since Phase 6, and the guard reads its presence -- so appending news to
RULES would have seeded it on fresh installs only, and on exactly the upgrades
that lose their raw push, never. One key per seed GROUP is now the rule;
seedGroup() is the shared implementation and seedCoreRules() is what boot calls.

Also fixes news.post's `postUrl` example, which named `/news/<slug>` -- a path
App.jsx does not mount. An example is what the template editor previews and
test-sends with, so a wrong one is a preview that looks right and a mail that is
not. It is `/site/news`, the list, which is what the Discord and town-crier
announcements have always linked.

1550 tests pass (16 new), 327 client tests pass, client builds, check:modules
clean -- core still names no module identifier with module-uo now registering 24
UO-named triggers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 20:33:02 -05:00
49a61fdafa Merge pull request 'feat(engagement): deliverability — suppression, bounces and the verification gate (Phase 9)' (#176) from feature/engagement-deliverability into edge
Reviewed-on: #176
2026-08-31 15:52:47 +00:00
c208543044 feat(engagement): deliverability — suppression, bounces and the verification gate
All checks were successful
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 5m12s
ENGAGEMENT.md Phase 9, closing gap G16. Two mechanisms decide that somebody in a
rule's audience does not get the mail, and they sit at deliberately different
points in the pipeline.

`engagement_suppressions` is checked at DELIVERY: an outbox row can sit through a
rule's `delay_seconds` grace window and an address can bounce inside it, so the
only correct check is the one taken immediately before the transport call — which
is also what produces the `status='suppressed'` row with no transport call at all.

The Phase 1b verification gate is applied at ENQUEUE, through a new optional
`registerDeliveryChannel({ eligible })` that only `email` declares. Filtering the
shared audience would have silenced the wrong sink: a rule spanning email and
in-app must still put an item in an unverified user's inbox. The excluded counts
reach `summary.ineligible` and the admin reach preview, which until now reported
an audience size that was never the number of people who would be mailed.

`bounceClassify.js` is the only thing that may write a `bounce` row, and it is
deliberately NOT `mailer.PERMANENT_CODES`. That set answers "is retrying
pointless?" and contains EAUTH and 554 — an auth failure and a relay-wide policy
refusal, neither of which is a fact about the recipient. Reusing it would mean one
stale SMTP password suppressing every address the worker touched, silently. The
classifier reads the RFC 3463 enhanced status first, falls back to a phrase match
only past a veto list and only for 550/551/553, and does not suppress anything it
is unsure about.

Scope is engagement rules only: resets, invites, verification and the contact form
still attempt, matching the posture passwordReset.controller.js already stated.

Found on the live rig, against a real MariaDB and a real SMTP conversation: a hard
bounce was being recorded as `failed`, so the Send Log's "Bounced" filter — a
status `engagement_sends` has carried since §4.5 — matched nothing and always
would have. It is now its own outcome; the outbox row stays `failed`, since that
ENUM has no `bounced` and a bounced row is one that finished unsuccessfully.

`address_masked` is this phase's one addition to §4.5's DDL. A hash-only table
cannot be operated — an operator cannot tell three typos from a whole domain
refusing mail — and the domain survives while the local part is destroyed, so the
column can never be read back as an address book.

- schema: `engagement_suppressions` (+ `address_masked`, `created_by`)
- `GET/POST/DELETE /api/v1/admin/engagement/suppressions`, and Admin → Engagement
  → Suppressions, the only way out of the list
- `sendNotification` returns `smtp: { code, responseCode, response }`
- 26 new tests; swagger, routes manifest and guards regenerated

Docs: RunicGateway/docs#191.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 10:47:34 -05:00
87c4e71025 Merge pull request 'feat(engagement): the in-app channel, core and web (engagement Phase 7)' (#175) from feature/engagement-inapp-channel into edge
Reviewed-on: #175
2026-08-31 07:22:24 +00:00
24a3cd85b3 feat(engagement): the in-app channel, core and web (engagement Phase 7)
All checks were successful
PR Checks / client-build (pull_request) Successful in 37s
PR Checks / server-tests (pull_request) Successful in 3m27s
PR Checks / bot-tests (pull_request) Successful in 8m36s
ENGAGEMENT.md Phase 7. `user_notifications`, the in-app DeliveryChannel, the
four inbox routes, and the web surface — plus the two pieces earlier phases
assigned here that Phase 7's own acceptance line omits.

Four decisions settled by the org lead before any code:

1. `inapp` defaults to `instant` — the only channel that does. Push wakes a
   device somebody is holding and email leaves the building, so both are asked
   for; an inbox item is a row on a page the user chose to open. Left `off` the
   channel ships dead.
2. The phase takes push's `deliver` (§2603) and the web per-channel preferences
   screen (Phase 3's as-built), neither of which its own bullets mention.
3. The inbox takes `/auth/me/notifications` and `/account/notifications`; the
   preferences screen moves to `…/settings`. The plain word belongs to the
   content, which is what the bell opens.
4. `ctx.inbox.push` honours the user's in-app preference when `triggerId` names
   a registered trigger, and writes when it does not.

Server
- `user_notifications` + `model/userNotifications/`. The dedupe UNIQUE is scoped
  to the USER, narrower than the outbox's `(rule, user, channel)`: an inbox has
  no channel dimension, so two rows for one event would be one item shown twice.
- `engagement/inappChannel.js` — renders by block ROLE (first heading → title,
  first button → url, the rest → body) and inserts. `pushChannel.js` — a
  content-free `{stream, ref}` tickle whose ref deep-links the inbox row.
- `engine.liveChannels` orders `inapp` first (`CHANNEL_ORDER`) so that ref
  resolves on the first sweep. An ordering, not a dependency.
- `templates.renderInappByKey` + `resolveTemplate` extracted from `renderByKey`,
  so both channels take the same fallback chain.
- `inapp.event` seed → seedVersion 2: it named `body`/`url`, which nothing
  supplies. Renamed to the structural vocabulary the projection fills in.
- `utils/userNotificationsPrune.js` — nightly, READ items only, horizon in
  `settings.user_notifications_retain_days` (default 90).
- `GET /auth/me/notifications`, `…/unread-count`, `POST …/:id/read`,
  `POST …/read-all`. Swagger + route manifest + four component schemas.

Web
- `NotificationBell` in all three headers, polling its badge once a minute and
  pausing while the tab is hidden. `PlayerInbox` at `/account/notifications`.
- The preferences screen becomes a channel matrix over
  `/auth/me/notifications/channels` — a strict superset of the push-only stream
  list it replaces. The two legacy endpoints are untouched, so the shipped
  Android app keeps its wire shape.
- Staff get the same two screens at `/admin/notifications…`: `RequirePlayer`
  keeps them out of `/account`, so without this the inbox was unreachable for
  every non-player account. `lib/notificationPaths.js` is the one mapping.

Verified: 28 new server tests (5 of them against a real MariaDB, for the three
index/statement properties that are a server contract rather than a reading of
this code) + 3 client. Server suite green, client 327 green. A live rig walked
the whole path: two rules on one event produced three outbox rows and exactly
one inbox item, the tickle carried `ref: notification:2`, and the retention
sweep dropped an aged read row while keeping an equally aged unread one.

Docs: RunicGateway/docs#TBD, RunicGateway/runicgateway.com#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 02:07:10 -05:00
5168446c53 Merge pull request 'feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)' (#174) from feature/engagement-email-channel into edge
Reviewed-on: #174
2026-08-31 06:07:14 +00:00
065bec7ad8 feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 11m9s
Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being
its own thing. `teamNotify.forumPost` now emits an event; a rule decides who is
mailed, through which template, and how often at most. One walk goes forum write
-> events.emit -> rule -> outbox -> worker -> email channel -> template -> SMTP.

Seven decisions settled by the org lead before any code:

  - email only moves; the push tickle and the Discord bridge stay direct calls
  - the EVENT carries its access-checked audience, and `members` resolves to it
  - the four Team rules are seeded DISABLED, with an admin banner and a note
  - team_notification_prefs stays, read by the engine as a scoped preference
  - the payload wins and a structural projection fills the gaps
  - the digest keeps computing at send time; only its state generalizes
  - an unsubscribe token turns off the channel it names, and nothing else

Three defects found while building it:

  - `email.button` never absolutized its href, while image and itemList both
    did. Every rule-driven CTA would have been a dead relative link, because a
    trigger's url variables are validated site-relative by construction.
  - Phase 4a enqueued digest-mode recipients for a drain that Phase 6 decided
    not to build. An outbox row snapshots the payload and so has none of the
    three properties the digest design exists for, including the security one.
  - the digest's send-log row carried no address_hash while the instant row
    beside it did, which would have made half the mail uncorrelatable in Phase 9.

Also: engagement_digest_state + a replay-safe backfill, engagement_outbox.scope_key,
a v2 unsubscribe token that still verifies v1 forever, and the canonical
/public/engagement/unsubscribe pair with the old /public/teams path kept
permanently — mail is not editable once sent.

Verified with 1464 server tests, 324 client tests, and a live rig (MariaDB +
Mailpit + a real Team) covering the instant mail, the digest, the generic
template, a pre-migration unsubscribe link and the backfill's replay-safety.

Docs: RunicGateway/docs#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 20:11:54 -05:00
e2dad3104f Merge pull request 'feat(engagement): the template editor, the trigger catalog and the send log (engagement Phase 5b)' (#173) from feature/engagement-template-editor into edge
Reviewed-on: #173
2026-08-29 23:37:39 +00:00
3f90070566 feat(engagement): the template editor, the trigger catalog and the send log (engagement Phase 5b)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 27s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m38s
Phase 5a gave templates a table, a renderer and nine seeded rows; nothing could
change one. This is the screen that lets an operator change one without being able
to break the mail the system depends on — plus the two screens Q4 promised Phase 5:
Triggers (read-only, from the registries) and the Send Log, which closes G15.

The shape follows from one fact: a mail body is rendered by the SERVER, so the
preview is too, and framed rather than redrawn in React. A client-side renderer
would be a second implementation of the one artifact that matters, agreeing with
the send path on the day it was written and drifting from the first Outlook fix on.

Settled with the org lead before any code: a shipped default is edited IN PLACE
(`protected` blocks deletion and nothing else, `customized = 1` keeps the edit);
duplicate is the only way to a new template; `renderByKey` now requires
`published`; a test send is logged under a synthetic `core.admin.test-send`; and a
template a rule points at refuses deletion with a 409 naming the rules.

Three things the plan did not know, found by building it:

  - The undeclared-variable check cannot be a token scan. `email.itemList.variable`
    holds a BARE name, so a digest pointed at `itmes` would have saved clean and
    arrived empty. Blocks now declare `variables(props)`; the editor makes that
    field a select over the trigger's list variables so the typo is unavailable.
  - A duplicate that drops `seed_key` loses its variable palette, so duplicating
    `notify.event` would have been refused for the tokens it was copied with — the
    one action §4.6.2 offers, refusing itself. The copy inherits it; `customized`
    is what the seeder actually reads.
  - `validateEmailBlocks` returns `{ valid, errors }`, not an array, and the first
    version tested it with `.length` — so block validation never ran at all.

Also fixes a Phase 4a defect the live walk found, with the org lead's approval: a
rule's template key was checked against a pattern with no dot in it, so no rule
could name any template that exists — §4.6.2's whole duplicate-and-point-a-rule-at-it
workflow was unreachable. Both models now read one pattern.

Verified against the running stack: real multipart mail into a mailpit catcher
including an unsaved draft, the draft/published arms both ways through the real
mailer path, every refusal, and the end-to-end duplicate → rule → 409 walk.
Server 1428 tests green, client 324.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 18:13:57 -05:00
42b40fdec2 Merge pull request 'feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)' (#172) from feature/engagement-templates into edge
Reviewed-on: #172
2026-08-29 18:14:45 +00:00
12ff201ed5 feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m38s
Every subject and body moves out of `mailer.js` into `engagement_templates` rows an
operator can edit. A relocation, not a regression: nothing that sends mail today
starts depending on an operator authoring something first.

- `email.*` block family in its own registry, sharing the page family's envelope
  walk and validate-then-sanitize order by binding rather than by copy.
- A server-side renderer producing both parts of a multipart message; the text
  part is byte-identical to the literals this commit deletes.
- Nine seeded templates, six of them wired now; the seeder's `customized = 0`
  guard lives in the UPDATE's own WHERE.
- `renderByKey` falls back to the shipped seed when a row is missing or unusable,
  so no failure of the table can stop a password reset.

Also fixes `check:hosts` reading the template key `auth.email-verify` as the
hostname `auth.email`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 13:07:39 -05:00
1d7961e7a2 Merge pull request 'feat(engagement): Admin → Engagement → Rules and Audiences (engagement Phase 4b)' (#171) from feature/engagement-rules-admin into edge
Reviewed-on: #171
2026-08-29 17:28:54 +00:00
3a7a08425c fix(engagement): the six defects the browser pass found (Phase 4b)
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 2m35s
PR Checks / bot-tests (pull_request) Successful in 8m34s
Driving the two screens in Chrome, after the API walk had already found the two
in Phase 4a's code. None of these is visible from a test or from curl.

Two cost an operator something real:

  - The Audience dropdown rendered EMPTY before a trigger was chosen. There is
    genuinely nothing it may offer without a ceiling, but a select with zero
    options reads as broken rather than as waiting. It now says "Choose a
    trigger first..." and is disabled.
  - A `members` audience with no saved audience reaches NOBODY, and only the
    preview button said so. That is the design, but it is also the default the
    instant a members-ceiling trigger is picked - so the rule saves, gets
    switched on, and mails nobody with nothing on screen saying so. The editor
    now says it inline, and stands down once a preview has answered the same
    question more precisely.

One the server was already refusing, just too late:

  - The composer offered "exclude" on the only row, building an `and` whose
    every child is a complement. The server refuses it correctly but only after
    a save, and it is one checkbox away at all times. Now refused inline, in the
    operator's words.

Three wording and layout:

  - the template-key input truncated its placeholder, and said "optional until
    Phase 5" - a sentence about the plan document, not about the deployment
  - "segment" leaked into a screen that says "saved audience" everywhere else.
    The API, schema and docs keep saying segment (one word for one table);
    translated at the point of display only
  - the composer repeated its AUDIENCE heading above every row

Client only - no server change, so swagger and the route manifest are untouched.
Client suite 316/316; all six verified in the browser after the fix.

- [x] AI-assisted: written with Claude Code (Opus)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 12:26:29 -05:00
4b45eddb5d feat(engagement): Admin - Engagement - Rules and Audiences (engagement Phase 4b)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Successful in 10m36s
The admin surface over the Phase 4a engine: two screens, twelve routes and the
reach preview. Nothing in the engine changed; what changed is that an operator
can now reach it.

Four decisions settled by the org lead before any code:

  - segments get their OWN nav entry, "Audiences", not a tab of the rules screen
  - the on/off switch is its own PATCH route, not a full PUT
  - the reach preview is a count only, on demand
  - a rule can be hard-deleted; the send log survives it

The switch is the one with real content in it. A PUT re-validates against the
registries as they are NOW, so the rules a re-validating toggle cannot switch
off are exactly the three an operator most wants stopped: a rule whose module
was uninstalled, one naming a channel that is gone, and one whose trigger has
since narrowed its ceiling under a saved audience. PATCH .../enabled writes one
column and always works. Switching ON unvalidated is safe because the engine
re-checks the ceiling at send time.

The preview calls the engine's own resolver rather than a second query that
agrees with it today, and answers a count and nothing else - the resolver's
output for a module-declared segment is a set of players derived from game data.
It reports `capped` at the 5000-row bound (the count is a floor, not a total),
`reason` for an `owner` audience (which resolves per event and has no advance
answer), and `permitted` so the editor cannot show a healthy number beside a
save the server will refuse.

Two defects found by walking it against a live server, both in Phase 4a's code:

  1. A rule pointing at a DORMANT segment read as healthy. listAnnotated asked
     only whether the segment ROW existed. The other shape of the same failure
     is a segment sitting exactly where it was whose every audience belongs to
     an uninstalled module: same outcome, nothing deleted. Uninstalling a module
     under an enabled rule produced a rule the screen showed as on and firing.
     The expression walk now lives in engagement/segments.js as
     `missingAudiences` and both lists ask it.
  2. "1 rule still use this segment" - the delete refusal pluralised the noun
     and not the verb, in the sentence an operator reads when told no.

Also: a rule's trigger is now a stated rule rather than an omission in the
UPDATE statement (its cooldowns, queued sends and history are all about one
trigger id); a condition tree the editor cannot render is shown read-only rather
than flattened, because flattening changes which events fire the rule; and
literals are coerced client-side to the type the trigger declared, with anything
that does not parse passed through unchanged so the server's refusal names the
variable.

Tests: 21 new server tests (test/engagementAdmin.test.js) and 25 client ones
(client/test/engagementRules.test.js), all green. The single failure in the
server suite (`the committed manifest matches the declarations in the tree`) is
the known Windows CRLF artifact and fails identically on clean edge.

Companion docs PR: docs#184.

- [x] AI-assisted: written with Claude Code (Opus)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 12:10:04 -05:00
4d3f574480 Merge pull request 'feat(engagement): the rules engine, cooldowns and outbox (engagement Phase 4a)' (#170) from feature/engagement-engine into edge
Reviewed-on: #170
2026-08-29 13:24:14 +00:00
2079aaf667 feat(engagement): the rules engine, cooldowns and outbox (engagement Phase 4a)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 27s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 2m37s
Phase 4 of docs/website/ENGAGEMENT.md, split 4a/4b at the org lead's direction.
This is 4a: the engine, server only, with no HTTP surface at all. A fired trigger
now produces outbox rows and send-log entries; Admin - Engagement - Rules and the
segment composition UI are 4b.

Five tables (rules, audience segments, cooldowns, outbox, sends), the sweep
worker, audience resolution, condition evaluation, the grace window and its
cancellation, and the save-path validation 4b's form will call. engagementEmit's
Phase 2 log line becomes the engine call.

Two settled questions this phase was blocked on:

  Q2 (multi-instance) - neither SKIP LOCKED nor documented single-instance: the
  outbox claims each row with a compare-and-set into the 'sending' state the ENUM
  already carried. It makes the outbox safe for two instances, not the deployment.

  Q4 (admin surface) - its own top-level nav group, built in 4b.

Two defects in the plan's own section 4, both found by building it:

  The global UNIQUE(dedupe_key) was data loss. A dedupe key names the EVENT, and
  one event is one row per (rule, user, channel) - so a fifty-person audience
  would have had one row admitted and forty-nine silently ignored. Scoped.

  Section 4.1's single INSERT ... ON DUPLICATE KEY UPDATE cooldown claim always
  passes against this codebase's pool: the mariadb connector defaults
  foundRows:true, so a no-op update reports affectedRows 1 rather than 0. It is
  two statements now, with the interval guard in a WHERE clause.

The second defect is why there is a second test file. The stubbed suite was green
against the broken claim, because a stub can only agree with whoever wrote it;
engagementEngineSql.test.js runs the raw statements against a real MariaDB and
skips when there is none.

Verification: 43 new tests green in engagementEngine.test.js, 12 more against
MariaDB 11.8, and the whole path exercised end to end against a live database -
per-subject cooldowns, conditions, the CAS claim, the send log's honest failure
detail, and dormancy on uninstall. The three pre-existing Windows-only CRLF
failures in the generated-artifact tests are unchanged from clean edge.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 08:07:27 -05:00
447c9113d3 Merge pull request 'feat(notifications): per-channel preferences and the delivery-channel registry (engagement Phase 3)' (#169) from feature/engagement-channel-prefs into edge
Reviewed-on: #169
2026-08-29 12:12:01 +00:00
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
ea3499e70b Merge pull request 'feat(modules): event triggers, audiences and the ceiling lattice (engagement Phase 2)' (#168) from feature/engagement-trigger-registry into edge
Reviewed-on: #168
2026-08-29 11:48:22 +00: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
6016b325bb Merge pull request 'feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)' (#167) from feature/unique-verifiable-email into edge
Reviewed-on: #167
2026-08-29 07:08:43 +00:00
fbb4b0bd91 feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 10m34s
Makes `users.email` unique, de-duplicates the addresses an upgrade will find,
and builds the self-service change-and-verify flow that did not exist.

The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED`
column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan
specified. Every case-insensitive collation this server offers is also
accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are
two different mailboxes. The plan's index would have refused the second address
forever and the de-duplication would have nulled a legitimate account's.

A requested address is STAGED in `email_pending` and only a tokened link
installs it, so a typo cannot silently redirect account-recovery mail.

`isDuplicateUsername()` now distinguishes the two indexes. All five call sites
branch on it; each answers differently on purpose, because a public form, an
IdP callback, a half-completed invite and an admin screen do not owe the same
person the same amount of truth.

SSO reads the IdP's actual `email_verified`/`verified` claim instead of
inferring verification from an address merely being present.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 01:53:50 -05:00
c2e4df5b3d Merge pull request 'refactor(api): collapse /admin/account and /player/account onto /auth/me/account' (#166) from refactor/collapse-account-surfaces into edge
Reviewed-on: #166
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-29 05:54:26 +00:00
6e61146678 refactor(api): collapse /admin/account and /player/account onto /auth/me/account
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 26s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 10m32s
Self-service account security had three URL surfaces onto one controller. All
three mounted the same `admin/account.controller.js` handlers; each of the three
router files carried a header comment apologising for the arrangement.

`/auth/me/account` was already a strict superset, which settles which to keep:

  /admin/account   6 routes  noindex, isLoggedIn, staffOnly
  /player/account  8 routes  noindex, requireAuth
  /auth/me/account 10 routes noindex, requireAuth

Neither of the deleted surfaces carried recovery codes, and /admin/account
carried no username or password change at all — so client.js already called
/auth/me/account/recovery-codes/* for two operations on a screen it otherwise
served from /admin/account. The split was leaking before this change.

Gating is equivalent where it overlapped: /player and /auth/me apply identical
`noindex, requireAuth`, and `staffOnly` on /admin/account was strictly narrower
while buying nothing, since every handler is self-scoped to req.user.id. There
is no CSRF layer to differ.

  - 14 routes deleted, 0 added, no handler changed.
  - account.controller.js moves router/v1/admin/ -> router/v1/auth/, beside the
    one router that still reaches it.
  - Web client: 14 call sites move onto a root-level api.myAccount /
    api.changeUsername / ... group, matching the /auth/me methods already there.
  - Android app: no change. MeApi.kt was already 100% /auth/me/account/*.
  - Two swagger tags, `Admin · Account` and `Player`, were declared only by the
    deleted routes and go with them. The orphaned `AccountStatus` schema goes
    too; `PlayerAccount` is re-described as the any-role /auth/me/account shape
    (the name is kept so existing $refs resolve).

Breaking to the published OpenAPI surface, accepted deliberately: both consumers
are in this org, and deprecate-then-delete would leave the next phase deciding
whether to add routes to surfaces already marked for removal.

Verification: routes.manifest.json shows exactly 14 deletions and 0 additions.
The OpenAPI spec loses the same 14 paths with zero surviving path definitions
changed; its large textual diff is pure reordering, because removing the
first-mounted router shifts every later path. 1203 server tests, 288 client
tests, 53 bot tests green; check:modules, check:hosts and routes:manifest
--check all pass.

Design of record: docs/website/ENGAGEMENT.md Phase 1a. This lands ahead of
engagement Phase 1b, which adds a self-service email field — written once here
rather than three times.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 00:49:25 -05:00
f5aa32e0ed Merge pull request 'feat(email): engagement Phase 1 — remove Gmail OAuth2, SMTP behind a transport registry' (#165) from feat/engagement-phase-1-smtp into edge
Reviewed-on: #165
2026-08-29 02:09:44 +00:00
b77e817fb1 Merge pull request 'fix(swagger): hoist the one inline predicate that makes the generator run away' (#164) from fix/swagger-generator-runaway into edge
Reviewed-on: #164
2026-08-29 02:09:28 +00:00
c4ab8b9b9d docs(email): SMTP setup, the three postures, and the upgrade note
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 26s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 2m32s
The operator-facing half of engagement Phase 1. README's stack table and
security section, plus both .env.example files, all pointed at the
removed Connect Gmail flow.

The env comments now name the three supported postures rather than one
provider — a relay as the recommendation, smtp.gmail.com:587 with an app
password as the shortest migration, an unauthenticated local MTA as the
third — and point at docs/website/UPGRADE_NOTES.md for the deployment
this actually happens to.

The OpenAPI spec is regenerated: two routes gone, three annotations
rewritten, and the dashboard's new warnings[] documented.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 20:54:04 -05:00
47c8b37d45 feat(email): remove Gmail OAuth2, put SMTP behind a transport registry
Engagement Phase 1 (docs/website/ENGAGEMENT.md §1.2a, §3.1, §3.2). A
subtraction and a replacement in one commit, because leaving the OAuth2
flow half-wired across a release is worse than either end state.

Deleted, per the §1.2a inventory: GET /admin/email/connect/start and
/connect/callback, the connectStart/connectCallback controllers with the
email_oauth_tx signed cookie, the PKCE verifier and CSRF nonce plumbing,
the https://mail.google.com/ scope, the borrowed `google` auth-providers
client, the OAuth2 nodemailer transport with its smtp.gmail.com:465
literals, the refresh-token decrypt in the model, and the client's
Connect Gmail button, redirect banner and six Gmail error strings.
`provider` and `refresh_token_enc` stay as columns under the
additive-only discipline, unread.

Added: a mail transport registry (server/src/engagement/transports) with
`smtp` as the sole registration. `credentialFields` is the single
declaration the admin form renders, the sanitizer filters against, and
the "is it secret" answer comes from, so adding a transport is a
registration rather than four edits. email_config gains transport /
credential_enc (one encrypted JSON blob, since the field list is the
transport's to declare) / reply_to.

All six call sites keep their exact failure contracts: the contact
form's mailto fallback, the invite's copyable link, the reset's generic
200, and sendTeamNotification's never-throws. One deliberate behaviour
change: `enabled` now gates every sender rather than only isConfigured()
— the connect flow used to set it as a side effect, and with a credential
form the toggle has to mean what it says.

Send-test becomes the real verification. Under OAuth2 the sender came
back from Google and was guaranteed to belong to the credential;
operator-typed, it can be refused, so failures name the sender and the
SPF/DMARC reason (§1.2a consequence 2).

G22, the silent degradation: an upgraded deployment backfills to smtp
with no credentials and every sink politely does nothing. The admin
dashboard now warns when the deprecated Gmail token is present and no
replacement credential is, so the one deployment this happens to is told.
A fresh install has never had mail and is not nagged.

Guardrails: new `npm run check:hosts` (§3.2 rule 4) with its own
self-test, wired into pr-checks before the install; routes.manifest and
routes.guards regenerated (-2 routes).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 20:52:55 -05:00
e25e7ade80 fix(swagger): hoist the one inline predicate that makes the generator run away
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 26s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 10m42s
`npm run swagger` cannot run on this tree. It dies with swagger-autogen's
"FATAL ERROR: invalid array length - Allocation failed", generating
nothing, and it reproduces on a pristine checkout under both Node 20 and
Node 24 — so the committed spec cannot be regenerated by anyone, and any
PR that adds or changes a route is unable to meet the standing obligation
to update it.

Bisected to one statement in `teams.router.js`:

  param('teamId').custom((v) => v === 'default' || TEAM_ID.test(v))

Hoisting that arrow to a named const fixes it outright. Nothing else
changes and the regenerated spec is byte-identical to the committed one,
so this is a generator fix, not a spec change.

The diagnosis worth keeping, because the file's own comment recorded a
different one. Phase 8 shipped a bare regex LITERAL before `.test(` and
phase 9 hoisted the regex, blaming a per-file route limit measured at
twenty statements; the file has sat at nineteen ever since on the theory
that it was one under the edge. That theory is wrong. Probing every
router file individually, `teams.router.js` at nineteen statements dies
while a THREE-route file carrying only this one route also dies — so the
trigger is the inline arrow reaching `.test(`, not the count. Hoisting
the regex was half the fix; the predicate around it needed hoisting too.

The comments in `teams.router.js`, `teamsVoice.router.js` and
`admin/index.js` are corrected to say so, since all three currently tell
the next person to keep counting statements.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 20:47:25 -05:00
3bca112502 Merge pull request 'fix(env): the documented Compose deploy could not boot' (#163) from fix/env-example-secret-enc-key into main
All checks were successful
sync-project-tree / sync (push) Successful in 15s
Build container images / build (push) Successful in 31s
Build container images / deploy (push) Successful in 40s
SonarQube / analysis (push) Successful in 5m27s
Reviewed-on: #163
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-24 16:31:12 +00:00
c43e092248 fix(env): the documented Compose deploy could not boot
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m32s
`.env.example` — the file docker-compose.yml actually reads — never listed
SECRET_ENC_KEY. `utils/secretBox.js` resolves the key at require time and throws
`SECRET_ENC_KEY must be set in production`, so following README Option A exactly
produces a container that crash-loops before it ever listens.

It was easy to miss because the variable IS documented in two places that a
Compose operator never opens: `server/.env.example`, which is what local
development copies, and the README's environment-variable reference table. Only
the file the deployment reads was missing it.

Reproduced against the published image with a clean `cp .env.example .env`, then
verified the fix the same way: fill in the values the README names and
`docker compose up -d` reaches `listening on http://0.0.0.0:3000` and
`/api/health` → `{"status":"ok"}`.

- `.env.example` gains SECRET_ENC_KEY, beside JWT_SECRET, with what it encrypts,
  that production refuses to start without it, and that changing it later
  orphans every stored secret rather than re-encrypting them.
- README's Option A "set at least" list gains SECRET_ENC_KEY and
  BOT_INTERNAL_KEY. Both are refused-at-boot in production, and BOT_INTERNAL_KEY
  is required even on a deployment that runs no bot, which is exactly the case
  the list omitted.

Found while writing the runicgateway.com installation docs, whose quickstart is
checked against this file on every build.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:19:49 -05:00
0f96a372cf Merge pull request 'fix(admin): style the Teams admin screen with the site's own classes' (#162) from fix/teams-admin-theming into main
All checks were successful
sync-project-tree / sync (push) Successful in 10s
Build container images / build (push) Successful in 2m3s
Build container images / deploy (push) Successful in 48s
SonarQube / analysis (push) Successful in 5m20s
Reviewed-on: #162
2026-08-19 19:35:00 +00:00
68f038f456 fix(admin): style the Teams admin screen with the site's own classes
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m34s
The Teams admin screen was written against four CSS classes that do not
exist anywhere in the project — `.table`, `.kv`, `.list` and `.notice` —
and against `.btn-ghost` / `.btn` used without the `.btn` box they depend
on. The result rendered as unstyled UA tables and bare browser buttons
sitting flush against unpadded panels, and looked nothing like the rest
of the admin panel.

Nothing here changes behaviour, data or routes; it is presentation only.

- Tables become `adm-table` / `adm-th` / `adm-td` inside `panel-flat`,
  the markup the other fourteen admin views use, and scroll rather than
  clip when a row is wider than the shell (a status badge and the action
  buttons are both nowrap by design, so a narrow viewport can always
  overflow one).
- Buttons take the full `btn btn-primary btn-sq` / `btn btn-ghost btn-sq`
  triplet. `.btn` carries the padding, border and radius; the variants
  carry only colour, so a bare `.btn-ghost` had none of the box and a
  bare `.btn` fell back to the UA's light button face.
- `.panel` supplies no padding, so every panel now sets it explicitly at
  22px, as ModulesAdmin and EmptyState already do.
- Headings become `h2.display`, and the in-page `<h1>Teams</h1>` goes
  away in favour of AdminLayout's topbar title — which needed
  `/admin/teams` adding to TITLES, the reason the bar read "ADMIN".
- Status pills use the existing `badge-pub` / `badge-moderator` /
  `badge-ban` / `badge-draft` modifiers. `.badge` alone declares no
  border, so the old inline `borderColor` was inert.
- The bridge and voice panels drop their private palette
  (`#e08b77` / `#8fbf7a` / `#e0b877`) for the site's
  `#d98b84` / `#7fd0a4` / `#e0b070`, and a literal `rgba(255,255,255,.12)`
  rule and a `borderRadius: 4` for `var(--line-soft)` and
  `var(--radius-input)`.

Walked live against the dev DB as an admin: sync panel, review queue, all
Teams, the forum ledger, the bridge draft form and its acknowledgement
dialog. Client 288/288, server 1162/1162, client build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
2026-08-19 14:03:01 -05:00
963d734dcc Merge pull request 'feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6)' (#161) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 25s
Build container images / build (push) Successful in 1m17s
Build container images / deploy (push) Successful in 49s
SonarQube / analysis (push) Successful in 6m16s
Reviewed-on: #161
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-19 08:57:11 +00:00
48a3e33be4 Merge pull request 'fix(modules): core offers a contribution, never a slot name' (#160) from feature/teams-slot-contributions into edge
All checks were successful
PR Checks / client-build (pull_request) Successful in 41s
PR Checks / bot-tests (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Successful in 2m46s
Reviewed-on: #160
2026-08-19 06:19:38 +00:00
335d69d122 fix(modules): core offers a contribution, never a slot name
All checks were successful
PR Checks / client-build (pull_request) Successful in 46s
PR Checks / server-tests (pull_request) Successful in 2m50s
PR Checks / bot-tests (pull_request) Successful in 9m2s
The inverted slot direction reached exactly one module. Core filled three
literal names - uo.guild.detail, uo.guild.forum, uo.guild.header - matched by
exact name in applyCoreFills, so a second game declaring a place under its own
id got an empty page and no error. "A fill for a slot nobody declared is not an
error" is the rule that made the miss invisible, and it is the right rule; what
was wrong was core knowing a slot's name at all.

It also put a module identifier inside core, in three string literals
scripts/checkModuleIdentifiers.js masks by construction and could never catch.

Found by the integration kit while writing the chapter that teaches this shape
to an audience outside this org - which is what that phase is for.

So the module says WHERE, in its own vocabulary, and WHICH of core's
contributions goes there:

  declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' })

and core offers into the catalogue rather than into a name:

  offerCoreFill('team.activity', TeamActivityFeed)

CORE_CONTRIBUTIONS is exported and fixed at build time, so asking for one core
does not offer THROWS at the declaration. That asymmetry with an unfilled slot
is deliberate: an unknown contribution is always a typo or a version skew - the
module's coreApi range has already been checked - and the failure it would
otherwise produce is a page that renders empty forever with nothing logged.

options.core is optional; a slot that asks for nothing stays empty, which is
what a module declaring a place it fills itself wants. More than one slot may
ask for the same contribution and each gets it: how many places a module wants
its feed in is a layout decision on a page core does not own.

Amends MODULE_API 1.6.0 in place rather than adding 1.7.0 - the same rule the
eighth and ninth members were given, and 1.6.0 has only ever been on edge.

Also: the UI kit is nine exports, not eight. Slot made it nine in phase 3 and
the comment beside it still said eighth.

288 client tests, 1162 server tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 01:15:29 -05:00
9619fdf1e1 Merge pull request 'feat(teams): phase 9 — one voice channel per Team, granted by a role' (#159) from feature/teams-phase9-voice-channels into edge
Reviewed-on: #159
2026-08-19 05:21:15 +00:00
f72c92ffbe fix(teams): the two defects the phase 9 rig walk found
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 2m33s
Walked against real MariaDB, the real app, and a fake standing in for Discord
that mounts the bot's real internal routes — everything up to the Discord API
call was production code. 47 assertions, and it found two things every unit
test in the phase had passed over.

1. **Every query failed: two result columns named `team_id`.** `desiredTeams`
   and `holdersWithoutClaim` both select `t.id AS team_id`, and the shared
   column list added `i.team_id` beside it. The `mariadb` driver refuses a
   result set with a repeated field name outright, so the pass died at its
   first query with "Error in results, duplicate field name `team_id`" — on the
   one code path every unit test stubs.

   It was also the wrong column: `desiredTeams` LEFT JOINs, so `i.team_id` is
   NULL for exactly the Teams that have no channel yet, which is the create
   case. The two queries that do not join `teams` now ask for it by name.

   The regression test checks the INTERPOLATED sql captured from a fake
   `query`, not the source text — in the source the shared list is still a
   `${COLUMNS}` placeholder, and a first attempt that read the file passed
   happily with the bug reintroduced.

2. **"Sync now" said "Nothing was done" while it was doing it.** Saving the
   settings with voice switched on asks for a pass. An operator who then
   presses Sync now — the obvious next thing — hit `running` and got back
   `ran: false, reason: "a pass is already running"`, which the panel renders
   as nothing having happened, while the pass they triggered was busy creating
   their channels. A pass in flight is now JOINED and its real outcome
   returned, the same choice `teamSync.reconcileNow` makes for the same reason.

Tests: 1162 server (+2), 53 bot, 284 client.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 00:07:27 -05:00
61abb3ec89 feat(teams): phase 9 — one voice channel per Team, granted by a role
TEAMS.md §7.3. Each qualifying Team gets a Discord voice channel of its own
and a role that opens it, kept in step by a reconciler that rides the Team
reconcile it already depends on.

Access is a per-Team ROLE, always. §7.3 designed per-member overwrites with
escalation to a role above ~90 members; the org lead settled on roles always
(2026-08-18), which deletes `voice_overwrite_max`, the escalation and the
`mode` column — and moves the ceiling. Overwrites are capped per channel, so
the old shape's limit was "how big can one Team be"; roles are capped per
guild at 250, so the new one is "how many Teams can have voice at all". That
is a limit an operator must be told about before they hit it, so the panel
reports it and the pass refuses the create rather than letting Discord do it.

Three things §7.3 named that this codebase does not have, all settled by
asking the operator because nothing in the data model can answer:

  - "the staff role" — there is no staff-role concept anywhere. Now a list of
    role ids the admin designates; empty is a normal answer, since guild
    administrators bypass overwrites and what is really missing is a way to
    let NON-admin staff in.
  - the parent category — §7.3 said the bot creates it and gave the id nowhere
    to live (`team_integrations.team_id` is NOT NULL). The bot creates it and
    the server stores the id in settings.
  - whether the bot can act at all — nothing has ever checked. The operator
    invites the bot by hand and no invite URL with a permission integer exists
    in the tree, so a deployment can be one unticked box from every call
    failing. A preflight is now a PRECONDITION to enabling (422), not a
    per-Team error discovered afterwards.

Two more, decided rather than asked:

  - the threshold counts every active member, not linked ones. §7.3 wrote
    `voice_min_linked_members`; the operator is judging whether a Team is real,
    and link state answers a different question.
  - hidden Teams are never provisioned. A channel name is a game-sourced string
    published outside the site, which is exactly §2.8's concern —
    reservedNames.js already names "and eventually a Discord channel name" as a
    surface it protects — so the screen that suppresses a Team's page suppresses
    its channel, and a Team that becomes hidden takes the grace window.

Turning voice OFF tears nothing down: the pass suspends in both directions and
the panel offers per-row removal. A checkbox must not delete structure in
somebody's guild.

Fixes a phase 8 defect that blocks this phase's own artifact: `npm run swagger`
has been unable to run on `edge` at all. `param('teamId').custom((v) => ... ||
/^[0-9]+$/.test(v))` makes swagger-autogen's parser run away — a regex literal
followed directly by `.test(`. Hoisted to a const, as modules.router.js
already does. Underneath it, `teams.router.js` sits exactly at that parser's
per-file limit: at twenty `teamsRouter.*` statements it dies, at nineteen it
generates, and one more statement of ANY shape tips it — an unannotated route
does, and so does a bare `use`. So the voice routes are their own router file
mounted from `admin/index.js`, and teams.router.js keeps its nineteen.

Also breaks a require cycle this phase would have introduced:
teamSync -> teamVoiceSync -> teams.model -> teamSync left `teams.model` holding
the reconciler's exports object as it stood mid-load — the empty one, since
`module.exports = {…}` replaces rather than fills. The symptom is not in the
new code: it is `teamSync.intervalSeconds is not a function` thrown out of
`syncStatus()`, the freshness banner on every public Team page.

Tests: 1160 server (+40), 53 bot (+21), 284 client (+21). Swagger, routes
manifest and guards regenerated; the guard shape of the four new routes is
byte-identical to the existing admin-only ones.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 23:49:28 -05:00
d1d56cf847 Merge pull request 'feat(teams): phase 8 — the notifications bridge, and the gate §7.2 could not check' (#158) from feature/teams-phase8-notifications-bridge into edge
Reviewed-on: #158
2026-08-19 01:33:13 +00:00
11b4368b57 feat(teams): phase 8 — the notifications bridge, and the gate §7.2 could not check
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / bot-tests (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Successful in 10m49s
The same Team event as §6, delivered a third time: push, email, and now a
Discord channel the operator configured. Not a second pipeline — teamNotify.js
already computed the recipient set once, so the bridge is a sink beside the two
that were there.

The design's gate has no data source. §7.2 bridges an event only if "its
visibility is public, or its destination channel is configured for a
members-only Team context". The four team.* streams carry no visibility; forum
threads have no public/members column because a forum is members-only by
construction; and core cannot see a Discord channel's permissions. So §7.2's own
example config names exactly the two events that are never public.

The gate is therefore an attributed operator acknowledgement, in the shape
teams_forum_uploads_ack already uses. It is a precondition — 422, not a quiet
drop at delivery — it is re-asked at delivery as well as at the save, and
changing the channel clears it, because an acknowledgement is about a
destination and cannot survive the destination changing underneath it.

The design's DDL cannot hold its own default row: MariaDB coerces every PRIMARY
KEY column to NOT NULL, so `team_id NULL` — the deployment-wide default every
override overrides — is unrepresentable. Proved on a real MariaDB (error 1048).
Replaced with a surrogate id, a generated team_key AS IFNULL(team_id, 0) in the
unique key, and the foreign key the original had no room for.

One-shot, not queued: "identical to announce and mod-reverse" names two
different reliability models, and a Team notification is the moment it
describes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 20:25:30 -05:00
220 changed files with 37365 additions and 2837 deletions

View File

@@ -56,6 +56,21 @@ DB_ROOT_PASSWORD=change-me-root-password
# Auth
JWT_SECRET=change-me-to-a-long-random-string
# Encrypts every secret this site stores at rest (AES-256-GCM): OAuth client
# secrets, the Discord bot token, the mail transport credentials, the uo-link auth
# token. REQUIRED in production — with NODE_ENV=production the app REFUSES TO
# START without it (utils/secretBox.js), so a Compose deployment that leaves it
# blank crash-loops before it ever listens. Development falls back to a key
# derived from JWT_SECRET, with a warning.
#
# Any string; it is hashed to 32 bytes. Generate a long random one and treat it
# like the database password.
#
# Changing it on a live instance does NOT re-encrypt anything: every secret
# already stored becomes unreadable and has to be entered again from the admin
# panel. That is also the reason it is a dedicated key rather than a reuse of
# JWT_SECRET — rotating a session secret must not orphan stored credentials.
SECRET_ENC_KEY=change-me-to-a-different-long-random-string
JWT_EXPIRES_IN=1d
# auto = Secure cookie only when the request arrives over HTTPS (Pangolin).
# Leave as auto so login works both via the LAN IP (HTTP) and the proxy (HTTPS).
@@ -83,10 +98,14 @@ TOTP_CHALLENGE_TTL=5m
ADMIN_USERNAME=
ADMIN_PASSWORD=
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not via
# env. It reuses the Google auth provider's OAuth client and stores an encrypted
# refresh token in the DB. Until it's connected, the contact form falls back to
# a mailto: link (recipient = the `contact_email` site setting).
# Email is configured in Admin → Settings → Email, not via env: pick a mail
# transport (SMTP) and enter its host, port and credentials, which are stored
# encrypted in the DB. Three postures work — a relay (Mailgun/SES/Postmark) is
# the recommended one, a mailbox provider over SMTP (e.g. smtp.gmail.com:587
# with an app password) is the simplest, and an unauthenticated local MTA on
# port 25 needs no credentials at all. Until one is configured the contact form
# falls back to a mailto: link (recipient = the `contact_email` site setting).
# Upgrading from the removed Gmail connect flow: see docs/website/UPGRADE_NOTES.md.
# CORS — only needed for local dev when the Vite dev server is a different origin.
CLIENT_ORIGIN=http://localhost:5173

View File

@@ -56,6 +56,12 @@ jobs:
# something found under a pile of unrelated failures, and it costs
# nothing when it passes.
run: npm run check:modules
- name: Check the engagement subsystem names no external host
# ENGAGEMENT.md §3.2 rule 4 — no transport may ship a default host,
# endpoint or sender. Dependency-free and runs before the install for the
# same reason as the check above: a phone-home is a design break, not a
# test failure, and it should be the first thing a reviewer sees.
run: npm run check:hosts
- name: Install server deps
run: npm ci --prefix server
- name: Run server tests
@@ -69,6 +75,15 @@ jobs:
# of a reviewer instead of letting it pass silently.
run: npm run routes:manifest --prefix server -- --check
- name: Check the engagement trigger manifest is current
# ENGAGEMENT.md 4.3 property 4 - the same mechanism as the route manifest
# above, for the event contract instead of the URL surface. A trigger
# declaration is what a stored template interpolates and what a stored
# rule is written against, so renaming a variable or widening a ceiling
# breaks them silently, at send time, in mail someone already received.
# Regenerating and diffing makes that change something a reviewer reads.
run: npm run engagement:manifest --prefix server -- --check
client-build:
runs-on: ubuntu-latest
steps:

View File

@@ -151,7 +151,7 @@ flowchart TB
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
| Database | MariaDB 11 (own container) |
| Frontend | React 18, Vite 5, React Router 6 |
| Email | Nodemailer via Gmail OAuth2 (configured in admin), with a `mailto:` fallback |
| Email | Nodemailer over a configurable mail transport — SMTP (relay, mailbox provider or your own MTA), set up in the admin panel — with a `mailto:` fallback |
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
| Deploy | Docker Compose, any reverse proxy (Pangolin, Nginx, Caddy, Traefik, …) |
@@ -216,7 +216,12 @@ cp .env.example .env
# Edit .env and set at least:
# DB_PASSWORD, DB_ROOT_PASSWORD (any strong values)
# JWT_SECRET (a long random string)
# SECRET_ENC_KEY (a different long random string)
# BOT_INTERNAL_KEY (a third one, 16+ chars — even with no bot)
# ADMIN_USERNAME, ADMIN_PASSWORD (your first admin login)
#
# SECRET_ENC_KEY and BOT_INTERNAL_KEY are not optional in production: the app
# refuses to start without them, so the container crash-loops before it listens.
docker compose pull && docker compose up -d # IMAGE_TAG defaults to `latest`
# pin a specific build (reproducible deploy / rollback):
@@ -579,7 +584,7 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
| `TOTP_ISSUER` | `BRAND_NAME` | label shown in authenticator apps for optional per-user 2FA |
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
| _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting |
| _Email_ | — | configured in Admin → Settings → Email (transport + credentials), never via env; recipient = `contact_email` setting. Upgrading from the removed Gmail connect flow: see [`docs/website/UPGRADE_NOTES.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/UPGRADE_NOTES.md) |
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
@@ -672,9 +677,11 @@ run this repo as UOMysticmoon.
- `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs
behind a reverse proxy (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail
OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the
contact form falls back to a `mailto:` link when unconfigured.
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through a mail
transport configured in the admin, whose credentials are stored AES-GCM-encrypted and are
write-only over the API (never returned, never in env); no transport ships a default host or
sender, so an unconfigured deployment sends nowhere. The contact form falls back to a `mailto:`
link when unconfigured.
---

View File

@@ -0,0 +1,80 @@
// Team notifications posted into an operator-configured channel (TEAMS.md §7.2).
//
// **The channel comes from the app, not from guild_config.** `newsAnnounce` looks
// its channel up here because there is exactly one #news; a Team's destination is
// per-Team configuration living in `team_integration_config`, and a bot that
// resolved it would need a second copy of that table and a second place for it to
// drift. The app sends the id it already decided on.
//
// **Everything this file knows about a Team it was told.** No lookups, no
// membership checks, no access decisions: whether this content may reach this
// channel was settled on the site, where the acknowledgement that gates it lives.
// The bot is the transport, exactly as it is for slash commands.
const { EmbedBuilder } = require('discord.js')
const brand = require('../brand')
const createLogger = require('../utils/logger')
const log = createLogger('team-notify')
// Discord's own limits. Truncating here rather than trusting the app is not
// distrust — an embed that exceeds them is rejected wholesale, and a message
// silently not appearing is the worst failure mode this path has.
const TITLE_MAX = 256
const DESCRIPTION_MAX = 4096
const clamp = (value, max) => {
const text = String(value || '').trim()
if (!text) return null
return text.length > max ? `${text.slice(0, max - 1)}…` : text
}
// What each stream is called in a channel. The app composes the BODY; this is
// only the label above it, and it is here because it is Discord presentation —
// the same reason the embed colour is.
const HEADINGS = {
'team.member.joined': 'New member',
'team.leadership.changed': 'Leadership change',
'team.forum.post': 'New forum post',
'team.announcement': 'Announcement',
}
async function postTeamNotification(client, { channelId, stream, teamName, teamUrl, title, body, url }) {
if (!channelId) throw new Error('No channel id supplied.')
const channel = await client.channels.fetch(channelId).catch(() => null)
if (!channel || !channel.isTextBased()) {
throw new Error('Configured channel is missing, not text-based, or not visible to the bot.')
}
const heading = HEADINGS[stream] || 'Team update'
const name = clamp(teamName, 120) || 'A team'
const embed = new EmbedBuilder()
.setColor(brand.accentInt)
// The Team is the AUTHOR line and the event is the title, not the other way
// round: a channel carrying one Team's events would otherwise repeat its name
// as every heading, and a channel carrying several needs the name to be the
// thing the eye lands on first.
.setAuthor(teamUrl ? { name, url: teamUrl } : { name })
.setTitle(clamp(title, TITLE_MAX) || heading)
if (url) embed.setURL(url)
// Both a title and a body means a forum post: the heading has to go somewhere
// or "New forum post" and "Announcement" become indistinguishable once the
// thread title takes the title slot.
//
// **Clamped AFTER the heading is prepended, not before.** Clamping the body and
// then adding a prefix produces a description one heading longer than the limit,
// which discord.js rejects outright — so an over-long post would not arrive at
// all rather than arriving truncated. The prefix is part of what has to fit.
const composed = title && body ? `**${heading}**\n${String(body)}` : body
const description = clamp(composed, DESCRIPTION_MAX)
if (description) embed.setDescription(description)
await channel.send({ embeds: [embed] })
log.info('team notification posted', { stream, channelId, team: name })
}
module.exports = { postTeamNotification, HEADINGS, clamp, TITLE_MAX, DESCRIPTION_MAX }

View File

@@ -0,0 +1,315 @@
// Per-Team voice channels (TEAMS.md §7.3, phase 9).
//
// **The site decides; this file compares and applies.** Every judgement — which
// Teams qualify, who may enter, what the channel is called — was made on the site
// and arrives in the request. What cannot be made there is the DIFF: which of
// those people already hold the role, whether the channel still exists, whether
// the category was deleted last week. That is live guild state, only this process
// can see it, and shipping it to the site to be compared and shipped back would
// be a copy of the guild in a database that cannot watch it change.
//
// So the contract is "make it look like this", not "do these calls".
//
// **Access is a per-Team ROLE.** §7.3 designed per-member permission overwrites
// with a role only above ~90 members; the org lead settled on roles always
// (2026-08-18). The channel therefore carries exactly three kinds of overwrite —
// @everyone denied, the Team's role allowed, and each operator-designated staff
// role allowed — and membership is the role's member list rather than a hundred
// entries on the channel.
const { ChannelType, PermissionFlagsBits } = require('discord.js')
const createLogger = require('../utils/logger')
const log = createLogger('team-voice')
// The category every Team channel is created under. Created on the first pass
// that needs one; the site stores the id and sends it back next time.
const CATEGORY_NAME = 'Teams'
// discord.js REST error codes for "the thing you are addressing is already gone".
// A teardown that finds its target missing has SUCCEEDED — the desired end state
// holds — and the same is true of a sync that finds a channel a human deleted,
// which simply becomes a create.
const UNKNOWN_CHANNEL = 10003
const UNKNOWN_ROLE = 10011
const isMissing = (err) => err && (err.code === UNKNOWN_CHANNEL || err.code === UNKNOWN_ROLE)
// What a Team member may do in their channel, and what @everyone may not. Both
// halves are needed: denying ViewChannel alone still leaves Connect resolvable
// for anyone who has the id, and allowing ViewChannel alone shows a channel
// nobody can enter.
const ACCESS_BITS = [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect]
/**
* Can this bot do §7.3's job in this guild?
*
* Asked before an operator may switch voice on, and again at the top of every
* pass. The site has no way to know: the operator invites the bot by hand, there
* is no invite URL with a permission integer anywhere in this project, and an
* unticked box means every call fails with nothing to point at.
*
* `bot_role_position` is reported because it is the second, quieter failure:
* ManageRoles lets the bot create a role, but it can only GRANT roles below its
* own highest one. A bot sitting at the bottom of the role list creates roles it
* then cannot hand to anybody — which looks exactly like a channel nobody can
* enter, with no error anywhere.
*/
async function preflight(client, guildId) {
const guild = await client.guilds.fetch(guildId)
const me = guild.members.me || (await guild.members.fetchMe())
return {
connected: true,
guild_id: guild.id,
can_manage_channels: me.permissions.has(PermissionFlagsBits.ManageChannels),
can_manage_roles: me.permissions.has(PermissionFlagsBits.ManageRoles),
// The guild's whole role list, not just the ones this feature made. The
// 250-role cap is guild-wide and shared with everything the operator created
// themselves, so counting ours would promise headroom that is not there.
role_count: guild.roles.cache.size,
bot_role_position: me.roles.highest.position,
}
}
/** The `Teams` category, reusing the one we were given when it is still there. */
async function ensureCategory(guild, categoryId) {
if (categoryId) {
const existing = await guild.channels.fetch(categoryId).catch(() => null)
if (existing && existing.type === ChannelType.GuildCategory) return existing
log.warn('the configured Teams category is gone; making another', { categoryId })
}
const created = await guild.channels.create({
name: CATEGORY_NAME,
type: ChannelType.GuildCategory,
reason: 'Team voice channels',
})
log.info('created the Teams category', { categoryId: created.id })
return created
}
/**
* The Team's own role.
*
* A rename is applied but never allowed to fail the pass: a Team's name is the
* least important thing here and Discord rate-limits name edits hard, so losing
* one is worth strictly less than losing the access change in the same request.
*/
async function ensureRole(guild, roleId, name) {
let role = roleId ? await guild.roles.fetch(roleId).catch(() => null) : null
let created = false
if (!role) {
role = await guild.roles.create({
name,
// Not mentionable and not hoisted: this role exists to open a door, and a
// Team with two hundred members should not become a way to ping them all or
// a second copy of the member list down the sidebar.
mentionable: false,
hoist: false,
reason: 'Team voice access',
})
created = true
log.info('created a team role', { roleId: role.id, name })
} else if (role.name !== name) {
await role.setName(name, 'Team renamed').catch((err) => {
log.warn('could not rename the team role', { roleId: role.id, message: err.message })
})
}
return { role, created }
}
/** The overwrites a Team channel carries, in the order Discord takes them. */
function overwritesFor(guild, role, staffRoleIds) {
const overwrites = [
{ id: guild.roles.everyone.id, deny: ACCESS_BITS },
{ id: role.id, allow: ACCESS_BITS },
]
for (const staffId of staffRoleIds) {
// A staff role the operator has since deleted would make Discord reject the
// WHOLE set, taking the Team's own grant down with it. Filtered here rather
// than validated on the site, which cannot see the guild's role list.
if (!guild.roles.cache.has(staffId)) {
log.warn('a configured staff role is not in this guild; skipping it', { roleId: staffId })
continue
}
overwrites.push({ id: staffId, allow: ACCESS_BITS })
}
return overwrites
}
async function ensureChannel(guild, channelId, { name, category, role, staffRoleIds }) {
const overwrites = overwritesFor(guild, role, staffRoleIds)
let channel = channelId ? await guild.channels.fetch(channelId).catch(() => null) : null
if (channel && channel.type !== ChannelType.GuildVoice) {
// Somebody pointed us at, or converted this into, something that is not a
// voice channel. Not ours to repurpose — make the right one and leave theirs.
log.warn('the stored channel is not a voice channel; making a new one', { channelId })
channel = null
}
if (!channel) {
const created = await guild.channels.create({
name,
type: ChannelType.GuildVoice,
parent: category.id,
permissionOverwrites: overwrites,
reason: 'Team voice channel',
})
log.info('created a team voice channel', { channelId: created.id, name })
return { channel: created, created: true }
}
// Overwrites are re-set on every pass rather than diffed: the set is three or
// four entries, `set` is one API call, and re-asserting it is what repairs a
// channel somebody edited by hand.
await channel.permissionOverwrites.set(overwrites, 'Team voice access')
if (channel.parentId !== category.id) {
await channel.setParent(category.id, { lockPermissions: false, reason: 'Team voice channel' })
}
if (channel.name !== name) {
await channel.setName(name, 'Team renamed').catch((err) => {
log.warn('could not rename the team voice channel', { channelId: channel.id, message: err.message })
})
}
return { channel, created: false }
}
/**
* Bring the role's member list to the site's list, up to `maxOps` changes.
*
* **Bounded, and the remainder is reported rather than dropped.** Each grant is
* its own API call under its own rate limit, so an unbounded first pass on a
* large guild is a request that outlives its own timeout — and a timeout is the
* one outcome that leaves the site not knowing what was applied. The site asks
* again until `pending` reaches zero.
*
* **A member the site names who is not in this guild is skipped silently.** They
* linked their Discord account to the site and never joined the guild, which is
* an ordinary state (§2.6 hop 3 without hop 4) and not something an operator
* needs to see a hundred of.
*/
async function syncRoleMembers(guild, role, memberIds, maxOps) {
// One fetch of the whole member list, so `role.members` and the "are they even
// here" check both read from a cache that is actually populated. discord.js
// keeps it current from gateway events afterwards; without the fetch, a bot
// that has been up for five minutes knows only the members who spoke.
await guild.members.fetch()
const desired = new Set(memberIds.map(String))
const current = new Set(role.members.map((member) => member.id))
const toAdd = [...desired].filter((id) => !current.has(id) && guild.members.cache.has(id))
const toRemove = [...current].filter((id) => !desired.has(id))
let ops = 0
let added = 0
let removed = 0
for (const id of toAdd) {
if (ops >= maxOps) break
const member = guild.members.cache.get(id)
try {
// eslint-disable-next-line no-await-in-loop
await member.roles.add(role, 'Team member')
added += 1
} catch (err) {
// One member the bot cannot touch — almost always the role hierarchy, when
// the member outranks the bot — must not cost the other forty-nine.
log.warn('could not grant the team role', { userId: id, roleId: role.id, message: err.message })
}
ops += 1
}
for (const id of toRemove) {
if (ops >= maxOps) break
const member = guild.members.cache.get(id)
if (!member) continue
try {
// eslint-disable-next-line no-await-in-loop
await member.roles.remove(role, 'No longer a team member')
removed += 1
} catch (err) {
log.warn('could not revoke the team role', { userId: id, roleId: role.id, message: err.message })
}
ops += 1
}
return { added, removed, pending: Math.max(0, toAdd.length + toRemove.length - ops) }
}
/** One Team, reconciled. */
async function syncTeamVoice(client, guildId, {
teamId, name, categoryId, channelId, roleId, staffRoleIds = [], memberIds = [], maxMemberOps = 50,
}) {
const guild = await client.guilds.fetch(guildId)
const category = await ensureCategory(guild, categoryId)
const { role, created: roleCreated } = await ensureRole(guild, roleId, name)
const { channel, created: channelCreated } = await ensureChannel(guild, channelId, {
name, category, role, staffRoleIds,
})
const members = await syncRoleMembers(guild, role, memberIds, maxMemberOps)
log.info('team voice reconciled', {
teamId, name, channelId: channel.id, roleId: role.id, ...members,
})
return {
category_id: category.id,
channel_id: channel.id,
role_id: role.id,
created: { channel: channelCreated, role: roleCreated },
members,
}
}
/**
* Remove a Team's channel and role.
*
* Both, in one call, because they are one lifecycle: deleting the channel and
* leaving the role would leave every member wearing a badge for a place that no
* longer exists. Either being already gone is success.
*/
async function removeTeamVoice(client, guildId, { channelId, roleId }) {
const guild = await client.guilds.fetch(guildId)
const result = { channel_deleted: false, role_deleted: false }
if (channelId) {
const channel = await guild.channels.fetch(channelId).catch(() => null)
if (channel) {
try {
await channel.delete('Team no longer qualifies for a voice channel')
result.channel_deleted = true
} catch (err) {
if (!isMissing(err)) throw err
}
}
}
if (roleId) {
const role = await guild.roles.fetch(roleId).catch(() => null)
if (role) {
try {
await role.delete('Team no longer qualifies for a voice channel')
result.role_deleted = true
} catch (err) {
if (!isMissing(err)) throw err
}
}
}
log.info('team voice removed', { channelId, roleId, ...result })
return result
}
module.exports = {
CATEGORY_NAME,
ACCESS_BITS,
preflight,
ensureCategory,
ensureRole,
ensureChannel,
overwritesFor,
syncRoleMembers,
syncTeamVoice,
removeTeamVoice,
}

View File

@@ -1,5 +1,7 @@
const discordManager = require('../discord/discordManager')
const newsAnnounce = require('../discord/newsAnnounce')
const teamNotify = require('../discord/teamNotify')
const teamVoice = require('../discord/teamVoice')
const modLog = require('../discord/modLog')
const createLogger = require('../utils/logger')
@@ -121,4 +123,120 @@ async function refreshCommands(req, res) {
}
}
module.exports = { setConfig, getStatus: getStatusHandler, announce, reverseModAction, refreshCommands }
// POST /internal/team-notify — a Team notification the site has already decided
// belongs in a channel (TEAMS.md §7.2). Body: { channel_id, stream, team_name,
// team_url, title, body, url }.
//
// **The site chose the channel and the site checked the access.** Whether
// members-only forum text may reach this channel is an acknowledgement recorded
// against team_integration_config, and re-deciding it here would mean the bot
// holding a copy of a policy it cannot see the inputs to.
//
// 503 when disconnected and 400 for a channel the bot cannot post to, matching
// /internal/announce — the caller is one-shot and best-effort and only logs the
// difference, but an operator debugging a silent channel needs the two to read
// differently in the bot's log.
async function teamNotifyHandler(req, res) {
const connection = discordManager.getConnection()
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
const { channel_id: channelId, stream, team_name: teamName, team_url: teamUrl, title, body, url } = req.body || {}
if (!channelId || !stream) {
return res.status(400).json({ message: 'channel_id and stream are required' })
}
try {
await teamNotify.postTeamNotification(connection.client, { channelId, stream, teamName, teamUrl, title, body, url })
return res.json({ posted: true })
} catch (err) {
log.warn('team-notify failed', { message: err.message, stream, channelId })
return res.status(400).json({ message: err.message })
}
}
// ── Voice channels (TEAMS.md §7.3, phase 9) ────────────────────────────────
// GET /internal/team-voice/preflight — can this bot do the job at all?
//
// Its own endpoint, and the app asks it BEFORE letting an operator switch voice
// on. §7.3 assumed the bot could manage channels and roles; nothing in this
// project has ever checked, because the operator invites the bot by hand and
// there is no invite URL with a permission integer anywhere in the tree. Without
// this the first symptom of an unticked box is every Team recording its own
// identical error, which reads like forty problems instead of one.
async function voicePreflight(req, res) {
const connection = discordManager.getConnection()
if (!connection) return res.status(503).json({ connected: false, message: 'Bot is not connected' })
try {
return res.json(await teamVoice.preflight(connection.client, connection.guildId))
} catch (err) {
log.warn('voice preflight failed', { message: err.message })
return res.status(400).json({ connected: true, message: err.message })
}
}
// POST /internal/team-voice/sync — make one Team's channel, role and role
// membership match what the site sent.
//
// The site sends DESIRED STATE and this works out the calls, which is the
// opposite of the split every other endpoint here uses. The decisions are all
// still the site's; what is here is the comparison against live guild state,
// which only this process can see.
async function voiceSync(req, res) {
const connection = discordManager.getConnection()
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
const {
team_id: teamId, name, category_id: categoryId, channel_id: channelId, role_id: roleId,
staff_role_ids: staffRoleIds, member_ids: memberIds, max_member_ops: maxMemberOps,
} = req.body || {}
if (!name) return res.status(400).json({ message: 'name is required' })
try {
const result = await teamVoice.syncTeamVoice(connection.client, connection.guildId, {
teamId,
name,
categoryId: categoryId || null,
channelId: channelId || null,
roleId: roleId || null,
staffRoleIds: Array.isArray(staffRoleIds) ? staffRoleIds.map(String) : [],
memberIds: Array.isArray(memberIds) ? memberIds.map(String) : [],
maxMemberOps: Number(maxMemberOps) > 0 ? Number(maxMemberOps) : 50,
})
return res.json(result)
} catch (err) {
// 400 rather than 500, matching /internal/announce: from the app's side this
// is "Discord refused", which is a condition it records against the Team and
// retries next pass — not a bug in this process.
log.warn('voice sync failed', { message: err.message, teamId, name })
return res.status(400).json({ message: err.message })
}
}
// POST /internal/team-voice/remove — the grace window expired, or an admin said so.
async function voiceRemove(req, res) {
const connection = discordManager.getConnection()
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
const { channel_id: channelId, role_id: roleId } = req.body || {}
try {
const result = await teamVoice.removeTeamVoice(connection.client, connection.guildId, { channelId, roleId })
return res.json(result)
} catch (err) {
log.warn('voice remove failed', { message: err.message, channelId, roleId })
return res.status(400).json({ message: err.message })
}
}
module.exports = {
setConfig,
getStatus: getStatusHandler,
announce,
reverseModAction,
refreshCommands,
teamNotify: teamNotifyHandler,
voicePreflight,
voiceSync,
voiceRemove,
}

View File

@@ -12,5 +12,9 @@ router.get('/status', ctrl.getStatus)
router.post('/announce', ctrl.announce)
router.post('/mod-reverse', ctrl.reverseModAction)
router.post('/refresh-commands', ctrl.refreshCommands)
router.post('/team-notify', ctrl.teamNotify)
router.get('/team-voice/preflight', ctrl.voicePreflight)
router.post('/team-voice/sync', ctrl.voiceSync)
router.post('/team-voice/remove', ctrl.voiceRemove)
module.exports = router

138
bot/test/teamNotify.test.js Normal file
View File

@@ -0,0 +1,138 @@
// ── The bot's half of the Team notifications bridge (TEAMS.md §7.2) ────────
//
// Nothing here talks to Discord. `channel` is a fake that records what was sent,
// and the assertions are about the three things this side genuinely owns:
//
// 1. **the channel comes from the app and is never looked up.** `newsAnnounce`
// reads guild_config because there is one #news; a Team's destination is
// per-Team configuration, and a bot that resolved it would hold a second
// copy of a table it cannot see the inputs to;
// 2. **a channel the bot cannot post to fails loudly rather than silently.** A
// caller that is one-shot and best-effort only logs the difference, but an
// operator debugging a quiet channel needs the bot's log to distinguish
// "not connected" from "that id is not a text channel";
// 3. **Discord's own limits are enforced here.** An embed that exceeds them is
// rejected WHOLESALE, so a long forum body must be truncated on this side
// even though the app already excerpted it — the app's limit is a product
// decision and this one is a protocol constraint.
const { test } = require('node:test')
const assert = require('node:assert/strict')
const teamNotify = require('../src/discord/teamNotify')
// A fake channel that records what it was sent. `isTextBased` is the one method
// the code branches on, so it is the one worth making configurable.
function fakeChannel({ textBased = true } = {}) {
const sends = []
return {
sends,
isTextBased: () => textBased,
send: async (payload) => { sends.push(payload); return { id: 'm1' } },
}
}
function fakeClient(channel, { throws = false } = {}) {
return {
channels: {
fetch: async (id) => {
if (throws) throw new Error('Unknown Channel')
return id === 'chan-1' ? channel : null
},
},
}
}
const post = (client, over = {}) => teamNotify.postTeamNotification(client, {
channelId: 'chan-1',
stream: 'team.forum.post',
teamName: 'Blackthorn’s Legion',
teamUrl: 'https://site/guilds/blackthorns-legion',
title: 'Siege tonight',
body: 'Meet at the moongate.',
url: 'https://site/guilds/blackthorns-legion?thread=41',
...over,
})
// ── 1. The channel is the app's decision ───────────────────────────────────
test('the message goes to the channel the app named', async () => {
const channel = fakeChannel()
await post(fakeClient(channel))
assert.equal(channel.sends.length, 1)
const [embed] = channel.sends[0].embeds
assert.equal(embed.data.title, 'Siege tonight')
assert.equal(embed.data.author.name, 'Blackthorn’s Legion')
assert.equal(embed.data.url, 'https://site/guilds/blackthorns-legion?thread=41')
})
test('no channel id at all is refused before anything is fetched', async () => {
await assert.rejects(() => post(fakeClient(fakeChannel()), { channelId: '' }), /No channel id/)
})
// ── 2. A channel the bot cannot use ────────────────────────────────────────
test('a channel the bot cannot see is a clear error, not a silent no-op', async () => {
await assert.rejects(() => post(fakeClient(null)), /missing, not text-based, or not visible/)
})
test('a fetch that throws is reported the same way — the bot does not distinguish gone from hidden', async () => {
await assert.rejects(() => post(fakeClient(fakeChannel(), { throws: true })), /missing, not text-based/)
})
test('a voice channel is refused', async () => {
await assert.rejects(() => post(fakeClient(fakeChannel({ textBased: false }))), /not text-based/)
})
// ── 3. Discord's limits, and the heading ───────────────────────────────────
test('an over-long title is truncated rather than rejected by Discord as a whole', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { title: 'y'.repeat(400) })
const [embed] = channel.sends[0].embeds
assert.equal(embed.data.title.length, teamNotify.TITLE_MAX)
assert.ok(embed.data.title.endsWith('…'))
})
test('an over-long body is truncated to the description limit', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { body: 'z'.repeat(9000) })
const [embed] = channel.sends[0].embeds
assert.ok(embed.data.description.length <= teamNotify.DESCRIPTION_MAX + 32)
})
test('a titled event keeps its heading, so a post and an announcement stay distinguishable', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { stream: 'team.announcement' })
const [embed] = channel.sends[0].embeds
assert.match(embed.data.description, /^\*\*Announcement\*\*/)
assert.match(embed.data.description, /Meet at the moongate\./)
})
test('a roster event has no title, so the heading becomes the title', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { stream: 'team.member.joined', title: null, body: '3 new members joined.' })
const [embed] = channel.sends[0].embeds
assert.equal(embed.data.title, 'New member')
assert.equal(embed.data.description, '3 new members joined.', 'no heading prefix when the title already is one')
})
test('an unknown stream still posts, under a neutral heading', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { stream: 'team.something.new', title: null })
const [embed] = channel.sends[0].embeds
assert.equal(embed.data.title, 'Team update')
})
test('a missing team name does not produce an embed with an empty author line', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { teamName: '', teamUrl: null })
const [embed] = channel.sends[0].embeds
assert.equal(embed.data.author.name, 'A team')
assert.equal(embed.data.author.url, undefined)
})
test('clamp treats whitespace-only as absent, which is what keeps an empty description off the embed', async () => {
assert.equal(teamNotify.clamp(' ', 100), null)
assert.equal(teamNotify.clamp('ok', 100), 'ok')
})

364
bot/test/teamVoice.test.js Normal file
View File

@@ -0,0 +1,364 @@
// ── The bot's half of Team voice channels (TEAMS.md §7.3, phase 9) ────────
//
// Nothing here talks to Discord. `fakeGuild` records the calls, and the
// assertions are about the four things this side genuinely owns — the ones the
// site cannot decide because it cannot see the guild:
//
// 1. **The overwrite set.** @everyone denied, the Team's role allowed, each
// configured staff role allowed — and a staff role the operator has since
// deleted is FILTERED, because Discord rejects the whole set for one bad id
// and that would take the Team's own grant down with it.
// 2. **The membership diff is bounded and the remainder is reported.** Each
// grant is its own API call; an unbounded first pass on a large guild
// outlives its own timeout, which is the one failure that leaves the site
// not knowing what was applied.
// 3. **A member who linked Discord but never joined the guild is skipped
// silently.** That is §2.6 hop 3 without hop 4 — an ordinary state, not an
// error, and certainly not a hundred log lines.
// 4. **A missing target is success.** A teardown that finds its channel already
// deleted has reached the desired end state; a sync that finds one deleted
// simply creates it again.
const { test } = require('node:test')
const assert = require('node:assert/strict')
const { ChannelType, PermissionFlagsBits } = require('discord.js')
const teamVoice = require('../src/discord/teamVoice')
const EVERYONE = 'guild-everyone'
function fakeMember(id, { canGrant = true } = {}) {
const roles = new Set()
return {
id,
roles: {
cache: roles,
add: async (role) => {
if (!canGrant) throw new Error('Missing Permissions')
roles.add(role.id)
},
remove: async (role) => { roles.delete(role.id) },
},
}
}
function fakeGuild({
members = [],
roles = [],
channels = [],
botPermissions = [PermissionFlagsBits.ManageChannels, PermissionFlagsBits.ManageRoles],
} = {}) {
const memberMap = new Map(members.map((m) => [m.id, m]))
const roleMap = new Map(roles.map((r) => [r.id, r]))
const channelMap = new Map(channels.map((c) => [c.id, c]))
const created = { roles: [], channels: [] }
let nextId = 1000
const guild = {
id: 'guild-1',
created,
roles: {
everyone: { id: EVERYONE },
cache: roleMap,
fetch: async (id) => roleMap.get(id) || null,
create: async (opts) => {
const role = {
id: String(nextId++),
name: opts.name,
members: [],
setName: async (name) => { role.name = name },
delete: async () => { roleMap.delete(role.id) },
}
roleMap.set(role.id, role)
created.roles.push(opts)
return role
},
},
channels: {
cache: channelMap,
fetch: async (id) => channelMap.get(id) || null,
create: async (opts) => {
const channel = {
id: String(nextId++),
name: opts.name,
type: opts.type,
parentId: opts.parent || null,
overwrites: opts.permissionOverwrites || [],
permissionOverwrites: {
set: async (list) => { channel.overwrites = list },
},
setParent: async (parentId) => { channel.parentId = parentId },
setName: async (name) => { channel.name = name },
delete: async () => { channelMap.delete(channel.id) },
}
channelMap.set(channel.id, channel)
created.channels.push(opts)
return channel
},
},
members: {
me: { permissions: { has: (bit) => botPermissions.includes(bit) }, roles: { highest: { position: 7 } } },
cache: memberMap,
fetch: async () => memberMap,
},
}
return guild
}
const fakeClient = (guild) => ({ guilds: { fetch: async () => guild } })
const voiceChannel = (id, over = {}) => {
const channel = {
id,
name: 'The Silver Hand',
type: ChannelType.GuildVoice,
parentId: '500',
overwrites: [],
permissionOverwrites: { set: async (list) => { channel.overwrites = list } },
setParent: async (parentId) => { channel.parentId = parentId },
setName: async (name) => { channel.name = name },
delete: async () => {},
...over,
}
return channel
}
const category = (id = '500') => ({ id, type: ChannelType.GuildCategory })
const role = (id, name = 'The Silver Hand', members = []) => {
const r = {
id,
name,
members,
setName: async (next) => { r.name = next },
delete: async () => {},
}
return r
}
// ── Preflight ──────────────────────────────────────────────────────────────
test('preflight reports both permissions and the guild-wide role count', async () => {
const guild = fakeGuild({ roles: [role('1'), role('2')] })
const result = await teamVoice.preflight(fakeClient(guild), 'guild-1')
assert.equal(result.can_manage_channels, true)
assert.equal(result.can_manage_roles, true)
// The GUILD's roles, not ours. The 250 cap is shared with everything the
// operator made themselves, so counting only ours would promise headroom that
// is not there.
assert.equal(result.role_count, 2)
assert.equal(result.bot_role_position, 7)
})
test('preflight reports a missing permission rather than throwing', async () => {
const guild = fakeGuild({ botPermissions: [PermissionFlagsBits.ManageChannels] })
const result = await teamVoice.preflight(fakeClient(guild), 'guild-1')
assert.equal(result.can_manage_channels, true)
assert.equal(result.can_manage_roles, false)
})
// ── Overwrites ─────────────────────────────────────────────────────────────
test('the overwrite set denies @everyone and allows the Team role', () => {
const guild = fakeGuild()
const list = teamVoice.overwritesFor(guild, role('900'), [])
assert.equal(list.length, 2)
assert.equal(list[0].id, EVERYONE)
assert.deepEqual(list[0].deny, teamVoice.ACCESS_BITS)
assert.equal(list[1].id, '900')
assert.deepEqual(list[1].allow, teamVoice.ACCESS_BITS)
})
test('a configured staff role that still exists gets an allow', () => {
const staff = role('777', 'Moderators')
const guild = fakeGuild({ roles: [staff] })
const list = teamVoice.overwritesFor(guild, role('900'), ['777'])
assert.equal(list.length, 3)
assert.equal(list[2].id, '777')
})
test('a staff role deleted in Discord is skipped, not sent — it would void the whole set', () => {
const guild = fakeGuild({ roles: [] })
const list = teamVoice.overwritesFor(guild, role('900'), ['deleted-1'])
assert.equal(list.length, 2)
assert.ok(!list.some((o) => o.id === 'deleted-1'))
})
// ── Ensure ─────────────────────────────────────────────────────────────────
test('a missing category is created; an existing one is reused', async () => {
const guild = fakeGuild()
const made = await teamVoice.ensureCategory(guild, null)
assert.equal(guild.created.channels.length, 1)
assert.equal(guild.created.channels[0].type, ChannelType.GuildCategory)
const again = await teamVoice.ensureCategory(guild, made.id)
assert.equal(again.id, made.id)
assert.equal(guild.created.channels.length, 1)
})
test('a category id pointing at something that is not a category makes a new one', async () => {
const guild = fakeGuild({ channels: [voiceChannel('700')] })
await teamVoice.ensureCategory(guild, '700')
assert.equal(guild.created.channels.length, 1)
})
test('the Team role is created not mentionable and not hoisted', async () => {
const guild = fakeGuild()
const { role: made, created } = await teamVoice.ensureRole(guild, null, 'The Silver Hand')
assert.equal(created, true)
assert.equal(made.name, 'The Silver Hand')
// A Team with two hundred members must not become a way to ping them all, or a
// second copy of the member list down the sidebar.
assert.equal(guild.created.roles[0].mentionable, false)
assert.equal(guild.created.roles[0].hoist, false)
})
test('a renamed Team renames its role rather than making a second', async () => {
const existing = role('900', 'Old Name')
const guild = fakeGuild({ roles: [existing] })
const { role: made, created } = await teamVoice.ensureRole(guild, '900', 'New Name')
assert.equal(created, false)
assert.equal(made.name, 'New Name')
assert.equal(guild.created.roles.length, 0)
})
test('a rename Discord refuses does not fail the pass — access matters more than a label', async () => {
const existing = role('900', 'Old Name')
existing.setName = async () => { throw new Error('rate limited') }
const guild = fakeGuild({ roles: [existing] })
const { role: made } = await teamVoice.ensureRole(guild, '900', 'New Name')
assert.equal(made.id, '900')
})
test('a channel a human deleted is simply created again', async () => {
const guild = fakeGuild()
const { channel, created } = await teamVoice.ensureChannel(guild, 'gone-1', {
name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
})
assert.equal(created, true)
assert.equal(channel.type, ChannelType.GuildVoice)
assert.equal(channel.parentId, '500')
})
test('an existing channel has its overwrites re-asserted every pass', async () => {
const existing = voiceChannel('600')
const guild = fakeGuild({ channels: [existing] })
const { created } = await teamVoice.ensureChannel(guild, '600', {
name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
})
assert.equal(created, false)
// Re-setting rather than diffing is what repairs a channel somebody edited by
// hand.
assert.equal(existing.overwrites.length, 2)
})
test('a channel that is no longer a voice channel is left alone and a new one made', async () => {
const text = voiceChannel('600', { type: ChannelType.GuildText })
const guild = fakeGuild({ channels: [text] })
const { channel, created } = await teamVoice.ensureChannel(guild, '600', {
name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
})
assert.equal(created, true)
assert.notEqual(channel.id, '600')
})
// ── Membership ─────────────────────────────────────────────────────────────
test('the role is granted to the members the site named', async () => {
const alice = fakeMember('a')
const bob = fakeMember('b')
const guild = fakeGuild({ members: [alice, bob] })
const teamRole = role('900', 'The Silver Hand', [])
const result = await teamVoice.syncRoleMembers(guild, teamRole, ['a', 'b'], 50)
assert.equal(result.added, 2)
assert.equal(result.removed, 0)
assert.equal(result.pending, 0)
})
test('a member who left the Team has the role taken away', async () => {
const alice = fakeMember('a')
const bob = fakeMember('b')
const guild = fakeGuild({ members: [alice, bob] })
const teamRole = role('900', 'The Silver Hand', [alice, bob])
const result = await teamVoice.syncRoleMembers(guild, teamRole, ['a'], 50)
assert.equal(result.added, 0)
assert.equal(result.removed, 1)
})
test('a member who linked Discord but never joined the guild is skipped without an error', async () => {
const guild = fakeGuild({ members: [] })
const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), ['not-in-guild'], 50)
assert.equal(result.added, 0)
assert.equal(result.pending, 0)
})
test('the diff is bounded and the remainder is REPORTED, not dropped', async () => {
const members = Array.from({ length: 10 }, (_, i) => fakeMember(`m${i}`))
const guild = fakeGuild({ members })
const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), members.map((m) => m.id), 4)
assert.equal(result.added, 4)
assert.equal(result.pending, 6)
})
test('one member the bot cannot touch does not cost the other forty-nine', async () => {
const ok1 = fakeMember('a')
const nope = fakeMember('b', { canGrant: false })
const ok2 = fakeMember('c')
const guild = fakeGuild({ members: [ok1, nope, ok2] })
const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), ['a', 'b', 'c'], 50)
assert.equal(result.added, 2)
})
// ── Teardown ───────────────────────────────────────────────────────────────
test('a teardown deletes the channel and the role together', async () => {
const channel = voiceChannel('600')
const teamRole = role('900')
let deletedChannel = false
let deletedRole = false
channel.delete = async () => { deletedChannel = true }
teamRole.delete = async () => { deletedRole = true }
const guild = fakeGuild({ channels: [channel], roles: [teamRole] })
const result = await teamVoice.removeTeamVoice(fakeClient(guild), 'guild-1', { channelId: '600', roleId: '900' })
assert.equal(deletedChannel, true)
assert.equal(deletedRole, true)
assert.equal(result.channel_deleted, true)
assert.equal(result.role_deleted, true)
})
test('a teardown whose target is already gone is success, not a failure to retry forever', async () => {
const guild = fakeGuild({ channels: [], roles: [] })
const result = await teamVoice.removeTeamVoice(fakeClient(guild), 'guild-1', { channelId: 'gone', roleId: 'gone' })
assert.equal(result.channel_deleted, false)
assert.equal(result.role_deleted, false)
})
// ── The whole thing ────────────────────────────────────────────────────────
test('a first sync creates the category, the role and the channel, and grants the members', async () => {
const alice = fakeMember('a')
const guild = fakeGuild({ members: [alice] })
const result = await teamVoice.syncTeamVoice(fakeClient(guild), 'guild-1', {
teamId: 1,
name: 'The Silver Hand',
categoryId: null,
channelId: null,
roleId: null,
staffRoleIds: [],
memberIds: ['a'],
maxMemberOps: 50,
})
assert.equal(result.created.channel, true)
assert.equal(result.created.role, true)
assert.ok(result.category_id)
assert.ok(result.channel_id)
assert.ok(result.role_id)
assert.equal(result.members.added, 1)
})

View File

@@ -42,6 +42,12 @@ import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import UserDetail from './routes/admin/views/UserDetail.jsx'
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
import EngagementRules from './routes/admin/views/EngagementRules.jsx'
import EngagementAudiences from './routes/admin/views/EngagementAudiences.jsx'
import EngagementTemplates from './routes/admin/views/EngagementTemplates.jsx'
import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
@@ -54,10 +60,12 @@ import PlayerLogin from './routes/player/PlayerLogin.jsx'
import PlayerRegister from './routes/player/PlayerRegister.jsx'
import ForgotPassword from './routes/player/ForgotPassword.jsx'
import ResetPassword from './routes/player/ResetPassword.jsx'
import VerifyEmail from './routes/player/VerifyEmail.jsx'
import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx'
import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
import PlayerInbox from './routes/player/PlayerInbox.jsx'
import Unsubscribe from './routes/player/Unsubscribe.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
@@ -183,7 +191,34 @@ export default function App() {
actions that publish a game-written name is applied per request
on the server, from the caller's live role (TEAMS.md 2.9). */}
<Route path="teams" element={<TeamsAdmin />} />
{/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the
server: every route under /admin/engagement re-gates to `admin`
on top of the group's staff gate, because this is the group that
decides who receives mail. */}
<Route
path="engagement"
element={
<RoleGate roles={['admin']}>
<Outlet />
</RoleGate>
}
>
<Route index element={<Navigate to="rules" replace />} />
<Route path="rules" element={<EngagementRules />} />
<Route path="audiences" element={<EngagementAudiences />} />
<Route path="templates" element={<EngagementTemplates />} />
<Route path="triggers" element={<EngagementTriggers />} />
<Route path="sends" element={<EngagementSendLog />} />
<Route path="suppressions" element={<EngagementSuppressions />} />
</Route>
<Route path="account" element={<AccountAdmin />} />
{/* Staff have an inbox and channel preferences like anyone else —
`/auth/me/notifications` is behind requireAuth only — but
`RequirePlayer` sends them out of the player portal, so the two
screens are mounted here as well. Same components, same API,
two paths; `lib/notificationPaths.js` is the one mapping. */}
<Route path="notifications" element={<PlayerInbox />} />
<Route path="notifications/settings" element={<PlayerNotifications />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
wrapper — only an optional { roles }, which core applies as the
@@ -205,6 +240,9 @@ export default function App() {
<Route path="/account/register" element={<PlayerRegister />} />
<Route path="/account/forgot" element={<ForgotPassword />} />
<Route path="/account/reset/:token" element={<ResetPassword />} />
{/* Opened from a mailbox, so public like the reset page above — the
token is the proof, and confirming issues no session. */}
<Route path="/account/verify-email/:token" element={<VerifyEmail />} />
<Route path="/invite/:token" element={<AcceptInvite />} />
{/* PUBLIC, and grouped with the other tokened landings above rather
than with the portal below: the person following an unsubscribe
@@ -224,7 +262,14 @@ export default function App() {
<Route path="/player" element={<PlayerIndex />} />
<Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} />
<Route path="/account/notifications" element={<PlayerNotifications />} />
{/* The inbox took `/account/notifications` in engagement Phase 7
and the preferences screen moved under it. Content and
settings are different kinds of thing, and the plain word
belongs to the one a person means when they say it — which is
also what the bell in the header opens. The server's routes
split at the same place. */}
<Route path="/account/notifications" element={<PlayerInbox />} />
<Route path="/account/notifications/settings" element={<PlayerNotifications />} />
{/* Installed modules' player-portal pages, at /player/<id>/…. This
group's own routes are absolute (its layout route has no path),
so the prefix is written here rather than inherited — the one

View File

@@ -105,6 +105,36 @@ export const api = {
revokeTrustedDevice: (id) =>
req(`/auth/me/trusted-devices/${encodeURIComponent(id)}`, { method: 'DELETE' }),
revokeAllTrustedDevices: () => req('/auth/me/trusted-devices', { method: 'DELETE' }),
// Self-service account security, role-agnostic under /auth/me/account. This is
// the ONLY surface for it: the /admin/account/* and /player/account/* copies
// were deleted (both were strictly smaller — neither carried recovery codes),
// which is why recovery codes below already lived here while the rest did not.
// The change endpoints re-issue the session cookie server-side, so the caller
// stays signed in.
myAccount: () => req('/auth/me/account'),
changeUsername: (username) =>
req('/auth/me/account/username', { method: 'PATCH', body: { username } }),
changePassword: (newPassword, currentPassword) =>
req('/auth/me/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
// Email address (engagement Phase 1b). changeEmail STAGES the address — the
// account keeps its current one until the emailed link is opened — so the UI
// must show `email_pending` as pending, never as the address in force.
changeEmail: (email, currentPassword) =>
req('/auth/me/account/email', { method: 'PATCH', body: { email, currentPassword } }),
resendEmailVerification: () => req('/auth/me/account/email/resend', { method: 'POST' }),
cancelEmailChange: () => req('/auth/me/account/email/pending', { method: 'DELETE' }),
// The confirm half is public and token-gated — it is reached from a mailbox,
// often with no session, so it deliberately sits outside /auth/me.
lookupEmailVerification: (token) => req(`/auth/email/verify/${encodeURIComponent(token)}`),
confirmEmailVerification: (token) =>
req(`/auth/email/verify/${encodeURIComponent(token)}`, { method: 'POST' }),
totpSetup: () => req('/auth/me/account/totp/setup', { method: 'POST' }),
totpEnable: (code) => req('/auth/me/account/totp/enable', { method: 'POST', body: { code } }),
totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }),
// Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link.
myIdentities: () => req('/auth/me/account/identities'),
unlinkIdentity: (provider) =>
req(`/auth/me/account/identities/${encodeURIComponent(provider)}`, { method: 'DELETE' }),
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
// returned ONCE (password step-up for accounts that have a password).
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
@@ -200,6 +230,25 @@ export const api = {
// field, so clearing the last subscription must not become an absent key.
setNotificationSubscriptions: (streams) =>
req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }),
// Per-channel preferences (ENGAGEMENT.md Phase 3). A SPARSE update: only the
// (id, channel) pairs sent are written, so a screen managing one channel need
// not know what the others hold. Shipped with no surface at all until Phase 7.
notificationChannelPrefs: () => req('/auth/me/notifications/channels'),
setNotificationChannelPrefs: (prefs) =>
req('/auth/me/notifications/channels', { method: 'PUT', body: { prefs } }),
// The in-app inbox (ENGAGEMENT.md Phase 7). `before` is a keyset cursor — the
// id of the last item on the previous page — not an offset: the list gains
// rows at the top while it is being read.
notifications: ({ limit, before, unread } = {}) => {
const qs = new URLSearchParams()
if (limit) qs.set('limit', String(limit))
if (before) qs.set('before', String(before))
if (unread) qs.set('unread', 'true')
return req(`/auth/me/notifications${withQs(qs.toString())}`)
},
notificationsUnreadCount: () => req('/auth/me/notifications/unread-count'),
markNotificationRead: (id) => req(`/auth/me/notifications/${id}/read`, { method: 'POST' }),
markAllNotificationsRead: () => req('/auth/me/notifications/read-all', { method: 'POST' }),
teamNotificationPrefs: () => req('/auth/me/notifications/teams'),
setTeamNotificationPrefs: (teams) =>
req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }),
@@ -294,6 +343,12 @@ export const api = {
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
// Accounts whose address was cleared when addresses became unique (Phase 1b).
// They can still sign in but can receive no mail until they set a new one, so
// they are the list an operator has to work through.
emailDedupeReport: () => req('/admin/users/email-dedupe-report'),
acknowledgeEmailDedupeReport: () =>
req('/admin/users/email-dedupe-report/acknowledge', { method: 'POST' }),
// A user's trusted devices + MFA reset (admin only).
userTrustedDevices: (id) => req(`/admin/users/${id}/trusted-devices`),
revokeUserTrustedDevice: (id, deviceId) =>
@@ -321,6 +376,83 @@ export const api = {
setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
restartServer: () => req('/admin/modules/restart', { method: 'POST' }),
// Engagement (docs/website/ENGAGEMENT.md Phase 4b). The first three are the
// catalog — triggers, audiences and channels, all served from the registries
// rather than from tables, so an installed module's declarations appear here
// without a client release.
//
// `setEngagementRuleEnabled` is its own call rather than a `saveEngagementRule`
// with one field, because the route is its own route: turning a rule off must
// work on a rule the registries would now refuse, which is exactly the rule an
// operator most wants stopped.
//
// `previewEngagementReach` answers with a COUNT and never a list of people.
engagementTriggers: () => req('/admin/engagement/triggers'),
engagementAudiences: () => req('/admin/engagement/audiences'),
engagementChannels: () => req('/admin/engagement/channels'),
listEngagementRules: () => req('/admin/engagement/rules'),
createEngagementRule: (body) => req('/admin/engagement/rules', { method: 'POST', body }),
updateEngagementRule: (id, body) => req(`/admin/engagement/rules/${id}`, { method: 'PUT', body }),
setEngagementRuleEnabled: (id, enabled) =>
req(`/admin/engagement/rules/${id}/enabled`, { method: 'PATCH', body: { enabled } }),
deleteEngagementRule: (id) => req(`/admin/engagement/rules/${id}`, { method: 'DELETE' }),
listEngagementSegments: () => req('/admin/engagement/segments'),
createEngagementSegment: (body) => req('/admin/engagement/segments', { method: 'POST', body }),
updateEngagementSegment: (id, body) => req(`/admin/engagement/segments/${id}`, { method: 'PUT', body }),
deleteEngagementSegment: (id) => req(`/admin/engagement/segments/${id}`, { method: 'DELETE' }),
previewEngagementReach: ({ audience, audienceSegmentId, triggerId } = {}) => {
const qs = new URLSearchParams()
if (audienceSegmentId) qs.set('audienceSegmentId', String(audienceSegmentId))
else if (audience) qs.set('audience', audience)
if (triggerId) qs.set('triggerId', triggerId)
return req(`/admin/engagement/audience-preview${withQs(qs.toString())}`)
},
// Templates and the send log (engagement Phase 5b). `previewEngagementTemplate`
// and `testSendEngagementTemplate` are POSTs that write nothing: both act on
// the draft in the request, so the editor can show and send what is on screen
// rather than what was last saved.
listEngagementTemplates: () => req('/admin/engagement/templates'),
getEngagementTemplate: (id) => req(`/admin/engagement/templates/${id}`),
updateEngagementTemplate: (id, body) =>
req(`/admin/engagement/templates/${id}`, { method: 'PUT', body }),
duplicateEngagementTemplate: (id, body) =>
req(`/admin/engagement/templates/${id}/duplicate`, { method: 'POST', body }),
deleteEngagementTemplate: (id) => req(`/admin/engagement/templates/${id}`, { method: 'DELETE' }),
previewEngagementTemplate: (id, body) =>
req(`/admin/engagement/templates/${id}/preview`, { method: 'POST', body }),
testSendEngagementTemplate: (id, body) =>
req(`/admin/engagement/templates/${id}/test-send`, { method: 'POST', body }),
listEngagementSends: ({ limit, offset, triggerId, ruleId, userId, status } = {}) => {
const qs = new URLSearchParams()
if (limit) qs.set('limit', String(limit))
if (offset) qs.set('offset', String(offset))
if (triggerId) qs.set('triggerId', triggerId)
if (ruleId) qs.set('ruleId', String(ruleId))
if (userId) qs.set('userId', String(userId))
if (status) qs.set('status', status)
return req(`/admin/engagement/sends${withQs(qs.toString())}`)
},
// Suppressions (Phase 9). `unsuppressAddress` sends the address in the BODY
// of a DELETE rather than in the path, and that is not style: a path
// parameter lands in the access log, the browser history and every proxy in
// front of the deployment, and this one is a real person's address. The list
// never returns a hash to use instead.
listEngagementSuppressions: ({ limit, offset, reason, channel, search } = {}) => {
const qs = new URLSearchParams()
if (limit) qs.set('limit', String(limit))
if (offset) qs.set('offset', String(offset))
if (reason) qs.set('reason', reason)
if (channel) qs.set('channel', channel)
if (search) qs.set('search', search)
return req(`/admin/engagement/suppressions${withQs(qs.toString())}`)
},
suppressAddress: (address, detail) =>
req('/admin/engagement/suppressions', { method: 'POST', body: { address, detail } }),
unsuppressAddress: (address, channel) =>
req('/admin/engagement/suppressions', { method: 'DELETE', body: { address, channel } }),
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
// different depending on who calls them: for a moderator, unhide and
// setTeamDisplayName file a request and the response says `pending: true`.
@@ -340,6 +472,17 @@ export const api = {
clearTeamLeaderOverride: (id, memberKey) =>
req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }),
teamForumSettings: () => req('/admin/teams/forum/settings'),
// The notification bridge (TEAMS.md §7.2). Admin-only server-side, so a
// moderator's admin panel never renders the panel that calls these.
teamIntegrations: () => req('/admin/teams/integrations'),
saveTeamIntegration: (body) => req('/admin/teams/integrations', { method: 'PUT', body }),
deleteTeamIntegration: (teamId) =>
req(`/admin/teams/integrations/${teamId === null ? 'default' : teamId}`, { method: 'DELETE' }),
// Voice channels (TEAMS.md §7.3). Admin-only server-side, like the bridge.
teamVoice: () => req('/admin/teams/voice'),
saveTeamVoice: (body) => req('/admin/teams/voice', { method: 'PUT', body }),
teamVoicePass: () => req('/admin/teams/voice/sync', { method: 'POST' }),
removeTeamVoice: (teamId) => req(`/admin/teams/voice/${teamId}`, { method: 'DELETE' }),
teamForumUploads: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.deleted) qs.set('deleted', '1')
@@ -424,16 +567,6 @@ export const api = {
req(`/admin/moderation/appeals/${id}/resolve`, { method: 'POST', body: data }),
getUserAppeals: (discordId) => req(`/admin/moderation/user/${discordId}/appeals`),
// ----- account security (self-service 2FA) -----
getAccount: () => req('/admin/account'),
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
// ----- linked SSO identities (self-service) -----
linkedIdentities: () => req('/admin/account/identities'),
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
// ----- auth providers / SSO config (admin only) -----
listAuthProviders: () => req('/admin/auth/providers'),
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
@@ -444,29 +577,19 @@ export const api = {
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
// ----- Email delivery / Gmail OAuth2 (admin only) -----
// ----- Email delivery (admin only) -----
// The connect-flow call went with Gmail OAuth2 (ENGAGEMENT.md §1.2a); the
// config response now carries the transport catalog the form renders from.
getEmailConfig: () => req('/admin/email/config'),
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
emailConnectUrl: () => req('/admin/email/connect/start'),
testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }),
disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }),
},
// ----- player self-service (role: 'player') -----
// Mirrors the admin account methods but self-scoped under /player. The change
// endpoints re-issue the session cookie server-side, so the caller stays signed in.
// Account security is NOT here — it is role-agnostic and lives at the root of
// this object, on /auth/me/account. What remains is genuinely player-scoped.
player: {
getAccount: () => req('/player/account'),
changeUsername: (username) =>
req('/player/account/username', { method: 'PATCH', body: { username } }),
changePassword: (newPassword, currentPassword) =>
req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }),
totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }),
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
linkedIdentities: () => req('/player/account/identities'),
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
// ----- moderation appeals (self-service) -----
getMyAppeals: () => req('/player/appeals'),
getEligibleAppeals: () => req('/player/appeals/eligible'),

View File

@@ -0,0 +1,353 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext.jsx'
import { api } from '../api/client.js'
import { inboxPath } from '../lib/notificationPaths.js'
// The in-app inbox's header surface (ENGAGEMENT.md Phase 7): a bell with an
// unread badge, and a panel with the most recent items.
//
// **The badge is polled, not pushed**, and the reason is that there is nothing
// to push over. The site's two SSE streams are the shard's; neither is
// per-user, and adding a third authenticated stream to carry an integer would
// mean one open connection per signed-in tab for the rest of the deployment's
// life. A minute-granular badge on a page somebody is already looking at is the
// same answer for a fraction of that. The poll pauses while the tab is hidden —
// a background tab has nobody to show a badge to — and refreshes the moment it
// comes back, which is also the moment it would be most wrong.
//
// **The panel shows a handful and links out.** Paging belongs on the page; a
// dropdown that scrolls is a list in the wrong place.
//
// Dismissal follows `NavDropdown`'s contract exactly — Escape closes and
// returns focus, an outside `mousedown` closes, navigating closes — because
// this sits beside it in the same header and two menus that dismiss differently
// is a bug nobody files.
const POLL_MS = 60_000
const PANEL_ITEMS = 6
function BellIcon({ size = 17 }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.7 21a2 2 0 01-3.4 0" />
</svg>
)
}
// "3m", "4h", "6d" — a relative stamp, because the only question a reader has
// about an inbox item's time is how fresh it is.
function ago(iso) {
const then = new Date(iso).getTime()
if (!Number.isFinite(then)) return ''
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
if (secs < 60) return 'now'
if (secs < 3600) return `${Math.floor(secs / 60)}m`
if (secs < 86400) return `${Math.floor(secs / 3600)}h`
return `${Math.floor(secs / 86400)}d`
}
export default function NotificationBell() {
const { user } = useAuth()
const [unread, setUnread] = useState(0)
const [items, setItems] = useState([])
const [open, setOpen] = useState(false)
const [error, setError] = useState('')
const wrapRef = useRef(null)
const triggerRef = useRef(null)
const location = useLocation()
const navigate = useNavigate()
// Every read here swallows its failure. A count that could not be fetched is
// a bell with no badge, which is what a bell with nothing to report looks
// like anyway — the alternative is an error banner in the site header for a
// number nobody asked for.
const refreshCount = useCallback(async () => {
if (!user) return
try {
const res = await api.notificationsUnreadCount()
setUnread(res.unread || 0)
} catch {
/* leave the badge as it was */
}
}, [user])
useEffect(() => {
if (!user) return undefined
refreshCount()
const timer = setInterval(() => {
if (document.visibilityState === 'visible') refreshCount()
}, POLL_MS)
const onVisible = () => {
if (document.visibilityState === 'visible') refreshCount()
}
document.addEventListener('visibilitychange', onVisible)
return () => {
clearInterval(timer)
document.removeEventListener('visibilitychange', onVisible)
}
}, [user, refreshCount])
// The panel's items are fetched when it opens, never kept warm: a list nobody
// has asked to see is a request per minute for content nobody is reading.
const load = useCallback(async () => {
setError('')
try {
const res = await api.notifications({ limit: PANEL_ITEMS })
setItems(res.items || [])
setUnread(res.unread || 0)
} catch (err) {
setError(err.message || 'Could not load notifications')
}
}, [])
useEffect(() => setOpen(false), [location.pathname])
useEffect(() => {
if (!open) return undefined
const onKey = (e) => {
if (e.key !== 'Escape') return
setOpen(false)
triggerRef.current?.focus()
}
const onOutside = (e) => {
if (!wrapRef.current?.contains(e.target)) setOpen(false)
}
document.addEventListener('keydown', onKey)
document.addEventListener('mousedown', onOutside)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onOutside)
}
}, [open])
if (!user) return null
const toggle = () => {
const next = !open
setOpen(next)
if (next) load()
}
// Opening an item marks it read and then goes where it points. The mark is
// awaited rather than fired off, so the badge the next screen renders is the
// one this click produced; a failed mark still navigates, because the item's
// link is the thing the user asked for.
const openItem = async (item) => {
setOpen(false)
if (!item.read) {
try {
const res = await api.markNotificationRead(item.id)
setUnread(res.unread ?? Math.max(0, unread - 1))
} catch {
/* the link still works */
}
}
navigate(item.url || inboxPath(user))
}
const markAll = async () => {
try {
await api.markAllNotificationsRead()
setUnread(0)
setItems((list) => list.map((i) => ({ ...i, read: true })))
} catch (err) {
setError(err.message || 'Could not mark them read')
}
}
return (
<div ref={wrapRef} style={{ position: 'relative' }}>
<button
ref={triggerRef}
type="button"
className="pill"
aria-haspopup="true"
aria-expanded={open}
// The count is in the label, not only in the badge: a screen reader gets
// "Notifications, 3 unread" rather than "Notifications" and a number it
// has no way to relate to it.
aria-label={unread ? `Notifications, ${unread} unread` : 'Notifications'}
onClick={toggle}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
position: 'relative',
...(open ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : {}),
}}
>
<BellIcon />
{unread > 0 && (
<span
aria-hidden="true"
className="sans"
style={{
minWidth: 17,
height: 17,
padding: '0 4px',
borderRadius: 9,
background: 'var(--accent)',
color: 'var(--bg-deep)',
fontSize: '0.68rem',
fontWeight: 700,
lineHeight: '17px',
textAlign: 'center',
}}
>
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
{open && (
<div
role="menu"
aria-label="Notifications"
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
right: 0,
width: 320,
maxWidth: 'calc(100vw - 24px)',
padding: 6,
borderRadius: 'var(--radius-card)',
border: '1px solid var(--line)',
background: 'var(--panel-flat)',
boxShadow: 'var(--shadow-card)',
zIndex: 40,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
padding: '4px 8px 8px',
}}
>
<strong className="sans" style={{ fontSize: '0.82rem', color: 'var(--head)' }}>
Notifications
</strong>
{unread > 0 && (
<button
type="button"
onClick={markAll}
className="sans"
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'var(--accent)',
fontSize: '0.78rem',
}}
>
Mark all read
</button>
)}
</div>
{error && (
<p className="sans" style={{ margin: '0 8px 8px', fontSize: '0.8rem', color: '#d98b84' }}>
{error}
</p>
)}
{!error && items.length === 0 && (
<p className="sans dim" style={{ margin: '0 8px 10px', fontSize: '0.82rem' }}>
Nothing here yet.
</p>
)}
{items.map((item) => (
<button
key={item.id}
type="button"
role="menuitem"
onClick={() => openItem(item)}
className="sans"
style={{
display: 'block',
width: '100%',
textAlign: 'left',
padding: '8px 10px',
borderRadius: 'var(--radius-input)',
border: 'none',
cursor: 'pointer',
background: item.read ? 'transparent' : 'var(--panel)',
}}
>
<span
style={{
display: 'block',
fontSize: '0.85rem',
color: item.read ? 'var(--muted)' : 'var(--head)',
fontWeight: item.read ? 400 : 600,
}}
>
{item.title}
</span>
{item.body && (
<span
className="dim"
style={{
fontSize: '0.78rem',
marginTop: 2,
// The body is stored and rendered as TEXT, never as markup —
// `white-space: pre-line` is what keeps the template's own
// line breaks without ever interpreting anything.
whiteSpace: 'pre-line',
// Two lines, then an ellipsis. `-webkit-box` is the only
// clamp with real support; it is also why there is no second
// `display: block` above it.
display: '-webkit-box',
overflow: 'hidden',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{item.body}
</span>
)}
<span className="dim" style={{ display: 'block', fontSize: '0.72rem', marginTop: 3 }}>
{ago(item.createdAt)}
</span>
</button>
))}
<Link
to={inboxPath(user)}
role="menuitem"
onClick={() => setOpen(false)}
className="sans"
style={{
display: 'block',
marginTop: 4,
padding: '8px 10px',
borderTop: '1px solid var(--line-soft)',
fontSize: '0.8rem',
color: 'var(--accent)',
textDecoration: 'none',
}}
>
See all notifications →
</Link>
</div>
)}
</div>
)
}

View File

@@ -5,6 +5,7 @@ import BrandLogo from './BrandLogo.jsx'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import NavDropdown from './NavDropdown.jsx'
import NotificationBell from './NotificationBell.jsx'
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
import { parseJsonSetting } from '../lib/settingsJson.js'
import { withModuleNav } from '../modules/nav.js'
@@ -107,6 +108,10 @@ export default function SiteHeader() {
</NavLink>
),
)}
{/* Renders nothing when signed out, so the header keeps its shape for
a visitor. It is here rather than only in the portal because an
inbox item is worth seeing from the page you are already on. */}
{!loading && <NotificationBell />}
{!loading && (
<NavLink
to={account.to}

View File

@@ -0,0 +1,175 @@
import { useState } from 'react'
import { api } from '../../api/client.js'
// Self-service email address (engagement Phase 1b). Shared by the player portal
// and the admin account screen, the same way TrustedDevicesPanel and
// RecoveryCodesPanel are — /auth/me/account is one surface for every role, so its
// UI is one component too.
//
// The property this component exists to make visible: a requested address is
// STAGED, not applied. The account keeps receiving mail — password resets
// included — at the address it already has until the emailed link is opened. If
// the UI let a pending address look like the address in force, someone who
// mistyped would believe the change took and would only discover otherwise when
// they could not recover their account.
//
// `hasPassword` decides whether the current-password field appears: an address is
// where account recovery lands, so changing it is re-authenticated, with the same
// carve-out the password form makes for an SSO-only account.
export default function EmailAddressPanel({ account, reload, embedded = false }) {
const hasPassword = account.has_password !== false
const [email, setEmail] = useState('')
const [current, setCurrent] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
const pending = account.email_pending
async function save(e) {
e.preventDefault()
setMsg('')
setError('')
setBusy(true)
try {
const res = await api.changeEmail(email.trim(), hasPassword ? current : undefined)
setEmail('')
setCurrent('')
// Report an unsent mail honestly. Saying "check your inbox" about a message
// that was never sent turns a configuration problem into a user who waits.
if (res.emailed === false) {
setMsg(
res.reason === 'NOT_CONFIGURED'
? 'Address saved, but this site cannot send email right now. Ask an administrator, then use Resend.'
: 'Address saved, but the confirmation email could not be sent. Try Resend in a moment.',
)
} else {
setMsg(
`Confirmation sent to ${res.email_pending}. Your current address stays in use until you open that link.`,
)
}
await reload()
} catch (err) {
if (err.status === 429) setError('Too many confirmation emails. Try again later.')
else setError(err.message || 'Could not change your email address.')
} finally {
setBusy(false)
}
}
async function resend() {
setMsg('')
setError('')
setBusy(true)
try {
const res = await api.resendEmailVerification()
setMsg(
res.emailed === false
? 'Could not send the confirmation email.'
: `Confirmation re-sent to ${res.email_pending}.`,
)
} catch (err) {
setError(err.message || 'Could not resend the confirmation email.')
} finally {
setBusy(false)
}
}
async function discard() {
setMsg('')
setError('')
setBusy(true)
try {
await api.cancelEmailChange()
setMsg('Pending address discarded.')
await reload()
} catch (err) {
setError(err.message || 'Could not discard the pending address.')
} finally {
setBusy(false)
}
}
const wrap = embedded
? {}
: { marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }
return (
<div style={wrap}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Email address
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
{account.email ? (
<>
Currently <strong style={{ color: 'var(--head)' }}>{account.email}</strong>
{account.email_verified ? ' (confirmed)' : ' (not yet confirmed)'}. This is where password-reset
email is sent.
</>
) : (
'You have no email address on file, so you cannot reset your password by email.'
)}
</p>
{pending && (
<div
className="sans"
style={{
border: '1px solid var(--line-soft)',
borderRadius: 6,
padding: '10px 12px',
marginBottom: 16,
fontSize: '0.85rem',
color: 'var(--muted)',
}}
>
<strong style={{ color: 'var(--head)' }}>{pending}</strong> is waiting to be confirmed. It is not in
use until you open the link in that email.
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<button type="button" onClick={resend} disabled={busy} className="btn btn-sq">
Resend
</button>
<button type="button" onClick={discard} disabled={busy} className="btn btn-sq">
Discard
</button>
</div>
</div>
)}
<form onSubmit={save} style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 320 }}>
<label>
<span className="field-label">{pending ? 'Use a different address' : 'New email address'}</span>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input"
autoComplete="email"
/>
</label>
{hasPassword && (
<label>
<span className="field-label">Current password</span>
<input
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
className="input"
autoComplete="current-password"
/>
</label>
)}
<div>
<button type="submit" disabled={busy || !email.trim()} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Send confirmation'}
</button>
</div>
{(msg || error) && (
<p className="sans" style={{ margin: 0, fontSize: '0.85rem', color: error ? '#e08a8a' : 'var(--muted)' }}>
{error || msg}
</p>
)}
</form>
</div>
)
}

View File

@@ -0,0 +1,12 @@
// Client email-block registry entrypoint. Importing this module registers every
// `email.*` authoring definition exactly once, then re-exports the registry API.
// The template editor imports from HERE, never from ./registry, so the
// definitions are loaded before anything reads the palette.
//
// Same shape as `blocks/index.js` — and the same reason for existing.
export * from './registry'
export { VariablePalette } from './types.jsx'
// ── Definitions (self-register on import) ──────────────────────────────────
import './types.jsx'

View File

@@ -0,0 +1,100 @@
// ── The client-side `email.*` block registry ───────────────────────────────
//
// ENGAGEMENT.md §4.6.2, Phase 5b. A sibling of `blocks/registry.js` for the same
// reason its server counterpart is a sibling of `blocks/registry.js` on that side
// — and with ONE structural difference that is the whole argument for the shape of
// this screen:
//
// **an email block definition here has no `component`.**
//
// A page block carries a React renderer because a page IS React. A mail body is a
// string this deployment's server produces, and the preview shows exactly that
// string. Giving these entries a React renderer would mean two renderers for one
// artifact — one drawing the editor's preview, one producing what actually lands
// in someone's inbox — and nothing would make them agree. They would agree on the
// day they were written and drift from the first Outlook fix onward, at which
// point the preview becomes a confident lie about mail nobody can see.
//
// So the division is: **this registry owns authoring, the server owns rendering.**
// Everything here is about the editing experience — the palette entry, the prop
// form, the starting props — and the preview arrives from
// `POST /admin/engagement/templates/:id/preview` as HTML that goes into a
// sandboxed iframe.
//
// `type` and `version` must match the server definition in
// `server/src/emailBlocks/types/`. That pairing is the same discipline the page
// family already runs on, and the save is the thing that enforces it: the server
// validates against its own registry, so a client entry that has drifted produces
// a refused save rather than a bad row.
const registry = new Map()
// The same reserved envelope keys the server's `RESERVED_KEYS` names. Duplicated
// rather than imported because the client cannot import from `server/`, exactly as
// `blocks/registry.js` duplicates them — and, as there, the server is the one that
// decides: a block this list let through is still refused at the save.
export const RESERVED_KEYS = ['id', 'type', 'version', 'visible', 'props']
/**
* Register an email block definition.
*
* @param {object} def
* @param {string} def.type must match the server type, e.g. 'email.heading'
* @param {number} def.version must match the server schema version
* @param {string} def.label palette display name
* @param {string} def.icon palette icon glyph
* @param {Function} def.editor ({ props, onChange, variables }) => JSX
* @param {Function} def.defaults starting props when the block is added
*/
export function registerEmailBlock(def) {
if (!def || typeof def.type !== 'string' || !def.type.startsWith('email.')) {
throw new Error('registerEmailBlock: a definition needs a type namespaced "email."')
}
if (registry.has(def.type)) {
throw new Error(`registerEmailBlock: block type already registered: ${def.type}`)
}
const entry = {
type: def.type,
version: Number.isInteger(def.version) ? def.version : 1,
label: def.label || def.type,
icon: def.icon || null,
// The one-line description under the palette button. Mail blocks are less
// self-evident than page ones — "Item list" does not say that it repeats over
// a variable — and the palette is where that has to be said.
hint: def.hint || '',
editor: def.editor || null,
defaults: typeof def.defaults === 'function' ? def.defaults : () => ({}),
}
registry.set(entry.type, entry)
return entry
}
/** @returns {object|null} the definition for `type`, or null if unknown. */
export function getEmailBlock(type) {
return registry.get(type) || null
}
/** @returns {object[]} every definition, in registration order — the palette. */
export function listEmailBlocks() {
return [...registry.values()]
}
/**
* A fresh block envelope of `type`, ready to push onto the array.
*
* The id is random rather than sequential because block ids are unique across the
* whole document and an operator can delete block 2 and add another; a counter
* would hand out an id that is already taken and the save would be refused for a
* reason nothing on screen explains.
*/
export function newEmailBlock(type) {
const def = getEmailBlock(type)
if (!def) return null
return {
id: `b${Math.random().toString(36).slice(2, 10)}`,
type: def.type,
version: def.version,
visible: true,
props: def.defaults(),
}
}

View File

@@ -0,0 +1,272 @@
// The six `email.*` block editors, in one file rather than one file each.
//
// The page family gives every block its own module because each carries a React
// RENDERER as well as a form, and those are substantial. An email block carries
// only a form — the rendering is the server's (see ./registry.js) — and six short
// prop panels split across six files would be six imports of the same three
// controls to no benefit.
//
// Every `type` and `version` here pairs with a definition in
// `server/src/emailBlocks/types/`, and the field lists are the server's `onlyKeys`
// lists. Where a server schema has a bound (`MAX_TEXT`, `MAX_LABEL`), the input
// carries the same `maxLength` — not as the check, which is the server's, but so
// that an operator meets the limit while typing rather than at the save.
import { TextField, TextAreaField, SelectField, Field } from '../blocks/editorKit.jsx'
import { registerEmailBlock } from './registry'
/**
* The variable palette, rendered under whichever field is being edited.
*
* Clicking a variable APPENDS its token rather than inserting at the caret. That
* is a deliberate simplification: tracking a caret across a controlled React input
* that a parent may re-render costs a ref and a selection-restore on every change,
* and appending is both predictable and trivially undone. §4.6.2's requirement is
* that inserting a variable "writes a token; it is never free-text" — which this
* satisfies — not that it lands at the cursor.
*/
export function VariablePalette({ variables, onInsert }) {
if (!variables || !variables.length) return null
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
{variables.map((v) => (
<button
key={v.name}
type="button"
className="btn btn-ghost btn-xs"
title={`${v.type || 'string'}${v.required ? ' · required' : ''}${v.description ? ` — ${v.description}` : ''}`}
onClick={() => onInsert(`{{${v.name}}}`)}
style={{ fontFamily: 'monospace', fontSize: '0.72rem', padding: '2px 6px' }}
>
{v.name}
</button>
))}
</div>
)
}
/** A text field with the palette attached — the shape four of the six blocks want. */
function VariableTextField({ label, hint, value, onChange, variables, maxLength, area, rows }) {
const Control = area ? TextAreaField : TextField
return (
<div>
<Control
label={label}
hint={hint}
value={value}
onChange={onChange}
maxLength={maxLength}
rows={rows}
/>
<VariablePalette variables={variables} onInsert={(token) => onChange(`${value || ''}${token}`)} />
</div>
)
}
registerEmailBlock({
type: 'email.heading',
version: 1,
label: 'Heading',
icon: 'H',
hint: 'A section heading, at one of three sizes.',
defaults: () => ({ level: 'h2', text: 'Heading' }),
editor: ({ props, onChange, variables }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<SelectField
label="Size"
// Named "Size" and not "Level" for the reason the server block's header
// gives: mail clients build no outline from a message, so this is
// typography rather than structure, and calling it a level in the UI would
// invite someone to use it as one.
hint="Mail clients build no document outline, so this is a size, not a rank."
value={props.level || 'h2'}
onChange={(level) => onChange({ ...props, level })}
options={[
['h1', 'Large'],
['h2', 'Medium'],
['h3', 'Small'],
]}
/>
<VariableTextField
label="Text"
value={props.text}
maxLength={200}
variables={variables}
onChange={(text) => onChange({ ...props, text })}
/>
</div>
),
})
registerEmailBlock({
type: 'email.text',
version: 1,
label: 'Paragraph',
icon: '¶',
hint: 'A paragraph of body text.',
defaults: () => ({ text: 'Write your message here.', muted: false }),
editor: ({ props, onChange, variables }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<VariableTextField
label="Text"
area
rows={5}
value={props.text}
maxLength={4000}
variables={variables}
onChange={(text) => onChange({ ...props, text })}
/>
<Field label="Style">
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={Boolean(props.muted)}
onChange={(e) => onChange({ ...props, muted: e.target.checked })}
/>
<span>Quieter — for footnotes and small print</span>
</label>
</Field>
</div>
),
})
registerEmailBlock({
type: 'email.button',
version: 1,
label: 'Button / link',
icon: '▭',
hint: 'The call to action. Its plain-text form is a sentence plus the URL.',
defaults: () => ({ label: 'Open', url: '/', textLead: 'Open it here:' }),
editor: ({ props, onChange, variables }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<TextField
label="Button text"
value={props.label}
maxLength={80}
onChange={(label) => onChange({ ...props, label })}
/>
<VariableTextField
label="Link"
hint="Usually a variable, so the link is built for each recipient."
value={props.url}
maxLength={600}
variables={variables}
onChange={(url) => onChange({ ...props, url })}
/>
<TextField
label="Plain-text lead-in"
// The server block's header is worth repeating here in one line, because
// this field looks optional and is the difference between a bare URL and a
// sentence in every text-only inbox.
hint="A button is nothing in plain text. This sentence introduces the link there, e.g. “Choose a new password here:”."
value={props.textLead}
maxLength={200}
onChange={(textLead) => onChange({ ...props, textLead })}
/>
</div>
),
})
registerEmailBlock({
type: 'email.divider',
version: 1,
label: 'Divider',
icon: '—',
hint: 'A horizontal rule.',
defaults: () => ({}),
editor: () => (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
A divider has nothing to configure.
</p>
),
})
registerEmailBlock({
type: 'email.image',
version: 1,
label: 'Image',
icon: '▣',
hint: 'An image by URL. Many clients block images until the reader allows them.',
defaults: () => ({ url: '/brand/logo.png', alt: 'Logo' }),
editor: ({ props, onChange, variables }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<VariableTextField
label="Image URL"
value={props.url}
maxLength={600}
variables={variables}
onChange={(url) => onChange({ ...props, url })}
/>
<TextField
label="Alt text"
hint="Most mail clients block images by default, so for many readers this IS the image."
value={props.alt}
maxLength={200}
onChange={(alt) => onChange({ ...props, alt })}
/>
<Field label="Width" hint="Pixels, 16-560. Leave blank to let the image size itself.">
<input
type="number"
className="input"
min={16}
max={560}
value={props.width ?? ''}
// Blank REMOVES the prop rather than setting it to 0. The server accepts
// `width` absent or between 16 and 560, so a 0 left behind by an empty
// field is a refused save whose message names a field the operator
// believes they cleared.
onChange={(e) => {
const next = { ...props }
const value = Number(e.target.value)
if (!e.target.value || !Number.isFinite(value)) delete next.width
else next.width = Math.trunc(value)
onChange(next)
}}
/>
</Field>
</div>
),
})
registerEmailBlock({
type: 'email.itemList',
version: 1,
label: 'Item list',
icon: '☰',
hint: 'Repeats over a list variable — this is how a digest lists its items.',
defaults: () => ({ variable: '', emptyText: '' }),
editor: ({ props, onChange, variables }) => {
// Only LIST variables may be chosen, and the field is a select rather than a
// text input because this prop is a bare NAME, not a token: a typo here is the
// one variable reference a reader of the template cannot see is wrong, and it
// renders as an empty mail rather than as a visible gap.
const lists = (variables || []).filter((v) => v.type === 'list' || v.type === 'array')
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{lists.length ? (
<SelectField
label="List variable"
hint="Each item becomes a row with its heading, excerpt and link."
value={props.variable || ''}
onChange={(variable) => onChange({ ...props, variable })}
options={[['', 'Choose a list…'], ...lists.map((v) => [v.name, v.name])]}
/>
) : (
<Field label="List variable">
<p className="sans dim" style={{ fontSize: '0.85rem', margin: 0 }}>
This template’s trigger declares no list variable, so an item list has nothing to
repeat over. Point the template at a trigger that declares one — a digest, typically —
or use paragraphs instead.
</p>
</Field>
)}
<TextField
label="When the list is empty"
hint="Shown instead of the list. Leave blank to show nothing at all."
value={props.emptyText}
maxLength={200}
onChange={(emptyText) => onChange({ ...props, emptyText })}
/>
</div>
)
},
})

View File

@@ -0,0 +1,348 @@
// What the Engagement screens say, and what they let an operator choose.
//
// ENGAGEMENT.md Phase 4b. Plain JS in its own file for the reason
// `lib/moduleAdmin.js` is: it is the part of these two screens worth testing, and
// the test runner cannot reach a `.jsx`.
//
// **None of this is a boundary.** `engagementRules.model.js` on the server
// decides what may be saved, and the engine re-checks the audience ceiling again
// at send time. Everything here is an affordance — not offering a choice the
// server is going to refuse, and saying why in the form rather than in a toast.
// The two copies are expected to drift, which is why the server's is the one
// that decides.
//
// The one rule worth stating out loud, because it is the reason the audience
// list is derived rather than hardcoded: **the ceiling vocabulary comes from the
// server** (`GET /admin/engagement/triggers` serves `ceilings`, each with the set
// it `permits`). A second copy of the lattice in the client would be a second
// copy of a security rule, and a second copy is a copy that drifts.
/** A rule row as the API returns it → the shape the form edits. */
export function formFromRule(rule) {
return {
id: rule?.id ?? null,
triggerId: rule?.trigger_id ?? '',
name: rule?.name ?? '',
enabled: Boolean(rule?.enabled),
audience: rule?.audience ?? 'owner',
audienceSegmentId: rule?.audience_segment_id ?? null,
channels: Array.isArray(rule?.channels) ? [...rule.channels] : [],
templateKeys: { ...(rule?.template_keys || {}) },
conditions: rule?.conditions ?? null,
cooldownSeconds: Number(rule?.cooldown_seconds ?? 0),
delaySeconds: Number(rule?.delay_seconds ?? 0),
cancelOn: Array.isArray(rule?.cancel_on) ? [...rule.cancel_on] : [],
maxSendsPerHour: Number(rule?.max_sends_per_hour ?? 100),
}
}
/**
* The form → a POST/PUT body.
*
* `templateKeys` is filtered to the rule's channels rather than sent whole,
* because unticking a channel in the form leaves its template key behind and the
* server refuses a key naming a channel the rule does not have. Dropping it here
* makes unticking a channel do the obvious thing instead of producing an error
* about a field the operator cannot see.
*/
export function ruleToPayload(form) {
const channels = [...new Set(form.channels || [])]
const templateKeys = {}
for (const channel of channels) {
const key = (form.templateKeys || {})[channel]
if (key) templateKeys[channel] = key
}
return {
triggerId: form.triggerId,
name: (form.name || '').trim(),
enabled: Boolean(form.enabled),
audience: form.audience,
audienceSegmentId: form.audienceSegmentId ?? null,
channels,
templateKeys,
conditions: form.conditions ?? null,
cooldownSeconds: Number(form.cooldownSeconds) || 0,
delaySeconds: Number(form.delaySeconds) || 0,
cancelOn: [...new Set(form.cancelOn || [])],
maxSendsPerHour: Number(form.maxSendsPerHour) || 100,
}
}
/**
* Which plain audiences this trigger's ceiling allows, in lattice order.
*
* Derived from the `permits` list the server sends with each ceiling, so a
* trigger declared `owner` offers only `owner` and the editor never presents a
* choice the save is going to refuse. An unknown trigger (a dormant rule whose
* module is gone) offers nothing rather than everything — failing closed is the
* same posture `ceilings.permits` takes on the server.
*/
export function audienceChoicesFor(trigger, ceilings) {
if (!trigger || !Array.isArray(ceilings)) return []
const declared = ceilings.find((c) => c.id === trigger.ceiling)
if (!declared) return []
const allowed = new Set(declared.permits || [])
return ceilings.filter((c) => allowed.has(c.id))
}
/** Segments a rule under this trigger may point at — the same test, on the stored ceiling. */
export function segmentChoicesFor(trigger, ceilings, segments) {
const allowed = new Set(audienceChoicesFor(trigger, ceilings).map((c) => c.id))
return (segments || []).filter((s) => allowed.has(s.ceiling))
}
/**
* The sentence rendered beside a reach preview.
*
* Every branch here exists because the bare number would be a lie in that case:
* a capped count is a floor, an `owner` audience has no advance answer, a dormant
* segment resolves to nobody for a reason worth naming, and a count the trigger's
* ceiling forbids is a number the save is about to refuse.
*/
export function describeReach(preview) {
if (!preview) return ''
const why = operatorWords(preview.reason)
if (preview.dormant) return `Resolves to nobody right now — ${why || 'dormant'}.`
if (preview.permitted === false) {
return `Reaches ${preview.count}, but this trigger does not permit that audience — saving will be refused.`
}
if (why) return `${preview.count} right now — ${why}.`
if (preview.capped) return `At least ${preview.count} people (the preview stops counting there).`
return preview.count === 1 ? '1 person right now.' : `${preview.count} people right now.`
}
/**
* The server says "segment"; these screens say "saved audience".
*
* The API, the schema and the docs all call it a segment and should keep doing
* so - it is one word for one table. But an operator meets the concept here,
* under a heading that says "Audiences", and a sentence that switches vocabulary
* mid-screen reads as a sentence about something else.
*/
export function operatorWords(text) {
if (!text) return text
// Word-wise rather than a regex, so "segmented" and the like are left alone.
const swap = { segment: 'saved audience', segments: 'saved audiences' }
return String(text)
.split(' ')
.map((word) => swap[word] || word)
.join(' ')
}
/**
* The one audience choice that silently reaches nobody, said out loud.
*
* `members` is the ceiling for "a module-declared list". Without a saved
* audience naming WHICH list there is no list, and core knows no game vocabulary
* with which to guess - so the rule resolves to the empty set every time it
* fires. It is also the DEFAULT the moment an operator picks a `members`-ceiling
* trigger, which is what makes it a trap rather than a curiosity: the rule saves,
* switches on, and mails nobody, with nothing on the screen saying so unless the
* operator happens to press Preview.
*
* Returns a sentence, or null when there is nothing to warn about.
*/
export function audienceWarning(form) {
if (!form) return null
if (form.audienceSegmentId) return null
if (form.audience === 'members') {
return 'This reaches nobody as it stands. “Members of a module-declared list” needs a saved audience naming which list.'
}
return null
}
// ── Segment expressions ────────────────────────────────────────────────────
/**
* `not` is legal only as a child of `and` — the server's rule, checked here so
* the composer can grey the button out instead of letting the operator build
* something and then be refused.
*
* The reason, from §5.1a: a complement needs a universe, and the only one that
* does not widen is the set its siblings produced. `A AND NOT B` is "A, less B".
* A bare `NOT B`, or `A OR NOT B`, would have to mean "everyone except…", which
* is a way to build the whole deployment out of one narrow audience.
*/
export function notPlacementError(expression) {
const walk = (node, underAnd) => {
if (!node || typeof node !== 'object') return null
if (!node.op) return null
if (node.op === 'not' && !underAnd) {
return 'An excluded audience can only be used alongside an included one — on its own it would mean “everyone except…”.'
}
// The same rule from the other side: a group of nothing but exclusions has
// no set to take them from. The composer offers "exclude" on every row, so
// this is one checkbox away at all times and is worth saying before the
// round trip - the server refuses it, correctly, but only after a save.
if ((node.op === 'and' || node.op === 'or') && (node.nodes || []).length) {
if ((node.nodes || []).every((c) => c && c.op === 'not')) {
return 'At least one audience has to be included — a list made only of exclusions has nothing to exclude from.'
}
}
for (const child of node.nodes || []) {
const err = walk(child, node.op === 'and')
if (err) return err
}
return null
}
return walk(expression, false)
}
/** A one-line summary of a segment expression, for the list. */
export function describeExpression(node, audiencesById = {}) {
if (!node || typeof node !== 'object') return '—'
if (!node.op) {
const label = audiencesById[node.audienceId]?.label || node.audienceId
const params = Object.entries(node.params || {})
return params.length ? `${label} (${params.map(([k, v]) => `${k}: ${v}`).join(', ')})` : label
}
const parts = (node.nodes || []).map((n) => describeExpression(n, audiencesById))
if (node.op === 'not') return `not ${parts.join(', ')}`
return parts.join(node.op === 'and' ? ' and ' : ' or ')
}
/**
* The one-line summary of a rule, for the list.
*
* `dormant` is deliberately not folded in here — the list renders that as its own
* badge, because "this rule cannot fire" is a different fact from "this is what
* the rule says" and an operator needs both.
*/
export function describeRule(rule, { segmentsById = {} } = {}) {
const parts = []
const audience = rule.audience_segment_id
? segmentsById[rule.audience_segment_id]?.name || `segment ${rule.audience_segment_id}`
: rule.audience
parts.push(`to ${audience}`)
parts.push(`via ${(rule.channels || []).join(', ') || 'no channel'}`)
if (rule.delay_seconds) parts.push(`after ${humanSeconds(rule.delay_seconds)}`)
if (rule.cooldown_seconds) parts.push(`at most once per ${humanSeconds(rule.cooldown_seconds)}`)
parts.push(`≤ ${rule.max_sends_per_hour}/hour`)
return parts.join(' · ')
}
// ── Conditions ─────────────────────────────────────────────────────────────
//
// The stored grammar is and/or/not over comparisons; the editor offers the flat
// half of it — one and/or over a list of comparisons — because that is what a
// dropdown-per-operator can render honestly and it covers the rules anyone
// writes by hand.
//
// **A tree the editor cannot render is shown, not silently flattened.**
// Flattening `A AND (B OR C)` into `A AND B AND C` changes which events fire the
// rule, and the operator would have no way to know the save had done it. Such a
// rule opens read-only with its JSON visible and one honest choice: leave it, or
// clear it and start again.
/** Which comparison operators apply to a variable of this declared type? */
export function operatorsForType(operators, type) {
return (operators || []).filter((o) => !type || (o.types || []).includes(type))
}
/**
* A stored conditions tree → the flat rows the editor edits.
*
* `editable: false` means "this file will not pretend it can round-trip that",
* and the screen renders the tree read-only rather than losing part of it.
*/
export function conditionRowsFrom(conditions) {
if (!conditions) return { op: 'and', rows: [], editable: true }
if (conditions.cmp) return { op: 'and', rows: [rowFrom(conditions)], editable: true }
if (conditions.op === 'and' || conditions.op === 'or') {
const children = conditions.nodes || []
if (children.every((n) => n && n.cmp)) {
return { op: conditions.op, rows: children.map(rowFrom), editable: true }
}
}
return { op: 'and', rows: [], editable: false }
}
const rowFrom = (node) => ({
variable: node.variable,
cmp: node.cmp,
// A list operator's value arrives as an array and is edited as comma-separated
// text; everything else is edited as the literal it is.
value: Array.isArray(node.value) ? node.value.join(', ') : node.value === undefined ? '' : String(node.value),
})
/**
* The editor's rows → a conditions tree, with each literal coerced to the type
* the trigger DECLARED for that variable.
*
* The coercion is the point. Every value in an HTML input is a string, and the
* server refuses `{ cmp: 'gt', value: "5" }` against an `int` variable — rightly,
* because a rule whose comparison silently compares a number to a string is a
* rule that quietly never fires. Doing it here means the form's error is about
* something the operator typed rather than about JSON.
*/
export function conditionsFromRows(op, rows, variables) {
const byName = Object.fromEntries((variables || []).map((v) => [v.name, v]))
const nodes = (rows || [])
.filter((r) => r.variable && r.cmp)
.map((r) => {
const type = byName[r.variable]?.type || 'string'
const node = { variable: r.variable, cmp: r.cmp }
if (r.cmp === 'present' || r.cmp === 'absent') return node
if (r.cmp === 'in' || r.cmp === 'nin') {
node.value = String(r.value ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => coerceLiteral(type, s))
} else {
node.value = coerceLiteral(type, r.value)
}
return node
})
if (!nodes.length) return null
if (nodes.length === 1) return nodes[0]
return { op, nodes }
}
/**
* One typed literal out of one string.
*
* A value that does not parse is passed through UNCHANGED rather than turned
* into `NaN` or `false`: the server's type check will then refuse it and name the
* variable, which is a better error than a rule that saves cleanly and compares
* against a number the operator never typed.
*/
export function coerceLiteral(type, raw) {
if (raw === null || raw === undefined) return raw
const text = typeof raw === 'string' ? raw.trim() : raw
switch (type) {
case 'int': {
const n = Number(text)
return Number.isInteger(n) && text !== '' ? n : text
}
case 'float': {
const n = Number(text)
return Number.isFinite(n) && text !== '' ? n : text
}
case 'boolean': {
if (text === true || text === 'true') return true
if (text === false || text === 'false') return false
return text
}
default:
return text
}
}
/** Seconds as the coarsest exact unit — 3600 is "1 hour", 3660 is "61 minutes". */
export function humanSeconds(seconds) {
const n = Number(seconds) || 0
if (n === 0) return 'none'
const units = [
[86_400, 'day'],
[3_600, 'hour'],
[60, 'minute'],
]
for (const [size, name] of units) {
if (n % size === 0) {
const count = n / size
return `${count} ${name}${count === 1 ? '' : 's'}`
}
}
return `${n} seconds`
}

View File

@@ -0,0 +1,21 @@
// Where a given account's notification screens live.
//
// **Staff and players reach the same two screens at different paths, and that is
// this file's whole reason to exist.** `/auth/me/notifications` is role-agnostic
// — behind `requireAuth` only, like every other `/auth/me` route — but the WEB
// has two logged-in shells: `RequirePlayer` sends anyone who is not a player to
// the admin area, where staff manage their own account under `/admin/account`.
// So a bell that always pointed at `/account/notifications` would, for every
// staff member, point at a page that redirects.
//
// Discovered in the Phase 7 rig: signed in as an admin, the inbox was simply
// unreachable on the web. Two routes, one pair of components, one mapping here.
export const isStaff = (user) => !!(user && user.role && user.role !== 'player')
/** The inbox — what the bell opens. */
export const inboxPath = (user) => (isStaff(user) ? '/admin/notifications' : '/account/notifications')
/** The per-channel preferences screen. */
export const notificationSettingsPath = (user) =>
isStaff(user) ? '/admin/notifications/settings' : '/account/notifications/settings'

View File

@@ -0,0 +1,103 @@
// What Admin → Teams → Notification bridge decides (TEAMS.md §7.2, phase 8).
//
// The view is a form; these are the rules it applies, extracted for the same
// reason `teamAdmin.js` is: the interesting parts are decisions — when the
// acknowledgement dialog opens, and when a standing acknowledgement stops being
// valid — and a decision embedded in JSX is one nothing can assert on.
//
// **The rules here MIRROR the server's and do not replace them.** The server
// refuses to enable a members-only bridge without the acknowledgement (422)
// whether or not this file ever ran. What is here is so the screen agrees with
// that answer before making the round trip, rather than showing an operator a
// save that fails for a reason the form did not mention.
// Wording an operator reads, per event id the server offers. Presentation, so it
// lives on this side; the one bit that is policy — which events are members-only —
// comes from the server with each event.
export const EVENT_LABELS = {
'team.member.joined': 'New members joined',
'team.leadership.changed': 'Leadership changed',
'team.forum.post': 'New forum post',
'team.announcement': 'Announcement posted',
}
export const eventLabel = (id) => EVENT_LABELS[id] || id
/** A row's identity in a list. `null` and `undefined` are both the default row. */
export const rowKey = (row) =>
(row.team_id === null || row.team_id === undefined ? 'default' : String(row.team_id))
export const isDefaultRow = (row) => row.team_id === null || row.team_id === undefined
export const blankDraft = (teamId = null) => ({
teamId,
events: [],
channelRef: '',
enabled: false,
membersAck: false,
})
export const draftFrom = (row) => ({
teamId: row.team_id ?? null,
events: row.events || [],
channelRef: row.channel_ref || '',
enabled: !!row.enabled,
membersAck: !!row.members_ack,
})
export function appliesToLabel(row, fallback = 'All Teams') {
if (isDefaultRow(row)) return fallback
return row.display_name_override || row.team_name || `Team #${row.team_id}`
}
/** Toggle one event in a draft, preserving order of first selection. */
export const toggleEvent = (draft, id) => ({
...draft,
events: draft.events.includes(id) ? draft.events.filter((e) => e !== id) : [...draft.events, id],
})
/**
* Repointing the row drops a standing acknowledgement, in the SAME place the
* server does.
*
* Leaving the tick showing while the server has already decided to clear it is
* the one way this screen could actively mislead: an operator repoints a row at a
* public channel, sees "members-only destination confirmed" still ticked, and
* believes the confirmation they gave for a private channel covers the new one.
*/
export function setChannel(draft, channelRef) {
if (channelRef === draft.channelRef) return draft
return { ...draft, channelRef, membersAck: false }
}
/** Does this draft carry anything that would publish members-only text? */
export const carriesMembersOnly = (draft, membersOnlyIds) =>
draft.events.some((id) => membersOnlyIds.includes(id))
/**
* Should saving stop and ask first?
*
* Only when ENABLING. A draft that carries forum events but is switched off is a
* configuration being written, not a channel being published to — asking then
* would make an operator confirm something they have not decided to do yet, which
* is how a confirmation dialog becomes a thing people click through.
*/
export const needsAcknowledgement = (draft, membersOnlyIds) =>
!!draft.enabled && carriesMembersOnly(draft, membersOnlyIds) && !draft.membersAck
/** The ids of every event the server flagged as members-only. */
export const membersOnlyIdsOf = (events) => (events || []).filter((e) => e.membersOnly).map((e) => e.id)
/**
* Which Teams may still be given an override, and whether the default is taken.
*
* Offering a Team that already has a row would only produce a save that silently
* overwrote it, since the unique key is (platform, team).
*/
export function availableTargets(rows, teams) {
const taken = new Set(rows.filter((r) => !isDefaultRow(r)).map((r) => r.team_id))
return {
hasDefault: rows.some(isDefaultRow),
teams: (teams || []).filter((t) => t.status === 'active' && !taken.has(t.id)),
}
}

112
client/src/lib/teamVoice.js Normal file
View File

@@ -0,0 +1,112 @@
// What Admin → Teams → Voice channels decides (TEAMS.md §7.3, phase 9).
//
// Extracted for the reason `teamIntegrations.js` is: the interesting parts are
// decisions — when the panel refuses to let voice be switched on, how close the
// guild is to running out of roles, what a row's state actually means to the
// person reading it — and a decision written inline in JSX is one nothing can
// assert on.
//
// **These rules MIRROR the server's and do not replace them.** The server refuses
// to enable voice while the bot cannot manage channels and roles (422) whether or
// not this file ever ran, and the reconciler applies the threshold and the grace
// window regardless of what the screen says. What is here is so the screen agrees
// with those answers before making the round trip.
/** Wording for each state the server can report on a row. */
export const STATE_LABELS = {
none: 'Not provisioned',
active: 'Active',
pending_removal: 'Scheduled for removal',
error: 'Error',
}
export const stateLabel = (state) => STATE_LABELS[state] || state || 'Unknown'
/**
* Is the panel allowed to offer the enable switch?
*
* The preflight answers three separate questions and they fail differently: the
* bot is not connected at all, it is connected but missing a permission, or it
* could not be reached. An operator can act on each of those and they need
* different actions, so the reason is passed through rather than flattened to a
* boolean.
*/
export function enableBlockedReason(preflight) {
if (!preflight) return 'The bot’s status is unknown.'
if (!preflight.connected) return preflight.reason || 'The Discord bot is not connected.'
if (preflight.missingPermissions && preflight.missingPermissions.length > 0) {
return `The bot is missing ${preflight.missingPermissions.join(' and ')} in this guild.`
}
if (!preflight.ready) return preflight.reason || 'The bot cannot manage channels and roles yet.'
return null
}
// Below this many free roles the panel starts saying so. Not a server rule and
// deliberately not one: it is a warning, and the server's only hard behaviour is
// to refuse the create that would exceed the cap.
const HEADROOM_WARNING = 25
/**
* How much room is left, and whether to say something about it.
*
* The 250-role cap is the ceiling this phase's shape brings with it. Access is a
* per-Team role, so it is not "how big can a Team be" — the old overwrite design's
* limit — but "how many Teams can have voice at all", and the difference matters
* to an operator with sixty guilds on their shard. It is guild-wide and shared
* with every role they created themselves, which is why the count comes from the
* bot rather than from core's own rows.
*/
export function roleHeadroom(preflight) {
if (!preflight || !preflight.roleCap) return null
const used = Number(preflight.roleCount) || 0
const cap = Number(preflight.roleCap)
const free = Math.max(0, cap - used)
return { used, cap, free, tight: free <= HEADROOM_WARNING, exhausted: free === 0 }
}
/** How a row's grace window reads while it is running. */
export function removalCountdown(row, now = new Date()) {
if (!row || row.state !== 'pending_removal' || !row.removeAfter) return null
const ms = new Date(row.removeAfter).getTime() - now.getTime()
if (ms <= 0) return 'due for removal on the next pass'
const days = Math.floor(ms / 86400000)
if (days >= 1) return `in ${days} day${days === 1 ? '' : 's'}`
const hours = Math.max(1, Math.round(ms / 3600000))
return `in ${hours} hour${hours === 1 ? '' : 's'}`
}
/**
* Parse the staff-role field an operator types.
*
* Comma-separated ids, because that is what a person copying role ids out of
* Discord ends up with. Validated rather than filtered, mirroring the server: a
* quietly dropped id is a settings screen showing a save that did not happen.
*/
export function parseStaffRoles(text) {
const parts = String(text || '')
.split(',')
.map((part) => part.trim())
.filter(Boolean)
const bad = parts.filter((part) => !/^[0-9]{5,32}$/.test(part))
return { roles: parts, invalid: bad }
}
export const formatStaffRoles = (roles) => (roles || []).join(', ')
/**
* The sentence under the enable switch, which changes meaning with the state.
*
* "Off" is not "nothing is provisioned": switching voice off suspends the
* reconciler in BOTH directions and leaves existing channels in place, which is
* deliberate — a checkbox must not delete structure in somebody's guild — but it
* is also surprising unless the screen says so.
*/
export function statusSummary(settings, rows) {
const provisioned = (rows || []).filter((row) => row.channelRef).length
if (!settings || !settings.enabled) {
return provisioned > 0
? `Off. ${provisioned} channel${provisioned === 1 ? '' : 's'} remain in Discord and are no longer being kept in step — remove them below if they are not wanted.`
: 'Off. No channels are provisioned.'
}
return `On. Teams with at least ${settings.minMembers} member${settings.minMembers === 1 ? '' : 's'} get a voice channel and a role; ${provisioned} provisioned.`
}

View File

@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js'
import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js'
import { declareSlot, applyCoreFills, offerCoreFill } from './modules/registry.js'
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
import TeamForumPanel from './modules/TeamForumPanel.jsx'
import TeamNotifyToggle from './modules/TeamNotifyToggle.jsx'
@@ -72,24 +72,33 @@ declareSlot('player.invite.accepted')
// module chunk has evaluated, which is the only moment a module-declared slot
// exists to be filled.
//
// Naming a slot no installed module declares is not an error. On a deployment
// with no game module this fill simply never lands, which is the mirror of an
// **Core offers a CONTRIBUTION and never names a slot.** The module that owns the
// page says where each of these goes, in its own vocabulary, by asking for one on
// `declareModuleSlot`. Naming the slots here instead — which is how this was first
// written — meant core's Team content reached exactly one module: any other game
// declaring a place under its own id got an empty page and no error, because a
// fill nobody asked for is deliberately not an error. It also put a module id
// inside core, in string literals `scripts/checkModuleIdentifiers.js` masks by
// construction and so could never have caught.
//
// Offering something nothing asks for is still not an error: a deployment with no
// game module installed asks for none of these, which is the mirror of an
// unfilled slot rendering nothing.
fillModuleSlot('uo.guild.detail', TeamActivityFeed)
offerCoreFill('team.activity', TeamActivityFeed)
// The forum is core's for the same reason and goes in a SECOND place the module
// declares, rather than joining the feed in the first: a slot takes one component
// (first fill wins), and stacking two unrelated panels into one fill would make
// the module unable to place them separately on its own page. It also keeps the
// two independent — a deployment with the forum switched off renders the feed
// exactly as before.
fillModuleSlot('uo.guild.forum', TeamForumPanel)
// The forum is core's for the same reason and goes wherever the module asked for
// it — a SECOND place, in module-uo's case, rather than joining the feed in the
// first: a slot takes one component (first fill wins), and stacking two unrelated
// panels into one contribution would make the module unable to place them
// separately on its own page. It also keeps the two independent — a deployment
// with the forum switched off renders the feed exactly as before.
offerCoreFill('team.forum', TeamForumPanel)
// And the notification control, in a third place the module declares ABOVE its
// roster. A third slot rather than a corner of the feed for the same reason there
// were two: this is an action on the page and the other two are content in it,
// and only the module can say where each belongs on a page it owns.
fillModuleSlot('uo.guild.header', TeamNotifyToggle)
// And the notification control. A third contribution rather than a corner of the
// feed for the same reason there were two: this is an action on the page and the
// other two are content in it, and only the module can say where each belongs on
// a page it owns.
offerCoreFill('team.notify', TeamNotifyToggle)
// Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes.

View File

@@ -96,7 +96,7 @@ export default function TeamNotifyToggle({ externalId, moduleId }) {
{/* The one link off this control, because "mute" is a blunt answer to a
question the account screen asks properly — which streams, and whether
email is on at all. */}
<Link to="/account/notifications" className="dim">All notification settings</Link>
<Link to="/account/notifications/settings" className="dim">All notification settings</Link>
</div>
)
}

View File

@@ -135,6 +135,37 @@ export function declareSlot(name) {
slots.set(name, { Component: null, filledBy: null })
}
/**
* The contributions core has for a module-declared slot.
*
* **Core offers a CONTRIBUTION, not a slot name, and that is the whole of why
* this list exists.** The first cut of the inverted direction had core fill three
* literal names — `uo.guild.detail` and its two siblings — which worked for
* exactly one module and silently did nothing for any other: a second game
* declaring `clan.detail` under its own id got an empty page and no error,
* because "a fill for a slot nobody declared is not an error" is the rule that
* makes an unknown name invisible. It also put a module identifier in core, in
* three string literals `scripts/checkModuleIdentifiers.js` cannot see, since it
* masks string bodies by construction.
*
* So the module says WHERE (its own slot, in its own vocabulary) and WHICH of
* core's contributions goes there. Core never names a module id.
*
* Adding a member here is a **minor** MODULE_API bump. Requesting one that is not
* here THROWS at the declaration, deliberately: unlike an unfilled slot, an
* unknown contribution is always a typo or a version skew — core's list is fixed
* at build time and a module's `coreApi` range has already been checked — and the
* failure it would otherwise produce is a page that renders empty forever.
*/
export const CORE_CONTRIBUTIONS = Object.freeze({
/** The Team activity feed. Core's because only core can resolve the public/members split on it. */
'team.activity': true,
/** The Team forum panel. Core's because membership and manual grants are core's rules. */
'team.forum': true,
/** The per-Team notification control. Core's because it resolves whether the viewer is in the Team. */
'team.notify': true,
})
/**
* The INVERTED direction: a MODULE declares a slot and CORE fills it.
*
@@ -153,47 +184,67 @@ export function declareSlot(name) {
* what stops two modules colliding and what makes the owner readable at the fill
* site. The namespace is enforced rather than conventional.
*
* **`options.core` names which of core's contributions belongs in that place.**
* It is optional — a module may declare a slot it fills itself, or one it keeps
* empty for now — and it is the only thing that gets core's content into the
* page. The place name stays the module's own word; the contribution is core's.
*
* **Ordering is why this is a separate call and not just `declareSlot` exposed
* to modules.** Core's bundle evaluates BEFORE any module chunk (module scripts
* are deferred and injected after core's), so at the moment core would like to
* fill one of these, it does not exist yet. Core therefore registers its fills
* through `fillModuleSlot` below, which is applied after every module chunk has
* evaluated — see main.jsx.
* fill one of these, it does not exist yet. Core therefore offers its
* contributions through `offerCoreFill` below, applied after every module chunk
* has evaluated — see main.jsx.
*/
export function declareModuleSlot(id, name) {
export function declareModuleSlot(id, name, options = {}) {
if (!name.startsWith(`${id}.`)) {
throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
}
if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`)
slots.set(name, { Component: null, filledBy: null, declaredBy: id })
const contribution = options.core ?? null
if (contribution !== null && !Object.hasOwn(CORE_CONTRIBUTIONS, contribution)) {
throw new Error(
`declareModuleSlot: "${name}" asks for core contribution "${contribution}", which core does not ` +
`offer. Known: ${Object.keys(CORE_CONTRIBUTIONS).join(', ')}.`,
)
}
slots.set(name, { Component: null, filledBy: null, declaredBy: id, wants: contribution })
}
// Core's pending fills for module-declared slots, applied once every module
// chunk has evaluated. Kept as a list rather than applied eagerly because the
// slot does not exist when core asks — see the ordering note above.
// Core's pending contributions, applied once every module chunk has evaluated.
// Kept as a list rather than applied eagerly because no module-declared slot
// exists when core offers — see the ordering note above.
const coreFills = []
/**
* Core: "fill this module-declared slot when it turns up."
* Core: "here is my <contribution>, for whichever module asked for it."
*
* Deliberately not an error when the slot never appears. A module that is not
* installed declares nothing, and core offering content for a page that does not
* exist is the ordinary case on any deployment — not a misconfiguration. That is
* the mirror of an unfilled slot rendering nothing.
* Deliberately not an error when nothing asked. A deployment with no game module
* installed asks for none of these, and core offering content for a page that
* does not exist is the ordinary case rather than a misconfiguration — the mirror
* of an unfilled slot rendering nothing.
*
* More than one slot may ask for the same contribution, and each gets it. Core
* has no reason to care how many places a module wants its feed in, and refusing
* the second would be core making a layout decision on a page it does not own.
*/
export function fillModuleSlot(name, Component) {
if (typeof Component !== 'function') throw new Error(`fillModuleSlot: ${name} is not a component`)
coreFills.push([name, Component])
export function offerCoreFill(contribution, Component) {
if (!Object.hasOwn(CORE_CONTRIBUTIONS, contribution)) {
throw new Error(`offerCoreFill: "${contribution}" is not in CORE_CONTRIBUTIONS`)
}
if (typeof Component !== 'function') throw new Error(`offerCoreFill: ${contribution} is not a component`)
coreFills.push([contribution, Component])
}
/** Apply core's fills. Called once from main.jsx, after module chunks have run. */
/** Apply core's contributions. Called once from main.jsx, after module chunks have run. */
export function applyCoreFills() {
for (const [name, Component] of coreFills) {
const entry = slots.get(name)
if (!entry) continue // the declaring module is not installed
if (entry.filledBy) continue // a module already claimed it; first fill wins
entry.Component = Component
entry.filledBy = 'core'
for (const [contribution, Component] of coreFills) {
for (const entry of slots.values()) {
if (entry.wants !== contribution) continue
if (entry.filledBy) continue // a module already claimed it; first fill wins
entry.Component = Component
entry.filledBy = 'core'
}
}
coreFills.length = 0
}

View File

@@ -67,7 +67,7 @@ const ui = {
useAsync,
useAuth,
useSite,
// The eighth member, for the INVERTED slot direction (TEAMS.md Part 3). A
// The ninth member, for the INVERTED slot direction (TEAMS.md Part 3). A
// module that declares a slot on its own page needs the same component core
// renders its own with — the error boundary in particular, since the thing
// being contained here is CORE's content failing inside the MODULE's page.

View File

@@ -11,6 +11,19 @@
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
// 1.8.0 - the ceiling lattice gains `admin` (ENGAGEMENT.md Phase 11). Nothing on
// this half changed: a ceiling is declared on the server's `api` and enforced
// there, and the admin screens that render one read the vocabulary from
// `GET /admin/engagement/triggers` rather than holding a copy. This file bumps
// anyway, for the reason at the top - the two halves state ONE version.
// 1.7.0 — the engagement contract (docs/website/ENGAGEMENT.md Phase 2). Nothing
// on this half changed: every member the version adds is on the server's `api`
// and `ctx` (registerEventTriggers, registerAudiences, ctx.events.emit,
// ctx.inbox.push). This file bumps anyway, for the reason at the top — the two
// halves state ONE version, and a module declares one `coreApi` range against
// both. The web surfaces the engagement system needs (the rules and template
// editors, the in-app inbox) land in Phases 4, 5 and 7 and will add to this half
// then.
// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Nothing on this half
// changed yet: the two client additions the version covers are the `team.overview`
// and `team.member.row` slots, and a slot can only be declared by the page that
@@ -45,4 +58,4 @@
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
export const MODULE_API_VERSION = '1.6.0'
export const MODULE_API_VERSION = '1.8.0'

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import NotificationBell from '../../components/NotificationBell.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js'
@@ -43,9 +44,15 @@ const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
const IconModules = () => <Icon><path d="M12 3l8 4.5-8 4.5-8-4.5z" /><path d="M4 12l8 4.5 8-4.5" /><path d="M4 16.5L12 21l8-4.5" /></Icon>
const IconMail = () => <Icon><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M3.5 6.5L12 13l8.5-6.5" /></Icon>
const IconList = () => <Icon><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1.2" /><circle cx="4" cy="12" r="1.2" /><circle cx="4" cy="18" r="1.2" /></Icon>
const IconTemplate = () => <Icon><rect x="4" y="3" width="16" height="18" rx="2" /><path d="M8 8h8M8 12h8M8 16h4" /></Icon>
const IconSpark = () => <Icon><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" /><path d="M18 16l.9 2.1L21 19l-2.1.9L18 22l-.9-2.1L15 19l2.1-.9z" /></Icon>
const IconLog = () => <Icon><path d="M4 5h16v14H4z" /><path d="M8 9h8M8 12h8M8 15h5" /></Icon>
// Nav is grouped into collapsible categories. A group with no `title` renders
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
@@ -89,6 +96,25 @@ export const NAV = [
{ to: '/admin/teams', label: 'Teams', icon: IconUsers, roles: ['admin', 'moderator'] },
],
},
{
// Its own top-level group (ENGAGEMENT.md §7.1 Q4), not a section of
// Settings. Settings is already one long page of sections, and these six
// screens are two editors, a catalog and two paged tables, none of which is
// a settings section. Email Delivery stays under Settings: configuring a
// transport is not the same job as deciding who gets mail.
title: 'Engagement',
items: [
{ to: '/admin/engagement/rules', label: 'Rules', icon: IconMail, roles: ['admin'] },
{ to: '/admin/engagement/audiences', label: 'Audiences', icon: IconList, roles: ['admin'] },
{ to: '/admin/engagement/templates', label: 'Templates', icon: IconTemplate, roles: ['admin'] },
{ to: '/admin/engagement/triggers', label: 'Triggers', icon: IconSpark, roles: ['admin'] },
{ to: '/admin/engagement/sends', label: 'Send Log', icon: IconLog, roles: ['admin'] },
// Beside the Send Log rather than inside it (Phase 9): the log answers
// "did that message go out", and this answers "why is this person not
// getting any" - and it is the only screen that can lift a suppression.
{ to: '/admin/engagement/suppressions', label: 'Suppressions', icon: IconLog, roles: ['admin'] },
],
},
{
title: 'System',
items: [
@@ -109,6 +135,11 @@ export const NAV = [
},
{
items: [
// No `end`: `allowedPathsFor` turns an `end` row into an EXACT match, so
// marking this one exact would leave `/admin/notifications/settings`
// outside the allowlist and bounce a staff member off their own
// preferences screen. The row covering its sub-routes is the point.
{ to: '/admin/notifications', label: 'Notifications', icon: IconBell },
{ to: '/admin/account', label: 'Account', icon: IconUser },
],
},
@@ -146,6 +177,7 @@ const TITLES = {
'/admin/moderation': 'Moderation',
'/admin/moderation/appeals': 'Appeals',
'/admin/moderation/reports': 'Reports',
'/admin/teams': 'Teams',
'/admin/settings': 'Site Settings',
'/admin/appearance': 'Appearance',
'/admin/navigation': 'Navigation',
@@ -156,6 +188,14 @@ const TITLES = {
'/admin/users': 'Users',
'/admin/invites': 'Invites',
'/admin/account': 'Account Security',
'/admin/notifications': 'Notifications',
'/admin/notifications/settings': 'Notification settings',
'/admin/engagement/rules': 'Engagement Rules',
'/admin/engagement/audiences': 'Engagement Audiences',
'/admin/engagement/templates': 'Message Templates',
'/admin/engagement/triggers': 'Triggers',
'/admin/engagement/suppressions': 'Suppressions',
'/admin/engagement/sends': 'Send Log',
}
// An installed module's admin pages are not in TITLES and cannot be — core does
@@ -175,6 +215,7 @@ function moduleTitle(baseNav, pathname) {
function sectionTitle(pathname) {
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
if (pathname.startsWith('/admin/users/')) return 'User'
if (pathname.startsWith('/admin/engagement')) return 'Engagement'
return 'Admin'
}
@@ -416,6 +457,11 @@ export default function AdminLayout() {
{title}
</h1>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: '0.84rem', color: 'var(--muted)' }}>
{/* Staff have an inbox like anyone else — `/auth/me/notifications`
is role-agnostic — and `RequirePlayer` keeps them out of the
player portal, so without this the one place they spend their
time is the one place the bell is missing. */}
<NotificationBell />
<a href="/" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
View site →
</a>

View File

@@ -4,6 +4,7 @@ import ProviderIcon from '../../../components/ProviderIcon.jsx'
import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisplay.jsx'
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
import { api } from '../../../api/client.js'
// Link/unlink external SSO identities to this account. Linking redirects through
@@ -25,7 +26,7 @@ function LinkedAccounts() {
const load = useCallback(async () => {
try {
const [ids, avail] = await Promise.all([
api.admin.linkedIdentities(),
api.myIdentities(),
api.authProviders().catch(() => []),
])
setLinked(ids)
@@ -44,7 +45,7 @@ function LinkedAccounts() {
async function unlink(provider) {
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
try {
await api.admin.unlinkIdentity(provider)
await api.unlinkIdentity(provider)
await load()
} catch (err) {
setError(err.message || 'Could not unlink.')
@@ -134,7 +135,7 @@ export default function AccountAdmin() {
async function load() {
try {
setAccount(await api.admin.getAccount())
setAccount(await api.myAccount())
} catch {
setError('Could not load your account.')
} finally {
@@ -154,7 +155,7 @@ export default function AccountAdmin() {
setMsg('')
setError('')
try {
setSetup(await api.admin.totpSetup())
setSetup(await api.totpSetup())
setCode('')
} catch (err) {
setError(err.message || 'Could not start setup.')
@@ -168,7 +169,7 @@ export default function AccountAdmin() {
setMsg('')
setError('')
try {
const res = await api.admin.totpEnable(code.trim())
const res = await api.totpEnable(code.trim())
setSetup(null)
setCode('')
setNewCodes(res?.recoveryCodes || null)
@@ -186,7 +187,7 @@ export default function AccountAdmin() {
setMsg('')
setError('')
try {
await api.admin.totpDisable(code.trim())
await api.totpDisable(code.trim())
setCode('')
setMsg('Two-factor authentication has been disabled.')
await load()
@@ -322,6 +323,10 @@ export default function AccountAdmin() {
</>
)}
{/* The self-service address, from the same component the player portal
renders — /auth/me/account is one surface for every role. */}
{account && <EmailAddressPanel account={account} reload={load} />}
<LinkedAccounts />
</section>
)

View File

@@ -66,6 +66,34 @@ export default function Dashboard() {
return (
<section>
{/* Operator warnings: things that are quietly not working and would
otherwise be discovered by someone not receiving an email. The list is
normally empty, which is why it sits above the fold rather than in a
panel — see ENGAGEMENT.md §1.2a (G22). */}
{(dash.warnings || []).map((w) => (
<div
key={w.code}
className="sans"
style={{
fontSize: '0.86rem',
lineHeight: 1.5,
borderRadius: 10,
padding: '12px 16px',
marginBottom: 18,
border: '1px solid #7a6440',
background: 'rgba(224,176,112,0.08)',
color: '#e0b070',
}}
>
{w.message}
{w.href && (
<>
{' '}
<a href={w.href} style={{ color: '#e0b070', textDecoration: 'underline' }}>Open settings</a>
</>
)}
</div>
))}
<div
style={{
display: 'flex',

View File

@@ -2,11 +2,20 @@ import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
// Email delivery panel (Gmail over OAuth2), rendered as a section on the Settings
// page. Sending is authorized by an in-app "Connect Gmail" consent flow that
// captures a refresh token server-side — the token is write-only over the API
// (stored encrypted, never returned). Reuses the Google SSO OAuth client, so it
// requires the Google provider to be configured on the Authentication page first.
// Email delivery panel, rendered as a section on the Settings page. Sending goes
// through a registered mail transport (SMTP today) whose credentials the operator
// types here; they are stored encrypted server-side and are write-only over the
// API — a secret field comes back as "set", never as its value.
//
// **The form is not written here.** The server ships each transport's declared
// `credentialFields` with the config, and this renders them. That is the whole
// point of the declaration (ENGAGEMENT.md §3.1): adding a transport must not mean
// editing this file. So there is no `host`, `port` or `password` anywhere below —
// only field kinds.
//
// The "Connect Gmail" button, its redirect banner and its six error strings went
// with the OAuth2 flow (§1.2a). Gmail is still reachable, as an ordinary SMTP
// relay with an app password — which the operator types in like any other host.
const STATUS_COLOR = {
connected: '#7fd0a4',
@@ -14,17 +23,6 @@ const STATUS_COLOR = {
unconfigured: 'var(--muted)',
}
// Human-friendly text for the ?email_error=<code> the callback may redirect with.
const ERROR_TEXT = {
denied: 'Google sign-in was cancelled or denied.',
bad_state: 'The connect session expired. Please try again.',
no_client: 'The Google OAuth client is not configured.',
no_refresh_token:
'Google did not return a refresh token. Remove this app under your Google Account → Security → Third-party access, then reconnect.',
no_email: 'Could not read the Gmail address from Google.',
error: 'Could not connect the Gmail account. Please try again.',
}
function StatusPanel({ config }) {
const color = STATUS_COLOR[config.status] || 'var(--muted)'
return (
@@ -52,60 +50,100 @@ function StatusPanel({ config }) {
)
}
// One declared credential field. A `secret` already held renders empty with a
// "leave blank to keep" hint, matching the server's patch semantics: an empty
// secret is omitted from the save, not written as a blank.
function CredentialField({ field, value, isSet, onChange }) {
const hint = [field.help, field.kind === 'secret' && isSet ? 'Currently set — leave blank to keep it.' : null]
.filter(Boolean)
.join(' ')
if (field.kind === 'boolean') {
return (
<label className="sans" style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={Boolean(value)} onChange={(e) => onChange(e.target.checked)} style={{ marginTop: 3 }} />
<span>
{field.label}
{hint && <span className="sans dim" style={{ display: 'block', fontSize: '0.78rem' }}>{hint}</span>}
</span>
</label>
)
}
return (
<label style={{ display: 'block' }}>
<span className="field-label">
{field.label}
{field.required ? '' : ' (optional)'}
</span>
<input
type={field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
className="input"
autoComplete={field.kind === 'secret' ? 'new-password' : 'off'}
placeholder={field.placeholder || ''}
/>
{hint && <span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>{hint}</span>}
</label>
)
}
export default function EmailDelivery() {
const { siteTitle } = useSite()
const [config, setConfig] = useState(null)
const [error, setError] = useState('')
const [transport, setTransport] = useState('smtp')
const [senderEmail, setSenderEmail] = useState('')
const [senderName, setSenderName] = useState('')
const [replyTo, setReplyTo] = useState('')
const [credential, setCredential] = useState({})
const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState('')
const [msg, setMsg] = useState('')
const [actionError, setActionError] = useState('')
const [banner, setBanner] = useState(null) // fields kind ('ok' or 'err') and text
// Seed the credential inputs from the non-secret values the server returned,
// falling back to each field's declared default. Secrets are never seeded —
// the server does not send them and an empty box means "keep what you have".
const seedCredential = useCallback((c, transportId) => {
const def = (c.transports || []).find((t) => t.id === transportId)
const next = {}
for (const f of def?.credentialFields || []) {
if (f.kind === 'secret') continue
next[f.key] = c.credential?.[f.key] ?? (f.default === null ? '' : f.default)
}
return next
}, [])
const load = useCallback(async (seedForm = false) => {
try {
const c = await api.admin.getEmailConfig()
setConfig(c)
if (seedForm) {
setTransport(c.transport || 'smtp')
setSenderEmail(c.senderEmail || '')
setSenderName(c.senderName || '')
setReplyTo(c.replyTo || '')
setEnabled(c.enabled)
setCredential(seedCredential(c, c.transport || 'smtp'))
}
return c
} catch {
setError('Could not load email settings.')
return null
}
}, [])
}, [seedCredential])
// On mount, surface the outcome of a just-completed connect redirect, strip the
// query params so a refresh doesn't replay the banner, then load config.
useEffect(() => {
const params = new URLSearchParams(window.location.search)
if (params.has('email_connected')) {
setBanner({ kind: 'ok', text: 'Gmail account connected.' })
} else if (params.has('email_error')) {
setBanner({ kind: 'err', text: ERROR_TEXT[params.get('email_error')] || 'Could not connect email.' })
}
if (params.has('email_connected') || params.has('email_error')) {
params.delete('email_connected')
params.delete('email_error')
const qs = params.toString()
window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : ''))
}
load(true)
}, [load])
async function connect() {
setBusy('connect')
setActionError('')
try {
const { url } = await api.admin.emailConnectUrl()
window.location.href = url
} catch (err) {
setActionError(err.message || 'Could not start the connect flow.')
setBusy('')
}
// Switching transport starts from the new one's declared defaults, because the
// server does the same: a credential blob is never carried across transports.
function changeTransport(id) {
setTransport(id)
setCredential(seedCredential(config, id))
}
async function save() {
@@ -113,10 +151,19 @@ export default function EmailDelivery() {
setMsg('')
setActionError('')
try {
const saved = await api.admin.saveEmailConfig({ senderName, enabled })
const saved = await api.admin.saveEmailConfig({ transport, senderEmail, senderName, replyTo, credential, enabled })
setConfig(saved)
setEnabled(saved.enabled)
setCredential(seedCredential(saved, saved.transport))
setMsg('Saved.')
} catch (err) {
// A refused enable comes back with the reverted config attached, so the
// screen shows what is actually stored rather than the state that was
// rejected.
if (err.body?.config) {
setConfig(err.body.config)
setEnabled(err.body.config.enabled)
}
setActionError(err.message || 'Could not save.')
} finally {
setBusy('')
@@ -133,12 +180,13 @@ export default function EmailDelivery() {
await load()
} catch (err) {
setActionError(err.message || 'Could not send the test email.')
await load()
} finally {
setBusy('')
}
}
async function disconnect() {
async function clearCredentials() {
setBusy('disconnect')
setMsg('')
setActionError('')
@@ -146,9 +194,11 @@ export default function EmailDelivery() {
const c = await api.admin.disconnectEmail()
setConfig(c)
setEnabled(false)
setMsg('Disconnected.')
setSenderEmail('')
setCredential(seedCredential(c, c.transport))
setMsg('Credentials cleared.')
} catch (err) {
setActionError(err.message || 'Could not disconnect.')
setActionError(err.message || 'Could not clear the credentials.')
} finally {
setBusy('')
}
@@ -157,84 +207,118 @@ export default function EmailDelivery() {
if (error) return <p className="sans" style={{ color: '#d98b84' }}>{error}</p>
if (!config) return null
const connected = config.hasRefreshToken
const catalog = config.transports || []
const selected = catalog.find((t) => t.id === transport)
return (
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 16, marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 30 }}>
<div>
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Email delivery</h2>
<p className="sans dim" style={{ margin: '6px 0 0', fontSize: '0.82rem' }}>
Sends the contact form through Gmail over OAuth2, delivered to the
<strong> Contact email</strong> above. Reuses the Google authentication
client — configure that on the Authentication page first.
Sends the contact form, invitations, password resets and team
notifications. Contact-form mail is delivered to the
<strong> Contact email</strong> above. Credentials are stored encrypted
and never shown again.
</p>
</div>
{banner && (
{config.hadLegacyConnection && !config.hasCredential && (
<div
className="sans"
style={{
fontSize: '0.85rem',
borderRadius: 8,
padding: '10px 12px',
border: `1px solid ${banner.kind === 'ok' ? '#3f6b52' : '#7a4440'}`,
color: banner.kind === 'ok' ? '#7fd0a4' : '#d98b84',
}}
style={{ fontSize: '0.85rem', borderRadius: 8, padding: '10px 12px', border: '1px solid #7a6440', color: '#e0b070' }}
>
{banner.text}
This deployment was connected with the old Gmail sign-in, which has been
removed. <strong>No mail is being sent.</strong> Enter SMTP credentials
below to restore it — for Gmail, use <code>smtp.gmail.com</code> port 587
with an app password.
</div>
)}
<StatusPanel config={config} />
{!config.googleConfigured && (
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: '#e0b070' }}>
The Google authentication provider needs a client ID and secret before
you can connect a Gmail account.
</p>
{catalog.length > 1 && (
<label style={{ display: 'block' }}>
<span className="field-label">Transport</span>
<select value={transport} onChange={(e) => changeTransport(e.target.value)} className="input">
{catalog.map((t) => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
</label>
)}
{!connected ? (
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={connect} disabled={busy === 'connect' || !config.googleConfigured} className="btn btn-primary btn-sq">
{busy === 'connect' ? 'Redirecting…' : 'Connect Gmail'}
{selected?.help && (
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>{selected.help}</p>
)}
{(selected?.credentialFields || []).map((f) => (
<CredentialField
key={f.key}
field={f}
value={credential[f.key]}
isSet={Boolean(config.secretsSet?.[f.key])}
onChange={(v) => setCredential((prev) => ({ ...prev, [f.key]: v }))}
/>
))}
<label style={{ display: 'block' }}>
<span className="field-label">Send from</span>
<input
type="email"
value={senderEmail}
onChange={(e) => setSenderEmail(e.target.value)}
className="input"
autoComplete="off"
placeholder="noreply@example.com"
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
Must be an address this account is allowed to send as, or the relay will
reject it. Use <strong>Send test</strong> to confirm.
</span>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">From display name (optional)</span>
<input
type="text"
value={senderName}
onChange={(e) => setSenderName(e.target.value)}
className="input"
autoComplete="off"
placeholder={siteTitle}
/>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Reply-To (optional)</span>
<input
type="email"
value={replyTo}
onChange={(e) => setReplyTo(e.target.value)}
className="input"
autoComplete="off"
placeholder="Leave blank to reply to the sending address"
/>
</label>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
Enable email sending
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={busy === 'save'} className="btn btn-primary btn-sq">
{busy === 'save' ? 'Saving…' : 'Save changes'}
</button>
<button onClick={sendTest} disabled={busy === 'test' || !config.hasCredential} className="pill">
{busy === 'test' ? 'Sending…' : 'Send test'}
</button>
{config.hasCredential && (
<button onClick={clearCredentials} disabled={busy === 'disconnect'} className="pill">
Clear credentials
</button>
</div>
) : (
<>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
Enable email sending
</label>
<label style={{ display: 'block' }}>
<span className="field-label">From display name (optional)</span>
<input
type="text"
value={senderName}
onChange={(e) => setSenderName(e.target.value)}
className="input"
autoComplete="off"
placeholder={siteTitle}
/>
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={busy === 'save'} className="btn btn-primary btn-sq">
{busy === 'save' ? 'Saving…' : 'Save changes'}
</button>
<button onClick={sendTest} disabled={busy === 'test'} className="pill">
{busy === 'test' ? 'Sending…' : 'Send test'}
</button>
<button onClick={connect} disabled={busy === 'connect'} className="pill">
Reconnect
</button>
<button onClick={disconnect} disabled={busy === 'disconnect'} className="pill">
Disconnect
</button>
</div>
</>
)}
)}
</div>
<div style={{ minHeight: 18 }}>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}

View File

@@ -0,0 +1,433 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { describeExpression, describeReach, notPlacementError } from '../../../lib/engagementRules.js'
// Admin → Engagement → Audiences (ENGAGEMENT.md §5.1a, Phase 4b).
//
// A module declares named sets of users over its own data — "members of a team",
// "the governors" — and an operator combines them here into a saved audience a
// rule can point at. Core learns no game vocabulary: it knows an id, a label and
// a resolver it may call.
//
// **Composition narrows and never widens**, and that is the whole security
// content of this screen:
//
// • the saved ceiling is DERIVED from the tightest audience in the expression,
// not chosen — including for "any of", where the intuitive answer (the widest
// of the two) is the wrong one. A ceiling says what an expression is allowed
// to reach, not what it will resolve to, so the boolean operator makes no
// difference to it.
// • two ceilings with no ordering between them (staff and owner, say) have no
// answer at all, and the save is refused rather than guessing a side.
// • "none of" is only available inside an "all of" group. On its own it would
// have to mean "everyone except…" — a broadcast built out of one narrow list.
// The composer does not offer it anywhere else, and the server refuses it
// anyway.
//
// The three-level composer here is deliberate: one top-level all-of/any-of, one
// level of groups inside it, and audiences at the leaves. The stored grammar
// allows more nesting; anything deeper is left to the rule that made it and shown
// read-only, the same way the rule editor treats a nested condition.
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
/** A fresh, empty top-level group. */
const blankExpression = () => ({ op: 'and', nodes: [] })
/** Is this tree one the composer can render — a single group of leaves and not-groups? */
function isComposable(node) {
if (!node || typeof node !== 'object') return false
if (!node.op) return true
if (node.op === 'not') return (node.nodes || []).every((n) => n && !n.op)
if (node.op !== 'and' && node.op !== 'or') return false
return (node.nodes || []).every((n) => n && (!n.op || (n.op === 'not' && (n.nodes || []).every((c) => !c.op))))
}
/** The composer edits a top-level group; a bare leaf is lifted into one. */
const toGroup = (expression) =>
!expression ? blankExpression() : expression.op ? expression : { op: 'and', nodes: [expression] }
// ── One leaf: an audience and its declared parameters ──────────────────────
function LeafRow({ audiences, node, onChange, onRemove, negated, onToggleNegate, canNegate, first }) {
const declared = audiences.find((a) => a.id === node.audienceId)
return (
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<label style={{ flex: '1 1 240px' }}>
{/* The heading belongs to the group, not to every line in it. */}
{first && <span className="field-label">Audience</span>}
<select
className="select"
value={node.audienceId || ''}
onChange={(e) => onChange({ audienceId: e.target.value, params: {} })}
>
<option value="">Choose…</option>
{audiences.map((a) => (
<option key={a.id} value={a.id}>{a.label} — reaches at most “{a.ceiling}”</option>
))}
</select>
</label>
{(declared?.params || []).map((p) => (
<label key={p.id} style={{ flex: '0 1 160px' }}>
<span className="field-label">{p.id}{p.required ? ' *' : ''}</span>
<input
className="input"
value={node.params?.[p.id] ?? ''}
onChange={(e) =>
onChange({
...node,
params: {
...node.params,
// `int` params are sent as numbers: the server type-checks each
// declared param, and "3" against an int is a refusal.
[p.id]: p.type === 'int' && e.target.value !== '' ? Number(e.target.value) : e.target.value,
},
})
}
/>
</label>
))}
{canNegate && (
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, paddingBottom: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={negated} onChange={onToggleNegate} />
exclude
</label>
)}
<button type="button" className="pill" style={{ ...DANGER, fontSize: '0.72rem', marginBottom: 6 }} onClick={onRemove}>
Remove
</button>
</div>
)
}
// ── The composer ───────────────────────────────────────────────────────────
function SegmentEditor({ audiences, segment, onSaved, onCancel }) {
const [name, setName] = useState(segment?.name || '')
const [group, setGroup] = useState(() => toGroup(segment?.expression))
const [errors, setErrors] = useState([])
const [busy, setBusy] = useState(false)
const isNew = !segment
// `not` is only offered under "all of" (§5.1a). Under "any of" the checkbox
// disappears rather than being offered and refused.
const canNegate = group.op === 'and'
function setNodes(nodes) {
setGroup((g) => ({ ...g, nodes }))
}
function addLeaf() {
setNodes([...group.nodes, { audienceId: '', params: {} }])
}
function replaceAt(i, next) {
setNodes(group.nodes.map((n, j) => (i === j ? next : n)))
}
function toggleNegate(i) {
const node = group.nodes[i]
replaceAt(i, node.op === 'not' ? node.nodes[0] : { op: 'not', nodes: [node] })
}
function changeOp(op) {
// Switching to "any of" drops the exclusions rather than sending a tree the
// server will refuse — and says so, because silently keeping them and failing
// at save would be worse than either.
const nodes = op === 'or' ? group.nodes.map((n) => (n.op === 'not' ? n.nodes[0] : n)) : group.nodes
setGroup({ op, nodes })
}
const expression = useMemo(() => {
const nodes = group.nodes.filter((n) => (n.op === 'not' ? n.nodes[0]?.audienceId : n.audienceId))
if (!nodes.length) return null
if (nodes.length === 1 && !nodes[0].op) return nodes[0]
return { op: group.op, nodes }
}, [group])
const localError = expression ? notPlacementError(expression) : null
async function submit(e) {
e.preventDefault()
setErrors([])
if (!expression) return setErrors(['Add at least one audience.'])
if (localError) return setErrors([localError])
setBusy(true)
try {
const body = { name: name.trim(), expression }
if (isNew) await api.admin.createEngagementSegment(body)
else await api.admin.updateEngagementSegment(segment.id, body)
await onSaved()
} catch (err) {
setErrors(err.body?.errors?.length ? err.body.errors : [err.message || 'Could not save that audience.'])
} finally {
setBusy(false)
}
}
return (
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
<div className="field-label" style={{ marginBottom: 14 }}>
{isNew ? 'New saved audience' : `Editing “${segment.name}”`}
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 280px' }}>
<span className="field-label">Name</span>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Governors" />
</label>
<label style={{ flex: '0 1 200px' }}>
<span className="field-label">Combine with</span>
<select className="select" value={group.op} onChange={(e) => changeOp(e.target.value)}>
<option value="and">all of these</option>
<option value="or">any of these</option>
</select>
</label>
</div>
<div style={{ marginTop: 18 }}>
{group.nodes.length === 0 && (
<p className="sans" style={{ margin: '0 0 10px', fontSize: '0.84rem', color: 'var(--muted)' }}>
No audiences yet. A saved audience is built out of the lists installed modules declare.
</p>
)}
{group.nodes.map((node, i) => {
const negated = node.op === 'not'
const leaf = negated ? node.nodes[0] : node
return (
<LeafRow
key={i}
first={i === 0}
audiences={audiences}
node={leaf}
negated={negated}
canNegate={canNegate}
onToggleNegate={() => toggleNegate(i)}
onChange={(next) => replaceAt(i, negated ? { op: 'not', nodes: [next] } : next)}
onRemove={() => setNodes(group.nodes.filter((_, j) => j !== i))}
/>
)
})}
<button type="button" className="btn btn-sq" onClick={addLeaf} disabled={!audiences.length}>
Add an audience
</button>
{!audiences.length && (
<span className="sans" style={{ marginLeft: 10, fontSize: '0.8rem', color: 'var(--muted)' }}>
No module currently declares any. Install one, or use a plain audience on the rule itself.
</span>
)}
</div>
{canNegate ? (
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
“Exclude” removes people from what the other rows produced. It is only available under “all
of”: on its own it would mean “everyone except…”, which is a way to reach the whole
deployment from one narrow list.
</p>
) : (
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
“Any of” takes the tightest limit of the audiences in it, not the widest — combining two
lists never reaches further than the narrower one allows.
</p>
)}
{(errors.length > 0 || localError) && (
<ul className="sans" style={{ margin: '14px 0 0', paddingLeft: 18, color: '#d98b84', fontSize: '0.84rem' }}>
{(errors.length ? errors : [localError]).map((e) => <li key={e}>{e}</li>)}
</ul>
)}
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
{busy ? 'Saving…' : isNew ? 'Create' : 'Save changes'}
</button>
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}
// ── The screen ─────────────────────────────────────────────────────────────
export default function EngagementAudiences() {
const [audiences, setAudiences] = useState([])
const [segments, setSegments] = useState(null)
const [editing, setEditing] = useState(null) // null | { segment } | { segment: null }
const [error, setError] = useState('')
const [rowError, setRowError] = useState('')
const [reach, setReach] = useState({}) // segment id -> preview
const load = useCallback(async () => {
setError('')
try {
const [declared, saved] = await Promise.all([
api.admin.engagementAudiences(),
api.admin.listEngagementSegments(),
])
setAudiences(declared.audiences || [])
setSegments(saved.segments || [])
} catch {
setError('Could not load audiences.')
}
}, [])
useEffect(() => { load() }, [load])
const audiencesById = useMemo(
() => Object.fromEntries(audiences.map((a) => [a.id, a])),
[audiences],
)
async function preview(segment) {
try {
const counted = await api.admin.previewEngagementReach({ audienceSegmentId: segment.id })
setReach((r) => ({ ...r, [segment.id]: counted }))
} catch (err) {
setReach((r) => ({ ...r, [segment.id]: { count: 0, dormant: true, reason: err.message } }))
}
}
async function remove(segment) {
if (!window.confirm(`Delete “${segment.name}”?`)) return
setRowError('')
try {
await api.admin.deleteEngagementSegment(segment.id)
await load()
} catch (err) {
// A 409 here is the interesting case and the message carries the count:
// deleting a segment a rule still points at would leave that rule reaching
// a different set of people, so it is refused rather than cascaded.
setRowError(err.message || 'Could not delete that audience.')
}
}
if (error) return <ErrorState message={error} />
if (!segments) return <Loading />
if (editing) {
return (
<section>
<SegmentEditor
audiences={audiences}
segment={editing.segment}
onSaved={async () => { setEditing(null); await load() }}
onCancel={() => setEditing(null)}
/>
</section>
)
}
return (
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 640 }}>
Named sets of people a rule can be pointed at, built out of the lists installed modules
declare. A saved audience can only ever narrow — combining two lists never reaches further
than the tighter of them allows.
</p>
<button type="button" className="btn btn-primary btn-sq" onClick={() => setEditing({ segment: null })}>
New audience
</button>
</div>
{rowError && (
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
)}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Name</th>
<th className="adm-th">Made of</th>
<th className="adm-th">Reaches at most</th>
<th className="adm-th">Right now</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{segments.length === 0 && (
<tr>
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
No saved audiences yet.
</td>
</tr>
)}
{segments.map((s) => (
<tr key={s.id}>
<td className="adm-td" style={{ color: 'var(--text)' }}>
{s.name}
{s.dormant && (
<div>
<span
className="badge"
title={`Not declared right now: ${(s.missingAudiences || []).join(', ')}`}
style={{ color: 'var(--accent)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
>
Dormant
</span>
</div>
)}
</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
{describeExpression(s.expression, audiencesById)}
</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{s.ceiling}</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
{reach[s.id] ? (
describeReach(reach[s.id])
) : (
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => preview(s)}>
Count
</button>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button
type="button"
className="pill"
style={{ fontSize: '0.72rem', marginRight: 6 }}
disabled={!isComposable(s.expression)}
title={isComposable(s.expression) ? undefined : 'Nested more deeply than this composer renders'}
onClick={() => setEditing({ segment: s })}
>
Edit
</button>
<button
type="button"
className="pill"
style={{ ...DANGER, fontSize: '0.72rem' }}
onClick={() => remove(s)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="panel" style={{ padding: 18, marginTop: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>What modules currently declare</div>
{audiences.length === 0 ? (
<p className="sans" style={{ margin: 0, fontSize: '0.84rem', color: 'var(--muted)' }}>
Nothing. Audiences come from installed modules — core declares none, because core knows no
game vocabulary.
</p>
) : (
<ul className="sans" style={{ margin: 0, paddingLeft: 18, fontSize: '0.84rem', color: 'var(--muted)' }}>
{audiences.map((a) => (
<li key={a.id}>
<span style={{ color: 'var(--text)' }}>{a.label}</span> — <code>{a.id}</code>, reaches at
most “{a.ceiling}”
{(a.params || []).length ? ` (${a.params.map((p) => p.id).join(', ')})` : ''}
</li>
))}
</ul>
)}
</div>
</section>
)
}

View File

@@ -0,0 +1,716 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import {
formFromRule,
ruleToPayload,
audienceChoicesFor,
segmentChoicesFor,
describeReach,
describeRule,
audienceWarning,
conditionRowsFrom,
conditionsFromRows,
operatorsForType,
} from '../../../lib/engagementRules.js'
// Admin → Engagement → Rules (ENGAGEMENT.md Phase 4b).
//
// A rule is trigger → audience → channels → timing, and this is the screen that
// writes one. Everything it decides lives in lib/engagementRules.js so it can be
// tested; this file renders it and talks to the API.
//
// Four things about this screen are deliberate and would be wrong the obvious
// way round:
//
// 1. **The on/off switch is not the form.** It is its own request against its
// own route, and it does not re-validate the rule. A rule whose module has
// been uninstalled is dormant, is the rule an operator most wants stopped,
// and is exactly the rule the form would refuse to save.
// 2. **A rule's trigger is fixed once it exists.** Its cooldowns, its pending
// outbox rows and its send-log history are all about one trigger id.
// 3. **Every rule arrives off.** §7.1 Q3 makes rules operator-editable data on
// the condition that nothing starts mailing by itself — so a new rule is
// created disabled and switched on afterwards, as a separate act.
// 4. **The reach preview is a number.** Never a list of people: a
// module-declared segment resolves over game data, and this screen is about
// mail scheduling.
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
const BLANK = {
id: null,
triggerId: '',
name: '',
enabled: false,
audience: 'owner',
audienceSegmentId: null,
channels: [],
templateKeys: {},
conditions: null,
cooldownSeconds: 0,
delaySeconds: 0,
cancelOn: [],
maxSendsPerHour: 100,
}
function Dormant({ reasons }) {
return (
<span
className="badge"
title={reasons.join('\n')}
style={{ color: 'var(--accent)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
>
Dormant
</span>
)
}
// ── The editor ─────────────────────────────────────────────────────────────
function RuleEditor({ catalog, segments, rule, onSaved, onCancel }) {
const [form, setForm] = useState(() => (rule ? formFromRule(rule) : { ...BLANK }))
const [conditionState, setConditionState] = useState(() => conditionRowsFrom(rule?.conditions))
const [preview, setPreview] = useState(null)
const [previewing, setPreviewing] = useState(false)
const [errors, setErrors] = useState([])
const [busy, setBusy] = useState(false)
const isNew = !form.id
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
const trigger = useMemo(
() => catalog.triggers.find((t) => t.id === form.triggerId) || null,
[catalog.triggers, form.triggerId],
)
const audienceChoices = audienceChoicesFor(trigger, catalog.ceilings)
const segmentChoices = segmentChoicesFor(trigger, catalog.ceilings, segments)
const variables = trigger?.variables || []
// Changing the trigger invalidates the audience and every condition, because
// both are stated in the old trigger's vocabulary. Clearing them is the honest
// move: keeping a condition on a variable the new trigger never carries would
// make the rule fire on nothing, silently (an absent variable fails every
// comparison, by design).
function pickTrigger(id) {
const next = catalog.triggers.find((t) => t.id === id)
setForm((f) => ({
...f,
triggerId: id,
audience: next?.audience || 'owner',
audienceSegmentId: null,
}))
setConditionState({ op: 'and', rows: [], editable: true })
setPreview(null)
}
function toggleChannel(id) {
setForm((f) => ({
...f,
channels: f.channels.includes(id) ? f.channels.filter((c) => c !== id) : [...f.channels, id],
}))
}
async function runPreview() {
setPreviewing(true)
try {
setPreview(
await api.admin.previewEngagementReach({
audience: form.audience,
audienceSegmentId: form.audienceSegmentId,
triggerId: form.triggerId,
}),
)
} catch (err) {
setPreview({ count: 0, dormant: true, reason: err.message || 'could not be resolved' })
} finally {
setPreviewing(false)
}
}
async function submit(e) {
e.preventDefault()
setErrors([])
setBusy(true)
const payload = ruleToPayload({
...form,
conditions: conditionState.editable
? conditionsFromRows(conditionState.op, conditionState.rows, variables)
: form.conditions,
})
try {
if (isNew) await api.admin.createEngagementRule(payload)
else await api.admin.updateEngagementRule(form.id, payload)
await onSaved()
} catch (err) {
// The server sends every problem, not just the first. A form that shows one
// makes an operator fix four things in four round trips.
setErrors(err.body?.errors?.length ? err.body.errors : [err.message || 'Could not save the rule.'])
} finally {
setBusy(false)
}
}
return (
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
<div className="field-label" style={{ marginBottom: 14 }}>
{isNew ? 'New rule' : `Editing “${rule.name}”`}
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 280px' }}>
<span className="field-label">Trigger</span>
{isNew ? (
<select className="select" value={form.triggerId} onChange={(e) => pickTrigger(e.target.value)}>
<option value="">Choose an event…</option>
{catalog.triggers.map((t) => (
<option key={t.id} value={t.id}>
{t.label} ({t.id})
</option>
))}
</select>
) : (
<input className="input" value={form.triggerId} readOnly disabled />
)}
{!isNew && (
<span className="sans" style={{ fontSize: '0.78rem', color: 'var(--muted)' }}>
A rule keeps its trigger — its cooldowns, queued sends and history are all about this one.
</span>
)}
</label>
<label style={{ flex: '1 1 280px' }}>
<span className="field-label">Name</span>
<input
className="input"
value={form.name}
onChange={(e) => set({ name: e.target.value })}
placeholder="IDOC warning to the owner"
/>
</label>
</div>
{trigger?.description && (
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.82rem', color: 'var(--muted)' }}>
{trigger.description}
</p>
)}
{/* ── Audience ── */}
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Who it reaches</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<label style={{ flex: '1 1 220px' }}>
<span className="field-label">Audience</span>
<select
className="select"
value={form.audienceSegmentId ? '' : form.audience}
disabled={Boolean(form.audienceSegmentId) || !audienceChoices.length}
onChange={(e) => { set({ audience: e.target.value, audienceSegmentId: null }); setPreview(null) }}
>
{/* Without a trigger there is no ceiling, so there is nothing this
may legitimately offer — and a select with zero options renders
as a control that is broken rather than as one that is waiting. */}
{!audienceChoices.length && <option value="">Choose a trigger first…</option>}
{Boolean(form.audienceSegmentId) && <option value="">Using the saved audience →</option>}
{audienceChoices.map((c) => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
</select>
</label>
<label style={{ flex: '1 1 220px' }}>
<span className="field-label">…or a saved audience</span>
<select
className="select"
value={form.audienceSegmentId || ''}
onChange={(e) => {
set({ audienceSegmentId: e.target.value ? Number(e.target.value) : null })
setPreview(null)
}}
>
<option value="">None — use the audience on the left</option>
{segmentChoices.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
<button type="button" className="btn btn-sq" disabled={previewing || !form.triggerId} onClick={runPreview}>
{previewing ? 'Counting…' : 'Preview reach'}
</button>
</div>
{preview && (
<p
className="sans"
style={{
margin: '10px 0 0',
fontSize: '0.84rem',
color: preview.permitted === false || preview.dormant ? '#d98b84' : 'var(--muted)',
}}
>
{describeReach(preview)}
</p>
)}
{/* The `members`-with-no-saved-audience trap, said before the save rather
than discovered after it. It is the DEFAULT the moment a
members-ceiling trigger is chosen, and the rule it produces saves,
switches on and mails nobody. */}
{!preview && audienceWarning(form) && (
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.84rem', color: 'var(--accent)' }}>
{audienceWarning(form)}
</p>
)}
{trigger && audienceChoices.length <= 1 && (
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
This event only permits “{trigger.ceiling}”. The audience a rule may use is capped by the
event itself, not by the rule.
</p>
)}
{/* ── Channels ── */}
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>How it is delivered</div>
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap' }}>
{catalog.channels.map((c) => (
<div key={c.id} style={{ flex: '0 1 260px' }}>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={form.channels.includes(c.id)} onChange={() => toggleChannel(c.id)} />
{c.label}
</label>
{form.channels.includes(c.id) && (
<input
className="input"
style={{ marginTop: 6, width: '100%' }}
placeholder="template key (optional)"
value={form.templateKeys[c.id] || ''}
onChange={(e) => set({ templateKeys: { ...form.templateKeys, [c.id]: e.target.value } })}
/>
)}
</div>
))}
</div>
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
Every channel is opt-in: a rule reaches only the people who turned that channel on for this
event in their own notification settings.
</p>
{/* ── Conditions ── */}
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Only when…</div>
{!conditionState.editable ? (
<div>
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--accent)' }}>
This rule has a nested condition this editor does not render. It is left exactly as it is
unless you clear it — flattening it here would change which events fire the rule.
</p>
<pre
style={{ background: 'var(--panel-flat)', border: '1px solid var(--line)', borderRadius: 6, padding: 10, fontSize: '0.76rem', overflowX: 'auto' }}
>
{JSON.stringify(form.conditions, null, 2)}
</pre>
<button
type="button"
className="pill"
style={{ ...DANGER, fontSize: '0.72rem' }}
onClick={() => { set({ conditions: null }); setConditionState({ op: 'and', rows: [], editable: true }) }}
>
Clear and start again
</button>
</div>
) : (
<>
{conditionState.rows.length > 1 && (
<label style={{ display: 'block', marginBottom: 8 }}>
<span className="field-label">Match</span>
<select
className="select"
style={{ maxWidth: 220 }}
value={conditionState.op}
onChange={(e) => setConditionState((s) => ({ ...s, op: e.target.value }))}
>
<option value="and">all of these</option>
<option value="or">any of these</option>
</select>
</label>
)}
{conditionState.rows.map((row, i) => {
const type = variables.find((v) => v.name === row.variable)?.type
const ops = operatorsForType(catalog.operators, type)
const takesValue = row.cmp !== 'present' && row.cmp !== 'absent'
const patch = (p) =>
setConditionState((s) => ({
...s,
rows: s.rows.map((r, j) => (i === j ? { ...r, ...p } : r)),
}))
return (
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
<select
className="select"
style={{ flex: '1 1 160px' }}
value={row.variable}
onChange={(e) => patch({ variable: e.target.value })}
>
<option value="">Variable…</option>
{variables.map((v) => (
<option key={v.name} value={v.name}>{v.name}</option>
))}
</select>
<select
className="select"
style={{ flex: '1 1 160px' }}
value={row.cmp}
onChange={(e) => patch({ cmp: e.target.value })}
>
<option value="">Is…</option>
{ops.map((o) => (
<option key={o.cmp} value={o.cmp}>{o.label}</option>
))}
</select>
{takesValue && (
<input
className="input"
style={{ flex: '2 1 200px' }}
value={row.value}
placeholder={row.cmp === 'in' || row.cmp === 'nin' ? 'comma, separated, values' : 'value'}
onChange={(e) => patch({ value: e.target.value })}
/>
)}
<button
type="button"
className="pill"
style={{ ...DANGER, fontSize: '0.72rem' }}
onClick={() => setConditionState((s) => ({ ...s, rows: s.rows.filter((_, j) => j !== i) }))}
>
Remove
</button>
</div>
)
})}
<button
type="button"
className="btn btn-sq"
disabled={!variables.length}
onClick={() =>
setConditionState((s) => ({ ...s, rows: [...s.rows, { variable: '', cmp: '', value: '' }] }))
}
>
Add a condition
</button>
{!variables.length && (
<span className="sans" style={{ marginLeft: 10, fontSize: '0.8rem', color: 'var(--muted)' }}>
Choose a trigger first — its declared variables are what a condition can talk about.
</span>
)}
</>
)}
{/* ── Timing and the ceiling ── */}
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Timing</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 160px' }}>
<span className="field-label">Wait before sending (seconds)</span>
<input
className="input"
type="number"
min="0"
value={form.delaySeconds}
onChange={(e) => set({ delaySeconds: Number(e.target.value) })}
/>
</label>
<label style={{ flex: '1 1 160px' }}>
<span className="field-label">At most once per (seconds)</span>
<input
className="input"
type="number"
min="0"
value={form.cooldownSeconds}
onChange={(e) => set({ cooldownSeconds: Number(e.target.value) })}
/>
</label>
<label style={{ flex: '1 1 160px' }}>
<span className="field-label">Hard cap (sends per hour)</span>
<input
className="input"
type="number"
min="1"
value={form.maxSendsPerHour}
onChange={(e) => set({ maxSendsPerHour: Number(e.target.value) })}
/>
</label>
</div>
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
The cooldown is per recipient and per subject
{trigger?.subjectKey ? ` (“${trigger.subjectKey}”)` : ''} — a player whose four houses are all
decaying hears about all four, once each. The hourly cap is per rule and is the hard stop that
keeps a misconfiguration to a bad hour.
</p>
{form.delaySeconds > 0 && (
<label style={{ display: 'block', marginTop: 14 }}>
<span className="field-label">Cancel the wait if any of these happen</span>
<select
className="select"
multiple
size={Math.min(5, Math.max(2, catalog.triggers.length))}
value={form.cancelOn}
onChange={(e) => set({ cancelOn: [...e.target.selectedOptions].map((o) => o.value) })}
>
{catalog.triggers.map((t) => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
<span className="sans" style={{ fontSize: '0.78rem', color: 'var(--muted)' }}>
Only meaningful with a wait — there is no window to cancel otherwise, and the save says so.
</span>
</label>
)}
{errors.length > 0 && (
<ul className="sans" style={{ margin: '14px 0 0', paddingLeft: 18, color: '#d98b84', fontSize: '0.84rem' }}>
{errors.map((e) => <li key={e}>{e}</li>)}
</ul>
)}
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
{busy ? 'Saving…' : isNew ? 'Create rule (off)' : 'Save changes'}
</button>
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
{isNew && (
<span className="sans" style={{ alignSelf: 'center', fontSize: '0.8rem', color: 'var(--muted)' }}>
A new rule is created switched off. Turn it on from the list when you are happy with it.
</span>
)}
</div>
</form>
)
}
// ── The screen ─────────────────────────────────────────────────────────────
// ── The Phase 6 migration notice ───────────────────────────────────────────
//
// Team notifications used to be sent with no operator configuration at all;
// ENGAGEMENT.md Phase 6 moved them onto rules, and the org lead's decision was to
// seed those rules DISABLED rather than carve an exception into "nothing is on by
// default". The consequence is a deployment whose Team email has stopped and
// nobody has been told — which is G22's failure mode with a different cause — so
// the screen that can fix it says so.
//
// It reads the RULES rather than a flag, so it disappears the moment one is
// switched on and comes back if every one is switched off again. A deployment
// that deleted them all sees nothing, which is right: they made that choice.
//
// **Phase 11 added a second notice of exactly the same shape, for news**
// (ENGAGEMENT.md §7.1 Q9). Publishing a news post used to tickle every subscriber
// directly, and that call is now an emit through the engine, so news push stops
// on upgrade until the seeded `news.post` rule is switched on. Two notices rather
// than one generalised "some rules are off" banner, deliberately: each names a
// capability that USED to work without configuration and now does not, which is
// a different statement from "you have a disabled rule" — and a rule an operator
// created and disabled themselves must never produce a warning.
const TEAM_TRIGGERS = [
'team.forum.post',
'team.announcement',
'team.member.joined',
'team.leadership.changed',
]
const NEWS_TRIGGERS = ['news.post']
// One style for both notices, so the pair reads as one kind of message rather
// than two that happen to look alike.
const NOTICE_STYLE = {
fontSize: '0.85rem',
borderRadius: 8,
padding: '10px 12px',
marginBottom: 16,
border: '1px solid #7a6440',
color: '#e0b070',
}
const triggerOf = (rule) => rule.triggerId || rule.trigger_id
// True only when rules for these triggers EXIST and every one of them is off.
// Zero matching rules means the operator deleted them, which is a choice, not a
// regression to warn about.
function allOff(rules, triggers) {
const group = rules.filter((r) => triggers.includes(triggerOf(r)))
return group.length > 0 && group.every((r) => !r.enabled)
}
const teamRulesAllOff = (rules) => allOff(rules, TEAM_TRIGGERS)
const newsRulesAllOff = (rules) => allOff(rules, NEWS_TRIGGERS)
export default function EngagementRules() {
const [catalog, setCatalog] = useState(null)
const [segments, setSegments] = useState([])
const [rules, setRules] = useState(null)
const [editing, setEditing] = useState(null) // null | { rule } | { rule: null } for new
const [error, setError] = useState('')
const [rowError, setRowError] = useState('')
const load = useCallback(async () => {
setError('')
try {
const [triggers, channels, segs, list] = await Promise.all([
api.admin.engagementTriggers(),
api.admin.engagementChannels(),
api.admin.listEngagementSegments(),
api.admin.listEngagementRules(),
])
setCatalog({
triggers: triggers.triggers || [],
ceilings: triggers.ceilings || [],
operators: triggers.operators || [],
channels: channels.channels || [],
})
setSegments(segs.segments || [])
setRules(list.rules || [])
} catch {
setError('Could not load the engagement rules.')
}
}, [])
useEffect(() => { load() }, [load])
const segmentsById = useMemo(
() => Object.fromEntries(segments.map((s) => [s.id, s])),
[segments],
)
async function toggle(rule) {
setRowError('')
try {
await api.admin.setEngagementRuleEnabled(rule.id, !rule.enabled)
await load()
} catch (err) {
setRowError(err.message || 'Could not change that rule.')
}
}
async function remove(rule) {
if (!window.confirm(`Delete “${rule.name}”? Its queued sends go with it; the send log does not.`)) return
setRowError('')
try {
await api.admin.deleteEngagementRule(rule.id)
await load()
} catch (err) {
setRowError(err.message || 'Could not delete that rule.')
}
}
if (error) return <ErrorState message={error} />
if (!catalog || !rules) return <Loading />
if (editing) {
return (
<section>
<RuleEditor
catalog={catalog}
segments={segments}
rule={editing.rule}
onSaved={async () => { setEditing(null); await load() }}
onCancel={() => setEditing(null)}
/>
</section>
)
}
return (
<section>
{teamRulesAllOff(rules) && (
<div className="sans" style={NOTICE_STYLE}>
<strong>Team notification emails are off.</strong> They used to be sent automatically; they
are now rules, and the four below arrived switched off so that nothing starts mailing on its
own. Switch on the ones this deployment wants — per-member preferences and per-Team mutes
still apply above them, and unsubscribe links in mail already sent still work.
</div>
)}
{newsRulesAllOff(rules) && (
<div className="sans" style={NOTICE_STYLE}>
<strong>News notifications are off.</strong> Publishing a news post used to send a push
notification to everyone subscribed to it. That is now the “News posts” rule below, and it
arrived switched off for the same reason the Team rules did. Switch it on to resume news
push — it also carries email and the in-app inbox, each still subject to each person’s own
preferences. The in-game town crier and the Discord announcement are unaffected either way.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 640 }}>
A rule turns an event into mail: which event, who hears about it, on which channels, and how
often at most. Nothing sends until a rule is switched on.
</p>
<button type="button" className="btn btn-primary btn-sq" onClick={() => setEditing({ rule: null })}>
New rule
</button>
</div>
{rowError && (
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
)}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Rule</th>
<th className="adm-th">Trigger</th>
<th className="adm-th">What it does</th>
<th className="adm-th">State</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{rules.length === 0 && (
<tr>
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
No rules yet. Nothing is being sent.
</td>
</tr>
)}
{rules.map((rule) => (
<tr key={rule.id}>
<td className="adm-td" style={{ color: 'var(--text)' }}>{rule.name}</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{rule.trigger_id}</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
{describeRule(rule, { segmentsById })}
</td>
<td className="adm-td">
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={Boolean(rule.enabled)} onChange={() => toggle(rule)} />
{rule.enabled ? 'On' : 'Off'}
</label>
{rule.dormant && (
<div style={{ marginTop: 4 }}><Dormant reasons={rule.dormantReasons || []} /></div>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button
type="button"
className="pill"
style={{ fontSize: '0.72rem', marginRight: 6 }}
onClick={() => setEditing({ rule })}
>
Edit
</button>
<button
type="button"
className="pill"
style={{ ...DANGER, fontSize: '0.72rem' }}
onClick={() => remove(rule)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{rules.some((r) => r.dormant) && (
<p className="sans" style={{ marginTop: 12, fontSize: '0.8rem', color: 'var(--muted)' }}>
A dormant rule names something that is not registered right now — usually a module that has
been uninstalled. It is kept exactly as it is, it never fires, and it starts working again
when the module comes back. It can still be switched off.
</p>
)}
</section>
)
}

View File

@@ -0,0 +1,172 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Admin → Engagement → Send Log (ENGAGEMENT.md §4.5, gap G15, Phase 5b).
//
// G15 was stated as: "no per-message record — no send log, no delivery status, no
// audit". The table has been filling since Phase 4a; this is the screen that reads
// it, and the question it exists to answer is the operator's, not the engine's:
// **did that person get that mail, and if not, why not?**
//
// Two things it deliberately does not show.
//
// • **The address.** The log stores a sha256 so a bounce can be correlated back
// to a recipient (Phase 9) without becoming a second address book. The route
// strips the column; this screen could not render it if it wanted to.
// • **A name for the user.** The `user_id` is what the log holds, and joining
// users in would make a delivery screen into a directory. The id is enough to
// paste into Moderation, which is where a person's record belongs.
//
// `failed` rows are the point of the screen, so the reason is a column and not a
// tooltip: a delivery log whose failures need a hover is a log nobody reads.
const STATUS_LABEL = {
sent: 'Sent',
failed: 'Failed',
suppressed: 'Not sent',
bounced: 'Bounced',
complained: 'Marked as spam',
}
const STATUS_COLOR = {
failed: '#d98b84',
bounced: '#d98b84',
complained: '#d98b84',
}
const PAGE = 50
export default function EngagementSendLog() {
const [rows, setRows] = useState([])
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const [status, setStatus] = useState('')
const [testTrigger, setTestTrigger] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const load = useCallback(async (nextOffset, nextStatus) => {
const result = await api.admin.listEngagementSends({
limit: PAGE,
offset: nextOffset,
status: nextStatus || undefined,
})
setRows(result.sends || [])
setTotal(result.total || 0)
setTestTrigger(result.testSendTrigger || '')
}, [])
useEffect(() => {
let alive = true
;(async () => {
setLoading(true)
try {
await load(offset, status)
if (alive) setError(null)
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => { alive = false }
}, [load, offset, status])
if (loading && rows.length === 0) return <Loading />
if (error) return <ErrorState message={error} />
const to = Math.min(offset + PAGE, total)
return (
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 560 }}>
Every message this deployment tried to deliver, successful or not. Addresses are not kept
here — only a one-way hash, so a bounce can be matched back without the log becoming a
second address book.
</p>
<label>
<span className="field-label">Show</span>
<select className="select" value={status} onChange={(e) => { setOffset(0); setStatus(e.target.value) }}>
<option value="">Everything</option>
<option value="sent">Sent</option>
<option value="failed">Failed</option>
<option value="suppressed">Not sent</option>
<option value="bounced">Bounced</option>
<option value="complained">Marked as spam</option>
</select>
</label>
</div>
{total === 0 ? (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
{status ? 'Nothing matches that filter.' : 'Nothing has been sent yet.'}
</p>
) : (
<>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">When</th>
<th className="adm-th">What</th>
<th className="adm-th">To</th>
<th className="adm-th">Channel</th>
<th className="adm-th">Result</th>
<th className="adm-th">Detail</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
{new Date(r.created_at).toLocaleString()}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{/* The synthetic test-send id is rendered by name: it is not a
registered trigger and will never appear in the catalog,
so showing the raw id would send someone looking for it. */}
{r.trigger_id === testTrigger
? <span>Test send <span className="dim">from the template editor</span></span>
: <code style={{ fontSize: '0.8rem' }}>{r.trigger_id}</code>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{r.user_id ? <span className="dim">user #{r.user_id}</span> : <span className="dim">—</span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{r.channel}
{r.transport && <span className="dim"> · {r.transport}</span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem', color: STATUS_COLOR[r.status] || undefined }}>
{STATUS_LABEL[r.status] || r.status}
</td>
<td className="adm-td" style={{ fontSize: '0.8rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
{r.detail || ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
{offset + 1}–{to} of {total}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
Newer
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
disabled={to >= total} onClick={() => setOffset(offset + PAGE)}>
Older
</button>
</div>
</div>
</>
)}
</section>
)
}

View File

@@ -0,0 +1,259 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Admin → Engagement → Suppressions (ENGAGEMENT.md §4.5 gap G16, Phase 9).
//
// **This screen is the only way out of the suppression list**, which is the whole
// reason it exists rather than the list living as a filter on the Send Log. A
// hard bounce is written by a background worker with no human in the loop, so
// without a lift button a mistyped-then-corrected mailbox is silenced for good
// and nobody ever finds out why that person stopped hearing from the deployment.
//
// **Addresses are shown masked, and the mask is deliberate on both ends.** The
// table holds a sha256 and an `address_masked` — `d***@example.com` — and the
// route never returns the hash, for the same reason the Send Log strips it: a
// digest of every address on the deployment, handed to a browser, is an offline
// dictionary attack waiting to be run. The domain survives because the signal an
// operator is actually hunting is domain-shaped ("everything to this company is
// bouncing" is a different problem from three people mistyping their own
// address), and the local part is destroyed rather than shortened so the list can
// never be read back as an address book.
//
// The consequence to keep in mind while reading this file: **lifting a
// suppression needs the WHOLE address typed in**, because the screen genuinely
// does not have it. That is not a rough edge to be smoothed later — it is the
// privacy design working, and the confirm dialog says so.
const REASON_LABEL = {
bounce: 'Hard bounce',
complaint: 'Marked as spam',
manual: 'Added by an admin',
unverified: 'Unverified',
}
const REASON_HELP = {
bounce: 'The receiving server said this mailbox does not exist.',
complaint: 'The recipient reported a message as spam.',
manual: 'Somebody here added it — usually a bounce reported another way.',
unverified: 'Reserved: the verification gate excludes these before a send is queued.',
}
const PAGE = 50
export default function EngagementSuppressions() {
const [rows, setRows] = useState([])
const [total, setTotal] = useState(0)
const [byReason, setByReason] = useState({})
const [offset, setOffset] = useState(0)
const [reason, setReason] = useState('')
const [search, setSearch] = useState('')
// Debounced separately from `search` so typing a domain does not fire a request
// per keystroke; `search` is what the input shows, `applied` is what was asked.
const [applied, setApplied] = useState('')
const [adding, setAdding] = useState('')
const [note, setNote] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const load = useCallback(async (nextOffset, nextReason, nextSearch) => {
const result = await api.admin.listEngagementSuppressions({
limit: PAGE,
offset: nextOffset,
reason: nextReason || undefined,
search: nextSearch || undefined,
})
setRows(result.suppressions || [])
setTotal(result.total || 0)
setByReason(result.byReason || {})
}, [])
useEffect(() => {
const t = setTimeout(() => { setOffset(0); setApplied(search.trim()) }, 300)
return () => clearTimeout(t)
}, [search])
const refresh = useCallback(async () => {
setLoading(true)
try {
await load(offset, reason, applied)
setError(null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [load, offset, reason, applied])
useEffect(() => { refresh() }, [refresh])
async function addByHand(e) {
e.preventDefault()
const address = adding.trim()
if (!address) return
setNote(null)
try {
const result = await api.admin.suppressAddress(address)
// `created: false` is not a failure — the operator asked for the address to
// be suppressed and it is. Saying so plainly beats an error dialog for an
// outcome that is exactly what was wanted.
setNote(result.created
? `${result.address} will no longer be mailed.`
: `${result.address} was already suppressed.`)
setAdding('')
await refresh()
} catch (err) {
setNote(err.message)
}
}
async function lift() {
// The address cannot come from the row — the screen has only the mask. Asking
// for it in full is the cost of not storing it, and the prompt says why so it
// does not read as a missing feature.
const address = window.prompt(
'Type the full address to let it be mailed again.\n\n'
+ 'Suppressed addresses are stored one-way, so this screen never has the address itself.',
)
if (!address || !address.trim()) return
setNote(null)
try {
await api.admin.unsuppressAddress(address.trim())
setNote(`${address.trim()} can be mailed again.`)
await refresh()
} catch (err) {
setNote(err.message)
}
}
if (loading && rows.length === 0 && !applied && !reason) return <Loading />
if (error) return <ErrorState message={error} />
const to = Math.min(offset + PAGE, total)
const summary = Object.entries(byReason).filter(([, n]) => n > 0)
return (
<section>
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 620 }}>
Addresses this deployment has stopped mailing. Engagement rules skip them; password resets,
invites and verification mails still go out, because those are asked for by the person
themselves. Addresses are stored one-way and shown masked.
</p>
{summary.length > 0 && (
<div className="panel-flat" style={{ display: 'flex', gap: 24, flexWrap: 'wrap', padding: '12px 16px', marginBottom: 16 }}>
{summary.map(([r, n]) => (
<div key={r}>
<div className="sans" style={{ fontSize: '1.1rem', fontWeight: 600 }}>{n}</div>
<div className="sans dim" style={{ fontSize: '0.76rem' }} title={REASON_HELP[r] || ''}>
{REASON_LABEL[r] || r}
</div>
</div>
))}
</div>
)}
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap', marginBottom: 16 }}>
<label style={{ flex: '1 1 220px' }}>
<span className="field-label">Search</span>
<input
className="input"
value={search}
placeholder="a domain, or part of one"
onChange={(e) => setSearch(e.target.value)}
/>
</label>
<label>
<span className="field-label">Reason</span>
<select className="select" value={reason} onChange={(e) => { setOffset(0); setReason(e.target.value) }}>
<option value="">Any</option>
{Object.keys(REASON_LABEL).map((r) => (
<option key={r} value={r}>{REASON_LABEL[r]}</option>
))}
</select>
</label>
<form onSubmit={addByHand} style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flex: '1 1 280px' }}>
<label style={{ flex: 1 }}>
<span className="field-label">Suppress an address</span>
<input
className="input"
type="email"
value={adding}
placeholder="someone@example.com"
onChange={(e) => setAdding(e.target.value)}
/>
</label>
<button type="submit" className="pill" style={{ fontSize: '0.74rem' }} disabled={!adding.trim()}>
Suppress
</button>
</form>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} onClick={lift}>
Lift a suppression
</button>
</div>
{note && (
<p className="sans" style={{ fontSize: '0.82rem', margin: '0 0 14px' }}>{note}</p>
)}
{total === 0 ? (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
{reason || applied ? 'Nothing matches that filter.' : 'No addresses are suppressed.'}
</p>
) : (
<>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Address</th>
<th className="adm-th">Reason</th>
<th className="adm-th">Detail</th>
<th className="adm-th">Channel</th>
<th className="adm-th">Since</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={`${r.channel}:${r.address_masked}:${r.created_at}`}>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{r.address_masked
? <code style={{ fontSize: '0.8rem' }}>{r.address_masked}</code>
: <span className="dim">not recorded</span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }} title={REASON_HELP[r.reason] || ''}>
{REASON_LABEL[r.reason] || r.reason}
</td>
<td className="adm-td" style={{ fontSize: '0.8rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
{r.detail || ''}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{r.channel}</td>
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
{new Date(r.created_at).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
{offset + 1}–{to} of {total}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
Newer
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
disabled={to >= total} onClick={() => setOffset(offset + PAGE)}>
Older
</button>
</div>
</div>
</>
)}
</section>
)
}

View File

@@ -0,0 +1,649 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { getEmailBlock, listEmailBlocks, newEmailBlock } from '../../../emailBlocks/index.js'
// Admin → Engagement → Templates (ENGAGEMENT.md §4.6.2, Phase 5b).
//
// Phase 5a moved every subject and body out of `mailer.js` into rows. This is the
// screen that lets someone change one, and its whole shape follows from a single
// fact about email:
//
// **the server renders the mail, so the server renders the preview.**
//
// There is no React renderer for an `email.*` block anywhere in this client. The
// preview is HTML the server produced with the same call the send path uses,
// dropped into a sandboxed iframe. That costs a round trip per edit — debounced
// below — and buys the only property that matters on a screen like this: what is
// on screen is what will arrive, not a second implementation's opinion of it.
//
// **The sandbox is a security boundary, not a nicety.** The preview is
// operator-authored HTML. It renders with `sandbox` and no `allow-scripts`, from
// `srcdoc` (an opaque origin), so it can neither run script nor reach this page's
// cookies even if someone stores markup that gets past `sanitizeHtml`. The
// attributes are asserted in `client/test/emailTemplates.test.js` for the same
// reason the server's checks are asserted: this is the kind of attribute someone
// removes while debugging and does not put back.
//
// What the operator can do here is deliberately bounded (settled with the org
// lead at the start of the phase):
//
// • **A shipped default is edited in place.** `protected` blocks deletion and
// nothing else; saving sets `customized = 1`, which is what stops the next
// seed bump from taking the edit back.
// • **Duplicate is the only way to a new template**, so every template on a
// deployment descends from one that renders.
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
// Three widths, because a mail body has to survive all of them and the failures
// are different: 640 is a desktop client's reading pane, 360 is a phone, and the
// plain-text part is what a text-only client and every screen reader gets.
const WIDTHS = [
['desktop', 'Desktop', 640],
['mobile', 'Mobile', 360],
]
/** Short, human label for a template's channel. */
const CHANNEL_LABEL = { email: 'Email', inapp: 'On the site', push: 'Push' }
// ── The preview frame ──────────────────────────────────────────────────────
/**
* The rendered HTML, in a sandboxed frame.
*
* `dark` applies a CSS inversion to the FRAME, not to the mail: it approximates
* what Apple Mail and Outlook do to a light-only message, which is the failure
* §4.6.2 asks this control to expose ("a light-only template renders as unreadable
* dark-on-dark in about a third of inboxes"). It is an approximation and says so
* on screen — the alternative, rendering a second dark palette server-side, would
* be a preview of a mail this system does not send.
*/
function PreviewFrame({ html, width, dark }) {
return (
<div
style={{
background: dark ? '#1b1b1b' : '#f4f4f5',
padding: 12,
borderRadius: 6,
overflowX: 'auto',
}}
>
<iframe
// No allow-scripts, and no allow-same-origin. Both omissions are load
// bearing; see this file's header.
sandbox=""
srcDoc={html || ''}
title="Message preview"
style={{
width,
maxWidth: '100%',
height: 520,
border: '1px solid var(--rule)',
borderRadius: 4,
background: '#fff',
display: 'block',
margin: '0 auto',
filter: dark ? 'invert(1) hue-rotate(180deg)' : 'none',
}}
/>
</div>
)
}
// ── The editor ─────────────────────────────────────────────────────────────
function TemplateEditor({ template, triggers, onDone, onCancel }) {
const [name, setName] = useState(template.name)
const [subject, setSubject] = useState(template.subject || '')
const [blocks, setBlocks] = useState(template.blocks || [])
const [textBody, setTextBody] = useState(template.text_body || '')
const [status, setStatus] = useState(template.status)
const [triggerId, setTriggerId] = useState(template.trigger_id || '')
const [selected, setSelected] = useState(template.blocks?.[0]?.id || null)
const [preview, setPreview] = useState(null)
const [previewError, setPreviewError] = useState(null)
const [tab, setTab] = useState('html')
const [width, setWidth] = useState('desktop')
const [dark, setDark] = useState(false)
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState([])
const [saved, setSaved] = useState(false)
const [testTo, setTestTo] = useState('')
const [testState, setTestState] = useState(null)
// The variable palette. It comes from the server with the row and is refreshed
// by every preview, because re-pointing the template at another trigger changes
// it and the server is the one that knows what that trigger declares.
const [variables, setVariables] = useState(template.variables || [])
const draft = useMemo(
() => ({ name, subject, blocks, textBody: textBody || null, status, triggerId: triggerId || null }),
[name, subject, blocks, textBody, status, triggerId],
)
// Debounced preview. The delay is not about server load — it is one small
// render — but about the frame: re-mounting an iframe on every keystroke makes
// the preview flicker and steals nothing back.
const timer = useRef(null)
useEffect(() => {
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(async () => {
try {
const body = { subject: draft.subject, blocks: draft.blocks, textBody: draft.textBody, triggerId: draft.triggerId }
const result = await api.admin.previewEngagementTemplate(template.id, body)
setPreview(result)
setPreviewError(null)
if (Array.isArray(result.variables)) setVariables(result.variables)
} catch (err) {
// A preview failure is expected while a block is half-edited, so it is
// shown where the preview would be rather than as a page-level error.
setPreviewError(err.body?.errors?.join(' · ') || err.message)
}
}, 400)
return () => timer.current && clearTimeout(timer.current)
}, [draft, template.id])
const selectedBlock = blocks.find((b) => b.id === selected) || null
const selectedDef = selectedBlock ? getEmailBlock(selectedBlock.type) : null
const updateBlock = (id, props) =>
setBlocks((bs) => bs.map((b) => (b.id === id ? { ...b, props } : b)))
const addBlock = (type) => {
const block = newEmailBlock(type)
if (!block) return
setBlocks((bs) => [...bs, block])
setSelected(block.id)
}
const move = (id, delta) =>
setBlocks((bs) => {
const i = bs.findIndex((b) => b.id === id)
const j = i + delta
if (i < 0 || j < 0 || j >= bs.length) return bs
const next = [...bs]
;[next[i], next[j]] = [next[j], next[i]]
return next
})
const removeBlock = (id) =>
setBlocks((bs) => {
const next = bs.filter((b) => b.id !== id)
if (selected === id) setSelected(next[0]?.id || null)
return next
})
async function save() {
setSaving(true)
setErrors([])
setSaved(false)
try {
await api.admin.updateEngagementTemplate(template.id, draft)
setSaved(true)
onDone()
} catch (err) {
setErrors(err.body?.errors?.length ? err.body.errors : [err.message])
} finally {
setSaving(false)
}
}
async function sendTest() {
setTestState({ busy: true })
try {
const body = { ...draft, to: testTo }
const result = await api.admin.testSendEngagementTemplate(template.id, body)
setTestState({ ok: true, message: `Sent to ${result.to}.` })
} catch (err) {
setTestState({ ok: false, message: err.body?.errors?.join(' · ') || err.message })
}
}
const widthPx = WIDTHS.find(([id]) => id === width)?.[2] || 640
return (
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 16 }}>
<div>
<h2 className="sans" style={{ margin: '0 0 4px', fontSize: '1.05rem' }}>{template.name}</h2>
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>
<code>{template.key}</code> · {CHANNEL_LABEL[template.channel] || template.channel}
{template.protected && ' · part of the system'}
</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn btn-sq" onClick={onCancel}>Back</button>
<button type="button" className="btn btn-primary btn-sq" onClick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
{errors.length > 0 && (
<div className="panel" style={{ padding: 14, marginBottom: 16, borderColor: '#5b2020' }}>
{errors.map((e) => (
<p key={e} className="sans" style={{ margin: '0 0 4px', color: '#d98b84', fontSize: '0.85rem' }}>{e}</p>
))}
</div>
)}
{saved && errors.length === 0 && (
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.85rem', color: 'var(--muted)' }}>Saved.</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 1fr) minmax(320px, 1.2fr)', gap: 22, alignItems: 'start' }}>
{/* ── Authoring ── */}
<div>
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Name</span>
<input className="input" value={name} maxLength={160} onChange={(e) => setName(e.target.value)} />
</label>
{template.channel === 'email' && (
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Subject</span>
<input className="input" value={subject} maxLength={300} onChange={(e) => setSubject(e.target.value)} />
<VariableButtons variables={variables} onInsert={(t) => setSubject((s) => s + t)} />
</label>
)}
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Trigger</span>
<select className="select" value={triggerId} onChange={(e) => setTriggerId(e.target.value)}>
{/* "None" is the right default and not a missing value: every
transactional template is tied to no trigger — mailer renders
it by key with no rule involved. */}
<option value="">None — used by key, not by a rule</option>
{triggers.map((t) => (
<option key={t.id} value={t.id}>{t.label} ({t.id})</option>
))}
</select>
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
The trigger decides which variables this template may use.
</span>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Status</span>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="draft">Draft — the shipped default is sent instead</option>
<option value="published">Published — this is what goes out</option>
</select>
</label>
</div>
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Body</div>
{blocks.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>No blocks yet. Add one below.</p>
)}
{blocks.map((b, i) => {
const def = getEmailBlock(b.type)
return (
<div
key={b.id}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', marginBottom: 4,
borderRadius: 4, cursor: 'pointer',
background: b.id === selected ? 'var(--panel-2, rgba(255,255,255,0.05))' : 'transparent',
border: `1px solid ${b.id === selected ? 'var(--accent)' : 'transparent'}`,
}}
onClick={() => setSelected(b.id)}
>
<span style={{ width: 18, textAlign: 'center' }}>{def?.icon || '?'}</span>
<span className="sans" style={{ flex: 1, fontSize: '0.86rem' }}>
{/* An unknown type is a client/server version skew, and saying
so beats rendering a blank row the operator cannot act on. */}
{def ? def.label : `${b.type} (not known to this client)`}
</span>
<button type="button" className="pill" style={{ fontSize: '0.7rem' }} disabled={i === 0}
onClick={(e) => { e.stopPropagation(); move(b.id, -1) }}>↑</button>
<button type="button" className="pill" style={{ fontSize: '0.7rem' }} disabled={i === blocks.length - 1}
onClick={(e) => { e.stopPropagation(); move(b.id, 1) }}>↓</button>
<button type="button" className="pill" style={{ ...DANGER, fontSize: '0.7rem' }}
onClick={(e) => { e.stopPropagation(); removeBlock(b.id) }}>×</button>
</div>
)
})}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 12 }}>
{listEmailBlocks().map((def) => (
<button key={def.type} type="button" className="pill" title={def.hint}
style={{ fontSize: '0.74rem' }} onClick={() => addBlock(def.type)}>
+ {def.label}
</button>
))}
</div>
</div>
{selectedBlock && selectedDef?.editor && (
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
<div className="field-label" style={{ marginBottom: 10 }}>{selectedDef.label}</div>
<selectedDef.editor
props={selectedBlock.props || {}}
variables={variables}
onChange={(props) => updateBlock(selectedBlock.id, props)}
/>
</div>
)}
<div className="panel" style={{ padding: 18 }}>
<label style={{ display: 'block' }}>
<span className="field-label">Plain-text part (optional override)</span>
<textarea
className="input" rows={5} value={textBody}
placeholder="Leave blank to generate it from the blocks above."
onChange={(e) => setTextBody(e.target.value)}
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '0.82rem' }}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
Every message has both parts. Writing one here REPLACES the generated text entirely.
</span>
</label>
</div>
</div>
{/* ── Preview ── */}
<div>
<div style={{ display: 'flex', gap: 6, marginBottom: 10, flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: tab === 'html' ? 1 : 0.6 }}
onClick={() => setTab('html')}>HTML</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: tab === 'text' ? 1 : 0.6 }}
onClick={() => setTab('text')}>Plain text</button>
{tab === 'html' && (
<>
<span style={{ width: 10 }} />
{WIDTHS.map(([id, label]) => (
<button key={id} type="button" className="pill"
style={{ fontSize: '0.74rem', opacity: width === id ? 1 : 0.6 }}
onClick={() => setWidth(id)}>{label}</button>
))}
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: dark ? 1 : 0.6 }}
onClick={() => setDark((d) => !d)}>Dark mode</button>
</>
)}
</div>
{previewError ? (
<div className="panel" style={{ padding: 16, borderColor: '#5b2020' }}>
<p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{previewError}</p>
</div>
) : !preview ? (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Rendering…</p>
) : tab === 'html' ? (
<>
{template.channel === 'email' && (
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.85rem' }}>
<span className="dim">Subject: </span>{preview.subject || <em className="dim">none</em>}
</p>
)}
<PreviewFrame html={preview.html} width={widthPx} dark={dark} />
{dark && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 6 }}>
An approximation of how a client that inverts a light-only message will show it.
</p>
)}
</>
) : (
<pre className="panel" style={{ padding: 16, fontSize: '0.82rem', whiteSpace: 'pre-wrap', margin: 0 }}>
{preview.text || '(empty — a published template is refused with no text part)'}
</pre>
)}
{preview?.missing?.length > 0 && (
<p className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
No example value for: {preview.missing.join(', ')} — these render as nothing here and
will carry real values when the message is actually sent.
</p>
)}
<div className="panel" style={{ padding: 18, marginTop: 18 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Send a test</div>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
Sends what is on screen, saved or not, through the configured transport.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<input className="input" type="email" placeholder="you@example.com" value={testTo}
onChange={(e) => setTestTo(e.target.value)} style={{ flex: 1 }} />
<button type="button" className="btn btn-sq" onClick={sendTest} disabled={testState?.busy}>
{testState?.busy ? 'Sending…' : 'Send'}
</button>
</div>
{testState && !testState.busy && (
<p className="sans" style={{ margin: '8px 0 0', fontSize: '0.82rem', color: testState.ok ? 'var(--muted)' : '#d98b84' }}>
{testState.message}
</p>
)}
</div>
</div>
</div>
</section>
)
}
/** The variable tokens, for the two fields that are not block props. */
function VariableButtons({ variables, onInsert }) {
if (!variables?.length) return null
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
{variables.map((v) => (
<button key={v.name} type="button" className="btn btn-ghost btn-xs"
title={`${v.type || 'string'}${v.description ? ` — ${v.description}` : ''}`}
style={{ fontFamily: 'monospace', fontSize: '0.72rem', padding: '2px 6px' }}
onClick={() => onInsert(`{{${v.name}}}`)}>
{v.name}
</button>
))}
</div>
)
}
// ── Duplicate ──────────────────────────────────────────────────────────────
function DuplicateForm({ source, triggers, onDone, onCancel }) {
const [key, setKey] = useState('')
const [name, setName] = useState(`${source.name} (copy)`)
const [triggerId, setTriggerId] = useState(source.trigger_id || '')
const [errors, setErrors] = useState([])
async function submit(e) {
e.preventDefault()
setErrors([])
try {
const { template } = await api.admin.duplicateEngagementTemplate(source.id, { key, name, triggerId: triggerId || null })
onDone(template)
} catch (err) {
setErrors(err.body?.errors?.length ? err.body.errors : [err.message])
}
}
return (
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.98rem' }}>Duplicate “{source.name}”</h3>
<p className="sans dim" style={{ margin: '0 0 16px', fontSize: '0.82rem' }}>
The copy starts as a draft, so nothing sends it until you publish it.
</p>
{errors.map((e) => (
<p key={e} className="sans" style={{ margin: '0 0 8px', color: '#d98b84', fontSize: '0.85rem' }}>{e}</p>
))}
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Key</span>
<input className="input" value={key} maxLength={96} placeholder="notify.my-event"
onChange={(e) => setKey(e.target.value)} />
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
How a rule points at this template. Lowercase letters, digits, dots and dashes; it cannot be
changed afterwards.
</span>
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Name</span>
<input className="input" value={name} maxLength={160} onChange={(e) => setName(e.target.value)} />
</label>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Trigger</span>
<select className="select" value={triggerId} onChange={(e) => setTriggerId(e.target.value)}>
<option value="">None — used by key, not by a rule</option>
{triggers.map((t) => <option key={t.id} value={t.id}>{t.label} ({t.id})</option>)}
</select>
</label>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq">Duplicate</button>
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}
// ── The list ───────────────────────────────────────────────────────────────
export default function EngagementTemplates() {
const [templates, setTemplates] = useState([])
const [triggers, setTriggers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [rowError, setRowError] = useState(null)
const [editing, setEditing] = useState(null)
const [duplicating, setDuplicating] = useState(null)
const load = useCallback(async () => {
const [t, tr] = await Promise.all([api.admin.listEngagementTemplates(), api.admin.engagementTriggers()])
setTemplates(t.templates || [])
setTriggers(tr.triggers || [])
}, [])
useEffect(() => {
let alive = true
;(async () => {
try {
await load()
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => { alive = false }
}, [load])
async function open(row) {
setRowError(null)
try {
const { template } = await api.admin.getEngagementTemplate(row.id)
setEditing(template)
} catch (err) {
setRowError(err.message)
}
}
async function remove(row) {
if (!window.confirm(`Delete “${row.name}”?`)) return
setRowError(null)
try {
await api.admin.deleteEngagementTemplate(row.id)
await load()
} catch (err) {
setRowError(err.body?.errors?.join(' · ') || err.message)
}
}
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
if (editing) {
return (
<TemplateEditor
template={editing}
triggers={triggers}
onDone={load}
onCancel={async () => { setEditing(null); await load() }}
/>
)
}
return (
<section>
{duplicating && (
<DuplicateForm
source={duplicating}
triggers={triggers}
onCancel={() => setDuplicating(null)}
onDone={async (template) => { setDuplicating(null); await load(); setEditing(template) }}
/>
)}
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 680 }}>
Every message this deployment sends. The shipped ones are editable — your edits survive
upgrades — and cannot be deleted, because the system breaks without them. To make a new
template, duplicate one that already works.
</p>
{rowError && (
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
)}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Name</th>
<th className="adm-th">Key</th>
<th className="adm-th">Channel</th>
<th className="adm-th">Status</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{templates.map((t) => (
<tr key={t.id}>
<td className="adm-td">
{t.name}
{t.protected && (
<span className="pill" style={{ marginLeft: 8, fontSize: '0.68rem' }}>system</span>
)}
<Flags template={t} />
</td>
<td className="adm-td"><code style={{ fontSize: '0.8rem' }}>{t.key}</code></td>
<td className="adm-td">{CHANNEL_LABEL[t.channel] || t.channel}</td>
<td className="adm-td">{t.status === 'published' ? 'Published' : 'Draft'}</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginRight: 6 }}
onClick={() => open(t)}>Edit</button>
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginRight: 6 }}
onClick={() => setDuplicating(t)}>Duplicate</button>
<button type="button" className="pill"
style={{ ...DANGER, fontSize: '0.72rem', opacity: t.protected ? 0.4 : 1 }}
disabled={t.protected}
title={t.protected ? 'Part of the system — edit it or duplicate it' : undefined}
onClick={() => remove(t)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
/**
* The three warnings a row can carry. Each is a different fact and they are worded
* as what an operator should DO, not as the flag name: "dormant" and "behind" mean
* nothing to someone who has not read the design document.
*/
function Flags({ template }) {
const notes = []
if (template.dormant) {
notes.push(`No installed module declares ${template.trigger_id} — nothing will send this.`)
}
if (template.triggerBehind) {
notes.push('Its trigger has changed since this was written; check the variables still exist.')
}
if (template.seedBehind) {
notes.push('A newer version of the shipped default exists. Your edits were kept, so it was not applied.')
}
if (!notes.length) return null
return (
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{notes.map((n) => <div key={n}>{n}</div>)}
</div>
)
}

View File

@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Admin → Engagement → Triggers (ENGAGEMENT.md §4.3, Phase 5b).
//
// Read-only, and structurally so: **there is no table behind this screen.** A
// trigger is DECLARED in code by core or by an installed module, so this is
// whatever registered on the current boot. Uninstall a module and its triggers
// stop appearing here; nothing was deleted and nothing needs to be.
//
// It exists because the two things it shows are otherwise invisible and both are
// load-bearing elsewhere:
//
// • **The variables** are the contract a template may reference. When a rule
// mails nothing sensible, "which variables does this event actually carry"
// is the first question, and the answer used to live only in a module's source.
// • **The ceiling** is the security boundary from G24 — the widest audience a
// rule may ever give this trigger. A rule editor that offers a narrower set
// than an operator expects is obeying a number declared here.
const CEILING_NOTE = {
owner: 'only the person the event is about',
members: 'only members of the thing it is about',
subscribers: 'only people who opted in',
staff: 'only staff',
admin: 'only administrators',
authenticated: 'any signed-in account',
everyone: 'anyone',
}
export default function EngagementTriggers() {
const [triggers, setTriggers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let alive = true
;(async () => {
try {
const { triggers: list } = await api.admin.engagementTriggers()
if (alive) setTriggers(list || [])
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => { alive = false }
}, [])
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
return (
<section>
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 680 }}>
The events a rule can be built on, declared in code by core and by installed modules. This
list is whatever is registered right now — it is not stored anywhere, so a module that is
uninstalled simply stops appearing.
</p>
{triggers.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Nothing is registered.</p>
)}
{triggers.map((t) => (
<div className="panel" key={t.id} style={{ padding: 18, marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
<div>
<h3 className="sans" style={{ margin: '0 0 2px', fontSize: '0.98rem' }}>{t.label}</h3>
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
<code>{t.id}</code> · from {t.owner} · v{t.version}
</p>
</div>
<div style={{ textAlign: 'right' }}>
<div className="field-label" style={{ marginBottom: 2 }}>Can reach at most</div>
<div className="sans" style={{ fontSize: '0.84rem' }}>
{t.ceiling}
<span className="dim"> — {CEILING_NOTE[t.ceiling] || 'see the design document'}</span>
</div>
</div>
</div>
{t.description && (
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.84rem', color: 'var(--muted)' }}>
{t.description}
</p>
)}
{(t.variables || []).length > 0 && (
<table className="adm-table" style={{ marginTop: 14 }}>
<thead>
<tr>
<th className="adm-th">Variable</th>
<th className="adm-th">Type</th>
<th className="adm-th">Example</th>
<th className="adm-th">What it is</th>
</tr>
</thead>
<tbody>
{t.variables.map((v) => (
<tr key={v.name}>
{/* `nowrap`: without it the "always set" pill wraps between its
two words on a longer variable name, orphaning "set" on a
line of its own and making the row read as two facts. */}
<td className="adm-td" style={{ whiteSpace: 'nowrap' }}>
<code style={{ fontSize: '0.8rem' }}>{`{{${v.name}}}`}</code>
{v.required && <span className="pill" style={{ marginLeft: 6, fontSize: '0.66rem' }}>always set</span>}
</td>
<td className="adm-td">{v.type}</td>
<td className="adm-td" style={{ maxWidth: 260, overflowWrap: 'anywhere' }}>
<span className="dim" style={{ fontSize: '0.8rem' }}>
{/* A list variable's example is an array of objects; showing
it as JSON is honest and short, and it is the shape an
item list repeats over. */}
{typeof v.example === 'string' ? v.example : JSON.stringify(v.example)}
</span>
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{v.description || ''}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
))}
</section>
)
}

View File

@@ -0,0 +1,292 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
import {
eventLabel, rowKey, isDefaultRow, blankDraft, draftFrom, appliesToLabel, toggleEvent,
setChannel, needsAcknowledgement, membersOnlyIdsOf, availableTargets,
} from '../../../lib/teamIntegrations.js'
// The Team notification bridge (TEAMS.md §7.2, phase 8).
//
// Named for the TEAM concern rather than for Discord, and placed under Teams
// rather than in the Discord Bot panel, because phase 10 replaces "Discord" here
// with whatever the capability registry declares. What changes then should be
// what fills this panel, not where an operator goes to find it. Nothing below
// hardcodes the word except the heading the server sends as `platform`.
//
// **The checkbox in the dialog is not the gate.** The server refuses to enable a
// row carrying `team.forum.post` or `team.announcement` without the
// acknowledgement, 422, whether or not this dialog was ever rendered — the same
// division TeamForumSettings draws for image uploads. What is here is how the
// gate is PRESENTED: the sentence an operator agrees to, and the fact that
// agreeing is a deliberate act rather than a checkbox they tab past.
const PANEL = { padding: 22, marginBottom: 22, maxWidth: 760 }
const HEADING = { margin: '0 0 6px', fontSize: '1.2rem', color: 'var(--head)' }
const ACK_TEXT = [
'Forum posts and announcements are visible only to a Team’s members. This site cannot see who can'
+ ' read a channel on another platform, so it cannot check that for you.',
'By enabling these events you confirm that the destination channel is restricted to the members of'
+ ' the Team whose posts it will carry.',
]
export default function TeamIntegrations() {
const [config, setConfig] = useState(null)
const [teams, setTeams] = useState([])
const [draft, setDraft] = useState(null)
const [dialog, setDialog] = useState(null)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
setError('')
try {
const [cfg, teamList] = await Promise.all([api.admin.teamIntegrations(), api.admin.listTeams()])
setConfig(cfg)
setTeams((teamList.teams || []).filter((t) => t.status === 'active'))
} catch (err) {
// A moderator never reaches this panel — the admin nav does not render it —
// so a 403 here means the role changed underneath an open tab rather than a
// routing mistake, and saying so beats "could not load".
setError(err.status === 403 ? 'Only an admin can configure the notification bridge.' : (err.message || 'Could not load the bridge configuration.'))
}
}, [])
useEffect(() => { load() }, [load])
if (!config) {
return (
<section className="panel" style={PANEL}>
<h2 className="display" style={HEADING}>Notification bridge</h2>
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
</section>
)
}
const membersOnlyIds = membersOnlyIdsOf(config.events)
const { hasDefault, teams: available } = availableTargets(config.rows, teams)
async function persist(next) {
setBusy(true)
setError('')
setNotice('')
try {
await api.admin.saveTeamIntegration({
teamId: next.teamId,
events: next.events,
channelRef: next.channelRef.trim() || null,
enabled: next.enabled,
membersAck: next.membersAck,
})
setDraft(null)
setDialog(null)
setNotice('Saved.')
await load()
} catch (err) {
setError(err.message || 'Could not save.')
setDialog(null)
} finally {
setBusy(false)
}
}
// Enabling members-only events without a standing acknowledgement asks first.
// Everything else — disabling, editing a channel, adding a roster event — saves
// straight through.
function save() {
if (!draft) return
if (needsAcknowledgement(draft, membersOnlyIds)) {
setDialog(draft)
return
}
persist(draft)
}
async function remove(row) {
setBusy(true)
setError('')
try {
await api.admin.deleteTeamIntegration(row.team_id ?? null)
setNotice('Removed.')
await load()
} catch (err) {
setError(err.message || 'Could not remove.')
} finally {
setBusy(false)
}
}
return (
<section className="panel" style={PANEL}>
<h2 className="display" style={HEADING}>Notification bridge</h2>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
Send Team notifications to a {config.platform} channel. Set a default that every Team uses, and
override it for individual Teams. A message is sent once and not retried — the bridge is a
courtesy, and nothing on the site depends on it arriving.
</p>
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
{notice && <p className="sans" style={{ color: '#7fd0a4', fontSize: '0.82rem' }}>{notice}</p>}
{config.rows.length === 0 && !draft && (
<p className="sans dim" style={{ fontSize: '0.8rem' }}>Nothing configured — no Team events leave the site.</p>
)}
{config.rows.length > 0 && (
<div className="panel-flat" style={{ overflowX: 'auto' }}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Applies to</th>
<th className="adm-th">Events</th>
<th className="adm-th">Channel</th>
<th className="adm-th">State</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{config.rows.map((row) => (
<tr key={rowKey(row)}>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{appliesToLabel(row)}
{isDefaultRow(row) && <span className="dim"> (default)</span>}
</td>
<td className="adm-td">
{row.events.length === 0
? <span className="dim">none</span>
: row.events.map(eventLabel).join(', ')}
</td>
<td className="adm-td dim">{row.channel_ref || <span className="dim">unset</span>}</td>
<td className="adm-td">
{row.enabled ? 'Enabled' : 'Disabled'}
{row.members_ack && (
<span className="dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 3 }}>
members-only destination confirmed
{row.members_ack_username ? ` by ${row.members_ack_username}` : ''}
</span>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => setDraft(draftFrom(row))}>Edit</button>
<button type="button" className="btn btn-ghost btn-sq" style={{ marginLeft: 8 }} disabled={busy} onClick={() => remove(row)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!draft && (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 14 }}>
{!hasDefault && (
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft(blankDraft(null))}>
Set a default for all Teams
</button>
)}
{available.length > 0 && (
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft(blankDraft(available[0].id))}>
Add a per-Team override
</button>
)}
</div>
)}
{draft && (
<div style={{ marginTop: 18, borderTop: '1px solid var(--line-soft)', paddingTop: 16 }}>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Applies to</span>
<select
className="select"
value={draft.teamId === null ? 'default' : String(draft.teamId)}
onChange={(e) => setDraft({ ...draft, teamId: e.target.value === 'default' ? null : Number(e.target.value) })}
>
<option value="default">All Teams (default)</option>
{teams.map((t) => (
<option key={t.id} value={t.id}>{t.display_name_override || t.name}</option>
))}
</select>
</label>
<span className="field-label">Events to send</span>
{config.events.map((event) => (
<label key={event.id} style={{ display: 'block', marginTop: 6 }}>
<input
type="checkbox"
checked={draft.events.includes(event.id)}
onChange={() => setDraft((d) => toggleEvent(d, event.id))}
style={{ marginRight: 8 }}
/>
<span className="sans" style={{ fontSize: '0.82rem' }}>{eventLabel(event.id)}</span>
{event.membersOnly && (
<span className="dim sans" style={{ fontSize: '0.72rem', marginLeft: 8 }}>members-only content</span>
)}
</label>
))}
<label style={{ display: 'block', marginTop: 14 }}>
<span className="field-label">Channel id</span>
<input
className="input"
value={draft.channelRef}
// Changing the channel drops a standing acknowledgement in the SAME
// place the server does. Leaving the tick showing while the server
// has already decided to clear it would let an operator repoint a row
// at a public channel and believe the confirmation still covered it.
onChange={(e) => setDraft((d) => setChannel(d, e.target.value))}
placeholder="1024839201048392010"
style={{ maxWidth: 280 }}
/>
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
Right-click a channel in {config.platform} and copy its id. Changing it asks you to confirm
the new channel’s audience again.
</span>
</label>
<label style={{ display: 'block', marginTop: 14 }}>
<input
type="checkbox"
checked={draft.enabled}
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
style={{ marginRight: 8 }}
/>
<span className="field-label" style={{ display: 'inline' }}>Enabled</span>
</label>
{draft.membersAck && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 10 }}>
You have confirmed this channel is restricted to the Team’s members.{' '}
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft({ ...draft, membersAck: false })}>
Withdraw
</button>
</p>
)}
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={save}>Save</button>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => { setDraft(null); setError('') }}>Cancel</button>
</div>
</div>
)}
{dialog && (
<div style={{ marginTop: 18, border: '1px solid #e0b070', padding: 16, borderRadius: 'var(--radius-input)' }}>
<h3 className="display" style={{ fontSize: '0.95rem', marginTop: 0 }}>Confirm the destination’s audience</h3>
{ACK_TEXT.map((line) => (
<p key={line} className="sans" style={{ fontSize: '0.8rem' }}>{line}</p>
))}
<button
type="button"
className="btn btn-primary btn-sq"
disabled={busy}
onClick={() => persist({ ...dialog, membersAck: true })}
>
I confirm the channel is members-only
</button>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => setDialog(null)}>Cancel</button>
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,273 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
import {
stateLabel, enableBlockedReason, roleHeadroom, removalCountdown,
parseStaffRoles, formatStaffRoles, statusSummary,
} from '../../../lib/teamVoice.js'
// Team voice channels (TEAMS.md §7.3, phase 9).
//
// Named for the Team concern and placed under Teams beside the notification
// bridge, for the reason that panel gives: phase 10 replaces "Discord" with
// whatever the capability registry declares, and what should change then is what
// fills this panel rather than where an operator goes to find it.
//
// **The preflight is the first thing on the page, not a diagnostic.** §7.3
// assumed the bot could manage channels and roles; nothing in this project has
// ever checked, because the operator invites the bot by hand and no invite URL
// with a permission integer exists anywhere in the tree. An operator whose bot
// lacks Manage Roles otherwise has a screen full of controls that cannot work,
// and finds out one Team at a time from a column of identical errors.
const PANEL = { padding: 22, marginBottom: 22, maxWidth: 760 }
const HEADING = { margin: '0 0 6px', fontSize: '1.2rem', color: 'var(--head)' }
export default function TeamVoice() {
const [config, setConfig] = useState(null)
const [draft, setDraft] = useState(null)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
setError('')
try {
const cfg = await api.admin.teamVoice()
setConfig(cfg)
setDraft({
enabled: cfg.settings.enabled,
minMembers: cfg.settings.minMembers,
graceDays: cfg.settings.graceDays,
staffRoles: formatStaffRoles(cfg.settings.staffRoles),
})
} catch (err) {
// A moderator never reaches this panel — the admin nav does not render it —
// so a 403 means the role changed underneath an open tab.
setError(err.status === 403
? 'Only an admin can configure Team voice channels.'
: (err.message || 'Could not load the voice configuration.'))
}
}, [])
useEffect(() => { load() }, [load])
if (!config || !draft) {
return (
<section className="panel" style={PANEL}>
<h2 className="display" style={HEADING}>Voice channels</h2>
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
</section>
)
}
const blocked = enableBlockedReason(config.preflight)
const headroom = roleHeadroom(config.preflight)
async function save() {
const { roles, invalid } = parseStaffRoles(draft.staffRoles)
if (invalid.length > 0) {
setError(`Not a role id: ${invalid.join(', ')}. Copy role ids from Discord with Developer Mode on.`)
return
}
setBusy(true)
setError('')
setNotice('')
try {
await api.admin.saveTeamVoice({
enabled: draft.enabled,
minMembers: Number(draft.minMembers),
graceDays: Number(draft.graceDays),
staffRoles: roles,
})
setNotice('Saved.')
await load()
} catch (err) {
setError(err.message || 'Could not save.')
} finally {
setBusy(false)
}
}
async function runPass() {
setBusy(true)
setError('')
setNotice('')
try {
const result = await api.admin.teamVoicePass()
// A pass that refused says why, and that is the useful answer far more often
// than a count is — "stale projection" and "synced 0" look identical in a
// summary and mean completely different things.
setNotice(result.ran
? `Synced ${result.synced}, created ${result.created}, scheduled ${result.scheduled}, removed ${result.removed}, failed ${result.failed}.`
: `Nothing was done: ${result.reason}`)
await load()
} catch (err) {
setError(err.message || 'Could not run a pass.')
} finally {
setBusy(false)
}
}
async function remove(row) {
setBusy(true)
setError('')
try {
await api.admin.removeTeamVoice(row.teamId)
setNotice('Removed.')
await load()
} catch (err) {
setError(err.message || 'Could not remove.')
} finally {
setBusy(false)
}
}
return (
<section className="panel" style={PANEL}>
<h2 className="display" style={HEADING}>Voice channels</h2>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
Give each Team a {config.platform} voice channel of its own. Access is granted with a role per
Team, so members of a Team can see and join their channel and nobody else can. Members need a
linked {config.platform} account and must be in the guild.
</p>
{blocked && (
<p className="sans" style={{ color: '#e0b070', fontSize: '0.82rem' }}>
{blocked} Voice channels cannot be switched on until that is fixed.
</p>
)}
{headroom && (
<p className="sans dim" style={{ fontSize: '0.78rem' }}>
{headroom.used} of {headroom.cap} {config.platform} roles used in this guild
{headroom.exhausted
? ' — no room for another Team.'
: headroom.tight
? ` — room for about ${headroom.free} more Teams.`
: '.'}
</p>
)}
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
{notice && <p className="sans" style={{ color: '#7fd0a4', fontSize: '0.82rem' }}>{notice}</p>}
<p className="sans" style={{ fontSize: '0.8rem' }}>{statusSummary(config.settings, config.rows)}</p>
<div style={{ marginTop: 14, borderTop: '1px solid var(--line-soft)', paddingTop: 16 }}>
<label className="sans" style={{ display: 'block', marginBottom: 12, fontSize: '0.82rem' }}>
<input
type="checkbox"
checked={draft.enabled}
disabled={busy || (!!blocked && !draft.enabled)}
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
/>
{' '}Provision voice channels for Teams
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Minimum members</span>
<input
className="input"
type="number"
min="1"
max="10000"
value={draft.minMembers}
disabled={busy}
onChange={(e) => setDraft({ ...draft, minMembers: e.target.value })}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
Every active member counts, whether or not they have linked an account.
</span>
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Grace window (days)</span>
<input
className="input"
type="number"
min="0"
max="90"
value={draft.graceDays}
disabled={busy}
onChange={(e) => setDraft({ ...draft, graceDays: e.target.value })}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the
window keeps the same channel; zero removes it on the next pass.
</span>
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Staff roles</span>
<input
className="input"
type="text"
value={draft.staffRoles}
disabled={busy}
placeholder="role id, role id"
onChange={(e) => setDraft({ ...draft, staffRoles: e.target.value })}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
Roles that can see and join every Team’s channel. Guild administrators already can, so this
is for staff who are not administrators. Leave empty if there are none.
</span>
</label>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={save}>Save</button>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={runPass}>Sync now</button>
</div>
</div>
{config.rows.length > 0 && (
<div className="panel-flat" style={{ marginTop: 18, overflowX: 'auto' }}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Team</th>
<th className="adm-th">Members</th>
<th className="adm-th">Channel</th>
<th className="adm-th">State</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{config.rows.map((row) => (
<tr key={row.teamId}>
<td className="adm-td" style={{ color: 'var(--head)' }}>{row.teamName}</td>
<td className="adm-td">{row.memberCount}</td>
<td className="adm-td dim">
{row.channelRef || <span className="dim">none</span>}
</td>
<td className="adm-td">
{stateLabel(row.state)}
{removalCountdown(row) && (
<span className="dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 3 }}>
{removalCountdown(row)}
</span>
)}
{row.lastError && (
<span style={{ display: 'block', color: '#d98b84', fontSize: '0.78rem', marginTop: 3 }}>
{row.lastError}
</span>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => remove(row)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{config.lastPass && config.lastPass.at && (
<p className="sans dim" style={{ fontSize: '0.74rem', marginTop: 10 }}>
Last pass {new Date(config.lastPass.at).toLocaleString()}
{config.lastPass.ran ? '' : ` — nothing was done: ${config.lastPass.reason}`}
</p>
)}
</section>
)
}

View File

@@ -6,6 +6,8 @@ import {
} from '../../../lib/teamAdmin.js'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { api } from '../../../api/client.js'
import TeamIntegrations from './TeamIntegrations.jsx'
import TeamVoice from './TeamVoice.jsx'
// Admin → Teams (docs/website/TEAMS.md §2.4, §2.8, §2.9).
//
@@ -22,17 +24,24 @@ import { api } from '../../../api/client.js'
// Everything that decides what a row SAYS lives in lib/teamAdmin.js, which is
// plain JS and has tests; this file renders it.
const TONE_COLOR = { ok: '#7fd0a4', warn: 'var(--accent)', bad: '#d98b84', idle: 'var(--muted)' }
// Tones map onto the badge modifiers the rest of the admin panel already uses,
// rather than onto inline colours. `.badge` on its own carries no border or
// background — those live on the modifier — so a bare `className="badge"` with an
// inline `borderColor` renders borderless, which is what this screen used to do.
const TONE_BADGE = { ok: 'badge-pub', warn: 'badge-moderator', bad: 'badge-ban', idle: 'badge-draft' }
// The same three tones as text, for the places a badge would be wrong (a verbatim
// error line). House palette — the values every other admin view uses.
const TONE_TEXT = { ok: '#7fd0a4', warn: '#e0b070', bad: '#d98b84', idle: 'var(--muted)' }
const PANEL = { padding: 22, marginBottom: 22 }
const HEADING = { margin: '0 0 12px', fontSize: '1.2rem', color: 'var(--head)' }
const KV_VALUE = { margin: 0, fontSize: '0.88rem', color: 'var(--text)' }
const SCROLLER = { overflowX: 'auto' }
const BLURB = { margin: '0 0 14px', color: 'var(--muted)', fontSize: '0.85rem', lineHeight: 1.6 }
function Pill({ tone, children }) {
return (
<span
className="badge"
style={{ color: TONE_COLOR[tone] || 'var(--muted)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
>
{children}
</span>
)
return <span className={`badge ${TONE_BADGE[tone] || 'badge-draft'}`}>{children}</span>
}
// ── Sync state ─────────────────────────────────────────────────────────────
@@ -40,34 +49,48 @@ function Pill({ tone, children }) {
function SyncPanel({ sync, syncState, onResync, busy }) {
const freshness = freshnessOf(sync)
return (
<section className="panel" style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '.75rem', flexWrap: 'wrap' }}>
<h2 style={{ margin: 0 }}>Sync</h2>
<section className="panel" style={PANEL}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
<h2 className="display" style={{ ...HEADING, margin: 0 }}>Sync</h2>
<Pill tone={freshness.tone}>{freshness.label}</Pill>
<button type="button" className="btn" onClick={onResync} disabled={busy || !sync.configured}>
<button
type="button"
className="btn btn-ghost btn-sq"
onClick={onResync}
disabled={busy || !sync.configured}
>
{busy ? 'Resyncing…' : 'Resync now'}
</button>
</div>
<p className="muted" style={{ marginTop: '.5rem' }}>{freshness.detail}</p>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.85rem' }}>{freshness.detail}</p>
{syncState && (
<dl className="kv" style={{ marginTop: '.75rem' }}>
<dt>Module</dt><dd>{syncState.moduleId}</dd>
<dt>Last attempt</dt><dd>{dateTime(syncState.lastAttemptAt) || 'never'}</dd>
<dt>Last success</dt><dd>{dateTime(syncState.lastSuccessAt) || 'never'}</dd>
<dt>Consecutive failures</dt><dd>{syncState.consecutiveFailures}</dd>
<dl
style={{
display: 'grid', gridTemplateColumns: 'auto minmax(0, 1fr)', gap: '9px 20px',
margin: '16px 0 0', alignItems: 'baseline',
}}
>
<dt className="field-label" style={{ margin: 0 }}>Module</dt>
<dd className="sans" style={KV_VALUE}>{syncState.moduleId}</dd>
<dt className="field-label" style={{ margin: 0 }}>Last attempt</dt>
<dd className="sans" style={KV_VALUE}>{dateTime(syncState.lastAttemptAt) || 'never'}</dd>
<dt className="field-label" style={{ margin: 0 }}>Last success</dt>
<dd className="sans" style={KV_VALUE}>{dateTime(syncState.lastSuccessAt) || 'never'}</dd>
<dt className="field-label" style={{ margin: 0 }}>Consecutive failures</dt>
<dd className="sans" style={KV_VALUE}>{syncState.consecutiveFailures}</dd>
{syncState.lastError && (
<>
{/* Verbatim. An operator debugging a stale projection needs what the
provider actually said, not a friendlier paraphrase of it. */}
<dt>Last error</dt>
<dd style={{ color: TONE_COLOR.bad }}>{syncState.lastError}</dd>
<dt className="field-label" style={{ margin: 0 }}>Last error</dt>
<dd className="sans" style={{ ...KV_VALUE, color: TONE_TEXT.bad }}>{syncState.lastError}</dd>
</>
)}
{syncState.pendingEmptySince && (
<>
<dt>Empty answer held</dt>
<dd>
<dt className="field-label" style={{ margin: 0 }}>Empty answer held</dt>
<dd className="sans" style={KV_VALUE}>
since {dateTime(syncState.pendingEmptySince)} — an authoritative but empty list is
applied only if the next answer agrees.
</dd>
@@ -84,32 +107,45 @@ function SyncPanel({ sync, syncState, onResync, busy }) {
function ReviewQueue({ rows, role, onAct, busy }) {
if (!rows.length) return null
return (
<section className="panel" style={{ marginBottom: '1.5rem' }}>
<h2>Names to review</h2>
<p className="muted">
<section className="panel" style={PANEL}>
<h2 className="display" style={HEADING}>Names to review</h2>
<p className="sans" style={BLURB}>
These Teams are hidden from every public surface because their name matched a reserved term.
They work normally for their own members. {GATED_NOTE}
</p>
<table className="table">
<thead>
<tr><th>Name</th><th>Matched</th><th>Members</th><th>Created</th><th /></tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>{row.name}</td>
<td><Pill tone="bad">{row.hidden_term}</Pill></td>
<td>{row.member_count}</td>
<td>{dateTime(row.created_at)}</td>
<td>
<button type="button" className="btn" disabled={busy} onClick={() => onAct(row.id, 'unhide')}>
{gateLabelFor(role, 'Publish')}
</button>
</td>
<div className="panel-flat" style={SCROLLER}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Name</th>
<th className="adm-th">Matched</th>
<th className="adm-th">Members</th>
<th className="adm-th">Created</th>
<th className="adm-th" />
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td className="adm-td" style={{ color: 'var(--head)' }}>{row.name}</td>
<td className="adm-td"><Pill tone="bad">{row.hidden_term}</Pill></td>
<td className="adm-td">{row.member_count}</td>
<td className="adm-td dim">{dateTime(row.created_at)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={busy}
onClick={() => onAct(row.id, 'unhide')}
>
{gateLabelFor(role, 'Publish')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
@@ -120,32 +156,55 @@ function RequestQueue({ rows, role, onDecide, busy }) {
if (!rows.length) return null
const canDecide = role === 'admin'
return (
<section className="panel" style={{ marginBottom: '1.5rem' }}>
<h2>Awaiting approval</h2>
<p className="muted">
<section className="panel" style={PANEL}>
<h2 className="display" style={HEADING}>Awaiting approval</h2>
<p className="sans" style={BLURB}>
{canDecide
? 'Approving publishes the name; rejecting keeps the record and changes nothing.'
: 'Only an admin can decide these. Your own requests stay here until one does.'}
</p>
<ul className="list">
{rows.map((row) => (
<li key={row.id} style={{ display: 'flex', gap: '.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span>{describeRequest(row)}</span>
<span className="muted">{dateTime(row.requested_at)}</span>
{row.reason && <span className="muted">“{row.reason}”</span>}
{canDecide && (
<>
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'approved')}>
Approve
</button>
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'rejected')}>
Reject
</button>
</>
)}
</li>
))}
</ul>
<div className="panel-flat" style={SCROLLER}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Request</th>
<th className="adm-th">Requested</th>
<th className="adm-th">Reason</th>
{canDecide && <th className="adm-th" />}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td className="adm-td" style={{ color: 'var(--head)' }}>{describeRequest(row)}</td>
<td className="adm-td dim">{dateTime(row.requested_at)}</td>
<td className="adm-td dim">{row.reason ? `“${row.reason}”` : '—'}</td>
{canDecide && (
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={busy}
onClick={() => onDecide(row.id, 'approved')}
>
Approve
</button>
<button
type="button"
className="btn btn-ghost btn-sq"
style={{ marginLeft: 8 }}
disabled={busy}
onClick={() => onDecide(row.id, 'rejected')}
>
Reject
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
@@ -156,32 +215,47 @@ function TeamRow({ team, role, onAct, busy, onLedger }) {
const status = statusOf(team)
return (
<tr>
<td>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{team.displayName}
{team.displayNameOverride && (
<div className="muted" style={{ fontSize: '.85em' }}>
<div className="dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
shown instead of “{team.name}”
</div>
)}
</td>
<td><Pill tone={status.tone}>{status.label}</Pill></td>
<td>{team.memberCount}</td>
<td>{team.linkedCount}</td>
<td>{team.onlineCount}</td>
<td className="muted">{dateTime(team.rosterSyncedAt) || 'never'}</td>
<td>
<td className="adm-td"><Pill tone={status.tone}>{status.label}</Pill></td>
<td className="adm-td">{team.memberCount}</td>
<td className="adm-td">{team.linkedCount}</td>
<td className="adm-td">{team.onlineCount}</td>
<td className="adm-td dim">{dateTime(team.rosterSyncedAt) || 'never'}</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
{team.status === 'active' && (team.hidden
? (
<button type="button" className="btn" disabled={busy} onClick={() => onAct(team.id, 'unhide')}>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={busy}
onClick={() => onAct(team.id, 'unhide')}
>
{gateLabelFor(role, 'Publish')}
</button>
)
: (
<button type="button" className="btn" disabled={busy} onClick={() => onAct(team.id, 'hide')}>
<button
type="button"
className="btn btn-ghost btn-sq"
disabled={busy}
onClick={() => onAct(team.id, 'hide')}
>
Hide
</button>
))}
<button type="button" className="btn" onClick={() => onLedger(team)} style={{ marginLeft: 6 }}>
<button
type="button"
className="btn btn-ghost btn-sq"
onClick={() => onLedger(team)}
style={{ marginLeft: 8 }}
>
Forum log
</button>
</td>
@@ -223,37 +297,51 @@ function ForumLedger({ team, onClose }) {
}, [team.id])
return (
<section className="panel">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<h2>Forum log — {team.displayName}</h2>
<button type="button" className="btn" onClick={onClose}>Close</button>
<section className="panel" style={PANEL}>
<header
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
gap: 14, flexWrap: 'wrap', marginBottom: 12,
}}
>
<h2 className="display" style={{ ...HEADING, margin: 0 }}>Forum log — {team.displayName}</h2>
<button type="button" className="btn btn-ghost btn-sq" onClick={onClose}>Close</button>
</header>
{error && <ErrorState message={error} />}
{!rows && !error && <Loading />}
{rows && rows.length === 0 && <p className="muted">Nothing has been moderated in this forum.</p>}
{rows && rows.length === 0 && (
<p className="sans" style={{ ...BLURB, margin: 0 }}>Nothing has been moderated in this forum.</p>
)}
{rows && rows.length > 0 && (
<table className="table">
<thead>
<tr>
<th>When</th><th>Action</th><th>Target</th><th>By</th><th>As</th><th>Reason</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="muted">{dateTime(r.created_at)}</td>
<td>{r.action}</td>
<td className="muted">{r.target_type} #{r.target_id}</td>
<td>{r.actor_username || '—'}</td>
<td>
{/* The distinction the whole ledger exists to preserve. */}
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>
</td>
<td className="muted">{r.reason || '—'}</td>
<div className="panel-flat" style={SCROLLER}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">When</th>
<th className="adm-th">Action</th>
<th className="adm-th">Target</th>
<th className="adm-th">By</th>
<th className="adm-th">As</th>
<th className="adm-th">Reason</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="adm-td dim">{dateTime(r.created_at)}</td>
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.action}</td>
<td className="adm-td dim">{r.target_type} #{r.target_id}</td>
<td className="adm-td">{r.actor_username || '—'}</td>
<td className="adm-td">
{/* The distinction the whole ledger exists to preserve. */}
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>
</td>
<td className="adm-td dim">{r.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
@@ -335,46 +423,64 @@ export default function TeamsAdmin() {
return (
<div>
<h1>Teams</h1>
{/* No page <h1>: AdminLayout's topbar already titles the page, as it does for
every other admin screen. This one used to render its own, which is why
"Teams" appeared twice — once in Cinzel in the bar and once in the body
in whatever the UA picked for an unstyled heading. */}
{error && <ErrorState message={error} />}
{notice && <p className="notice">{notice}</p>}
{notice && (
<div className="note sans" style={{ fontSize: '0.85rem', marginBottom: 22 }}>{notice}</div>
)}
{ledgerTeam && <ForumLedger team={ledgerTeam} onClose={() => setLedgerTeam(null)} />}
{/* Admin-only, matching the server (§7.2). Rendered for a moderator it would
be a panel every action in fails 403 — the role gate is the server's, and
this is only how the screen agrees with it. */}
{role === 'admin' && <TeamIntegrations />}
{role === 'admin' && <TeamVoice />}
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
<section className="panel">
<h2>All Teams</h2>
<section className="panel" style={PANEL}>
<h2 className="display" style={HEADING}>All Teams</h2>
{!data.teams.length && (
<p className="muted">
<p className="sans" style={{ ...BLURB, margin: 0 }}>
{data.configured
? 'No Teams in the projection yet.'
: 'No installed module supplies Teams, so there is nothing to show.'}
</p>
)}
{data.teams.length > 0 && (
<table className="table">
<thead>
<tr>
<th>Name</th><th>Status</th><th>Members</th><th>Linked</th><th>Online</th>
<th>Roster confirmed</th><th />
</tr>
</thead>
<tbody>
{data.teams.map((team) => (
<TeamRow
key={team.id}
team={team}
role={role}
onAct={act}
busy={busy}
onLedger={setLedgerTeam}
/>
))}
</tbody>
</table>
<div className="panel-flat" style={SCROLLER}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Name</th>
<th className="adm-th">Status</th>
<th className="adm-th">Members</th>
<th className="adm-th">Linked</th>
<th className="adm-th">Online</th>
<th className="adm-th">Roster confirmed</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{data.teams.map((team) => (
<TeamRow
key={team.id}
team={team}
role={role}
onAct={act}
busy={busy}
onLedger={setLedgerTeam}
/>
))}
</tbody>
</table>
</div>
)}
</section>
</div>

View File

@@ -4,6 +4,7 @@ import { Loading, ErrorState } from '../../components/PageState.jsx'
import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay.jsx'
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
@@ -21,7 +22,7 @@ function ChangeUsername({ account, onChanged }) {
if (username.trim().length < 3) return setError('Username must be at least 3 characters.')
setBusy(true)
try {
const { username: next } = await api.player.changeUsername(username.trim())
const { username: next } = await api.changeUsername(username.trim())
setMsg('Username updated.')
await onChanged(next)
} catch (err) {
@@ -67,7 +68,7 @@ function ChangePassword({ account }) {
if (hasPassword && !current) return setError('Enter your current password.')
setBusy(true)
try {
await api.player.changePassword(next, hasPassword ? current : undefined)
await api.changePassword(next, hasPassword ? current : undefined)
setMsg(hasPassword ? 'Password changed.' : 'Password set. You can now sign in with it.')
setCurrent('')
setNext('')
@@ -124,7 +125,7 @@ function TwoFactor({ account, reload }) {
async function begin() {
setBusy(true); setMsg(''); setError('')
try {
setSetup(await api.player.totpSetup())
setSetup(await api.totpSetup())
setCode('')
} catch (err) {
setError(err.message || 'Could not start setup.')
@@ -135,7 +136,7 @@ function TwoFactor({ account, reload }) {
async function confirm() {
setBusy(true); setMsg(''); setError('')
try {
const res = await api.player.totpEnable(code.trim())
const res = await api.totpEnable(code.trim())
setSetup(null); setCode(''); setNewCodes(res?.recoveryCodes || null); setMsg('Two-factor is now enabled.')
await reload()
} catch (err) {
@@ -147,7 +148,7 @@ function TwoFactor({ account, reload }) {
async function disable() {
setBusy(true); setMsg(''); setError('')
try {
await api.player.totpDisable(code.trim())
await api.totpDisable(code.trim())
setCode(''); setMsg('Two-factor has been disabled.')
await reload()
} catch (err) {
@@ -234,7 +235,7 @@ function LinkedAccounts() {
const load = useCallback(async () => {
try {
const [ids, avail] = await Promise.all([
api.player.linkedIdentities(),
api.myIdentities(),
api.authProviders().catch(() => []),
])
setLinked(ids)
@@ -251,7 +252,7 @@ function LinkedAccounts() {
async function unlink(provider) {
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
try {
await api.player.unlinkIdentity(provider)
await api.unlinkIdentity(provider)
await load()
} catch (err) {
setError(err.message || 'Could not unlink.')
@@ -397,7 +398,7 @@ export default function PlayerAccount() {
const load = useCallback(async () => {
try {
setAccount(await api.player.getAccount())
setAccount(await api.myAccount())
} catch {
setError('Could not load your account.')
} finally {
@@ -423,6 +424,7 @@ export default function PlayerAccount() {
{account.email ? ` · ${account.email}` : ''}
</p>
<ChangeUsername account={account} onChanged={onUsernameChanged} />
<EmailAddressPanel account={account} reload={load} />
<ChangePassword account={account} />
<TwoFactor account={account} reload={load} />
{account.totp_enabled && (

View File

@@ -0,0 +1,264 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { api } from '../../api/client.js'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { notificationSettingsPath, inboxPath } from '../../lib/notificationPaths.js'
// The in-app inbox (ENGAGEMENT.md Phase 7), at `/account/notifications`.
//
// **It took that path from the preferences screen, which moved to
// `/account/notifications/settings`.** The two are different kinds of thing —
// one is content addressed to this person, the other is how they would like to
// be reached — and the word "notifications" belongs to the first: it is what a
// person means when they say it, and what the bell in the header opens. The
// server's routes make the same split at the same place.
//
// Everything a row can carry is TEXT. `body` is stored as the text part of the
// in-app template's blocks and rendered with `white-space: pre-line`, never as
// markup; `url` is site-relative by the time it is stored, checked against the
// same character class `pageUrlTemplate` uses. So there is no sanitizing to do
// here — there is nothing on this screen that could be markup.
const PAGE = 30
function ago(iso) {
const then = new Date(iso).getTime()
if (!Number.isFinite(then)) return ''
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
if (secs < 60) return 'just now'
if (secs < 3600) return `${Math.floor(secs / 60)} min ago`
if (secs < 86400) return `${Math.floor(secs / 3600)} h ago`
if (secs < 30 * 86400) return `${Math.floor(secs / 86400)} d ago`
return new Date(iso).toLocaleDateString()
}
function Item({ item, onOpen, onMark }) {
const body = (
<>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
<strong
className="sans"
style={{
fontSize: '0.95rem',
color: item.read ? 'var(--muted)' : 'var(--head)',
fontWeight: item.read ? 500 : 700,
}}
>
{item.title}
</strong>
<span className="sans dim" style={{ fontSize: '0.76rem' }}>{ago(item.createdAt)}</span>
</div>
{item.body && (
<p
className="sans dim"
style={{ margin: '6px 0 0', fontSize: '0.86rem', whiteSpace: 'pre-line' }}
>
{item.body}
</p>
)}
</>
)
return (
<li
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 12,
padding: '14px 16px',
borderRadius: 'var(--radius-card)',
border: '1px solid var(--line-soft)',
// The one visual difference between read and unread, plus the weight
// above. A dot alone is easy to miss on a long list.
background: item.read ? 'transparent' : 'var(--panel)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
{item.url ? (
<button
type="button"
onClick={() => onOpen(item)}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
}}
>
{body}
</button>
) : (
body
)}
</div>
{!item.read && (
<button
type="button"
onClick={() => onMark(item)}
className="sans"
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'var(--accent)',
fontSize: '0.78rem',
whiteSpace: 'nowrap',
}}
>
Mark read
</button>
)}
</li>
)
}
export default function PlayerInbox() {
const [items, setItems] = useState([])
const [unread, setUnread] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [unreadOnly, setUnreadOnly] = useState(false)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const navigate = useNavigate()
const { user } = useAuth()
const load = useCallback(async (only) => {
setLoading(true)
setError('')
try {
const res = await api.notifications({ limit: PAGE, unread: only })
setItems(res.items || [])
setHasMore(!!res.hasMore)
setUnread(res.unread || 0)
} catch (err) {
setError(err.message || 'Could not load your notifications')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load(unreadOnly) }, [load, unreadOnly])
// The cursor is the last item's id, not a page number: the list gains rows at
// the top while it is being read, and an offset under those conditions repeats
// or skips items.
const more = async () => {
if (!items.length) return
setBusy(true)
try {
const res = await api.notifications({
limit: PAGE,
before: items[items.length - 1].id,
unread: unreadOnly,
})
setItems((list) => [...list, ...(res.items || [])])
setHasMore(!!res.hasMore)
} catch (err) {
setError(err.message || 'Could not load more')
} finally {
setBusy(false)
}
}
const mark = async (item) => {
try {
const res = await api.markNotificationRead(item.id)
setUnread(res.unread ?? Math.max(0, unread - 1))
// Filtered to unread, a marked item leaves the list; unfiltered it stays
// and goes quiet. Either way the list matches what it says it is showing.
setItems((list) =>
unreadOnly
? list.filter((i) => i.id !== item.id)
: list.map((i) => (i.id === item.id ? { ...i, read: true } : i)),
)
} catch (err) {
setError(err.message || 'Could not mark it read')
}
}
const open = async (item) => {
if (!item.read) await mark(item)
if (item.url) navigate(item.url)
}
const markAll = async () => {
setBusy(true)
try {
await api.markAllNotificationsRead()
setUnread(0)
setItems((list) => (unreadOnly ? [] : list.map((i) => ({ ...i, read: true }))))
} catch (err) {
setError(err.message || 'Could not mark them read')
} finally {
setBusy(false)
}
}
if (loading) return <Loading label="Loading your notifications…" />
if (error && !items.length) return <ErrorState message={error} />
return (
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
marginBottom: 18,
}}
>
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
{unread > 0 ? `${unread} unread` : 'Everything is read.'}{' '}
<Link to={notificationSettingsPath(user)} className="dim">
Notification settings
</Link>
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="pill"
onClick={() => setUnreadOnly((v) => !v)}
style={unreadOnly ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : {}}
>
{unreadOnly ? 'Showing unread' : 'Show unread only'}
</button>
<button type="button" className="pill" onClick={markAll} disabled={busy || unread === 0}>
Mark all read
</button>
</div>
</div>
{error && (
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
)}
{items.length === 0 ? (
<p className="sans dim" style={{ fontSize: '0.9rem' }}>
{unreadOnly
? 'Nothing unread.'
: 'Nothing here yet. Anything the shard or your guilds want to tell you will show up on this page.'}
</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map((item) => (
<Item key={item.id} item={item} onOpen={open} onMark={mark} />
))}
</ul>
)}
{hasMore && (
<button type="button" className="pill" onClick={more} disabled={busy} style={{ marginTop: 16 }}>
{busy ? 'Loading…' : 'Load older'}
</button>
)}
</div>
)
}

View File

@@ -1,8 +1,15 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { api } from '../../api/client.js'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { inboxPath } from '../../lib/notificationPaths.js'
// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6).
// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6; the
// per-channel matrix is ENGAGEMENT.md Phase 3, surfaced in Phase 7).
//
// **It moved to `/account/notifications/settings` in Phase 7**, because the
// inbox took the plain path. See `PlayerInbox.jsx`.
//
// **This screen did not exist before phase 6, and that was the phase's first
// finding.** §6.3 says the per-Team mute list is "surfaced under the existing
@@ -18,6 +25,14 @@ import { api } from '../../api/client.js'
// thing to be told about, then which Teams, then whether any of it should reach a
// mailbox.
// The three modes a per-channel preference can take, labelled for a person. The
// set a given channel actually offers comes from its `supportsDigest` flag.
const MODES = [
{ value: 'off', label: 'Off' },
{ value: 'instant', label: 'As it happens' },
{ value: 'digest', label: 'Daily digest' },
]
const EMAIL_MODES = [
{ value: 'off', label: 'No email' },
{ value: 'digest', label: 'Daily digest' },
@@ -48,48 +63,125 @@ function Note({ msg, error }) {
)
}
// ── What to be told about ──────────────────────────────────────────────────
// ── What to be told about, and how ─────────────────────────────────────────
//
// **This replaced the push-only checkbox list, and it is a strict superset of
// it.** `GET /auth/me/notifications/channels` returns every subscribable id —
// every push stream and every event trigger, one namespace (§7.2) — with the
// EFFECTIVE mode on each channel that applies. A trigger with nothing
// registered to push it simply has no push cell; core does not have to explain
// which kind of id a row is, and neither does a reader.
//
// The old whole-set endpoints are untouched and are now this surface's push
// projection: the shipped Android app keeps its wire shape, and a `push` entry
// written here is mirrored back into `notification_subscriptions` server-side.
//
// The update is SPARSE: only the cells that changed are sent. That is what lets
// this screen manage three channels without a whole-set PUT that could clobber
// a preference a newer client set.
function Streams({ streams, subscribed, onSave, busy, msg, error }) {
const [set, setSet] = useState(() => new Set(subscribed))
useEffect(() => { setSet(new Set(subscribed)) }, [subscribed])
function Channels({ channels, items, onSave, busy, msg, error }) {
const [edits, setEdits] = useState({})
useEffect(() => setEdits({}), [items])
const toggle = (id) => {
const next = new Set(set)
if (next.has(id)) next.delete(id)
else next.add(id)
setSet(next)
const key = (id, channel) => `${id}|${channel}`
const modeOf = (item, channel) => edits[key(item.id, channel)] ?? item.modes[channel]
const set = (id, channel, mode) => setEdits((e) => ({ ...e, [key(id, channel)]: mode }))
// A channel that supports digest offers three modes; one that does not offers
// two. Read off the registry rather than hardcoded, so a channel added later
// shows the right options without touching this file.
const modesFor = (c) => (c.supportsDigest ? MODES : MODES.filter((m) => m.value !== 'digest'))
const changed = Object.entries(edits).filter(([k, mode]) => {
const [id, channel] = k.split('|')
const item = items.find((i) => i.id === id)
return item && item.modes[channel] !== mode
})
const save = () =>
onSave(
changed.map(([k, mode]) => {
const [id, channel] = k.split('|')
return { id, channel, mode }
}),
)
if (items.length === 0) {
return (
<Section title="What to notify me about">
<p className="sans dim" style={{ fontSize: '0.9rem', margin: 0 }}>
There is nothing to configure yet.
</p>
</Section>
)
}
const team = streams.filter((s) => isTeamStream(s.id))
const rest = streams.filter((s) => !isTeamStream(s.id))
const team = items.filter((i) => isTeamStream(i.id))
const rest = items.filter((i) => !isTeamStream(i.id))
const row = (s) => (
<label key={s.id} className="sans" style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: '0.92rem' }}>
<input type="checkbox" checked={set.has(s.id)} onChange={() => toggle(s.id)} style={{ marginTop: 3 }} />
<span>
<span style={{ color: 'var(--ink)' }}>{s.label}</span>
{s.description && <span className="dim" style={{ display: 'block', fontSize: '0.82rem' }}>{s.description}</span>}
</span>
</label>
)
const rows = (list) =>
list.map((item) => (
<tr key={item.id} style={{ borderTop: '1px solid var(--line-soft)' }}>
<td className="sans" style={{ padding: '10px', color: 'var(--ink)' }}>
{item.label}
{item.description && (
<span className="dim" style={{ display: 'block', fontSize: '0.8rem' }}>{item.description}</span>
)}
</td>
{channels.map((c) => (
<td key={c.id} style={{ padding: '10px' }}>
{item.channels.includes(c.id) ? (
<select
className="input"
aria-label={`${item.label} — ${c.label}`}
value={modeOf(item, c.id)}
onChange={(e) => set(item.id, c.id, e.target.value)}
style={{ fontSize: '0.86rem' }}
>
{modesFor(c).map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
) : (
// Not "off" — a dash. Nothing is registered to push this id, so
// there is no preference to hold, and an `off` select would invite
// somebody to switch on a channel that has no sender behind it.
<span className="dim" style={{ fontSize: '0.86rem' }}>—</span>
)}
</td>
))}
</tr>
))
return (
<Section
title="What to notify me about"
hint="Applies to every device you have signed in on. Notifications are delivered to the app; the website itself does not pop anything up."
hint="Applies to every device you have signed in on. On the site means an item in your notification inbox; push wakes the app, which then fetches the content."
>
<div style={{ display: 'grid', gap: 12 }}>{rest.map(row)}</div>
{team.length > 0 && (
<>
<h3 className="sans dim" style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '20px 0 10px' }}>
Teams
</h3>
<div style={{ display: 'grid', gap: 12 }}>{team.map(row)}</div>
</>
)}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
<th style={{ padding: '8px 10px' }}>Notification</th>
{channels.map((c) => (
<th key={c.id} style={{ padding: '8px 10px' }} title={c.description || undefined}>{c.label}</th>
))}
</tr>
</thead>
<tbody>
{rows(rest)}
{team.length > 0 && (
<tr>
<td colSpan={channels.length + 1} className="sans dim" style={{ padding: '18px 10px 6px', fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Teams — set site-wide here, then per team below
</td>
</tr>
)}
{rows(team)}
</tbody>
</table>
</div>
<div style={{ marginTop: 18 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave([...set])}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy || changed.length === 0} onClick={save}>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
@@ -176,27 +268,29 @@ function Teams({ teams, onSave, busy, msg, error }) {
// ── Page ───────────────────────────────────────────────────────────────────
export default function PlayerNotifications() {
const { user } = useAuth()
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [streams, setStreams] = useState([])
const [subscribed, setSubscribed] = useState([])
const [channels, setChannels] = useState([])
const [items, setItems] = useState([])
const [teams, setTeams] = useState([])
const [saving, setSaving] = useState({ streams: false, teams: false })
const [notes, setNotes] = useState({ streams: '', teams: '', streamsError: '', teamsError: '' })
const [saving, setSaving] = useState({ channels: false, teams: false })
const [notes, setNotes] = useState({ channels: '', teams: '', channelsError: '', teamsError: '' })
const load = useCallback(async () => {
setLoading(true)
try {
// Three reads in parallel: the catalog is boot-fixed, the subscriptions and
// the Team list are this user's. None depends on another.
const [cat, subs, prefs] = await Promise.all([
api.notificationStreams(),
api.notificationSubscriptions(),
// Two reads in parallel, where there used to be three: the per-channel
// surface already carries the catalog and this user's effective modes, so
// the streams+subscriptions pair it replaced is one request fewer as well
// as one concept fewer.
const [prefs, teamPrefs] = await Promise.all([
api.notificationChannelPrefs(),
api.teamNotificationPrefs(),
])
setStreams(cat.streams || [])
setSubscribed(subs.streams || [])
setTeams(prefs.teams || [])
setChannels(prefs.channels || [])
setItems(prefs.items || [])
setTeams(teamPrefs.teams || [])
setError('')
} catch {
setError('Could not load your notification settings.')
@@ -207,17 +301,23 @@ export default function PlayerNotifications() {
useEffect(() => { load() }, [load])
const saveStreams = useCallback(async (ids) => {
setSaving((s) => ({ ...s, streams: true }))
setNotes((n) => ({ ...n, streams: '', streamsError: '' }))
const saveChannels = useCallback(async (prefs) => {
if (prefs.length === 0) return
setSaving((s) => ({ ...s, channels: true }))
setNotes((n) => ({ ...n, channels: '', channelsError: '' }))
try {
const { streams: stored } = await api.setNotificationSubscriptions(ids)
setSubscribed(stored || [])
setNotes((n) => ({ ...n, streams: 'Saved.' }))
// The endpoint echoes the FULL stored state back, not just what was sent —
// so an entry it dropped (an unknown id, a channel that does not apply, a
// mode that channel will not take) is visible here as a cell that did not
// move, rather than as a screen that claims a save it did not make.
const stored = await api.setNotificationChannelPrefs(prefs)
setChannels(stored.channels || [])
setItems(stored.items || [])
setNotes((n) => ({ ...n, channels: 'Saved.' }))
} catch {
setNotes((n) => ({ ...n, streamsError: 'Could not save that.' }))
setNotes((n) => ({ ...n, channelsError: 'Could not save that.' }))
} finally {
setSaving((s) => ({ ...s, streams: false }))
setSaving((s) => ({ ...s, channels: false }))
}
}, [])
@@ -245,16 +345,17 @@ export default function PlayerNotifications() {
return (
<div>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
Choose what you are told about, and how. Nothing here is on by default except team
notifications to the app, which you can mute per team below.
Choose what you are told about, and how. Email and push are off until you switch them on;
items on the site go to your <Link to={inboxPath(user)}>notification inbox</Link>,
which you can turn off here per notification.
</p>
<Streams
streams={streams}
subscribed={subscribed}
onSave={saveStreams}
busy={saving.streams}
msg={notes.streams}
error={notes.streamsError}
<Channels
channels={channels}
items={items}
onSave={saveChannels}
busy={saving.channels}
msg={notes.channels}
error={notes.channelsError}
/>
<Teams
teams={teams}

View File

@@ -2,6 +2,7 @@ import { useMemo } from 'react'
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import NotificationBell from '../../components/NotificationBell.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js'
@@ -36,6 +37,9 @@ function Icon({ children, size = 16 }) {
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
// The settings row's own icon: a bell would make the two rows read as the same
// destination twice, which is exactly the confusion the split was meant to end.
const IconBellGear = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h11" /><circle cx="18" cy="18" r="3" /><path d="M18 14v1M18 21v1M14 18h1M21 18h1" /></Icon>
// Exported because Admin -> Navigation edits this list. It stays declared here;
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
@@ -48,7 +52,8 @@ const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-
// with `order: 0`.
export const NAV = [
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account/notifications', label: 'Notifications', icon: IconBell },
{ to: '/account/notifications', label: 'Notifications', end: true, icon: IconBell },
{ to: '/account/notifications/settings', label: 'Notification settings', icon: IconBellGear },
{ to: '/account', label: 'Account', end: true, icon: IconGear },
]
@@ -59,6 +64,7 @@ const TITLES = {
'/account': 'Account',
'/account/appeals': 'Appeals',
'/account/notifications': 'Notifications',
'/account/notifications/settings': 'Notification settings',
}
function moduleTitle(baseNav, pathname) {
@@ -186,9 +192,12 @@ export default function PlayerPortalLayout() {
<h1 className="display" style={{ margin: 0, fontSize: '1.5rem', color: 'var(--head)' }}>
{title}
</h1>
<a href="/" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.84rem', fontFamily: 'var(--sans)' }}>
← Site
</a>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<NotificationBell />
<a href="/" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.84rem', fontFamily: 'var(--sans)' }}>
← Site
</a>
</div>
</header>
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: 900, width: '100%' }}>

View File

@@ -54,14 +54,14 @@ export default function Unsubscribe() {
<p className="sans dim" style={{ fontSize: '0.9rem' }}>
This muted the team rather than switching off your account&rsquo;s email, so your other
teams are unaffected. You can turn it back on any time under{' '}
<Link to="/account/notifications">notification settings</Link>.
<Link to="/account/notifications/settings">notification settings</Link>.
</p>
</>
)}
{state === 'failed' && (
<p className="sans" style={{ color: 'var(--ink)' }}>
We could not reach the site to record that. Please try the link again, or change the
setting yourself under <Link to="/account/notifications">notification settings</Link>.
setting yourself under <Link to="/account/notifications/settings">notification settings</Link>.
</p>
)}
</PublicLayout>

View File

@@ -0,0 +1,166 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { api } from '../../api/client.js'
import PlayerShell from './PlayerShell.jsx'
// Public, token-gated confirmation page (/account/verify-email/:token).
//
// Unauthenticated on purpose: the link arrives in a mailbox and is routinely
// opened on a device with no session. That is safe because the token IS the
// proof — opening it installs an address on the account it was minted for and
// does nothing else. No session is issued here, deliberately: proving control of
// a mailbox is not proving control of an account.
//
// Every failure the server can have — expired, already used, superseded by a
// later request, or an address another account confirmed first — comes back as
// the same 404. That is not laziness on the server's part; distinguishing them
// would let anyone test which addresses have accounts. So this page says the same
// thing for all of them, and must keep doing so.
export default function VerifyEmail() {
const { token } = useParams()
const [link, setLink] = useState(null) // { username, email } once validated
const [loadErr, setLoadErr] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
useEffect(() => {
let active = true
api
.lookupEmailVerification(token)
.then((r) => active && setLink(r || {}))
.catch(
(err) =>
active &&
setLoadErr(
err.status === 404
? 'This confirmation link is invalid or has expired.'
: 'Could not load this confirmation link.',
),
)
return () => {
active = false
}
}, [token])
async function onConfirm() {
setError('')
setBusy(true)
try {
await api.confirmEmailVerification(token)
setDone(true)
} catch (err) {
if (err.status === 404) setError('This confirmation link is no longer usable. Request a new one from your account page.')
else if (err.status === 429) setError('Too many attempts. Please try again in a little while.')
else setError('Could not confirm your address right now. Please try again later.')
setBusy(false)
}
}
// ── Invalid link ───────────────────────────────────────────────────────────
if (loadErr) {
return (
<PlayerShell subtitle="Confirm your email">
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>
{loadErr}
</p>
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
<Link to="/account" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Go to your account
</Link>
</p>
</PlayerShell>
)
}
if (link === null) {
return (
<PlayerShell subtitle="Confirm your email">
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}>
<span className="spin" />
</div>
</PlayerShell>
)
}
// ── Done ───────────────────────────────────────────────────────────────────
if (done) {
return (
<PlayerShell subtitle="Email confirmed">
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>
{link.email ? (
<>
<strong style={{ color: 'var(--head)' }}>{link.email}</strong> is now the address for
{link.username ? (
<>
{' '}
<strong style={{ color: 'var(--head)' }}>{link.username}</strong>
</>
) : (
' your account'
)}
.
</>
) : (
'Your email address has been confirmed.'
)}
</p>
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', fontSize: '0.85rem', color: 'var(--dim)' }}>
You have not been signed in — confirming an address does not sign you in.
</p>
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Sign in
</Link>
</p>
</PlayerShell>
)
}
// ── Confirm ────────────────────────────────────────────────────────────────
//
// A button rather than confirming on load. A mail client or scanner that
// pre-fetches links would otherwise spend the token before the person ever saw
// it, and this token is single-use.
return (
<PlayerShell subtitle="Confirm your email">
<p
className="sans"
style={{ marginTop: 0, marginBottom: 20, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}
>
Confirm that{' '}
{link.email ? <strong style={{ color: 'var(--head)' }}>{link.email}</strong> : 'this address'} should be
the contact and account-recovery address for
{link.username ? (
<>
{' '}
<strong style={{ color: 'var(--head)' }}>{link.username}</strong>
</>
) : (
' this account'
)}
.
</p>
{error && (
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
{error}
</p>
)}
<button
type="button"
onClick={onConfirm}
disabled={busy}
className="btn btn-primary"
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
>
{busy ? 'Confirming…' : 'Confirm this address'}
</button>
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', fontSize: '0.82rem', color: 'var(--dim)' }}>
If you did not ask for this, close this page. Nothing changes and no account of yours is affected.
</p>
</PlayerShell>
)
}

View File

@@ -0,0 +1,154 @@
import { test, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
RESERVED_KEYS,
registerEmailBlock,
getEmailBlock,
listEmailBlocks,
newEmailBlock,
} from '../src/emailBlocks/registry.js'
// Engagement Phase 5b — the client half of the template editor.
//
// Two kinds of test, and the second kind is the one worth explaining.
//
// `registry.js` is plain `.js` and imports nothing, so it is exercised directly.
// `types.jsx` and `EngagementTemplates.jsx` cannot be: this runner has no JSX
// transform and no DOM, the same limit `moduleRegistry.test.js` documents. So the
// properties that live in those files are asserted **against their source text**.
//
// That is a weaker test than executing them, and it is used for exactly two things
// where a weak test still beats none:
//
// • **The preview sandbox.** `sandbox=""` with no `allow-scripts` is the reason
// operator-authored HTML cannot run under this site's origin. It is one
// attribute, on one element, and it is precisely the sort of thing someone
// removes to debug a rendering problem and does not put back. A source
// assertion catches that in review; nothing else here would.
// • **Registry drift.** Every `email.*` type this client offers must exist in
// the server registry with the same version, because the server validates
// against its own and a drifted client produces a refused save with no
// explanation on screen. Reading both trees is the only way to check a
// pairing that spans a process boundary.
const here = path.dirname(fileURLToPath(import.meta.url))
const read = (rel) => fs.readFileSync(path.join(here, '..', rel), 'utf8')
// The registry is module state; each test starts from a known entry.
beforeEach(() => {
if (!getEmailBlock('email.test')) {
registerEmailBlock({
type: 'email.test',
version: 2,
label: 'Test block',
defaults: () => ({ text: 'hi' }),
editor: () => null,
})
}
})
// ── The registry ───────────────────────────────────────────────────────────
test('a definition must be namespaced "email."', () => {
assert.throws(() => registerEmailBlock({ type: 'heading' }), /namespaced/)
assert.throws(() => registerEmailBlock({}), /namespaced/)
})
test('a duplicate type is a programmer error, caught at import', () => {
assert.throws(() => registerEmailBlock({ type: 'email.test' }), /already registered/)
})
test('a new block carries the envelope the server expects, and a unique id', () => {
const a = newEmailBlock('email.test')
const b = newEmailBlock('email.test')
assert.deepEqual(Object.keys(a).sort(), [...RESERVED_KEYS].sort())
assert.equal(a.type, 'email.test')
assert.equal(a.version, 2)
assert.deepEqual(a.props, { text: 'hi' })
// Ids are unique across a whole document. A counter would re-issue an id after
// a delete and the save would be refused for a reason nothing on screen explains.
assert.notEqual(a.id, b.id)
})
test('an unknown type yields nothing rather than a half-built block', () => {
assert.equal(newEmailBlock('email.nope'), null)
assert.equal(getEmailBlock('email.nope'), null)
})
// ── The sandbox: §4.6.2's security posture, as an attribute ────────────────
test('the preview frame is sandboxed with no allow-scripts', () => {
const source = read('src/routes/admin/views/EngagementTemplates.jsx')
// It renders in an iframe at all — not into the page.
assert.match(source, /<iframe/)
// Read the ATTRIBUTE, not the file. The first version of this test searched the
// whole source for "allow-scripts" and failed on the comment above the iframe
// explaining that there is no allow-scripts — a check that a correct file fails
// is worse than no check, because the fix is to delete the explanation.
const sandboxes = [...source.matchAll(/sandbox=(?:"([^"]*)"|\{([^}]*)\})/g)].map((m) => m[1] ?? m[2])
assert.equal(sandboxes.length, 1, 'expected exactly one sandboxed frame')
// Empty: every restriction on, nothing granted back.
assert.equal(sandboxes[0], '')
// The two grants that would undo it, whatever else were listed.
assert.doesNotMatch(sandboxes[0], /allow-scripts/)
assert.doesNotMatch(sandboxes[0], /allow-same-origin/)
// And no iframe without one at all.
assert.equal((source.match(/<iframe/g) || []).length, sandboxes.length)
// From srcDoc — an opaque origin — rather than a src pointing at this site.
assert.match(source, /srcDoc=/)
})
test('the preview HTML is never injected into this document', () => {
const source = read('src/routes/admin/views/EngagementTemplates.jsx')
// The one API that would undo all of the above in a single line.
assert.doesNotMatch(source, /dangerouslySetInnerHTML/)
})
// ── Drift between the two registries ───────────────────────────────────────
test('every client email block pairs with a server definition at the same version', () => {
const clientSource = read('src/emailBlocks/types.jsx')
const clientTypes = [...clientSource.matchAll(/type:\s*'(email\.[A-Za-z]+)',\s*\n\s*version:\s*(\d+)/g)].map(
(m) => [m[1], Number(m[2])],
)
assert.ok(clientTypes.length >= 6, 'expected the six block definitions to be found')
const serverDir = path.join(here, '..', '..', 'server', 'src', 'emailBlocks', 'types')
const serverTypes = new Map()
for (const file of fs.readdirSync(serverDir)) {
const src = fs.readFileSync(path.join(serverDir, file), 'utf8')
const type = src.match(/type:\s*'(email\.[A-Za-z]+)'/)
const version = src.match(/\n\s*version:\s*(\d+)/)
if (type) serverTypes.set(type[1], version ? Number(version[1]) : 1)
}
for (const [type, version] of clientTypes) {
assert.ok(serverTypes.has(type), `${type} has no server definition`)
assert.equal(serverTypes.get(type), version, `${type} version differs between client and server`)
}
// And the other direction: a server block with no authoring form is a block an
// operator can be sent a template containing and cannot edit.
for (const type of serverTypes.keys()) {
assert.ok(
clientTypes.some(([t]) => t === type),
`${type} exists on the server but has no editor in this client`,
)
}
})
test('no client email block declares a React renderer', () => {
// The structural claim in registry.js's header. A `component` here would be a
// second renderer for a body the server produces, and the two would agree only
// until the first Outlook fix.
const clientSource = read('src/emailBlocks/types.jsx')
assert.doesNotMatch(clientSource, /\n\s*component:/)
assert.ok(listEmailBlocks().every((d) => !('component' in d)))
})

View File

@@ -0,0 +1,307 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
formFromRule,
ruleToPayload,
audienceChoicesFor,
segmentChoicesFor,
describeReach,
describeRule,
describeExpression,
notPlacementError,
audienceWarning,
operatorWords,
conditionRowsFrom,
conditionsFromRows,
operatorsForType,
coerceLiteral,
humanSeconds,
} from '../src/lib/engagementRules.js'
// lib/engagementRules.js — what the two Engagement screens say and what they let
// an operator pick (ENGAGEMENT.md Phase 4b).
//
// None of this is a boundary: the server's `engagementRules.model` decides what
// may be saved and the engine re-checks the audience ceiling at send time. What
// is tested here is the part that would be wrong SILENTLY — a form that sends a
// string where the trigger declared an int, a composer that flattens a nested
// condition into one that fires on different events, an editor that offers an
// audience the save is going to refuse.
const CEILINGS = [
{ id: 'everyone', label: 'Everyone', permits: ['everyone', 'authenticated', 'subscribers', 'members', 'staff', 'owner'] },
{ id: 'authenticated', label: 'Signed-in users', permits: ['authenticated', 'subscribers', 'members', 'staff', 'owner'] },
{ id: 'subscribers', label: 'Subscribers', permits: ['subscribers'] },
{ id: 'members', label: 'A module list', permits: ['members'] },
{ id: 'staff', label: 'Staff', permits: ['staff'] },
{ id: 'owner', label: 'The person it is about', permits: ['owner'] },
]
const TRIGGER = {
id: 'uo.house.idoc_warning',
label: 'House approaching collapse',
ceiling: 'owner',
audience: 'owner',
subjectKey: 'house',
variables: [
{ name: 'house', type: 'string', required: true },
{ name: 'daysLeft', type: 'int', required: false },
{ name: 'insured', type: 'boolean', required: false },
],
}
const OPERATORS = [
{ cmp: 'eq', label: 'is', types: ['string', 'int', 'boolean'], arity: 1 },
{ cmp: 'gt', label: 'is greater than', types: ['int'], arity: 1 },
{ cmp: 'in', label: 'is one of', types: ['string', 'int'], arity: 'list' },
{ cmp: 'present', label: 'is present', types: ['string', 'int', 'boolean'], arity: 0 },
]
const row = (over = {}) => ({
id: 3,
trigger_id: 'uo.house.idoc_warning',
name: 'IDOC warning',
enabled: 1,
audience: 'owner',
audience_segment_id: null,
channels: ['email'],
template_keys: { email: 'idoc-warning' },
conditions: null,
cooldown_seconds: 86400,
delay_seconds: 0,
cancel_on: [],
max_sends_per_hour: 100,
...over,
})
// ── The form round trip ────────────────────────────────────────────────────
test('a rule row round-trips through the form without changing what it means', () => {
const payload = ruleToPayload(formFromRule(row()))
assert.equal(payload.triggerId, 'uo.house.idoc_warning')
assert.equal(payload.enabled, true)
assert.deepEqual(payload.channels, ['email'])
assert.deepEqual(payload.templateKeys, { email: 'idoc-warning' })
assert.equal(payload.cooldownSeconds, 86400)
assert.equal(payload.maxSendsPerHour, 100)
})
test('unticking a channel drops its template key, rather than sending one the server refuses', () => {
const form = formFromRule(row({ channels: ['email', 'push'], template_keys: { email: 'a', push: 'b' } }))
form.channels = ['email']
const payload = ruleToPayload(form)
// The server refuses `templateKeys` naming a channel the rule does not have.
// Leaving it in would produce an error about a field the operator cannot see.
assert.deepEqual(payload.templateKeys, { email: 'a' })
})
// ── The audience the editor may offer ──────────────────────────────────────
test('the editor offers only what the trigger ceiling permits', () => {
const choices = audienceChoicesFor(TRIGGER, CEILINGS).map((c) => c.id)
assert.deepEqual(choices, ['owner'])
})
test('a wider trigger offers more, in lattice order', () => {
const choices = audienceChoicesFor({ ...TRIGGER, ceiling: 'authenticated' }, CEILINGS).map((c) => c.id)
assert.deepEqual(choices, ['authenticated', 'subscribers', 'members', 'staff', 'owner'])
})
test('an unknown trigger offers nothing — failing closed, like the server', () => {
// This is a dormant rule, whose module has been uninstalled. Offering the full
// vocabulary would be the widening the whole ceiling design exists to prevent.
assert.deepEqual(audienceChoicesFor({ ...TRIGGER, ceiling: 'nonsense' }, CEILINGS), [])
assert.deepEqual(audienceChoicesFor(null, CEILINGS), [])
})
test('segments are filtered by their STORED ceiling, not re-derived', () => {
const segments = [
{ id: 1, name: 'Governors', ceiling: 'members' },
{ id: 2, name: 'Watchers', ceiling: 'authenticated' },
]
const wide = segmentChoicesFor({ ...TRIGGER, ceiling: 'authenticated' }, CEILINGS, segments)
assert.deepEqual(wide.map((s) => s.id), [1, 2])
const narrow = segmentChoicesFor({ ...TRIGGER, ceiling: 'members' }, CEILINGS, segments)
assert.deepEqual(narrow.map((s) => s.id), [1])
})
// ── The reach preview ──────────────────────────────────────────────────────
test('a capped count reads as a floor, never as a total', () => {
const said = describeReach({ count: 5000, capped: true, dormant: false, reason: null, permitted: true })
assert.match(said, /At least 5000/)
})
test('a count the trigger would refuse says so, instead of looking healthy', () => {
const said = describeReach({ count: 12, capped: false, dormant: false, reason: null, permitted: false })
assert.match(said, /will be refused/)
})
test('a dormant segment says why, rather than reading as "nobody"', () => {
const said = describeReach({ count: 0, dormant: true, reason: 'audience segment is dormant' })
assert.match(said, /dormant/)
})
test('an owner audience carries its reason forward', () => {
const said = describeReach({ count: 0, dormant: false, reason: 'event carries no ownerUserId', permitted: true })
assert.match(said, /ownerUserId/)
})
// ── Conditions ─────────────────────────────────────────────────────────────
test('operators narrow to the variable type that was picked', () => {
assert.deepEqual(operatorsForType(OPERATORS, 'boolean').map((o) => o.cmp), ['eq', 'present'])
assert.deepEqual(operatorsForType(OPERATORS, 'int').map((o) => o.cmp), ['eq', 'gt', 'in', 'present'])
})
test('a literal is coerced to the type the trigger DECLARED', () => {
// Every value in an HTML input is a string, and `{ cmp: 'gt', value: "5" }`
// against an int variable is refused by the server — rightly, because a
// comparison between a number and a string quietly never matches.
const built = conditionsFromRows('and', [{ variable: 'daysLeft', cmp: 'gt', value: '5' }], TRIGGER.variables)
assert.deepEqual(built, { variable: 'daysLeft', cmp: 'gt', value: 5 })
})
test('a value that does not parse is passed through, so the server names the field', () => {
// NOT NaN, and not 0: a rule that saves cleanly having silently compared
// against a number nobody typed is worse than a refusal that says which
// variable it was.
assert.equal(coerceLiteral('int', 'soon'), 'soon')
assert.equal(coerceLiteral('boolean', 'yes'), 'yes')
assert.equal(coerceLiteral('boolean', 'true'), true)
assert.equal(coerceLiteral('float', '1.5'), 1.5)
})
test('a list operator splits on commas and types each item', () => {
const built = conditionsFromRows('and', [{ variable: 'daysLeft', cmp: 'in', value: '1, 2, 3' }], TRIGGER.variables)
assert.deepEqual(built.value, [1, 2, 3])
})
test('present and absent carry no value at all', () => {
const built = conditionsFromRows('and', [{ variable: 'house', cmp: 'present', value: 'ignored' }], TRIGGER.variables)
assert.deepEqual(built, { variable: 'house', cmp: 'present' })
})
test('no rows means no conditions — not an empty group that matches nothing', () => {
assert.equal(conditionsFromRows('and', [], TRIGGER.variables), null)
assert.equal(conditionsFromRows('and', [{ variable: '', cmp: '' }], TRIGGER.variables), null)
})
test('a flat stored tree opens editable; a nested one opens read-only', () => {
const flat = conditionRowsFrom({
op: 'and',
nodes: [{ variable: 'house', cmp: 'eq', value: 'x' }, { variable: 'daysLeft', cmp: 'gt', value: 5 }],
})
assert.equal(flat.editable, true)
assert.equal(flat.rows.length, 2)
// `A AND (B OR C)` flattened to `A AND B AND C` fires on different events, and
// the operator would have no way to know the save had done it.
const nested = conditionRowsFrom({
op: 'and',
nodes: [
{ variable: 'house', cmp: 'eq', value: 'x' },
{ op: 'or', nodes: [{ variable: 'daysLeft', cmp: 'gt', value: 5 }] },
],
})
assert.equal(nested.editable, false)
assert.deepEqual(nested.rows, [])
})
test('a single stored comparison is one editable row', () => {
const one = conditionRowsFrom({ variable: 'house', cmp: 'eq', value: 'x' })
assert.equal(one.editable, true)
assert.deepEqual(one.rows, [{ variable: 'house', cmp: 'eq', value: 'x' }])
})
// ── Segment composition ────────────────────────────────────────────────────
test('a members audience with no saved audience is warned about BEFORE the save', () => {
// The trap the browser walk found: it is the default the moment a
// members-ceiling trigger is chosen, and the rule it produces saves, switches
// on and mails nobody. Nothing on the screen said so unless you pressed
// Preview.
assert.match(audienceWarning({ audience: 'members', audienceSegmentId: null }), /reaches nobody/)
assert.equal(audienceWarning({ audience: 'members', audienceSegmentId: 4 }), null)
assert.equal(audienceWarning({ audience: 'owner', audienceSegmentId: null }), null)
})
test('the server says "segment"; the screens say "saved audience"', () => {
// One word for one table in the API, the schema and the docs. But an operator
// meets the concept under a heading that says "Audiences", and a sentence that
// switches vocabulary mid-screen reads as being about something else.
assert.equal(operatorWords('audience segment is dormant'), 'audience saved audience is dormant')
assert.match(describeReach({ count: 0, dormant: true, reason: 'audience segment is dormant' }), /saved audience/)
// and it does not maul a word that merely contains it
assert.equal(operatorWords('segmented data'), 'segmented data')
})
test('a list of nothing but exclusions is refused before the round trip', () => {
// One checkbox away at all times, because the composer offers "exclude" on
// every row including the only one. The server refuses it correctly — but
// only after a save.
const err = notPlacementError({ op: 'and', nodes: [{ op: 'not', nodes: [{ audienceId: 'a' }] }] })
assert.match(err, /at least one audience/i)
})
test('a bare not is refused before it reaches the server', () => {
assert.ok(notPlacementError({ op: 'not', nodes: [{ audienceId: 'uo.governors' }] }))
assert.ok(notPlacementError({ op: 'or', nodes: [{ audienceId: 'a' }, { op: 'not', nodes: [{ audienceId: 'b' }] }] }))
})
test('a not under an "all of" is fine — that is the only universe that does not widen', () => {
assert.equal(
notPlacementError({
op: 'and',
nodes: [{ audienceId: 'uo.governors' }, { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }],
}),
null,
)
})
test('an expression describes itself with module labels where it has them', () => {
const byId = { 'uo.governors': { label: 'Governors' } }
const said = describeExpression(
{ op: 'and', nodes: [{ audienceId: 'uo.governors' }, { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }] },
byId,
)
assert.equal(said, 'Governors and not uo.flagged')
})
test('a leaf renders its parameters, so two rows built on the same audience are distinguishable', () => {
const said = describeExpression({ audienceId: 'uo.team.members', params: { teamId: 4 } }, {})
assert.equal(said, 'uo.team.members (teamId: 4)')
})
// ── The list summary ───────────────────────────────────────────────────────
test('a rule summarises to what it will do, and always names its hourly cap', () => {
const said = describeRule(row({ delay_seconds: 3600 }), { segmentsById: {} })
assert.match(said, /to owner/)
assert.match(said, /via email/)
assert.match(said, /after 1 hour/)
assert.match(said, /once per 1 day/)
assert.match(said, /100\/hour/)
})
test('a rule on a segment names the segment, not the ceiling column', () => {
// The `audience` column on such a rule holds the segment's ceiling, which is a
// fact about what it MAY reach and not about who it does.
const said = describeRule(row({ audience: 'members', audience_segment_id: 7 }), {
segmentsById: { 7: { name: 'Governors' } },
})
assert.match(said, /to Governors/)
})
test('humanSeconds picks the coarsest EXACT unit, and never rounds', () => {
assert.equal(humanSeconds(0), 'none')
assert.equal(humanSeconds(3600), '1 hour')
assert.equal(humanSeconds(86400), '1 day')
assert.equal(humanSeconds(7200), '2 hours')
assert.equal(humanSeconds(3660), '61 minutes')
assert.equal(humanSeconds(90), '90 seconds')
})

View File

@@ -5,7 +5,8 @@ import {
registry,
declareSlot,
declareModuleSlot,
fillModuleSlot,
offerCoreFill,
CORE_CONTRIBUTIONS,
applyCoreFills,
registerExtension,
extensionFor,
@@ -112,34 +113,72 @@ test('a module-declared slot must be namespaced under the declaring module', ()
assert.doesNotThrow(() => declareModuleSlot('uo', 'uo.guild.detail'))
})
test('core fills a module slot only after the module has declared it', () => {
// The ordering that makes this a separate call: core's bundle evaluates BEFORE
// any module chunk, so at the moment core registers its fill the slot does not
// exist yet. Filling eagerly would silently do nothing.
fillModuleSlot('uo.guild.detail', Feed)
assert.equal(extensionFor('uo.guild.detail'), null, 'not filled before the module declared it')
declareModuleSlot('uo', 'uo.guild.detail')
assert.equal(extensionFor('uo.guild.detail'), null, 'and not before the fills are applied')
test('core offers a contribution and the module says where it goes', () => {
// The ordering that makes this two calls: core's bundle evaluates BEFORE any
// module chunk, so at the moment core offers, no module-declared slot exists.
offerCoreFill('team.activity', Feed)
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
assert.equal(extensionFor('uo.guild.detail'), null, 'not before the fills are applied')
applyCoreFills()
assert.equal(extensionFor('uo.guild.detail'), Feed)
})
test('a fill for a slot nobody declared is not an error', () => {
// The module is not installed. Core offering content for a page that does not
// exist is the ordinary case on any deployment, not a misconfiguration — the
// mirror of an unfilled slot rendering nothing.
fillModuleSlot('rust.clan.detail', Feed)
test('core names no slot, so a second game gets the same content in its own words', () => {
// The defect this replaced: core used to fill three literal `uo.guild.*` names,
// which reached exactly one module. Every other game declared a place under its
// own id and got an empty page with no error, because a fill nobody declared is
// deliberately not an error — the rule that makes an unknown name invisible.
offerCoreFill('team.activity', Feed)
declareModuleSlot('examplegame', 'examplegame.clan.detail', { core: 'team.activity' })
applyCoreFills()
assert.equal(extensionFor('examplegame.clan.detail'), Feed)
})
test('two modules can ask for the same contribution, and both get it', () => {
// Core has no reason to care how many places want its feed, and refusing the
// second would be core making a layout decision on a page it does not own.
offerCoreFill('team.activity', Feed)
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
declareModuleSlot('uo', 'uo.guild.summary', { core: 'team.activity' })
applyCoreFills()
assert.equal(extensionFor('uo.guild.detail'), Feed)
assert.equal(extensionFor('uo.guild.summary'), Feed)
})
test('a slot that asks for nothing stays empty', () => {
// Optional on purpose: a module may declare a place it fills itself, or one it
// is keeping for later. Neither is core's business.
offerCoreFill('team.activity', Feed)
declareModuleSlot('uo', 'uo.guild.detail')
applyCoreFills()
assert.equal(extensionFor('uo.guild.detail'), null)
})
test('asking for a contribution core does not offer THROWS', () => {
// The asymmetry with an unfilled slot, and it is deliberate. An unknown
// contribution is always a typo or a version skew — core's list is fixed at
// build time and the module's coreApi range has already been checked — and the
// alternative failure is a page that renders empty forever with nothing logged.
assert.throws(
() => declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activityfeed' }),
/does not offer/,
)
assert.ok(CORE_CONTRIBUTIONS['team.activity'], 'the catalogue is exported so a test can name it')
})
test('a contribution nothing asks for is not an error', () => {
// No game module installed. Core offering content for a page that does not
// exist is the ordinary case on any deployment, not a misconfiguration.
offerCoreFill('team.forum', Feed)
assert.doesNotThrow(() => applyCoreFills())
assert.equal(extensionFor('rust.clan.detail'), null)
})
test('a module that fills its own slot first keeps it', () => {
const Own = () => null
declareModuleSlot('uo', 'uo.guild.detail')
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
registerExtension('uo', 'uo.guild.detail', Own)
fillModuleSlot('uo.guild.detail', Feed)
offerCoreFill('team.activity', Feed)
applyCoreFills()
assert.equal(extensionFor('uo.guild.detail'), Own, 'first fill wins, as everywhere else')
})
@@ -150,21 +189,21 @@ test('a module-declared slot cannot be declared twice', () => {
})
test('applying the fills twice does not re-fill or throw', () => {
declareModuleSlot('uo', 'uo.guild.detail')
fillModuleSlot('uo.guild.detail', Feed)
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
offerCoreFill('team.activity', Feed)
applyCoreFills()
assert.doesNotThrow(() => applyCoreFills())
assert.equal(extensionFor('uo.guild.detail'), Feed)
})
test('a non-component fill is refused at the call site, not at render', () => {
assert.throws(() => fillModuleSlot('uo.guild.detail', 'nope'), /is not a component/)
test('a non-component contribution is refused at the call site, not at render', () => {
assert.throws(() => offerCoreFill('team.activity', 'nope'), /is not a component/)
})
test('_reset clears pending fills, so one test cannot leak into the next', () => {
fillModuleSlot('uo.guild.detail', Feed)
offerCoreFill('team.activity', Feed)
_reset()
declareModuleSlot('uo', 'uo.guild.detail')
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
applyCoreFills()
assert.equal(extensionFor('uo.guild.detail'), null)
})

View File

@@ -0,0 +1,42 @@
// ── Where each account's notification screens live ─────────────────────────
//
// ENGAGEMENT.md Phase 7. Three assertions for a nine-line module, because the
// defect they pin was invisible to every other check: `/auth/me/notifications`
// is role-agnostic (behind `requireAuth` only, like the rest of `/auth/me`), so
// the server, the tests and the API all agreed a staff member had an inbox —
// and on the web they could not reach it, because `RequirePlayer` sends anyone
// who is not a player back out of `/account`. The bell pointed at a redirect.
//
// Found in the Phase 7 rig, signed in as an admin. What stops it coming back is
// this file plus the two admin routes it maps onto.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { isStaff, inboxPath, notificationSettingsPath } from '../src/lib/notificationPaths.js'
test('a player gets the portal paths', () => {
const user = { role: 'player' }
assert.equal(isStaff(user), false)
assert.equal(inboxPath(user), '/account/notifications')
assert.equal(notificationSettingsPath(user), '/account/notifications/settings')
})
test('every non-player role gets the admin paths, not just admin', () => {
for (const role of ['admin', 'editor', 'moderator']) {
const user = { role }
assert.equal(isStaff(user), true, role)
assert.equal(inboxPath(user), '/admin/notifications', role)
assert.equal(notificationSettingsPath(user), '/admin/notifications/settings', role)
}
})
// The bell renders nothing when signed out, so these are never asked for a null
// user in practice — but a default that guessed "staff" would send a signed-out
// visitor at the admin area the moment that changed.
test('no user, or a user with no role, falls back to the player paths', () => {
for (const user of [null, undefined, {}, { role: '' }]) {
assert.equal(isStaff(user), false)
assert.equal(inboxPath(user), '/account/notifications')
}
})

View File

@@ -0,0 +1,129 @@
// What Admin → Teams → Notification bridge decides (client/src/lib/teamIntegrations.js).
//
// The test that earns this file: **repointing a row must not carry its
// acknowledgement across.** That is the one way this screen could actively
// mislead — an operator confirms a private channel, changes the id to a public
// one, and the form still shows the confirmation as standing. The server clears
// it either way, so the failure would be a screen that disagrees with the answer
// it is about to get, which is worse than one that simply refuses.
//
// The rest is the boundary of the confirmation dialog: it must open when it
// matters and stay shut when it does not, because a dialog that appears on saves
// that did not need it is one people learn to click through.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
eventLabel, rowKey, isDefaultRow, blankDraft, draftFrom, appliesToLabel, toggleEvent,
setChannel, carriesMembersOnly, needsAcknowledgement, membersOnlyIdsOf, availableTargets,
} from '../src/lib/teamIntegrations.js'
const MEMBERS_ONLY = ['team.forum.post', 'team.announcement']
const ROSTER = 'team.member.joined'
const FORUM = 'team.forum.post'
const draft = (over = {}) => ({ ...blankDraft(null), ...over })
// ── The acknowledgement dies with its channel ──────────────────────────────
test('changing the channel drops a standing acknowledgement', () => {
const before = draft({ channelRef: '111', membersAck: true, events: [FORUM], enabled: true })
const after = setChannel(before, '222')
assert.equal(after.membersAck, false)
assert.equal(after.channelRef, '222')
})
test('setting the SAME channel does not clear it — an unrelated re-render is not a repoint', () => {
const before = draft({ channelRef: '111', membersAck: true })
const after = setChannel(before, '111')
assert.equal(after.membersAck, true)
assert.equal(after, before, 'and the object is returned unchanged, so nothing re-renders')
})
test('a repointed row needs the dialog again, which is the whole point of clearing it', () => {
const before = draft({ channelRef: '111', membersAck: true, events: [FORUM], enabled: true })
assert.equal(needsAcknowledgement(before, MEMBERS_ONLY), false)
assert.equal(needsAcknowledgement(setChannel(before, '222'), MEMBERS_ONLY), true)
})
// ── When the dialog opens ──────────────────────────────────────────────────
test('enabling a forum event without the tick asks first', () => {
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: true }), MEMBERS_ONLY), true)
})
test('a DISABLED draft carrying forum events does not ask — nothing is being published yet', () => {
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: false }), MEMBERS_ONLY), false)
})
test('a roster-only bridge never asks, however it is configured', () => {
assert.equal(needsAcknowledgement(draft({ events: [ROSTER], enabled: true }), MEMBERS_ONLY), false)
assert.equal(carriesMembersOnly(draft({ events: [ROSTER] }), MEMBERS_ONLY), false)
})
test('an acknowledgement already given means no second dialog for an unrelated edit', () => {
const d = draft({ events: [FORUM], enabled: true, membersAck: true, channelRef: '111' })
const withRoster = toggleEvent(d, ROSTER)
assert.equal(needsAcknowledgement(withRoster, MEMBERS_ONLY), false)
})
test('the members-only set comes from the server, not from a list held here', () => {
// The client must not decide what is members-only: a future stream added
// server-side would silently escape a hardcoded client list.
assert.deepEqual(
membersOnlyIdsOf([{ id: ROSTER, membersOnly: false }, { id: FORUM, membersOnly: true }]),
[FORUM],
)
// Told nothing is members-only, the dialog never opens — the server is the one
// that would then refuse, which is the correct division.
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: true }), []), false)
})
// ── Events, rows and targets ───────────────────────────────────────────────
test('toggling adds then removes, and preserves selection order', () => {
let d = draft()
d = toggleEvent(d, FORUM)
d = toggleEvent(d, ROSTER)
assert.deepEqual(d.events, [FORUM, ROSTER])
d = toggleEvent(d, FORUM)
assert.deepEqual(d.events, [ROSTER])
})
test('the default row is identified by a NULL team, and an undefined one counts too', () => {
assert.equal(isDefaultRow({ team_id: null }), true)
assert.equal(isDefaultRow({}), true)
assert.equal(isDefaultRow({ team_id: 4 }), false)
assert.equal(rowKey({ team_id: null }), 'default')
assert.equal(rowKey({ team_id: 4 }), '4')
})
test('a row is labelled by the staff override first, then the name, then its id', () => {
assert.equal(appliesToLabel({ team_id: null }), 'All Teams')
assert.equal(appliesToLabel({ team_id: 4, team_name: 'Real', display_name_override: 'Shown' }), 'Shown')
assert.equal(appliesToLabel({ team_id: 4, team_name: 'Real' }), 'Real')
assert.equal(appliesToLabel({ team_id: 4 }), 'Team #4')
})
test('a Team that already has an override is not offered a second one', () => {
const rows = [{ team_id: null }, { team_id: 2 }]
const teams = [{ id: 1, status: 'active' }, { id: 2, status: 'active' }, { id: 3, status: 'archived' }]
const { hasDefault, teams: available } = availableTargets(rows, teams)
assert.equal(hasDefault, true)
assert.deepEqual(available.map((t) => t.id), [1], 'the taken one and the archived one are both out')
})
test('with no default configured, the default is still offered', () => {
const { hasDefault } = availableTargets([{ team_id: 2 }], [])
assert.equal(hasDefault, false)
})
test('a row round-trips through the draft without changing what it means', () => {
const row = { team_id: 4, events: [FORUM], channel_ref: '111', enabled: 1, members_ack: 1 }
assert.deepEqual(draftFrom(row), { teamId: 4, events: [FORUM], channelRef: '111', enabled: true, membersAck: true })
})
test('an unknown event id renders as itself rather than as blank', () => {
assert.equal(eventLabel(FORUM), 'New forum post')
assert.equal(eventLabel('team.something.new'), 'team.something.new')
})

View File

@@ -0,0 +1,152 @@
// Admin → Teams → Voice channels, the decisions (TEAMS.md §7.3, phase 9).
//
// These mirror server rules and do not replace them: the server refuses to enable
// voice while the bot cannot act, and the reconciler applies the threshold and the
// grace window, whether or not this file ever ran. What is asserted here is that
// the SCREEN agrees with those answers instead of offering a control that will
// fail, or describing a state the deployment is not in.
//
// The one that matters most is `statusSummary`'s "off" branch. Switching voice off
// suspends the reconciler in both directions and deliberately leaves existing
// channels standing — a checkbox must not delete structure in somebody's guild —
// and an operator who reads "off" as "nothing is provisioned" would never go
// looking for the channels that are still there.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
stateLabel, enableBlockedReason, roleHeadroom, removalCountdown,
parseStaffRoles, formatStaffRoles, statusSummary,
} from '../src/lib/teamVoice.js'
test('every state the server can report has wording', () => {
for (const state of ['none', 'active', 'pending_removal', 'error']) {
assert.notEqual(stateLabel(state), state)
}
})
test('an unknown state falls back to itself rather than rendering blank', () => {
assert.equal(stateLabel('something-new'), 'something-new')
})
// ── The enable gate ────────────────────────────────────────────────────────
test('a ready bot blocks nothing', () => {
assert.equal(enableBlockedReason({ ready: true, connected: true, missingPermissions: [] }), null)
})
test('a disconnected bot and a bot missing a permission read differently', () => {
const disconnected = enableBlockedReason({ ready: false, connected: false, reason: 'the bot is not connected to Discord' })
const missing = enableBlockedReason({ ready: false, connected: true, missingPermissions: ['Manage Roles'] })
assert.match(disconnected, /not connected/)
assert.match(missing, /Manage Roles/)
// An operator fixes these in two completely different places, so collapsing
// them into one message would send half of them to the wrong one.
assert.notEqual(disconnected, missing)
})
test('an absent preflight blocks rather than silently allowing', () => {
assert.ok(enableBlockedReason(null))
assert.ok(enableBlockedReason(undefined))
})
// ── The role ceiling ───────────────────────────────────────────────────────
test('headroom is counted against the guild-wide cap', () => {
const h = roleHeadroom({ roleCount: 200, roleCap: 250 })
assert.equal(h.free, 50)
assert.equal(h.tight, false)
assert.equal(h.exhausted, false)
})
test('a nearly full guild is flagged before the create fails, not after', () => {
// The whole reason this is in the panel: access is a per-Team role, so the cap
// limits how many TEAMS can have voice, and an operator with sixty guilds needs
// to know that before the sixtieth silently errors.
const h = roleHeadroom({ roleCount: 240, roleCap: 250 })
assert.equal(h.tight, true)
assert.equal(h.exhausted, false)
})
test('a full guild is exhausted, and never reports negative headroom', () => {
const h = roleHeadroom({ roleCount: 260, roleCap: 250 })
assert.equal(h.free, 0)
assert.equal(h.exhausted, true)
})
test('no preflight means no claim about headroom', () => {
assert.equal(roleHeadroom(null), null)
assert.equal(roleHeadroom({}), null)
})
// ── The grace window ───────────────────────────────────────────────────────
test('a row that is not scheduled has no countdown', () => {
assert.equal(removalCountdown({ state: 'active', removeAfter: null }), null)
})
test('a running window reads in days', () => {
const now = new Date('2026-08-19T00:00:00Z')
const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-24T00:00:00Z' }, now)
assert.equal(text, 'in 5 days')
})
test('under a day reads in hours rather than rounding to zero days', () => {
const now = new Date('2026-08-19T00:00:00Z')
const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-19T06:00:00Z' }, now)
assert.equal(text, 'in 6 hours')
})
test('an expired window says the next pass will act, not "in 0 days"', () => {
const now = new Date('2026-08-19T00:00:00Z')
const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-18T00:00:00Z' }, now)
assert.match(text, /next pass/)
})
// ── Staff roles ────────────────────────────────────────────────────────────
test('staff roles parse from the comma-separated ids a person actually pastes', () => {
const { roles, invalid } = parseStaffRoles(' 123456789012345678 , 987654321098765432 ')
assert.deepEqual(roles, ['123456789012345678', '987654321098765432'])
assert.deepEqual(invalid, [])
})
test('a typo is REPORTED, never quietly dropped', () => {
const { invalid } = parseStaffRoles('123456789012345678, @Moderators')
assert.deepEqual(invalid, ['@Moderators'])
})
test('an empty field is a legitimate answer and not an error', () => {
const { roles, invalid } = parseStaffRoles('')
assert.deepEqual(roles, [])
assert.deepEqual(invalid, [])
})
test('roles round-trip through the field', () => {
const { roles } = parseStaffRoles(formatStaffRoles(['111111111111111111', '222222222222222222']))
assert.deepEqual(roles, ['111111111111111111', '222222222222222222'])
})
// ── The status line ────────────────────────────────────────────────────────
test('off with channels still standing says so — the surprising case', () => {
const text = statusSummary({ enabled: false }, [{ channelRef: '900' }, { channelRef: '901' }])
assert.match(text, /^Off\./)
assert.match(text, /2 channels remain/)
})
test('off with nothing provisioned does not invent a warning', () => {
const text = statusSummary({ enabled: false }, [])
assert.match(text, /No channels are provisioned/)
})
test('on states the threshold in the words the setting uses', () => {
const text = statusSummary({ enabled: true, minMembers: 5 }, [{ channelRef: '900' }])
assert.match(text, /at least 5 members/)
assert.match(text, /1 provisioned/)
})
test('a threshold of one is not pluralised', () => {
assert.match(statusSummary({ enabled: true, minMembers: 1 }, []), /at least 1 member get/)
})

View File

@@ -14,7 +14,8 @@
"seed": "npm run seed --prefix server",
"build": "npm run build --prefix client",
"start": "npm start --prefix server",
"check:modules": "node scripts/checkModuleIdentifiers.js"
"check:modules": "node scripts/checkModuleIdentifiers.js",
"check:hosts": "node scripts/checkNoExternalHosts.js"
},
"keywords": ["express", "mariadb", "react", "vite", "jwt"],
"author": "whitlocktech",

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env node
// ── §3.2 rule 4 — no phone-home in the engagement subsystem ────────────────
//
// ENGAGEMENT.md §3.2 records a posture the codebase already has and this check
// exists to keep: **no transport may ship a default host, endpoint, API base or
// sender.** A transport with no operator configuration is `unconfigured` and its
// channel is off — it never quietly falls back to a destination we chose.
//
// The rule is easy to hold and easy to break by accident, and the removed Gmail
// transport is the proof of both: `smtp.gmail.com` and port 465 were literals in
// `mailer.buildTransport()`, which made "which provider" a code edit and made the
// deployment's mail depend on a host nobody configured. Deleting that literal is
// what this check was written against, and it is the first thing it would have
// caught.
//
// **It reads code, not prose.** A comment naming `smtp.gmail.com` as the
// migration path for existing operators is exactly the documentation this phase
// owes, and a check that forbade it would teach people to phrase around it. So
// comments and the insides of ordinary strings are masked out; what is checked is
// a HOSTNAME OR URL appearing as a string literal in the engagement trees. Same
// design, and the same reasoning, as `checkModuleIdentifiers.js` — including
// having its own test suite, because a check that silently stops checking is
// worse than no check.
//
// Scope is the engagement subsystem plus the mail path it owns, not the whole
// server: core legitimately talks to hosts an operator configured elsewhere
// (ntfy, Discord, the sidecar), and those are not this rule's business.
const fs = require('fs')
const path = require('path')
const ROOT = path.resolve(__dirname, '..')
// The trees the rule covers. `server/src/engagement/` is where transports and,
// later, the rules engine live; `utils/mailer.js` is the one file outside it that
// composes and sends mail.
const TREES = [path.join(ROOT, 'server', 'src', 'engagement')]
const FILES = [path.join(ROOT, 'server', 'src', 'utils', 'mailer.js')]
const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', '.git'])
const CODE = new Set(['.js', '.jsx', '.mjs', '.cjs'])
// A URL, or a bare dotted hostname with a real TLD. The TLD length floor is what
// keeps `emailConfig.model` and `foo.js` out of it — a two-plus-letter final
// label after at least one dot, with no path characters, is a host.
// The `(?![-\w])` after the TLD is not redundant with `\b`: `\b` matches between
// `l` and `-`, so `auth.email-verify` — an engagement TEMPLATE KEY, and one the
// plan names (§4.6.1) — was read as the host `auth.email` with a stray suffix.
// A real hostname's TLD is the last label, so a `-` or a word character following
// it means the match is a truncation of a longer identifier rather than a
// destination. Everything a host IS followed by (a quote, `/`, `:`, `?`) still
// matches.
const URL_LITERAL = /\b(?:https?|smtps?):\/\/[^\s'"`]+/
const HOSTNAME_LITERAL = /\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:com|net|org|io|dev|co|email|mail|cloud|app|us|eu)(?![-\w])/i
// Hosts that are not destinations: the loopback family, and the RFC 2606 names
// reserved for documentation. A placeholder in an admin form's help text is the
// opposite of a phone-home — it shows the operator the SHAPE of a value they
// must supply, and blanking it would make the form worse to hold the rule.
const ALLOWED = [
/^(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)$/i,
/(?:^|\.)example\.(?:com|net|org)$/i,
/(?:^|\.)(?:invalid|test|localhost)$/i,
]
const isAllowed = (host) => ALLOWED.some((re) => re.test(host))
const hostOf = (literal) => {
const withoutScheme = literal.replace(/^[a-z]+:\/\//i, '')
return withoutScheme.split(/[/?#:]/)[0]
}
/**
* Blank comments and mask string bodies in one left-to-right pass, keeping every
* offset aligned so reported line numbers stay honest.
*
* Lifted from `checkModuleIdentifiers.maskCode` deliberately rather than
* imported: that file's masking is tuned to ITS four checks (it keeps quotes so a
* route-path check can re-read the original at the same offsets), and coupling
* two checks through a shared helper means a change made for one silently
* re-scopes the other. Both are ~40 lines and both are tested.
*/
function maskComments(src) {
const out = Array.from(src)
const blank = (from, to) => {
for (let i = from; i < to && i < out.length; i++) if (out[i] !== '\n') out[i] = ' '
}
let i = 0
while (i < src.length) {
const c = src[i]
const next = src[i + 1]
if (c === '/' && next === '/') {
let j = i
while (j < src.length && src[j] !== '\n') j++
blank(i, j)
i = j
continue
}
if (c === '/' && next === '*') {
const end = src.indexOf('*/', i + 2)
const j = end === -1 ? src.length : end + 2
blank(i, j)
i = j
continue
}
if (c === '"' || c === "'" || c === '`') {
let j = i + 1
while (j < src.length) {
if (src[j] === '\\') { j += 2; continue }
if (src[j] === c) break
j++
}
// Keep the string body: it is what this check reads. Only the delimiters
// matter for finding it, and comments are what has to go.
i = j + 1
continue
}
i++
}
return out.join('')
}
// Every string literal in the (comment-free) source, with its line number.
const STRING = /(['"`])((?:\\.|(?!\1)[^\\])*)\1/g
function lineOf(src, index) {
return src.slice(0, index).split('\n').length
}
/** Check one file's contents. Returns [{ file, line, literal, host }]. */
function checkFile(rel, src) {
const hits = []
const code = maskComments(src)
for (const m of code.matchAll(STRING)) {
const value = m[2]
if (!value) continue
const urlMatch = value.match(URL_LITERAL)
const hostMatch = urlMatch ? null : value.match(HOSTNAME_LITERAL)
const literal = urlMatch ? urlMatch[0] : hostMatch ? hostMatch[0] : null
if (!literal) continue
const host = hostOf(literal)
if (isAllowed(host)) continue
hits.push({ file: rel, line: lineOf(src, m.index), literal, host })
}
return hits
}
function walk(dir, out = []) {
if (!fs.existsSync(dir)) return out
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walk(full, out)
else out.push(full)
}
return out
}
function run() {
const files = [...TREES.flatMap((t) => walk(t)), ...FILES.filter((f) => fs.existsSync(f))]
const hits = []
for (const file of files) {
if (!CODE.has(path.extname(file))) continue
const rel = path.relative(ROOT, file).split(path.sep).join('/')
hits.push(...checkFile(rel, fs.readFileSync(file, 'utf8')))
}
return hits
}
module.exports = { run, checkFile, maskComments, isAllowed, hostOf }
if (require.main === module) {
const hits = run()
if (hits.length === 0) {
console.log('OK — the engagement subsystem names no external host (ENGAGEMENT.md §3.2 rule 4).')
process.exit(0)
}
console.error(
`\nThe engagement subsystem names ${hits.length} external host${hits.length === 1 ? '' : 's'} ` +
'in code (ENGAGEMENT.md §3.2 rule 4). A destination belongs in operator-supplied ' +
'configuration, never in a literal:\n',
)
for (const h of hits) {
console.error(` ${h.file}:${h.line} "${h.literal}"`)
}
console.error(
'\nIf this is help text or documentation rather than a destination, put it in a comment or ' +
'use an example.com placeholder — the check masks comments and allows the reserved ' +
'documentation names on purpose.\n',
)
process.exit(1)
}

View File

@@ -80,10 +80,12 @@ TOTP_CHALLENGE_TTL=5m
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-admin-password
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not here.
# It reuses the Google auth provider's OAuth client and stores an encrypted
# refresh token in the DB. The contact recipient is the `contact_email` site
# Email is configured in Admin → Settings → Email, not here: pick a mail
# transport (SMTP) and enter its host, port and credentials, stored encrypted in
# the DB. A relay is the recommended posture; smtp.gmail.com:587 with an app
# password is the simplest. The contact recipient is the `contact_email` site
# setting; while email is unconfigured the contact form falls back to a mailto: link.
# Upgrading from the removed Gmail connect flow: see docs/website/UPGRADE_NOTES.md.
CLIENT_ORIGIN=http://localhost:5173

View File

@@ -21,11 +21,30 @@ CREATE TABLE IF NOT EXISTS users (
-- (validatePassword returns false).
password_hash VARCHAR(72) NULL,
role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin',
-- Optional contact email (players). Not unique — SSO emails may repeat. Used
-- only for display + a future self-serve reset. email_verified is wired now so
-- an eventual SMTP verification flow needs no schema change.
-- The account's ONE contact address, and the destination for password-reset
-- mail. Unique since engagement Phase 1b — but the index is on email_norm
-- below, never on this column, and the reason is not stylistic:
--
-- Every case-insensitive (_ci) collation this server offers is ALSO
-- accent-insensitive, so a UNIQUE index on `email` would refuse
-- jose@x.com once josé@x.com exists. Those are two different mailboxes.
--
-- LOWER() under a _bin collation folds case WITHOUT folding accents, which is
-- exactly the equivalence a mail system uses. Keeping the fold in a generated
-- column rather than in application code means it cannot be bypassed by a
-- caller that forgets to normalize.
email VARCHAR(255) NULL,
-- The uniqueness key. STORED (not VIRTUAL) because a UNIQUE index over it must
-- be materialized. Multiple NULLs are legal under a UNIQUE index, which is what
-- lets the Phase 1b de-duplication null the losers without deleting an account.
email_norm VARCHAR(255) COLLATE utf8mb4_bin AS (LOWER(email)) STORED,
email_verified TINYINT(1) NOT NULL DEFAULT 0,
-- An address the user has asked for but not yet proved. It does NOT displace
-- `email` until the verification link is used, so a typo cannot silently
-- redirect this account's password-reset mail. Deliberately NOT unique: a
-- pending address reserves nothing, and two users may both be pending on one
-- address — the second to verify loses, with the same generic failure.
email_pending VARCHAR(255) NULL,
-- Account lifecycle, independent of role: staff can disable/ban a player
-- without changing their role. active = normal; disabled = admin-locked;
-- banned = moderation ban; pending = reserved for future email-verify gating.
@@ -38,7 +57,12 @@ CREATE TABLE IF NOT EXISTS users (
tokens_valid_after DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME NULL,
last_login_ip VARCHAR(45) NULL -- IPv6-capable, set on each login
last_login_ip VARCHAR(45) NULL, -- IPv6-capable, set on each login
-- One account per mailbox (engagement Phase 1b). On the generated column, not
-- on `email` — see the note there. Upgraded databases get this in the migration
-- block at the foot of this file, AFTER the de-duplication that makes it
-- addable; adding it here too is what gives a FRESH install the same shape.
UNIQUE KEY uq_users_email_norm (email_norm)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS posts (
@@ -327,19 +351,33 @@ CREATE TABLE IF NOT EXISTS bot_config (
CONSTRAINT chk_bot_config_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Outbound email configuration (Gmail over OAuth2 / SMTP XOAUTH2). Singleton row
-- (id = 1), mirroring bot_config: the DB only ever holds the AES-256-GCM-encrypted
-- refresh token, never plaintext, and the client id/secret are NOT stored here —
-- they are read live from the `google` auth_providers row. The refresh token is
-- captured by the in-app "Connect Gmail" consent flow and is write-only over the
-- admin API (never returned; responses expose only hasRefreshToken).
-- Outbound email configuration. Singleton row (id = 1), mirroring bot_config: the
-- DB only ever holds the AES-256-GCM-encrypted credential, never plaintext, and it
-- is write-only over the admin API (never returned; responses expose only
-- hasCredential and the non-secret fields the transport declares).
--
-- `transport` names a registered mail transport (server/src/engagement/transports).
-- `credential_enc` is that transport's whole credential set as one encrypted JSON
-- blob rather than a column per field, because the field list is the transport's to
-- declare — SMTP wants host/port/secure/user/password, an API relay wants a domain
-- and a key, and a column per union member would make adding a transport a schema
-- change. ENGAGEMENT.md §3.1.
--
-- `provider` and `refresh_token_enc` are DEPRECATED and no longer read: they held
-- the removed Gmail OAuth2 connection (ENGAGEMENT.md §1.2a). They are kept rather
-- than dropped under the additive-only discipline, and `refresh_token_enc` earns
-- its keep in the meantime as the marker for "this deployment had working mail
-- before the upgrade" — which is what the admin dashboard warning reads.
CREATE TABLE IF NOT EXISTS email_config (
id INT PRIMARY KEY DEFAULT 1,
provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2',
provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2', -- DEPRECATED, unread
transport VARCHAR(32) NOT NULL DEFAULT 'smtp',
enabled TINYINT(1) NOT NULL DEFAULT 0,
sender_email VARCHAR(255) NULL, -- connected Gmail address (from userinfo)
sender_email VARCHAR(255) NULL, -- envelope From, operator-typed
sender_name VARCHAR(120) NULL, -- optional From display name
refresh_token_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed
reply_to VARCHAR(255) NULL, -- optional Reply-To for sent mail
credential_enc TEXT NULL, -- AES-256-GCM ciphertext (JSON), never exposed
refresh_token_enc TEXT NULL, -- DEPRECATED, unread; see above
status VARCHAR(20) NOT NULL DEFAULT 'unconfigured',
status_detail VARCHAR(500) NULL,
last_verified_at DATETIME NULL,
@@ -401,6 +439,54 @@ CREATE TABLE IF NOT EXISTS password_resets (
INDEX idx_password_resets_status (status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Self-service email verification (engagement Phase 1b). The same shape as
-- password_resets, deliberately: an opaque random token whose sha256 is all that
-- is stored, single-use, short-lived. The design of record calls this link
-- "signed"; every comparable flow in this codebase (user_invites,
-- password_resets, mobile_refresh_tokens) uses a hashed random token instead, and
-- matching them beats introducing a second token mechanism for one caller.
--
-- The address lives on the ROW, not just on the user: a token proves control of
-- the address it was mailed to, so if the user changes their mind and requests a
-- different address, the older token must not be able to confirm the newer one.
CREATE TABLE IF NOT EXISTS email_verifications (
id INT AUTO_INCREMENT PRIMARY KEY,
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token
user_id INT NOT NULL,
email VARCHAR(255) NOT NULL, -- the address THIS token proves
status ENUM('pending','used') NOT NULL DEFAULT 'pending',
requested_ip VARCHAR(64) NULL, -- who asked (audit only)
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
used_at DATETIME NULL,
CONSTRAINT fk_email_verifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_email_verifications_user (user_id),
INDEX idx_email_verifications_status (status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Who lost an address to the Phase 1b de-duplication, and what they lost.
--
-- These accounts are exactly the ones an operator must contact: they can no
-- longer receive password-reset or engagement mail until they set a new address.
-- Written by the migration below in pure SQL (ensureSchema() reads this file
-- statement-by-statement and there is no JS migration hook), surfaced as a
-- dashboard warning until acknowledged.
--
-- No foreign key to users, on purpose: the same reasoning as posts.announce_job_id
-- — a constraint re-added on every boot is a constraint that can fail a boot, and
-- this table is a historical record rather than a live relation.
CREATE TABLE IF NOT EXISTS email_dedupe_report (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
username VARCHAR(32) NOT NULL, -- captured at clear time
lost_address VARCHAR(255) NOT NULL,
cleared_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
acknowledged_at DATETIME NULL, -- set when an admin dismisses the warning
-- Makes the migration's INSERT strictly idempotent: an account cleared once is
-- never reported twice, however many times ensureSchema() runs.
UNIQUE KEY uq_edr_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Push notifications (opt-in) ─────────────────────────────────────────────
-- One row per registered push endpoint (Android/UnifiedPush v1; FCM later). The
-- `endpoint` is the UnifiedPush distributor URL the app's ntfy topic was handed —
@@ -1305,6 +1391,101 @@ CREATE TABLE IF NOT EXISTS team_notification_prefs (
INDEX idx_tnp_digest (email_mode, last_digest_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── The integration bridge's configuration (TEAMS.md §7.2, phase 8) ─────────
--
-- The SAME events as §6, delivered to a second consumer. Not a second pipeline:
-- `utils/teamNotify.js` computes the recipient set once and hands the event to
-- push, to email and now to this bridge.
--
-- `team_id NULL` is the deployment-wide default and a per-Team row overrides it,
-- which is what §7.2 asks for — but its `PRIMARY KEY (platform, team_id)` cannot
-- express it: MariaDB coerces every PRIMARY KEY column to NOT NULL, so the
-- default row is unrepresentable and the whole override mechanism has no base
-- case. Hence the surrogate key plus a generated `team_key`, the same trick
-- `teams.active_key` and `content_reports.open_marker` use: IFNULL folds the
-- default row onto 0, which no `teams.id` can be, so one default and one row per
-- Team coexist under a single UNIQUE key. It also buys the foreign key the
-- original DDL had no room for — without it, deleting a Team leaves its bridge
-- config behind to be inherited by the next Team that lands on the id.
--
-- **`members_ack` is a precondition, not a preference.** Forum posts and
-- announcements are members-only ALWAYS — there is no public forum thread, and
-- §7.2's gate ("visibility is public, or the channel is configured for a
-- members-only context") has no data source on either side: the streams carry no
-- visibility and core cannot see a Discord channel's permissions. Only the
-- operator can. So enabling a members-only event requires an explicit, attributed
-- acknowledgement that the destination is restricted to that Team, recorded the
-- way `teams_forum_uploads_ack` records the image-policy one. Changing the channel
-- CLEARS it (see the model): an acknowledgement is about a destination, and it
-- cannot survive the destination changing underneath it.
CREATE TABLE IF NOT EXISTS team_integration_config (
id INT AUTO_INCREMENT PRIMARY KEY,
platform VARCHAR(32) NOT NULL, -- 'discord'; opaque here, phase 10 makes it a registry key
team_id INT NULL, -- NULL = the deployment-wide default
events JSON NOT NULL, -- ['team.announcement','team.forum.post']
channel_ref VARCHAR(64) NULL, -- destination on that platform, opaque to core
enabled TINYINT(1) NOT NULL DEFAULT 0,
-- The §7.2 gate, as an operator assertion with a name against it.
members_ack TINYINT(1) NOT NULL DEFAULT 0,
members_ack_by INT NULL,
members_ack_at DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
team_key INT AS (IFNULL(team_id, 0)) STORED,
UNIQUE KEY uq_tic_platform_team (platform, team_key),
CONSTRAINT fk_tic_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
-- SET NULL rather than CASCADE, for the same reason every other snapshot in
-- this file is: deleting the admin's account must not silently un-acknowledge a
-- policy and start withholding messages the deployment is configured to send.
CONSTRAINT fk_tic_ack_by FOREIGN KEY (members_ack_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Per-Team external resources: the voice channel (TEAMS.md §7.3, phase 9) ─
--
-- One row per (Team, platform, resource). Today the only resource is 'voice',
-- and the column exists because the NEXT one — a text channel, a Matrix room —
-- is the same lifecycle with a different noun, and phase 10's capability
-- registry needs somewhere to say which resources a platform declares.
--
-- **Access is a per-Team ROLE, not per-member overwrites.** §7.3 designed
-- overwrites-by-default with escalation to a role above ~90 members; the org lead
-- settled on roles always (2026-08-18). That deletes `voice_overwrite_max` and the
-- mode transition, and it moves the ceiling: the binding limit is no longer ~100
-- overwrites on one channel but Discord's guild-wide cap of 250 roles, which the
-- admin panel surfaces rather than letting a create fail into `state='error'`.
-- `role_ref` is therefore NOT the escalation artefact it was in §7.3 — it is the
-- grant itself, and a row with a channel and no role is a broken row.
--
-- **Two external refs, two lifetimes, and the pair is why this is a table rather
-- than two columns on `teams`.** A channel can be deleted in Discord while the
-- role survives, and vice versa; the reconciler has to be able to say "the role is
-- there, the channel is not" and repair one without touching the other.
--
-- `state` is core's belief about Discord, never Discord's own answer: the
-- reconciler writes what it just did, and the next pass re-derives the truth. A
-- Team dropping below the threshold goes to 'pending_removal' with `remove_after`
-- set rather than being deleted at once (§7.3's grace window) — a Team hovering
-- around the threshold would otherwise delete-and-recreate, changing the channel
-- id and breaking every pinned link to it, and a voice channel holds no message
-- history, so the window costs nothing to keep.
CREATE TABLE IF NOT EXISTS team_integrations (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
platform VARCHAR(32) NOT NULL, -- 'discord'; opaque here, a registry key in phase 10
resource VARCHAR(32) NOT NULL, -- 'voice'
external_ref VARCHAR(64) NULL, -- the channel id
role_ref VARCHAR(64) NULL, -- the Team's own role; the grant itself, not an escalation
state ENUM('none','active','pending_removal','error') NOT NULL DEFAULT 'none',
remove_after DATETIME NULL, -- set with 'pending_removal'; the grace window's expiry
last_error VARCHAR(500) NULL,
synced_at DATETIME NULL, -- last pass that reached Discord and was believed
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_team_integration (team_id, platform, resource),
-- Expiry is swept across every Team, so the index is on the pair the sweep
-- filters by rather than on the Team the unique key already covers.
INDEX idx_ti_pending (state, remove_after),
CONSTRAINT fk_ti_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here.
@@ -1369,3 +1550,489 @@ ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME
-- trust token. A boolean only — the token is returned over that app→server call
-- and never persisted here (only its sha256 lands in trusted_devices).
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;
-- ── Engagement Phase 1: Gmail OAuth2 removed, SMTP is the baseline ──────────
-- (ENGAGEMENT.md §1.2a). Additive on an upgraded database: `transport` backfills
-- to 'smtp' for every existing row, and `credential_enc` starts NULL — so an
-- upgraded deployment is deliberately CREDENTIAL-LESS until its operator supplies
-- SMTP settings. That is the whole point of the G22 warning below: nothing about
-- this fails loudly, so something has to say it out loud.
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS transport VARCHAR(32) NOT NULL DEFAULT 'smtp';
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS credential_enc TEXT NULL;
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS reply_to VARCHAR(255) NULL;
-- ── Engagement Phase 1b: one account per mailbox ───────────────────────────
-- (ENGAGEMENT.md Phase 1b / §0.6.) ORDER IS LOAD-BEARING and every statement here
-- is idempotent — after the first successful boot each one matches zero rows.
--
-- Why the generated column is added BEFORE the de-duplication rather than after:
-- the de-dupe must group addresses exactly the way the index will, and it cannot
-- do that with LOWER(email) = LOWER(email) in SQL, because that comparison uses
-- the COLUMN's collation, which is accent-insensitive. Grouping on email_norm —
-- the very column the UNIQUE index goes on — makes the two agree by construction
-- instead of by a hand-matched COLLATE clause someone can get wrong later.
-- (Tested: with the LOWER()=LOWER() form, jose@x.com was nulled as a "duplicate"
-- of josé@x.com. They are different mailboxes.)
-- 1. An empty string is a value, not an absence, so two accounts holding '' would
-- collide under the index and stop the boot. Unreachable through the current
-- routes (isEmail() rejects ''), but this runs against databases whose history
-- we do not control.
UPDATE users SET email = NULL WHERE email = '';
-- 2. The pending-address column and the uniqueness key. No index yet — a UNIQUE
-- index here, before step 3, is precisely the ALTER that fails and takes the
-- site down with it (§0.6 finding 1).
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_pending VARCHAR(255) NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_norm VARCHAR(255) COLLATE utf8mb4_bin AS (LOWER(email)) STORED;
-- 3. Record every account about to lose its address, BEFORE nulling it — the
-- report is the only place the lost value survives. Oldest-wins (§7.1 Q1):
-- the earliest-created account keeps the address, ties broken by id so the
-- outcome is deterministic. Verified status deliberately does NOT arbitrate —
-- SSO set email_verified from the mere presence of an address, so it is too
-- weak a signal to decide who keeps a mailbox (§0.6 finding 3).
INSERT IGNORE INTO email_dedupe_report (user_id, username, lost_address)
SELECT l.id, l.username, l.email FROM (
SELECT u.id, u.username, u.email FROM users u
WHERE u.email_norm IS NOT NULL
AND u.id <> (SELECT u2.id FROM users u2
WHERE u2.email_norm = u.email_norm
ORDER BY u2.created_at ASC, u2.id ASC LIMIT 1)
) AS l;
-- 4. Clear the losers. NEVER deletes a row: multiple NULLs are legal under a
-- UNIQUE index, so every account survives with its login intact and simply has
-- no contact address until its owner sets one. The extra derived table is not
-- decoration — MariaDB refuses a subquery on the table being updated (error
-- 1093) without it.
UPDATE users SET email = NULL, email_verified = 0
WHERE id IN (SELECT id FROM (
SELECT u.id FROM users u
WHERE u.email_norm IS NOT NULL
AND u.id <> (SELECT u2.id FROM users u2
WHERE u2.email_norm = u.email_norm
ORDER BY u2.created_at ASC, u2.id ASC LIMIT 1)
) AS losers);
-- 5. Now the table can hold it.
ALTER TABLE users ADD UNIQUE INDEX IF NOT EXISTS uq_users_email_norm (email_norm);
-- 6. The verification gate: may an UNVERIFIED address receive opt-in engagement
-- mail? ON for a fresh install, OFF for an upgrade — the asymmetry is the G22
-- lesson, not an oversight. Turning it on retroactively would silently stop
-- mailing every existing opted-in user on upgrade day, which is exactly the
-- kind of quiet breakage Phase 1 had to write a dashboard warning to undo.
-- "Fresh" is read off the users table: a database with no users has no one to
-- surprise. Both statements are INSERT IGNORE, so an operator who has since
-- changed the value keeps theirs.
INSERT IGNORE INTO settings (`key`, value)
SELECT 'email_verification_required', 'on' FROM DUAL WHERE (SELECT COUNT(*) FROM users) = 0;
INSERT IGNORE INTO settings (`key`, value) VALUES ('email_verification_required', 'off');
-- The status a Gmail-connected deployment carries is 'connected', and after the
-- upgrade that is a lie: nothing can send. Correct it once, narrowly. The WHERE
-- makes this idempotent and self-limiting — it matches only a row that still holds
-- a Gmail refresh token AND has no replacement credential, so re-running it after
-- the operator configures SMTP touches nothing, and it can never overwrite a real
-- status recorded by a later send.
UPDATE email_config
SET status = 'unconfigured',
status_detail = 'Gmail OAuth2 was removed. Configure SMTP credentials in Admin - Settings - Email.'
WHERE refresh_token_enc IS NOT NULL
AND credential_enc IS NULL
AND status <> 'unconfigured';
-- ── Per-channel notification preferences (ENGAGEMENT.md §4.5, Phase 3) ──────
--
-- G8: `notification_subscriptions` above has no channel dimension. It answers
-- "which streams does this user want pushed", and the shipped Android client's
-- wire shape (`{ streams: [...] }`) is frozen around exactly that question. This
-- table answers the general one — which streams AND triggers, on which channel,
-- in which mode — and the old table becomes its push projection: every write to
-- one fans out to the other (`notificationChannelPrefs.model`).
--
-- `stream_id` names a stream OR a trigger id, ONE namespace (§7.2, settled in
-- Phase 2). That decision is what keeps this primary key single-keyed: under two
-- namespaces it would have needed a `kind` discriminator, and `news.post` would
-- have meant two different rows forever.
--
-- **A row exists only where a user has expressed something.** Absence is not
-- "off" — it is "the channel's `defaultMode`", which lives in
-- `src/engagement/channels.js` and nowhere else (§3.1, G9: per-channel defaults
-- differ). All three of core's channels default 'off' today, so absence and off
-- coincide; that is a fact about the current declarations, not about this table,
-- and code must not assume it. The column DEFAULT below is the value a write with
-- no mode takes, not the value a missing row means.
CREATE TABLE IF NOT EXISTS notification_channel_prefs (
user_id INT NOT NULL,
stream_id VARCHAR(64) NOT NULL,
channel VARCHAR(32) NOT NULL,
mode ENUM('off','instant','digest') NOT NULL DEFAULT 'off',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, stream_id, channel),
CONSTRAINT fk_ncp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_ncp_channel (channel, mode)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Carry the existing push subscriptions across, once. Same shape as the
-- announce_jobs -> announce_job_legs backfill above: an INSERT IGNORE ... SELECT,
-- so replaying this file on every boot is a no-op after the first, and a user who
-- has since turned a stream OFF is not resurrected by the next boot (their row
-- exists with mode 'off', and INSERT IGNORE leaves it alone).
--
-- 'instant' rather than the column default, because a row in
-- notification_subscriptions IS an opt-in: the user asked to be pushed, and push
-- has no digest mode to be asked into instead.
INSERT IGNORE INTO notification_channel_prefs (user_id, stream_id, channel, mode)
SELECT user_id, stream_id, 'push', 'instant' FROM notification_subscriptions;
-- ── The engagement engine (ENGAGEMENT.md §4.1, §4.2a, §4.5 — Phase 4a) ──────
--
-- Five tables and no delivery. A rule says "when this trigger fires, for these
-- people, on these channels, no more often than this"; the outbox is the queue
-- the grace window needs; the cooldown table is what makes "once per house" mean
-- once per house; and the send log is the first answer this deployment has ever
-- had to "did user X get the mail?".
--
-- Nothing here sends anything. Core seeds no rules and `enabled` defaults to 0,
-- so on a real deployment these five tables stay empty until an operator turns a
-- rule on from the screen Phase 4b builds.
-- What an operator actually configures: trigger -> audience -> template -> timing.
--
-- `trigger_id` deliberately has NO foreign key and no existence check: a trigger
-- is DECLARED IN CODE (§4.3), so the set of them is whatever registered on this
-- boot. A rule naming a trigger no module currently registers is DORMANT — it is
-- listed, it never fires, and it starts working again when the module comes back
-- (§7.3). Deleting it on uninstall would silently destroy an operator's
-- configuration on the strength of a module being temporarily absent.
CREATE TABLE IF NOT EXISTS engagement_rules (
id INT AUTO_INCREMENT PRIMARY KEY,
trigger_id VARCHAR(96) NOT NULL,
name VARCHAR(160) NOT NULL,
-- OFF by default (§7.1 Q3). A rule arrives inert and an operator turns it on,
-- so no import, seed or restore can start mailing on its own.
enabled TINYINT(1) NOT NULL DEFAULT 0,
audience VARCHAR(32) NOT NULL DEFAULT 'owner',
audience_segment_id INT NULL,
-- §7.1 Q3: the hard stop that makes operator-editable rules safe to choose over
-- code-registered ones. Counted in engagement_sends, enforced before the outbox
-- row is written, never overridable from the rule editor beyond this column.
max_sends_per_hour INT NOT NULL DEFAULT 100,
channels JSON NOT NULL,
template_keys JSON NOT NULL,
conditions JSON NULL,
cooldown_seconds INT NOT NULL DEFAULT 0,
delay_seconds INT NOT NULL DEFAULT 0,
cancel_on JSON NULL,
updated_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_engr_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_engr_trigger (trigger_id, enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- §5.1a: an operator-composed segment over module-declared audiences. Stored as a
-- boolean tree of audience ids + params; `ceiling` is DERIVED at save time as the
-- NARROWEST ceiling in the tree (ceilings.meetAll) and re-checked against the
-- trigger's own ceiling, so composition can never widen. It is a column rather
-- than a runtime computation so an audit can read what a rule was allowed to
-- reach without re-resolving it — and so a module that has since changed its
-- audience's ceiling cannot retroactively widen a saved segment.
--
-- `engagement_rules.audience_segment_id` above points here with NO foreign key,
-- on purpose and for the same reason `trigger_id` has none: a rule whose segment
-- has been deleted must go DORMANT, not silently fall back to its plain
-- `audience` column. ON DELETE SET NULL would be exactly that silent fallback,
-- and the fallback reaches a DIFFERENT set of people (§5.1a rule 4).
CREATE TABLE IF NOT EXISTS engagement_audience_segments (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(160) NOT NULL,
expression JSON NOT NULL,
ceiling VARCHAR(32) NOT NULL,
updated_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_engseg_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- §4.1. NOT `settings`: cooldown state is high-cardinality (recipients x rules x
-- subjects), written on every fire, and asked "is this one pair still cooling?".
-- A JSON blob under one settings key would be a read-modify-write of the whole
-- deployment's cooldown state per event, with a lost-update race between two
-- concurrent triggers.
--
-- `subject_key` is what makes "one IDOC mail per player per day" the right rule
-- instead of the wrong one: a player with four houses decaying should hear about
-- all four, once each. Cooling per (rule, user) alone silently drops three.
CREATE TABLE IF NOT EXISTS engagement_cooldowns (
rule_id INT NOT NULL,
user_id INT NOT NULL,
-- The SUBJECT the cooldown is about, opaque to core: a house serial, a vendor
-- id, ''. NOT NULL with a '' default, because this is a PRIMARY KEY column and
-- MariaDB would coerce a NULL one anyway. '' is "this rule cools per user, not
-- per subject".
subject_key VARCHAR(190) NOT NULL DEFAULT '',
last_fired_at DATETIME NOT NULL,
fire_count INT NOT NULL DEFAULT 1,
PRIMARY KEY (rule_id, user_id, subject_key),
CONSTRAINT fk_engc_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE,
CONSTRAINT fk_engc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
-- So a prune worker can drop rows older than the longest configured cooldown.
-- Without it this table grows without bound, which is the failure mode
-- teamActivityPrune was written for.
INDEX idx_engc_sweep (last_fired_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- §4.2a. Modelled on announce_jobs / announce_job_legs. One row per
-- (rule, user, channel) occurrence of an event.
CREATE TABLE IF NOT EXISTS engagement_outbox (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
rule_id INT NOT NULL,
trigger_id VARCHAR(96) NOT NULL, -- denormalized; survives a rule edit
user_id INT NOT NULL,
channel VARCHAR(32) NOT NULL, -- VARCHAR, never ENUM: the channel set is data
subject_key VARCHAR(190) NOT NULL DEFAULT '',
payload JSON NOT NULL, -- the declared variables, snapshotted at emit
dedupe_key VARCHAR(190) NULL,
status ENUM('scheduled','sending','sent','failed','cancelled','suppressed') NOT NULL DEFAULT 'scheduled',
due_at DATETIME NOT NULL,
attempts SMALLINT NOT NULL DEFAULT 0,
last_error TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
sent_at DATETIME NULL,
CONSTRAINT fk_engo_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE,
CONSTRAINT fk_engo_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
-- **Scoped to the row's identity, and §4.2a's global `UNIQUE (dedupe_key)` is
-- a defect this phase found while building it.** A dedupe key names the EVENT
-- ("house 0x4001 entered IDOC"), and one event legitimately becomes many rows:
-- an audience of fifty users is fifty rows, a rule spanning email and in-app
-- doubles that, and two rules on one trigger double it again. Under a global
-- unique index the FIRST of those inserts wins and every other one is silently
-- ignored — ninety-nine recipients dropped by the mechanism meant to stop a
-- replayed event becoming a second mail. Scoping it to (rule, user, channel)
-- keeps exactly that guarantee and nothing more.
UNIQUE KEY uq_engo_dedupe (rule_id, user_id, channel, dedupe_key),
INDEX idx_engo_due (status, due_at),
-- What a RESOLVING event queries: a house repaired back to LikeNew cancels
-- every scheduled row for that (rule, user, house).
INDEX idx_engo_cancel (rule_id, user_id, subject_key, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- G15: the per-message record. Today "did user X get the mail?" is unanswerable.
--
-- It is deliberately NOT a second address book: the address is stored as a
-- sha256, which is enough to correlate a bounce (Phase 9) and useless as a
-- mailing list. `user_id` is SET NULL rather than CASCADE so the log survives an
-- account deletion — an audit of what this deployment sent must not be erasable
-- by deleting the recipient.
CREATE TABLE IF NOT EXISTS engagement_sends (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
outbox_id BIGINT NULL,
rule_id INT NULL,
trigger_id VARCHAR(96) NOT NULL,
user_id INT NULL,
channel VARCHAR(32) NOT NULL,
transport VARCHAR(32) NULL,
address_hash CHAR(64) NULL,
status ENUM('sent','failed','suppressed','bounced','complained') NOT NULL,
detail VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_engs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_engs_trigger (trigger_id, created_at),
INDEX idx_engs_user (user_id, created_at),
-- The per-rule hourly ceiling (§7.1 Q3) is counted here, so the count has to be
-- an index range scan rather than a table scan: it runs once per rule per event.
INDEX idx_engs_rule_window (rule_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- §4.4. The mail (and, from Phase 7, in-app) bodies an operator can edit, stored
-- as a validated block array rather than as raw HTML: `blocks` goes through the
-- same validate-then-sanitize gate the CMS pages do, against the `email.*`
-- registry (src/emailBlocks/). Storing operator HTML would hand the renderer an
-- injection surface and give up the prop schemas.
--
-- Three columns carry the whole "ship a better default without stealing an
-- operator's work" mechanism (§4.6.1 property 3). `seed_key` says which shipped
-- template a row came from, `seed_version` which revision of it, and `customized`
-- whether a person has since edited it. The seeder updates a row whose version is
-- behind ONLY while `customized = 0`; a customized row is left exactly as it is
-- and the newer default is surfaced in the admin list instead. Same posture
-- `settingsJson` takes: never overwrite what someone chose.
--
-- `trigger_id` has no foreign key for the reason `engagement_rules.trigger_id`
-- has none -- a trigger is declared in code, so the set of them is whatever
-- registered on this boot. NULL means a reusable template not tied to one
-- trigger, which is what every transactional seed is: `mailer` renders them by
-- key, no rule involved.
CREATE TABLE IF NOT EXISTS engagement_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
`key` VARCHAR(96) NOT NULL UNIQUE,
name VARCHAR(160) NOT NULL,
trigger_id VARCHAR(96) NULL,
trigger_version INT NULL,
channel VARCHAR(32) NOT NULL,
subject VARCHAR(300) NULL,
blocks MEDIUMTEXT NOT NULL,
text_body MEDIUMTEXT NULL,
status ENUM('draft','published') NOT NULL DEFAULT 'draft',
-- Editable, NOT deletable -- the pages.protected flag, for the same reason:
-- the system breaks without a password-reset body.
protected TINYINT(1) NOT NULL DEFAULT 0,
seed_key VARCHAR(96) NULL,
seed_version INT NULL,
customized TINYINT(1) NOT NULL DEFAULT 0,
updated_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_engt_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_engt_trigger (trigger_id, channel, status),
INDEX idx_engt_seed (seed_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── The email channel on the engine (ENGAGEMENT.md §4.2b — Phase 6) ─────────
-- The scope an event is ABOUT, opaque to core and distinct from `subject_key`.
--
-- They are two different things and Phase 6 is where that stopped being
-- theoretical. `subject_key` is what a COOLDOWN is keyed on and comes from the
-- trigger's declared `subjectKey` — for the four Team triggers that is `teamName`,
-- a display string. `scope_key` is what a PREFERENCE and an UNSUBSCRIBE are keyed
-- on, and it has to be a stable identifier: `team:12` survives a rename, and a
-- Team renamed between the mail and the click must not orphan the unsubscribe
-- link in it. Same vocabulary as engagement_digest_state.scope_key below.
ALTER TABLE engagement_outbox ADD COLUMN IF NOT EXISTS scope_key VARCHAR(190) NULL;
-- §4.2b: digest state, and DELIBERATELY not a digest queue.
--
-- The generic engine enqueues an outbox row per (rule, user, channel) at emit
-- time, carrying a snapshot of the payload. That is right for an instant send and
-- wrong for a digest, and `teamDigestWorker`'s header says why in three
-- properties: a deployment down for two days sends ONE digest rather than two
-- days of replay; a post a moderator hid after it was written is not in the
-- query so it is not in the mail; and a user who lost forum access between the
-- post and the send is no longer in the recipient set. The third is a security
-- property, and all three are properties of RE-DERIVING the content at send time.
-- A snapshot taken at emit time has none of them.
--
-- So a digest-mode recipient gets NO outbox row (see engine.js), and what
-- generalizes is this: the state the worker keeps, lifted out of
-- team_notification_prefs.last_digest_at so that a second digest — on another
-- channel, or over another scope — needs no second column on somebody's
-- preferences table.
CREATE TABLE IF NOT EXISTS engagement_digest_state (
user_id INT NOT NULL,
channel VARCHAR(32) NOT NULL,
-- '' is deployment-wide; 'team:12' is one Team. NOT NULL with a '' default
-- because this is a PRIMARY KEY column and MariaDB coerces a nullable one
-- anyway — the same workaround team_integration_config and teams.active_key
-- both carry, and the trap Part 4's preamble flags.
scope_key VARCHAR(190) NOT NULL DEFAULT '',
last_digest_at DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, channel, scope_key),
CONSTRAINT fk_engd_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
-- The worker's driving question is "whose email digest is due?", which is a
-- range scan of this index rather than of every digest ever sent.
INDEX idx_engd_due (channel, last_digest_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Carry the Team digest windows across, once. Replay-safe by construction: an
-- INSERT IGNORE against the primary key, so the second and every later boot
-- writes nothing, and a window the new worker has since MOVED is not dragged
-- backwards by the next restart.
--
-- Rows with a NULL last_digest_at are copied too, and that is deliberate rather
-- than incidental: `clampSince` treats a missing row and a NULL stamp the same
-- way (reach back one interval, not to the floor), so the copy is faithful — and
-- copying only the stamped rows would make the backfill's own idempotence depend
-- on which rows happened to have fired.
INSERT IGNORE INTO engagement_digest_state (user_id, channel, scope_key, last_digest_at)
SELECT user_id, 'email', CONCAT('team:', team_id), last_digest_at
FROM team_notification_prefs;
-- ── The in-app channel (ENGAGEMENT.md §4.5 G17 — Phase 7) ──────────────────
-- The inbox. Core, game-agnostic, and the first sink core owns that CARRIES its
-- content: a push tickle deliberately holds none and an email leaves the
-- building, so this is the one place a message both belongs to this deployment
-- and can be read without a mailbox.
--
-- `dedupe_key` is the acceptance criterion, expressed as an index rather than as
-- a check the writer has to remember: a replayed event, a retried outbox row and
-- a module calling `ctx.inbox.push` twice all reduce to the same INSERT IGNORE.
-- It is scoped to the USER (not to the rule and channel the outbox scopes by),
-- because one event may legitimately be two outbox rows for one person — a rule
-- spanning channels — and two inbox rows for it is one item shown twice.
-- Multiple NULLs are permitted by a UNIQUE index, which is what "this item does
-- not dedupe" means.
--
-- `url` is stored RELATIVE only, validated with the character class
-- `pageUrlTemplate` and the engine's `url` variables already use: it ends up in
-- an href on a page a signed-in user is looking at, and `//evil.test/x` passes
-- every "is it rooted" check anyone writes by hand.
CREATE TABLE IF NOT EXISTS user_notifications (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
trigger_id VARCHAR(96) NOT NULL,
title VARCHAR(300) NOT NULL,
body TEXT NULL, -- rendered by the inapp template, sanitized on write
url VARCHAR(500) NULL, -- relative only, validated like pageUrlTemplate
dedupe_key VARCHAR(190) NULL,
read_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_un_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uq_un_dedupe (user_id, dedupe_key),
-- Both of the two questions this table is asked: "what is in my inbox" (the
-- list, newest first) and "how many are unread" (the badge, on every page
-- load). A single index answers both because `read_at` is IS NULL in one and
-- unconstrained in the other, and `created_at` orders what is left.
INDEX idx_un_unread (user_id, read_at, created_at),
-- What the prune sweep queries. Without it the sweep is a table scan of every
-- notification this deployment has ever written.
INDEX idx_un_prune (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Deliverability: suppression and bounces (ENGAGEMENT.md §4.5 G16 — Phase 9) ──
-- The addresses this deployment has stopped mailing, and why.
--
-- **Keyed on the ADDRESS, not the user** (§4.5), and after Phase 1b that is a
-- deliberate choice rather than a workaround for a missing unique index. Two
-- accounts can no longer share an address, but a bounce arrives as an ADDRESS —
-- it does not know which account was behind it, and it stays true after the
-- account that held it changed its address or was deleted. Keying on the user
-- would forget a dead mailbox the moment anybody moved.
--
-- `address_masked` is Phase 9's one addition to §4.5's DDL, and it exists because
-- the hash-only table cannot be operated. An operator looking at a screen of
-- sha256 digests cannot tell whether the list is three typos or a whole domain
-- refusing mail, and un-suppressing somebody who fixed their mailbox is the one
-- action this table has to support. `d***@example.com` is enough to act on and to
-- see a domain-wide pattern in, and — the reason it is safe — the local part is
-- destroyed rather than shortened, so the column is not an address book and
-- cannot be turned back into one. It is NULLable because a row written from a
-- correlation that only ever held a hash has nothing to mask.
--
-- **`reason` is not a synonym for "the send failed".** `mailer.PERMANENT_CODES`
-- classifies a failure as not-worth-retrying, and that set contains EAUTH and 554
-- — an authentication failure and a relay-wide policy refusal, neither of which
-- is a fact about the recipient. Writing a suppression on every terminal failure
-- would mean one wrong SMTP password suppresses every address the worker touches
-- before anyone notices. Only recipient-scoped evidence reaches this table; see
-- `src/engagement/bounceClassify.js`.
CREATE TABLE IF NOT EXISTS engagement_suppressions (
address_hash CHAR(64) NOT NULL PRIMARY KEY, -- sha256 of the lowercased address
address_masked VARCHAR(190) NULL, -- d***@example.com; never the local part
channel VARCHAR(32) NOT NULL DEFAULT 'email',
reason ENUM('bounce','complaint','manual','unverified') NOT NULL,
detail VARCHAR(500) NULL,
created_by INT NULL, -- the admin, for a manual row; NULL for automatic
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_engsup_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
-- The screen's two orderings: newest first, and filtered by reason.
INDEX idx_engsup_created (created_at),
INDEX idx_engsup_reason (reason, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -4,6 +4,8 @@ const settingsDb = require('../src/model/settings/settings.db')
const wikiDb = require('../src/model/wiki/wiki.db')
const users = require('../src/model/users/users.model')
const { ensureSchema, close } = require('../src/utils/db')
const { seedTemplates } = require('../src/engagement/templates')
const { seedCoreRules } = require('../src/engagement/coreRules')
const brand = require('../src/config/brand')
const log = require('../src/utils/logger')('seed')
@@ -74,6 +76,19 @@ async function seedDefaults() {
// migration of pages seeded before the wiki upgrade).
await wikiDb.assignCategoryBySlug(slug, categorySlug)
}
// The shipped mail bodies (ENGAGEMENT.md §4.6.1). Idempotent, and it never
// overwrites a row an operator has edited — `customized = 1` is checked in the
// UPDATE's own WHERE, not in a read-then-write. Never throws: a template that
// failed to seed costs the shipped default, which `renderByKey` falls back to
// anyway, and must not stop a boot.
await seedTemplates()
// Core's five rules — the four Team ones (Phase 6) and news (Phase 11) —
// seeded ONCE and all disabled. Each GROUP carries its own settings-key guard
// rather than re-ensured, so a rule an operator deleted stays deleted and one
// they enabled stays enabled; and so the news rule reaches the deployments that
// were already stamped for Teams, which are exactly the ones that lose their
// raw news push to the engine (ENGAGEMENT.md §7.1 Q9).
await seedCoreRules()
log.info('settings and wiki defaults ensured')
}

View File

@@ -0,0 +1,211 @@
{
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
"moduleApiVersion": "1.8.0",
"triggers": [
{
"id": "news.post",
"owner": "core",
"label": "News post published",
"description": "A news / Five-on-Friday / newsletter post was published.",
"kind": "event",
"subjectKey": null,
"audience": "subscribers",
"ceiling": "authenticated",
"version": 1,
"variables": [
{
"name": "title",
"type": "string",
"required": true,
"example": "Five on Friday — the Yew invasion",
"description": "The post title."
},
{
"name": "excerpt",
"type": "string",
"required": false,
"example": "Four new champion spawns, and the fate of the Yew moongate…",
"description": "A plain-text summary, already stripped of markup."
},
{
"name": "category",
"type": "string",
"required": false,
"example": "Five on Friday",
"description": "The post category, when it has one."
},
{
"name": "postUrl",
"type": "url",
"required": true,
"example": "/site/news",
"description": "Site-relative path to the post. The news list today — the site has no per-post route."
}
]
},
{
"id": "team.announcement",
"owner": "core",
"label": "Team — announcement",
"description": "A leader posted an announcement in a Team.",
"kind": "event",
"subjectKey": "teamName",
"audience": "members",
"ceiling": "members",
"version": 1,
"variables": [
{
"name": "teamName",
"type": "string",
"required": true,
"example": "The Silver Anvil",
"description": "The Team the event is about. Also the cooldown subject."
},
{
"name": "authorName",
"type": "string",
"required": true,
"example": "Marisol",
"description": "Display name of the leader who posted."
},
{
"name": "title",
"type": "string",
"required": true,
"example": "Siege practice moved to Sunday",
"description": "The announcement title."
},
{
"name": "excerpt",
"type": "string",
"required": false,
"example": "We are moving practice to Sunday 8pm…",
"description": "Plain-text excerpt of the announcement body."
},
{
"name": "postUrl",
"type": "url",
"required": false,
"example": "/guilds/the-silver-anvil/forum/419",
"description": "Site-relative path to the announcement."
}
]
},
{
"id": "team.forum.post",
"owner": "core",
"label": "Team — new forum post",
"description": "A new thread or reply in a Team forum.",
"kind": "event",
"subjectKey": "teamName",
"audience": "members",
"ceiling": "members",
"version": 1,
"variables": [
{
"name": "teamName",
"type": "string",
"required": true,
"example": "The Silver Anvil",
"description": "The Team the event is about. Also the cooldown subject."
},
{
"name": "authorName",
"type": "string",
"required": true,
"example": "Darrow",
"description": "Display name of the poster."
},
{
"name": "threadTitle",
"type": "string",
"required": true,
"example": "Tuesday champ rotation",
"description": "Title of the thread the post belongs to."
},
{
"name": "excerpt",
"type": "string",
"required": false,
"example": "Moving the Tuesday run an hour later…",
"description": "Plain-text excerpt of the post body, already stripped of markup."
},
{
"name": "postUrl",
"type": "url",
"required": false,
"example": "/guilds/the-silver-anvil/forum/412",
"description": "Site-relative path to the post."
}
]
},
{
"id": "team.leadership.changed",
"owner": "core",
"label": "Team — leadership change",
"description": "Leadership changed in a Team.",
"kind": "event",
"subjectKey": "teamName",
"audience": "members",
"ceiling": "members",
"version": 1,
"variables": [
{
"name": "teamName",
"type": "string",
"required": true,
"example": "The Silver Anvil",
"description": "The Team the event is about. Also the cooldown subject."
},
{
"name": "leaderName",
"type": "string",
"required": true,
"example": "Marisol",
"description": "Display name of the new leader."
},
{
"name": "teamUrl",
"type": "url",
"required": false,
"example": "/guilds/the-silver-anvil",
"description": "Site-relative path to the Team page."
}
]
},
{
"id": "team.member.joined",
"owner": "core",
"label": "Team — new member",
"description": "Someone joined a Team.",
"kind": "event",
"subjectKey": "teamName",
"audience": "members",
"ceiling": "members",
"version": 1,
"variables": [
{
"name": "teamName",
"type": "string",
"required": true,
"example": "The Silver Anvil",
"description": "The Team the event is about. Also the cooldown subject."
},
{
"name": "memberName",
"type": "string",
"required": true,
"example": "Darrow",
"description": "Display name of the member who joined."
},
{
"name": "teamUrl",
"type": "url",
"required": false,
"example": "/guilds/the-silver-anvil",
"description": "Site-relative path to the Team page. Absent when no module supplies a pageUrlTemplate."
}
]
}
]
}

View File

@@ -9,6 +9,7 @@
"seed": "node db/seed.js",
"swagger": "node swagger/swagger.js",
"routes:manifest": "node scripts/routeManifest.js",
"engagement:manifest": "node scripts/engagementManifest.js",
"test": "node --test --require ./test/_setup.js"
},
"keywords": [

View File

@@ -27,66 +27,6 @@
"handlers": 1,
"gates": []
},
{
"method": "GET",
"path": "/api/v1/admin/account",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/account/identities",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/account/identities/:provider",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/disable",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/enable",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/setup",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/activity",
@@ -199,7 +139,7 @@
{
"method": "PUT",
"path": "/api/v1/admin/email/config",
"handlers": 5,
"handlers": 9,
"gates": [
"noindex",
"requireAuth",
@@ -207,24 +147,6 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/email/connect/callback",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/email/connect/start",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/email/disconnect",
@@ -245,6 +167,231 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/audience-preview",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/audiences",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/channels",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/rules",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/rules",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/rules/:id",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/rules/:id",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/engagement/rules/:id",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PATCH",
"path": "/api/v1/admin/engagement/rules/:id/enabled",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/segments",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/segments",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/segments/:id",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/engagement/segments/:id",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/sends",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/suppressions",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/suppressions",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/suppressions",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/templates",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/templates/:id",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/templates/:id",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/engagement/templates/:id",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/templates/:id/duplicate",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/templates/:id/preview",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/templates/:id/test-send",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/triggers",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/invites",
@@ -880,6 +1027,37 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/integrations",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/teams/integrations",
"handlers": 8,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/integrations/:teamId",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/requests",
@@ -920,6 +1098,46 @@
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/voice",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/teams/voice",
"handlers": 7,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/voice/:teamId",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/voice/sync",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/uploads",
@@ -1027,6 +1245,24 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/users/email-dedupe-report",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/users/email-dedupe-report/acknowledge",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/wiki",
@@ -1169,6 +1405,24 @@
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/auth/email/verify/:token",
"handlers": 3,
"gates": [
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/auth/email/verify/:token",
"handlers": 4,
"gates": [
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/invite/:token",
@@ -1233,6 +1487,35 @@
"requireAuth"
]
},
{
"method": "PATCH",
"path": "/api/v1/auth/me/account/email",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/account/email/pending",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/auth/me/account/email/resend",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/account/identities",
@@ -1358,6 +1641,57 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/auth/me/notifications/:id/read",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/channels",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/auth/me/notifications/channels",
"handlers": 6,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/auth/me/notifications/read-all",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/streams",
@@ -1407,6 +1741,15 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/unread-count",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/sessions",
@@ -1587,88 +1930,6 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/player/account",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/player/account/identities",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/player/account/identities/:provider",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "PATCH",
"path": "/api/v1/player/account/password",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/disable",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/enable",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/setup",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PATCH",
"path": "/api/v1/player/account/username",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/player/appeals",
@@ -1874,6 +2135,18 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/public/engagement/unsubscribe/:token",
"handlers": 1,
"gates": []
},
{
"method": "POST",
"path": "/api/v1/public/engagement/unsubscribe/:token",
"handlers": 1,
"gates": []
},
{
"method": "GET",
"path": "/api/v1/public/modules",

View File

@@ -17,30 +17,6 @@
"method": "GET",
"path": "/api/health"
},
{
"method": "GET",
"path": "/api/v1/admin/account"
},
{
"method": "GET",
"path": "/api/v1/admin/account/identities"
},
{
"method": "DELETE",
"path": "/api/v1/admin/account/identities/:provider"
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/disable"
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/enable"
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/setup"
},
{
"method": "GET",
"path": "/api/v1/admin/activity"
@@ -89,14 +65,6 @@
"method": "PUT",
"path": "/api/v1/admin/email/config"
},
{
"method": "GET",
"path": "/api/v1/admin/email/connect/callback"
},
{
"method": "GET",
"path": "/api/v1/admin/email/connect/start"
},
{
"method": "POST",
"path": "/api/v1/admin/email/disconnect"
@@ -105,6 +73,106 @@
"method": "POST",
"path": "/api/v1/admin/email/test"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/audience-preview"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/audiences"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/channels"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/rules"
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/rules"
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/rules/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/rules/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/engagement/rules/:id"
},
{
"method": "PATCH",
"path": "/api/v1/admin/engagement/rules/:id/enabled"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/segments"
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/segments"
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/segments/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/engagement/segments/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/sends"
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/suppressions"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/suppressions"
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/suppressions"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/templates"
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/templates/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/templates/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/engagement/templates/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/templates/:id/duplicate"
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/templates/:id/preview"
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/templates/:id/test-send"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/triggers"
},
{
"method": "GET",
"path": "/api/v1/admin/invites"
@@ -349,6 +417,18 @@
"method": "GET",
"path": "/api/v1/admin/teams/forum/uploads"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/integrations"
},
{
"method": "PUT",
"path": "/api/v1/admin/teams/integrations"
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/integrations/:teamId"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/requests"
@@ -365,6 +445,22 @@
"method": "GET",
"path": "/api/v1/admin/teams/review"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/voice"
},
{
"method": "PUT",
"path": "/api/v1/admin/teams/voice"
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/voice/:teamId"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/voice/sync"
},
{
"method": "POST",
"path": "/api/v1/admin/uploads"
@@ -405,6 +501,14 @@
"method": "DELETE",
"path": "/api/v1/admin/users/:id/trusted-devices/:deviceId"
},
{
"method": "GET",
"path": "/api/v1/admin/users/email-dedupe-report"
},
{
"method": "POST",
"path": "/api/v1/admin/users/email-dedupe-report/acknowledge"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki"
@@ -461,6 +565,14 @@
"method": "GET",
"path": "/api/v1/admin/wiki/tags"
},
{
"method": "GET",
"path": "/api/v1/auth/email/verify/:token"
},
{
"method": "POST",
"path": "/api/v1/auth/email/verify/:token"
},
{
"method": "GET",
"path": "/api/v1/auth/invite/:token"
@@ -489,6 +601,18 @@
"method": "GET",
"path": "/api/v1/auth/me/account"
},
{
"method": "PATCH",
"path": "/api/v1/auth/me/account/email"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/account/email/pending"
},
{
"method": "POST",
"path": "/api/v1/auth/me/account/email/resend"
},
{
"method": "GET",
"path": "/api/v1/auth/me/account/identities"
@@ -537,6 +661,26 @@
"method": "DELETE",
"path": "/api/v1/auth/me/devices/:id"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications"
},
{
"method": "POST",
"path": "/api/v1/auth/me/notifications/:id/read"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/channels"
},
{
"method": "PUT",
"path": "/api/v1/auth/me/notifications/channels"
},
{
"method": "POST",
"path": "/api/v1/auth/me/notifications/read-all"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/streams"
@@ -557,6 +701,10 @@
"method": "PUT",
"path": "/api/v1/auth/me/notifications/teams"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/unread-count"
},
{
"method": "GET",
"path": "/api/v1/auth/me/sessions"
@@ -637,38 +785,6 @@
"method": "POST",
"path": "/api/v1/auth/sso/totp"
},
{
"method": "GET",
"path": "/api/v1/player/account"
},
{
"method": "GET",
"path": "/api/v1/player/account/identities"
},
{
"method": "DELETE",
"path": "/api/v1/player/account/identities/:provider"
},
{
"method": "PATCH",
"path": "/api/v1/player/account/password"
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/disable"
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/enable"
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/setup"
},
{
"method": "PATCH",
"path": "/api/v1/player/account/username"
},
{
"method": "GET",
"path": "/api/v1/player/appeals"
@@ -749,6 +865,14 @@
"method": "POST",
"path": "/api/v1/public/contact"
},
{
"method": "GET",
"path": "/api/v1/public/engagement/unsubscribe/:token"
},
{
"method": "POST",
"path": "/api/v1/public/engagement/unsubscribe/:token"
},
{
"method": "GET",
"path": "/api/v1/public/modules"

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* Engagement trigger manifest — the machine-readable freeze of core's event
* contract (ENGAGEMENT.md §4.3, property 4).
*
* Why this exists: a trigger declaration is what a template interpolates and what
* a rule is written against. Renaming a variable, changing its type, or widening
* a ceiling breaks stored templates and stored rules — and does it silently, at
* send time, in an email someone already received. `routes.manifest.json` freezes
* the URL surface for exactly this reason and this is its twin: a generated
* artifact committed to the repo, whose DIFF is the review signal. Changing a
* declaration without regenerating is a red build; changing one deliberately puts
* the change in front of a reviewer instead of letting it pass as a comment edit.
*
* **Core's only.** A module ships its own `engagement-triggers.json` in its
* bundle, for the same reason it ships a prebuilt swagger fragment: core never
* has its sources to analyse (MODULE_API.md §6.1a). So this loads
* `config/coreTriggers.js` through the real `registerCore()` — the declarations
* as VALIDATED, not as authored — which means a shape error is a failure here
* rather than a surprise at boot.
*
* The `resolve` half of an audience cannot be frozen (it is a function over a
* module's own store), so audiences are deliberately absent: what a manifest can
* usefully freeze is the payload contract, and freezing half a declaration would
* suggest the other half was checked.
*
* Usage:
* npm run engagement:manifest # write server/engagement-triggers.json
* npm run engagement:manifest -- --check # exit 1 if the committed file is stale
*/
// registries.js -> config/coreStreams + utils/discordAnnounce, which reach
// utils/db and build a mariadb pool at require time. Point it at a closed port
// (the same trick routeManifest.js and the test suite use) so generating a
// manifest never opens a connection or hangs on a missing database.
process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1'
process.env.DB_PORT = process.env.DB_PORT || '59999'
const fs = require('fs')
const path = require('path')
const registries = require('../src/modules/registries')
const db = require('../src/utils/db')
const { MODULE_API_VERSION } = require('../src/modules/version')
const SERVER_ROOT = path.join(__dirname, '..')
const MANIFEST_PATH = path.join(SERVER_ROOT, 'engagement-triggers.json')
const MANIFEST_COMMENT =
'Generated event-trigger inventory - the authoritative freeze of CORE\'s engagement ' +
'contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` ' +
'in website/server. A renamed variable, a changed type or a widened ceiling breaks stored ' +
'templates and rules, so the diff here is the review signal. A module ships its own copy ' +
'in its bundle; this file never contains one.'
function build() {
// Through registerCore(), not by reading the array: what a reviewer needs
// frozen is what the registry ACCEPTED — defaults filled in, audience resolved
// against the ceiling, variables normalised — because that is what the editor
// will read and the emit path will check against.
registries.registerCore()
const triggers = registries
.allTriggers()
.filter((t) => t.owner === 'core')
// Sorted by id rather than left in registration order, like the route
// manifest: reordering a declaration in the source is not a contract change
// and must not produce a diff that looks like one.
.sort((a, b) => a.id.localeCompare(b.id))
.map((t) => ({
id: t.id,
owner: t.owner,
label: t.label,
description: t.description,
kind: t.kind,
subjectKey: t.subjectKey,
audience: t.audience,
ceiling: t.ceiling,
version: t.version,
// Variables keep their DECLARED order. Here it is contract: it is the
// order the template editor lists them in, and an author reading the
// manifest should see what the editor will show.
variables: t.variables.map((v) => ({
name: v.name,
type: v.type,
required: v.required,
example: v.example,
description: v.description,
})),
}))
return {
_comment: MANIFEST_COMMENT,
// The contract version these declarations are shaped by. A reader looking at
// a stale manifest needs to know which API's rules produced it.
moduleApiVersion: MODULE_API_VERSION,
triggers,
}
}
function main() {
const check = process.argv.includes('--check')
const next = `${JSON.stringify(build(), null, 2)}\n`
if (!check) {
fs.writeFileSync(MANIFEST_PATH, next)
process.stdout.write(`wrote ${path.relative(SERVER_ROOT, MANIFEST_PATH)}\n`)
return
}
const current = fs.existsSync(MANIFEST_PATH) ? fs.readFileSync(MANIFEST_PATH, 'utf8') : ''
if (current === next) {
process.stdout.write('engagement-triggers.json is current\n')
return
}
process.stderr.write(
'engagement-triggers.json is stale.\n' +
'A trigger declaration changed without the manifest being regenerated.\n' +
'Run `npm run engagement:manifest` in website/server and commit the result —\n' +
'the diff is what a reviewer reads to see the contract change.\n',
)
process.exitCode = 1
}
if (require.main === module) {
main()
// The mariadb pool never connects here, but it keeps the loop alive even
// pointed at a dead port — the same exit routeManifest.js takes.
db.close().finally(() => process.exit(process.exitCode || 0))
}
module.exports = { build }

View File

@@ -192,6 +192,15 @@ app.use('/api', apiRouter)
// 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).
// The engagement subsystem's own door, which is what brings core's mail
// transports and its three delivery channels into existence (ENGAGEMENT.md
// §3.1). Requiring `engagement/channels` or `engagement/transports` directly gets
// the empty registry — populating it is deliberately a side effect of this one
// require, so there is exactly one place either can be registered from. It runs
// beside registerCore() and before the loader for the same reason: a preference
// read or a mail send must never find a half-populated registry.
require('./engagement')
registries.registerCore()
modules.load({
public: require('./router/v1/public'),

View File

@@ -37,7 +37,7 @@ class BaseProvider {
}
// Complete an SSO redirect flow: exchange the callback code for a normalized
// user profile ({ subject, email, name }).
// user profile ({ subject, email, emailVerified, name }).
// eslint-disable-next-line no-unused-vars
async handleCallback(params) {
throw new Error(`handleCallback() not implemented for provider '${this.id}'`)
@@ -49,7 +49,7 @@ class BaseProvider {
throw new Error(`getUserProfile() not implemented for provider '${this.id}'`)
}
// Normalize a raw external profile to { subject, email, name }.
// Normalize a raw external profile to { subject, email, emailVerified, name }.
// eslint-disable-next-line no-unused-vars
mapUser(profile) {
throw new Error(`mapUser() not implemented for provider '${this.id}'`)

View File

@@ -23,7 +23,14 @@ class DiscordProvider extends OAuth2Provider {
}
normalizeProfile(p = {}) {
// global_name is the new display name; fall back to the legacy username.
return { subject: p.id, email: p.email || null, name: p.global_name || p.username || null }
return {
subject: p.id,
email: p.email || null,
// Discord spells the claim `verified` rather than `email_verified`, and it
// means exactly this: the user confirmed the address with Discord.
emailVerified: p.verified === true,
name: p.global_name || p.username || null,
}
}
}

View File

@@ -30,6 +30,10 @@ class GenericOidcProvider extends OAuth2Provider {
return {
subject: p.sub || p.id || p.user_id || p.uid || null,
email: p.email || null,
// The standard OIDC claim. An IdP that omits it has not asserted anything,
// so the address stays unverified and the user proves it the ordinary way —
// absent is treated as false, never as true.
emailVerified: p.email_verified === true || p.email_verified === 'true',
name: p.name || p.preferred_username || p.username || p.email || null,
}
}

View File

@@ -27,7 +27,15 @@ class GoogleProvider extends OAuth2Provider {
return { access_type: 'online', prompt: 'select_account' }
}
normalizeProfile(p = {}) {
return { subject: p.sub, email: p.email || null, name: p.name || p.email || null }
return {
subject: p.sub,
email: p.email || null,
// Google's OIDC userinfo carries the standard `email_verified` claim. Read
// it rather than inferring verification from the mere presence of an
// address, which is what this code used to do (ENGAGEMENT.md §0.6/1b).
emailVerified: p.email_verified === true || p.email_verified === 'true',
name: p.name || p.email || null,
}
}
}

View File

@@ -4,44 +4,58 @@
// normalizer (e.g. rich_text runs its html through the allowlist), stamping the
// registry `version`, defaulting `visible` to true, and recursing one level into
// container slots. Returns a new array; never mutates the input.
//
// Parameterized by a registry lookup for the same reason validateBlocks is
// (engagement Phase 5a): the `email.*` family is a separate registry and must get
// the same validate-then-sanitize order, not a second implementation of it.
const { getBlock } = require('./registry')
function sanitizeBlocks(blocks) {
if (!Array.isArray(blocks)) return []
return blocks.map(sanitizeOne)
}
/**
* Build a blocks sanitizer bound to one registry.
* @param {(type: string) => object|null} lookup registry `getBlock`
* @returns {(blocks: unknown) => object[]}
*/
function makeSanitizeBlocks(lookup) {
function sanitizeOne(block) {
const def = lookup(block.type)
if (!def) return block // unreachable after validation, but stay defensive
function sanitizeOne(block) {
const def = getBlock(block.type)
if (!def) return block // unreachable after validation, but stay defensive
let props = block.props && typeof block.props === 'object' ? { ...block.props } : {}
let props = block.props && typeof block.props === 'object' ? { ...block.props } : {}
// Recurse into container slots first (leaf sub-blocks get sanitized too).
if (def.container) {
for (const slot of def.containerSlots) {
if (Array.isArray(props[slot])) props[slot] = props[slot].map(sanitizeOne)
}
}
// Recurse into container slots first (leaf sub-blocks get sanitized too).
if (def.container) {
for (const slot of def.containerSlots) {
if (Array.isArray(props[slot])) props[slot] = props[slot].map(sanitizeOne)
// Apply the block's own normalizer last (operates on its scalar props).
if (def.sanitize) {
try {
props = def.sanitize(props)
} catch {
// Leave props as-is; validation already passed, a sanitize throw shouldn't
// block the save.
}
}
return {
id: block.id,
type: block.type,
version: Number.isInteger(block.version) ? block.version : def.version,
visible: block.visible !== false,
props,
}
}
// Apply the block's own normalizer last (operates on its scalar props).
if (def.sanitize) {
try {
props = def.sanitize(props)
} catch {
// Leave props as-is; validation already passed, a sanitize throw shouldn't
// block the save.
}
}
return {
id: block.id,
type: block.type,
version: Number.isInteger(block.version) ? block.version : def.version,
visible: block.visible !== false,
props,
return function sanitizeBlocks(blocks) {
if (!Array.isArray(blocks)) return []
return blocks.map(sanitizeOne)
}
}
module.exports = { sanitizeBlocks }
// The page-registry binding — the export every existing caller already uses.
const sanitizeBlocks = makeSanitizeBlocks(getBlock)
module.exports = { sanitizeBlocks, makeSanitizeBlocks }

View File

@@ -1,4 +1,4 @@
// Server-side validation for a page's `blocks` array, run on every save before
// Server-side validation for a stored `blocks` array, run on every save before
// persisting. The admin UI validates client-side too, but that can be bypassed
// by a direct API call, so this is the authoritative gate: it enforces the block
// envelope (reserved keys only), that every `type` is a registered block, that
@@ -9,6 +9,14 @@
// Returns { valid, errors } — a flat list of human-readable error strings, each
// prefixed with the path to the offending block (e.g. `blocks[2].props.text`).
// It never throws on bad input; callers turn a non-empty `errors` into a 400.
//
// **The walk is parameterized by a registry lookup, and the page registry is one
// binding of it** (engagement Phase 5a). The `email.*` family is a SEPARATE
// registry — its entries carry renderers instead of a cache policy, and a
// CMS page must not validate with an email block inside it — but the envelope,
// the id uniqueness, the schema dispatch and the nesting cap are the same rules
// for both. Sharing the walk is what keeps them the same rules rather than two
// copies that drift.
const { getBlock, RESERVED_KEYS } = require('./registry')
@@ -18,114 +26,129 @@ const MAX_SUBBLOCKS = 50 // sub-blocks per container slot
const ID_RE = /^[A-Za-z0-9_-]{1,40}$/
/**
* Validate a stored blocks array against the registry.
* @param {unknown} blocks
* @returns {{ valid: boolean, errors: string[] }}
* Build a blocks validator bound to one registry.
*
* @param {(type: string) => object|null} lookup registry `getBlock`
* @param {{ maxBlocks?: number, maxSubBlocks?: number }} [limits]
* @returns {(blocks: unknown) => { valid: boolean, errors: string[] }}
*/
function validateBlocks(blocks) {
const errors = []
if (!Array.isArray(blocks)) {
return { valid: false, errors: ['blocks must be an array'] }
}
if (blocks.length > MAX_BLOCKS) {
errors.push(`blocks may not exceed ${MAX_BLOCKS} top-level entries`)
}
const seenIds = new Set()
blocks.forEach((block, i) => {
validateBlock(block, `blocks[${i}]`, seenIds, errors, { nested: false })
})
return { valid: errors.length === 0, errors }
}
function makeValidateBlocks(lookup, limits = {}) {
const maxBlocks = limits.maxBlocks || MAX_BLOCKS
const maxSubBlocks = limits.maxSubBlocks || MAX_SUBBLOCKS
// Envelope: only the reserved keys, nothing smuggled at the top level.
function checkEnvelope(block, path, errors) {
for (const key of Object.keys(block)) {
if (!RESERVED_KEYS.includes(key)) {
errors.push(`${path}.${key} is not an allowed top-level key`)
// Envelope: only the reserved keys, nothing smuggled at the top level.
function checkEnvelope(block, path, errors) {
for (const key of Object.keys(block)) {
if (!RESERVED_KEYS.includes(key)) {
errors.push(`${path}.${key} is not an allowed top-level key`)
}
}
}
}
// id — stable, unique across the whole page (top-level and nested share one
// namespace since ids are the future join point for revision history).
function checkId(block, path, seenIds, errors) {
if (typeof block.id !== 'string' || !ID_RE.test(block.id)) {
errors.push(`${path}.id must be a short id string`)
} else if (seenIds.has(block.id)) {
errors.push(`${path}.id duplicates another block id (${block.id})`)
} else {
seenIds.add(block.id)
}
}
// Per-block prop schema from the registry (skipped when props isn't an object —
// that's already reported separately).
function checkPropSchema(def, props, path, errors) {
if (!def.schema || !props || typeof props !== 'object') return
let schemaErrors = []
try {
schemaErrors = def.schema(props) || []
} catch (err) {
schemaErrors = [`schema threw: ${err.message}`]
}
for (const e of schemaErrors) errors.push(`${path}.props.${e}`)
}
// Nesting: only container blocks may hold sub-blocks, capped at one level.
function checkNesting(def, props, path, seenIds, errors, nested) {
if (nested) {
errors.push(`${path} is a container and may not be nested inside another container`)
return
}
for (const slot of def.containerSlots) {
const sub = props ? props[slot] : undefined
if (sub === undefined) continue // an empty slot is allowed
if (!Array.isArray(sub)) {
errors.push(`${path}.props.${slot} must be an array of blocks`)
continue
// id — stable, unique across the whole document (top-level and nested share one
// namespace since ids are the future join point for revision history).
function checkId(block, path, seenIds, errors) {
if (typeof block.id !== 'string' || !ID_RE.test(block.id)) {
errors.push(`${path}.id must be a short id string`)
} else if (seenIds.has(block.id)) {
errors.push(`${path}.id duplicates another block id (${block.id})`)
} else {
seenIds.add(block.id)
}
if (sub.length > MAX_SUBBLOCKS) {
errors.push(`${path}.props.${slot} may not exceed ${MAX_SUBBLOCKS} blocks`)
}
// Per-block prop schema from the registry (skipped when props isn't an object —
// that's already reported separately).
function checkPropSchema(def, props, path, errors) {
if (!def.schema || !props || typeof props !== 'object') return
let schemaErrors = []
try {
schemaErrors = def.schema(props) || []
} catch (err) {
schemaErrors = [`schema threw: ${err.message}`]
}
sub.forEach((child, j) => {
validateBlock(child, `${path}.props.${slot}[${j}]`, seenIds, errors, { nested: true })
for (const e of schemaErrors) errors.push(`${path}.props.${e}`)
}
// Nesting: only container blocks may hold sub-blocks, capped at one level.
function checkNesting(def, props, path, seenIds, errors, nested) {
if (nested) {
errors.push(`${path} is a container and may not be nested inside another container`)
return
}
for (const slot of def.containerSlots) {
const sub = props ? props[slot] : undefined
if (sub === undefined) continue // an empty slot is allowed
if (!Array.isArray(sub)) {
errors.push(`${path}.props.${slot} must be an array of blocks`)
continue
}
if (sub.length > maxSubBlocks) {
errors.push(`${path}.props.${slot} may not exceed ${maxSubBlocks} blocks`)
}
sub.forEach((child, j) => {
validateBlock(child, `${path}.props.${slot}[${j}]`, seenIds, errors, { nested: true })
})
}
}
/**
* Validate one block envelope in place. `nested` = true when validating a
* sub-block inside a container slot, which forbids further nesting.
*/
function validateBlock(block, path, seenIds, errors, { nested }) {
if (block === null || typeof block !== 'object' || Array.isArray(block)) {
errors.push(`${path} must be an object`)
return
}
checkEnvelope(block, path, errors)
checkId(block, path, seenIds, errors)
// visible — optional in input, but if present must be a boolean.
if (block.visible !== undefined && typeof block.visible !== 'boolean') {
errors.push(`${path}.visible must be a boolean`)
}
// props — always an object bag.
const props = block.props
if (props === null || typeof props !== 'object' || Array.isArray(props)) {
errors.push(`${path}.props must be an object`)
}
// type — must resolve to a registered block.
const def = typeof block.type === 'string' ? lookup(block.type) : null
if (!def) {
errors.push(`${path}.type is not a registered block type (${String(block.type)})`)
return // can't validate props or nesting without a definition
}
checkPropSchema(def, props, path, errors)
if (def.container) checkNesting(def, props, path, seenIds, errors, nested)
}
/**
* Validate a stored blocks array against the bound registry.
* @param {unknown} blocks
* @returns {{ valid: boolean, errors: string[] }}
*/
return function validateBlocks(blocks) {
const errors = []
if (!Array.isArray(blocks)) {
return { valid: false, errors: ['blocks must be an array'] }
}
if (blocks.length > maxBlocks) {
errors.push(`blocks may not exceed ${maxBlocks} top-level entries`)
}
const seenIds = new Set()
blocks.forEach((block, i) => {
validateBlock(block, `blocks[${i}]`, seenIds, errors, { nested: false })
})
return { valid: errors.length === 0, errors }
}
}
/**
* Validate one block envelope in place. `nested` = true when validating a
* sub-block inside a container slot, which forbids further nesting.
*/
function validateBlock(block, path, seenIds, errors, { nested }) {
if (block === null || typeof block !== 'object' || Array.isArray(block)) {
errors.push(`${path} must be an object`)
return
}
// The page-registry binding — the export every existing caller already uses.
const validateBlocks = makeValidateBlocks(getBlock)
checkEnvelope(block, path, errors)
checkId(block, path, seenIds, errors)
// visible — optional in input, but if present must be a boolean.
if (block.visible !== undefined && typeof block.visible !== 'boolean') {
errors.push(`${path}.visible must be a boolean`)
}
// props — always an object bag.
const props = block.props
if (props === null || typeof props !== 'object' || Array.isArray(props)) {
errors.push(`${path}.props must be an object`)
}
// type — must resolve to a registered block.
const def = typeof block.type === 'string' ? getBlock(block.type) : null
if (!def) {
errors.push(`${path}.type is not a registered block type (${String(block.type)})`)
return // can't validate props or nesting without a definition
}
checkPropSchema(def, props, path, errors)
if (def.container) checkNesting(def, props, path, seenIds, errors, nested)
}
module.exports = { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS }
module.exports = { validateBlocks, makeValidateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS }

View File

@@ -0,0 +1,154 @@
// ── Core's own engagement triggers ─────────────────────────────────────────
//
// ENGAGEMENT.md §4.3 and Phase 2. The twin of config/coreStreams.js, and
// deliberately the SAME FIVE IDS — that is the org lead's §7.2 decision, taken at
// the start of this phase: **one namespace.** A trigger is not a second thing
// standing next to a stream; it is a payload contract attached to an id that may
// also carry a subscription toggle. `news.post` names one event, whether the
// question being asked of it is "may I push this?" or "what may a template
// interpolate?".
//
// What that buys, concretely: `notification_channel_prefs.stream_id` (§4.5) stays
// single-keyed. Under two namespaces it would have needed a `kind` discriminator
// in its primary key, and `news.post` would have named two different things
// forever.
//
// What it costs is the rule enforced in registries.js: an id has ONE owner across
// both facets, so a module cannot attach a payload contract to another module's
// stream, and core cannot attach one to a module's. Core's five ids below are
// already core's five streams, so all five are the same-owner upgrade case.
//
// **These declare; nothing here emits yet.** Phase 2 is the contract only — the
// Team pipeline keeps its own hardcoded mail until Phase 6 migrates it onto the
// engine, and this file is what it migrates ONTO. Registering the declarations a
// phase early is the same decision registerCore() has always taken: a registry
// whose first real exercise is a module is a registry that has already drifted.
//
// Every variable carries an `example`, and that is required rather than
// decorative (§4.3 property 3). It is what lets the template editor preview and
// test-send without a live game event, which is the reason template systems go
// untested.
const TRIGGERS = [
{
id: 'news.post',
label: 'News post published',
description: 'A news / Five-on-Friday / newsletter post was published.',
kind: 'event',
// No subjectKey. The subject of a cooldown here is the USER, not the post —
// "do not mail me about news more than once an hour" is the useful rule, and
// keying it per post would make every cooldown a no-op. Compare the four
// Team triggers below, where the Team genuinely is the subject.
audience: 'subscribers',
ceiling: 'authenticated',
version: 1,
variables: [
{ name: 'title', type: 'string', required: true, example: 'Five on Friday — the Yew invasion',
description: 'The post title.' },
{ name: 'excerpt', type: 'string', required: false, example: 'Four new champion spawns, and the fate of the Yew moongate…',
description: 'A plain-text summary, already stripped of markup.' },
{ name: 'category', type: 'string', required: false, example: 'Five on Friday',
description: 'The post category, when it has one.' },
// **`/site/news`, the LIST, and not a per-post path.** The example said
// `/news/<slug>` when this was declared with no caller; Phase 11 gave it
// one and the path turned out not to exist — `App.jsx` mounts `/site/news`
// and nothing under it, which is why `announceJobs.logic.js` links the list
// from the Discord and town-crier announcements too. An `example` is what
// the template editor previews and test-sends with (§4.3 property 3), so an
// example naming a 404 is a preview that looks right and a mail that is not.
{ name: 'postUrl', type: 'url', required: true, example: '/site/news',
description: 'Site-relative path to the post. The news list today — the site has no per-post route.' },
],
},
// ── Teams (TEAMS.md Part 6) ─────────────────────────────────────────────
//
// All four ceiling at `members` and not one of them higher. Who may be told
// about a Team event is the access resolver's answer and always has been
// (coreStreams.js says the same thing about the push catalog); the ceiling is
// that rule written where a RULE EDITOR has to obey it too. Without it an
// operator could point a rule at `authenticated` and mail a private Team's
// forum excerpt to the whole site.
{
id: 'team.member.joined',
label: 'Team — new member',
description: 'Someone joined a Team.',
kind: 'event',
subjectKey: 'teamName',
audience: 'members',
ceiling: 'members',
version: 1,
variables: [
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
description: 'The Team the event is about. Also the cooldown subject.' },
{ name: 'memberName', type: 'string', required: true, example: 'Darrow',
description: 'Display name of the member who joined.' },
{ name: 'teamUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil',
description: 'Site-relative path to the Team page. Absent when no module supplies a pageUrlTemplate.' },
],
},
{
id: 'team.leadership.changed',
label: 'Team — leadership change',
description: 'Leadership changed in a Team.',
kind: 'event',
subjectKey: 'teamName',
audience: 'members',
ceiling: 'members',
version: 1,
variables: [
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
description: 'The Team the event is about. Also the cooldown subject.' },
{ name: 'leaderName', type: 'string', required: true, example: 'Marisol',
description: 'Display name of the new leader.' },
{ name: 'teamUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil',
description: 'Site-relative path to the Team page.' },
],
},
{
id: 'team.forum.post',
label: 'Team — new forum post',
description: 'A new thread or reply in a Team forum.',
kind: 'event',
subjectKey: 'teamName',
audience: 'members',
ceiling: 'members',
version: 1,
variables: [
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
description: 'The Team the event is about. Also the cooldown subject.' },
{ name: 'authorName', type: 'string', required: true, example: 'Darrow',
description: 'Display name of the poster.' },
{ name: 'threadTitle', type: 'string', required: true, example: 'Tuesday champ rotation',
description: 'Title of the thread the post belongs to.' },
{ name: 'excerpt', type: 'string', required: false, example: 'Moving the Tuesday run an hour later…',
description: 'Plain-text excerpt of the post body, already stripped of markup.' },
{ name: 'postUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil/forum/412',
description: 'Site-relative path to the post.' },
],
},
{
id: 'team.announcement',
label: 'Team — announcement',
description: 'A leader posted an announcement in a Team.',
kind: 'event',
subjectKey: 'teamName',
audience: 'members',
ceiling: 'members',
version: 1,
variables: [
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
description: 'The Team the event is about. Also the cooldown subject.' },
{ name: 'authorName', type: 'string', required: true, example: 'Marisol',
description: 'Display name of the leader who posted.' },
{ name: 'title', type: 'string', required: true, example: 'Siege practice moved to Sunday',
description: 'The announcement title.' },
{ name: 'excerpt', type: 'string', required: false, example: 'We are moving practice to Sunday 8pm…',
description: 'Plain-text excerpt of the announcement body.' },
{ name: 'postUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil/forum/419',
description: 'Site-relative path to the announcement.' },
],
},
]
module.exports = { TRIGGERS }

View File

@@ -0,0 +1,28 @@
// Email block registry entrypoint. Requiring this module registers every
// `email.*` block definition exactly once, then re-exports the registry API, the
// renderer and the registry-bound validator/sanitizer. Anything that needs to
// validate or render a mail template's blocks should require THIS module, not
// ./registry or ./render directly, so the definitions are guaranteed loaded.
//
// Same shape as `blocks/index.js`, on purpose — the two families are siblings
// (see ./registry.js for why they are not one registry).
const registry = require('./registry')
const render = require('./render')
const interpolate = require('./interpolate')
const variables = require('./variables')
// ── Block definitions (self-register on require) ───────────────────────────
require('./types/heading')
require('./types/text')
require('./types/button')
require('./types/divider')
require('./types/image')
require('./types/itemList')
module.exports = {
...registry,
...render,
...interpolate,
...variables,
}

View File

@@ -0,0 +1,79 @@
// ── Template variable interpolation ────────────────────────────────────────
//
// ENGAGEMENT.md §4.6.2's security posture, as code: "variable interpolation is
// HTML-escaped by default with no raw-HTML variable type in v1. A module supplies
// data; it does not supply markup."
//
// The token grammar is deliberately the smallest thing that works: `{{ name }}`,
// a bare declared variable name, and NOTHING else. No filters, no conditionals,
// no loops, no dotted paths. Three reasons:
//
// - A template is operator-authored data rendered by the server. Every construct
// added here is a construct an operator can get wrong and a construct someone
// has to sandbox.
// - §4.3 makes the trigger declaration the source of truth for what a template
// may reference, and a save-time check names the offending variable. That check
// can only be exact if a token is a name — `{{ user.profile.email }}` is not a
// declared variable, it is an expression over one.
// - Repetition is a BLOCK (`email.itemList`), not a template construct, so the
// one place a template needs "for each" already has a typed, validated home.
//
// A token whose variable has no value at render time becomes the empty string and
// is reported in `missing`. It does not become "undefined", which is the failure
// §4.3's versioning paragraph is about — a renamed variable rendering as the word
// undefined in a person's inbox.
// `{{ name }}` / `{{name}}`. Leading letter, then letters/digits/underscore —
// the same shape §4.3's declarations use.
const TOKEN_RE = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g
/** Escape text for interpolation into HTML. Same table as utils/htmlShell.js. */
function htmlEscape(s) {
return String(s).replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]),
)
}
/**
* Every distinct variable name a string references, in first-appearance order.
* This is what the save-time check (Phase 5b) walks to find undeclared variables.
* @param {unknown} str
* @returns {string[]}
*/
function scanTokens(str) {
if (typeof str !== 'string') return []
const found = []
for (const m of str.matchAll(TOKEN_RE)) {
if (!found.includes(m[1])) found.push(m[1])
}
return found
}
/**
* Substitute declared variables into a string.
*
* @param {unknown} str
* @param {Record<string, unknown>} values
* @param {{ escape?: boolean, missing?: Set<string> }} [opts]
* `escape` (default true) HTML-escapes each value — pass false ONLY for the
* plain-text part, where there is no markup to escape into and `&amp;` in a
* person's inbox is a bug. `missing` collects names with no value.
* @returns {string}
*/
function interpolate(str, values, opts = {}) {
if (typeof str !== 'string' || str === '') return ''
const escape = opts.escape !== false
const missing = opts.missing || null
return str.replace(TOKEN_RE, (_match, name) => {
const value = values ? values[name] : undefined
if (value === undefined || value === null) {
if (missing) missing.add(name)
return ''
}
const asString = typeof value === 'string' ? value : String(value)
return escape ? htmlEscape(asString) : asString
})
}
module.exports = { TOKEN_RE, htmlEscape, scanTokens, interpolate }

View File

@@ -0,0 +1,138 @@
// ── The `email.*` block registry ───────────────────────────────────────────
//
// ENGAGEMENT.md §4.4. A sibling of `blocks/registry.js`, not an extension of it,
// settled with the org lead at the start of Phase 5a. Three reasons, in order of
// how much they cost if ignored:
//
// 1. **These blocks render on the SERVER.** Page blocks do not: `blocks/` carries
// `schema` / `sanitize` / `cacheTTL` and the actual drawing happens in React
// (`client/src/blocks/BlockRenderer.jsx`). Mail has no React — a message body
// is a string this process produces — so an email definition carries `toHtml`
// and `toText`. `registerBlock` freezes a fixed field set and would silently
// DROP both.
// 2. **One registry would be one namespace.** `blocks/validateBlocks.js`'s only
// server consumer is `pages.model.js`; registering `email.heading` into that
// Map makes a CMS page containing an email block validate and save, and the
// client renderer has nothing to draw for it.
// 3. The two entry shapes genuinely differ: `cacheTTL` and `container` mean
// nothing to a mail body, and a renderer means nothing to a cached page block.
//
// What IS shared is everything that is the same rule for both, and it is shared by
// binding rather than by copy: `propHelpers`, the envelope/id/nesting walk
// (`makeValidateBlocks`) and the validate-then-sanitize order (`makeSanitizeBlocks`).
// §4.4's "do not build a second editor" is honoured where it is about the editor —
// Phase 5b drives these through the existing block/prop-panel machinery.
//
// A registered definition looks like:
// {
// type: 'email.heading',
// version: 1,
// schema: (props) => [], // error strings ([] = valid)
// sanitize: (props) => props, // optional, run on save AFTER validation
// toHtml: (props, ctx) => '<tr>…', // a table ROW; see render.js for the shell
// toText: (props, ctx) => 'text', // '' means "contributes nothing"
// variables: (props) => [], // optional; see below
// }
//
// `variables` exists because of ONE block, and the exception is the reason it has
// to be declared rather than inferred. Every other block references a declared
// variable the same way a person writes it — as a `{{token}}` inside an authored
// string — so scanning the string props finds them all. `email.itemList` does not:
// its `variable` prop holds a BARE NAME (`items`), because the block iterates the
// value rather than interpolating it. A save-time check that only scanned tokens
// would pass a template pointing its one repeating block at a variable no trigger
// declares, and the failure would surface as an empty digest in someone's inbox.
// A block that reads a variable by any means other than a token says so here.
//
// `ctx` is the render context (render.js): resolved brand values, an `interp`
// that substitutes declared variables HTML-escaped, and `interpText` that does
// the same without escaping for the plain-text part.
const registry = new Map()
// Same envelope as a page block — deliberately the same constant list, because
// the shared validator enforces it and the two must not diverge.
const { RESERVED_KEYS } = require('../blocks/registry')
/**
* Register an email block definition. Throws on a missing type, a duplicate, or a
* missing renderer — all three are programmer errors surfaced at boot.
* @param {object} def
* @returns {object} the normalized, frozen definition
*/
function registerEmailBlock(def) {
if (!def || typeof def.type !== 'string' || def.type.length === 0) {
throw new Error('registerEmailBlock: a block definition needs a string `type`')
}
if (!def.type.startsWith('email.')) {
// The prefix is not needed to disambiguate — this is its own Map — but a
// stored blocks array should say what it is when someone reads the row.
throw new Error(`registerEmailBlock: ${def.type} must be namespaced "email."`)
}
if (registry.has(def.type)) {
throw new Error(`registerEmailBlock: block type already registered: ${def.type}`)
}
if (typeof def.toHtml !== 'function' || typeof def.toText !== 'function') {
// §4.4: "Every block type gets a toText(props) alongside its renderer, so a
// text part always exists." A block that can only produce HTML would make a
// published template's text part depend on which blocks it happened to use.
throw new Error(`registerEmailBlock: ${def.type} needs both toHtml and toText`)
}
if (def.schema != null && typeof def.schema !== 'function') {
throw new Error(`registerEmailBlock: ${def.type}.schema must be a function`)
}
if (def.sanitize != null && typeof def.sanitize !== 'function') {
throw new Error(`registerEmailBlock: ${def.type}.sanitize must be a function`)
}
if (def.variables != null && typeof def.variables !== 'function') {
throw new Error(`registerEmailBlock: ${def.type}.variables must be a function`)
}
const entry = Object.freeze({
type: def.type,
label: def.label || def.type,
version: Number.isInteger(def.version) ? def.version : 1,
schema: def.schema || null,
sanitize: def.sanitize || null,
toHtml: def.toHtml,
toText: def.toText,
// Null, not a default `() => []`: `variables.js` distinguishes "this block
// declares no non-token references" from "this block was never asked", and
// only the second is worth a comment when a new block type is added.
variables: def.variables || null,
// The shared walk reads these; email has no containers, and saying so here is
// what lets `makeValidateBlocks` be the same function for both families.
container: false,
containerSlots: Object.freeze([]),
})
registry.set(entry.type, entry)
return entry
}
/** @returns {object|null} the definition for `type`, or null if unknown. */
function getEmailBlock(type) {
return registry.get(type) || null
}
/** @returns {boolean} whether `type` is a registered email block. */
function hasEmailBlock(type) {
return registry.has(type)
}
/** @returns {object[]} all registered definitions (registration order). */
function listEmailBlocks() {
return [...registry.values()]
}
/** Drop every registered block. Test-only. */
function _resetRegistry() {
registry.clear()
}
module.exports = {
RESERVED_KEYS,
registerEmailBlock,
getEmailBlock,
hasEmailBlock,
listEmailBlocks,
_resetRegistry,
}

View File

@@ -0,0 +1,196 @@
// ── Rendering a block array into a mail body ───────────────────────────────
//
// Pure and synchronous: everything that needs a database — the brand values, the
// resolved theme, the site title — is resolved by `engagement/templates.js` and
// arrives here as a plain object. That split is what lets the whole renderer be
// tested without a MariaDB, and it is why the byte-comparison test for the five
// transactional bodies (§5a acceptance) is a unit test rather than a live send.
//
// **The shell contributes structure and NO content.** No appended footer, no
// injected logo, no "sent by" line. Two reasons, and the second is the load-bearing
// one:
//
// - A person's mail must say what the operator wrote and nothing else. An
// unsubscribe line is a variable inside the template (§4.6.1 lists
// `unsubscribeUrl` for exactly the two templates that need one), so an operator
// can move it, reword it, or see that a transactional mail correctly has none.
// - **The HTML and text parts must say the same things.** A shell that put a
// footer only in the HTML would make every message's two parts disagree, which
// is a deliverability signal and, worse, means the text reader is told less
// than the HTML reader. Every block produces both halves; nothing else does.
//
// The HTML is table-based and inline-styled throughout, which is not a stylistic
// choice: `<div>` layout and a `<style>` block are the two things mail clients
// most reliably break.
const { htmlEscape, interpolate } = require('./interpolate')
const { getEmailBlock } = require('./registry')
const { makeValidateBlocks } = require('../blocks/validateBlocks')
const { makeSanitizeBlocks } = require('../blocks/sanitizeBlocks')
const { isSafeUrl } = require('../blocks/propHelpers')
// Bound to the email registry — the same walk the page family gets, so the
// envelope rules, id uniqueness and schema dispatch cannot drift between them.
const validateEmailBlocks = makeValidateBlocks(getEmailBlock, { maxBlocks: 60 })
const sanitizeEmailBlocks = makeSanitizeBlocks(getEmailBlock)
// A stack every mail client resolves. No webfont: a @font-face in mail is either
// stripped or silently ignored, and the fallback is what the reader sees anyway.
const FONT_STACK = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"
/**
* The mail palette — a light scaffold plus the deployment's accent.
*
* **Only the accent comes from the theme, and that is deliberate.** Every shipped
* preset (`config/themePresets.js`) is a DARK palette, and mail is not a page: a
* dark-background body is what §4.6.2 names as rendering "unreadable dark-on-dark
* in about a third of inboxes", because a good share of clients invert or force a
* background of their own. Deriving a light palette from a dark one would be a
* guess at six colours; taking the one colour that carries the brand — the accent,
* used for the button and for links — is exact. §4.6.1's property 2 holds either
* way: no seeded template contains a hex code, so one prebuilt image running as
* any shard mails in that shard's colour.
*
* @param {{ accent?: string }} [theme] resolved theme tokens
*/
function palette(theme = {}) {
const accent = isHex(theme.accent) ? theme.accent : '#7f99bd'
return Object.freeze({
accent,
onAccent: readableOn(accent),
heading: '#151a20',
text: '#33404d',
muted: '#6b7885',
rule: '#dfe4ea',
page: '#f4f6f8',
card: '#ffffff',
fontStack: FONT_STACK,
})
}
function isHex(v) {
return typeof v === 'string' && /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(v)
}
/** Black or white text over `hex`, whichever a reader can actually read. */
function readableOn(hex) {
let h = hex.slice(1)
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255)
// Relative luminance (WCAG). 0.45 rather than 0.5: the accents here are mid-tone
// and white-on-mid reads better than black-on-mid at button weight.
const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
return L > 0.45 ? '#151a20' : '#ffffff'
}
/**
* Build the render context every block's `toHtml` / `toText` receives.
*
* @param {object} opts
* @param {Record<string, unknown>} opts.values variable values
* @param {object} [opts.theme] resolved theme tokens
* @param {string} [opts.baseUrl] absolute site base, for relative urls
* @param {Set<string>} [opts.missing] collects unresolved variable names
*/
function buildContext({ values = {}, theme = {}, baseUrl = '', missing = new Set() }) {
const base = String(baseUrl || '').replace(/\/+$/, '')
const ctx = {
values,
missing,
palette: palette(theme),
escape: htmlEscape,
/** Interpolate + HTML-escape — for anything going into markup. */
h: (s) => interpolate(s, values, { escape: true, missing }),
/** Interpolate WITHOUT escaping — for the plain-text part only. */
t: (s) => interpolate(s, values, { escape: false, missing }),
/**
* Interpolate a URL and re-check it. Returns the URL or null.
*
* A stored `{{resetUrl}}` says nothing about where it points; the value
* arrives from a caller or a module at render time. Checking only the stored
* literal would mean a variable carrying `javascript:` becomes an href.
*/
safeHref: (s) => {
const url = interpolate(s, values, { escape: false, missing })
return url && isSafeUrl(url) ? url : null
},
/** Same-origin path → absolute URL; http(s) unchanged; anything else null. */
absolute: (url) => {
if (!url) return null
if (/^https?:\/\//i.test(url)) return url
if (url.startsWith('/')) return base ? `${base}${url}` : null
return null
},
}
return ctx
}
/**
* Render a blocks array into the two body parts.
*
* Blocks are joined by a blank line in text and stacked as table rows in HTML.
* A block whose `toText` returns '' contributes nothing to the text part and does
* not leave a doubled blank line behind it (`email.divider` is the case).
*
* @returns {{ html: string, text: string }} html is the ROWS, not a document
*/
function renderBlocks(blocks, ctx) {
const rows = []
const paras = []
for (const block of Array.isArray(blocks) ? blocks : []) {
if (block && block.visible === false) continue
const def = block && typeof block.type === 'string' ? getEmailBlock(block.type) : null
if (!def) continue // unreachable after validation; never emit an unknown block
const props = block.props && typeof block.props === 'object' ? block.props : {}
try {
const html = def.toHtml(props, ctx)
if (html) rows.push(html)
const text = def.toText(props, ctx)
if (text) paras.push(text)
} catch {
// One misbehaving block must not cost the whole message. Skipped in both
// parts together, so the two never disagree about what the mail contains.
}
}
return { html: rows.join(''), text: paras.join('\n\n') }
}
/**
* Wrap rendered rows in the mail document.
* @param {string} rowsHtml
* @param {object} ctx
* @param {string} [title] the <title>, shown by a few webmail clients
*/
function renderDocument(rowsHtml, ctx, title = '') {
const p = ctx.palette
return (
'<!doctype html><html><head><meta charset="utf-8" />' +
'<meta name="viewport" content="width=device-width,initial-scale=1" />' +
// Tells a client that inverts colours that this body already handles both,
// so it leaves the palette alone instead of inverting the card to near-black.
'<meta name="color-scheme" content="light" />' +
'<meta name="supported-color-schemes" content="light" />' +
`<title>${htmlEscape(title)}</title></head>` +
`<body style="margin:0;padding:0;background:${p.page};">` +
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${p.page};">` +
'<tr><td align="center" style="padding:24px 12px;">' +
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" ` +
`style="width:100%;max-width:600px;background:${p.card};border:1px solid ${p.rule};border-radius:6px;">` +
'<tr><td style="padding:28px 28px 16px 28px;">' +
'<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">' +
rowsHtml +
'</table></td></tr></table></td></tr></table></body></html>'
)
}
module.exports = {
FONT_STACK,
palette,
readableOn,
buildContext,
renderBlocks,
renderDocument,
validateEmailBlocks,
sanitizeEmailBlocks,
}

View File

@@ -0,0 +1,98 @@
// email.button — the call to action, and the one block whose two renderings are
// deliberately NOT the same content.
//
// **`textLead` is why the plain-text part is authored rather than derived.** In
// HTML this is a button reading "Choose a new password"; in plain text a button
// is nothing, and what a reader needs is the sentence that introduces the URL
// ("Choose a new password here:") followed by the URL on its own line. Deriving
// the second from the first produces either a bare URL with no lead-in or the
// button's label used as a sentence. §4.4 calls the text part generated-by-default
// and overridable; this block is the reason the default has to be good enough that
// an operator rarely reaches for the override.
//
// **The href is re-checked AFTER interpolation.** `url` is nearly always a token
// (`{{resetUrl}}`), so nothing about the stored value tells you where it points —
// the value arrives at render time from a module or a caller. A substituted URL
// that is not http/https/same-origin loses its href and renders as inert text
// rather than as a link the reader would have no reason to distrust.
const { registerEmailBlock } = require('../registry')
const { requiredText, optionalText, onlyKeys, isSafeUrl } = require('../../blocks/propHelpers')
const { scanTokens } = require('../interpolate')
const MAX_LABEL = 80
const MAX_URL = 600
const MAX_LEAD = 200
registerEmailBlock({
type: 'email.button',
label: 'Button / link',
version: 1,
schema(props) {
const errors = onlyKeys(props, ['label', 'url', 'textLead'])
const label = requiredText('label', props.label, MAX_LABEL)
if (label) errors.push(label)
const lead = optionalText('textLead', props.textLead, MAX_LEAD)
if (lead) errors.push(lead)
const url = requiredText('url', props.url, MAX_URL)
if (url) {
errors.push(url)
} else if (scanTokens(props.url).length === 0 && !isSafeUrl(props.url)) {
// A literal url is checked here, at save. One built from variables cannot
// be — see the header note; render.js checks the substituted value instead.
errors.push('url must be a relative path, an http(s) URL, or a template variable')
}
return errors
},
toHtml(props, ctx) {
// An EMPTY url and an UNSAFE one are different failures and get different
// answers. Empty means the caller chose not to supply this link at all (an
// unsubscribe line on a transactional mail), so the block disappears from both
// parts. Unsafe means a value arrived that must not become an href — the label
// still renders, inert, because dropping it silently would hide from the
// reader that the mail was meant to offer them something.
if (ctx.t(props.url).trim() === '') return ''
// ABSOLUTIZED, like `email.image` and `email.itemList` already do, and this
// was a real defect until Phase 6 put a rule-driven variable in here. A
// trigger's `url` variables are validated site-RELATIVE by construction
// (`engagementEmit.RELATIVE_URL`), so `{{actionUrl}}` interpolates to
// `/guilds/the-silver-anvil` and a mail client has no origin to resolve that
// against: the button rendered a dead link. `absolute()` returns null for a
// relative path when no base is configured, which falls into the inert-label
// branch below rather than shipping the broken href.
const href = ctx.absolute(ctx.safeHref(props.url))
const label = ctx.h(props.label)
if (!href) {
return (
`<tr><td style="padding:4px 0 16px 0;font-family:${ctx.palette.fontStack};` +
`font-size:15px;color:${ctx.palette.muted};">${label}</td></tr>`
)
}
// Table-wrapped, inline-styled, with explicit padding on the anchor: the shape
// that survives Outlook, which ignores padding on a <td> containing an <a>.
return (
'<tr><td style="padding:4px 0 20px 0;">' +
'<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>' +
`<td bgcolor="${ctx.palette.accent}" style="border-radius:4px;">` +
`<a href="${ctx.escape(href)}" style="display:inline-block;padding:11px 22px;` +
`font-family:${ctx.palette.fontStack};font-size:15px;font-weight:600;` +
`color:${ctx.palette.onAccent};text-decoration:none;border-radius:4px;">${label}</a>` +
'</td></tr></table>' +
// The bare URL under the button, for the clients that strip anchors and for
// the reader who wants to see where it goes before pressing it.
`<div style="padding-top:10px;font-family:${ctx.palette.fontStack};font-size:12px;` +
`line-height:1.5;color:${ctx.palette.muted};word-break:break-all;">${ctx.escape(href)}</div>` +
'</td></tr>'
)
},
toText(props, ctx) {
const raw = ctx.t(props.url).trim()
if (raw === '') return '' // see toHtml: no url, no block, in either part
// The text part shows the same absolute URL the button links to. Falls back
// to the raw value rather than dropping the block: a reader who can see a
// relative path can still find the site, and `itemList` makes the same trade.
const url = ctx.absolute(raw) || raw
const lead = props.textLead ? ctx.t(props.textLead).trim() : ''
return lead ? `${lead}\n${url}` : url
},
})

View File

@@ -0,0 +1,29 @@
// email.divider — a horizontal rule.
//
// **Its text form is the empty string, not a row of dashes.** A block whose only
// job is visual separation has no plain-text equivalent, and render.js already
// joins blocks with a blank line. Rendering `-----` would put a decoration in the
// text part that the author never wrote and cannot remove without deleting the
// rule from the HTML too. Returning '' is what the "'' means contributes nothing"
// contract in registry.js exists for.
const { registerEmailBlock } = require('../registry')
const { onlyKeys } = require('../../blocks/propHelpers')
registerEmailBlock({
type: 'email.divider',
label: 'Divider',
version: 1,
schema(props) {
return onlyKeys(props, [])
},
toHtml(_props, ctx) {
return (
'<tr><td style="padding:8px 0 20px 0;">' +
`<div style="height:1px;line-height:1px;font-size:0;background:${ctx.palette.rule};">&nbsp;</div>` +
'</td></tr>'
)
},
toText() {
return ''
},
})

View File

@@ -0,0 +1,41 @@
// email.heading — a section heading inside a mail body.
//
// `level` is a SIZE, not a tag hierarchy: mail clients do not build an outline
// from an email and several strip heading tags outright, so this renders a styled
// <div> at one of three sizes rather than h1/h2/h3. Keeping the prop named `level`
// means the prop panel Phase 5b reuses reads the same as the page block's.
const { registerEmailBlock } = require('../registry')
const { oneOf, requiredText, onlyKeys } = require('../../blocks/propHelpers')
const LEVELS = ['h1', 'h2', 'h3']
const MAX_TEXT = 200
const SIZES = { h1: '24px', h2: '19px', h3: '16px' }
registerEmailBlock({
type: 'email.heading',
label: 'Heading',
version: 1,
schema(props) {
const errors = onlyKeys(props, ['level', 'text'])
const level = oneOf('level', LEVELS)(props.level)
if (level) errors.push(level)
const text = requiredText('text', props.text, MAX_TEXT)
if (text) errors.push(text)
return errors
},
toHtml(props, ctx) {
// Same "nothing in, nothing out" rule as email.text: a heading that is one
// optional variable disappears rather than leaving its margin behind.
if (ctx.t(props.text).trim() === '') return ''
const size = SIZES[props.level] || SIZES.h2
return (
`<tr><td style="padding:0 0 12px 0;font-family:${ctx.palette.fontStack};` +
`font-size:${size};line-height:1.3;font-weight:700;color:${ctx.palette.heading};">` +
`${ctx.h(props.text)}</td></tr>`
)
},
toText(props, ctx) {
return ctx.t(props.text).trim()
},
})

View File

@@ -0,0 +1,60 @@
// email.image — an inline image.
//
// Two things differ from the page block of the same name, both because the reader
// is in a mail client rather than on the site:
//
// - **The src is absolutized.** `brand_assets` stores `/uploads/…` and every page
// renderer is same-origin, so a relative src has always been correct there. In
// an inbox there is no origin to be relative to; render.js's `absolute()` turns
// it into a URL against APP_BASE_URL / BRAND_URL, and an image that cannot be
// absolutized is DROPPED rather than emitted broken.
// - **`alt` is required.** Most mail clients block remote images by default, so
// for a large share of readers the alt text IS the image. On a web page it is
// an accessibility nicety; here it is the common case.
const { registerEmailBlock } = require('../registry')
const { requiredText, onlyKeys, isSafeUrl } = require('../../blocks/propHelpers')
const { scanTokens } = require('../interpolate')
const MAX_URL = 600
const MAX_ALT = 200
const MAX_WIDTH = 560
registerEmailBlock({
type: 'email.image',
label: 'Image',
version: 1,
schema(props) {
const errors = onlyKeys(props, ['url', 'alt', 'width'])
const alt = requiredText('alt', props.alt, MAX_ALT)
if (alt) errors.push(alt)
const url = requiredText('url', props.url, MAX_URL)
if (url) {
errors.push(url)
} else if (scanTokens(props.url).length === 0 && !isSafeUrl(props.url)) {
errors.push('url must be a relative path, an http(s) URL, or a template variable')
}
if (props.width !== undefined) {
if (!Number.isInteger(props.width) || props.width < 16 || props.width > MAX_WIDTH) {
errors.push(`width must be a whole number between 16 and ${MAX_WIDTH}`)
}
}
return errors
},
toHtml(props, ctx) {
const src = ctx.absolute(ctx.safeHref(props.url))
if (!src) return '' // unresolvable: no broken image in someone's inbox
const width = props.width ? ` width="${props.width}"` : ''
const style = props.width
? `max-width:100%;width:${props.width}px;height:auto;display:block;border:0;`
: 'max-width:100%;height:auto;display:block;border:0;'
return (
`<tr><td style="padding:0 0 16px 0;">` +
`<img src="${ctx.escape(src)}" alt="${ctx.h(props.alt)}"${width} style="${style}" /></td></tr>`
)
},
toText(props, ctx) {
// The alt text alone, with no [image] decoration: it was written to stand in
// for the picture, and in the text part standing in for it is all it does.
return ctx.t(props.alt)
},
})

View File

@@ -0,0 +1,112 @@
// email.itemList — the one repeating block, and the reason the token grammar in
// interpolate.js needs no loop construct.
//
// It renders an ARRAY variable rather than an inline list: the prop is the NAME of
// a declared variable (`items`), and the value arrives at render time. §4.6.1's
// two generic templates — `notify.event` and `notify.digest` — are generic because
// of this block: their variables are structural (`title`, `intro`, `items[]`), so
// a trigger from any module renders through them with no authoring at all.
//
// **The item shape is `{ heading, excerpt?, url? }`, matching what
// `teamNotify`/`teamDigestWorker` already build**, so Phase 6's migration onto the
// engine is a rewiring rather than a reshaping of every producer.
//
// A non-array value, or an empty one, renders `emptyText` if there is one and
// nothing at all otherwise. That is the same fail-soft posture `settingsJson`
// takes: a stored value that is unusable is treated as absent, never as an error —
// a digest whose item query returned nothing must still be a sendable mail.
const { registerEmailBlock } = require('../registry')
const { requiredText, optionalText, onlyKeys } = require('../../blocks/propHelpers')
const MAX_NAME = 64
const MAX_EMPTY = 200
const MAX_ITEMS = 100
const NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/
/** Coerce whatever the caller passed into a bounded array of item objects. */
function itemsOf(value) {
if (!Array.isArray(value)) return []
return value
.slice(0, MAX_ITEMS)
.map((item) => {
if (typeof item === 'string') return { heading: item }
if (!item || typeof item !== 'object') return null
return {
heading: item.heading == null ? '' : String(item.heading),
excerpt: item.excerpt == null ? '' : String(item.excerpt),
url: item.url == null ? '' : String(item.url),
}
})
.filter((item) => item && item.heading !== '')
}
registerEmailBlock({
type: 'email.itemList',
label: 'Item list',
version: 1,
schema(props) {
const errors = onlyKeys(props, ['variable', 'emptyText'])
const variable = requiredText('variable', props.variable, MAX_NAME)
if (variable) {
errors.push(variable)
} else if (!NAME_RE.test(props.variable)) {
errors.push('variable must be the name of a declared list variable')
}
const empty = optionalText('emptyText', props.emptyText, MAX_EMPTY)
if (empty) errors.push(empty)
return errors
},
// The one block whose variable reference is not a token (see registry.js).
// Without this the Phase 5b save check reads a template whose digest points at
// `itmes` as clean, and the mistake surfaces as an empty mail rather than as an
// error naming the variable.
variables(props) {
return typeof props.variable === 'string' && props.variable ? [props.variable] : []
},
toHtml(props, ctx) {
const items = itemsOf(ctx.values[props.variable])
if (items.length === 0) {
if (!props.emptyText) return ''
return (
`<tr><td style="padding:0 0 16px 0;font-family:${ctx.palette.fontStack};font-size:14px;` +
`line-height:1.55;color:${ctx.palette.muted};">${ctx.h(props.emptyText)}</td></tr>`
)
}
const rows = items
.map((item) => {
const href = ctx.absolute(ctx.safeHref(item.url))
const heading = ctx.escape(item.heading)
const title = href
? `<a href="${ctx.escape(href)}" style="color:${ctx.palette.accent};text-decoration:none;font-weight:600;">${heading}</a>`
: `<span style="font-weight:600;color:${ctx.palette.heading};">${heading}</span>`
const excerpt = item.excerpt
? `<div style="padding-top:4px;font-size:14px;color:${ctx.palette.muted};">${ctx.escape(item.excerpt)}</div>`
: ''
return (
`<tr><td style="padding:0 0 14px 0;border-left:3px solid ${ctx.palette.rule};padding-left:12px;` +
`font-family:${ctx.palette.fontStack};font-size:15px;line-height:1.5;color:${ctx.palette.text};">` +
`${title}${excerpt}</td></tr>`
)
})
.join('')
return (
'<tr><td style="padding:0 0 8px 0;">' +
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">${rows}</table>` +
'</td></tr>'
)
},
toText(props, ctx) {
const items = itemsOf(ctx.values[props.variable])
if (items.length === 0) return props.emptyText ? ctx.t(props.emptyText) : ''
// Heading flush left, excerpt and url indented two spaces, one blank line
// between items — the shape `mailer.sendTeamNotification` builds today.
return items
.map((item) => {
const lines = [item.heading]
if (item.excerpt) lines.push(` ${item.excerpt}`)
if (item.url) lines.push(` ${ctx.absolute(item.url) || item.url}`)
return lines.join('\n')
})
.join('\n\n')
},
})

View File

@@ -0,0 +1,69 @@
// email.text — a run of plain-text paragraphs.
//
// **There is no rich-text email block, and that is the §4.6.2 posture rather than
// an omission.** The page family has `rich_text` because a page author is trusted
// staff writing into a surface the site's own CSS controls. A mail body is
// different in both halves: the markup an operator writes here is re-rendered by
// thirty mail clients with thirty different subsets of HTML, and the VALUES
// interpolated into it come from modules and from game data. §4.6.2 settles the
// second half — "a module supplies data; it does not supply markup" — and the
// first is why even the operator's own markup earns nothing here: a <div> an
// author typed is a layout bug in Outlook, while `email.heading` / `email.button`
// are shapes this renderer knows how to make survive.
//
// So: blank line separates paragraphs, single newline is a line break, and every
// character is escaped on the way into HTML.
const { registerEmailBlock } = require('../registry')
const { requiredText, onlyKeys } = require('../../blocks/propHelpers')
const MAX_TEXT = 4000
/** Split on blank lines; each paragraph keeps its internal single newlines. */
function paragraphs(s) {
return String(s)
.split(/\n[ \t]*\n/)
.map((p) => p.replace(/^\n+|\n+$/g, ''))
.filter((p) => p !== '')
}
registerEmailBlock({
type: 'email.text',
label: 'Paragraph',
version: 1,
schema(props) {
const errors = onlyKeys(props, ['text', 'muted'])
const text = requiredText('text', props.text, MAX_TEXT)
if (text) errors.push(text)
if (props.muted !== undefined && typeof props.muted !== 'boolean') {
errors.push('muted must be a boolean')
}
return errors
},
toHtml(props, ctx) {
const color = props.muted ? ctx.palette.muted : ctx.palette.text
const size = props.muted ? '13px' : '15px'
// Interpolate FIRST, then split: a variable carrying a blank line becomes two
// paragraphs, which is what the contact-message mail needs (a player's typed
// message arrives as one variable and reads as they wrote it).
const body = ctx.h(props.text)
const parts = paragraphs(body)
// A block whose whole content is one optional variable renders NOTHING when
// that variable is absent, rather than an empty paragraph with its margin.
// This is what stands in for a conditional: `{{moreNote}}` on its own line is
// a line the caller can choose not to supply, and the template stays
// logic-free (interpolate.js).
if (parts.length === 0) return ''
const html = parts
.map((p) => `<p style="margin:0 0 12px 0;">${p.replace(/\n/g, '<br />')}</p>`)
.join('')
return (
`<tr><td style="padding:0;font-family:${ctx.palette.fontStack};font-size:${size};` +
`line-height:1.55;color:${color};">${html}</td></tr>`
)
},
toText(props, ctx) {
// Trimmed to match toHtml's "nothing in, nothing out": the two parts must
// agree about whether this block contributed anything at all.
return ctx.t(props.text).replace(/^\s+|\s+$/g, '')
},
})

View File

@@ -0,0 +1,100 @@
// ── Which declared variables a template references ─────────────────────────
//
// ENGAGEMENT.md §4.6.2: "A template referencing an undeclared variable is refused
// at save, naming the variable — the editor validates, it does not blindly
// interpolate module JSON."
//
// This is the walk that makes that sentence enforceable. It is deliberately a
// SEPARATE pass from rendering: a render only discovers a bad reference when a
// value happens to be missing at that moment, which makes the failure depend on
// the event rather than on the template. Phase 5a's `renderTemplate` already
// reports `missing` for exactly that runtime case; this answers the static
// question — what does this template ask for at all — and it can therefore refuse
// a save before any mail exists.
//
// Two kinds of reference, and both have to be found or the check is theatre:
//
// - **Tokens** in every authored string: the subject, an overriding text part,
// and every string-valued prop on every block. `scanTokens` finds these.
// - **Named references** a block declares (`registry.js`'s `variables`), which
// today is `email.itemList.variable` and its bare `items`. A token scan cannot
// see these and would pass them silently.
//
// The block walk mirrors `makeValidateBlocks`' — top level plus container slots —
// rather than sharing it, because that function's job is to decide validity and
// this one's is to collect names from a structure already known to be valid. The
// email family has no containers today; the slot arm exists so that adding one
// does not quietly halve this function's coverage.
const { scanTokens } = require('./interpolate')
const { getEmailBlock } = require('./registry')
/** Every distinct token name in a string, an array of strings, or a nested plain object. */
function tokensIn(value, out) {
if (typeof value === 'string') {
for (const name of scanTokens(value)) out.add(name)
return
}
if (Array.isArray(value)) {
for (const entry of value) tokensIn(entry, out)
return
}
if (value && typeof value === 'object') {
for (const entry of Object.values(value)) tokensIn(entry, out)
}
}
function walkBlock(block, out) {
if (!block || typeof block !== 'object') return
tokensIn(block.props, out)
const def = getEmailBlock(block.type)
if (def && typeof def.variables === 'function') {
let named = []
try {
named = def.variables(block.props || {}) || []
} catch {
// A definition that throws on malformed props must not take the save path
// down with it: validation runs first and has already refused those props,
// so the only way here is a definition bug, and the right answer to that is
// to contribute no names rather than to 500 the request.
named = []
}
for (const name of named) if (typeof name === 'string' && name) out.add(name)
}
for (const slot of def?.containerSlots || []) {
const children = block.props?.[slot]
if (Array.isArray(children)) for (const child of children) walkBlock(child, out)
}
}
/**
* Every declared-variable name this template references, in no particular order.
*
* @param {{ blocks?: unknown[], subject?: string, text_body?: string|null }} template
* @returns {string[]}
*/
function referencedVariables(template) {
const out = new Set()
tokensIn(template?.subject, out)
tokensIn(template?.text_body, out)
if (Array.isArray(template?.blocks)) for (const block of template.blocks) walkBlock(block, out)
return [...out]
}
/**
* The names `referencedVariables` found that `declared` does not contain.
*
* @param {{ blocks?: unknown[], subject?: string, text_body?: string|null }} template
* @param {Array<{ name: string }>} declared what §4.3 declares for this template's
* trigger, PLUS the ambient variables every template may use — the caller
* passes `templates.variablesFor(...)`, which already merges the two.
* @returns {string[]} sorted, so the error message is stable across saves
*/
function undeclaredVariables(template, declared) {
const known = new Set((declared || []).map((v) => v && v.name).filter(Boolean))
return referencedVariables(template)
.filter((name) => !known.has(name))
.sort()
}
module.exports = { referencedVariables, undeclaredVariables }

View File

@@ -0,0 +1,166 @@
// ── Resolving a rule's audience to recipients ──────────────────────────────
//
// ENGAGEMENT.md §5.1a / §4.5, Phase 4a. A rule names an audience two ways and
// only ever one at a time: a **plain ceiling name** (`owner`, `staff`, `admin`,
// `subscribers`, `authenticated`, `everyone`) resolved from core's own tables, or
// an **`audience_segment_id`** pointing at an operator-composed tree of
// module-declared audiences (segments.js). This file turns either into user ids.
//
// **Three things it is careful about, all of them the same worry.** The set this
// function returns is the set that gets mailed, so:
//
// 1. Every id is checked against `users.status = 'active'` - including the ones a
// MODULE's resolver produced, which core has no reason to trust with account
// status it does not know about.
// 2. A dormant segment (its module uninstalled) resolves to EMPTY and says so.
// The caller must not send. Falling back to the rule's plain `audience`
// column would reach a different population than the one composed (§5.1a
// rule 4), which is the failure mode this whole design exists to avoid.
// 3. `members` resolves to nobody unless something NAMED the list: a segment, or
// (from Phase 6) an event carrying its own access-checked recipient set. Core
// knows no game vocabulary and cannot guess which members were meant. A rule
// with neither is inert and visible as such, rather than quietly falling back
// to something wider.
const registries = require('../modules/registries')
const channels = require('./channels')
const segments = require('./segments')
const segmentsDb = require('../model/engagement/engagementSegments.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const ceilings = require('../modules/ceilings')
const log = require('../utils/logger')('engagement')
/**
* Which registered channels default to something other than 'off'?
*
* Read once per resolution rather than hardcoded, because it is the difference
* between "opted in" meaning a stored row and meaning the absence of one
* (§3.1, G9). All three of core's channels default 'off' today, so this is empty
* and `subscribers` is the simple query - but the answer lives in the registry.
*/
const defaultOnChannels = () => channels.all().filter((c) => c.defaultMode !== 'off').map((c) => c.id)
/**
* Resolve one rule against one event.
*
* @returns {{ userIds: number[], ceiling: string|null, dormant: boolean, reason: string|null }}
* `dormant` means "this rule cannot be resolved right now"; `reason` names why
* for the log and, in Phase 4b, for the admin list's dormant badge.
*/
async function resolveForRule(rule, event) {
if (rule.audience_segment_id) {
const segment = await segmentsDb.getById(rule.audience_segment_id)
if (!segment) {
// The segment was deleted out from under the rule. `audience_segment_id`
// deliberately has no ON DELETE SET NULL (see schema.sql), because that
// would silently fall back to the rule's plain `audience` column and mail
// a different set of people.
return { userIds: [], ceiling: null, dormant: true, reason: 'audience segment no longer exists' }
}
const { dormant, userIds } = await segments.resolve(segment.expression)
if (dormant) {
return { userIds: [], ceiling: segment.ceiling, dormant: true, reason: 'audience segment is dormant' }
}
return {
userIds: await recipients.filterActive(userIds),
// The STORED ceiling, not one re-derived now: a module that has since
// widened its own audience's ceiling must not widen a segment that was
// saved under the old one.
ceiling: segment.ceiling,
dormant: false,
reason: null,
}
}
switch (rule.audience) {
case 'owner': {
if (!event.ownerUserId) {
// Not dormant: the rule is fine and this particular event simply has no
// owner to mail. A trigger that never carries one is an operator's
// mistake the rule editor should catch (Phase 4b), not a runtime error.
return { userIds: [], ceiling: 'owner', dormant: false, reason: 'event carries no ownerUserId' }
}
return {
userIds: await recipients.filterActive([event.ownerUserId]),
ceiling: 'owner',
dormant: false,
reason: null,
}
}
case 'staff':
case 'admin':
// Both role-gated, and resolved through the ONE query rather than two.
// `ceilings.ROLE_CEILINGS` holds which roles each names, so the day a
// third is added the resolver does not need a third case — and, more to
// the point, cannot get one of them wrong while the others stay right.
return {
userIds: await recipients.staff(ceilings.ROLE_CEILINGS[rule.audience].roles),
ceiling: rule.audience,
dormant: false,
reason: null,
}
case 'subscribers':
return {
userIds: await recipients.subscribers(event.triggerId, defaultOnChannels()),
ceiling: 'subscribers',
dormant: false,
reason: null,
}
case 'authenticated':
case 'everyone':
return { userIds: await recipients.active(), ceiling: rule.audience, dormant: false, reason: null }
case 'members': {
// **The event may name its own list, and Phase 6 is why that exists.**
// `members` is the ceiling for "a module-declared list", and until this
// phase the only way to name one was a segment — an operator-composed tree
// over audiences with CONSTANT params. That cannot express "the members of
// the Team this particular post was in": the list is different for every
// firing, and nothing in a saved segment reads the event.
//
// So an emitter that has already computed an access-checked recipient set
// hands it over on the envelope, and this is where it is used. It is not a
// bypass of anything: the set is still filtered through `users.status`
// below, and the ceiling returned is still `members`, so the G24 re-check
// in the engine still refuses a rule whose trigger has since narrowed.
// What it removes is core having to guess a game's membership vocabulary —
// the thing this case's original comment said it could not do.
if (Array.isArray(event.recipientUserIds) && event.recipientUserIds.length) {
return {
userIds: await recipients.filterActive(event.recipientUserIds),
ceiling: 'members',
dormant: false,
reason: null,
}
}
return {
userIds: [],
ceiling: 'members',
dormant: false,
reason: 'a "members" audience needs a segment naming which list, or an event that carries one',
}
}
default:
// Fails closed on an audience name the lattice does not know - the same
// posture `ceilings.permits` takes, and for the same reason.
log.warn('rule names an unknown audience', { rule: rule.id, audience: rule.audience })
return { userIds: [], ceiling: null, dormant: true, reason: `unknown audience "${rule.audience}"` }
}
}
/**
* The G24 gate, re-run at SEND time and not only at save time.
*
* A rule's audience was checked against its trigger's ceiling when it was saved,
* so this can only fail when something changed underneath: a module upgraded and
* narrowed its trigger's ceiling, or a module was replaced by one declaring the
* same id more tightly. That is precisely the case where a stale rule would
* otherwise mail a population the current declaration forbids, which is what
* makes this the security boundary rather than a duplicate check.
*/
function permitted(triggerId, ceiling) {
const declaration = registries.eventTrigger(triggerId)
if (!declaration) return false
return ceilings.permits(declaration.ceiling, ceiling)
}
module.exports = { resolveForRule, permitted, defaultOnChannels }

View File

@@ -0,0 +1,216 @@
// ── Which send failures are facts about the RECIPIENT ───────────────────────
//
// ENGAGEMENT.md Phase 9. The suppression list's whole value is that an address on
// it is genuinely undeliverable; the moment it fills with addresses that were
// fine, an operator learns to ignore it and it may as well not exist. This file
// is the one place that judgement is made.
//
// **It is deliberately NOT `mailer.PERMANENT_CODES`, and reusing that set would
// have been a mass-suppression bug.** That set answers "is retrying pointless?"
// and holds `EAUTH` and `554` alongside `550` — an authentication failure and a
// relay-wide policy refusal. Both are permanent and neither says anything about
// the person: one wrong SMTP password would suppress every address the outbox
// worker touched before anybody noticed the mail had stopped. "Do not retry" and
// "this mailbox does not exist" are different questions, and this file only
// answers the second.
//
// **The primary signal is the enhanced status code (RFC 3463), not the reply
// code.** `550` alone is the catch-all every refusal arrives as; `5.1.1` means
// one specific thing — no such mailbox. Every relay worth configuring emits an
// enhanced code, so it is read first and, when present, decides on its own.
//
// **The fallback is narrow on purpose.** Without an enhanced code a phrase match
// is all that is left, and phrase matching is how a classifier quietly starts
// suppressing everything. So it applies only after the reply code has already
// narrowed the failure to the recipient address — 550, 551 and 553 are RFC 5321's
// recipient-address codes — and only for phrases that cannot mean anything else,
// with a veto list checked first. `552` (storage exceeded) and `554` (transaction
// failed) are excluded from even that: a full mailbox gets emptied, and a generic
// transaction failure is generic.
//
// Anything this file is unsure about is NOT suppressed. The cost of a false
// negative is mailing a dead address again next month; the cost of a false
// positive is a person who silently stops hearing from the deployment and has no
// way to find out.
// RFC 3463 subject.detail pairs that mean "this address will not accept mail,
// today or ever". Kept as strings because `5.1.10` and `5.1.1` are different
// codes and numeric parsing loses that.
const PERMANENT_RECIPIENT = new Set([
'1.1', // bad destination mailbox address — no such user
'1.2', // bad destination system address — the domain does not take mail
'1.3', // bad destination mailbox address syntax
'1.6', // mailbox has moved, no forwarding address
'1.10', // recipient address has a null MX (RFC 7505)
'2.1', // mailbox disabled, not accepting messages
])
// Enhanced subjects that are permanent but are NOT about the recipient. Listed
// rather than merely omitted, because each is a plausible-looking 5.x.y that a
// later edit would otherwise be tempted to add:
// 2.2 — mailbox full. Permanent-coded by some relays, emptied by every user.
// 7.x — policy. Our sending reputation, our SPF, our content; the recipient is
// the one party it is not about.
// 3.x — the destination MAIL SYSTEM is full or refusing. Not the mailbox.
// 5.x — protocol failure. A bug at one end or the other.
const NEVER_RECIPIENT_SUBJECTS = new Set(['3', '5', '7'])
// RFC 5321 reply codes that name the recipient address specifically. 554 is
// absent deliberately: "transaction failed" is what a relay reaches for when it
// does not want to say why, and it is the commonest shape of a content or policy
// rejection.
const RECIPIENT_REPLY_CODES = new Set([550, 551, 553])
// Phrases that only ever mean "no such mailbox", checked only once a reply code
// above has established the failure is about the address. Each is a substring of
// a real refusal from a widely deployed MTA (Postfix, Exim, Exchange, Google,
// Microsoft 365).
const NO_SUCH_MAILBOX = [
'user unknown',
'unknown user',
'no such user',
'no such recipient',
'unknown recipient',
'invalid recipient',
'recipient address rejected',
'recipient not found',
'address does not exist',
'does not exist',
'mailbox unavailable',
'mailbox not found',
'no mailbox',
'user does not exist',
'address rejected',
]
// Phrases that appear alongside the ones above and mean the opposite, checked
// FIRST. "Mailbox unavailable" is a substring of the sentence a relay sends when
// a mailbox is merely full, so a substring match with no veto list would read a
// temporary condition as a dead address.
const NOT_A_DEAD_MAILBOX = [
'full',
'quota',
'storage',
'temporar',
'try again',
'greylist',
'rate limit',
'too many',
'spam',
'blocked',
'blacklist',
'blocklist',
'reputation',
'policy',
'authentication',
'not authorized',
]
/**
* The enhanced status code in an SMTP response, as `{ class, subject, detail }`,
* or null.
*
* Anchored to the start of the line rather than searched for anywhere in it: a
* bounce that quotes another server's answer ("...said: 550 5.1.1...") contains
* two, and the one that matters is the one this relay just gave us. A free search
* finds whichever comes first, which is not the same thing.
*/
function parseEnhanced(response) {
if (!response) return null
const m = /^\s*(\d{3})[\s-]+(\d)\.(\d{1,3})\.(\d{1,3})\b/.exec(String(response))
if (!m) return null
return { class: m[2], subject: m[3], detail: m[4] }
}
/** The three-digit reply code, off the error object or out of the response text. */
function replyCode(err) {
const direct = Number(err && err.responseCode)
if (Number.isInteger(direct) && direct >= 400 && direct <= 599) return direct
const m = /^\s*(\d{3})\b/.exec(String((err && err.response) || ''))
return m ? Number(m[1]) : null
}
const lower = (s) => String(s || '').toLowerCase()
/**
* Should this send failure suppress the address?
*
* @param {object} err the error a transport's send threw, or an object carrying
* the `responseCode` / `response` / `code` lifted off one
* @returns {{ suppress: boolean, reason: string, evidence: string|null }}
*
* `reason` is populated on a refusal too, and that is not decoration: it becomes
* the send log's `detail`, so "not suppressed: 554 does not name the recipient
* address" is the line that stops somebody re-deriving this decision from an
* unexplained non-event six months from now.
*/
function classify(err) {
const e = err || {}
const response = e.response || e.message || ''
const enhanced = parseEnhanced(response)
const code = replyCode(e)
// No reply code at all means the failure happened before or outside the SMTP
// transaction: the connection, the credentials, the socket. Never the
// recipient. `EAUTH` lands here, which is the whole reason this file exists.
if (!code) {
return {
suppress: false,
reason: `no SMTP reply code (${e.code || 'transport failure'}); not a recipient failure`,
evidence: null,
}
}
if (code < 500) {
return { suppress: false, reason: `${code} is a temporary failure`, evidence: null }
}
if (enhanced) {
const pair = `${enhanced.subject}.${enhanced.detail}`
if (enhanced.class !== '5') {
return { suppress: false, reason: `enhanced status ${enhanced.class}.${pair} is not permanent`, evidence: null }
}
if (PERMANENT_RECIPIENT.has(pair)) {
return { suppress: true, reason: 'bounce', evidence: `5.${pair}` }
}
if (NEVER_RECIPIENT_SUBJECTS.has(enhanced.subject)) {
return {
suppress: false,
reason: `5.${pair} is about the server or our standing with it, not the address`,
evidence: `5.${pair}`,
}
}
// A permanent 5.x.y this file has no opinion on. Unknown means no.
return {
suppress: false,
reason: `5.${pair} is not a known recipient failure`,
evidence: `5.${pair}`,
}
}
// No enhanced code: the narrow fallback.
if (!RECIPIENT_REPLY_CODES.has(code)) {
return { suppress: false, reason: `${code} does not name the recipient address`, evidence: null }
}
const text = lower(response)
const veto = NOT_A_DEAD_MAILBOX.find((p) => text.includes(p))
if (veto) {
return { suppress: false, reason: `${code}, but the response says "${veto}"`, evidence: null }
}
const hit = NO_SUCH_MAILBOX.find((p) => text.includes(p))
if (hit) {
return { suppress: true, reason: 'bounce', evidence: `${code} "${hit}"` }
}
return { suppress: false, reason: `${code} with no enhanced status and no recognised reason`, evidence: null }
}
module.exports = {
classify,
parseEnhanced,
replyCode,
PERMANENT_RECIPIENT,
NEVER_RECIPIENT_SUBJECTS,
RECIPIENT_REPLY_CODES,
NO_SUCH_MAILBOX,
NOT_A_DEAD_MAILBOX,
}

View File

@@ -0,0 +1,197 @@
// ── The delivery-channel registry ──────────────────────────────────────────
//
// ENGAGEMENT.md §3.1, Phase 3. The other half of the axis `transports/index.js`
// splits: a **channel** is what kind of sink this is (email, push, in-app), a
// **transport** is how one channel actually delivers (SMTP, ntfy, FCM). Push has
// had this shape since before anyone named it — `push_devices.transport` is a
// transport column on a channel with one implementation.
//
// **Only the declarative half registers here today**, and that is the whole of
// what Phase 3 needs. `addressFor` / `render` / `deliver` arrive with the phases
// that can exercise them: email in Phase 6, in-app in Phase 7. Declaring a
// function nothing calls freezes a signature before anything has tried to use
// it, which is the reason `transports/index.js` deferred this file at all.
//
// What forced it into Phase 3 rather than Phase 6: `notification_channel_prefs`
// stores a mode only when a user has expressed one, so reading a preference
// means knowing the channel's default — and §3.1 says `defaultMode` is expressed
// **once**. A constant list beside the prefs model would be that expression in a
// second place two phases before the registry replaced it.
//
// Nothing here touches the database, the network or a user record.
// id → channel definition, in registration order.
const channels = new Map()
// The three modes a preference can take. `digest` is only offered by a channel
// that declares `supportsDigest` — push and in-app are instant-only in v1,
// because a digest of content-free tickles is not a thing you can batch.
const MODES = ['off', 'instant', 'digest']
const isMode = (value) => MODES.includes(value)
/**
* Register a delivery channel.
*
* Validate-then-commit, the same discipline `registerMailTransport` and
* `modules/registries.js` use: every check runs before the map is touched, so a
* rejected registration leaves nothing behind.
*
* @param {object} def
* @param {string} def.id 'email' | 'push' | 'inapp' | later 'discord.dm'
* @param {string} def.label operator/user-facing name
* @param {boolean} def.carriesContent false for push — the tickle invariant, stated structurally
* @param {string} def.defaultMode the mode that applies with no stored row
* @param {boolean} def.supportsDigest may a preference for this channel be 'digest'
* @param {string} [def.description] one line for the preferences screen
* @param {(userId: number) => Promise<{address: string}|null>} [def.addressFor]
* where this channel would send to, or null when it cannot reach the user
* @param {(row: object) => Promise<{ok?: boolean, retry?: boolean, transport?: string, detail?: string, addressHash?: string}>}
* [def.deliver] deliver one claimed outbox row. **Must not throw** — the
* worker treats a throw as a transient failure, which is the right guess
* and a worse answer than the channel's own classification. A channel
* without one is declared but not yet deliverable, which is exactly what
* `inapp` is until Phase 7; the worker finishes such a row `failed` and
* says so in the send log rather than pretending it was sent.
* @param {(userIds: number[]) => Promise<{userIds: number[], excluded: object}>} [def.eligible]
* Phase 9. Narrow an already-resolved audience to the users this channel
* may write an outbox row for, and say how many it dropped and why.
*
* **It exists so the engine can stay channel-agnostic.** The verification
* gate is an email fact — an unverified address is a reason not to mail
* somebody and no reason at all not to put an item in their inbox — and a
* rule may name both channels. Filtering the shared audience before the
* per-channel loop would have silenced the wrong sink; an `if (channel ===
* 'email')` in `engine.js` would have put a channel's rule inside the
* generic engine. This is the seam that is neither.
*
* Distinct from `deliver`'s refusals on purpose: this runs at ENQUEUE and
* is for standing properties of a user (is this address verified), which
* are stable across a delay window and are worth not writing a row for.
* A suppression is not one of those — it can appear between the enqueue
* and the send — so it is checked in `deliver`, where it produces a
* `suppressed` row in the send log the acceptance criterion asks for.
*/
function registerDeliveryChannel(def) {
if (!def || typeof def !== 'object') throw new Error('registerDeliveryChannel: definition required')
const { id, label, carriesContent, defaultMode, supportsDigest } = def
if (typeof id !== 'string' || !/^[a-z][a-z0-9_.-]*$/.test(id)) {
throw new Error(`registerDeliveryChannel: invalid id ${JSON.stringify(id)}`)
}
if (channels.has(id)) throw new Error(`registerDeliveryChannel: ${id} is already registered`)
if (typeof label !== 'string' || !label) throw new Error(`registerDeliveryChannel(${id}): label required`)
if (typeof carriesContent !== 'boolean') {
throw new Error(`registerDeliveryChannel(${id}): carriesContent must be declared explicitly`)
}
if (!isMode(defaultMode)) {
throw new Error(`registerDeliveryChannel(${id}): defaultMode must be one of ${MODES.join(', ')}`)
}
if (typeof supportsDigest !== 'boolean') {
throw new Error(`registerDeliveryChannel(${id}): supportsDigest must be declared explicitly`)
}
// A channel that cannot batch cannot default to batching. Cheap to check, and
// the failure it prevents is a stored 'digest' row no delivery path can honour.
if (defaultMode === 'digest' && !supportsDigest) {
throw new Error(`registerDeliveryChannel(${id}): defaultMode 'digest' needs supportsDigest`)
}
// Optional, but not optionally-typed. A channel registering `deliver: true` or
// a stale import that resolved to undefined would otherwise be a channel that
// silently never delivers — the failure Phase 3 deferred the whole behavioural
// half to avoid freezing, and the one the worker's "no delivery implementation
// yet" branch would report as if it were by design.
for (const fn of ['addressFor', 'deliver', 'eligible']) {
if (def[fn] !== undefined && typeof def[fn] !== 'function') {
throw new Error(`registerDeliveryChannel(${id}): ${fn} must be a function`)
}
}
channels.set(id, {
id,
label,
description: def.description || null,
carriesContent,
defaultMode,
supportsDigest,
addressFor: def.addressFor,
deliver: def.deliver,
eligible: def.eligible,
})
return id
}
/**
* Every channel, in registration order. The preferences screen's column set.
*
* **Declarative fields only** — `addressFor`, `deliver` and `eligible` are
* stripped. This is what a route serializes, and a function on an object bound
* for `res.json` is a key that silently disappears rather than an error; keeping
* the boundary here means the API shape is decided in one place instead of by
* JSON.stringify.
*/
const all = () =>
[...channels.values()].map(({ addressFor, deliver, eligible, ...declared }) => ({ ...declared }))
/** Just the ids. */
const ids = () => [...channels.keys()]
/** One channel, or null. Callers must handle null: a stored pref row can name a
* channel that is no longer registered, and that must read as "off", not throw. */
const get = (id) => {
const c = channels.get(id)
return c ? { ...c } : null
}
const has = (id) => channels.has(id)
/** The mode that applies when the user has expressed nothing. An unregistered
* channel is 'off' — never on by accident. */
const defaultMode = (id) => (channels.get(id) || {}).defaultMode || 'off'
/** Which modes this channel will accept from a client. */
const modesFor = (id) => {
const c = channels.get(id)
if (!c) return []
return c.supportsDigest ? MODES.slice() : MODES.filter((m) => m !== 'digest')
}
/** Is `mode` a mode this channel accepts? The gate on every preference write. */
const acceptsMode = (id, mode) => modesFor(id).includes(mode)
/**
* Narrow an audience to the users this channel may enqueue for (Phase 9).
*
* The default for a channel that declares no `eligible` is "everyone the
* audience resolved to", which is what every channel but email does. It lives
* here rather than at each call site so the two consumers — the engine and the
* admin reach preview — cannot answer the question differently, which is exactly
* how a preview comes to promise a number the engine will not deliver.
*/
async function eligibleFor(id, userIds) {
const c = channels.get(id)
if (!c || typeof c.eligible !== 'function') return { userIds: userIds.slice(), excluded: {} }
const result = await c.eligible(userIds)
return {
userIds: (result && result.userIds) || [],
excluded: (result && result.excluded) || {},
}
}
// Test-only: the registry is module-level state.
function _reset() {
channels.clear()
}
module.exports = {
MODES,
isMode,
registerDeliveryChannel,
all,
ids,
get,
has,
defaultMode,
modesFor,
acceptsMode,
eligibleFor,
_reset,
}

View File

@@ -0,0 +1,251 @@
// ── Rule conditions — a predicate over a trigger's DECLARED variables ───────
//
// ENGAGEMENT.md §4.5, Phase 4a. `engagement_rules.conditions` is the half of a
// rule that decides *whether* this particular firing is interesting: "only when
// decayStatus is IDOC", "only for threads in this Team". Without it every rule is
// all-or-nothing per trigger, and an operator's only way to narrow is to ask a
// module author for a second trigger.
//
// **It is validated against the declaration, not against a payload.** A condition
// naming a variable the trigger does not declare is refused at SAVE, with the
// variable named, for the same reason §4.3 gives the template editor: a predicate
// that silently reads `undefined` is a rule that silently never fires (or always
// does), and the day you find out is the day the mail did not go.
//
// **The grammar is small and closed on purpose.** No arbitrary expressions, no
// arithmetic, no regex. An operator composes and/or/not over comparisons of one
// declared variable against a literal, and every operator here is one a rule
// editor can render as a dropdown. Anything that needs more than this is asking
// for a condition the module should have declared as a variable.
//
// Nothing in this file reaches the database or the network.
const registries = require('../modules/registries')
// Comparison operators, grouped by what they may be applied to. The grouping is
// the whole of the type check: `gt` on a boolean and `startsWith` on an int are
// both refused at save rather than quietly answering false forever.
const OPERATORS = {
eq: { label: 'is', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 1 },
ne: { label: 'is not', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 1 },
in: { label: 'is one of', types: ['string', 'int', 'float', 'url'], arity: 'list' },
nin: { label: 'is none of', types: ['string', 'int', 'float', 'url'], arity: 'list' },
gt: { label: 'is greater than', types: ['int', 'float', 'datetime'], arity: 1 },
gte: { label: 'is at least', types: ['int', 'float', 'datetime'], arity: 1 },
lt: { label: 'is less than', types: ['int', 'float', 'datetime'], arity: 1 },
lte: { label: 'is at most', types: ['int', 'float', 'datetime'], arity: 1 },
contains: { label: 'contains', types: ['string', 'url'], arity: 1 },
startsWith: { label: 'starts with', types: ['string', 'url'], arity: 1 },
// The one operator that takes no value: "the emit carried this variable at
// all". It is the honest way to write a rule about an OPTIONAL variable, and
// without it `ne` would have to double as a presence test and get it wrong
// (an absent variable is not "not equal to X"; it is absent).
present: { label: 'is present', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 0 },
absent: { label: 'is absent', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 0 },
}
const BOOLEAN_OPS = ['and', 'or', 'not']
// A list literal an operator may type. Bounded because it is stored in a JSON
// column an admin can write, and an unbounded IN list is an unbounded predicate
// evaluated on every event.
const MAX_LIST = 50
// Depth of the and/or/not tree. Three levels is more nesting than any rule
// editor should offer; the bound is here so a hand-written JSON body cannot
// recurse this evaluator into a stack overflow on the emit path.
const MAX_DEPTH = 5
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
/**
* Check one literal against the declared type of the variable it is compared to.
*
* `datetime` accepts anything `Date` parses and is normalised to an ISO string,
* which is what `engagementEmit.coerce` does to the payload side — so both sides
* of every comparison are the same representation of a moment, and a lexical
* `<` on two ISO strings is a chronological one.
*/
function checkLiteral(type, raw) {
switch (type) {
case 'string':
case 'url':
return typeof raw === 'string' ? { value: raw } : { error: 'expected a string' }
case 'int':
return Number.isInteger(raw) ? { value: raw } : { error: 'expected an integer' }
case 'float':
return typeof raw === 'number' && Number.isFinite(raw)
? { value: raw }
: { error: 'expected a finite number' }
case 'boolean':
return typeof raw === 'boolean' ? { value: raw } : { error: 'expected a boolean' }
case 'datetime': {
const d = raw instanceof Date ? raw : new Date(raw)
if (Number.isNaN(d.getTime())) return { error: 'expected a date' }
return { value: d.toISOString() }
}
default:
return { error: `unsupported type "${type}"` }
}
}
/**
* Validate a condition tree against a trigger declaration.
*
* Returns `{ ok: true, conditions }` with a NEW normalised tree — literals
* coerced, unknown keys dropped — or `{ ok: false, errors }` listing every
* problem rather than the first, the posture `validatePayload` takes and for the
* same reason: an operator fixing one clause at a time is an operator making six
* round trips through a form.
*
* `null` and `undefined` are valid and mean "no conditions" — a rule that fires
* on every occurrence of its trigger, which is the common case.
*/
function validate(declaration, raw) {
const errors = []
const variables = new Map((declaration?.variables || []).map((v) => [v.name, v]))
function walk(node, depth, path) {
if (depth > MAX_DEPTH) {
errors.push(`${path}: nested deeper than ${MAX_DEPTH}`)
return null
}
if (!isPlainObject(node)) {
errors.push(`${path}: expected an object`)
return null
}
if (BOOLEAN_OPS.includes(node.op)) {
// `not` takes exactly one node; `and`/`or` take a list. Both are written
// as `nodes` so a client walks one shape.
const raws = Array.isArray(node.nodes) ? node.nodes : []
if (!raws.length) {
errors.push(`${path}: "${node.op}" has no nodes`)
return null
}
if (node.op === 'not' && raws.length !== 1) {
errors.push(`${path}: "not" takes exactly one node`)
return null
}
const nodes = raws.map((child, i) => walk(child, depth + 1, `${path}.nodes[${i}]`)).filter(Boolean)
return nodes.length === raws.length ? { op: node.op, nodes } : null
}
if (node.op !== undefined) {
errors.push(`${path}: unknown operator "${node.op}"`)
return null
}
// A leaf: { variable, cmp, value }.
const variable = variables.get(node.variable)
if (!variable) {
errors.push(`${path}: "${node.variable}" is not a variable of "${declaration?.id}"`)
return null
}
const operator = OPERATORS[node.cmp]
if (!operator) {
errors.push(`${path}: unknown comparison "${node.cmp}"`)
return null
}
if (!operator.types.includes(variable.type)) {
errors.push(`${path}: "${node.cmp}" cannot be applied to a ${variable.type}`)
return null
}
if (operator.arity === 0) return { variable: variable.name, cmp: node.cmp }
if (operator.arity === 'list') {
if (!Array.isArray(node.value) || !node.value.length) {
errors.push(`${path}: "${node.cmp}" needs a non-empty list`)
return null
}
if (node.value.length > MAX_LIST) {
errors.push(`${path}: "${node.cmp}" list is longer than ${MAX_LIST}`)
return null
}
const value = []
let bad = false
node.value.forEach((item, i) => {
const checked = checkLiteral(variable.type, item)
if (checked.error) {
errors.push(`${path}.value[${i}]: ${checked.error}`)
bad = true
} else value.push(checked.value)
})
return bad ? null : { variable: variable.name, cmp: node.cmp, value }
}
const checked = checkLiteral(variable.type, node.value)
if (checked.error) {
errors.push(`${path}: ${checked.error}`)
return null
}
return { variable: variable.name, cmp: node.cmp, value: checked.value }
}
if (raw === null || raw === undefined) return { ok: true, conditions: null }
const conditions = walk(raw, 0, 'conditions')
return errors.length ? { ok: false, errors } : { ok: true, conditions }
}
/** Compare one already-normalised leaf against a payload. */
function evaluateLeaf(leaf, data) {
const present = Object.prototype.hasOwnProperty.call(data, leaf.variable)
const actual = data[leaf.variable]
if (leaf.cmp === 'present') return present
if (leaf.cmp === 'absent') return !present
// Every other comparison against an absent variable is FALSE, never true.
// `ne` is the one that tempts otherwise — "not equal to X" reads as satisfied
// by nothing at all — and treating it as true would make an optional variable's
// absence fire the rule.
if (!present) return false
switch (leaf.cmp) {
case 'eq': return actual === leaf.value
case 'ne': return actual !== leaf.value
case 'in': return leaf.value.includes(actual)
case 'nin': return !leaf.value.includes(actual)
case 'gt': return actual > leaf.value
case 'gte': return actual >= leaf.value
case 'lt': return actual < leaf.value
case 'lte': return actual <= leaf.value
case 'contains': return typeof actual === 'string' && actual.includes(leaf.value)
case 'startsWith': return typeof actual === 'string' && actual.startsWith(leaf.value)
default: return false
}
}
/**
* Does this event's payload satisfy the rule's conditions?
*
* `null` conditions are satisfied — a rule with no conditions fires on every
* occurrence. A tree this evaluator does not recognise answers **false**, which
* is the fail-closed direction: a stored condition that no longer parses (a rule
* saved against an older trigger version, say) must stop the mail rather than
* become "no conditions" and mail everyone.
*/
function evaluate(conditions, data = {}) {
if (conditions === null || conditions === undefined) return true
if (!isPlainObject(conditions)) return false
if (conditions.op === 'and') return (conditions.nodes || []).every((n) => evaluate(n, data))
if (conditions.op === 'or') return (conditions.nodes || []).some((n) => evaluate(n, data))
if (conditions.op === 'not') return !evaluate((conditions.nodes || [])[0], data)
if (conditions.op !== undefined) return false
return evaluateLeaf(conditions, data)
}
/**
* The operator vocabulary a rule editor renders, with the variable types each
* one applies to. Served with the rule surface in Phase 4b rather than hardcoded
* in the client, on the same argument the ceiling vocabulary is served with the
* trigger catalog: a second copy of a rule is a copy that drifts.
*/
const vocabulary = () =>
Object.entries(OPERATORS).map(([cmp, o]) => ({ cmp, label: o.label, types: o.types, arity: o.arity }))
/** Convenience for a caller holding only a trigger id. */
const validateFor = (triggerId, raw) => validate(registries.eventTrigger(triggerId), raw)
module.exports = { validate, validateFor, evaluate, vocabulary, OPERATORS, MAX_LIST, MAX_DEPTH }

View File

@@ -0,0 +1,103 @@
// ── Core's own delivery channels ───────────────────────────────────────────
//
// ENGAGEMENT.md §3.1 / Phase 3. All three are core's, and none of them is a game
// concept: a mailbox, a push endpoint and an inbox row are the same three things
// on any shard running this platform.
//
// **They are declared here before two of them can deliver anything**, and that is
// deliberate rather than premature. A preference is a durable user statement; the
// three columns of the preferences screen have to exist from the moment the table
// does, or the first person to open it after Phase 6 finds an email toggle that
// has never had a value and a screen that changed shape under them. Registering
// the metadata early costs nothing — the registry holds no behaviour yet — while
// registering it late means back-filling opinions users were never asked for.
//
// The `defaultMode`s below are the whole of G9: "per-channel defaults differ and
// there is nowhere to express that generically". This is that place.
const { registerDeliveryChannel } = require('./channels')
const emailChannel = require('./emailChannel')
const pushChannel = require('./pushChannel')
const inappChannel = require('./inappChannel')
const CHANNELS = [
{
id: 'push',
label: 'Push',
description: 'A silent tickle to your phone; the app then pulls the real content.',
// The tickle invariant (docs/android/PLAN.md §11), stated structurally rather
// than as a comment: what leaves the server on this channel is { stream, ref }
// and never a message body. Phase 7's `deliver` reads this flag; declaring it
// false here is what makes "push must not carry content" a property of the
// registration instead of a rule each caller has to remember.
carriesContent: false,
// **Opt-IN, and this is the one place the phase's own acceptance line was
// wrong.** ENGAGEMENT.md Phase 3 said a fresh user's push defaults to
// 'instant'; §3.1 called push "opt-OUT", borrowing the semantics of
// `team_notification_prefs` (where no row does mean notified). But push
// STREAM subscriptions have never worked that way: `notification_subscriptions`
// holds a row only when a user opted in, so no row means not subscribed.
// Defaulting to 'instant' here would have projected the entire catalog into
// `GET /auth/me/notifications/subscriptions` for every existing user, and the
// shipped Android client would have shown every toggle switched on after an
// upgrade nobody asked for. Settled by the org lead 2026-08-29: 'off'.
defaultMode: 'off',
// A batched tickle is a contradiction — the content is not in the message, so
// there is nothing to roll up. Ten events are ten wakeups or one; either way
// the app pulls the same inbox.
supportsDigest: false,
// Phase 7: the oldest sink is the last to get a `deliver`, because until the
// inbox existed there was nothing for a content-free tickle to point at.
addressFor: pushChannel.addressFor,
deliver: pushChannel.deliver,
},
{
id: 'email',
label: 'Email',
description: 'A message to your verified address.',
carriesContent: true,
// Opt-IN, per §7.1 Q1: standard marketing-email practice, and the posture
// `team_notification_prefs.email_mode` already takes ('off' by default).
defaultMode: 'off',
supportsDigest: true,
// Phase 6: the first channel with a body. `supportsDigest` above is now load-
// bearing rather than aspirational — a 'digest' preference means the engine
// writes NO outbox row and the digest worker re-derives the content at send
// time (§4.2b), which is a different delivery path rather than a batched one.
addressFor: emailChannel.addressFor,
deliver: emailChannel.deliver,
// Phase 9: the only channel that declares one. The Phase 1b verification
// gate is an email fact, and this is the seam that keeps it out of the
// generic engine - see channels.js's `eligible` docs.
eligible: emailChannel.eligible,
},
{
id: 'inapp',
label: 'On the site',
description: 'An item in your notification inbox on the website and in the app.',
carriesContent: true,
// **Opt-OUT, and the only one of the three that is** — settled by the org
// lead 2026-08-31, which is the Phase 7 decision this comment used to defer.
//
// The argument against a live default was never about in-app: it was that
// push wakes a device the user is holding and email leaves the building, so
// both must be asked for. An inbox item does neither. It is a row on a page
// the user chose to open, on this deployment, costing them one glance — and
// left at 'off' the surface would ship dead, because no rule could reach
// anyone until every user found a toggle for a channel they had never seen
// deliver anything. The backlog Phase 3 worried about cannot happen either:
// the table is empty at cutover, rules default to `enabled = 0`, and every
// rule carries a per-hour ceiling.
defaultMode: 'instant',
// Instant-only, and unlike push the reason is not that batching is
// meaningless — it is that the inbox IS the batch. A digest of inbox items
// is a list of things already sitting in a list.
supportsDigest: false,
addressFor: inappChannel.addressFor,
deliver: inappChannel.deliver,
},
]
for (const channel of CHANNELS) registerDeliveryChannel(channel)
module.exports = { CHANNELS }

View File

@@ -0,0 +1,229 @@
// ── The five rules core ships, all of them OFF ─────────────────────────────
//
// ENGAGEMENT.md Phase 6, decision 3. Before this phase the Team pipeline mailed
// people with no operator configuration at all: the code decided who was mailed
// and about what, and the only knobs were per-user. Phase 6 moves that decision
// onto rules — which default `enabled = 0`, and of which core seeds none.
//
// **So a straight migration would have stopped Team email on every existing
// deployment, silently.** The org lead's decision was to honour the invariant
// rather than carve an exception into it: the rules are seeded, and they are
// seeded OFF. Team email resumes when an operator opens Admin → Engagement →
// Rules and switches one on, and until then the admin screen says so in as many
// words (`EngagementRules.jsx`). The release note names it.
//
// The alternative — seeding them enabled so nothing changes for anybody — was
// considered and refused. "Nothing is seeded, nothing is on by default" is what
// makes a rules table safe to restore, import or replicate, and an exception
// carved for the one pipeline that predates the engine is an exception that has
// to be re-argued every time somebody reads the invariant.
//
// **Seeded once, not ensured on every boot**, and the difference matters: an
// operator who deletes a rule must not find it back after a restart. The guard is
// a settings key, the same mechanism a one-shot migration uses, so a deployment
// that has seen this seed never sees it again — deleted rules stay deleted, and
// an enabled rule stays enabled rather than being reset to off.
// **Phase 11 added a fifth, for `news.post`, and it needed its OWN one-shot key
// rather than an entry in the list above.** The Team key is already stamped on
// every deployment that has booted since Phase 6, and the guard reads its
// presence — so appending to `RULES` would have seeded the news rule on fresh
// installs only, and on exactly the upgrades that need it, never. Those are the
// deployments where `pushDispatch.publish('news.post', …)` used to run and no
// longer does (§7.1 Q9): they would have lost news push with no rule to switch
// on and no way to tell why. One key per seed GROUP is the rule this establishes;
// a sixth rule for a new trigger takes a sixth key, and a rule added to an
// existing group is a rule that only fresh installs will ever see.
const rulesDb = require('../model/engagement/engagementRules.db')
const settingsDb = require('../model/settings/settings.db')
const log = require('../utils/logger')('engagement')
// The one-shot guard. Its VALUE is the timestamp, purely so an operator reading
// the settings table can tell when it ran; only its presence is read.
const SEEDED_KEY = 'engagement_team_rules_seeded'
// Phase 11's, and separate for the reason above. Same shape, same semantics.
const NEWS_SEEDED_KEY = 'engagement_news_rule_seeded'
const RULES = [
{
trigger_id: 'team.forum.post',
name: 'Team forum posts',
// `members`, which resolves to the recipient set the event carries — the
// access-checked list `teamNotify` has always computed. Not `authenticated`,
// and the trigger's own ceiling would refuse that anyway: a private Team's
// forum excerpt reaching the whole site is the failure G24 exists for.
audience: 'members',
channels: ['email'],
// `email` is the instant body; `digest` is what the digest worker renders.
// Two keys because they are two different messages — a template written for
// one post renders a day of them as a single missing variable.
template_keys: { email: 'notify.team-post', digest: 'notify.digest' },
// No cooldown. A busy thread is exactly what the per-user `email_mode` and
// the digest option are for, and a cooldown here would silently drop the
// second reply of a conversation rather than batching it.
cooldown_seconds: 0,
max_sends_per_hour: 500,
},
{
trigger_id: 'team.announcement',
name: 'Team announcements',
audience: 'members',
channels: ['email'],
// **The generic body, not `notify.team-post`, and the reason is a naming
// inconsistency in the Phase 2 declarations rather than a design choice
// here.** The two triggers describe the same underlying thing — a thread in a
// Team forum — but `team.forum.post` declares its title as `threadTitle` and
// `team.announcement` declares it as `title`. A template can only name one of
// them, so `notify.team-post`'s `{{threadTitle}}` renders empty for an
// announcement. `notify.event` + the structural projection gets it right
// (`title` is in the payload, `actionUrl` falls back to `postUrl`), and
// reconciling the two declarations is a version bump this phase did not take
// on its own authority.
template_keys: { email: 'notify.event', digest: 'notify.digest' },
cooldown_seconds: 0,
max_sends_per_hour: 500,
},
{
trigger_id: 'team.member.joined',
name: 'Team — new member',
audience: 'members',
channels: ['email'],
// The generic body: `notify.event` plus the structural projection renders it
// with no authoring (§4.6.1 property 1). A deployment that wants a better one
// duplicates the template and points this rule at the copy.
template_keys: { email: 'notify.event' },
// An hour, per user per Team. This is the rule §6.4 argued should not exist
// as a sink at all — a fifteen-minute sweep, already on the activity feed —
// and the cooldown is what makes it survivable for the operator who wants it
// anyway: a guild recruiting ten people in an afternoon sends one mail.
cooldown_seconds: 3600,
max_sends_per_hour: 200,
},
{
trigger_id: 'team.leadership.changed',
name: 'Team — leadership change',
audience: 'members',
channels: ['email'],
template_keys: { email: 'notify.event' },
cooldown_seconds: 3600,
max_sends_per_hour: 200,
},
]
// Phase 11's one rule, in its own list so it can carry its own one-shot key.
const NEWS_RULES = [
{
trigger_id: 'news.post',
name: 'News posts',
// `subscribers`, which is the trigger's declared default and the population
// `pushDispatch.publish('news.post', …)` used to reach directly: users who
// opted into this id on at least one channel. Not `authenticated`, even
// though the trigger's ceiling permits it — a news post is worth telling
// people who asked to be told, and mailing the whole user table on every
// publish is how a notification feature earns a spam complaint.
audience: 'subscribers',
// **All three channels, unlike the Team rules' `email` alone**, and that is
// the continuity half of §7.1 Q9's answer. Push is on this rule because push
// is what the raw tickle did; leaving it off would mean an operator who
// enabled the rule to restore news push got mail instead. In-app rides along
// because the inbox is the surface a tickle deep-links into (Phase 7).
channels: ['email', 'inapp', 'push'],
// The generic body plus the structural projection (§4.6.1 property 1):
// `news.post` declares its own `title` and `postUrl`, which the projection
// leaves exactly as emitted, so an unauthored mail already names the post and
// links it. `inapp.event` is the in-app renderer's; push carries no content
// by construction and needs no template.
template_keys: { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' },
// An hour, per USER — `news.post` declares no `subjectKey`, so the cooldown
// subject is the recipient. "Do not tell me about news more than once an
// hour" is the useful rule; keying it per post would make it a no-op, since
// every post is a new subject.
cooldown_seconds: 3600,
max_sends_per_hour: 1000,
},
]
/**
* Seed one group of rules, once, under its own guard key.
*
* Never throws: it is on the boot path beside `seedTemplates`, and a rule that
* failed to seed costs an operator one visit to the "new rule" form, not a
* deployment.
*
* @param {string} key the one-shot settings guard for THIS group
* @param {object[]} rules
* @param {string} note what the boot log should say when it inserts
*/
async function seedGroup(key, rules, note) {
const summary = { inserted: 0, skipped: 0 }
try {
const seen = await settingsDb.get(key)
if (seen) return { ...summary, skipped: rules.length }
for (const rule of rules) {
try {
await rulesDb.insert({
audience_segment_id: null,
conditions: null,
// No delay and nothing cancels these. `delay_seconds` is the grace
// window a cancelling event needs, and nothing cancels "someone
// posted" — the post happened.
delay_seconds: 0,
cancel_on: [],
...rule,
enabled: 0,
updated_by: null,
})
summary.inserted += 1
} catch (err) {
log.error('team rule seed failed', { trigger: rule.trigger_id, message: err.message })
}
}
// Stamped even on a partial run. Re-running would duplicate the rules that
// did insert, and a duplicate rule is two mails per event — a worse outcome
// than the one missing rule an operator can add from the screen.
await settingsDb.set(key, new Date().toISOString())
if (summary.inserted) {
log.info('seeded engagement rules, all disabled', { rules: summary.inserted, note })
}
} catch (err) {
log.error('rule seeding failed', { key, message: err.message })
}
return summary
}
/** The four Team rules (Phase 6). */
const seedTeamRules = () =>
seedGroup(SEEDED_KEY, RULES, 'Team email stays off until an operator enables one')
/** The one news rule (Phase 11). */
const seedNewsRule = () =>
seedGroup(NEWS_SEEDED_KEY, NEWS_RULES, 'News notifications stay off until an operator enables this rule')
/**
* Both groups, which is what the boot path calls.
*
* Sequential rather than concurrent, and not for correctness — each group has its
* own guard key and its own rows — but so the boot log reads in a fixed order and
* a failure names one group rather than an interleaving of two.
*/
async function seedCoreRules() {
const team = await seedTeamRules()
const news = await seedNewsRule()
return {
inserted: team.inserted + news.inserted,
skipped: team.skipped + news.skipped,
}
}
module.exports = {
seedCoreRules,
seedTeamRules,
seedNewsRule,
RULES,
NEWS_RULES,
SEEDED_KEY,
NEWS_SEEDED_KEY,
}

View File

@@ -0,0 +1,64 @@
// ── Core's own scope-preference provider: Teams ────────────────────────────
//
// ENGAGEMENT.md Phase 6, decision 4. `team_notification_prefs` stays exactly
// where it is and keeps exactly the meaning it has had since Teams shipped; this
// is the adapter that lets the generic engine read it without knowing what a Team
// is. Registered here rather than at the bottom of `scopedPrefs.js` for the same
// reason `coreChannels` and `transports/smtp` are: requiring a registry must not
// have the side effect of populating it.
//
// **The two columns say different things and the mapping is not symmetric.**
//
// - `muted` is the Team's master switch and it silences EVERY channel. That is
// what the toggle has always meant on the account screen ("mute this Team"),
// and narrowing it to email would be a behaviour change nobody asked for. Note
// this is belt-and-braces on the live path — `teamNotify.recipientIds` already
// excludes muted users before the event is emitted — and it is here anyway so
// the meaning survives an emitter that stops filtering.
// - `email_mode` says nothing about any other channel, so on push or in-app this
// provider returns no opinion and the stream-level preference decides.
//
// **Absence of a row means 'off' for email, and that is the whole reason this
// provider answers for every user rather than only for the rows it finds.** The
// column defaults to `'off'` and both recipient queries COALESCE to it: no row
// has always meant "this person has not asked for Team email". Deferring to the
// stream-level preference instead would mean a user who once switched on
// `team.forum.post` email in the channels screen starts receiving mail from every
// Team on the deployment — a widening, produced by a migration, of a preference
// they expressed about something else.
const { registerScopePreference } = require('./scopedPrefs')
const teamNotify = require('../model/teams/teamNotify.model')
// team_notification_prefs.email_mode → the three modes the engine speaks. The
// vocabularies differ by one word and only one word: 'immediate' predates
// `notification_channel_prefs`, whose ENUM says 'instant'.
const EMAIL_MODE = { off: 'off', immediate: 'instant', digest: 'digest' }
async function modesFor(userIds, channel, scopeId) {
const teamId = Number(scopeId)
if (!Number.isInteger(teamId) || teamId < 1) return new Map()
const rows = await teamNotify.prefsForTeam(userIds, teamId)
const byUser = new Map(rows.map((r) => [Number(r.user_id), r]))
const modes = new Map()
for (const userId of userIds) {
const row = byUser.get(Number(userId))
if (row && Number(row.muted)) {
modes.set(Number(userId), 'off')
continue
}
if (channel !== 'email') continue // no opinion; the stream preference decides
modes.set(Number(userId), EMAIL_MODE[(row && row.email_mode) || 'off'] || 'off')
}
return modes
}
registerScopePreference({
prefix: 'team',
label: 'Team',
modesFor,
})
module.exports = { modesFor, EMAIL_MODE }

View File

@@ -0,0 +1,241 @@
// ── The email DeliveryChannel: addressFor + deliver ────────────────────────
//
// ENGAGEMENT.md Phase 6. Phase 3 declared this channel and deliberately left it
// behaviourless ("declaring a function nothing calls freezes a signature before
// anything has tried to use it"); this is the phase that has something to try it
// with, and the signature survived unchanged.
//
// **What it does is four lookups and one send**, and the order matters because
// each step is a way the mail should not go out:
//
// 1. the address — re-checked for `status = 'active'`, because a delayed
// row can outlive the account it was queued for
// 2. the rule — for its per-channel template key; the outbox row
// carries `rule_id` and FK CASCADE guarantees it exists
// 3. the values — the payload snapshot, plus §4.6.1's structural
// projection, plus this recipient's unsubscribe link
// 4. the template — `renderByKey`, which falls back to the shipped seed
// rather than failing, and refuses a draft
// 5. the send — `mailer.sendNotification`, which classifies rather
// than throwing
//
// **It never throws**, and that is a stronger statement than the worker's
// `try/catch` around it: a throw would be read as a transient failure and retried
// five times, so an unrenderable template would become five identical failures in
// the send log instead of one honest terminal row.
//
// **The unsubscribe link is per recipient and is built from `scope_key`, never
// from `subject_key`.** They differ for every Team event: the subject is
// `teamName` (a display string the cooldown keys on) and the scope is `team:12`.
// A Team renamed between the mail and the click must not orphan the link in it.
const rulesDb = require('../model/engagement/engagementRules.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const templates = require('./templates')
const projection = require('./projection')
const suppressions = require('./suppressions')
const settings = require('../model/settings/settings.model')
const unsubscribeToken = require('../utils/unsubscribeToken')
const log = require('../utils/logger')('engagement')
// **Required lazily, and it is a real cycle rather than a style preference.**
// `engagement/index.js` requires `coreChannels`, which requires this file; and
// `utils/mailer` requires `engagement/index` for the transport registry. A
// top-level `require('../utils/mailer')` here therefore resolves while
// `engagement/index` is mid-evaluation, so mailer would capture `{}` for
// `transports` and every send would fail on `transports.get is not a function` —
// at send time, on a deployment, with the boot log clean. Resolved at call time
// instead, by which point both modules are fully evaluated.
const mailer = () => require('../utils/mailer')
// The template a rule renders through when it names none. §4.6.1 property 1: a
// new trigger must be mailable with no authoring at all, and this plus
// `projection.project` is that property's implementation.
const DEFAULT_TEMPLATE = 'notify.event'
const baseUrl = () => templates.baseUrl()
// Re-exported rather than defined here since Phase 9: the send log's hash and
// the suppression list's key have to be the same function or a bounce never finds
// the row it belongs to. `suppressions.js` owns it, next to the masking.
const hashAddress = suppressions.hashAddress
/**
* The two unsubscribe URLs for one recipient of one scope, or nulls.
*
* TWO urls from one token, and they are not interchangeable. `unsubscribeUrl` is
* the human one that goes in the mail body: the site's own page, which explains
* what is about to happen and POSTs once a person has read it. `unsubscribeApiUrl`
* is the machine one for the `List-Unsubscribe` header, where RFC 8058 says a
* client may POST without showing anybody anything — so it has to be an endpoint,
* not a page. The API route answers GET on the same path with a redirect to the
* page, which covers clients that render the header as an ordinary link.
*
* A scope the token format cannot carry yields nulls rather than an exception:
* the mail is worth sending without a one-click unsubscribe, and the recipient
* still has the preferences screen. It is logged because it is a programming
* error in whatever chose the scope key.
*/
function unsubscribeUrls(userId, scopeKey) {
try {
const token = unsubscribeToken.sign(userId, 'email', scopeKey || '')
const base = baseUrl()
return {
unsubscribeUrl: `${base}/unsubscribe/${token}`,
unsubscribeApiUrl: `${base}/api/v1/public/engagement/unsubscribe/${token}`,
}
} catch (err) {
log.warn('could not build an unsubscribe link', { scope: scopeKey, message: err.message })
return { unsubscribeUrl: null, unsubscribeApiUrl: null }
}
}
/** Where this channel would send to, or null. */
const addressFor = (userId) => recipients.addressFor(userId)
/**
* Narrow an audience to the users this channel may write an outbox row for
* (Phase 9, decision 4).
*
* **One gate, and it is the Phase 1b verification setting.** With
* `email_verification_required` on, a user whose address is unverified is
* excluded here rather than refused at delivery, and the org lead settled it that
* way for two reasons. It is a STANDING property — unlike a suppression, which
* can appear inside a `delay_seconds` window and therefore has to be re-checked
* at send time — so the outbox row would be written only to be thrown away. And a
* deployment that upgraded before verifying anybody has an audience that is
* almost entirely unverified: excluding at delivery would write a `suppressed`
* row per person per rule firing, which is a send log nobody can read.
*
* The count comes back so the admin reach preview can say "1,204 excluded:
* unverified" instead of quietly promising a number the engine will not deliver.
*
* **It fails OPEN, and the try/catch is load-bearing rather than defensive
* habit.** `settings.isEmailVerificationRequired` swallows its own errors and
* answers `off`, but `unverifiedAmong` does not, and an uncaught throw here does
* not fail one recipient — `applyRule` awaits this before the per-user loop, so
* it would abandon the whole rule for every channel it names. A database having
* a bad minute would become a rule that silently sent nothing, with a clean send
* log and nothing in the outbox to retry. Same direction as the suppression
* check, for the same reason: the recoverable mistake is mail going out, not mail
* silently stopping.
*/
async function eligible(userIds) {
const list = userIds || []
if (!list.length) return { userIds: [], excluded: {} }
try {
if (!(await settings.isEmailVerificationRequired())) {
return { userIds: list.slice(), excluded: {} }
}
const unverified = await recipients.unverifiedAmong(list)
if (!unverified.size) return { userIds: list.slice(), excluded: {} }
return {
userIds: list.filter((id) => !unverified.has(Number(id))),
excluded: { unverified: unverified.size },
}
} catch (err) {
log.error('verification gate could not be evaluated; not excluding anyone', { message: err.message })
return { userIds: list.slice(), excluded: {} }
}
}
/**
* Deliver one claimed outbox row.
*
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
*/
async function deliver(row) {
try {
const to = await addressFor(row.user_id)
if (!to) {
// Terminal. Retrying does not give somebody an address, and a banned
// account is not going to be un-banned by a five-minute backoff.
return { ok: false, detail: 'no deliverable address for this user' }
}
const rule = await rulesDb.getById(row.rule_id)
const key = (rule && rule.template_keys && rule.template_keys.email) || DEFAULT_TEMPLATE
// Once, not once per use: the body's link and the header's must be the same
// token, or a client that offers both offers two different unsubscribes.
const unsub = unsubscribeUrls(row.user_id, row.scope_key)
const values = projection.project(row.trigger_id, row.payload || {}, unsub)
const rendered = await templates.renderByKey(key, values)
if (!rendered) {
// Neither a usable row nor a shipped seed. Terminal, and it names the key:
// the operator deleted a template a rule points at, which the admin surface
// refuses with a 409 — so reaching here means it happened out of band.
return { ok: false, detail: `no template and no shipped default for "${key}"` }
}
if (rendered.missing.length) {
// Not a refusal: an optional variable a trigger chose not to supply renders
// as nothing by design. Logged with NAMES ONLY, never values — the same
// rule the emit and dispatch log lines follow.
log.debug('template variables had no value', { key, missing: rendered.missing })
}
// **The suppression check is HERE and not at enqueue** (Phase 9). An outbox
// row can sit through a `delay_seconds` grace window, and an address can hard
// bounce inside it — so the only check that can be correct is the one taken
// immediately before the transport call. It is also the check the acceptance
// criterion describes: a `suppressed` row in the send log, and no transport
// call at all.
const blocked = await suppressions.isSuppressed(to.address)
if (blocked) {
return {
ok: false,
suppressed: true,
detail: `address is suppressed (${blocked.reason})`,
addressHash: hashAddress(to.address),
}
}
const result = await mailer().sendNotification({ to: to.address, rendered, ...unsub })
// A failed send is where a hard bounce enters the system on SMTP, and the
// reason it is worth catching rather than waiting for an API provider: a
// single-recipient send refused at RCPT TO is a synchronous 5.1.1, which is
// the most valuable deliverability signal there is and it was already being
// thrown away. `considerFailure` is narrow — see bounceClassify.js — and its
// note goes into the log on BOTH outcomes, so "this failed and was not
// suppressed" says why.
if (result && !result.ok && result.smtp) {
const verdict = await suppressions.considerFailure({ address: to.address, error: result.smtp })
// A bounce is terminal by definition. Overriding `retry` matters because
// `PERMANENT_CODES` does not contain every code that can carry a 5.1.x, so
// without this a genuine dead mailbox could still be retried four more
// times — each one another refusal on our record with the relay.
if (verdict.suppressed) {
return {
ok: false,
retry: false,
// `engagement_sends.status` has carried 'bounced' since §4.5 and
// nothing wrote it until here, so the Send Log's "Bounced" filter
// matched nothing — the live rig is what showed that. It is a distinct
// status rather than a flavour of 'failed' because the two need
// different actions: a failure means look at the relay, and a bounce
// means that person's address is gone.
bounced: true,
transport: result.transport,
detail: `${result.detail} — ${verdict.note}`,
addressHash: hashAddress(to.address),
}
}
return { ...result, detail: `${result.detail} — ${verdict.note}`, addressHash: hashAddress(to.address) }
}
// The send log stores a sha256 of the address and never the address itself
// (schema.sql): enough to correlate a bounce, useless as a mailing list.
// Attached on every outcome, because a failure is exactly the row a bounce
// would need to be matched against.
return { ...result, addressHash: hashAddress(to.address) }
} catch (err) {
// See the header: a throw here would be retried as if it were the relay's
// fault. Classified as terminal instead, with the reason in the send log.
log.error('email delivery failed', { outbox: row.id, message: err.message })
return { ok: false, detail: `delivery error: ${err.message}` }
}
}
module.exports = { addressFor, eligible, deliver, unsubscribeUrls, hashAddress, DEFAULT_TEMPLATE }

View File

@@ -0,0 +1,319 @@
// ── The engagement engine ──────────────────────────────────────────────────
//
// ENGAGEMENT.md Phase 4a. `ctx.events.emit` validated a payload against a
// declaration and stopped (Phase 2); this is what it now hands the validated
// event to. The engine's whole job is to answer, for one event, **who gets told,
// on what, and not too often** - and then to write that down as outbox rows.
// It never delivers: `engagementWorker` drains the outbox, and what actually
// carries a message arrives with the channels' `deliver` in Phases 6 and 7.
//
// **The order of the gates is the design, and each one is here because skipping
// it is a way to mail the wrong people or too many of them:**
//
// 1. enabled rules for this trigger - nothing is seeded, nothing is on by default
// 2. conditions - is this particular firing interesting
// 3. audience -> user ids - core's tables, or a composed segment
// 4. ceiling re-check (G24) - re-run at SEND time, not only at save
// 5. per-channel preference - a user's own opt-in, effective mode
// 6. per-rule hourly ceiling (§7.1 Q3) - the hard stop that makes rules-as-data safe
// 7. cooldown, per (rule, user, subject) - one statement, so two emits cannot race
// 8. enqueue, deduped - a replayed event is one row, not two
//
// Steps 6 and 7 are in that order deliberately. The hourly ceiling is about the
// RULE and is the thing that stops a mail storm; the cooldown is about one
// recipient and one subject. Checking the cheap global bound before consuming a
// per-recipient cooldown slot means a rule that has hit its ceiling does not also
// silently burn everybody's cooldowns on sends that never happen.
//
// **Nothing here throws at its caller.** It is invoked from inside a game-event
// handler by way of `ctx.events.emit`, and a database problem of core's must not
// become a module's control flow (the same posture the emit validator takes).
const rulesDb = require('../model/engagement/engagementRules.db')
const outboxDb = require('../model/engagement/engagementOutbox.db')
const cooldownsDb = require('../model/engagement/engagementCooldowns.db')
const sendsDb = require('../model/engagement/engagementSends.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const conditions = require('./conditions')
const audiences = require('./audiences')
const channels = require('./channels')
const scopedPrefs = require('./scopedPrefs')
const log = require('../utils/logger')('engagement')
const HOUR_MS = 60 * 60 * 1000
// Channels one of whose payloads can REFERENCE another's result, earliest first
// (Phase 7). Only one pair qualifies today: a push tickle's `ref` deep-links to
// the inbox row `inapp` writes, and the outbox is swept `ORDER BY due_at, id`,
// so enqueueing in-app first is what makes that ref resolve on the first pass
// rather than on a retry. Everything not named here keeps the operator's own
// order, which is the order the rules screen shows.
//
// It is an ordering, not a dependency: `pushChannel` treats a missing ref as
// null and the app pulls regardless, so a rule that names only push, or a row
// that gets retried out of sequence, is still correct.
const CHANNEL_ORDER = ['inapp']
const channelRank = (id) => {
const i = CHANNEL_ORDER.indexOf(id)
return i === -1 ? CHANNEL_ORDER.length : i
}
/**
* Which of a rule's channels are actually deliverable right now?
*
* A rule stores channel ids as data (`channels JSON`), so it can name one whose
* module has been removed since. An unregistered channel is dropped rather than
* failing the rule: the other channels of that rule are still correct, and a
* dropped one is visible in the log line below.
*/
const liveChannels = (rule) =>
(rule.channels || [])
.filter((c) => channels.has(c))
.sort((a, b) => channelRank(a) - channelRank(b))
/**
* The EFFECTIVE mode each candidate holds for (id, channel), given the event's
* scope.
*
* Effective, not stored: a row exists only where a user has expressed something,
* and absence means the channel's `defaultMode` (§3.1). Reading the stored rows
* and applying the default here keeps that answer in the registry, which is the
* invariant Phase 3 established.
*
* **A scoped preference wins outright where one exists** (Phase 6, decision 4).
* `team_notification_prefs` stayed where it is and `scopedPrefs` is the adapter;
* for a Team-scoped event that table is the preference, exactly as it has been
* since Teams shipped. The argument for replacing rather than intersecting is in
* scopedPrefs.js's header, and it is short: intersecting would have silenced
* every existing Team-email subscriber on the deploy that migrated them.
*/
async function effectiveModes(userIds, streamId, channel, scopeKey) {
const modes = new Map()
if (!userIds.length) return modes
const scoped = await scopedPrefs.resolve(userIds, channel, scopeKey)
const stored = await recipients.storedModes(userIds, streamId, channel)
const fallback = channels.defaultMode(channel)
for (const id of userIds) modes.set(id, scoped.get(id) ?? stored.get(id) ?? fallback)
return modes
}
/**
* The candidates who should get an OUTBOX ROW for this channel.
*
* `off` is excluded for the obvious reason. **`digest` is excluded too, and that
* corrects what Phase 4a said here** — its comment read "a 'digest' preference is
* kept, not dropped… what changes in Phase 6 is who drains it", and what changed
* in Phase 6 is that nothing drains it. §4.2b keeps `teamDigestWorker`'s
* compute-at-send-time design, so a digest is re-derived from the source tables
* when it goes out, not assembled from snapshots taken hours earlier. An outbox
* row for a digest recipient would be a second copy of the content with none of
* the three properties that design exists for — most importantly, it would mail
* a user who lost access between the post and the send.
*/
async function subscribedTo(userIds, streamId, channel, scopeKey = null) {
const modes = await effectiveModes(userIds, streamId, channel, scopeKey)
return userIds.filter((id) => modes.get(id) === 'instant')
}
/**
* Run one rule against one event. Returns a small summary, for the log line and
* for tests; it is not read by the caller for control flow.
*/
async function applyRule(rule, event, now) {
const summary = {
ruleId: rule.id,
enqueued: 0,
deduped: 0,
cooled: 0,
capped: 0,
// Phase 9: people a CHANNEL refused to enqueue for, by reason. Counted
// separately from `cooled` and `capped` because those are the engine holding
// a message back and this is a channel saying it cannot carry one at all.
ineligible: {},
skipped: null,
}
if (!conditions.evaluate(rule.conditions, event.data)) {
summary.skipped = 'conditions'
return summary
}
const resolved = await audiences.resolveForRule(rule, event)
if (resolved.dormant) {
summary.skipped = resolved.reason || 'dormant'
return summary
}
if (!resolved.userIds.length) {
summary.skipped = resolved.reason || 'empty audience'
return summary
}
// G24, re-run at send time. A rule saved when its trigger permitted a wider
// audience must not keep reaching it after a module upgrade narrowed the
// declaration - and that is the only way this can fail, since the save path
// ran the same check.
if (!audiences.permitted(event.triggerId, resolved.ceiling)) {
log.warn('rule audience exceeds its trigger ceiling - refusing', {
rule: rule.id,
trigger: event.triggerId,
audience: resolved.ceiling,
})
summary.skipped = 'ceiling'
return summary
}
const live = liveChannels(rule)
if (!live.length) {
summary.skipped = 'no registered channel'
return summary
}
// The per-rule hourly ceiling (§7.1 Q3). Counted once for the whole event
// rather than per channel: an operator setting "100 an hour" means a hundred
// messages, not a hundred per channel per event.
const sentThisHour = await sendsDb.countSentSince(rule.id, new Date(now.getTime() - HOUR_MS))
let budget = Math.max(0, rule.max_sends_per_hour - sentThisHour)
if (budget === 0) {
log.warn('rule is at its hourly send ceiling', {
rule: rule.id,
trigger: event.triggerId,
ceiling: rule.max_sends_per_hour,
})
summary.skipped = 'hourly ceiling'
return summary
}
const subjectKey = (event.subject ?? '').toString().slice(0, 190)
const dueAt = new Date(now.getTime() + Math.max(0, rule.delay_seconds) * 1000)
for (const channel of live) {
// Phase 9, and it runs BEFORE the preference filter rather than after. Both
// orders reach the same recipients; this one costs one query against the
// narrower set only when the channel declares an `eligible` at all, and it
// means `summary.ineligible` counts people the CHANNEL cannot reach rather
// than people who happened to also be opted in. Channels that declare none —
// push and in-app — pass straight through.
const gated = await channels.eligibleFor(channel, resolved.userIds)
for (const [why, n] of Object.entries(gated.excluded)) {
summary.ineligible[why] = (summary.ineligible[why] || 0) + n
}
const eligible = await subscribedTo(gated.userIds, event.triggerId, channel, event.scopeKey)
for (const userId of eligible) {
if (budget <= 0) {
summary.capped += 1
continue
}
// One statement, guarded on the interval, so two concurrent emits cannot
// both pass a read-then-write check (§4.1).
const allowed = await cooldownsDb.claim(rule.id, userId, subjectKey, rule.cooldown_seconds, now)
if (!allowed) {
summary.cooled += 1
continue
}
const id = await outboxDb.enqueue({
rule_id: rule.id,
trigger_id: event.triggerId,
user_id: userId,
channel,
subject_key: subjectKey,
// The scope a PREFERENCE and an UNSUBSCRIBE are keyed on, which is not
// `subject_key`: for the Team triggers the subject is `teamName` (what a
// cooldown counts) and the scope is `team:12` (what survives a rename).
scope_key: event.scopeKey ?? null,
payload: event.data,
// Scoped per (rule, user, channel) by the unique index, so one event
// fanned out to fifty people is fifty rows carrying the same key.
dedupe_key: event.dedupeKey,
due_at: dueAt,
})
if (id === null) summary.deduped += 1
else {
summary.enqueued += 1
budget -= 1
}
}
}
return summary
}
/**
* Cancel pending rows that this event resolves (§4.2a).
*
* This is the actual point of `delay_seconds`: without cancellation a delay is
* just a late mail. A house repaired back to LikeNew fires a trigger that some
* rule names in its `cancel_on`, and every still-scheduled row for that
* (rule, subject) stops.
*
* When the resolving event names an owner, only that user's rows are cancelled;
* when it does not, every user queued about that subject is - which is the
* house-repaired case, where the event is about the house and not about any one
* of the people who were going to be told.
*/
async function applyCancellations(event, summary) {
const rules = await rulesDb.enabledCancelledBy(event.triggerId)
if (!rules.length) return
const subjectKey = (event.subject ?? '').toString().slice(0, 190)
for (const rule of rules) {
const n = await outboxDb.cancel(rule.id, subjectKey, event.ownerUserId || null)
if (n) {
summary.cancelled += n
log.info('cancelled scheduled sends', {
rule: rule.id,
by: event.triggerId,
subject: subjectKey,
rows: n,
})
}
}
}
/**
* Dispatch one validated event. Called by `engagementEmit.emit` after the payload
* has been checked against the declaration.
*
* @param {object} event the envelope `engagementEmit` built
* @returns {Promise<{ rules: number, enqueued: number, cancelled: number }>}
*/
async function dispatch(event, now = new Date()) {
const summary = { rules: 0, enqueued: 0, deduped: 0, cooled: 0, capped: 0, cancelled: 0 }
try {
const rules = await rulesDb.enabledForTrigger(event.triggerId)
summary.rules = rules.length
for (const rule of rules) {
const result = await applyRule(rule, event, now)
summary.enqueued += result.enqueued
summary.deduped += result.deduped
summary.cooled += result.cooled
summary.capped += result.capped
}
await applyCancellations(event, summary)
// Keys and counts, never values - the same rule the emit log line follows.
// A payload carries player names, house locations and forum excerpts, and a
// log that reproduces them is a second copy of exactly the content
// `engagement_sends` is careful to keep out of the database.
if (summary.rules || summary.cancelled) {
log.info('event dispatched', { trigger: event.triggerId, ...summary })
}
} catch (err) {
// A database problem of core's must not become the module's control flow at
// three in the morning. The emit already succeeded as a contract; what failed
// is delivery, and it is logged as core's failure.
log.error('dispatch failed', { trigger: event.triggerId, message: err.message })
}
return summary
}
module.exports = {
dispatch,
applyRule,
applyCancellations,
subscribedTo,
effectiveModes,
liveChannels,
CHANNEL_ORDER,
HOUR_MS,
}

View File

@@ -0,0 +1,204 @@
// ── The in-app DeliveryChannel: addressFor + deliver ───────────────────────
//
// ENGAGEMENT.md Phase 7. The third channel to get behaviour, and the one whose
// "address" is not an address at all: the destination is the user's own row in
// this deployment's own table. `addressFor` still exists and still answers null,
// because the question it asks — *can this channel reach this user right now* —
// has a real answer here, and it is the same answer email's has: not if the
// account is no longer active. An outbox row can sit through a `delay_seconds`
// grace window, so a user banned between the emit and the send is exactly the
// case this catches.
//
// **What makes it different from email is what it does NOT have to do.** There
// is no transport, no relay to classify a failure for us, no unsubscribe link to
// mint per recipient, and no address to hash — an inbox item is addressed to a
// user id, and `engagement_sends.address_hash` exists to correlate a bounce that
// this channel cannot have. So `deliver` is two steps: render the template into
// the three columns, and insert.
//
// **It never throws**, for the reason `emailChannel` states: the worker reads a
// throw as a transient failure and retries five times, so an unrenderable
// template would become five identical failures in the send log instead of one
// honest terminal row.
//
// **A duplicate `dedupe_key` reports success.** The acceptance line calls it a
// no-op; from the recipient's side it is a delivery — they have the item — and
// recording `failed` for it would put a red row in the send log for the
// mechanism working exactly as designed. The detail says which it was.
const rulesDb = require('../model/engagement/engagementRules.db')
const registries = require('../modules/registries')
const channelRegistry = require('./channels')
const inbox = require('../model/userNotifications/userNotifications.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const templates = require('./templates')
const projection = require('./projection')
const log = require('../utils/logger')('engagement')
// The template a rule renders through when it names none — §4.6.1 property 1's
// implementation for this channel, exactly as `notify.event` is for email.
const DEFAULT_TEMPLATE = 'inapp.event'
/**
* Can this channel reach `userId`?
*
* Returns the shape every `addressFor` returns rather than a boolean, so the
* registry's contract stays one contract. The "address" is the user id as a
* string, which is the honest answer: this channel's destination is an account,
* and there is nothing else to name.
*/
const addressFor = async (userId) => {
const active = await recipients.filterActive([userId])
return active.length ? { address: String(active[0]) } : null
}
/**
* Render one event into an inbox item. Shared with `ctx.inbox.push`'s rule-less
* path only in spirit — that one is handed its title and body by the module and
* renders nothing.
*/
async function renderItem(triggerId, payload, templateKey) {
const values = projection.project(triggerId, payload || {})
const rendered = await templates.renderInappByKey(templateKey, values)
if (!rendered) return null
if (rendered.missing.length) {
// Names only, never values — the rule every log line in this subsystem
// follows. An optional variable a trigger chose not to supply renders as
// nothing by design, so this is debug rather than a warning.
log.debug('template variables had no value', { key: templateKey, missing: rendered.missing })
}
return rendered
}
/**
* Deliver one claimed outbox row.
*
* @returns {Promise<{ok: boolean, retry?: boolean, detail?: string}>}
*/
async function deliver(row) {
try {
if (!(await addressFor(row.user_id))) {
// Terminal. A five-minute backoff does not un-ban an account, and writing
// the item anyway would put content in the inbox of somebody who is no
// longer allowed to open it.
return { ok: false, detail: 'this user can no longer be reached' }
}
const rule = await rulesDb.getById(row.rule_id)
const key = (rule && rule.template_keys && rule.template_keys.inapp) || DEFAULT_TEMPLATE
const rendered = await renderItem(row.trigger_id, row.payload, key)
if (!rendered) {
// Neither a usable row nor a shipped seed: the operator deleted a template
// a rule points at, which the admin surface refuses with a 409, so reaching
// here means it happened out of band. Terminal, and it names the key.
return { ok: false, detail: `no template and no shipped default for "${key}"` }
}
const { inserted } = await inbox.insert({
userId: row.user_id,
triggerId: row.trigger_id,
title: rendered.title,
body: rendered.body,
url: rendered.url,
dedupeKey: row.dedupe_key || null,
})
// `transport` is left absent rather than invented. The column means "which
// implementation of this channel delivered it", and this channel has one
// sink by construction — a value there would be a name nothing else uses.
return inserted
? { ok: true }
: { ok: true, detail: 'already in this inbox (duplicate dedupe key)' }
} catch (err) {
log.error('in-app delivery failed', { outbox: row.id, message: err.message })
return { ok: false, detail: `delivery error: ${err.message}` }
}
}
// ── The rule-less sink: ctx.inbox.push (§5.1) ──────────────────────────────
//
// A module writing the inbox directly, with no trigger declaration to project
// from, no rule to pick a template, and no audience to resolve. It exists for
// the cases a rule cannot express — something that concerns exactly one person
// and needs no operator configuration to be worth telling them about.
//
// **It respects the user's in-app preference where there is one to respect**
// (settled by the org lead 2026-08-31). If `triggerId` names a REGISTERED
// trigger, the user's effective mode for it decides, and 'off' drops the write:
// a toggle somebody switched off on the preferences screen must not be walkable
// around by the module that owns the trigger behind it. If it names nothing
// registered there is no toggle, nothing on any screen to have switched off, and
// the item is written — refusing it would make the sink useless for the one job
// it has while protecting a preference that does not exist.
//
// Scoped preferences are deliberately not consulted: a scope is a property of an
// EVENT (`team:12`), and a caller with no trigger declaration has no scope to
// name. The engine's path, which does, still applies them.
//
// Fire-and-forget, never throws, never rejects — `ctx.teams.activity.push`'s
// posture, for its reason: this is called from inside a game-event handler and a
// storage problem of core's must not become the module's control flow.
// user_notifications.title. Truncated rather than refused: a module that built a
// long title has still said something worth showing.
const MAX_TITLE = 300
// user_notifications.body is TEXT; this is a sanity bound, not the column's.
const MAX_BODY = 4000
/**
* Write one item on a module's behalf.
*
* @param {string} moduleId bound by the loader, never taken from the arguments
* @param {number} userId
* @param {{triggerId: string, title: string, body?: string, url?: string, dedupeKey?: string}} item
* @returns {Promise<{written: boolean, reason?: string}>} for tests; the loader
* discards it, because a module has nothing correct to do with it.
*/
async function pushDirect(moduleId, userId, item = {}) {
try {
const uid = Number(userId)
if (!Number.isInteger(uid) || uid <= 0) return { written: false, reason: 'invalid user id' }
const triggerId = String(item.triggerId || '').trim()
const title = String(item.title || '').trim().slice(0, MAX_TITLE)
if (!triggerId || !title) return { written: false, reason: 'triggerId and title are required' }
// The declaration is consulted for ONE thing — whether a preference for this
// id exists — and not to validate a payload: there is no payload here, only
// the three strings the module composed itself.
if (registries.eventTrigger(triggerId)) {
const stored = await recipients.storedModes([uid], triggerId, 'inapp')
const mode = stored.get(uid) ?? channelRegistry.defaultMode('inapp')
if (mode !== 'instant') return { written: false, reason: 'the user has this switched off' }
}
if (!(await addressFor(uid))) return { written: false, reason: 'this user can no longer be reached' }
// Same relative-only rule the rendered path applies, and for the same reason:
// this string ends up in an href on a page a signed-in user is looking at.
const url = item.url ? templates.relativeUrl(item.url, templates.baseUrl()) : null
if (item.url && !url) {
log.warn('ctx.inbox.push dropped an off-site url', { module: moduleId, trigger: triggerId })
}
const body = item.body ? String(item.body).slice(0, MAX_BODY) : null
const { inserted } = await inbox.insert({
userId: uid,
triggerId,
title,
// A module supplies data, never markup (§4.6.2's security posture). The
// body is stored as the text it claims to be and every surface renders it
// as text, so there is no markup to sanitize and none to be trusted.
body,
url,
dedupeKey: item.dedupeKey ? String(item.dedupeKey).slice(0, 190) : null,
})
return { written: inserted, reason: inserted ? undefined : 'duplicate dedupe key' }
} catch (err) {
log.error('ctx.inbox.push failed', { module: moduleId, message: err.message })
return { written: false, reason: err.message }
}
}
module.exports = { addressFor, deliver, renderItem, pushDirect, DEFAULT_TEMPLATE }

View File

@@ -0,0 +1,28 @@
// ── The engagement subsystem — one door ────────────────────────────────────
//
// ENGAGEMENT.md Phases 1 and 3. Today this is the mail transport registry, core's
// own transports, and the delivery-channel registry with core's three channels;
// the rules engine and the render/deliver half of a channel arrive in later
// phases and hang here too. (The trigger registry lives in `modules/registries.js`
// instead, because a trigger is something a MODULE declares and modules only ever
// see one registration door.)
//
// **Core's transports register through the same door a module's would**, and
// they register HERE rather than at the bottom of the registry file. That keeps
// the registry free of any knowledge of its registrants — the same reason
// `registerCore()` is called from app.js rather than from inside
// `modules/registries.js` (MODULE_API.md §7.6) — and it means requiring the
// registry never has the side effect of populating it.
//
// Requiring this module is what makes `smtp` and the three channels available.
// Everything that resolves either goes through here, so there is exactly one
// place a transport or a channel can come into existence.
require('./transports/smtp')
require('./coreChannels')
require('./coreScopePrefs')
const transports = require('./transports')
const channels = require('./channels')
module.exports = { transports, channels }

View File

@@ -0,0 +1,73 @@
// ── The structural projection: any trigger through a generic template ──────
//
// ENGAGEMENT.md §4.6.1 property 1, implemented in Phase 6. The property is that
// **a new trigger renders through `notify.event` with no authoring at all** —
// "add a trigger" must not mean "and now write a template". Nothing implemented
// it before this phase, and building the email channel is what made the hole
// visible: a trigger payload is domain-named (`teamName`, `threadTitle`,
// `postUrl`) while the generic seeds are structural (`title`, `intro`, `items`,
// `actionUrl`). The two vocabularies never met.
//
// **The rule, settled by the org lead 2026-08-29: the payload wins, and the
// projection fills gaps.** A name the payload already carries is left exactly as
// emitted — `news.post` and `team.announcement` both declare their own `title`,
// and a projection that overwrote it would replace a real headline with a
// category label. Only a name the payload does NOT define is supplied here.
//
// **What it is careful not to do is guess at domain meaning.** There is no table
// mapping `threadTitle` onto `title`, and there will not be one: every such
// mapping is a piece of one game's vocabulary compiled into core, and it is wrong
// the first time a module names the same thing differently. The three fallbacks
// below are all derived from the DECLARATION — a trigger's own label, its own
// description, its own first declared url — which every trigger has by
// construction because `registerEventTriggers` refuses one without them.
//
// The consequence, stated plainly: an unauthored mail for `team.forum.post` is
// titled "Team — new forum post" rather than the thread's title. That is a plain
// mail, not a wrong one, and the operator's answer is the bespoke template that
// ships beside it (`notify.team-post` reads the payload's own names). A projection
// clever enough to do better would be a projection that is confidently wrong on
// the first module that does not follow core's naming.
const registries = require('../modules/registries')
/**
* The values a template renders with, for one event and one recipient.
*
* @param {string} triggerId
* @param {Record<string, unknown>} payload the outbox row's snapshot — already
* validated at emit, so it holds declared variables and nothing else
* @param {Record<string, unknown>} [extra] per-recipient additions the channel
* computes (`unsubscribeUrl`), merged LAST because they are facts about
* the delivery rather than about the event
* @returns {Record<string, unknown>}
*/
function project(triggerId, payload = {}, extra = {}) {
const declaration = registries.eventTrigger(triggerId)
const values = { ...payload }
// A dormant trigger still has an outbox row to deliver — the module was
// uninstalled between enqueue and now. The payload is intact and the template
// may well only reference payload names, so the mail goes out with whatever the
// snapshot holds rather than being refused for want of a label.
if (declaration) {
if (values.title === undefined) values.title = declaration.label
if (values.intro === undefined) values.intro = declaration.description || ''
if (values.actionUrl === undefined) {
const url = (declaration.variables || []).find(
(v) => v.type === 'url' && typeof payload[v.name] === 'string' && payload[v.name],
)
if (url) values.actionUrl = payload[url.name]
}
}
// Set rather than left absent, so a generic template's item list renders as
// nothing instead of reporting `items` as a missing variable. `missing` is what
// the editor's preview shows an operator, and a name no trigger was ever going
// to supply is noise in it.
if (values.items === undefined) values.items = []
return { ...values, ...extra }
}
module.exports = { project }

View File

@@ -0,0 +1,91 @@
// ── The push DeliveryChannel: addressFor + deliver ─────────────────────────
//
// ENGAGEMENT.md Phase 7. Push is the channel that has existed longest and had a
// `deliver` last, because until this phase there was nothing for a tickle to
// point AT: `{ stream, ref }` carries no content by design, so a rule firing on
// push before the inbox existed would have woken a phone to pull a screen that
// had nothing on it.
//
// **The tickle invariant is the whole of this file's security posture.** What
// leaves the server is the stream id and an opaque ref, never a title, never a
// body, never the payload — `carriesContent: false` on the registration is the
// declaration and this is the implementation. ntfy is treated as an untrusted
// relay, so a leaked topic must reveal nothing but that *something* happened;
// the app then pulls the real item over the authenticated, ownership-checked
// inbox API. Every claim in that paragraph is one `pushDispatch` already makes,
// which is why delivery here is a call into it rather than a second publisher.
//
// **`ref` points at the inbox row when there is one, and is null otherwise.**
// A rule spanning `inapp` and `push` enqueues both, and `liveChannels` orders
// `inapp` first precisely so the row exists by the time this runs — but that is
// an optimisation, not a guarantee: the two rows are independent, either can be
// retried, and a push-only rule has no inbox row at all. So the ref is a HINT.
// The app's contract (docs/android/PLAN.md §11, Phase 8) is wake-and-pull; a
// client that renders the ref instead of pulling is a client that will show
// nothing the first time a retry reorders these two rows.
const inbox = require('../model/userNotifications/userNotifications.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const pushDispatch = require('../utils/pushDispatch')
const log = require('../utils/logger')('engagement')
/**
* Can this channel reach `userId`?
*
* Active account only, the same re-check `emailChannel` and `inappChannel` make
* for the same reason (a row can sit through a `delay_seconds` window). It does
* NOT check for a registered device: whether any endpoint is subscribed is the
* question `publishToUsers` answers in its own query, and asking it twice would
* mean two different definitions of "reachable" that could disagree.
*/
const addressFor = async (userId) => {
const active = await recipients.filterActive([userId])
return active.length ? { address: String(active[0]) } : null
}
/**
* Deliver one claimed outbox row.
*
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
*/
async function deliver(row) {
try {
if (!(await addressFor(row.user_id))) {
return { ok: false, detail: 'this user can no longer be reached' }
}
// Best effort, and it fails to null rather than to an error: no dedupe key,
// no in-app row for it, or an inapp row this rule never enqueued all mean
// the same thing to the app — wake up and pull.
let ref = null
try {
const item = await inbox.findByDedupe(row.user_id, row.dedupe_key)
if (item) ref = `notification:${item.id}`
} catch (err) {
log.debug('could not resolve a push ref', { outbox: row.id, message: err.message })
}
// The stream id IS the trigger id — §7.2's one namespace, settled in Phase 2.
// A push stream and an event trigger share a name space, so the app's
// existing `{ stream }` switch keeps working for an engagement rule without
// learning a second vocabulary.
await pushDispatch.publishToUsers(row.trigger_id, { ref, userIds: [row.user_id] })
// **Success here means "handed to the relay", and the send log must not
// claim more than that.** `publishToUsers` resolves whether it found a
// subscribed device or none at all, and a tickle is fire-and-forget over
// HTTP to a relay that owes us no receipt. Retrying on "we are not sure"
// would mean five wakeups for one event, which is worse than one uncertain
// log line — so this is the one channel whose 'sent' is weaker than email's,
// and saying so in the detail is how an operator reading G15 finds that out.
return { ok: true, transport: 'unifiedpush', detail: 'tickle published' }
} catch (err) {
// pushDispatch never throws, so reaching here is a programming error rather
// than a relay being down. Terminal for that reason: retrying a bug is five
// identical rows in the send log.
log.error('push delivery failed', { outbox: row.id, message: err.message })
return { ok: false, detail: `delivery error: ${err.message}` }
}
}
module.exports = { addressFor, deliver }

View File

@@ -0,0 +1,122 @@
// ── Scoped preferences: "this channel, for this one Team" ──────────────────
//
// ENGAGEMENT.md Phase 6, decision 4. `notification_channel_prefs` is keyed
// (user, stream, channel) and has no scope column; `team_notification_prefs` is
// keyed (user, Team) and is the preference people actually hold today — someone
// in six Teams silences one. Migrating the second into the first would mean a
// live migration of user data, a wire-shape change on two clients, and the loss
// of the granularity in between. The org lead's decision was to keep the Team
// table and have the engine consult it; this file is the seam that lets it,
// without core's engine learning what a Team is.
//
// A registrant claims a scope PREFIX — the part of a scope key before the colon,
// `team` in `team:12` — and answers, for a set of users and one channel, what
// that scope says their mode is.
//
// **Where a scope answers, its answer REPLACES the stream-level preference; it
// does not intersect with it.** The decision was phrased as "a suppression below
// the channel preference", and building it showed that reading is the one that
// cannot ship: `notification_channel_prefs` holds a row only where a user has
// expressed something, absence means the channel's `defaultMode`, and email's is
// `off`. Nobody has ever expressed a stream-level opinion about `team.forum.post`
// — the screen that would let them is Phase 3's and the preference predates it —
// so intersecting would resolve every existing Team-email subscriber to `off` and
// silence the entire live pipeline on the deploy that migrated it. That is the
// G22 failure mode with a different cause. Replacement keeps today's behaviour
// byte-for-byte: for a Team-scoped event, `team_notification_prefs` is the
// preference, exactly as it has been since Teams shipped.
//
// The cost, stated so nobody has to rediscover it: a user cannot turn Team email
// off for every Team at once from the channels screen. That control lives on the
// per-Team screen, which is where it has always lived and where the unsubscribe
// link points.
//
// Nothing here caches. A preference read is one indexed query per (event,
// channel), against a table the user can change between two events.
const log = require('../utils/logger')('engagement')
// prefix → provider
const providers = new Map()
const PREFIX_RE = /^[a-z][a-z0-9_-]*$/
/**
* Parse a scope key into its prefix and id. `''` and anything malformed are
* `null` — an unparseable scope must read as "no scope", never as some other
* scope's.
*
* @returns {{ prefix: string, id: string }|null}
*/
function parse(scopeKey) {
const raw = String(scopeKey || '')
const at = raw.indexOf(':')
if (at < 1 || at === raw.length - 1) return null
const prefix = raw.slice(0, at)
if (!PREFIX_RE.test(prefix)) return null
return { prefix, id: raw.slice(at + 1) }
}
/**
* Register a scope-preference provider.
*
* Validate-then-commit, the same discipline the transport and channel registries
* use: every check runs before the map is touched.
*
* @param {object} def
* @param {string} def.prefix the scope-key prefix this provider owns, e.g. 'team'
* @param {string} def.label operator-facing, for the send log and admin copy
* @param {(userIds: number[], channel: string, scopeId: string) => Promise<Map<number, string>>} def.modesFor
* A mode per user for the users this scope has an opinion about. A user
* left OUT of the map defers to the stream-level preference; a user in it
* is answered by the scope. Must not throw — see `resolve`.
*/
function registerScopePreference(def) {
if (!def || typeof def !== 'object') throw new Error('registerScopePreference: definition required')
const { prefix, label, modesFor } = def
if (typeof prefix !== 'string' || !PREFIX_RE.test(prefix)) {
throw new Error(`registerScopePreference: invalid prefix ${JSON.stringify(prefix)}`)
}
if (providers.has(prefix)) throw new Error(`registerScopePreference: ${prefix} is already registered`)
if (typeof label !== 'string' || !label) throw new Error(`registerScopePreference(${prefix}): label required`)
if (typeof modesFor !== 'function') throw new Error(`registerScopePreference(${prefix}): modesFor required`)
providers.set(prefix, { prefix, label, modesFor })
return prefix
}
/**
* What does this scope say about these users on this channel?
*
* @returns {Promise<Map<number, string>>} empty when the scope is absent,
* unparseable, or owned by nobody — all three of which mean "this event
* is not scoped as far as preferences are concerned", which is the right
* answer for a module whose scope provider has been uninstalled.
*/
async function resolve(userIds, channel, scopeKey) {
const parsed = parse(scopeKey)
if (!parsed || !userIds.length) return new Map()
const provider = providers.get(parsed.prefix)
if (!provider) return new Map()
try {
const modes = await provider.modesFor(userIds, channel, parsed.id)
return modes instanceof Map ? modes : new Map()
} catch (err) {
// **Fails OPEN, and that is the uncomfortable choice made deliberately.** A
// provider that throws leaves the stream-level preference in charge, which
// for every core channel is `off` — so the practical effect of a failure is
// that nothing is sent, not that everybody is mailed. Failing closed by
// refusing the whole event would instead drop an IDOC warning because a Team
// preference query timed out.
log.error('scope preference lookup failed', { scope: scopeKey, channel, message: err.message })
return new Map()
}
}
const has = (prefix) => providers.has(prefix)
// Test-only: the registry is module-level state.
function _reset() {
providers.clear()
}
module.exports = { registerScopePreference, resolve, parse, has, _reset }

View File

@@ -0,0 +1,258 @@
// ── Audience segments — operator composition over module-declared audiences ──
//
// ENGAGEMENT.md §5.1a, Phase 4a. A module declares named audiences over its own
// data ("members of a Team", "the governors"); an operator combines them with
// and/or/not into a saved segment; a rule points at the segment. This file is the
// two halves of that: derive the segment's ceiling at save time, and resolve the
// expression to user ids at send time.
//
// **Composition must NARROW, never widen** (§5.1a rule 3), and that is the whole
// security content of this file. `A OR B` takes the TIGHTER of the two ceilings,
// not the looser - a ceiling states what an expression is *allowed* to reach, not
// what it will resolve to, so the direction of the boolean operator is
// irrelevant. Union-widens is the intuitive implementation and it is the wrong
// one; `ceilings.meetAll` is the arithmetic, settled in Phase 2, and this is its
// first consumer.
//
// The second rule that shows up in both halves is **dormancy** (§5.1a rule 4).
// An audience whose module has been uninstalled resolves to the EMPTY set and
// flags itself, never to an error and never to some other set of people. A
// segment containing one is dormant, and a rule using a dormant segment does not
// send. Resolving the rest of the tree instead would mail a DIFFERENT population
// than the one the operator composed.
const registries = require('../modules/registries')
const ceilings = require('../modules/ceilings')
const BOOLEAN_OPS = ['and', 'or', 'not']
// Same bounds and the same reason as conditions.js: this tree comes out of a JSON
// column an admin can write, and it is walked on the emit path.
const MAX_DEPTH = 5
const MAX_NODES = 50
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
const isNot = (node) => isPlainObject(node) && node.op === 'not'
/** Check one audience's declared params against what the operator supplied. */
function checkParams(declaration, raw, path, errors) {
const params = {}
const supplied = isPlainObject(raw) ? raw : {}
for (const p of declaration.params || []) {
const value = supplied[p.id]
if (value === undefined || value === null || value === '') {
if (p.required) errors.push(`${path}: "${p.id}" is required`)
continue
}
if (p.type === 'int') {
const n = Number(value)
if (!Number.isInteger(n)) {
errors.push(`${path}: "${p.id}" expected an integer`)
continue
}
params[p.id] = n
} else if (p.type === 'boolean') {
if (typeof value !== 'boolean') {
errors.push(`${path}: "${p.id}" expected a boolean`)
continue
}
params[p.id] = value
} else {
if (typeof value !== 'string') {
errors.push(`${path}: "${p.id}" expected a string`)
continue
}
params[p.id] = value
}
}
return params
}
/**
* Validate an expression and derive its ceiling in one walk.
*
* Returns `{ ok: true, expression, ceiling }` with a normalised tree, or
* `{ ok: false, errors }`.
*
* **`not` is legal only as a child of `and`**, and that restriction is what makes
* a complement mean something. A complement needs a universe, and the only
* universe available here that does not widen is the set its siblings already
* produced: `A AND NOT B` is "A, less B", which is exactly what an operator
* wants and cannot be composed into a broadcast. A bare `NOT B`, or `A OR NOT B`,
* would have to mean "everyone except..." - a way to build the whole deployment
* out of one narrow audience, which is the widening rule 3 forbids. Refusing it
* at save is better than a semantics nobody can predict from the screen.
*
* Two failure modes, and they are different:
*
* - a leaf naming an audience nobody registers is refused AT SAVE, because an
* operator composing a segment out of a typo should hear about it now rather
* than discovering a permanently-empty rule later. (A segment that was VALID
* when saved and whose module has since gone is a different case - that is
* dormancy, handled in `resolve`, and it is not refused.)
* - two incomparable ceilings have NO meet, so the composition is refused rather
* than resolved to a guess. `staff AND owner` is not `owner`; it is a question
* the lattice declines to answer, and picking a side would be a widening.
*/
function validate(raw) {
const errors = []
let nodes = 0
// `underAnd` is the only context in which a `not` is legal.
function walk(node, depth, path, underAnd) {
if (++nodes > MAX_NODES) {
errors.push(`${path}: expression has more than ${MAX_NODES} nodes`)
return null
}
if (depth > MAX_DEPTH) {
errors.push(`${path}: nested deeper than ${MAX_DEPTH}`)
return null
}
if (!isPlainObject(node)) {
errors.push(`${path}: expected an object`)
return null
}
if (node.op === 'not') {
if (!underAnd) {
errors.push(`${path}: "not" is only allowed inside an "and" - a complement needs a set to take it from`)
return null
}
const children = Array.isArray(node.nodes) ? node.nodes : []
if (children.length !== 1) {
errors.push(`${path}: "not" takes exactly one node`)
return null
}
const inner = walk(children[0], depth + 1, `${path}.nodes[0]`, false)
if (!inner) return null
// A `not` contributes NO ceiling. Excluding people cannot widen who the
// expression reaches, so folding the excluded audience's ceiling into the
// meet would refuse perfectly safe segments: `members AND NOT staff` would
// hit meet('members','staff') = null and be rejected, even though it
// reaches strictly fewer people than `members` alone.
return { node: { op: 'not', nodes: [inner.node] }, ceiling: null, complement: true }
}
if (node.op === 'and' || node.op === 'or') {
const children = Array.isArray(node.nodes) ? node.nodes : []
if (!children.length) {
errors.push(`${path}: "${node.op}" has no nodes`)
return null
}
const walked = children.map((c, i) => walk(c, depth + 1, `${path}.nodes[${i}]`, node.op === 'and'))
if (walked.some((w) => w === null)) return null
const positives = walked.filter((w) => !w.complement)
if (!positives.length) {
errors.push(`${path}: "${node.op}" has nothing but complements - there is no set to exclude from`)
return null
}
return {
node: { op: node.op, nodes: walked.map((w) => w.node) },
ceiling: ceilings.meetAll(positives.map((w) => w.ceiling)),
}
}
if (node.op !== undefined) {
errors.push(`${path}: unknown operator "${node.op}"`)
return null
}
const declaration = registries.audience(node.audienceId)
if (!declaration) {
errors.push(`${path}: no audience "${node.audienceId}" is registered`)
return null
}
const params = checkParams(declaration, node.params, path, errors)
return { node: { audienceId: declaration.id, params }, ceiling: declaration.ceiling }
}
if (!isPlainObject(raw)) return { ok: false, errors: ['expression: expected an object'] }
const walked = walk(raw, 0, 'expression', false)
if (errors.length || !walked) return { ok: false, errors: errors.length ? errors : ['expression: invalid'] }
if (!walked.ceiling) {
return {
ok: false,
errors: [
'expression: the audiences combined here have no common ceiling, so there is no bound this segment could be given',
],
}
}
return { ok: true, expression: walked.node, ceiling: walked.ceiling }
}
/**
* Resolve a validated expression to a set of user ids.
*
* Returns `{ dormant, userIds }`. `dormant` is true the moment ANY leaf names an
* audience that is no longer registered, and when it is true the caller must not
* send: `userIds` is empty, because the tree it would have come from is not the
* tree the operator composed.
*
* `and` is the intersection of its positive children, less the union of its
* complements. `or` is the union of its children, which are all positive because
* `validate` refused any other shape.
*/
async function resolve(expression) {
let dormant = false
async function walk(node) {
if (!isPlainObject(node)) return new Set()
if (node.op === 'and' || node.op === 'or') {
const children = Array.isArray(node.nodes) ? node.nodes : []
const positives = children.filter((c) => !isNot(c))
const complements = children.filter(isNot)
let out = new Set()
for (let i = 0; i < positives.length; i += 1) {
const set = await walk(positives[i])
if (i === 0) out = set
else if (node.op === 'and') out = new Set([...out].filter((id) => set.has(id)))
else for (const id of set) out.add(id)
}
for (const c of complements) {
const excluded = await walk((c.nodes || [])[0])
out = new Set([...out].filter((id) => !excluded.has(id)))
}
return out
}
// A `not` reached directly (never produced by validate, but a stored row
// predates nothing and this must not throw): no universe, so no members.
if (node.op !== undefined) return new Set()
const { dormant: gone, userIds } = await registries.resolveAudience(node.audienceId, node.params || {})
if (gone) dormant = true
return new Set(userIds)
}
const set = await walk(expression)
return { dormant, userIds: dormant ? [] : [...set] }
}
/**
* Which audience ids in this expression nobody registers right now?
*
* The static half of the dormancy answer `resolve` gives at send time, and it
* lives here so the two cannot disagree. Two callers need it and neither may
* require the other: the segment list annotates itself with it, and the RULE
* list needs it to say that a rule pointing at a dormant segment is itself
* dormant — which is §5.1a rule 4, and which the first version of the rule
* annotation missed by asking only whether the segment ROW still existed.
*
* The difference is the whole point. A deleted segment and a segment whose
* module is gone both leave the rule reaching nobody; only one of them leaves a
* row behind. A screen that reports the first and not the second shows an
* enabled, healthy-looking rule that cannot fire.
*/
function missingAudiences(expression) {
const missing = []
const walk = (node) => {
if (!node || typeof node !== 'object') return
if (node.op) (node.nodes || []).forEach(walk)
else if (!registries.audience(node.audienceId)) missing.push(node.audienceId)
}
walk(expression)
return [...new Set(missing)]
}
module.exports = { validate, resolve, missingAudiences, MAX_DEPTH, MAX_NODES }

View File

@@ -0,0 +1,160 @@
// ── The suppression list ───────────────────────────────────────────────────
//
// ENGAGEMENT.md G16, Phase 9. Addresses this deployment has stopped mailing,
// and the two questions asked of them: "may I send to this one?" at delivery
// time, and "why did this one stop?" on the admin screen.
//
// **Scope: the engagement email channel only** (Phase 9 decision 2). A password
// reset, an invite, a verification mail and the contact form are all
// user-INITIATED and still attempt, exactly as they still attempt to an
// unverified address (`passwordReset.controller.js`). The posture is the same one
// that file already states: a background system's opinion about an address must
// not be able to lock somebody out of their own account. One reset to a dead
// mailbox is not a reputation problem; a rule mailing three thousand people every
// week is, and that is what this list guards.
//
// **The table holds a hash and a mask, never an address.** The hash is what
// correlates a bounce back to an `engagement_sends` row (Phase 6 was already
// writing `address_hash` on every outcome for this). The mask —
// `d***@example.com` — is Phase 9's one addition to §4.5's DDL and exists because
// a screen of sha256 digests cannot be operated: an operator has to be able to
// see that a whole domain is refusing mail, and to find the person who fixed
// their mailbox and let them back in. The local part is DESTROYED rather than
// shortened, so the column cannot be turned back into an address book.
//
// **Nothing here writes a suppression from "the send failed".** What may write
// one is `bounceClassify.classify`, which is a much narrower question — see that
// file's header for why reusing `mailer.PERMANENT_CODES` would have suppressed
// every address the moment an SMTP password went stale.
const crypto = require('crypto')
const db = require('../model/engagement/engagementSuppressions.db')
const bounceClassify = require('./bounceClassify')
const log = require('../utils/logger')('engagement')
const REASONS = ['bounce', 'complaint', 'manual', 'unverified']
/**
* The key an address is stored under.
*
* Lower-cased first, and that matters more here than anywhere else in the
* subsystem: a bounce reported for `Darrow@example.com` has to find the row
* written for `darrow@example.com`, and a hash of two spellings is two rows that
* never meet. RFC 5321 says the local part is technically case-sensitive; no
* relay anybody deploys treats it that way.
*/
const hashAddress = (address) =>
crypto.createHash('sha256').update(String(address).trim().toLowerCase()).digest('hex')
/**
* `darrow@example.com` → `d***@example.com`. Null for anything that is not an
* address.
*
* The domain survives intact because domain-level patterns are the signal an
* operator is actually looking for — "everything to this company is bouncing" is
* a different problem from three people mistyping their own address, and only the
* domain distinguishes them.
*
* The first character of the local part survives only when there are at least
* three, which is not fussiness: for a two-letter local part, one revealed
* character plus the domain is most of the address.
*/
function maskAddress(address) {
const s = String(address || '').trim()
const at = s.lastIndexOf('@')
if (at <= 0 || at === s.length - 1) return null
const local = s.slice(0, at)
const domain = s.slice(at + 1).toLowerCase()
const head = local.length >= 3 ? local[0].toLowerCase() : ''
return `${head}***@${domain}`.slice(0, 190)
}
/** Is this address suppressed on this channel? */
async function isSuppressed(address, channel = 'email') {
if (!address) return null
try {
return await db.get(hashAddress(address), channel)
} catch (err) {
// Fail OPEN, and the direction is deliberate. A database that cannot answer
// "is this suppressed" must not stop the deployment's mail; the failure mode
// it would otherwise produce is total silence with a clean send log, which is
// exactly G22's shape. Mailing one dead address during an outage is the
// cheaper mistake.
log.error('suppression check failed; sending anyway', { message: err.message })
return null
}
}
/**
* Suppress an address. Returns true when this call created the row.
*
* `reason` is validated rather than trusted: it is an ENUM in the schema, so an
* unknown value is a 500 from the driver at the worst possible moment (inside a
* failure handler), and the callers include an admin route.
*/
async function suppress({ address, reason, detail = null, channel = 'email', createdBy = null }) {
if (!address) return false
if (!REASONS.includes(reason)) throw new Error(`suppress: unknown reason "${reason}"`)
const created = await db.add({
address_hash: hashAddress(address),
address_masked: maskAddress(address),
channel,
reason,
detail,
created_by: createdBy,
})
if (created) {
// Masked, never the address — the same rule every other log line in this
// subsystem follows. It is logged at all because an address dropping off the
// mailing list is the kind of change an operator finds out about weeks later
// otherwise.
log.info('address suppressed', { address: maskAddress(address), reason, channel })
}
return created
}
/** Un-suppress. Returns true when a row was removed. */
async function unsuppress(address, channel = 'email') {
if (!address) return false
const removed = await db.remove(hashAddress(address), channel)
if (removed) log.info('suppression lifted', { address: maskAddress(address), channel })
return removed
}
/**
* Consider a failed send for suppression, and say what was decided.
*
* The seam between a delivery failure and this list, and the only one — nothing
* else in the codebase writes a `bounce` row. Called from `emailChannel.deliver`
* with the error the transport threw.
*
* @returns {Promise<{suppressed: boolean, note: string}>} `note` goes into the
* send log's detail, on both outcomes.
*/
async function considerFailure({ address, error, channel = 'email' }) {
const verdict = bounceClassify.classify(error)
if (!verdict.suppress) {
return { suppressed: false, note: `not suppressed (${verdict.reason})` }
}
try {
const detail = verdict.evidence ? `hard bounce: ${verdict.evidence}` : 'hard bounce'
await suppress({ address, reason: 'bounce', detail, channel })
return { suppressed: true, note: detail }
} catch (err) {
// A failure to record the suppression must not change how the send itself is
// reported. The mail failed either way, and that is the row the log owes.
log.error('could not record a suppression', { message: err.message })
return { suppressed: false, note: `hard bounce, not recorded: ${err.message}` }
}
}
module.exports = {
hashAddress,
maskAddress,
isSuppressed,
suppress,
unsuppress,
considerFailure,
REASONS,
}

View File

@@ -0,0 +1,299 @@
// ── The shipped template set (§4.6.1) ──────────────────────────────────────
//
// "A fresh deployment mails correctly before anyone opens the editor." Every body
// that used to be a template literal inside `utils/mailer.js` is a row here, so
// Phase 5 is a RELOCATION rather than a regression: nothing that sends mail today
// starts depending on an operator authoring something first.
//
// **Nine seeds, six of them wired in this phase.** The five transactional bodies
// plus `auth.email-verify` (which §4.6.1 lists as "new — Phase 9" and which Phase
// 1b in fact already shipped) are rendered by `mailer` from this moment. The three
// notification seeds are seeded but not yet rendered by anything: `notify.digest`
// and `notify.team-post` belong to `teamNotify`/`teamDigestWorker`, which Phase 6
// rewrites onto the engine, and `inapp.event` to the channel Phase 7 builds.
// Settled with the org lead: seed all nine now so those phases open something
// rather than shipping seeds of their own — a seeder bump is the mechanism of last
// resort (property 3 below), not a per-phase routine.
//
// **`seedVersion` is the whole "improve a default without stealing an operator's
// work" mechanism.** Bump it when a body changes; the seeder updates rows where
// `customized = 0` and skips rows where it is 1. Do NOT bump it for a comment.
//
// ── Two conventions the bodies follow, both of which are visible to operators ──
//
// **1. Presentational fragments are variables, because templates have no logic.**
// `mailer` used to build ` for the account “Darrow”` with a ternary. A template
// cannot, by design (interpolate.js: no conditionals). So the ternary stays at the
// call site and its RESULT arrives as a variable — `forWhom` — whose `example`
// shows exactly what it produces, leading space and quotes included. That is the
// price of a logic-free template language, and it is paid here rather than by
// giving operator-authored data a conditional to get wrong.
//
// **2. Ambient brand variables are supplied by the renderer, not by the caller.**
// `siteName`, `siteUrl`, `logoUrl` and `year` are available to every template and
// cannot be overridden by whatever a caller passes (`engagement/templates.js`).
// §4.6.1 property 2: "no template contains a literal hex code or a logo URL", so
// one prebuilt image running as any shard mails in that shard's identity.
// The ambient set, declared once so the editor's palette (Phase 5b) can offer them
// on EVERY template rather than each seed having to list them.
const AMBIENT_VARIABLES = Object.freeze([
{ name: 'siteName', type: 'string', required: true, example: 'UOMysticmoon' },
{ name: 'siteUrl', type: 'string', required: false, example: 'https://example.com' },
{ name: 'logoUrl', type: 'string', required: false, example: 'https://example.com/brand/logo.png' },
{ name: 'year', type: 'string', required: true, example: '2026' },
])
// A tiny helper so the block arrays below read as content rather than as JSON.
const text = (id, body, opts = {}) => ({
id,
type: 'email.text',
props: opts.muted ? { text: body, muted: true } : { text: body },
})
const heading = (id, body, level = 'h1') => ({
id,
type: 'email.heading',
props: { level, text: body },
})
const button = (id, label, url, textLead) => ({
id,
type: 'email.button',
props: textLead ? { label, url, textLead } : { label, url },
})
const itemList = (id, variable, emptyText) => ({
id,
type: 'email.itemList',
props: emptyText ? { variable, emptyText } : { variable },
})
const divider = (id) => ({ id, type: 'email.divider', props: {} })
const SEEDS = [
// ── Transactional: protected = 1, editable but not deletable ─────────────
{
key: 'auth.password-reset',
name: 'Password reset',
channel: 'email',
protected: true,
seedVersion: 1,
subject: 'Reset your {{siteName}} password',
variables: [
{ name: 'username', type: 'string', required: false, example: 'Darrow' },
{ name: 'forWhom', type: 'string', required: false, example: ' for the account “Darrow”' },
{ name: 'resetUrl', type: 'string', required: true, example: 'https://example.com/reset/abc123' },
],
blocks: [
text('p1', 'We received a request to reset the password{{forWhom}} at {{siteName}}.'),
button('cta', 'Choose a new password', '{{resetUrl}}', 'Choose a new password here:'),
text(
'p2',
'This link is single-use and expires in about an hour. If you didn\'t request this, ' +
'you can safely ignore this email — your password won\'t change.',
),
],
},
{
key: 'auth.invite',
name: 'Account invite',
channel: 'email',
protected: true,
seedVersion: 1,
subject: 'Your {{siteName}} invitation',
variables: [
{ name: 'acceptUrl', type: 'string', required: true, example: 'https://example.com/invite/abc123' },
{ name: 'roleLabel', type: 'string', required: false, example: ' as moderator' },
{ name: 'invitedBy', type: 'string', required: false, example: ' by Aldric' },
],
blocks: [
text('p1', 'You have been invited{{invitedBy}} to join {{siteName}}{{roleLabel}}.'),
button('cta', 'Accept your invitation', '{{acceptUrl}}', 'Accept your invitation and set up your account here:'),
text('p2', 'This link is single-use and will expire. If you weren\'t expecting this, you can ignore it.'),
],
},
{
key: 'auth.email-verify',
name: 'Email address confirmation',
channel: 'email',
protected: true,
seedVersion: 1,
subject: 'Confirm your email address for {{siteName}}',
variables: [
{ name: 'username', type: 'string', required: false, example: 'Darrow' },
{ name: 'forWhom', type: 'string', required: false, example: ' “Darrow”' },
{ name: 'verifyUrl', type: 'string', required: true, example: 'https://example.com/verify/abc123' },
],
blocks: [
text('p1', 'The {{siteName}} account{{forWhom}} asked to use this address for contact and account recovery.'),
button('cta', 'Confirm this address', '{{verifyUrl}}', 'Confirm it here:'),
text(
'p2',
'This link is single-use and expires in about a day. Until it is used, nothing changes — ' +
'the account keeps whatever address it had.',
),
text(
'p3',
'If you did not ask for this, you can ignore this email. Someone may have mistyped their ' +
'own address; no account of yours is affected and this link grants no access to anything.',
),
],
},
{
key: 'admin.contact-message',
name: 'Contact form message',
channel: 'email',
protected: true,
seedVersion: 1,
// `fromLabel` and `fromName` are the SAME missing name with two different
// fallbacks — 'a visitor' in the subject, 'unknown' in the body. That
// divergence is inherited from the literal this replaces, and the template is
// where it becomes visible and fixable: an operator who wants one word can now
// edit the subject line instead of a source file.
subject: '{{siteName}} contact from {{fromLabel}}',
variables: [
{ name: 'fromLabel', type: 'string', required: true, example: 'a visitor' },
{ name: 'fromName', type: 'string', required: true, example: 'unknown' },
{ name: 'fromEmail', type: 'string', required: true, example: 'ann@example.com' },
{ name: 'message', type: 'string', required: true, example: 'Is the shard open to new players?' },
],
blocks: [
text('p1', 'From: {{fromName}} <{{fromEmail}}>'),
text('p2', '{{message}}'),
],
},
{
key: 'admin.test',
name: 'Delivery test',
channel: 'email',
protected: true,
seedVersion: 1,
subject: '{{siteName}} email test',
variables: [
{ name: 'transport', type: 'string', required: true, example: 'smtp' },
{ name: 'sentAt', type: 'string', required: false, example: '2026-08-29 18:04 UTC' },
],
blocks: [
text('p1', 'This is a test message confirming {{transport}} email delivery is working.'),
],
},
// ── Notification: protected = 0, replaceable ─────────────────────────────
//
// **`notify.event` and `notify.digest` are generic on purpose** (§4.6.1 property
// 1): their variables are structural — `title`, `intro`, `items[]` — rather than
// domain-specific, so a trigger from core or from any module renders through
// them with NO authoring at all. This is what stops "add a trigger" from meaning
// "and now write a template".
{
key: 'notify.event',
name: 'Notification (single event)',
channel: 'email',
protected: false,
seedVersion: 1,
subject: '{{title}}',
variables: [
{ name: 'title', type: 'string', required: true, example: 'Your house is close to collapsing' },
{ name: 'intro', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
{ name: 'items', type: 'list', required: false, example: [{ heading: 'The Silver Anvil', excerpt: 'Britain, Trammel (1119, 1794)' }] },
{ name: 'actionUrl', type: 'string', required: false, example: 'https://example.com/houses' },
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
],
blocks: [
heading('h', '{{title}}'),
text('intro', '{{intro}}'),
itemList('items', 'items'),
button('cta', 'Open {{siteName}}', '{{actionUrl}}'),
divider('rule'),
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
],
},
{
key: 'notify.digest',
name: 'Notification digest',
channel: 'email',
protected: false,
seedVersion: 1,
subject: '{{siteName}}: {{periodLabel}}',
variables: [
{ name: 'periodLabel', type: 'string', required: true, example: 'your daily summary' },
{ name: 'intro', type: 'string', required: false, example: 'Here is what happened while you were away.' },
{ name: 'items', type: 'list', required: false, example: [{ heading: 'New thread in Guild Hall', excerpt: 'Meeting moved to Friday', url: 'https://example.com/teams/1?thread=9' }] },
// Precomputed for the same reason `forWhom` is: "and 3 more" needs a
// conditional and a plural, and a template has neither.
{ name: 'moreNote', type: 'string', required: false, example: 'and 3 more.' },
{ name: 'scopeUrl', type: 'string', required: false, example: 'https://example.com/teams/1' },
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
],
blocks: [
text('intro', '{{intro}}'),
itemList('items', 'items'),
text('more', '{{moreNote}}', { muted: true }),
button('cta', 'Open {{siteName}}', '{{scopeUrl}}'),
divider('rule'),
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
],
},
{
key: 'notify.team-post',
name: 'Team post notification',
channel: 'email',
protected: false,
seedVersion: 2,
subject: '{{teamName}}: {{threadTitle}}',
variables: [
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil' },
{ name: 'authorName', type: 'string', required: true, example: 'Aldric' },
{ name: 'threadTitle', type: 'string', required: true, example: 'Meeting moved to Friday' },
{ name: 'excerpt', type: 'string', required: false, example: 'We are pushing this week back a day so more people can make it.' },
{ name: 'postUrl', type: 'string', required: false, example: '/guilds/the-silver-anvil/forum/412' },
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
],
blocks: [
text('p1', '{{authorName}} posted in {{teamName}}.'),
heading('h', '{{threadTitle}}', 'h2'),
text('excerpt', '{{excerpt}}', { muted: true }),
button('cta', 'Read the thread', '{{postUrl}}'),
divider('rule'),
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails for this team, use this link:'),
],
},
{
key: 'inapp.event',
name: 'On-site notification',
channel: 'inapp',
protected: false,
// **seedVersion 2, and the bump is a correction rather than an improvement.**
// Phase 5a wrote this template before the channel that renders it existed, and
// named its variables `body` and `url` — names NOTHING supplies. A trigger
// declares domain names (`teamName`, `threadTitle`), and `projection.project`
// fills the gaps with the STRUCTURAL ones the generic seeds use: `title`,
// `intro`, `actionUrl`. So every rendering of this template would have found
// `body` and `url` missing and produced a title and nothing else. Renamed to
// the vocabulary `notify.event` uses, which is the same property stated once:
// a new trigger must render with no authoring at all.
seedVersion: 2,
// No subject: an inbox row has a title, and the title is a block. The column
// is email's, and leaving it NULL is how a non-email template says so.
subject: null,
variables: [
{ name: 'title', type: 'string', required: true, example: 'Your house is close to collapsing' },
{ name: 'intro', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
{ name: 'actionUrl', type: 'string', required: false, example: '/player/uo/houses' },
],
// The three blocks map onto the three columns of `user_notifications` by ROLE
// (templates.js `renderInappByKey`): the heading is the item's title, the
// button is its one action, and everything else is the body. There is no
// unsubscribe line — an inbox item has nowhere to send someone that the
// preferences screen it links to from does not already reach.
blocks: [
heading('h', '{{title}}', 'h3'),
text('intro', '{{intro}}'),
button('cta', 'Open', '{{actionUrl}}'),
],
},
]
/** @returns {object|null} the seed definition for `key`. */
function seedByKey(key) {
return SEEDS.find((s) => s.key === key) || null
}
module.exports = { SEEDS, AMBIENT_VARIABLES, seedByKey }

View File

@@ -0,0 +1,338 @@
// ── Templates: resolve, render, seed ───────────────────────────────────────
//
// The seam between a stored `engagement_templates` row and the two body parts a
// transport sends. Everything that needs a database happens here; `emailBlocks/`
// stays pure and synchronous below it.
//
// **A missing row renders the shipped default rather than nothing.** `renderByKey`
// falls back to `templateSeeds.js` whenever the row is absent or its blocks will
// not parse. This is not defensive padding — it is what makes it safe for
// `mailer` to depend on the database for a password-reset body at all. Before the
// first seed runs, after a restore that dropped the table, on a deployment whose
// operator deleted a row by hand: the mail still goes out, in the shipped wording,
// and the `protected` flag stops the last of those from being reachable through
// the API. The same posture `settingsJson` and `resolveThemeTokens` take — a
// stored value that is unusable is treated as absent, never as an error.
const templatesDb = require('../model/engagement/engagementTemplates.db')
const settings = require('../model/settings/settings.model')
const brand = require('../config/brand')
const emailBlocks = require('../emailBlocks')
const { SEEDS, AMBIENT_VARIABLES, seedByKey } = require('./templateSeeds')
// The trigger registry lives with the module registries, not here — a trigger is
// something a MODULE declares (see engagement/index.js's header).
const { eventTrigger } = require('../modules/registries')
const log = require('../utils/logger')('templates')
const baseUrl = () => (process.env.APP_BASE_URL || brand.url || 'http://localhost:5173').replace(/\/+$/, '')
/**
* The brand values every template may reference, resolved from the same places
* the site's own chrome resolves them (§4.6.1 property 2).
*
* **They are merged OVER the caller's values, not under.** A caller supplies the
* message; the deployment supplies its identity. Letting a caller pass its own
* `siteName` would mean a module — or a bug — could send mail that claims to be
* from somewhere else, which is precisely the thing a recipient cannot check.
*
* Never throws: a settings read that fails degrades to the BRAND_* env values, so
* mail is branded slightly less specifically rather than not sent.
*/
async function ambient() {
let name = brand.name
let logo = brand.logo
let theme = null
try {
name = await settings.getInstanceName()
const shell = await settings.getShellBrand()
logo = shell.logo || brand.logo
theme = shell.theme
} catch (err) {
log.warn('brand resolution failed; falling back to BRAND_* env', { message: err.message })
}
const base = baseUrl()
const absLogo = logo && logo.startsWith('/') ? `${base}${logo}` : logo || ''
return {
values: {
siteName: name,
siteUrl: base,
logoUrl: absLogo,
year: String(new Date().getUTCFullYear()),
},
// resolveThemeTokens speaks CSS custom properties; the renderer speaks colour
// names. One mapping, here, rather than the renderer knowing about CSS.
theme: { accent: theme ? theme['--accent'] : undefined },
baseUrl: base,
}
}
/**
* Which variables a template may reference — the input to Phase 5b's palette and
* to its save-time "undeclared variable" refusal.
*
* Two sources, because a template has two possible origins. One tied to a trigger
* reads §4.3's declaration, which is the authority for anything a module emits.
* One with no trigger — every transactional seed is one; `mailer` renders them by
* key with no rule involved — has no trigger to ask, so its shipped definition
* carries the list. Ambient brand variables are appended to both.
*
* @param {{ trigger_id?: string|null, seed_key?: string|null }} template
* @returns {Array<{name: string, type: string, required: boolean, example: unknown}>}
*/
function variablesFor(template) {
const own = []
if (template && template.trigger_id) {
const declared = eventTrigger(template.trigger_id)
if (declared && Array.isArray(declared.variables)) own.push(...declared.variables)
} else if (template && template.seed_key) {
const seed = seedByKey(template.seed_key)
if (seed) own.push(...seed.variables)
}
const names = new Set(own.map((v) => v.name))
return [...own, ...AMBIENT_VARIABLES.filter((v) => !names.has(v.name))]
}
/**
* Render one template into its two body parts.
*
* @param {object} template a row, or a seed definition
* @param {Record<string, unknown>} values
* @param {object} resolved the result of ambient()
* @returns {{ subject: string, html: string, text: string, missing: string[] }}
*/
function renderTemplate(template, values, resolved) {
const merged = { ...values, ...resolved.values }
const missing = new Set()
const ctx = emailBlocks.buildContext({
values: merged,
theme: resolved.theme,
baseUrl: resolved.baseUrl,
missing,
})
const rendered = emailBlocks.renderBlocks(template.blocks, ctx)
const subject = template.subject ? ctx.t(template.subject) : ''
// An authored `text_body` REPLACES the generated one (§4.4), and is interpolated
// like any other authored string. It is a per-template override, not an addition.
const text = template.text_body ? ctx.t(template.text_body) : rendered.text
return {
subject,
html: emailBlocks.renderDocument(rendered.html, ctx, subject),
text,
missing: [...missing],
}
}
/**
* The template `key` should actually render through, or null.
*
* Extracted from `renderByKey` in Phase 7 rather than duplicated into the in-app
* channel: the fallback chain below is a policy about what this deployment sends
* when its own table is in a bad state, and a second channel resolving templates
* by its own rules would be a second answer to that. `renderInappByKey` takes the
* same rows, the same seeds and the same three refusals.
*
* @returns {Promise<{subject: string|null, blocks: object[], text_body: string|null}|null>}
*/
async function resolveTemplate(key) {
let template = null
try {
template = await templatesDb.getByKey(key)
} catch (err) {
log.warn('template read failed; using the shipped default', { key, message: err.message })
}
// Three ways a row is not the thing to send, and they are one branch on purpose:
// whether the row is absent, structurally unusable, or deliberately unpublished,
// the answer is the shipped default rather than a failed message.
//
// **The `status` arm is the one with teeth** (Phase 5b, decision 3). `status`
// has existed since 5a and nothing read it, so an operator who saved a template
// as a draft kept mailing it — the editor offered a working state that did not
// work. A draft is now exactly what the word means: not what goes out. It falls
// back rather than refusing, for the same reason the other two arms do — no
// state of this table may stop a password reset.
let unusable = null
if (!template) unusable = null
else if (!Array.isArray(template.blocks) || template.blocks.length === 0) unusable = 'unusable'
else if (template.status !== 'published') unusable = 'unpublished'
if (!template || unusable) {
const seed = seedByKey(key)
if (!seed) return null
if (unusable === 'unusable') log.warn('stored template is unusable; using the shipped default', { key })
if (unusable === 'unpublished') log.warn('stored template is a draft; using the shipped default', { key })
template = { subject: seed.subject, blocks: seed.blocks, text_body: null }
}
return template
}
/**
* Render the template stored under `key`, falling back to its shipped default.
* @returns {Promise<{subject: string, html: string, text: string, missing: string[]}|null>}
* null when `key` names no usable row AND no seed — which now includes a
* duplicated (seedless) template still in draft.
*/
async function renderByKey(key, values = {}) {
const resolved = await ambient()
const template = await resolveTemplate(key)
if (!template) return null
return renderTemplate(template, values, resolved)
}
// ── The in-app projection (Phase 7) ────────────────────────────────────────
//
// `user_notifications` has three columns — title, body, url — where email has a
// subject and a document, so the in-app channel needs the template rendered into
// those three rather than into a mail. **The mapping is by block ROLE**, and it
// is here rather than in the channel because it is a statement about what the
// block registry means, not about how a row gets written:
//
// - the first `email.heading` → `title` (a heading IS the item's headline)
// - the first `email.button` → `url` (a button IS the item's one action)
// - everything else, as TEXT → `body`
//
// **Text, not the email HTML, and that is the load-bearing choice.** The block
// renderer's HTML is built for mail clients: table rows, inline hex colours, a
// light-only palette declared with `color-scheme`. Dropped into a page that
// follows the viewer's theme it renders as a pale card floating in a dark one.
// `toText` is the same content with none of that, and it is the part the block
// contract already promises every block can produce.
//
// The three refusals a mail can afford and an inbox row cannot are handled here
// too: a title is NOT NULL, so an empty one falls back to the projected `title`
// and then to the trigger id; and a url that is not site-relative is dropped
// rather than stored, because the column's whole contract is that a template
// cannot aim a signed-in user's click off-site.
const HEADING = 'email.heading'
const BUTTON = 'email.button'
// user_notifications.title / .url. Truncated rather than refused: a long title is
// a cosmetic problem and a dropped notification is not.
const MAX_TITLE = 300
const MAX_URL = 500
// The same character class `pageUrlTemplate` and the engine's `url` variables
// use (registries.js, engagementEmit.js). Duplicated as a literal rather than
// imported from `engagementEmit`, which would be a cycle through the engine.
const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/
/**
* Site-relative form of `raw`, or null.
*
* An absolute url on this deployment's own base is accepted and reduced — a
* template that writes `{{siteUrl}}/guilds/4` is saying the same thing as
* `/guilds/4`, and refusing it would make the ambient `siteUrl` variable a trap
* in the one channel where the link never leaves the site.
*/
function relativeUrl(raw, base) {
const value = String(raw || '').trim()
if (!value) return null
const stripped = base && value.startsWith(`${base}/`) ? value.slice(base.length) : value
if (!RELATIVE_URL.test(stripped)) return null
return stripped.slice(0, MAX_URL)
}
/**
* Render one template into an inbox item.
*
* @returns {Promise<{title: string, body: string|null, url: string|null, missing: string[]}|null>}
* null when `key` names no usable row and no seed — the caller reports a
* terminal failure, exactly as the email channel does.
*/
async function renderInappByKey(key, values = {}) {
const resolved = await ambient()
const template = await resolveTemplate(key)
if (!template) return null
const merged = { ...values, ...resolved.values }
const missing = new Set()
const ctx = emailBlocks.buildContext({
values: merged,
theme: resolved.theme,
baseUrl: resolved.baseUrl,
missing,
})
const blocks = Array.isArray(template.blocks) ? template.blocks : []
const visible = blocks.filter((b) => b && b.visible !== false)
const heading = visible.find((b) => b.type === HEADING)
const button = visible.find((b) => b.type === BUTTON)
// Only the FIRST of each is consumed; a second heading or button is ordinary
// body content, which is what an operator who added one meant.
const rest = visible.filter((b) => b !== heading && b !== button)
const headingText = heading ? ctx.t((heading.props || {}).text || '').trim() : ''
const title = (headingText || String(merged.title || '').trim() || key).slice(0, MAX_TITLE)
const url = button ? relativeUrl(ctx.t((button.props || {}).url || ''), resolved.baseUrl) : null
const body = emailBlocks.renderBlocks(rest, ctx).text.trim()
return { title, body: body || null, url, missing: [...missing] }
}
/**
* Ensure every shipped template exists, and bring un-customized rows up to the
* current seed. Idempotent: a second run reports nine skips and writes nothing.
*
* Never throws — it is called from `seedDefaults()` on the boot path, and a
* template that failed to seed costs the shipped default (see the header note),
* not the deployment.
*/
async function seedTemplates() {
const counts = { inserted: 0, updated: 0, skipped: 0, invalid: 0 }
for (const seed of SEEDS) {
// Validated against the registry before it is stored, even though a seed is
// code rather than input. The alternative is a shipped block array that no
// renderer understands sitting in the table, which reads to an operator as
// their deployment being broken; refusing to write it leaves `renderByKey`'s
// fallback in charge and puts the reason in the boot log.
const { valid, errors } = emailBlocks.validateEmailBlocks(seed.blocks)
if (!valid) {
log.error('shipped template is invalid and was not seeded', { key: seed.key, errors })
counts.invalid += 1
continue
}
try {
counts[await templatesDb.seedOne(seed)] += 1
} catch (err) {
log.error('template seed failed', { key: seed.key, message: err.message })
}
}
// The third arm of §4.6.1 property 3: a customized row is never touched, and the
// fact that a better default now exists is surfaced instead of applied.
let stale = []
try {
stale = await templatesDb.staleCustomized(SEEDS.map((s) => ({ key: s.key, seedVersion: s.seedVersion })))
} catch {
stale = []
}
if (stale.length) {
log.info('customized templates have a newer shipped default', { keys: stale.map((t) => t.key) })
}
log.info('engagement templates ensured', counts)
return { ...counts, stale: stale.map((t) => t.key) }
}
/**
* The shape of a template key, defined HERE rather than in the templates model
* because two unrelated callers need it and only one of them should own it:
* `engagementTemplates.model` checks it when a duplicate names a new key, and
* `engagementRules.model` checks it when a rule points at one. Phase 4a had its
* own pattern with no dot in it, which could not match any key this system
* actually uses; one definition is what stops that recurring.
*/
const KEY_RE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
const MAX_KEY = 96
module.exports = {
ambient,
variablesFor,
renderTemplate,
resolveTemplate,
renderByKey,
renderInappByKey,
relativeUrl,
seedTemplates,
baseUrl,
KEY_RE,
MAX_KEY,
}

View File

@@ -0,0 +1,198 @@
// ── The mail transport registry ────────────────────────────────────────────
//
// ENGAGEMENT.md §3.1, Phase 1. A **channel** is what kind of sink this is (email,
// push, in-app); a **transport** is how one channel actually delivers. This file
// is the second half only; the channel half is `../channels.js`, which Phase 3
// added when `notification_channel_prefs` needed a single place for `defaultMode`
// to live. Its render/deliver functions are still deferred to the phases that can
// exercise them, for the reason this comment used to give about the whole file:
// registering a function nothing calls freezes a signature before anything has
// tried to use it.
//
// What this replaces: `mailer.buildTransport()` had Gmail's host, port and
// OAuth2 auth type as literals, so "which provider" was a code edit. Now the
// stored `email_config.transport` names a registration, and the registration
// declares its own credential fields — which drives the admin form, the encrypted
// blob's shape and the validation, from one place.
//
// **`credentialFields` is the contract.** It is read by three consumers that
// would otherwise drift: the admin form renders it, `sanitizeCredential()` below
// filters a submitted body through it, and `describe()` tells the client which
// values are secret so they are never sent back. Adding a field to a transport is
// therefore one edit, not four.
//
// **No transport may carry a default host, endpoint or sender** (§3.2 rule 1). A
// transport with no operator configuration is `unconfigured` and its channel is
// off — it never falls back to somewhere we chose. `scripts/checkNoExternalHosts.js`
// is the CI backstop for that rule; this file is where it would be broken first.
//
// Nothing here touches the database or the network at require time.
const log = require('../../utils/logger')('mailer')
// id → transport definition
const transports = new Map()
// Field kinds the admin form knows how to render. `secret` is the only one that
// changes behaviour server-side: it is write-only, so an unchanged value arrives
// as '' and must be read from the stored credential rather than overwritten.
const FIELD_KINDS = new Set(['text', 'number', 'secret', 'boolean'])
/**
* Register a mail transport. Shape-checked at the call and collision-checked
* here, the same validate-then-commit discipline `modules/registries.js` uses.
*
* @param {object} def
* @param {string} def.id stable id stored in email_config.transport
* @param {string} def.label human name for the admin form
* @param {Array} def.credentialFields [{ key, label, kind, required, help, default }]
* @param {Function} def.build (credential, config) → a nodemailer-shaped transport
* @param {Function} def.isComplete (credential) → boolean; are the required fields present
*/
function registerMailTransport(def) {
if (!def || typeof def !== 'object') throw new Error('registerMailTransport: definition required')
const { id, label, credentialFields, build, isComplete } = def
if (typeof id !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(id)) {
throw new Error(`registerMailTransport: invalid id ${JSON.stringify(id)}`)
}
if (transports.has(id)) throw new Error(`registerMailTransport: ${id} is already registered`)
if (typeof label !== 'string' || !label) throw new Error(`registerMailTransport(${id}): label required`)
if (!Array.isArray(credentialFields) || credentialFields.length === 0) {
throw new Error(`registerMailTransport(${id}): credentialFields required`)
}
for (const f of credentialFields) {
if (!f || typeof f.key !== 'string' || !f.key) {
throw new Error(`registerMailTransport(${id}): every credential field needs a key`)
}
if (!FIELD_KINDS.has(f.kind)) {
throw new Error(`registerMailTransport(${id}): field ${f.key} has unknown kind ${f.kind}`)
}
}
if (typeof build !== 'function') throw new Error(`registerMailTransport(${id}): build() required`)
if (typeof isComplete !== 'function') throw new Error(`registerMailTransport(${id}): isComplete() required`)
transports.set(id, { ...def, credentialFields: credentialFields.map((f) => ({ ...f })) })
return id
}
/** The registered transport, or null. Callers must handle null — a stored id can
* name a transport that no longer exists (a downgrade, a removed provider), and
* that must degrade to "unconfigured", never throw at send time. */
function get(id) {
return transports.get(id) || null
}
function has(id) {
return transports.has(id)
}
/** Every transport, as the admin form needs it: no functions, secrets flagged. */
function describe() {
return [...transports.values()].map((t) => ({
id: t.id,
label: t.label,
help: t.help || null,
credentialFields: t.credentialFields.map((f) => ({
key: f.key,
label: f.label || f.key,
kind: f.kind,
required: Boolean(f.required),
help: f.help || null,
default: f.default === undefined ? null : f.default,
placeholder: f.placeholder || null,
})),
}))
}
/**
* Filter a submitted credential body down to the transport's declared fields,
* coercing each to its declared kind. Anything not declared is dropped — the
* blob that reaches `secretBox.encrypt` only ever holds fields a transport asked
* for, so a client cannot smuggle extra keys into stored ciphertext.
*
* `secret` fields submitted empty are OMITTED rather than blanked, which is the
* "leave the existing one alone" convention `botConfig.save`/`emailConfig.save`
* already use; `mergeCredential()` is what puts the stored value back.
*/
function sanitizeCredential(id, body) {
const t = get(id)
if (!t) return {}
const out = {}
for (const f of t.credentialFields) {
if (!(f.key in (body || {}))) continue
const raw = body[f.key]
if (f.kind === 'secret') {
if (raw === undefined || raw === null || raw === '') continue
out[f.key] = String(raw)
} else if (f.kind === 'number') {
const n = Number(raw)
if (Number.isFinite(n)) out[f.key] = n
} else if (f.kind === 'boolean') {
out[f.key] = Boolean(raw)
} else {
out[f.key] = raw === null || raw === undefined ? '' : String(raw)
}
}
return out
}
/** Stored credential + the submitted patch. Omitted secrets keep their stored value. */
function mergeCredential(id, stored, patch) {
return { ...(stored || {}), ...(patch || {}) }
}
/** Non-secret fields only — safe to return over the admin API. */
function publicCredential(id, credential) {
const t = get(id)
if (!t || !credential) return {}
const out = {}
for (const f of t.credentialFields) {
if (f.kind === 'secret') continue
if (credential[f.key] !== undefined) out[f.key] = credential[f.key]
}
return out
}
/** Which declared secrets are actually held, so the form can say "set" without
* ever returning the value. */
function secretsPresent(id, credential) {
const t = get(id)
if (!t) return {}
const out = {}
for (const f of t.credentialFields) {
if (f.kind !== 'secret') continue
out[f.key] = Boolean(credential && credential[f.key])
}
return out
}
/** Does this credential have everything its transport needs to send? */
function isComplete(id, credential) {
const t = get(id)
if (!t) return false
try {
return Boolean(t.isComplete(credential || {}))
} catch (err) {
log.warn('transport isComplete threw', { transport: id, message: err.message })
return false
}
}
// Test-only: the registry is module-level state and a suite that registers a
// fake transport must be able to undo it.
function _reset() {
transports.clear()
}
module.exports = {
registerMailTransport,
get,
has,
describe,
sanitizeCredential,
mergeCredential,
publicCredential,
secretsPresent,
isComplete,
_reset,
}

View File

@@ -0,0 +1,96 @@
// ── SMTP — the baseline mail transport ─────────────────────────────────────
//
// ENGAGEMENT.md decision 4: Gmail OAuth2 is removed, SMTP is the baseline. This
// is the only registered transport, and it is deliberately plain SMTP rather than
// anything provider-shaped — a relay (Mailgun, SES, Postmark), a self-hosted MTA
// and Gmail-with-an-app-password are all reachable through these five fields, so
// one transport covers all three postures §7.1 Q5 asks to document.
//
// **No defaults for host, port, user or sender.** §3.2 rule 1: a transport with
// no operator configuration is unconfigured, never pointed at somewhere we chose.
// `secure` gets a default because it is a protocol choice, not a destination — and
// even that is only a form default, not a fallback applied to a stored blank.
//
// **`secure` is the field operators get wrong**, so its help text says which port
// each setting means: `secure: true` is implicit TLS on 465, `secure: false` is
// plaintext-then-STARTTLS on 587 (which nodemailer upgrades automatically). The
// combination that silently fails is 587 with secure on — the handshake hangs
// rather than erroring cleanly — which is exactly why "Send test" is the real
// verification path now (§1.2a consequence 2).
const nodemailer = require('nodemailer')
const registry = require('./index')
const CREDENTIAL_FIELDS = [
{
key: 'host',
label: 'SMTP host',
kind: 'text',
required: true,
placeholder: 'smtp.example.com',
help: 'Your relay or mail server. No default — nothing is sent until you set this.',
},
{
key: 'port',
label: 'Port',
kind: 'number',
required: true,
default: 587,
help: '587 for STARTTLS (most relays), 465 for implicit TLS, 25 for an unauthenticated local MTA.',
},
{
key: 'secure',
label: 'Implicit TLS',
kind: 'boolean',
required: false,
default: false,
help: 'On for port 465. Leave off for 587 — the connection still upgrades to TLS via STARTTLS.',
},
{
key: 'user',
label: 'Username',
kind: 'text',
required: false,
help: 'Leave blank for an unauthenticated local relay.',
},
{
key: 'password',
label: 'Password / API key',
kind: 'secret',
required: false,
help: 'Stored encrypted and never returned. For Gmail this is an app password, not the account password.',
},
]
// Authentication is optional (a local MTA on port 25 needs none), so the only
// hard requirement is a destination. A username without a password is not
// "complete" — that combination authenticates as nobody and fails at the server.
function isComplete(credential) {
const c = credential || {}
if (!c.host || !Number(c.port)) return false
if (c.user && !c.password) return false
return true
}
function build(credential) {
const c = credential || {}
const options = {
host: String(c.host),
port: Number(c.port),
secure: Boolean(c.secure),
}
if (c.user) options.auth = { user: String(c.user), pass: String(c.password || '') }
return nodemailer.createTransport(options)
}
registry.registerMailTransport({
id: 'smtp',
label: 'SMTP',
help: 'Any SMTP relay or mail server. See the operator guide for the three supported postures.',
credentialFields: CREDENTIAL_FIELDS,
isComplete,
build,
})
module.exports = { CREDENTIAL_FIELDS, isComplete, build }

Some files were not shown because too many files have changed in this diff Show More