91 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
46f43a5fd6 Merge pull request 'feat(teams): phase 7 — the slash-command seam, and the bot's first tests' (#157) from feature/teams-phase7-slash-commands into edge
Reviewed-on: #157
2026-08-19 00:16:29 +00:00
aca4d23179 fix(teams): a private answer has to be private, and the deferral decides that
All checks were successful
PR Checks / client-build (pull_request) Successful in 39s
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / bot-tests (pull_request) Successful in 8m50s
Found on the live rig. Ephemerality is a property of the DEFERRAL, which happens
before the handler has said anything — so the envelope's `ephemeral` was being
read and then ignored, and `/guild`'s "not shown to your account" refusal was
posted into the channel, announcing a member's access level to everyone in it.

When the handler wants privacy the deferral did not give it, the deferred reply
is now withdrawn and the answer arrives as an ephemeral follow-up. The
interaction token stays valid, so this is a supported path and not a trick; the
cost is a "thinking..." that appears and vanishes. There is no reverse case — a
command deferred privately must not become public because a handler omitted a
flag — and a refusal is always private whatever the command's usual privacy.

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:53:34 -05:00
b1d3b87cd6 Merge pull request 'feat(teams): phase 6 — notifications, and the email sink the web never had' (#156) from feature/teams-phase6-notifications into edge
Reviewed-on: #156
2026-08-18 23:10:20 +00:00
13312d7fc3 fix(teams): make "replace the whole set" actually replace it
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 8m53s
Found walking the live rig, which is the only place it could be found: every unit
test and the settings screen itself send every row, so the bug was invisible to
both.

`PUT /auth/me/notifications/teams` documents itself as replacing the whole set. It
did not — it wrote the entries it was given and left every other preference
standing. So `{"teams": []}` cleared nothing, which is precisely the body the route
requires the array for: the field is mandatory even when empty so that clearing
everything is expressible, and it was the one thing that did not work.

A Team the caller could have named and did not now returns to its defaults. RESET
rather than deleted, and the difference is `last_digest_at`: that column is the
digest worker's state and not a preference, so dropping the row with it would make
every visit to the settings screen re-open a day-wide digest window and mail
somebody a summary they had already read.

Walked again after the fix on the real database: the empty set clears, an entry
naming a Team the caller is not in is still dropped, and the digest stamp survives.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:01:59 -05:00
5fa88baa0a test(teams): the refusals, which is most of what a notification feature is
A notification feature is mostly things that correctly do NOT happen, and each of
these is invisible until it goes wrong in production: a departed member and a
revoked guest are not recipients; a mute subtracts per Team and leaves the user's
other Teams alone; the author of a post never receives the notification about it;
forums switched off silences the forum streams including the digest; a Team's
first roster wakes nobody; a failed send does not stamp `last_digest_at`.

Two real defects came out of writing them.

`Number(null)` is 0 and 0 is an integer, so a null in a caller's id list survived
`filter(Number.isInteger)` and rode into an IN clause as user id 0. No row has id
0, so it was harmless — which is exactly why it would never have been noticed.
Fixed in all three places that filter ids.

`recipientIds: db.recipientIds` in the model captured the function OBJECT at
require time, so the layer below could never be substituted. That is not only
untestable; it means the model was not really the seam it claimed to be. Wrapped
so `db.x` resolves at call time.

The registries catalog assertion is now an exact five-element list, so a
shard-content stream creeping back into core's registration fails here rather
than shipping.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:35:23 -05:00
b458c1f46f feat(teams): the web surface — a notifications screen that did not exist
This is phase 6's first finding, and it changed the phase's shape.

TEAMS.md §6.3 says the per-Team mute list is surfaced "under the existing
notification settings screen". There was no such screen. `/auth/me/notifications/*`
was built for the Android app in M7 and had ZERO web consumers — a browser could
not see the stream catalog or its own subscriptions at all. That is tolerable
while push is the only sink, because push needs the app anyway. It is not
tolerable for email, whose entire argument is the web-only user who runs neither
the app nor Discord, so the sink and the screen to configure it had to ship
together.

`/account/notifications` carries all three: what to be told about, which Teams,
and whether any of it reaches a mailbox — in the order a user actually reasons
about them.

The mute toggle goes in a THIRD module-declared slot, above the roster, because
muting is an action ON the guild page while the feed and forum are content IN it.
It renders nothing for a viewer with no preference available, which is a privacy
property rather than a tidiness one: whether a preference EXISTS for a Team
answers "is this person in it", and the guild page is public.

`/unsubscribe/:token` is public and POSTs on mount — the link the user clicked was
a GET, and a GET that mutated would be triggered by every mail-client link scanner.

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:34:54 -05:00
686a214979 feat(teams): email as the third sink, with a digest that keeps no queue
A web-only user on a deployment running neither the Android app nor Discord gets
no notification that someone replied to their own thread — which is most users on
most deployments, and a forum where replies are invisible is a forum nobody
returns to. Email is a third consumer of the recipient set the previous commit
builds, not a fourth pipeline.

Unlike a push tickle, an email carries content: a mailbox is a destination the
recipient chose, not an untrusted relay reached by an unguessable topic. It
carries a title and an excerpt, never a full post.

The digest COMPUTES AT SEND TIME and keeps no pending-items queue. The only state
is `last_digest_at`. Three properties fall out, and the third is why it was chosen:
a deployment down for two days sends one correct digest rather than replaying a
backlog; a post a moderator hid after it was written is simply not in the query;
and a user who lost forum access between the post and the send is no longer in
the recipient set, so they are not emailed content they can no longer read.

`last_digest_at` is stamped only on a SUCCESSFUL send — stamping first would
quietly eat a day of somebody's notifications every time the mail provider had a
bad minute.

One-click unsubscribe is a stateless HMAC rather than a token table. Every
property that makes a password-reset token a row is absent: the link sits in a
mailbox for months so it has no useful expiry, and clicking it twice must mean
what clicking it once meant. Its whole capability is setting `muted` for one
(user, Team) pair.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:34:38 -05:00
26c23bd603 feat(teams): the notification core — four streams, and a recipient set
Phase 6's foundation: the fan-out shape the existing pipeline could not express.

`pushDispatch.publish` answers "everyone subscribed to a stream" and "this one
owner". Team notifications need "these N users", because Team scoping cannot live
in a stream id: the catalog is a static registration validated at boot against a
namespaced pattern, so a stream per Team is unexpressible, and stream ids are
stored in `notification_subscriptions` rows that would need collecting every time
a Team archived. So there are FOUR fixed core streams and the Team lives entirely
in the recipient set.

`team_notification_prefs` is opt-out for push and opt-IN for email — the two sinks
default opposite ways, and the asymmetry lives in the column defaults so no
condition anywhere has to remember it.

One recipient query serves all four streams, because §6.2's two populations are
the same set written twice: "active members with a user_id plus active grants" IS
"everyone with resolved forum access". Mutes are subtracted in SQL rather than by
the caller — there is no function here that returns an unfiltered set.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:34:24 -05:00
0467c71ea1 Merge pull request 'feat(teams): phase 5 — Forum 5b, discussion + moderation + reports' (#155) from feature/teams-phase5-discussion into edge
Reviewed-on: #155
2026-08-18 18:36:50 +00:00
c970caee16 fix(teams): let a post-moderation mistake reach the model that explains it
All checks were successful
PR Checks / bot-install (pull_request) Successful in 19s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 34s
Found on the live rig. `moderatePost` answers `pin` with «"pin" applies to a
thread, not to a post» and an invented action with "Unknown moderation action" —
the distinction exists because they are different mistakes and a caller who made
the first one has a bug worth naming precisely.

The route's validator listed only the four actions a post accepts, so `pin` never
got there: it came back as a generic "Validation failed". The precise message was
written, documented, unit-tested — and unreachable through the API, which is the
worst of both, because the branch reads as live code and is only exercised by its
own test.

The validator now lists all eight and lets the model discriminate. Both answers
are 400, neither is a security boundary, and widening the list is not removing it
— an action outside the enum still stops at the validator, which the added route
test asserts alongside the `pin` case.

Nothing else the walk exercised needed changing. The whole phase 5 surface was
driven against a real server, real MariaDB and real sessions across four
identities — an ordinary member, a granted non-member guest, a Team leader and a
staffer — plus a browser pass over the forum panel, the reports queue, the
per-Team forum ledger and the settings screen. Notably confirmed live: a locked
thread refuses replies from all four identities at 409; a hidden post renders for
the leader and staff with Unhide and **no Edit control for anyone**; the report
queue answers 200 to staff and 403 to the leader, the member and the guest alike;
and turning the edit window down to 0 stops the author while leaving staff
unbounded.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 13:25:21 -05:00
3f7e61af1c feat(teams): the phase 5 surface — discussion, replies, reports, and two admin screens
241 client tests pass (224 before).

**The forum panel becomes a forum.** It was "Announcements" with one composer;
it now has two, because phase 5 split one server capability into two: `canPost`
means "may open a discussion" and every participant may — a granted guest with no
game character included, which is path 3 doing its job — while `canAnnounce` is
the leader-only half `canPost` used to carry alone. Threads gain replies, an edit
control, per-post moderation and a report control, all still inside the one slot
the module declares, still navigating by `?thread=`.

**Almost nothing here is the client's decision, and the file says so.** `canPost`,
`canAnnounce`, `canReply` and each post's `canEdit`/`editableUntil` are read, not
computed. The one local judgement is a ticking clock that WITHDRAWS an edit offer
whose deadline passed while the page sat open — it can never grant one, because a
time-bounded permission must not take its clock from the party it bounds. That
asymmetry is the first thing client/test/teamForum.test.js asserts.

The panel's pure parts moved to `lib/teamForum.js` so they can be tested without a
browser, following teamActivity.js and teamAdmin.js. Two of them are subtler than
they look:

  * `stripToText` decodes entities AFTER stripping tags, and `&amp;` last of all.
    Decoding first turns an author's literal "&lt;script&gt;" into a real tag the
    strip pass then deletes — silently losing text that was never dangerous.
  * `threadSummary` counts REPLIES, which is one fewer than `postCount`. Showing
    the raw count tells a reader a brand-new thread already has one reply.

**Three admin surfaces.** The forum settings screen gains the edit-window field
(0 = posts permanent once written). The reports queue is a new screen beside
Appeals — under moderation rather than under Teams, because a staffer working a
queue should have one place to work and `target_type` is deliberately open-ended,
so the next reportable thing arrives as a row rather than as another nav entry.
Its copy tells a member where a report lands and that reporting changes nothing,
because a member who expects a post to vanish and watches it stay reports it
again. There is no leader-facing view and there is not meant to be.

And the per-Team forum moderation ledger finally renders: the route and
`api.admin.teamForumModeration()` have both existed since phase 4 with nothing
calling them, which made `actor_role` — the column that keeps a leader's ordinary
housekeeping distinguishable from a staff intervention — readable only from a DB
client.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 13:08:59 -05:00
128de0ff2e test(teams): phase 5's server surface, and the negative property under it
1008 pass (972 before). The tests worth reading first are the ones that pin a
property no screen would look different without:

  * **The edit window is decided on the server, twice.** One test proves the read
    path stamps `canEdit` per post per viewer; another proves the WRITE path
    re-derives it from `created_at` and refuses a stale edit even though the
    client was told it could — because a time-bounded permission must not take its
    clock from the party it bounds.

  * **A locked thread refuses staff too**, asserted over member, leader and staff
    in one loop, at 409 rather than 403: well-formed request, refusing state.

  * **delete → restore is reversible for images.** Without the second half of the
    pair a restored post returns its words and loses its pictures a retention
    window later, silently — the test asserts both calls and that `hide` makes
    neither.

  * **Post moderation recomputes the thread's counters** rather than nudging them;
    the test runs hide → unhide → hide, which is the cycle a delta gets wrong.

  * **acceptance: nothing in the report model is reachable by a Team leader.** The
    negative property is the whole point of §5.6 and negatives are what nobody
    notices going, so it is asserted directly — the module's function surface is
    pinned, and `queue`/`handle` are checked not to mention leadership at all. If
    a leader-facing queue is ever wanted it is the org lead's decision, and this
    test is what makes somebody ask.

  * **A report never changes the content it is about**, proved by stubbing every
    mutation the forum has to throw. If filing a report touched a status then
    "report" would BE moderation, and the first person to work that out would have
    found a way to hide anything on the site.

The test suite caught one real defect: `describeTarget` returned `undefined` for a
hard-deleted target, and `undefined` is dropped by JSON.stringify — so the
documented `target: null` would have reached clients as an absent key.

Two phase-4 tests were updated rather than added to, both because phase 5 changed
what they describe: `canPost` split into `canPost` (open a discussion, everyone)
and `canAnnounce` (leaders), and `discussion` is no longer a refused thread type.
Phase 5's four new player routes are added to acceptance criterion 2's list, so
"with the forum off every forum route 404s" keeps covering the whole surface.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 12:58:15 -05:00
fff14848f1 feat(moderation): member-raised abuse reports, to site staff only
TEAMS.md §5.6. **Core has had no user-facing report flow of any kind** — the
`moderation`, `mod_notes` and `appeals` tables are all either staff-initiated or
Discord-sanction-shaped, and nothing anywhere let a member say "this is a
problem". That was survivable while every piece of content on the site came from
staff; phase 5 lets players write to each other, so it stops being.

The gap has a specific shape: leaders moderate their own Team's forum, and a
Team's leaders are exactly the people who will not report their own Team. So the
whole point of this queue is a path that routes AROUND a Team's own leadership.
Org lead settled it on 2026-08-18: **reports are site administration only** —
there is no leader-facing view of this queue, not even a read-only one scoped to
their own Team. §5.6's "a leader may also see and act on reports for their own
Team" is not implemented and is not deferred.

`content_reports` is deliberately generic — `target_type` is a VARCHAR so a wiki
page or a news comment becomes a value rather than a table — and the queue is
mounted beside appeals under /admin/moderation rather than under Teams, because a
staffer working a queue should have one place to work.

**§5.6's literal unique key has a defect and this does not copy it.** Written as
(target_type, target_id, reporter_user_id, status) it makes CLOSED rows collide
with each other too: reporter reports a post, staff dismiss it, the behaviour
recurs, they report again — and the second dismissal is an UPDATE into a tuple
that already exists, so working the queue starts throwing duplicate-key errors on
the first repeat reporter. The key is on a generated `open_marker` instead, the
same trick `team_forum_grants.active_marker` uses: 1 while open, NULL once
closed, and NULLs are distinct — which is what §5.6's prose asks for, "one open
report per (target, reporter)".

Two other departures from the doc, both small and both flagged in the docs PR:
`handled_note`, because a queue whose resolution reason lives only in an
activity_log line is one where the next staffer to see a repeat report cannot
find out why the last was dismissed; and a CASCADE on `team_id`, so a deleted
Team does not leave a queue full of reports about content that no longer exists.

Also here: a report is filed against a target the model verifies really belongs to
the Team the request came through, or the queue's per-Team filter would quietly be
lying; the queue resolves every row's target in three batched reads rather than
N+1, which is §5.6's fourth rule (uploader, size and sniffed type without
hunting) actually paying for §5.5.4's attribution table; a target that has since
been hard-deleted comes back null and the report still lists, because "somebody
reported this and by the time we looked it was gone" is a fact a moderator needs;
and every transition writes activity_log, `dismissed` included — a queue where
acting is audited and declining to act is not is one where the cheapest way to
make a report vanish leaves no trace.

`teams_forum_edit_window_minutes` gains its range validation on the admin settings
PUT and is seeded at 15, so the value on the settings screen is the value in
force. Route manifest and OpenAPI regenerated: 6 operations added, 0 lost.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 12:51:42 -05:00
ae0d27cf27 feat(teams): discussion threads, replies, the edit window and post moderation
Phase 5's server half — TEAMS.md §5.1's "5b". The schema for all of it landed in
phase 4, so this adds no ALTER: every column it needed (`type`, `locked`,
`edited_at`, `edited_by`, the post table's `status`, the ledger's
`target_type='post'`) was already there waiting.

  * `teams_forum_edit_window_minutes` (0…1440, default 15) joins the forum's
    settings. It fails closed to ZERO rather than to its default, which is the
    opposite of what it looks like it should do: the risk an edit window bounds is
    an author rewriting a post out from under a reader quoting it or a moderator
    about to act on a report, so the safe answer during a DB fault is "nobody may
    edit for the next minute". A stale uploads acknowledgement freezes this key
    too — it is a forum setting.

  * Thread creation splits its authority BY TYPE, which is what phase 4's comment
    said would happen here rather than widening the leader gate. An announcement
    stays leader-authored; a discussion is open to every participant, and
    "participant" includes a granted non-member with no game identity — path 3
    doing its job. `type` still defaults to `announcement`, so a phase-4 client
    keeps meaning what it meant.

  * Replies refuse three ways with deliberately different codes: 404 for absent or
    hidden, 400 for an announcement (which takes no replies by TYPE, not by being
    closed), and 409 for locked — well-formed request, refusing state. Locked
    refuses staff too; they hold `unlock`, and unlock/post/relock reaches the same
    place leaving three ledger rows that say so.

  * The edit window is evaluated on the server twice, on purpose. The read path
    stamps every post with `canEdit`/`editableUntil` so the client knows whether to
    draw the control; the write re-derives it from `created_at` before allowing
    anything. A time-bounded permission must not take its clock from the party it
    bounds. Staff are not time-bounded, and a staff edit of someone else's words
    writes `activity_log` while a member fixing their own typo does not (§5.3).

  * Post moderation shares the thread ledger via `target_type='post'`, so
    "everything moderated in this Team" stays one query. `pin`/`lock` are refused
    by name rather than as unknown actions — they describe a thread's place in a
    list and its openness to replies, neither of which a post has. Counters are
    RECOMPUTED after each action rather than nudged, because hide → unhide → hide
    is a cycle a delta gets wrong the first time a step is retried.

Two fixes to phase 4 code this work reached: `softDeleteUploadsForPost` bound its
two arguments in the wrong order (never fired — nothing called it until post
deletion did), and it had no inverse, so `delete` → `restore` would have returned
a post's words and silently lost its pictures a retention window later.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 10:43:09 -05:00
763de66ebb Merge pull request 'fix(teams): four defects the live rig found in the phase 4 forum' (#154) from fix/teams-phase4-live-rig into edge
Reviewed-on: #154
2026-08-18 15:21:55 +00:00
5baada08ef fix(teams): four defects the live rig found in the forum
All checks were successful
PR Checks / bot-install (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 34s
PR Checks / client-build (pull_request) Successful in 8m48s
None of these could fail a unit test, and three of them break the feature for the
operator rather than for the code.

**The uploads acknowledgement was a one-way door.** A settings form sends every
field it owns, so once `teams_forum_images` was `uploads`, every later save
re-sent `uploads` — and the gate fired on the VALUE being present rather than on
the mode being SELECTED. The operator could never change a forum setting again,
and the thing they would reach for in a hurry, switching the forum off, was
exactly what came back 400. The gate now passes when an acknowledgement for the
version in force is already on record AND uploads is already the stored mode:
there is no new consent to take. A transition INTO uploads still asks, and a
reworded notice is still caught by assertSettingsWritable.

**An uploaded image could never become a picture.** `uploads` mode hands the
composer `/uploads/<name>.png`, the composer puts it in the body as text — the
author never writes markup, which is the whole design — and the renderer only
rewrites ANCHORS. The linkifier matched absolute http(s) URLs only, so the write
path could not produce the anchor the read path looks for, even though
`isEmbeddableImageUrl` had accepted those paths since the first commit. The two
halves disagreed and only a real upload showed it.

**The embed sat beside its link, not beneath it**, because an <img> is inline, and
nothing capped a remote image to the column — one post from a host serving a
4000px file would have blown the layout out. Core now emits `class="forum-embed"`
and the stylesheet owns both. A class rather than an inline style because the
style would then have to survive the client's DOMPurify pass, and its CSS
sanitiser is a larger thing to reason about than one class name.

**The panel's buttons had no button styling.** `btn-ghost` is a MODIFIER — every
other call site in this codebase pairs it with the base `btn` — so alone it
contributed colours and no geometry, and the controls rendered as bare boxes.
Small inline actions use `pill`, which is what the rest of the admin surface uses
for exactly these. Same class of mistake as the Material one in the Android M12
phase: the modifier carries no base.

Also: the post body now re-sanitises client-side like every other body-HTML
surface on this site, with `ADD_ATTR: ['referrerpolicy']`. That argument is
load-bearing — DOMPurify's default allowlist carries `loading` but not
`referrerpolicy`, so a plain sanitize() call silently strips the one attribute
limiting what a remote embed leaks to the host serving it, which is the privacy
property the admin help text promises.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 09:54:05 -05:00
16e31de087 Merge pull request 'feat(teams): phase 4 — the forum access model, announcements and the operator's controls' (#153) from feature/teams-phase4-forum-access into edge
Reviewed-on: #153
2026-08-18 14:18:08 +00:00
57286594e7 test(teams): the four acceptance criteria, and regenerate the API artifacts
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 32s
Four tests are named "acceptance" and are Phase 4's criteria verbatim. Each names
a property the code around it can lose without any screen looking different:

1. A granted, unlinked account reads the forum, is absent from the member rows, and
   is still refused external-platform eligibility. The membership projection is
   asserted byte-identical across a grant, which is what "non-contamination" means
   in practice.
2. With the switch off every forum route 404s AND nothing is read or written on the
   way there — a guard that 404s after loading the thread is one that still bumped
   a counter.
3. The stored HTML is byte-identical between `disabled` and `remote`; only the
   rendered output differs. That is the property the renderer-owned design exists
   to give, and it is what makes flipping the policy back a no-op rather than a
   migration.
4. Selecting `uploads` without a matching acknowledgement is refused server-side,
   with the admin checkbox bypassed.

Plus the ones that are not criteria but are the same kind of claim: an author
cannot smuggle an <img> or its attributes through in any mode, http and non-image
URLs stay plain links, a leader cannot revoke a staff-issued grant, a demoted
account stops protecting the grants it made, moderation records which authority was
exercised, and a RIFF container that is not WebP is not accepted as one.

Twelve new routes in the manifest, all annotated and in the OpenAPI spec.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:24:24 -05:00
cbb7339a3a feat(teams): the forum panel core fills, and the operator's controls
The forum had nowhere to live. TEAMS.md 3.1 gave it a CORE page, and phase 3
deleted every core Team page — Teams is a contract primitive and core does not own
the word for one. So the forum follows the activity feed: module-uo declares a
second place on its guild page and core fills it.

TWO slots rather than one, because a slot holds one component and the first fill
wins. Stacking the feed and the forum into a single fill would take from the module
the ability to place core's two contributions separately on its own page, which is
the whole point of the module owning it.

The panel navigates by SEARCH PARAM (?thread=12) rather than by route. A thread has
to be linkable and core cannot mount a route for one — the route belongs to the
module's page — so a search param gives a shareable URL under whatever path the
module chose, with the back button intact and no core route anywhere in it. That is
why the fill is one component holding both a list view and a detail view.

Post bodies arrive already rendered by the server under the current image policy,
which is why they are set as HTML here rather than sanitised again: the body was
cleaned on write with the forum's own profile, and any <img> in it was emitted by
core's own renderer with a fixed attribute set. A client-side sanitiser would have
to strip exactly the tag core just decided to add. The published image mode is read
only to decide which composer to draw — never what renders.

The composer puts an uploaded file's URL into the body as TEXT, not as a tag. The
author never writes markup, which is what keeps the operator's policy enforceable.

The admin panel carries both settings, the always-on help text, and the
confirmation dialog with its two checkboxes and one recorded acknowledgement — plus
the three additions the org lead settled: attribution and staff removal, the
warning that disabling later does not delete existing files, and who "users"
actually means. A stale acknowledgement raises a banner and freezes the settings;
it does not turn uploads off.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:24:24 -05:00
4ac353684a feat(teams): harden the upload path for an uploader who is not an admin
The existing admin upload path is already good for an admin: an 8 MB cap, a
mimetype allowlist, a random filename, an extension derived from the mimetype map
and never from originalname, and nosniff forced on serve. All of it is kept. What
it does not have is anything that assumes a hostile uploader, because until now it
has not had one.

Magic-byte sniffing, because `file.mimetype` is the client's own Content-Type
header — a player can send image/png with arbitrary bytes and land arbitrary
content under a .png. Unrecognised bytes are a rejection and never a fallback to
what the header claimed. The file is on disk before it can be sniffed, so the
rejection path removes it: a rejected upload left on disk is the same
disk-exhaustion vector reached another way.

A rolling per-account byte quota and a per-IP rate limit, because community uploads
with no ceiling is disk exhaustion on the operator's own host.

An attribution row per accepted file. Not bookkeeping: the acknowledgement is
meaningless if "who uploaded this" cannot be answered afterwards, which is exactly
what the operator has just accepted responsibility for.

A nightly sweep for soft-deleted files past retention and for never-referenced
orphans, in the same in-process shape as the activity prune. It runs whether or not
`uploads` is the current mode, and that is the point — an operator who turns
uploads off after a problem still has the files, and a sweep that switched itself
off with the setting would strand exactly the bytes they were trying to be rid of.
It works from the forum's own rows outward and never from the directory listing
inward, because UPLOAD_DIR is shared with the admin upload path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:24:02 -05:00
e27c368234 feat(teams): the grant flow, announcements, and the routes behind both guards
Path 3's WRITE half. The resolver landed in phase 2; this is who may hand access
out, to whom, and what stops a leader turning a Team forum into open hosting on
the operator's site.

Two authorities, and not one authority with different reach. Staff may act on any
Team, uncapped, and may revoke anything. A leader may grant and revoke ordinary
access on their own Team, is capped at `teams_max_grants_per_team` (default 50),
is rate-limited, and may NOT revoke a staff-issued grant — which is what stops a
leader undoing a moderation decision. The issuer's role is checked at revoke time
rather than stored, so an account that has since lost its staff role stops
protecting the grants it made.

Nothing on this path writes team_members, in either direction. A grant may name any
account, including one with no linked game identity — that is the point of it — and
that account stays off the roster, out of every count, and ineligible for external
platforms.

Announcements are a degenerate thread rather than their own object, so phase 5 adds
no migration. Moderation records WHICH authority was exercised: a staff action also
writes activity_log, a leader's writes only the Team's own ledger. Merging the two
would make a guild leader locking a thread an appealable Discord sanction.

Every forum route answers 404 while the switch is off, and 404 — never 403 — to a
caller with no access: in a private room the contents and the existence are the
same secret. The grant routes deliberately answer even while the forum is OFF,
because a toggle-off revokes no grant and the access list has to stay manageable.

Under /player rather than /admin: a leader is a player, and the /admin tier gate is
requireRole('admin','editor','moderator') — putting a leader endpoint behind it
would mean widening that gate.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:23:47 -05:00
fb70013adf feat(teams): the forum's own HTML profile, and core's image renderer
The load-bearing decision of the whole forum design, and deliberately not how the
rest of the site works.

Core's shared sanitizer allows <img> from any host — it is tuned for rich text
from the ADMIN editor, where the author is already trusted. Handing that to
arbitrary players would make `teams_forum_images` unenforceable: every post could
hotlink in every mode and the setting would be decoration. So the forum derives
its own profile in which `img` is never an allowed tag, in any mode.

What an author writes is a URL. What decides whether it becomes a picture is this
file's renderer, at READ time. Four properties fall out: the policy cannot be
evaded, because the only code that can emit an <img> is core's; flipping the
setting back to `disabled` un-renders every image on every existing post with no
data migration, since the images were never stored; there is no author-supplied
srcset, onerror, width or style to smuggle anything through; and a blocked or dead
image degrades to the URL the author actually wrote.

Two details found while building it:

`rel` had to be ADDED to the allowed attributes to make links safer, not laxer.
The profile writes rel="noopener noreferrer nofollow" through a transform, and
sanitize-html strips any attribute not on the allowlist — including one its own
transform just added. Without the entry, every forum link shipped without noopener.

The bare-URL linkifier runs AFTER sanitising, over the sanitiser's own output and
only on text outside tags. That ordering is the security property: every text node
is HTML-escaped by then, so the matched URL is safe in both the href and the link
text. Running it first would be an injection point.

https: only, because the CSP is `img-src 'self' data: https:` — an http: image is
blocked by the browser and renders broken, which presents as "images are broken on
my forum" with nothing in any log. And the server never fetches a user-supplied
URL: that is an SSRF vector, and an allow-set is useless when the point is
arbitrary hosts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:23:32 -05:00
11fd9821bf feat(teams): the forum schema, the operator's two switches, and the ack gate
The whole forum schema lands at once — threads, posts, the moderation ledger and
upload attribution — including the columns only phase 5's discussion threads use.
That is TEAMS.md 5.1's split BY LAYER rather than by feature: phase 5 opens paths
instead of migrating data.

Three settings keys, and only one of them is ordinary. `teams_forums_enabled` and
`teams_forum_images` are enum keys on the existing admin settings endpoint;
`teams_forum_images` also carries a server-side PRECONDITION, which is why the
three live in their own model rather than in the generic setMany() loop where a
reader would never find it.

The gate is the server's. `PUT teams_forum_images = 'uploads'` is rejected 400
unless the same request carries the acknowledgement version — the admin checkbox
is how the gate is presented, never the gate. What is stored is the TEXT VERSION,
so "which wording did they agree to" is answerable later; settings already record
updated_by/updated_at, and an activity_log row puts it in the staff audit trail.

A reworded notice makes a stored acknowledgement stale, and neither obvious answer
is right: uploads KEEP WORKING, and no other forum setting may be saved until it is
re-given. Non-destructive, and impossible to ignore.

Both reads fail closed. A DB fault reports the forum off and images disabled — a
forum that 404s for a minute is the cheap failure; a policy that is not a policy
is not.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:23:15 -05:00
7ed2ac9983 Merge pull request 'feat(teams): the activity feed, the roster projection, and the inverted slot' (#152) from feat/teams-phase3-pages-activity into edge
Reviewed-on: #152
2026-08-18 02:11:06 +00:00
5d9d10b245 refactor(teams)!: Teams is a contract, not a surface — invert the slots
All checks were successful
PR Checks / bot-install (pull_request) Successful in 20s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 35s
Org lead's correction, and it changes what this phase ships.

TEAMS.md §3.1 and §3.5 put four public pages and three nav rows in core. They
should never have been core's. **Teams is the platform primitive that the API
contract exposes; the module builds the pages on top of it.** module-uo builds
guilds; the Rust module that comes next builds clans. Core does not own the word
for a Team, so a core page under a noun core invented would have sat beside
module-uo's existing /uo/guilds saying the same thing in the wrong vocabulary.

Removed: /teams, /teams/:slug, /teams/:slug/roster, /player/teams, the public
and portal nav rows, the `teams` feature flag and the core feature provider that
answered it. /admin/teams stays — an operator inspecting the primitive is
looking at the primitive.

Kept, and unchanged: the tables, the reconciler, the access resolver, the
activity feed, the retention prune, the whole public/player/admin API,
optionalAuth and the roster projection. That is the contract, and it is what
this phase was actually for.

**So the extension slots invert, which is a new direction in MODULE_API §3.7.**
`team.overview` and `team.member.row` assumed core rendered the page. In their
place `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on
its own page and core fill it. Core fills `uo.guild.detail` with the Team
activity feed — the one part of that page core cannot hand over, because only
core can resolve whether the viewer is inside the Team and the public/members
split is a security boundary.

Three things about the inverted direction are load-bearing:

  - the name is namespaced under the declaring module and that is enforced, not
    conventional: it is the only thing keeping two modules off one name;
  - core's fills are applied at MOUNT rather than eagerly. Core's bundle
    evaluates before every module chunk, so when core registers a fill the slot
    does not exist yet — filling eagerly would silently do nothing;
  - a fill for a slot nobody declared is a no-op, never an error. The declaring
    module is simply not installed, which is the ordinary case. That is the
    opposite of §3.7, where an unknown slot throws, and the asymmetry is real:
    there, core declares first, so an unknown name is always a typo.

`Slot` becomes the eighth member of the shared UI kit, so a module renders the
place with core's own error boundary. It matters more here than anywhere else in
the kit: the thing being contained is core's content failing inside the module's
page.

`GET /public/teams/by-external/:moduleId/:externalId` is added because a module
names a Team in its own vocabulary and core keys the feed by slug. The module id
is matched rather than trusted — an external id is unique only within a module.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:58:07 -05:00
203ce9c654 chore(teams): regenerate the OpenAPI spec and the route manifests
`npm run swagger` + `npm run routes:manifest` for the one added route,
`GET /api/v1/public/teams/:slug/activity`, and for `optionalAuth` joining
`/teams/:slug/members`.

The guards manifest names `optionalAuth` on both, which is the point of that
file: a reviewer can see that two public routes now read the caller's identity
without reading the routers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:16:04 -05:00
8f4aff6946 feat(teams): the public Team pages, the two slots and the nav flag
TEAMS.md §3.1–§3.5. Four core pages — the index, a Team's overview, its full
roster and the player portal's "My Teams" — plus the two extension slots a
module adds to them, and the nav rows that lead there.

These are CORE routes, not module ones. A Team is a core platform entity that a
module merely populates, so the whole experience renders on bare core; a module
adds to these pages rather than supplying them.

`team.member.row` is declared with `{ displayName, isLeader, linked }` and not
§3.4's `{ memberKey, userId, displayName }`. The two documents contradict each
other and §3.2 is the one that is a security rule: a slot component runs in the
browser, so those props can only reach it by publishing a game-internal
identifier and a site account id in every public roster response, for every
visitor, module installed or not. Recorded as an amendment.

The presentation logic is split into lib/teams.js with its own tests, following
lib/teamAdmin.js, because these pages have to state differences that read as
bugs unless they are worded deliberately:

  - "37 members · 21 linked" — the gap is information (a character with no site
    account behind it), and the header says what each number IS rather than
    showing both and hoping;
  - an empty roster has three unrelated causes — nobody in the Team, a rung
    that shows nobody, and a module that could not be asked — and reporting the
    last as the first is a statement about the game that happens to be false;
  - a stale projection says how old it is rather than presenting itself as
    current.

`teams` is the first CORE nav row to carry a `feature` since the shard rows left
with the module cutover, and it brings core's own feature provider back with it.
It gates on whether this deployment has Teams AT ALL, not on who is looking —
Team pages are public and the server gates them. It fails open, so an unknown
answer shows the link: a Teams link leading somewhere empty is a far cheaper
mistake than a Team page nobody can find.

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:15:36 -05:00
aa332eda82 feat(teams): the activity feed, its two writers and its retention
TEAMS.md Part 4. `team_activity` takes items from two sources and treats them
identically on the read path: core writes its own membership and rename items
with source='core', and a module pushes game items through
`ctx.teams.activity.push`, which stops throwing and starts working.

Core writing here too is deliberate — the rendering path is exercised by core's
own content from day one, so the feed is never empty on a deployment whose
module pushes nothing.

Three rules shape the model:

  - core never composes a summary. It arrives already rendered and is stored
    verbatim; core cannot phrase "gained 15,000 gold" for a game whose
    vocabulary it does not know.
  - visibility fails closed. An item with no stated visibility is `members`.
  - a push never throws at its call site. It is called from inside a game-event
    handler, and a storage problem of core's must not become the module's
    control flow.

Core emits four of the five kinds §4.2 names — `core.forum.thread` has nothing
to emit it until the forum lands in phase 4 — and emits none of them for a
Team's FIRST roster: importing a 155-member guild is one Team arriving, not 155
people joining, and a join per member would bury every real event under the
import and reach the row cap on day one.

Retention ships with the feed rather than after someone notices. A nightly
worker applies an age horizon and a per-Team row cap, both settings; either
alone has a hole, since age lets one busy guild write a million rows inside the
window and a cap keeps a dead Team's feed forever.

The sync now reads member ROWS rather than keys, replacing the `memberKeys`
call rather than adding to it: the feed needs each changing member's display
name and prior `is_leader`, and the upsert is about to overwrite both.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:15:18 -05:00
1f175786a7 Merge pull request 'feat(teams): Team core — the reconciler, the four authority paths, and the impersonation controls' (#151) from feat/teams-phase2-team-core into edge
Reviewed-on: #151
2026-08-17 22:20:31 +00:00
cf2666e5bc feat(teams): the Team read API, the moderation routes, and Admin -> Teams
All checks were successful
PR Checks / bot-install (pull_request) Successful in 16s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 8m56s
The eighteen routes of docs/website/TEAMS.md §2.11, their OpenAPI annotations,
and the staff screen that drives them.

Two rules shape the read model. Hidden means absent from every public surface --
the index, the lookup and the roster alike, and a hidden Team 404s
indistinguishably from one that does not exist, because "absent" includes not
confirming it is there. And staleness is surfaced rather than silent: every
public payload carries { configured, stale, lastSyncAt }, so a page can say how
recently the projection was confirmed instead of presenting stale data as
current.

The public roster withholds both the member key and the user id -- one is a
game-internal identifier, the other names a site account. `linked` answers the
only question a public page has without publishing which account. The module's
per-audience field projection is phase 3's; this is a conservative core one.

The §2.9 gate is enforced per REQUEST, not per route. A moderator may call all
eighteen; three of them mean something different when they do, and the server
decides from the role it re-validates on every request rather than from a token
claim. The client has no "file as request" argument to get wrong.

Found by booting the real server against the real database, and not by any test:
**the index and the by-slug lookup disagreed about what exists.** listPublic was
keyed on a registered team provider while findBySlug is not, so with no module
installed `/teams` returned an empty list while `/teams/:slug/members` served a
full roster -- the index denying a Team that direct URLs answered for in full.
The rows are core's and they outlive the module that filled them: an uninstalled
module leaves a projection that is unmaintained, not one that stopped existing,
and `configured: false` is how a client learns that. The read side no longer
takes the provider into account at all. There is now a test named for the
property.

Also verified live: the public routes answer anonymously, an unknown and a hidden
slug both 404, the player and admin tiers 401 an anonymous caller, a seeded
roster projects correctly, and the reconciler logs that it is staying idle with
no provider registered rather than failing a boot.

Process obligations, all done: #swagger.* annotations on every route, `npm run
swagger` regenerated (18 paths in the spec, no dangling $refs, and the schemas
they reference added), `npm run routes:manifest` regenerated -- additions only,
184 public routes -- and BACKEND_DESIGN.md updated across the schema section and
all three tier tables.

Admin -> Teams follows the ModulesAdmin precedent: everything that decides what a
row SAYS lives in lib/teamAdmin.js, which is plain JS with tests, and the view
renders it. That split earns itself here specifically -- the screen's job is to
make "the shard has no Teams" and "core has not been able to ask for two hours"
impossible to confuse, and those two produce the same empty table. The four
freshness states are named and tested for exactly that reason, and the last
provider error is shown verbatim rather than paraphrased.

The button labels follow the caller's role: a moderator sees "Request publish",
so the pending result is not a surprise. Hiding is offered to everyone with no
gate, matching the server.

Server 894 passed, client 206 passed, client build clean. 17 route tests, 20
client display tests.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 15:27:02 -05:00
8fe2e01466 feat(teams): reserved-name screening, auto-hide, and the admin-approval gate
The one place untrusted game data becomes a public page (docs/website/TEAMS.md
§2.8), and the gate on releasing it (§2.9).

A Team's name is written by a player, in the game, with no review, and this
platform turns it into a public page, a URL and eventually a Discord channel
name. Someone naming their guild "Admin" or "<Brand> Staff" gets an
official-looking page on the operator's own site for free.

Hide, never reject. Core cannot refuse a name -- the guild already exists in the
game and core is a mirror of it, not an authority over it. A match hides the Team
from public surfaces and files it in a review queue, and it keeps working
completely for its own members: their forum, their grants, their notifications.
The people in it are not being punished for a name their leader chose.

That asymmetry -- a false positive costs a human glance, a false negative costs
an impersonated staff page -- is what lets the matcher be conservative. It is not
licence to be sloppy the other way: a check that fires on "Badminton" gets
switched off, and then the real cost is paid in full. So matching is whole WORDS
after normalisation, never substrings, following the precedent
scripts/checkModuleIdentifiers.js set for exactly this reason.

Three matcher gaps found by writing the tests, all real impersonation vectors:

  - "Guild of Moderators" did not match `moderator`. Only a trailing s off the
    WHOLE term is stripped, so "Nomads" still does not match `mod`.
  - "G.M." normalises to two single-letter words and matched nothing. A run of
    two or more single-letter words is now also offered joined. Deliberately not
    a whole-name condensation, which would re-admit substring matching.
  - The multi-word condensed form was already handled and is what makes
    "RunicGateway" match the two-word term -- the form an impersonator would
    reach for, since it is what the Gitea org and every URL use.

Terms resolve at CHECK time, never baked in, so renaming a deployment protects
the new name without a redeploy. A failed settings read falls back to the static
role and project terms rather than to an empty list: screening fewer terms is
bad, screening none is the whole hole.

Re-screening runs on every reconcile, over names no human has ruled on. Names are
immutable per row, so it only ever changes an outcome when the TERM LIST changed
-- an operator adding one, or a rename -- which is exactly what a create-time-only
check would miss forever. `name_reviewed_at` is what makes a staff decision
sticky; without it an override would be undone every fifteen minutes.

The gate is scoped to three actions because they publish untrusted game-sourced
strings, and to nothing else. Ordinary forum grants, leadership overrides,
archives and forum moderation still apply immediately and are audited. A
moderator initiating one files a pending request; an admin applies at once.
Never four-eyes on admins: users.role defaults to admin and `npm run seed`
creates exactly one, so most deployments have precisely one and a second-approver
rule would wedge them with no way out.

Hiding is deliberately NOT gated. Publishing untrusted data needs a second pair
of eyes; withdrawing it needs to be possible at once, by whoever is on duty.

Two concurrency details worth the review: a decision moves the row out of
`pending` under a guard and applies its effect only if the row actually moved,
so two admins clicking approve cannot double-apply or overwrite each other's
record; and a JSON payload is parsed defensively, because the driver returns
JSON columns already parsed on some versions and as a string on others.

Screening is stubbed in the reconciler's own tests -- it is a separate unit, and
the real call reads settings, which this suite must never do against a live
database. That was caught the hard way: the suite went from 11s to hanging, and
the cause was the reconciler reaching a dead pool through the new call.

44 tests in the reconciler file (up from 39), 19 for the matcher, 25 for the
gate. Full suite 877 passed, 0 failed.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 15:08:58 -05:00
bfd844e8fb feat(teams): the four-path access resolver and staff leadership overrides
The four authority paths of docs/website/TEAMS.md §2.5, and the rule that they
stay four: four tables answering four questions, and no resolver reads another
path's table.

  1. Is this account a member?        module  team_members
  2. Does this account lead the Team?  module  team_members.is_leader + override
  3. May it use the Team forum?        CORE    team_forum_grants OR path 1
  4. May it get external access?       CORE    derived, nothing of its own

The temptation this resists is collapsing 1 and 3 into one boolean. They answer
different questions about different populations: a forum grant may name any
Runic Gateway account, including one with no game identity at all -- that is the
point of it, since letting an unlinked guildmate into a forum must not require a
staff ticket. Reading "has forum access" as "is a member" would put that person
on the public roster, into every membership count, and into the external-platform
grant, which is where a modelling preference becomes an impersonation risk.

Path 4 is deliberately blind to path 3, and the reason is written down so nobody
"fixes" it: an integration cannot verify that an unlinked, forum-granted account
corresponds to a real game member, so it must not hand that account a privilege
on a platform where impersonation has consequences. A forum is a room on the
operator's own site with a known moderator; a Discord role is an identity claim
in someone else's space.

Leadership overrides are applied ON TOP of the synced value at read time, never
written into the projection. The sync owns that column and rewrites it every
interval, so an override stored there would be undone fifteen minutes after
staff set it -- which is the whole reason §2.5.1 is a separate table. The roster
carries both the resolved answer and `is_leader_synced`, so an admin sees that a
decision was made rather than being shown it as fact.

Three tests are named INVARIANT rather than for behaviour, because what they
protect is structural and a reasonable-looking refactor destroys it silently: a
grant never writes the membership projection, a granted user is absent from the
roster, and a grant does not confer external eligibility. None of those failures
appears on a screen as a bug -- the first shows up as a stranger on a public
roster, the second as a Discord role handed to an account nobody can tie to a
real player.

Every unit test here stubs the db layer, so the SQL itself was verified
separately: all 44 statements across teams.db.js and teamAccess.db.js were run
against MariaDB 11 with a throwaway module id and cleaned up after. That run
also confirmed live what the reconciler's tests could only assert against a
stub -- an upsert does not overwrite is_leader, a revoked grant frees the unique
key for a new one while the ledger keeps both, and an archived team stays
resolvable at its old slug while its external_id is free for the successor row.

19 tests. Full suite 828 passed, 0 failed.

Refs docs/website/TEAMS.md §2.5, §2.5.1, §2.6, Part 12 phase 2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 14:57:23 -05:00
92631347f9 feat(teams): the reconciler, its four refusal gates, and ctx.teams (API 1.6.0)
Core's projection of the module's Teams, kept in step (docs/website/TEAMS.md
§2.4), plus the two ctx members a module pushes through.

The four gates are the file, and each is invariant 1 in a different costume --
module unavailability is staleness, never emptiness:

  1. getTeams() not ok           -> record the failure, touch NOTHING, return.
  2. ok but empty, core holds >=1 -> quarantine; apply only if the NEXT
                                    authoritative answer, an interval later,
                                    agrees.
  3. getTeamMembers() not ok      -> that Team's roster untouched and stale; the
                                    other Teams sync normally.
  4. ok but zero members, had some -> the same two-strikes quarantine, per Team.

Gates 2 and 4 exist because an authoritative-looking empty answer during a cold
start is the one failure indistinguishable from a real wipe. "Every Team on the
shard disbanded at once" costs one interval to confirm; getting it wrong empties
every roster on the site.

Events are an optimisation, never the source of truth. Member and leadership
deltas apply at once for a Team core already knows; team.created and
team.disbanded only ask for a run. §2.2 scopes archival to an authoritative full
list, so a repeated or spurious disband event costs a reconcile rather than a
Team -- and a Team invented from a delta would have no name, no roster and no
leaders anyway.

Two columns TEAMS.md did not contemplate, both on `teams`:

  - roster_synced_at, because team_sync_state holds one row per MODULE and gate 3
    leaves ONE Team behind while the others sync. Without a per-Team stamp that
    Team's page would report the module's last success as its own -- exactly the
    staleness the gate exists to surface.

  - members_empty_since, gate 4's per-Team quarantine. The twin of
    team_sync_state.pending_empty_since, which is per module and cannot express it.

One real bug found by its own test. The roster upsert was writing is_leader, so a
refused getTeamLeaders() left every member demoted -- the roster had already
written `leader: false` before the authoritative call was even made. §2.5 is
explicit that path 2 is answered by getTeamLeaders(), so is_leader is now set on
INSERT only (seeding a Team so it is not leaderless while that call fails) and
moved afterwards by setLeaders() alone. Two writers for one column was the whole
defect.

MODULE_API_VERSION 1.6.0 on both halves -- they state one contract and a module
declares one coreApi range. The number covers the whole Team surface per Part 11;
the members arrive by phase. registerTeamProvider, ctx.teams.publish and
ctx.teams.reconcile are live. ctx.teams.activity.push (§4, phase 3) and
api.registerSlashCommands (§7.1, phase 7) are present and THROW with a sentence
naming their phase, rather than being absent or silently accepting data into
tables that do not exist yet.

39 tests here, and the ctx surface guard in moduleLoader.test.js updated -- it
caught the addition, which is what it is for. Server 809 passed, client 192
passed, 0 failed.

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

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

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

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

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

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

28 tests. Full suite 770 passed, 0 failed.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 14:44:45 -05:00
225663d62e feat(teams): core schema for Teams, membership, sync state and moderation
The six core tables Team core is built on (docs/website/TEAMS.md §2.1, §2.5,
§2.5.1, §2.9), plus the §2.10 account-deletion decisions expressed as foreign
keys rather than left to whatever the defaults happened to be.

Every table is core-internal (§10.3): a module populates them through the team
provider and must never read or write one directly. They carry no <moduleId>_
prefix, correctly -- MODULE_API.md §2.6's prefix rule binds modules, and these
are core's.

team_forum_grants lands in this phase rather than in phase 4, so the four-path
resolver is written once and its non-contamination tests are real. Nothing
writes it yet; the grant/revoke flow, the per-Team cap and the leader UI are
phase 4's.

Two departures from the SQL as TEAMS.md sketched it, both recorded in the file:

  - team_forum_grants.user_id is nullable with ON DELETE SET NULL, following
    §2.10 (the audit trail of who granted whom must survive the account) rather
    than §2.5's CASCADE.

  - its uniqueness marker is derived from revoked_at alone, with user_id moved
    into the unique KEY. §2.5's `active_user AS (IF(revoked_at IS NULL, user_id,
    NULL))` cannot coexist with the line above: MariaDB refuses ON DELETE SET
    NULL on a foreign key whose column is a base column of a STORED generated
    column (error 1901). The semantics are identical -- at most one active grant
    per (team, user), unlimited revoked rows.

Verified by running ensureSchema() against MariaDB 11: all six tables create,
both generated columns materialise, and every foreign key's delete rule matches
§2.10's table. The uniqueness encoding was checked directly -- a second active
grant for the same (team, user) is rejected 1062 while revoked rows accumulate
freely.

Refs docs/website/TEAMS.md Part 12 phase 2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 14:40:26 -05:00
e0c961c690 Merge pull request 'feat(modules)!: the module system cutover — a game-agnostic core reaches main' (#150) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 17s
Build container images / build (push) Successful in 59s
SonarQube / analysis (push) Successful in 2m35s
Build container images / deploy (push) Successful in 42s
Reviewed-on: #150
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-12 23:10:29 +00:00
3669696532 Merge pull request 'chore(modules): declare the UO module for the UOMysticmoon instance' (#149) from chore/declare-uo-module-for-uomm into edge
All checks were successful
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 30s
PR Checks / bot-install (pull_request) Successful in 8m43s
Reviewed-on: #149
2026-08-12 22:57:42 +00:00
953d0c25f6 chore(modules): declare the UO module for the UOMysticmoon instance
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 31s
The module-system cutover puts a game-agnostic core on `main`, so the image
UOMysticmoon deploys stops carrying any UO code of its own. Everything that
instance is actually for — the shard pages, the player's characters, vendors and
houses, Admin -> Shard and the uo-link connection — arrives as RunicGateway/Module-uo
or does not arrive at all.

Declare it in the tenant template, next to the other values that pin this
instance to production, so an operator copying the file gets a working shard
rather than a working site with no game on it. The compose host resolves the set
itself at boot (MODULE_SYSTEM.md 2.7.2 decision 4), which is what keeps the site
from being game-less between the image roll and someone clicking install in
Admin -> Modules.

Nothing here is new machinery: MODULES and its no-op-without-network behaviour
shipped in phase 4 slice 3, MODULE_SOURCE_HOSTS already defaults to the host
this URL names, and core's .env.example documents the variable and deliberately
leaves it commented out. Only this instance's template is opinionated, which is
the split the module system exists to make.

Verified the declared manifest resolves anonymously (200, coreApi ^1.3.0 against
core's MODULE_API_VERSION 1.5.0) — the container fetches it with no credentials.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 17:56:52 -05:00
4ad8b2bb0e Merge pull request 'feat(modules): PublicLayout takes a shell, MODULE_API_VERSION 1.5.0' (#148) from fix/public-layout-shell into edge
Reviewed-on: #148
2026-08-12 19:26:42 +00:00
1433b60d6c feat(modules): PublicLayout takes a shell, MODULE_API_VERSION 1.5.0
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 8m59s
The Integration Kit's acceptance run (Phase 5 slice 3) put a cold agent in front
of the kit alone and asked it to build a module for a second game. It built one
that works — and its page rendered outside the site.

PublicLayout supplies the chrome and not the body. Every core public page wraps
its own content in `<div className="shell-... page-body">`: the centred column,
the top and bottom padding, and — through `page-body { flex: 1 }` — the thing
that pushes the footer to the bottom of the viewport. Nine of nine core pages do
it, so the omission has never shown. A module cannot do it: it receives
PublicLayout through the UI kit and those two class names appear in no contract.
The result was a page at x=0 with the footer riding up under the content, which
is the exact failure MODULE_API.md §3.4 says the kit exists to prevent.

So the wrapper moves behind the component a module already has:

  <PublicLayout shell="narrow">   // or "mid" / "wide"

`shell` is opt-in and omitting it is 1.4.0's behaviour exactly, so core's nine
pages are untouched and keep their own wrapper. An unrecognised width falls back
to narrow rather than to nothing — a module page at the wrong width still looks
like the site; a page with no wrapper does not.

1.5.0 is minor, not major. §3.4 makes *changing* a kit component's props major
because that breaks a call already written; adding an optional one breaks
nothing. module-uo's `coreApi: "^1.3.0"` still resolves.

The width map and its fallback live in client/src/lib/pageShell.js rather than in
the component, for the reason lib/adminNav.js does: the client runner has no DOM
and cannot import .jsx at all, so a rule inside a component is a rule no test can
reach. Five tests cover it, including that every width it offers is a class
theme.css actually defines — the contract now names those widths to module
authors, so a rename has to fail here instead of silently in someone's page.

Also from the same run: modules/shared.js called the UI kit "seven" members while
exporting eight (§3.4's table has five rows because PageState contributes three),
and its note said AdminPage "appears in §3.4's table" when the table dropped it in
Phase 2 PR 7.

742 server + 192 client tests pass (+5). routes.manifest.json and the OpenAPI
spec regenerate byte-identical — no route changed.

Verified in a browser against the acceptance module (MODULE_API.md §7.7), which
is the only place this seam is visible: the untouched build renders full-bleed,
and shell="narrow" lands the page in the same column as core's own.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 14:23:26 -05:00
1b692bf624 Merge pull request 'chore(modules): bump MODULE_API_VERSION to 1.4.0 — the sidecar rule' (#147) from chore/module-api-1.4.0 into edge
Reviewed-on: #147
2026-08-12 14:41:26 +00:00
5410e7e0b3 chore(modules): bump MODULE_API_VERSION to 1.4.0 — the sidecar rule
All checks were successful
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 29s
PR Checks / bot-install (pull_request) Successful in 8m45s
Phase 5 decision 4 (MODULE_SYSTEM.md §2.11.1): a module does not open a
connection to a game server from the website process. It talks to a sidecar,
which owns the durable copy of the game's state.

No member was added, removed or changed — the surface is identical to 1.3.0.
Minor rather than major because module-uo's `coreApi: "^1.3.0"` still resolves
and module-uo already complies, but a module written against 1.3.0 could
satisfy every member and still be built the wrong way round, which is what this
number now says.

The rule itself is MODULE_API.md §2.7 (docs, separate PR) and is the one
prohibition there with no CI behind it: an outbound socket is not statically
detectable the way an internal require is (§5.1).

742 server + 187 client tests pass; routes.manifest.json and swagger-output.json
regenerate byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 09:37:47 -05:00
c3120ea3da Merge pull request 'fix(modules): stop a module before purging its tables' (#146) from fix/module-uninstall-stop-before-purge into edge
Reviewed-on: #146
2026-08-12 14:18:47 +00:00
a4da1cc438 fix(modules): stop a module before purging its tables
All checks were successful
PR Checks / bot-install (pull_request) Successful in 20s
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 33s
Uninstall-with-purge ran purge.sql while the module was still started: the
tables went, and the module kept serving and ingesting against a schema that no
longer existed until lifecycle.stop() finished — up to the five-second hook
budget. For module-uo that is the uo-link WebSocket writing shard events into
dropped tables, and requests in flight answering 500 where a stopped module
answers 404.

Nothing required the old order. The comment justified it as "purge while the SQL
is still readable", but removeDir is the only step that touches the filesystem,
so purge.sql stays readable until after the stop. The 400 for a module that
ships no purge.sql is now resolved before anything is stopped, so a refused
request leaves the module exactly as it found it.

Found while proving Phase 4's acceptance criterion 2 against the real
module-uo v0.3.0 release on an empty database (MODULE_SYSTEM.md §2.7.2).

742 server tests (+1); routes.manifest.json and swagger-output.json byte-identical.

AI disclosure: this contribution was AI-assisted (Claude Code).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 09:05:21 -05:00
12df79430f Merge pull request 'test(login): stop the backoff-guard test racing its own one-second lock' (#145) from fix/login-backoff-flake into edge
Reviewed-on: #145
2026-08-12 13:39:50 +00:00
ec2b530be7 test(login): stop the backoff-guard test racing its own one-second lock
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 8m57s
A single recordFailure() locks for BASE_MS * 2 ** 0 — exactly one second — and
the test then does a real HTTP round trip against it. On CI that round trip took
1,456 ms and the guard correctly answered 200, failing the run for a reason that
has nothing to do with what the test is about.

Five failures lock for sixteen seconds. The subject is the guard's answer while
locked out, which is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 08:16:59 -05:00
8bc09d8b53 Merge pull request 'feat(modules): the declarative Docker path (phase 4, slice 3)' (#144) from feature/module-docker-path into edge
Reviewed-on: #144
2026-08-12 13:02:23 +00:00
299 changed files with 62348 additions and 2293 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

@@ -28,3 +28,36 @@ TOTP_ISSUER=UOMysticmoon
DB_NAME=uomysticmoon
DB_USER=uomm
COOKIE_NAME=uomm_token
# ── The UO module — REQUIRED for this instance, not optional like the vars above.
#
# Core is game-agnostic (docs/website/MODULE_SYSTEM.md): every shard-facing
# surface this instance runs — the shard pages, the player's characters, vendors
# and houses, Admin → Shard, and the uo-link connection itself — lives in
# RunicGateway/Module-uo and reaches the deployment through this line. Without
# it, the same image is a perfectly working site with no game on it.
#
# It is declared here rather than left to Admin → Modules because a compose host
# should arrive at its own set at boot, and because this instance has a shard to
# be down for: the panel path would leave the site game-less between the image
# roll and someone clicking install.
#
# Bump the version deliberately, and read Module-uo's release notes when you do —
# the container resolves this at every start, so changing the version here is
# what upgrades the module. A version already unpacked is a no-op that makes no
# network call at all.
#
# This owns what is ON the volume, never whether the module RUNS: disabling it in
# Admin → Modules keeps it disabled across restarts even though its files return.
MODULES=uo@0.3.0=https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json
# Module-uo reads these as the DEFAULTS for its uo-link connection, used only
# until Admin → Shard has been saved once — after that the encrypted DB config
# (`uo_link_config`) is authoritative and these are ignored. Left unset here on
# purpose: an instance that has already saved Admin → Shard keeps that config
# across the extraction (the module's schema fragment is CREATE TABLE IF NOT
# EXISTS, so the existing row is untouched), and setting them would suggest they
# still decide something. Module-uo's README documents them.
# UOLINK_BASE_URL=
# UOLINK_WS_URL=
# UOLINK_PROTOCOL=

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:
@@ -86,9 +101,13 @@ jobs:
- name: Build client
run: npm run build --prefix client
bot-install:
# No tests/build to run; a clean install still catches a broken or
# out-of-sync lockfile before it ships in the bot image.
bot-tests:
# The install still runs first and still catches a broken or out-of-sync
# lockfile before it ships in the bot image — that was this job's whole
# purpose until phase 7 (TEAMS.md §7.1) put real logic in the bot: it now
# pulls slash-command definitions from the app, merges them into the
# whole-set PUT, and runs the defer→dispatch→edit path. None of that is
# reachable from the server suite, and phases 8 and 9 add more of it.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -99,3 +118,7 @@ jobs:
cache-dependency-path: bot/package-lock.json
- name: Install bot deps
run: npm ci --prefix bot
- name: Run bot tests
# Node's built-in runner, no browser and no Discord connection — the
# interaction is a fake that records what was called on it.
run: npm test --prefix bot

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

@@ -6,6 +6,7 @@
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"test": "node --test test/*.test.js",
"dev": "nodemon src/server.js"
},
"keywords": ["discord", "discord.js"],

View File

@@ -5,6 +5,7 @@ const { Client, GatewayIntentBits, REST, Routes } = require('discord.js')
const createLogger = require('../utils/logger')
const commands = require('./commands')
const dynamicCommands = require('./dynamicCommands')
const messageFilter = require('./messageFilter')
const scheduler = require('../scheduler/scheduler')
const roleMenuHandler = require('./roleMenuHandler')
@@ -22,12 +23,46 @@ let status = 'disconnected' // disconnected | connecting | connected | error
let statusDetail = null
let lastConnectedAt = null
// One whole-set PUT of the bot's own commands plus whatever the app has
// registered (TEAMS.md §7.1). Because it replaces the set rather than adding to
// it, DEREGISTRATION is free: a module that is gone is simply absent from the
// next pull, and nobody has to remember to take its command back.
async function registerCommands(applicationId, targetGuildId) {
const dynamic = dynamicCommands.definitions()
const rest = new REST({ version: '10' }).setToken(client.token)
await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), {
body: commands.all.map((c) => c.data),
body: [...commands.all.map((c) => c.data), ...dynamic],
})
log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length })
log.info('registered guild slash commands', {
guildId: targetGuildId,
builtIn: commands.all.length,
fromApp: dynamic.length,
})
}
/**
* Re-pull the app's commands and re-register the set if it moved.
*
* Called on `ready` and again whenever the app nudges
* (`POST /internal/refresh-commands`). A no-op when nothing changed, so a nudge
* per module state change costs one cheap GET rather than a REST.put per
* install — and a disconnected bot does nothing at all, since there is no
* application to register against until it logs in.
*/
async function refreshCommands() {
const result = await dynamicCommands.pull()
if (!result.ok || !result.changed) return result
if (!client || !client.isReady()) return result
try {
await registerCommands(client.application.id, guildId)
} catch (err) {
// The PUT is all-or-nothing: a definition Discord rejects costs every
// command, the built-ins included. Loud, and never fatal to the process.
log.error('re-registering slash commands failed — the previous set is still live', {
message: err.message,
})
}
return result
}
async function stop() {
@@ -54,6 +89,11 @@ async function stop() {
// failure here leaves the client connected but flags an error status.
async function onReady() {
try {
// Pull BEFORE the single PUT, so the app's commands are in the very first
// registration rather than appearing a beat later. The pull never throws —
// an unreachable app costs the module commands and nothing else, and the
// bot's own set registers exactly as it always did.
await dynamicCommands.pull()
await registerCommands(client.application.id, guildId)
await scheduler.start(client)
tempRoleSweeper.start(client)
@@ -70,14 +110,19 @@ async function onReady() {
}
}
// Route an interaction: role-menu handler first, then chat-input slash commands.
// Route an interaction: role-menu handler first, then chat-input slash commands
// — the bot's own, then the app's. Built-ins are consulted FIRST and the pull
// already drops any module name that collides with one, so the two orderings
// agree; checking here as well means a name that somehow reached Discord twice
// still runs the bot's version rather than whichever registry answered first.
async function onInteractionCreate(interaction) {
if (await roleMenuHandler.handleInteraction(interaction)) return
if (!interaction.isChatInputCommand()) return
const command = commands.get(interaction.commandName)
if (!command) return
if (!command && !dynamicCommands.has(interaction.commandName)) return
try {
await command.execute(interaction)
if (command) await command.execute(interaction)
else await dynamicCommands.execute(interaction)
} catch (err) {
log.error('command execution failed', { command: interaction.commandName, message: err.message })
const payload = { content: 'Something went wrong running that command.', ephemeral: true }
@@ -146,4 +191,4 @@ function getConnection() {
return { client, guildId }
}
module.exports = { start, stop, getStatus, getConnection }
module.exports = { start, stop, getStatus, getConnection, refreshCommands }

View File

@@ -0,0 +1,242 @@
// Slash commands whose DEFINITION and HANDLER live in the website process
// (TEAMS.md §7.1). The bot pulls the definitions, registers them alongside its
// own, and executes one by deferring, asking the app, and editing the reply in.
//
// Everything Discord-specific is here and nothing else is: the app's dispatcher
// resolves the actor, enforces access and produces a platform-neutral envelope,
// and this file turns that envelope into an interaction reply. A module never
// touches an interaction, which is what makes the registration API something a
// second platform could implement.
const { PermissionFlagsBits } = require('discord.js')
const appInternal = require('../site/appInternalClient')
const staticCommands = require('./commands')
const createLogger = require('../utils/logger')
const log = createLogger('dynamic-commands')
// §7.1.1's four types, and the only four. The app rejects anything else at
// registration; this map is the second half of that agreement.
const OPTION_TYPE = { string: 3, integer: 4, boolean: 5, user: 6 }
// The pulled set, and the app's module-state counter it came from. `null`
// version means "never successfully pulled", which is distinct from 0 ("pulled
// while the app had no modules loaded") — the first should retry, the second is
// a true answer.
let pulled = []
let version = null
/**
* Ask the app for the current definitions.
*
* **A failed pull KEEPS the previous set.** The app being briefly unreachable is
* not the same as it having no commands, and treating it as such would
* deregister every module command from Discord on a restart blip — then
* re-register them a minute later, with members watching commands appear and
* disappear. Nothing changes until the app actually answers.
*
* @returns {Promise<{ok: boolean, changed: boolean, count: number}>}
*/
async function pull() {
const res = await appInternal.fetchCommands()
if (!res.ok) {
log.warn('command pull failed — keeping the set already registered', {
error: res.error,
holding: pulled.length,
})
return { ok: false, changed: false, count: pulled.length }
}
const { version: pulledVersion, commands } = res.data || {}
const next = Array.isArray(commands) ? commands.filter(usable) : []
const changed = version === null || pulledVersion !== version || next.length !== pulled.length
pulled = next
version = typeof pulledVersion === 'number' ? pulledVersion : 0
return { ok: true, changed, count: pulled.length }
}
/**
* Drop a pulled definition the bot cannot honour.
*
* **The name collision the app cannot see.** The app validates a command against
* everything IT has registered; it does not know the bot's own static array
* exists. A module registering `ping` would produce two `ping` entries in one
* `REST.put`, which Discord rejects as a batch — taking down every command
* including the bot's own. The bot's built-ins win, because they are the ones a
* module cannot be asked to change.
*/
function usable(definition) {
if (!definition || typeof definition.name !== 'string') return false
if (staticCommands.get(definition.name)) {
log.warn('module slash command collides with a built-in and is ignored', {
command: definition.name,
owner: definition.owner,
})
return false
}
return true
}
/**
* The pulled definitions as Discord command data, for the whole-set PUT.
*
* `access: 'staff'` becomes a Discord-side permission default; `linked` cannot
* be expressed in Discord's permission model at all — there is no "has a website
* account" predicate — so it is simply not advertised and the app's dispatcher
* refuses it. That asymmetry is the reason §7.1 says access is enforced twice
* and that only the server half is the gate.
*/
function definitions() {
return pulled.map((c) => {
const data = {
name: c.name,
description: c.description,
options: (c.options || []).map((o) => ({
name: o.name,
description: o.description,
type: OPTION_TYPE[o.type],
required: Boolean(o.required),
...(o.choices ? { choices: o.choices } : {}),
})),
}
if (c.access === 'staff') data.default_member_permissions = PermissionFlagsBits.ModerateMembers.toString()
return data
})
}
/** Is this a command the app owns? Asked before the static registry is consulted. */
const has = (name) => pulled.some((c) => c.name === name)
// Read the options the member actually supplied, by the names the definition
// declared. A `user` option is passed on as the Discord user id and nothing else
// — a handler receives platform ids, never a platform object.
function collectOptions(interaction, definition) {
const out = {}
for (const option of definition.options || []) {
const supplied = interaction.options.get(option.name)
if (supplied === null || supplied === undefined) continue
out[option.name] = option.type === 'user' ? String(supplied.value) : supplied.value
}
return out
}
// What the caller sees when the app declined. The COPY lives here rather than in
// the app on purpose: the app answers with a machine reason, and how a refusal is
// phrased to a member is the platform's own voice.
function refusal({ reason, access }) {
if (reason === 'forbidden' && access === 'linked') {
return 'Link your Discord account on the site to use this command.'
}
if (reason === 'forbidden') return 'You do not have access to that command.'
if (reason === 'unknown') return 'That command is no longer available.'
return 'Something went wrong running that command.'
}
// Envelope → interaction payload. A response with fields or a title is an embed;
// a bare `text` is plain content, which reads better for a one-line answer.
function render(envelope) {
const { text, title, fields, url } = envelope
if (!title && !fields) return { content: text || '' }
const embed = {}
if (title) embed.title = title
if (text) embed.description = text
if (url) embed.url = url
if (fields) embed.fields = fields
return { embeds: [embed] }
}
/**
* Deliver the envelope at the privacy the HANDLER asked for, not the privacy the
* deferral guessed.
*
* When the two agree — the ordinary case — this is one `editReply`. When the
* handler wants a private answer to a publicly deferred command, the deferred
* reply is deleted and the answer arrives as an ephemeral follow-up: the
* interaction token stays valid, so this is a supported path rather than a
* trick, and the cost is a "thinking…" that appears and vanishes.
*
* There is no reverse case. A command deferred ephemerally is one whose answers
* are all about the caller's own account, and nothing it returns should become
* public because a handler forgot a flag.
*/
async function reply(interaction, envelope, deferredEphemeral) {
const payload = render(envelope)
if (!envelope.ephemeral || deferredEphemeral) {
await interaction.editReply(payload)
return
}
await interaction.deleteReply()
await interaction.followUp({ ...payload, ephemeral: true })
}
/**
* Defer, dispatch, edit.
*
* **The deferral comes first, always.** Discord gives three seconds to acknowledge
* an interaction; the app is given four to answer. Deferring before the dispatch
* is what keeps the website out of that critical path entirely — a wedged handler
* costs its own reply and never an "application did not respond".
*
* A failure at any point after the defer is an edit, not a reply: the interaction
* has already been acknowledged, and `reply()` on a deferred interaction throws.
*/
async function execute(interaction) {
const definition = pulled.find((c) => c.name === interaction.commandName)
if (!definition) return false
// **Ephemerality is fixed at the DEFERRAL, which happens before the answer
// exists.** That is Discord's rule, not a choice here, and it is the whole
// reason this needs care: the handler decides privacy per answer — a refusal
// is private, a guild summary is not — and by the time it says so the reply is
// already public or already not.
//
// So: defer for the common case (public, or private for a command that only
// ever speaks about the caller's own account), and if the envelope disagrees,
// reconcile below. Getting this wrong is not cosmetic — the live walk caught it
// posting "guild information is not shown to your account" into the channel,
// which announces a member's access level to everyone in it.
const ephemeral = definition.access === 'linked'
await interaction.deferReply({ ephemeral })
const res = await appInternal.dispatchCommand({
command: definition.name,
options: collectOptions(interaction, definition),
platformUserId: interaction.user.id,
guildId: interaction.guildId,
})
// A transport failure and a handler failure are the same sentence to the
// member and different lines in the log: one is the app being unreachable,
// the other is a module's code.
// A refusal is ALWAYS private, whatever the command's usual privacy: "you do
// not have access to that" is about one member and belongs to one member.
if (!res.ok) {
log.warn('command dispatch failed', { command: definition.name, error: res.error })
await reply(interaction, { text: refusal({ reason: 'error' }), ephemeral: true }, ephemeral)
return true
}
if (!res.data || !res.data.ok) {
await reply(interaction, { text: refusal(res.data || {}), ephemeral: true }, ephemeral)
return true
}
const envelope = res.data.response || {}
await reply(interaction, envelope, ephemeral)
// The private aside beside a public answer (§9 answer 5). Skipped when the
// reply was already private — the member would just be told the same thing
// twice, in the same place.
if (envelope.notice && !ephemeral && !envelope.ephemeral) {
await interaction.followUp({ content: envelope.notice, ephemeral: true })
}
return true
}
// Test-only: the pulled set is process-global, so a test that pulls has to be
// able to hand the process back.
function _reset() {
pulled = []
version = null
}
module.exports = { pull, definitions, has, execute, _reset }

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')
@@ -99,4 +101,142 @@ async function reverseModAction(req, res) {
}
}
module.exports = { setConfig, getStatus: getStatusHandler, announce, reverseModAction }
// POST /internal/refresh-commands — the app's nudge that its registered
// slash-command set has moved (TEAMS.md §7.1). No body: the bot re-pulls
// `/internal/commands` and re-registers only if the set actually changed, so the
// nudge stays a cheap thing the app can send on every module state change.
//
// Deliberately its OWN endpoint rather than riding on /internal/config, which
// carries the decrypted bot token: saying "commands changed" should not require
// the app to read a secret out of the database.
//
// Answers 200 even when disconnected — there is no application to register
// against until the bot logs in, and `ready` pulls again anyway. A 5xx here
// would make an ordinary module install look like a failure in the admin panel.
async function refreshCommands(req, res) {
try {
const result = await discordManager.refreshCommands()
return res.json({ ok: true, ...result })
} catch (err) {
log.error('refresh-commands failed', { message: err.message })
return res.json({ ok: false, error: err.message })
}
}
// 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

@@ -11,5 +11,10 @@ router.post('/config', ctrl.setConfig)
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

View File

@@ -0,0 +1,76 @@
// Shared-secret client for the APP's internal listener (port 3001) — the
// bot→app direction of the channel `botInternalClient.js` runs app→bot.
//
// Two callers, both slash-command plumbing (TEAMS.md §7.1): pull the registered
// command definitions, and dispatch one that a member has just run. Distinct
// from siteApiClient.js, which reads the site's PUBLIC API with no secret at all.
//
// **The base URL is derived from `SITE_INTERNAL_URL`'s origin, not configured
// separately.** That variable already points at the app's internal listener —
// `http://app:3001/internal/bot-config` — and adding a second variable naming the
// same host would be one more thing an operator can get half-right. Deriving it
// means every existing deployment gains these endpoints with no compose change.
const createLogger = require('../utils/logger')
const log = createLogger('app-internal')
const KEY = process.env.BOT_INTERNAL_KEY || ''
// §7.1's budget, and the same 4s `botInternalClient` uses in the other
// direction. The app bounds its own handlers UNDER this (3s), so a timeout here
// normally means the app itself is unreachable rather than a module being slow.
const TIMEOUT_MS = 4000
function baseUrl() {
const configured = process.env.SITE_INTERNAL_URL
if (!configured) return null
try {
return new URL(configured).origin
} catch {
log.error('SITE_INTERNAL_URL is not a URL — slash-command registration is off', { configured })
return null
}
}
async function call(path, { method = 'GET', body } = {}) {
const base = baseUrl()
if (!base || !KEY) return { ok: false, error: 'SITE_INTERNAL_URL or BOT_INTERNAL_KEY not set' }
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
try {
const res = await fetch(`${base}${path}`, {
method,
headers: { 'Content-Type': 'application/json', 'X-Internal-Key': KEY },
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
if (!res.ok) return { ok: false, status: res.status, error: `app responded ${res.status}` }
return { ok: true, status: res.status, data: await res.json() }
} catch (err) {
log.warn('app internal call failed', { path, message: err.message })
return { ok: false, status: 0, error: err.message }
} finally {
clearTimeout(timeout)
}
}
/** The registered slash-command definitions, plus the version they belong to. */
function fetchCommands() {
return call('/internal/commands')
}
/**
* Run one command in the app and get the response envelope back.
*
* The bot has already deferred by the time this is called, so the only deadline
* that matters is Discord's 15-minute follow-up window — TIMEOUT_MS is about not
* holding an interaction open on a wedged app, not about the 3-second ack.
*/
function dispatchCommand({ command, options, platformUserId, guildId }) {
return call('/internal/commands/dispatch', {
method: 'POST',
body: { command, options, platform: 'discord', platformUserId, guildId },
})
}
module.exports = { fetchCommands, dispatchCommand }

View File

@@ -0,0 +1,77 @@
// The bot→app internal client (TEAMS.md §7.1).
//
// One property carries this file: the base URL is DERIVED from
// `SITE_INTERNAL_URL`, which already names the app's internal listener with a
// path on the end. That derivation is the reason every existing deployment gains
// slash commands with no compose change, and it is exactly the kind of string
// handling that breaks silently — a wrong base means "the app is down" forever,
// with nothing in the logs but a fetch error.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const env = { ...process.env }
const realFetch = global.fetch
beforeEach(() => {
process.env.SITE_INTERNAL_URL = 'http://app:3001/internal/bot-config'
process.env.BOT_INTERNAL_KEY = 'shh'
delete require.cache[require.resolve('../src/site/appInternalClient')]
})
afterEach(() => {
process.env = { ...env }
global.fetch = realFetch
})
/** Load the client fresh and record the single fetch it makes. */
function withFetch(response) {
const seen = {}
global.fetch = async (url, init) => {
seen.url = url
seen.init = init
return response
}
// eslint-disable-next-line global-require
return { client: require('../src/site/appInternalClient'), seen }
}
const ok = (body) => ({ ok: true, status: 200, json: async () => body })
test('the commands URL is the internal listeners origin, not its bot-config path', async () => {
const { client, seen } = withFetch(ok({ version: 3, commands: [] }))
const res = await client.fetchCommands()
assert.equal(seen.url, 'http://app:3001/internal/commands')
assert.equal(seen.init.headers['X-Internal-Key'], 'shh')
assert.deepEqual(res.data, { version: 3, commands: [] })
})
test('a dispatch names the platform, so the app never has to guess', async () => {
const { client, seen } = withFetch(ok({ ok: true, response: {} }))
await client.dispatchCommand({ command: 'guild', options: { name: 'KOC' }, platformUserId: '5', guildId: '9' })
assert.equal(seen.url, 'http://app:3001/internal/commands/dispatch')
assert.deepEqual(JSON.parse(seen.init.body), {
command: 'guild', options: { name: 'KOC' }, platform: 'discord', platformUserId: '5', guildId: '9',
})
})
// A bot with no internal URL configured is an ordinary deployment state (the
// warning already exists in bootstrap.js); it must not become an exception on
// every `ready`.
test('an unconfigured or unparseable SITE_INTERNAL_URL is a refusal, not a throw', async () => {
delete process.env.SITE_INTERNAL_URL
const { client } = withFetch(ok({}))
assert.equal((await client.fetchCommands()).ok, false)
delete require.cache[require.resolve('../src/site/appInternalClient')]
process.env.SITE_INTERNAL_URL = 'not a url'
// eslint-disable-next-line global-require
assert.equal((await require('../src/site/appInternalClient').fetchCommands()).ok, false)
})
test('a non-2xx carries its status so the caller can tell "down" from "rejected"', async () => {
const { client } = withFetch({ ok: false, status: 401, json: async () => ({}) })
const res = await client.fetchCommands()
assert.equal(res.ok, false)
assert.equal(res.status, 401)
})

View File

@@ -0,0 +1,269 @@
// ── The bot's half of module slash commands (TEAMS.md §7.1) ────────────────
//
// The first tests in this package, and they exist for a specific reason: phases
// 8 and 9 put more of the Discord integration in this process, and the failure
// modes here are ones no unit test in `server/` can see — a whole-set PUT that
// one bad entry poisons, a deferral that has to happen before anything slow, and
// a reply that must be EDITED rather than sent once the interaction is deferred.
//
// Nothing here talks to Discord. `interaction` is a fake that records what was
// called on it, which is the whole of what this file is asserting about.
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const dynamic = require('../src/discord/dynamicCommands')
const appInternal = require('../src/site/appInternalClient')
const staticCommands = require('../src/discord/commands')
const originals = {
fetchCommands: appInternal.fetchCommands,
dispatchCommand: appInternal.dispatchCommand,
get: staticCommands.get,
}
beforeEach(() => {
dynamic._reset()
Object.assign(appInternal, originals)
staticCommands.get = originals.get
})
const definition = (over = {}) => ({
name: 'guild',
description: 'Show a guild',
owner: 'uo',
access: 'everyone',
options: [{ name: 'name', type: 'string', description: 'Guild name', required: false }],
...over,
})
const answers = (commands, version = 1) => {
appInternal.fetchCommands = async () => ({ ok: true, data: { version, commands } })
}
function fakeInteraction({ commandName = 'guild', options = {}, userId = '555' } = {}) {
const calls = []
return {
calls,
commandName,
guildId: '999',
user: { id: userId },
options: {
get: (name) => (name in options ? { value: options[name] } : null),
},
deferReply: async (payload) => calls.push(['defer', payload]),
editReply: async (payload) => calls.push(['edit', payload]),
deleteReply: async () => calls.push(['delete']),
followUp: async (payload) => calls.push(['followUp', payload]),
}
}
// ── Pulling ────────────────────────────────────────────────────────────────
test('a pull reports whether the set moved, so a nudge is cheap', async () => {
answers([definition()], 7)
assert.deepEqual(await dynamic.pull(), { ok: true, changed: true, count: 1 })
// Same version, same size: nothing to re-register, and re-registering anyway
// would mean a REST.put per module state change instead of per real change.
assert.deepEqual(await dynamic.pull(), { ok: true, changed: false, count: 1 })
answers([definition()], 8)
assert.equal((await dynamic.pull()).changed, true)
})
// Otherwise a restart blip would deregister every module command from Discord
// and re-register it a minute later, with members watching it happen.
test('a failed pull keeps the set already registered', async () => {
answers([definition()])
await dynamic.pull()
appInternal.fetchCommands = async () => ({ ok: false, error: 'ECONNREFUSED' })
assert.deepEqual(await dynamic.pull(), { ok: false, changed: false, count: 1 })
assert.equal(dynamic.definitions().length, 1)
})
// The collision the app cannot see: it validates against what IT registered and
// does not know the bot's own array exists. Two entries of one name in a single
// PUT is rejected as a batch, taking the built-ins down with it.
test('a module command that collides with a built-in is dropped, not registered', async () => {
staticCommands.get = (name) => (name === 'ping' ? { data: { name: 'ping' } } : undefined)
answers([definition({ name: 'ping' }), definition()])
await dynamic.pull()
assert.deepEqual(dynamic.definitions().map((d) => d.name), ['guild'])
assert.equal(dynamic.has('ping'), false)
})
test('definitions carry Discords numeric option types, not the contracts names', async () => {
answers([definition({
options: [
{ name: 'who', type: 'user', description: 'A member', required: true },
{ name: 'n', type: 'integer', description: 'How many', choices: [{ name: 'one', value: 1 }] },
],
})])
await dynamic.pull()
const [data] = dynamic.definitions()
assert.deepEqual(data.options.map((o) => o.type), [6, 4])
assert.deepEqual(data.options[1].choices, [{ name: 'one', value: 1 }])
assert.equal(data.default_member_permissions, undefined)
})
// `linked` has no Discord equivalent — there is no "has a website account"
// predicate — so only `staff` maps, and the app re-checks both regardless.
test('only access: staff becomes a Discord permission default', async () => {
answers([definition({ access: 'staff' }), definition({ name: 'other', access: 'linked' })])
await dynamic.pull()
const [staff, linked] = dynamic.definitions()
assert.equal(typeof staff.default_member_permissions, 'string')
assert.equal(linked.default_member_permissions, undefined)
})
// ── Executing ──────────────────────────────────────────────────────────────
test('the deferral happens before the dispatch, always', async () => {
answers([definition()])
await dynamic.pull()
let deferredFirst = false
const interaction = fakeInteraction()
appInternal.dispatchCommand = async () => {
deferredFirst = interaction.calls.length === 1 && interaction.calls[0][0] === 'defer'
return { ok: true, data: { ok: true, response: { text: 'hi' } } }
}
await dynamic.execute(interaction)
assert.ok(deferredFirst, 'the website is never in Discords 3-second ack path')
assert.deepEqual(interaction.calls.at(-1), ['edit', { content: 'hi' }])
})
test('the options the member supplied are passed by name, as plain values', async () => {
answers([definition({
options: [
{ name: 'name', type: 'string', description: 'd' },
{ name: 'who', type: 'user', description: 'd' },
{ name: 'missing', type: 'string', description: 'd' },
],
})])
await dynamic.pull()
let sent = null
appInternal.dispatchCommand = async (body) => {
sent = body
return { ok: true, data: { ok: true, response: {} } }
}
await dynamic.execute(fakeInteraction({ options: { name: 'KOC', who: '42' } }))
assert.deepEqual(sent.options, { name: 'KOC', who: '42' })
assert.equal(sent.platformUserId, '555')
assert.equal(sent.guildId, '999')
})
test('a title or fields render as an embed; a bare text does not', async () => {
answers([definition()])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({
ok: true,
data: { ok: true, response: { title: 'Knights', text: 'Alliance: Accord', fields: [{ name: 'Members', value: '12' }], url: 'https://site.test/uo/guilds/7' } },
})
const interaction = fakeInteraction()
await dynamic.execute(interaction)
const [, payload] = interaction.calls.at(-1)
assert.equal(payload.embeds[0].title, 'Knights')
assert.equal(payload.embeds[0].description, 'Alliance: Accord')
assert.equal(payload.embeds[0].url, 'https://site.test/uo/guilds/7')
})
// §9 answer 5: the public projection, plus a private nudge to link. One reply
// cannot be both, so the aside is a follow-up — which is the bot's decision to
// make, not the handler's.
test('a notice becomes an ephemeral follow-up beside a public answer', async () => {
answers([definition()])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({
ok: true,
data: { ok: true, response: { text: 'public', notice: 'Link your account' } },
})
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.deepEqual(interaction.calls.at(-1), ['followUp', { content: 'Link your account', ephemeral: true }])
})
test('a notice is not repeated when the answer was already private', async () => {
answers([definition({ access: 'linked' })])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({
ok: true,
data: { ok: true, response: { text: 'private', notice: 'Link your account' } },
})
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.deepEqual(interaction.calls[0], ['defer', { ephemeral: true }])
assert.equal(interaction.calls.some(([kind]) => kind === 'followUp'), false)
})
// Every failure path EDITS. Replying to a deferred interaction throws, so a
// refusal that used reply() would turn a clean "no" into an unhandled error.
// Ephemerality is fixed at the DEFERRAL, which happens before the handler has
// said anything — so honouring a per-answer flag needs the deferred reply
// withdrawn. The live walk caught the version that ignored it posting "guild
// information is not shown to your account" into the channel, which announces a
// member's access level to everyone in it.
test('a handler asking for privacy gets it, even though the deferral was public', async () => {
answers([definition()])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({
ok: true, data: { ok: true, response: { text: 'just for you', ephemeral: true } },
})
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'delete', 'followUp'])
assert.deepEqual(interaction.calls.at(-1)[1], { content: 'just for you', ephemeral: true })
})
test('an already-private deferral just edits — no second message', async () => {
answers([definition({ access: 'linked' })])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({
ok: true, data: { ok: true, response: { text: 'private', ephemeral: true } },
})
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'edit'])
})
// "You do not have access to that" is about one member and belongs to one
// member, whatever the command's usual privacy.
test('a refusal is always private', async () => {
answers([definition()])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({ ok: true, data: { ok: false, reason: 'forbidden' } })
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'delete', 'followUp'])
assert.equal(interaction.calls.at(-1)[1].ephemeral, true)
})
test('a refusal is phrased by the bot and edited into the deferred reply', async () => {
answers([definition({ access: 'linked' })])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({
ok: true, data: { ok: false, reason: 'forbidden', access: 'linked', isLinked: false },
})
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.match(interaction.calls.at(-1)[1].content, /Link your Discord account/)
// Deferred ephemerally (access: 'linked'), so the refusal is one edit and no
// withdrawal — replying twice to a deferred interaction is what throws.
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'edit'])
})
test('an unreachable app is the same sentence to the member and a different line in the log', async () => {
answers([definition()])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({ ok: false, error: 'timeout' })
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.match(interaction.calls.at(-1)[1].content, /Something went wrong/)
assert.equal(interaction.calls.at(-1)[1].ephemeral, true)
})
test('an interaction for a command the app no longer serves is left alone', async () => {
answers([definition()])
await dynamic.pull()
const interaction = fakeInteraction({ commandName: 'gone' })
assert.equal(await dynamic.execute(interaction), false)
assert.deepEqual(interaction.calls, [], 'nothing is deferred for a command that is not ours')
})

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: 'Blackthorns 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, 'Blackthorns 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,19 +42,31 @@ 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'
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
import Appeals from './routes/admin/views/Appeals.jsx'
import ContentReports from './routes/admin/views/ContentReports.jsx'
// Player portal
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'
export default function App() {
@@ -162,6 +174,7 @@ export default function App() {
<Route index element={<Moderation />} />
<Route path="user/:discordId" element={<ModerationUser />} />
<Route path="appeals" element={<Appeals />} />
<Route path="reports" element={<ContentReports />} />
</Route>
<Route path="activity" element={<ActivityAdmin />} />
<Route path="bot-activity" element={<BotActivityAdmin />} />
@@ -174,7 +187,38 @@ export default function App() {
the volume in the first place. Declared here with the rest of
core's routes, above the module-supplied ones below. */}
<Route path="modules" element={<ModulesAdmin />} />
{/* Staff-wide, like the moderation queues: the gate on the three
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
@@ -196,7 +240,14 @@ 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
link is reading their mail, not signed in (TEAMS.md §6.4). */}
<Route path="/unsubscribe/:token" element={<Unsubscribe />} />
<Route
element={
<RequirePlayer>
@@ -211,6 +262,14 @@ export default function App() {
<Route path="/player" element={<PlayerIndex />} />
<Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} />
{/* 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'),
@@ -133,6 +163,99 @@ export const api = {
return req(`/public/wiki${withQs(s)}`)
},
wikiCategories: () => req('/public/wiki/categories'),
// ----- Teams (TEAMS.md §2.11, §4.3) -----
//
// Only the two calls CORE's own client makes. Core renders no Team pages — the
// vocabulary belongs to whichever module owns the surface — so the index, the
// roster and the player list are not here; a module that renders those calls
// the same public API from its own client.
//
// The lookup exists because a module names a Team in its own terms and core
// keys the feed by slug. Resolving that is core's job precisely so a module
// never has to hold core's identifiers.
teamByExternalId: (moduleId, externalId) =>
req(`/public/teams/by-external/${encodeURIComponent(moduleId)}/${encodeURIComponent(externalId)}`),
teamActivity: (slug, opts = {}) => {
const qs = new URLSearchParams()
if (opts.limit != null) qs.set('limit', String(opts.limit))
if (opts.offset != null) qs.set('offset', String(opts.offset))
return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`)
},
// The Team FORUM, under /player because a participant may be a plain player and
// a leader is a player (TEAMS.md §2.11). Core's, for the same reason the feed is
// core's: only core resolves whether this viewer is inside the Team, and the
// member/guest split is a security boundary. The module renders the PLACE.
teamForumThreads: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`),
teamForumThread: (slug, id) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}`),
teamForumPost: (slug, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
teamForumModerate: (slug, id, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }),
// Phase 5 ("5b"). A reply, an edit and post-level moderation are separate
// routes from their thread-level cousins rather than the same route with a
// target kind, because they answer to different rules: a reply is refused by a
// lock, an edit by a clock, and `pin`/`lock` mean nothing to a post at all.
teamForumReply: (slug, threadId, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${threadId}/posts`, { method: 'POST', body }),
teamForumEditPost: (slug, postId, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}`, { method: 'PATCH', body }),
teamForumModeratePost: (slug, postId, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}/moderate`, { method: 'POST', body }),
// The report goes to SITE STAFF, never to the Team's leaders — the whole point
// of it is a path that routes around a Team's own leadership (TEAMS.md §5.6).
// There is no leader-facing counterpart to this call and there should not be.
teamForumReport: (slug, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/report`, { method: 'POST', body }),
teamForumUpload: (slug, file) => {
const fd = new FormData()
fd.append('image', file)
return req(`/player/teams/${encodeURIComponent(slug)}/forum/uploads`, { method: 'POST', body: fd, raw: true })
},
teamGrantList: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/grants`),
teamGrantAdd: (slug, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }),
teamGrantRevoke: (slug, userId) =>
req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }),
// ----- notifications (TEAMS.md Part 6) -----
//
// Under /auth/me rather than /player: these are role-agnostic self-service, the
// same rule that put the forum under /player rather than behind a staff gate.
// The streams catalog and the per-stream subscriptions were built for the app
// and had no web consumer at all until phase 6 gave them one.
notificationStreams: () => req('/auth/me/notifications/streams'),
notificationSubscriptions: () => req('/auth/me/notifications/subscriptions'),
// `streams` is always sent, empty array included — the endpoint requires the
// 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 } }),
// Unauthenticated, and the one write in the public tier: the caller is reading
// their mail, not signed in. Always resolves 200 whatever the token was.
unsubscribeTeam: (token) =>
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link
@@ -220,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) =>
@@ -247,8 +376,138 @@ 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`.
// The caller does not choose — the server decides from the live role — so
// there is deliberately no "asRequest" argument to get wrong.
listTeams: () => req('/admin/teams'),
getTeam: (id) => req(`/admin/teams/${id}`),
resyncTeams: () => req('/admin/teams/resync', { method: 'POST' }),
archiveTeam: (id, reason) => req(`/admin/teams/${id}/archive`, { method: 'POST', body: { reason } }),
teamGrants: (id) => req(`/admin/teams/${id}/grants`),
hideTeam: (id, reason) => req(`/admin/teams/${id}/hide`, { method: 'POST', body: { reason } }),
unhideTeam: (id, reason) => req(`/admin/teams/${id}/unhide`, { method: 'POST', body: { reason } }),
setTeamDisplayName: (id, displayName, reason) =>
req(`/admin/teams/${id}/display-name`, { method: 'POST', body: { displayName, reason } }),
setTeamLeaderOverride: (id, body) =>
req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }),
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')
return req(`/admin/teams/forum/uploads${withQs(qs.toString())}`)
},
teamForumModeration: (id) => req(`/admin/teams/${id}/forum/moderation`),
teamReviewQueue: () => req('/admin/teams/review'),
teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`),
decideTeamRequest: (id, status, note) =>
req(`/admin/teams/requests/${id}/decide`, { method: 'POST', body: { status, note } }),
// ----- moderation dashboard (admin + moderator) -----
modSummary: () => req('/admin/moderation/stats/summary'),
// The content-report queue (TEAMS.md §5.6). Under moderation rather than
// under Teams because a staffer working a queue should have one place to
// work, and a report about a forum post is the same job as a report about
// anything else — which is also why `targetType` is open-ended.
contentReports: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.status) qs.set('status', opts.status)
if (opts.teamId) qs.set('teamId', String(opts.teamId))
return req(`/admin/moderation/reports${withQs(qs.toString())}`)
},
handleContentReport: (id, body) =>
req(`/admin/moderation/reports/${id}/handle`, { method: 'POST', body }),
modRecent: (params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
@@ -308,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 }),
@@ -328,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

@@ -1,12 +1,36 @@
import SiteHeader from './SiteHeader.jsx'
import SiteFooter from './SiteFooter.jsx'
import { shellClass } from '../lib/pageShell.js'
// Standard page chrome for the public site + wiki.
export default function PublicLayout({ section = 'website', header = true, children }) {
//
// ── `shell` — added in MODULE_API_VERSION 1.5.0 ────────────────────────────
//
// This component supplies the chrome and NOT the body: every core public page
// wraps its own content in `<div className="shell-… page-body">`, which is what
// centres it in a max-width column, gives it its top and bottom padding, and —
// through `page-body { flex: 1 }` — pushes the footer to the bottom of the
// viewport. Nine of nine core pages do it, so the omission has never shown.
//
// A module page cannot: it is handed `PublicLayout` through the UI kit
// (MODULE_API.md §3.4) and has no way to learn about two class names that appear
// in no contract. The Integration Kit's acceptance run built a module exactly as
// the kit teaches and it rendered full-bleed at x=0 with the footer riding up
// under the content — the precise failure §3.4 says the kit exists to prevent
// ("a module page that does not look like the site it is installed in").
//
// So the wrapper moves behind the component a module already has. `shell` is
// OPT-IN and omitting it is exactly today's behaviour, which is why core's own
// nine pages are untouched by this change — they keep their own wrapper, and a
// page wanting an unusual body still writes its own. The width mapping and its
// fallback are in lib/pageShell.js, where the DOM-less test runner can reach them.
export default function PublicLayout({ section = 'website', header = true, shell, children }) {
const bodyClass = shellClass(shell)
return (
<div className="page">
{header && <SiteHeader section={section} />}
{children}
{bodyClass ? <div className={bodyClass}>{children}</div> : children}
<SiteFooter />
</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 templates 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,26 @@
// The page-body shell core's public pages sit in, as plain JS.
//
// Extracted from PublicLayout.jsx for the reason lib/adminNav.js was: the client
// test runner has no DOM and cannot import a .jsx file at all
// (client/test/moduleRegistry.test.js says the same about modules/shared.js), so
// anything with a rule worth asserting has to live outside the component.
//
// The rule worth asserting here is the fallback. `shell` is part of the module
// contract as of MODULE_API_VERSION 1.5.0 (MODULE_API.md §3.4), which means the
// value can come from a module core has never seen, written against a version of
// this list that is older or newer than the one running. An unknown width must
// therefore still produce a wrapper: a module page at the wrong width looks like
// the site, and a page with no wrapper does not — it renders full-bleed with the
// footer riding up under it, which is the defect the prop exists to fix.
const SHELLS = { narrow: 'shell-narrow', mid: 'shell-mid', wide: 'shell-wide' }
export const SHELL_WIDTHS = Object.keys(SHELLS)
// Returns the className for a page body, or null when no shell was asked for —
// null is "render children bare", which is every core page written before 1.5.0
// and stays the default forever.
export function shellClass(shell) {
if (!shell) return null
return `${SHELLS[shell] || SHELLS.narrow} page-body`
}

View File

@@ -0,0 +1,100 @@
// What core's Team activity feed SAYS, separated from how it renders
// (docs/website/TEAMS.md §4.3).
//
// Core renders this feed into a slot a MODULE declares on its own page, because
// Teams is a contract primitive and not a surface: core owns the feed, its
// visibility rules and its wording; the module owns the page and the vocabulary
// around it. So this file is deliberately narrow — the roster and index
// presentation that once lived here went with the core Team pages, to whichever
// module renders them.
//
// Plain JS with tests, following lib/teamAdmin.js. Worth splitting for the same
// reason it was there: a feed that is filtered, or a projection that is stale,
// has to say so in words, and getting that wording right is logic rather than
// markup.
const MINUTE = 60_000
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
/** "just now" / "14 minutes ago" / "3 hours ago" / "2 days ago". */
export function relativeTime(when, now = Date.now()) {
if (!when) return null
const ms = now - new Date(when).getTime()
if (!Number.isFinite(ms)) return null
if (ms < MINUTE) return 'just now'
if (ms < HOUR) {
const n = Math.floor(ms / MINUTE)
return `${n} ${n === 1 ? 'minute' : 'minutes'} ago`
}
if (ms < DAY) {
const n = Math.floor(ms / HOUR)
return `${n} ${n === 1 ? 'hour' : 'hours'} ago`
}
const n = Math.floor(ms / DAY)
return `${n} ${n === 1 ? 'day' : 'days'} ago`
}
/**
* How a public surface describes the projection's freshness (§2.4).
*
* Distinct from `teamAdmin.freshnessOf`, which is worded for an operator
* debugging a sync. A visitor needs one sentence about whether what they are
* looking at is current, and specifically must never be shown an unconfirmed
* empty projection as though it were a confirmed empty shard.
*/
export function freshnessNote(sync = {}, now = Date.now()) {
// Nothing supplies Teams here, so there is nothing to be stale ABOUT. A
// deployment with no game module is not a broken one.
if (!sync.configured) return null
if (!sync.lastSyncAt) return { tone: 'warn', text: 'Not yet confirmed against the game.' }
const ago = relativeTime(sync.lastSyncAt, now)
if (sync.stale) return { tone: 'warn', text: `Last confirmed ${ago} — the game may have moved on.` }
return { tone: 'idle', text: `Last confirmed ${ago}.` }
}
/**
* Group feed items into days, newest first, preserving order within a day (§4.3).
*
* Keyed by local calendar date rather than by a UTC slice: "yesterday" is a
* property of where the reader is sitting, and a shard's evening raid landing at
* 00:30 UTC belongs on the day the players experienced it.
*/
export function groupByDay(items = [], locale = undefined) {
const days = []
const byKey = new Map()
for (const item of items) {
const date = new Date(item.occurredAt)
if (Number.isNaN(date.getTime())) continue
const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
if (!byKey.has(key)) {
const day = {
key,
label: date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }),
items: [],
}
byKey.set(key, day)
days.push(day)
}
byKey.get(key).items.push(item)
}
return days
}
/**
* What to say under a feed that has been filtered.
*
* Only when there is something to say: a caller who saw everything is told
* nothing, and an anonymous caller is invited to sign in rather than simply
* informed that entries exist which they cannot have.
*
* The wording avoids core's own noun. The reader is looking at a page the module
* titled — a guild, a clan — and "this Team" would be core's vocabulary leaking
* onto a surface that deliberately does not use it.
*/
export function activityScopeNote(feed = {}, signedIn = false) {
if (feed.scope !== 'public') return null
return signedIn
? 'Some entries are visible to members only.'
: 'Sign in as a member to see the members-only entries.'
}

140
client/src/lib/teamAdmin.js Normal file
View File

@@ -0,0 +1,140 @@
// What Admin → Teams SAYS, separated from how it renders (docs/website/TEAMS.md
// §2.4, §2.8, §2.9).
//
// Plain JS with tests, following lib/moduleAdmin.js. The reason it is worth
// splitting here specifically: this screen's job is to tell an operator the
// difference between "the shard has no Teams" and "core has not been able to ask
// for two hours", and those two produce almost the same page. Getting that
// wording right is logic, not markup.
/** Tones the screen uses. Names, not colours — the view maps them. */
export const TONE = { ok: 'ok', warn: 'warn', bad: 'bad', idle: 'idle' }
/**
* How to describe the projection's freshness.
*
* The four states are genuinely different and an operator needs to tell them
* apart:
*
* - no provider registered — nothing to sync, and not a fault;
* - never synced — core has an empty projection it has never confirmed, which
* must NOT read as "there are no Teams";
* - stale — the projection is real but old, and the reason is usually in
* `lastError`;
* - current.
*/
export function freshnessOf(sync = {}) {
if (!sync.configured) {
return { tone: TONE.idle, label: 'No Team provider', detail: 'No installed module supplies Teams.' }
}
if (!sync.lastSyncAt) {
return {
tone: TONE.bad,
label: 'Never synced',
detail: 'Core has never had an answer it could trust. What is shown below is not a confirmed empty shard.',
}
}
if (sync.stale) {
return {
tone: TONE.warn,
label: 'Stale',
detail: `Last confirmed ${ago(sync.lastSyncAt)}. Rosters below may be out of date.`,
}
}
return { tone: TONE.ok, label: 'Current', detail: `Last confirmed ${ago(sync.lastSyncAt)}.` }
}
/**
* A short, human age. Deliberately coarse: this exists so a sentence reads
* "confirmed 14 minutes ago", and second-level precision would be false comfort
* about a projection whose interval is fifteen minutes.
*/
export function ago(value) {
if (!value) return 'never'
const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000))
if (seconds < 90) return 'just now'
const minutes = Math.round(seconds / 60)
if (minutes < 60) return `${minutes} minutes ago`
const hours = Math.round(minutes / 60)
if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
return `${Math.round(hours / 24)} days ago`
}
/** The status pill for one Team row. */
export function statusOf(team = {}) {
if (team.status === 'archived') {
return { tone: TONE.idle, label: team.archivedReason === 'renamed' ? 'Renamed' : 'Archived' }
}
if (team.hidden && team.hiddenReason === 'reserved_name') {
return { tone: TONE.bad, label: 'Hidden — reserved name' }
}
if (team.hidden) return { tone: TONE.warn, label: 'Hidden by staff' }
return { tone: TONE.ok, label: 'Public' }
}
/**
* What a staff member is told will happen when they press the button.
*
* The gate is decided server-side from the caller's live role, so this only
* describes it. Saying "Request" to a moderator and "Apply" to an admin is what
* stops the pending result being a surprise.
*/
export function gateLabelFor(role, verb) {
return role === 'admin' ? verb : `Request ${verb.toLowerCase()}`
}
/** The three gated actions, for the note under the buttons. */
export const GATED_NOTE =
'Publishing a game-written name needs an admin: a moderators un-hide or display-name change '
+ 'is filed for approval. Hiding is not gated — suppression is always safe.'
/** A one-line description of a queued request, for the approval queue. */
export function describeRequest(request = {}) {
const payload = parsePayload(request.payload)
const who = request.requested_username || 'a deleted user'
switch (request.action) {
case 'unhide':
return `${who} asks to publish “${request.team_name}`
case 'display_name_override':
return `${who} asks to display “${request.team_name}” as “${payload.displayName || ''}`
case 'clear_display_name_override':
return `${who} asks to clear the display name on “${request.team_name}`
default:
return `${who} asks for “${request.action}” on “${request.team_name}`
}
}
/**
* The payload may arrive parsed or as a JSON string depending on the driver, so
* this normalises rather than assuming either. The server has the same note.
*/
export function parsePayload(payload) {
if (payload == null) return {}
if (typeof payload === 'object') return payload
try {
return JSON.parse(payload)
} catch {
return {}
}
}
/**
* How a member's leadership should read.
*
* An override is shown AS an override rather than folded into the answer: staff
* looking at a roster need to see that a decision was made, not a fact that looks
* like the game's.
*/
export function leadershipOf(member = {}) {
if (!member.leaderOverride) {
return { isLeader: Boolean(member.isLeader), overridden: false, note: null }
}
const granted = member.leaderOverride.effect === 'grant'
return {
isLeader: granted,
overridden: true,
note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}`
+ `${member.leaderOverride.reason ? `${member.leaderOverride.reason}` : ''}`
+ ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`,
}
}

View File

@@ -0,0 +1,85 @@
// The Team forum's client-side judgements — the few there are (TEAMS.md Part 5).
//
// This file is small on purpose. **Almost nothing about the forum is the
// client's to decide**: who may post, who may moderate, whether an image
// renders, and whether a post may be edited are all answered by the server and
// read from the payload. What is left here is the handful of pure functions that
// turn those answers into what a reader sees, and they are extracted so they can
// be tested without a browser.
//
// The one that deserves a second look is `editOfferOpen`. It can only ever take
// an offer AWAY — the server grants the edit and re-derives the window from
// `created_at` when the write arrives. A client that granted one would be
// deciding a time-bounded permission against the clock of the party it bounds.
export const REPORT_REASONS = [
['abuse', 'Abusive or harassing'],
['spam', 'Spam'],
['sexual', 'Sexual content'],
['illegal', 'Illegal content'],
['impersonation', 'Impersonation'],
['other', 'Something else'],
]
/**
* Should the Edit control still be offered for this post?
*
* Three states, and the middle one is the reason this exists:
* • the server said no → no offer, and nothing here can create one
* • the server said yes, no deadline (staff) → offer
* • the server said yes with a deadline that has since passed while the page
* sat open → withdraw the offer, rather than leave a button that fails
*/
export function editOfferOpen(post, now = Date.now()) {
if (!post || !post.canEdit) return false
if (!post.editableUntil) return true
const until = new Date(post.editableUntil).getTime()
return Number.isFinite(until) && until > now
}
/**
* Turn a rendered body back into something an author can edit.
*
* The server stores sanitised HTML and generates images at READ time from the
* URLs an author wrote (§5.5.3), so what comes back is not what was typed. The
* `<img>` has to go — it is core's output, not the author's input, and leaving it
* in would let an author "edit" markup they never wrote and cannot control.
* The URL survives as the link text beside it, which is what re-renders.
*/
export function stripToText(html) {
return String(html || '')
.replace(/<img[^>]*>/gi, '')
.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]*>/g, '')
// Entities last: unescaping before tag-stripping would let an escaped
// "&lt;script&gt;" become a real tag the next pass then removes, which is a
// different string from the one the author wrote.
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
// `&amp;` last of all, or "&amp;lt;" would decode two steps into "<".
.replace(/&amp;/g, '&')
.trim()
}
/**
* The one-line summary under a thread's title in the list.
*
* `postCount` counts every post including the opening one, so a discussion's
* REPLY count is one less — and an announcement has no replies to count at all,
* which is why the count is omitted rather than shown as zero.
*/
export function threadSummary(thread) {
const parts = []
if (thread.type === 'announcement') parts.push('Announcement')
parts.push(thread.author)
if (thread.type === 'discussion' && thread.postCount > 1) {
const replies = thread.postCount - 1
parts.push(`${replies} ${replies === 1 ? 'reply' : 'replies'}`)
}
if (thread.status === 'hidden') parts.push('hidden')
return parts.join(' · ')
}

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 bots 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,10 @@ 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 } 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'
import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
@@ -18,8 +21,6 @@ publishSharedDependencies()
// and namespace `uo`, so that the seam was exercised by real content from the
// day it was built. That prediction paid out exactly as written: the extraction
// deleted the registration and the hook it named, and SiteHeader was not touched.
// There is nothing for core to register now — no core nav row carries a
// `feature` — and the filter is a correct no-op until a module supplies one.
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
//
@@ -56,6 +57,49 @@ declareSlot('player.invite.accepted')
// all three, and core's own fills had to go for it to be able to — the first
// fill wins, and core registered first (§3.7).
// ── The inverted direction: core fills a MODULE's slot ─────────────────────
//
// Teams is a contract PRIMITIVE, not a surface (TEAMS.md Part 3). Core owns the
// tables, the sync, the access rules and the activity feed; it does not own the
// word for one — a UO shard says guild, and the module that comes after it will
// say clan. So core publishes no Team page and no Team nav row, and the module
// that owns the vocabulary owns the page.
//
// The activity feed is the one piece of that page core cannot hand over: only
// core can resolve whether this viewer is inside the Team, and the public/members
// split is a security boundary. So the module declares the place and core fills
// it. Registered here, applied at mount — `applyCoreFills` runs after every
// module chunk has evaluated, which is the only moment a module-declared slot
// exists to be filled.
//
// **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.
offerCoreFill('team.activity', TeamActivityFeed)
// 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. 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.
//
@@ -82,6 +126,10 @@ declareSlot('player.invite.accepted')
// static deferred script, so this branch is the genuine "the event has already
// been and gone" case and not a wrong guess about our own timing.
function mount() {
// Every module chunk has evaluated by now, so any slot a module declared is
// present and core's pending fills can land. Must happen before the first
// render: `extensionFor` is read during render and there is no subscription.
applyCoreFills()
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>

View File

@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { activityScopeNote, freshnessNote, groupByDay } from '../lib/teamActivity.js'
// Core's Team activity feed, rendered into a slot a MODULE declares
// (TEAMS.md Part 4, §3.4 as amended).
//
// **This is the inverted slot direction, and this component is why it exists.**
// The feed is core's: core owns `team_activity`, writes the membership and rename
// items into it, enforces the public/members split, and is the only thing that
// can resolve whether this viewer is inside the Team. None of that is a module's
// to reimplement. But the PAGE is the module's, because Teams is a contract
// primitive and core does not own the word for one — a UO shard says guild, the
// next game will say something else. So the module declares the place and core
// puts the feed in it.
//
// The module passes the Team in ITS OWN vocabulary — `externalId` plus its module
// id — and core resolves the slug. A module never learns core's Team id and never
// needs to: it names the thing the way it already names it.
//
// Everything here degrades to rendering nothing. A slot that throws is contained
// by core's own boundary (Slot.jsx), but a slot that renders an error box would
// still be core putting a defect on a page it does not own — so a failed fetch is
// silence, not a message.
export default function TeamActivityFeed({ externalId, moduleId, limit = 25 }) {
const { user } = useAuth()
const [state, setState] = useState({ loading: true, feed: null, team: null })
useEffect(() => {
let active = true
if (!externalId || !moduleId) {
setState({ loading: false, feed: null, team: null })
return undefined
}
// Two calls because the module names the Team its way and the feed is keyed
// by core's slug. The lookup is core's job precisely so the module does not
// have to hold core's identifiers.
api.teamByExternalId(moduleId, externalId)
.then(async (team) => {
const feed = await api.teamActivity(team.slug, { limit })
if (active) setState({ loading: false, feed, team })
})
.catch(() => { if (active) setState({ loading: false, feed: null, team: null }) })
return () => { active = false }
}, [externalId, moduleId, limit])
const { loading, feed, team } = state
if (loading || !feed) return null
const days = groupByDay(feed.items || [])
const note = team ? freshnessNote(team) : null
const scopeNote = activityScopeNote(feed, Boolean(user))
// Nothing has happened and nothing to explain: render nothing rather than an
// empty heading on someone else's page.
if (days.length === 0 && !scopeNote) return null
return (
<section style={{ marginTop: 26 }}>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', marginBottom: 4 }}>
Recent activity
</h2>
{note && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 12px' }}>{note.text}</p>
)}
{days.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.9rem' }}>Nothing has happened here yet.</p>
)}
{days.map((day) => (
<div key={day.key} style={{ marginBottom: 16 }}>
<h3
className="sans dim"
style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 6 }}
>
{day.label}
</h3>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: 6 }}>
{day.items.map((item) => (
<li key={item.id} className="sans" style={{ fontSize: '0.92rem', color: 'var(--ink)' }}>
{item.summary}
</li>
))}
</ul>
</div>
))}
{scopeNote && (
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>{scopeNote}</p>
)}
</section>
)
}

View File

@@ -0,0 +1,754 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import DOMPurify from 'dompurify'
import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../lib/teamForum.js'
// Core's Team forum, rendered into a second slot a MODULE declares
// (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
//
// **Why the forum is core's content on a module's page.** Everything that decides
// who may read a thread is core's — the §2.5 resolver, the grants ledger, the
// member/guest distinction — and none of it is a module's to reimplement. But
// core does not own the word for a Team, so it publishes no Team page: the module
// that says "guild" owns the page and declares a place on it, and core fills the
// place. Same direction as the activity feed, same reason.
//
// **It is a whole forum inside one slot, and navigates by SEARCH PARAM.** A
// thread needs to be linkable, and core cannot mount a route for it — the route
// belongs to the module's page. `?thread=12` gives a shareable URL that works
// under whatever path the module chose, with no route of core's anywhere in it,
// and the browser's back button behaves. That is the whole reason this component
// holds a list view and a detail view rather than being two components.
//
// **The image mode is published so this can draw the right composer — never to
// decide what renders.** Post bodies arrive already rendered by the server under
// the current policy (§5.5.3); the mode is read here only to show or hide an
// upload control that would otherwise 404. If the two ever disagree, the server
// is right.
//
// **Phase 5 added discussion, and with it three capabilities this file must not
// invent for itself.** `canPost`, `canAnnounce` and each post's `canEdit` are
// computed on the server and read here. In particular the edit window is a
// server decision twice over — the read path stamps `canEdit`/`editableUntil` and
// the write re-derives it — because a time-bounded permission must not take its
// clock from the party it bounds. What this file does with `editableUntil` is
// stop OFFERING an edit whose deadline has passed while the page sat open; it
// never grants one.
//
// Like the feed, everything here degrades to rendering nothing. A 404 from the
// thread list is the ordinary case — the forum is switched off, or this viewer
// has no access — and putting an error box on a page core does not own would be
// core reporting its own absence as a defect on someone else's surface.
export default function TeamForumPanel({ externalId, moduleId }) {
const { user } = useAuth()
const { settings } = useSite()
const [params, setParams] = useSearchParams()
const [team, setTeam] = useState(null)
const [state, setState] = useState({ loading: true, forum: null })
const [thread, setThread] = useState(null)
const [composing, setComposing] = useState(null) // 'discussion' | 'announcement' | null
const openThreadId = params.get('thread')
const imageMode = settings?.teams_forum_images || 'disabled'
const forumsEnabled = String(settings?.teams_forums_enabled ?? '0') === '1'
const loadThreads = useCallback(async (slug) => {
try {
setState({ loading: false, forum: await api.teamForumThreads(slug) })
} catch {
setState({ loading: false, forum: null })
}
}, [])
const loadThread = useCallback(async (slug, id) => {
try {
setThread(await api.teamForumThread(slug, id))
} catch {
setThread(null)
}
}, [])
useEffect(() => {
let active = true
// An anonymous visitor has no forum by definition — every route is behind
// requireAuth — so skip the two calls rather than provoking a 401 per page.
if (!externalId || !moduleId || !user || !forumsEnabled) {
setState({ loading: false, forum: null })
return undefined
}
// The module names the Team its own way; core resolves that to a slug. Same
// two-call shape as the activity feed, and for the same reason: a module
// never has to hold core's identifiers.
api.teamByExternalId(moduleId, externalId)
.then(async (found) => {
if (!active) return
setTeam(found)
await loadThreads(found.slug)
})
.catch(() => { if (active) setState({ loading: false, forum: null }) })
return () => { active = false }
}, [externalId, moduleId, user, forumsEnabled, loadThreads])
useEffect(() => {
let active = true
if (!team || !openThreadId) {
setThread(null)
return undefined
}
api.teamForumThread(team.slug, openThreadId)
.then((t) => { if (active) setThread(t) })
.catch(() => { if (active) setThread(null) })
return () => { active = false }
}, [team, openThreadId])
const openThread = (id) => {
const next = new URLSearchParams(params)
if (id == null) next.delete('thread')
else next.set('thread', String(id))
setParams(next)
}
const { loading, forum } = state
if (loading || !forum) return null
if (openThreadId && thread) {
return (
<ThreadView
slug={team.slug}
thread={thread}
canModerate={forum.canModerate}
imageMode={imageMode}
onBack={() => openThread(null)}
onChanged={() => loadThread(team.slug, thread.id)}
onModerate={async (action) => {
await api.teamForumModerate(team.slug, thread.id, { action })
await loadThreads(team.slug)
openThread(null)
}}
/>
)
}
return (
<section style={{ marginTop: 26 }}>
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: 0 }}>
Forum
</h2>
{!composing && (
<div style={{ display: 'flex', gap: 8 }}>
{/*
Two buttons, because phase 5 split one capability in two. `canPost`
means "may open a discussion" and every participant may — including a
granted guest with no game character, which is path 3 doing its job.
`canAnnounce` is the leader-only half.
*/}
{forum.canPost && (
<button type="button" className="pill" onClick={() => setComposing('discussion')}>
Start a discussion
</button>
)}
{forum.canAnnounce && (
<button type="button" className="pill" onClick={() => setComposing('announcement')}>
Post an announcement
</button>
)}
</div>
)}
</header>
{composing && (
<Composer
slug={team.slug}
type={composing}
imageMode={imageMode}
onCancel={() => setComposing(null)}
onPosted={async () => {
setComposing(null)
await loadThreads(team.slug)
}}
/>
)}
{forum.threads.length === 0 && !composing && (
<p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}>
Nothing has been posted here yet.
</p>
)}
{forum.canModerate && <GuestManager slug={team.slug} />}
<ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0', display: 'grid', gap: 8 }}>
{forum.threads.map((t) => (
<li key={t.id}>
<button
type="button"
className="sans"
onClick={() => openThread(t.id)}
style={{
background: 'none', border: 0, padding: 0, cursor: 'pointer',
textAlign: 'left', color: 'var(--ink)', font: 'inherit',
}}
>
{t.pinned && <span className="dim" style={{ marginRight: 6 }} title="Pinned">📌</span>}
{t.locked && <span className="dim" style={{ marginRight: 6 }} title="Locked">🔒</span>}
<strong>{t.title}</strong>
<span className="dim" style={{ marginLeft: 8, fontSize: '0.82rem' }}>
{threadSummary(t)}
</span>
</button>
</li>
))}
</ul>
</section>
)
}
/**
* The leader's grant control — §2.5 path 3, exercised by a leader rather than by
* staff.
*
* Worth being explicit about what this admits someone to and what it does not: a
* grant may name ANY account, including one with no linked game character, and it
* writes nothing but the grants ledger. A guest here never appears on the roster,
* never counts towards the Team's membership, and never becomes eligible for a
* Discord role — an integration cannot verify that an unlinked account is a real
* game member, so it must not hand that account a privilege somewhere
* impersonation has consequences.
*
* A leader is capped; staff are not. The cap is shown rather than only enforced,
* because a leader who hits a limit they were never told about reads it as a bug.
*/
function GuestManager({ slug }) {
const [open, setOpen] = useState(false)
const [data, setData] = useState(null)
const [username, setUsername] = useState('')
const [error, setError] = useState(null)
const load = useCallback(async () => {
try {
setData(await api.teamGrantList(slug))
} catch {
setData(null)
}
}, [slug])
useEffect(() => { if (open) load() }, [open, load])
const add = async (event) => {
event.preventDefault()
setError(null)
try {
await api.teamGrantAdd(slug, { username })
setUsername('')
await load()
} catch (err) {
setError(err.message || 'Could not grant access')
}
}
const revoke = async (userId) => {
setError(null)
try {
await api.teamGrantRevoke(slug, userId)
await load()
} catch (err) {
setError(err.message || 'Could not revoke that')
}
}
if (!open) {
return (
<button type="button" className="pill" onClick={() => setOpen(true)} style={{ marginTop: 10 }}>
Forum guests
</button>
)
}
return (
<section style={{ marginTop: 12, padding: 12, border: '1px solid var(--rule, #ccc)', borderRadius: 6 }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<h3 className="sans" style={{ margin: 0, fontSize: '0.95rem' }}>Forum guests</h3>
<button type="button" className="pill" onClick={() => setOpen(false)}>Close</button>
</header>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '6px 0 10px' }}>
Guests read and post in this forum without being members of the Team. They do not appear on the
roster and are not counted as members.
{data?.cap ? ` Up to ${data.cap} at a time.` : ''}
</p>
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 10px', display: 'grid', gap: 6 }}>
{(data?.guests || []).map((g) => (
<li key={g.userId} className="sans" style={{ fontSize: '0.88rem', display: 'flex', gap: 8 }}>
<span>{g.username}</span>
<button type="button" className="pill" onClick={() => revoke(g.userId)}>Remove</button>
</li>
))}
{data && data.guests.length === 0 && (
<li className="sans dim" style={{ fontSize: '0.85rem' }}>No guests yet.</li>
)}
</ul>
<form onSubmit={add} style={{ display: 'flex', gap: 8 }}>
<input
className="input"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Account name"
maxLength={32}
required
/>
<button type="submit" className="btn btn-primary btn-sq">Add</button>
</form>
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
</section>
)
}
function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onModerate }) {
// A clock that ticks, so an edit control whose deadline passed while the page
// sat open goes away instead of becoming a button that fails. It only ever
// REMOVES an offer — the server decides whether an edit happens, and re-derives
// the window from created_at when it does.
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 30_000)
return () => clearInterval(id)
}, [])
const [replying, setReplying] = useState(false)
return (
<section style={{ marginTop: 26 }}>
<button type="button" className="pill" onClick={onBack} style={{ marginBottom: 10 }}>
All threads
</button>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}>
{thread.title}
</h2>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
{thread.type === 'announcement' ? 'Announcement · ' : ''}
{thread.author}
{thread.authorDeleted && ' (account removed)'}
{thread.locked && ' · locked'}
</p>
{thread.posts.map((post) => (
<PostView
key={post.id}
slug={slug}
post={post}
canModerate={canModerate}
now={now}
onChanged={onChanged}
/>
))}
{/*
`canReply` is the server's answer to "does this thread take replies right
now", and it folds together the two reasons it might not: an announcement
takes none by TYPE, and a locked thread takes none by STATE. Both are
reported separately above so the reader can see which.
*/}
{thread.canReply && !replying && (
<button type="button" className="pill" onClick={() => setReplying(true)} style={{ marginTop: 4 }}>
Reply
</button>
)}
{thread.canReply && replying && (
<ReplyBox
slug={slug}
threadId={thread.id}
imageMode={imageMode}
onCancel={() => setReplying(false)}
onPosted={async () => {
setReplying(false)
await onChanged()
}}
/>
)}
{!thread.canReply && thread.locked && (
<p className="sans dim" style={{ fontSize: '0.85rem', marginTop: 10 }}>
This thread is locked. Nobody can reply to it, including staff a moderator who wants the
last word unlocks it first, which leaves a record.
</p>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' }}>
<ReportControl
slug={slug}
targetType="team_forum_thread"
targetId={thread.id}
label="Report this thread"
/>
{canModerate && (
<>
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
{thread.pinned ? 'Unpin' : 'Pin'}
</button>
<button type="button" className="pill" onClick={() => onModerate(thread.locked ? 'unlock' : 'lock')}>
{thread.locked ? 'Unlock' : 'Lock'}
</button>
<button type="button" className="pill" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
</>
)}
</div>
</section>
)
}
/**
* One post, with whatever this reader may do to it.
*
* Every capability shown here was decided by the server and is read, not
* computed: `canEdit` and `editableUntil` come stamped on the post, and
* `canModerate` on the thread. The one local judgement is whether an
* already-granted edit window has since elapsed, which can only take an offer
* away.
*/
function PostView({ slug, post, canModerate, now, onChanged }) {
const [editing, setEditing] = useState(false)
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const stillEditable = useMemo(() => editOfferOpen(post, now), [post, now])
const save = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
await api.teamForumEditPost(slug, post.id, { body })
setEditing(false)
await onChanged()
} catch (err) {
setError(err.message || 'Could not save that')
} finally {
setBusy(false)
}
}
const moderate = async (action) => {
setError(null)
try {
await api.teamForumModeratePost(slug, post.id, { action })
await onChanged()
} catch (err) {
setError(err.message || 'Could not do that')
}
}
return (
<article style={{ marginBottom: 16 }}>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 2px' }}>
{post.author}
{post.authorDeleted && ' (account removed)'}
{post.editedAt && ' · edited'}
{post.status === 'hidden' && ' · hidden'}
</p>
{editing ? (
<form onSubmit={save} style={{ display: 'grid', gap: 8 }}>
<textarea
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={6}
required
/>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Save</button>
<button type="button" className="pill" onClick={() => setEditing(false)}>Cancel</button>
</div>
</form>
) : (
<>
{/*
Sanitised on write with the forum's own profile, rendered server-side
under the operator's image policy, and re-sanitised here — the same
defence-in-depth every other body-HTML surface on this site applies
(FiveOnFriday, NewsletterIssue, the rich-text block).
`ADD_ATTR: ['referrerpolicy']` is load-bearing and not a preference.
DOMPurify's default allowlist carries `loading` but NOT
`referrerpolicy`, so a plain sanitize() call silently strips the one
attribute that limits what a remote embed leaks to the host serving it
— the privacy property the admin help text promises an operator. The
<img> itself is core's own output with a fixed attribute set, so
nothing here is widening what an author can write.
*/}
{/* eslint-disable-next-line react/no-danger */}
<div
className="prose"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body || '', { ADD_ATTR: ['referrerpolicy'] }) }}
/>
</>
)}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
{!editing && (
<div style={{ display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{stillEditable && (
<button
type="button"
className="pill"
onClick={() => { setBody(stripToText(post.body)); setEditing(true) }}
>
Edit
</button>
)}
{/* Reporting your own post is pointless rather than harmful, but
offering it reads as an invitation to misunderstand the control. */}
{!post.mine && (
<ReportControl
slug={slug}
targetType="team_forum_post"
targetId={post.id}
label="Report"
/>
)}
{canModerate && (
<>
<button type="button" className="pill" onClick={() => moderate(post.status === 'hidden' ? 'unhide' : 'hide')}>
{post.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
<button type="button" className="pill" onClick={() => moderate('delete')}>Delete</button>
</>
)}
</div>
)}
</article>
)
}
/**
* The report control — the first user-facing report flow this site has ever had.
*
* **It goes to site staff, and it says so.** The gap it closes is that leaders
* moderate their own Team's forum and a Team's leaders are exactly the people who
* will not report their own Team, so telling a member where the report lands is
* not reassurance copy — it is the whole reason the control is worth using in a
* Team whose leadership is the problem.
*
* A report changes nothing about the content, and the confirmation says that too,
* because a member who expects a post to vanish and watches it stay will report
* it again.
*/
function ReportControl({ slug, targetType, targetId, label }) {
const [open, setOpen] = useState(false)
const [reason, setReason] = useState('abuse')
const [detail, setDetail] = useState('')
const [done, setDone] = useState(false)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
await api.teamForumReport(slug, { targetType, targetId, reason, detail: detail || undefined })
setDone(true)
setOpen(false)
} catch (err) {
setError(err.message || 'Could not send that')
} finally {
setBusy(false)
}
}
if (done) {
return (
<span className="sans dim" style={{ fontSize: '0.8rem' }}>
Reported to site staff.
</span>
)
}
if (!open) {
return (
<button type="button" className="pill" onClick={() => setOpen(true)}>
{label}
</button>
)
}
return (
<form
onSubmit={submit}
style={{
display: 'grid', gap: 8, marginTop: 8, padding: 12, width: '100%',
border: '1px solid var(--rule, #ccc)', borderRadius: 6,
}}
>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
This goes to <strong>site staff</strong>, not to this Team&rsquo;s leaders. Reporting does not
hide or change anything it asks a staffer to look.
</p>
<label className="sans" style={{ fontSize: '0.85rem' }}>
Reason
{' '}
<select className="input" value={reason} onChange={(e) => setReason(e.target.value)}>
{REPORT_REASONS.map(([value, text]) => (
<option key={value} value={value}>{text}</option>
))}
</select>
</label>
<textarea
className="textarea"
value={detail}
onChange={(e) => setDetail(e.target.value)}
placeholder="Anything a staffer should know (optional)"
maxLength={500}
rows={3}
/>
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Send report</button>
<button type="button" className="pill" onClick={() => setOpen(false)}>Cancel</button>
</div>
</form>
)
}
/** A reply to an open discussion thread. */
function ReplyBox({ slug, threadId, imageMode, onCancel, onPosted }) {
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
await api.teamForumReply(slug, threadId, { body })
await onPosted()
} catch (err) {
setError(err.message || 'Could not post that')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 10 }}>
<textarea
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write a reply. Paste an image URL on its own line to share a picture."
rows={5}
required
/>
{imageMode === 'uploads' && (
<ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
)}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Post reply</button>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}
/**
* The upload control, shared by both composers.
*
* The URL goes into the BODY as text, never as an `<img>` tag. The author never
* writes markup here — core decides at render time whether a URL becomes a
* picture, which is what makes the operator's image policy enforceable rather
* than decorative.
*/
function ImageAttacher({ slug, onAttached, onError }) {
const attach = async (event) => {
const file = event.target.files?.[0]
if (!file) return
try {
const { url } = await api.teamForumUpload(slug, file)
onAttached(url)
} catch (err) {
onError(err.message || 'Could not upload that')
}
}
return (
<label className="sans dim" style={{ fontSize: '0.85rem' }}>
Attach an image: <input type="file" accept="image/*" onChange={attach} />
</label>
)
}
function Composer({ slug, type, imageMode, onCancel, onPosted }) {
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const isAnnouncement = type === 'announcement'
const submit = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
// `type` is always sent explicitly. The server defaults an absent one to
// `announcement` so that a phase-4 client keeps meaning what it meant, and
// relying on that default here would make a discussion depend on a
// compatibility shim.
await api.teamForumPost(slug, { type, title, body })
await onPosted()
} catch (err) {
setError(err.message || 'Could not post that')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 12 }}>
<input
className="input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
maxLength={200}
required
/>
<textarea
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={isAnnouncement
? 'Write your announcement. Paste an image URL on its own line to share a picture.'
: 'Start the discussion. Paste an image URL on its own line to share a picture.'}
rows={6}
required
/>
{isAnnouncement && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
Announcements cannot be replied to.
</p>
)}
{imageMode === 'uploads' && (
<ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
)}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
{isAnnouncement ? 'Post announcement' : 'Start discussion'}
</button>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,102 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.jsx'
// Core's per-Team notification control, rendered into a THIRD slot a module
// declares (TEAMS.md §6.3, phase 6).
//
// **Why this is a slot at all, and why it is the third one.** Teams have no core
// page — the module that owns the vocabulary owns the page — so a control that
// acts on one Team has nowhere of core's to live. The feed and the forum go below
// the module's roster; this goes above it, because muting a guild is an action ON
// the page rather than more content in it, and that is exactly the placement
// decision a module cannot make if core stacks everything into one fill.
//
// **It renders nothing for a viewer who is not in the Team**, including anonymous
// ones, and that is a privacy property rather than a tidiness one: whether a
// notification preference EXISTS for a Team answers "is this person in it", and
// the guild page is public. The server decides — the preference list only contains
// Teams the caller may be notified about — and this file never infers membership
// from anything it can see on the page.
//
// **Muting is per-Team and covers all four streams.** The per-stream on/off lives
// on the account screen, where the catalog does; the thing that could not be
// expressed before phase 6 is "I am in five Teams and want notifications from
// one", and that is the only question this control asks.
export default function TeamNotifyToggle({ externalId, moduleId }) {
const { user } = useAuth()
const [state, setState] = useState({ loading: true, team: null, pref: null })
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
// Anonymous viewers never fetch. The endpoint would 401 harmlessly, but a
// guild page rendering a public roster should not put an authenticated
// request on the wire for every visitor.
if (!user) return setState({ loading: false, team: null, pref: null })
try {
const team = await api.teamByExternalId(moduleId, externalId)
const { teams } = await api.teamNotificationPrefs()
const pref = (teams || []).find((t) => t.teamId === team.id) || null
setState({ loading: false, team, pref })
} catch {
// Same rule as the feed and the forum: this is core's content on a page
// core does not own, so a failure renders nothing rather than putting an
// error box on somebody else's surface.
setState({ loading: false, team: null, pref: null })
}
}, [externalId, moduleId, user])
useEffect(() => { load() }, [load])
const { loading, pref } = state
if (loading || !pref) return null
async function toggle() {
setBusy(true)
// Optimistic, and reconciled from the server's echo rather than assumed: a
// PUT that silently dropped the entry (a Team left in another tab) must not
// leave the control claiming a state the server does not hold.
const next = { ...pref, muted: !pref.muted }
setState((s) => ({ ...s, pref: next }))
try {
const { teams } = await api.setTeamNotificationPrefs([
{ teamId: pref.teamId, muted: next.muted, emailMode: pref.emailMode },
])
const echoed = (teams || []).find((t) => t.teamId === pref.teamId)
if (echoed) setState((s) => ({ ...s, pref: echoed }))
} catch {
setState((s) => ({ ...s, pref }))
} finally {
setBusy(false)
}
}
return (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
flexWrap: 'wrap',
margin: '10px 0 0',
fontSize: '0.84rem',
}}
>
<button type="button" onClick={toggle} disabled={busy} className="btn btn-sq">
{pref.muted ? 'Unmute notifications' : 'Mute notifications'}
</button>
<span className="dim">
{pref.muted
? 'You get no notifications about this team.'
: 'You get notifications about this team.'}
</span>
{/* 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/settings" className="dim">All notification settings</Link>
</div>
)
}

View File

@@ -135,6 +135,120 @@ 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.
*
* Added for Teams (TEAMS.md Part 3). The original direction assumes core owns
* the page and a module contributes to it, which is right for the footer and the
* admin user detail. Teams is the other shape: **Teams is a contract primitive,
* not a surface.** Core owns the tables, the sync, the access rules and the
* activity feed; it does not own the vocabulary — a UO shard calls them guilds
* and the next game will call them something else — so the PAGE is the module's
* and the content core contributes to it is core's.
*
* Without this, core would have to publish a `/teams` page under a word it
* invented, next to the module's own Guilds page saying the same thing twice.
*
* A module namespaces its slot under its own id (`uo.guild.detail`), which is
* 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 offers its
* contributions through `offerCoreFill` below, applied after every module chunk
* has evaluated — see main.jsx.
*/
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`)
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 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: "here is my <contribution>, for whichever module asked for it."
*
* 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 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 contributions. Called once from main.jsx, after module chunks have run. */
export function applyCoreFills() {
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
}
/**
* Fill a declared slot with a component.
*
@@ -207,6 +321,7 @@ export function _reset() {
nav[area].length = 0
}
providers.clear()
coreFills.length = 0
// Declarations go too, unlike the server's, where a slot is declared once at
// require time by the router that owns it. Core declares its slots in
// main.jsx — the one file no test loads — so on this side there is nothing
@@ -224,6 +339,8 @@ export const registry = {
registerNav,
registerFeatureProvider,
registerExtension,
// The inverted direction (TEAMS.md Part 3): the module declares, core fills.
declareModuleSlot,
routesFor,
navFor,
featureProviderFor,

View File

@@ -34,13 +34,15 @@ import { MODULE_API_VERSION } from './version.js'
import PublicLayout from '../components/PublicLayout.jsx'
import PageHeader from '../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
import Slot from './Slot.jsx'
import { useAsync } from '../lib/useAsync.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { request, ApiError, BASE } from '../api/client.js'
// The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
// seven are what the smallest UO page already needs beyond React and the router:
// eight exports — five table rows in §3.4, since `PageState` contributes three —
// are what the smallest UO page already needs beyond React and the router:
// without them a module either reaches into core's tree — violating the
// zero-import rule the whole boundary rests on — or ships its own copies, which
// means a module page that does not look like the site it is installed in, and
@@ -50,11 +52,12 @@ import { request, ApiError, BASE } from '../api/client.js'
// is a MAJOR one. That is a real constraint on core's own refactoring and it is
// the price of the boundary being worth anything.
//
// `AdminPage` appears in §3.4's table and is deliberately absent: core has no
// such component — admin views are plain markup inside AdminLayout — and
// inventing one to satisfy a table would be a core change with no consumer until
// Phase 3. The contract is amended rather than the code padded, and adding it
// later costs a minor bump, which is exactly the case the versioning is for.
// `AdminPage` was in an early draft of §3.4's table and is deliberately absent:
// core has no such component — admin views are plain markup inside AdminLayout —
// and inventing one to satisfy a table would be a core change with no consumer
// until Phase 3. The contract was amended rather than the code padded (it no
// longer lists it), and adding it later costs a minor bump, which is exactly the
// case the versioning is for.
const ui = {
PublicLayout,
PageHeader,
@@ -64,6 +67,13 @@ const ui = {
useAsync,
useAuth,
useSite,
// 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.
// Shared rather than reimplemented for the reason the whole kit exists: two
// boundaries with different behaviour would be two bugs.
Slot,
}
// The request PRIMITIVE, not the `api` object (§3.5): a module builds its own

View File

@@ -11,6 +11,38 @@
// 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
// hosts it, which lands with the Team pages in phase 3. 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.
//
// 1.5.0 — `PublicLayout` takes an optional `shell` prop ('narrow' | 'mid' |
// 'wide') that renders the `shell-… page-body` wrapper core's own pages write by
// hand. Additive: omitting it is 1.4.0's behaviour, so §3.4's "changing a kit
// component's props is major" does not bite — nothing already written changes
// meaning. It exists because the kit's acceptance run proved a module cannot
// discover the wrapper: the class names are theme.css's and appear in no
// contract, so a module page rendered outside the site's column while doing
// everything the kit said (docs/modules/kit-acceptance.md).
// 1.4.0 — a rule, not a member: §2.7 forbids a module opening a connection to a
// game server from the website process (it talks to a sidecar, which owns the
// durable copy). Nothing on window.__rg changed and nothing on the server's ctx
// changed either; this half bumps because the two halves state ONE version.
// 1.3.0 — three additions, all from Phase 3 slice 3 needing them: a nav item may
// carry an `icon` component (§3.3), core declares a third slot
// `player.invite.accepted` (§3.7), and `window.__rg.api` gained `BASE`, which
@@ -26,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.3.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`
@@ -76,6 +83,36 @@ export const NAV = [
items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
// Member-raised reports (TEAMS.md §5.6). Here rather than under Teams
// because a staffer working a queue should have one place to work — and
// because the queue is deliberately generic, so the next thing that can
// be reported arrives as a row rather than as another nav entry.
{ to: '/admin/moderation/reports', label: 'Reports', icon: IconShield, roles: ['admin', 'moderator'] },
// Moderation rather than System: the screen's daily job is the
// reserved-name review queue, which is moderator work. The three actions
// that publish a game-written name are gated to admins server-side, so a
// moderator reaching this screen is correct — what they do here is file a
// request (TEAMS.md §2.9).
{ 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'] },
],
},
{
@@ -98,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 },
],
},
@@ -134,6 +176,8 @@ const TITLES = {
'/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation',
'/admin/moderation/appeals': 'Appeals',
'/admin/moderation/reports': 'Reports',
'/admin/teams': 'Teams',
'/admin/settings': 'Site Settings',
'/admin/appearance': 'Appearance',
'/admin/navigation': 'Navigation',
@@ -144,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
@@ -163,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'
}
@@ -404,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

@@ -0,0 +1,310 @@
import { useCallback, useState } from 'react'
import Modal from '../../../components/Modal.jsx'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// The member-raised content-report queue (TEAMS.md §5.6).
//
// **This is the only view of this queue, and that is the design.** The gap §5.6
// exists to close has a specific shape: leaders moderate their own Team's forum,
// and a Team's leaders are exactly the people who will not report their own Team.
// A leader-visible queue would route a complaint about a leader back to that
// leader. Org lead, 2026-08-18: reports are **site administration only**. If a
// leader-facing view is ever wanted it is a design decision, not a component.
//
// It sits beside Appeals rather than under Teams because a staffer working a
// queue should have one place to work — and because `target_type` is deliberately
// open-ended, so the next consumer (a wiki page, a news comment) arrives as a new
// row here rather than as a new screen.
//
// **Handling a report is bookkeeping about the REPORT, not moderation of the
// content.** Acting on the content itself is the ordinary forum moderation
// control, or a site-wide sanction against the account. Keeping those separate is
// what stops "report" from becoming a way for any member to hide anything, so
// this screen deliberately offers no hide/delete button of its own.
const STATUS_TABS = [
{ key: 'open_work', label: 'Open work', param: undefined },
{ key: 'open', label: 'Open', param: 'open' },
{ key: 'reviewing', label: 'Reviewing', param: 'reviewing' },
{ key: 'actioned', label: 'Actioned', param: 'actioned' },
{ key: 'dismissed', label: 'Dismissed', param: 'dismissed' },
{ key: 'all', label: 'All', param: 'all' },
]
const STATUS_STYLE = {
open: { color: '#e0b070', background: 'rgba(224,176,112,0.12)', border: '1px solid rgba(224,176,112,0.4)' },
reviewing: { color: '#7fa8d0', background: 'rgba(127,168,208,0.14)', border: '1px solid rgba(127,168,208,0.4)' },
actioned: { color: '#7fd0a4', background: 'rgba(95,185,138,0.16)', border: '1px solid rgba(95,185,138,0.4)' },
dismissed: { color: '#9fb0c6', background: 'rgba(127,153,189,0.14)', border: '1px solid var(--line)' },
}
const STATUS_LABEL = {
open: 'Open', reviewing: 'Reviewing', actioned: 'Actioned', dismissed: 'Dismissed',
}
const REASON_LABEL = {
spam: 'Spam',
abuse: 'Abuse',
sexual: 'Sexual',
illegal: 'Illegal',
impersonation: 'Impersonation',
other: 'Other',
}
const bytes = (n) => {
if (!n && n !== 0) return ''
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`
return `${(n / (1024 * 1024)).toFixed(1)} MB`
}
/**
* What was reported, rendered from the row the queue already resolved.
*
* Nothing here fetches: §5.6's fourth rule is that a staffer sees uploader, size
* and sniffed type without hunting, and the server attaches all of it in three
* batched reads. A `null` target is a target that has since been hard-deleted,
* and the row still shows — "somebody reported this and by the time we looked it
* was gone" is a fact worth seeing, and dropping it would hide the pattern of a
* member deleting their own content the moment it is reported.
*/
function TargetCell({ report }) {
const t = report.target
if (!t) {
return (
<span style={{ color: 'var(--muted)' }}>
{report.targetType.replace('team_forum_', '')} #{report.targetId} no longer exists
</span>
)
}
if (t.kind === 'upload') {
return (
<span>
<a href={t.url} target="_blank" rel="noopener noreferrer" className="link-accent">{t.filename}</a>
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.uploader || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
{t.deleted && ' · removed'}
</span>
</span>
)
}
if (t.kind === 'thread') {
return (
<span>
<strong>{t.title}</strong>
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.type} by {t.author || 'unknown'}
{t.status !== 'visible' && ` · ${t.status}`}
</span>
</span>
)
}
return (
<span>
{t.excerpt || <em className="dim">(no text)</em>}
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.author || 'unknown'} in {t.threadTitle}
{t.status !== 'visible' && ` · ${t.status}`}
</span>
</span>
)
}
export default function ContentReports() {
const [tab, setTab] = useState('open_work')
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const [handling, setHandling] = useState(null)
const [notice, setNotice] = useState(null)
const activeTab = STATUS_TABS.find((t) => t.key === tab) || STATUS_TABS[0]
const { loading, error, data } = useAsync(
() => api.admin.contentReports({ status: activeTab.param }),
[tab, tick],
)
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load reports." />
const rows = data?.reports || []
return (
<section>
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: 720 }}>
Reports raised by members about Team forum content. They come to site staff and are not visible
to a Team&rsquo;s own leaders a leader moderates their own forum, so a report about a leader
has to reach someone above them. Handling a report records a decision about the report; hiding
or removing the content itself is done from the forum, or as a sanction against the account.
{typeof data?.openCount === 'number' && ` ${data.openCount} open.`}
</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 16 }}>
{STATUS_TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className="pill"
style={tab === t.key ? activePill : undefined}
>
{t.label}
</button>
))}
</div>
{notice && (
<p
className="sans"
style={{ margin: '0 0 14px', color: notice.tone === 'error' ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}
>
{notice.text}
</p>
)}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Reported content</th>
<th className="adm-th">Reason</th>
<th className="adm-th">Detail</th>
<th className="adm-th">Reporter</th>
<th className="adm-th">Age</th>
<th className="adm-th">Status</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td className="adm-td" colSpan={7} style={muted}>
No reports match this filter.
</td>
</tr>
)}
{rows.map((r) => (
<tr key={r.id}>
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 340 }}>
<TargetCell report={r} />
</td>
<td className="adm-td">
<span className="badge">{REASON_LABEL[r.reason] || r.reason}</span>
</td>
<td className="adm-td dim" style={{ maxWidth: 260 }}>{r.detail || '—'}</td>
<td className="adm-td dim">{r.reporter}</td>
<td className="adm-td dim" title={dateTime(r.createdAt)}>{ago(r.createdAt)}</td>
<td className="adm-td">
<span className="badge" style={STATUS_STYLE[r.status]}>{STATUS_LABEL[r.status] || r.status}</span>
{r.handledBy && (
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>
{r.handledBy}
{r.handledNote ? `${r.handledNote}` : ''}
</span>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button
onClick={() => setHandling(r)}
className="btn btn-primary btn-sq"
style={{ padding: '5px 12px', fontSize: '0.82rem' }}
>
Handle
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{handling && (
<HandleModal
report={handling}
onCancel={() => setHandling(null)}
onDone={() => {
setHandling(null)
setNotice({ text: 'Report updated.', tone: 'ok' })
reload()
}}
onError={(message) => setNotice({ text: message, tone: 'error' })}
/>
)}
</section>
)
}
/**
* Record a decision about a report.
*
* The note is optional and worth writing: every transition is audited, dismissals
* included, and the note is what the next staffer to see a repeat report about the
* same content reads to find out why the last one was closed.
*/
function HandleModal({ report, onCancel, onDone, onError }) {
const [status, setStatus] = useState(report.status === 'open' ? 'reviewing' : 'actioned')
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
const submit = async () => {
setBusy(true)
try {
await api.admin.handleContentReport(report.id, { status, note: note || undefined })
onDone()
} catch (err) {
onError(err.message || 'Could not update that report.')
setBusy(false)
}
}
return (
<Modal
title={`Report #${report.id}`}
onClose={onCancel}
footer={(
<>
<button className="pill" onClick={onCancel}>Cancel</button>
<button className="btn btn-primary btn-sq" onClick={submit} disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</button>
</>
)}
>
<div style={{ display: 'grid', gap: 12 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
This records a decision about the report. It does not hide, delete or restore the content
do that from the forum itself, or against the account.
</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{['reviewing', 'actioned', 'dismissed', 'open'].map((value) => (
<button
key={value}
onClick={() => setStatus(value)}
className="pill"
style={status === value ? activePill : undefined}
>
{STATUS_LABEL[value]}
</button>
))}
</div>
<label>
<span className="field-label">Note (optional)</span>
<textarea
className="textarea"
placeholder="Why this was actioned or dismissed — the next staffer to see a repeat report reads this."
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={500}
rows={4}
style={{ width: '100%' }}
/>
</label>
</div>
</Modal>
)
}
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
const muted = { color: 'var(--muted)' }

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 persons 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

@@ -3,6 +3,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
import EmailDelivery from './EmailDelivery.jsx'
import TeamForumSettings from './TeamForumSettings.jsx'
// Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor).
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
@@ -143,6 +144,8 @@ export default function SettingsAdmin() {
</div>
</div>
<TeamForumSettings />
<EmailDelivery />
</section>
)

View File

@@ -0,0 +1,276 @@
import { useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
// The operator's Team-forum controls (TEAMS.md §5.5, plus phase 5's edit window),
// and the acknowledgement.
//
// Its own panel rather than two more rows in SettingsAdmin's FIELDS table, for the
// same reason EmailDelivery is its own: one of these settings has a server-side
// PRECONDITION and a confirmation flow, and a control with a precondition inside a
// generic list of key/value inputs is one whose behaviour nobody reading that list
// would predict.
//
// **The checkbox below is not the gate.** The server rejects `teams_forum_images =
// 'uploads'` with 400 unless the same request carries the acknowledgement version,
// and it does so whether or not this dialog was ever rendered. What is here is how
// the gate is PRESENTED — the wording an operator agrees to, and the recording of
// which version they agreed to.
// §5.5.5(a). Rendered beneath the selector at ALL times, in every mode: it
// explains what the setting is, which is a different job from the confirmation.
const HELP_TEXT = [
'Image uploads are disabled by default.',
'Enabling uploads allows users to store files on infrastructure that you control.',
'By enabling this feature, you acknowledge that you are responsible for:',
]
const HELP_BULLETS = [
'Moderating uploaded content',
'Managing storage and backups',
'Complying with applicable laws and regulations',
'Establishing policies for your community',
]
const HELP_TAIL = [
'Runic Gateway does not provide hosted storage or content moderation services. All uploaded content'
+ ' is stored on your own infrastructure.',
// Addition 1 — the reassuring counterpart, and the reason the attribution table
// in §5.5.4 exists at all.
'Uploads are attributed to the account that made them, and your staff can remove them at any time.',
// Addition 3 — the blast radius. "Users" is doing a lot of work: forum access is
// not the same as game membership, so this genuinely surprises.
'Anyone with access to a team forum can upload, including members granted access manually who have'
+ ' no linked game account.',
]
// §5.5.2's non-blocking advisory for `remote`. Not an acknowledgement — nothing is
// stored in that mode — but the operator's server is still doing the displaying.
const REMOTE_ADVISORY = 'Images hosted elsewhere are loaded by each visitors browser directly from the'
+ ' site hosting them. That site can see your visitors IP addresses, and you do not control whether'
+ ' the image changes or disappears.'
// §5.5.5(b). Shown only when changing the mode TO uploads.
const DIALOG_CHECKS = [
'I understand that uploaded files will be stored on infrastructure that I control.',
'I understand that I am responsible for community moderation policies on this installation.',
]
// Addition 2 — the expectation gap most likely to bite. An operator who turns
// uploads off because of a problem will assume the problem goes with it.
const DIALOG_TAIL = 'Disabling uploads later stops new files being accepted. It does not delete files'
+ ' already uploaded — remove those from the forum moderation tools.'
const MODES = [
{ value: 'disabled', label: 'Disabled — image URLs stay plain links' },
{ value: 'remote', label: 'Remote — images hosted elsewhere are shown' },
{ value: 'uploads', label: 'Uploads — members may upload images to this server' },
]
export default function TeamForumSettings() {
const { refresh: refreshSite } = useSite()
const [state, setState] = useState(null)
const [enabled, setEnabled] = useState(false)
const [mode, setMode] = useState('disabled')
const [editWindow, setEditWindow] = useState('15')
const [dialog, setDialog] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [saved, setSaved] = useState(false)
const load = async () => {
try {
const s = await api.admin.teamForumSettings()
setState(s)
setEnabled(s.enabled)
setMode(s.imageMode)
setEditWindow(String(s.editWindowMinutes ?? 15))
} catch {
setError('Could not load forum settings.')
}
}
useEffect(() => { load() }, [])
if (!state) return null
const stale = state.acknowledgement?.stale
async function persist(next, acknowledge) {
setBusy(true)
setError('')
try {
await api.admin.updateSettings({
teams_forums_enabled: next.enabled ? '1' : '0',
teams_forum_images: next.mode,
teams_forum_edit_window_minutes: String(next.editWindow),
...(acknowledge ? { acknowledge } : {}),
})
setSaved(true)
await load()
await refreshSite()
} catch (err) {
setError(err.message || 'Could not save forum settings.')
} finally {
setBusy(false)
}
}
// Moving TO uploads asks first; every other change saves directly. A stale
// acknowledgement also routes through the dialog, because re-acknowledging is
// the only thing that unfreezes these settings.
function save() {
setSaved(false)
if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) {
setDialog({ enabled, mode, editWindow })
return
}
if (stale) {
setDialog({ enabled, mode, editWindow })
return
}
persist({ enabled, mode, editWindow })
}
return (
<section style={{ marginTop: 34, maxWidth: 620 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Team forums</h2>
{stale && (
<p className="sans" style={{ fontSize: '0.82rem', color: '#e0b877', margin: '0 0 12px' }}>
The image-upload notice has changed since it was accepted
{state.acknowledgement.acknowledgedBy ? ` by ${state.acknowledgement.acknowledgedBy}` : ''}.
Uploads keep working, but no forum setting can be saved until it is acknowledged again.
</p>
)}
<label style={{ display: 'block', marginBottom: 14 }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => { setEnabled(e.target.checked); setSaved(false) }}
style={{ marginRight: 8 }}
/>
<span className="field-label" style={{ display: 'inline' }}>Enable Team forums</span>
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
Off by default. Switching forums off hides them completely every forum route answers not
found but deletes nothing: threads, posts, access grants and notification preferences all
survive and come back exactly as they were.
</span>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Images in forum posts</span>
<select value={mode} onChange={(e) => { setMode(e.target.value); setSaved(false) }} className="select">
{MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</label>
<label style={{ display: 'block', marginTop: 14 }}>
<span className="field-label">Post edit window (minutes)</span>
<input
type="number"
className="input"
min={0}
max={state.editWindowMax ?? 1440}
value={editWindow}
onChange={(e) => { setEditWindow(e.target.value); setSaved(false) }}
style={{ maxWidth: 120 }}
/>
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
How long an author may edit their own post after writing it. Staff are not bound by it and
may edit at any time. Set it to 0 to make posts permanent once written a bound of some
kind is what stops a post being rewritten out from under someone quoting it, or under a
moderator about to act on a report.
</span>
</label>
<div className="sans dim" style={{ marginTop: 8, fontSize: '0.76rem', lineHeight: 1.55 }}>
{HELP_TEXT.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
<ul style={{ margin: '0 0 6px 18px' }}>
{HELP_BULLETS.map((b) => <li key={b}>{b}</li>)}
</ul>
{HELP_TAIL.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
{mode !== 'disabled' && (
<p style={{ margin: '0 0 6px', color: '#e0b877' }}>{REMOTE_ADVISORY}</p>
)}
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 12, alignItems: 'center' }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save forum settings'}
</button>
{saved && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
{dialog && (
<UploadsDialog
version={state.acknowledgement.version}
onCancel={() => {
setDialog(null)
setMode(state.imageMode)
setEnabled(state.enabled)
setEditWindow(String(state.editWindowMinutes ?? 15))
}}
onConfirm={async (version) => {
setDialog(null)
await persist(dialog, version)
}}
/>
)}
</section>
)
}
/**
* Two checkboxes, one recorded acknowledgement.
*
* `Enable uploads` stays disabled until both are ticked, but the request carries a
* single version and the stored value is the text VERSION. Recording two booleans
* would add nothing — there is no reachable state where an operator consented to
* one clause and not the other and proceeded anyway — while the version answers
* the question that actually matters later: which text did they agree to?
*/
function UploadsDialog({ version, onCancel, onConfirm }) {
const [checks, setChecks] = useState(DIALOG_CHECKS.map(() => false))
const all = checks.every(Boolean)
return (
<div
role="dialog"
aria-modal="true"
aria-label="Enable image uploads"
style={{
marginTop: 14, padding: 14, border: '1px solid #e0b877', borderRadius: 6,
}}
>
<p className="sans" style={{ margin: '0 0 8px', fontWeight: 600 }}>
Image uploads are currently disabled.
</p>
<p className="sans" style={{ margin: '0 0 10px', fontSize: '0.88rem' }}>
Enabling uploads will allow users to store files on your server.
</p>
{DIALOG_CHECKS.map((text, i) => (
<label key={text} className="sans" style={{ display: 'block', fontSize: '0.85rem', marginBottom: 6 }}>
<input
type="checkbox"
checked={checks[i]}
onChange={(e) => setChecks((c) => c.map((v, j) => (j === i ? e.target.checked : v)))}
style={{ marginRight: 8 }}
/>
{text}
</label>
))}
<p className="sans dim" style={{ margin: '10px 0', fontSize: '0.8rem' }}>{DIALOG_TAIL}</p>
<div style={{ display: 'flex', gap: 10 }}>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={!all}
onClick={() => onConfirm(version)}
>
Enable uploads
</button>
</div>
</div>
)
}

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 Teams 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 channels 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 Teams 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 destinations 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 Teams 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

@@ -0,0 +1,490 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { dateTime } from '../../../lib/format.js'
import {
freshnessOf, statusOf, gateLabelFor, describeRequest, leadershipOf, GATED_NOTE,
} 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).
//
// Three panels, in the order an operator needs them:
//
// 1. **Sync state**, verbatim, including the last error. The screen's first job
// is to make "the shard has no Teams" and "core has not been able to ask for
// two hours" impossible to confuse — they render almost identically
// otherwise, and one is fine while the other is an outage.
// 2. **The review queue** — Teams auto-hidden because their name matched the
// impersonation list, each showing which term matched.
// 3. **The approval queue** — what moderators have asked to publish.
//
// Everything that decides what a row SAYS lives in lib/teamAdmin.js, which is
// plain JS and has tests; this file renders it.
// 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 ${TONE_BADGE[tone] || 'badge-draft'}`}>{children}</span>
}
// ── Sync state ─────────────────────────────────────────────────────────────
function SyncPanel({ sync, syncState, onResync, busy }) {
const freshness = freshnessOf(sync)
return (
<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 btn-ghost btn-sq"
onClick={onResync}
disabled={busy || !sync.configured}
>
{busy ? 'Resyncing…' : 'Resync now'}
</button>
</div>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.85rem' }}>{freshness.detail}</p>
{syncState && (
<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 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 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>
</>
)}
</dl>
)}
</section>
)
}
// ── The reserved-name review queue ─────────────────────────────────────────
function ReviewQueue({ rows, role, onAct, busy }) {
if (!rows.length) return null
return (
<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>
<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>
</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>
)
}
// ── The approval queue ─────────────────────────────────────────────────────
function RequestQueue({ rows, role, onDecide, busy }) {
if (!rows.length) return null
const canDecide = role === 'admin'
return (
<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>
<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>
)
}
// ── One Team ───────────────────────────────────────────────────────────────
function TeamRow({ team, role, onAct, busy, onLedger }) {
const status = statusOf(team)
return (
<tr>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{team.displayName}
{team.displayNameOverride && (
<div className="dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
shown instead of {team.name}
</div>
)}
</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 btn-primary btn-sq"
disabled={busy}
onClick={() => onAct(team.id, 'unhide')}
>
{gateLabelFor(role, 'Publish')}
</button>
)
: (
<button
type="button"
className="btn btn-ghost btn-sq"
disabled={busy}
onClick={() => onAct(team.id, 'hide')}
>
Hide
</button>
))}
<button
type="button"
className="btn btn-ghost btn-sq"
onClick={() => onLedger(team)}
style={{ marginLeft: 8 }}
>
Forum log
</button>
</td>
</tr>
)
}
/**
* One Team's forum moderation ledger (TEAMS.md §5.3).
*
* The route and the API method have existed since phase 4 and nothing rendered
* them, which made the ledger a table only a DB client could read. The column
* that earns the screen is `actorRole`: it records WHICH authority was exercised,
* so a leader's ordinary housekeeping stays distinguishable from a staff
* intervention after the fact.
*
* **This is deliberately not merged with the site's mod_actions/appeals pair.**
* That one is Discord-sanction-shaped and bot-owned; routing a guild leader
* locking a thread through it would make ordinary housekeeping an appealable
* sanction with a reversal path into the bot. Every STAFF-exercised action here
* additionally writes activity_log, so the site's accountability trail sees it —
* the two are cross-referenced, not merged.
*/
function ForumLedger({ team, onClose }) {
const [rows, setRows] = useState(null)
const [error, setError] = useState('')
useEffect(() => {
let active = true
api.admin.teamForumModeration(team.id)
// `{ entries }`, and the rows are the ledger table's own snake_case
// columns — this endpoint serves them unmapped, unlike the Team payloads
// above it. Reading them as they are, rather than accepting three possible
// shapes, is what makes a change to that endpoint fail here instead of
// rendering an empty table.
.then((res) => { if (active) setRows(res.entries) })
.catch((err) => { if (active) setError(err.message || 'Could not load the forum log.') })
return () => { active = false }
}, [team.id])
return (
<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="sans" style={{ ...BLURB, margin: 0 }}>Nothing has been moderated in this forum.</p>
)}
{rows && rows.length > 0 && (
<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>
</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>
)
}
// ── The screen ─────────────────────────────────────────────────────────────
export default function TeamsAdmin() {
const { user } = useAuth()
const role = user ? user.role : null
const [data, setData] = useState(null)
const [review, setReview] = useState([])
const [requests, setRequests] = useState([])
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [ledgerTeam, setLedgerTeam] = useState(null)
const load = useCallback(async () => {
setError('')
try {
const [teams, reviewQueue, requestQueue] = await Promise.all([
api.admin.listTeams(),
api.admin.teamReviewQueue(),
api.admin.teamRequests('pending'),
])
setData(teams)
setReview(reviewQueue.teams || [])
setRequests(requestQueue.requests || [])
} catch (err) {
setError(err.message || 'Could not load Teams.')
}
}, [])
useEffect(() => { load() }, [load])
async function run(fn, pendingMessage) {
setBusy(true)
setNotice('')
setError('')
try {
const result = await fn()
// The server decides whether an action applied or was filed, from the
// caller's live role. Saying so plainly is what stops a moderator thinking
// nothing happened.
if (result && result.pending) setNotice(pendingMessage)
await load()
} catch (err) {
setError(err.message || 'That did not work.')
} finally {
setBusy(false)
}
}
const act = (id, action) => run(
() => (action === 'hide' ? api.admin.hideTeam(id) : api.admin.unhideTeam(id)),
'Filed for approval. Nothing has changed publicly until an admin approves it.',
)
const decide = (id, status) => run(
() => api.admin.decideTeamRequest(id, status),
'',
)
const resync = () => run(async () => {
const result = await api.admin.resyncTeams()
// A refusal is the normal, designed outcome when the provider cannot answer,
// so it is reported as a result rather than thrown as an error.
if (!result.ok) setError(`Resync refused: ${result.reason}. Nothing was changed.`)
else if (result.quarantined) {
setNotice('The provider answered with an empty list. It is being held for confirmation, not applied.')
}
return null
}, '')
if (error && !data) return <ErrorState message={error} />
if (!data) return <Loading />
return (
<div>
{/* 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 && (
<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" style={PANEL}>
<h2 className="display" style={HEADING}>All Teams</h2>
{!data.teams.length && (
<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 && (
<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>
)
}
export { leadershipOf }

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

@@ -0,0 +1,369 @@
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
// 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
// notification settings screen" — there was no such screen on the web. The stream
// catalog and the per-stream subscriptions have been built and shipped since M7,
// with the Android app as their only consumer; a browser could not see them at
// all. That is tolerable for push, which needs the app anyway. It is not tolerable
// for email, whose whole reason for existing (§6.4) is the web-only user who runs
// neither the app nor Discord — so the sink and the screen to configure it had to
// arrive together.
//
// Three blocks, in the order a user actually reasons about them: what kinds of
// 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' },
{ value: 'immediate', label: 'Every post' },
]
// Streams whose scoping lives in this page's second block rather than in the
// first. Shown as a group so a user does not toggle `team.forum.post` off site-
// wide when what they meant was "not this one guild".
const isTeamStream = (id) => String(id).startsWith('team.')
function Section({ title, hint, children }) {
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 26, marginTop: 26 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: 'var(--head)' }}>{title}</h2>
{hint && <p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.86rem' }}>{hint}</p>}
{children}
</section>
)
}
function Note({ msg, error }) {
if (!msg && !error) return null
return (
<p className="sans" style={{ margin: '10px 0 0', color: error ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}>
{error || msg}
</p>
)
}
// ── 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 Channels({ channels, items, onSave, busy, msg, error }) {
const [edits, setEdits] = useState({})
useEffect(() => setEdits({}), [items])
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 = items.filter((i) => isTeamStream(i.id))
const rest = items.filter((i) => !isTeamStream(i.id))
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. On the site means an item in your notification inbox; push wakes the app, which then fetches the content."
>
<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 || changed.length === 0} onClick={save}>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
<Note msg={msg} error={error} />
</Section>
)
}
// ── Which Teams, and whether by email ──────────────────────────────────────
function Teams({ teams, onSave, busy, msg, error }) {
const [rows, setRows] = useState(teams)
useEffect(() => { setRows(teams) }, [teams])
const patch = (teamId, change) =>
setRows((rs) => rs.map((r) => (r.teamId === teamId ? { ...r, ...change } : r)))
if (rows.length === 0) {
return (
<Section title="Teams">
<p className="sans dim" style={{ fontSize: '0.9rem', margin: 0 }}>
You are not in a team, and nobody has given you access to a team forum. There is nothing to
configure here yet.
</p>
</Section>
)
}
return (
<Section
title="Teams"
hint="Muting a team silences all four team notifications for it, without changing anything for your other teams. Email is off until you turn it on."
>
<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' }}>Team</th>
<th style={{ padding: '8px 10px' }}>Notifications</th>
<th style={{ padding: '8px 10px' }}>Email</th>
</tr>
</thead>
<tbody>
{rows.map((t) => (
<tr key={t.teamId} style={{ borderTop: '1px solid var(--line-soft)' }}>
<td className="sans" style={{ padding: '10px', color: 'var(--ink)' }}>
{t.name}
{/* An archived Team is still listed when a preference exists for
it, so a mute does not silently vanish when a guild disbands
and reappear if it re-forms under the same name. */}
{t.archived && <span className="dim" style={{ fontSize: '0.78rem' }}> · archived</span>}
</td>
<td style={{ padding: '10px' }}>
<label className="sans" style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: '0.88rem' }}>
<input type="checkbox" checked={!t.muted} onChange={() => patch(t.teamId, { muted: !t.muted })} />
<span className="dim">{t.muted ? 'Muted' : 'On'}</span>
</label>
</td>
<td style={{ padding: '10px' }}>
<select
className="input"
value={t.emailMode}
onChange={(e) => patch(t.teamId, { emailMode: e.target.value })}
style={{ fontSize: '0.88rem' }}
>
{EMAIL_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ marginTop: 18 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave(rows)}>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
<Note msg={msg} error={error} />
</Section>
)
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function PlayerNotifications() {
const { user } = useAuth()
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [channels, setChannels] = useState([])
const [items, setItems] = useState([])
const [teams, setTeams] = useState([])
const [saving, setSaving] = useState({ channels: false, teams: false })
const [notes, setNotes] = useState({ channels: '', teams: '', channelsError: '', teamsError: '' })
const load = useCallback(async () => {
setLoading(true)
try {
// 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(),
])
setChannels(prefs.channels || [])
setItems(prefs.items || [])
setTeams(teamPrefs.teams || [])
setError('')
} catch {
setError('Could not load your notification settings.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const saveChannels = useCallback(async (prefs) => {
if (prefs.length === 0) return
setSaving((s) => ({ ...s, channels: true }))
setNotes((n) => ({ ...n, channels: '', channelsError: '' }))
try {
// 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, channelsError: 'Could not save that.' }))
} finally {
setSaving((s) => ({ ...s, channels: false }))
}
}, [])
const saveTeams = useCallback(async (rows) => {
setSaving((s) => ({ ...s, teams: true }))
setNotes((n) => ({ ...n, teams: '', teamsError: '' }))
try {
// The whole set, every time, and the array is sent even when empty — the
// endpoint requires the field (docs/android/PLAN.md §11).
const { teams: stored } = await api.setTeamNotificationPrefs(
rows.map((t) => ({ teamId: t.teamId, muted: t.muted, emailMode: t.emailMode })),
)
setTeams(stored || [])
setNotes((n) => ({ ...n, teams: 'Saved.' }))
} catch {
setNotes((n) => ({ ...n, teamsError: 'Could not save that.' }))
} finally {
setSaving((s) => ({ ...s, teams: false }))
}
}, [])
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
return (
<div>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
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>
<Channels
channels={channels}
items={items}
onSave={saveChannels}
busy={saving.channels}
msg={notes.channels}
error={notes.channelsError}
/>
<Teams
teams={teams}
onSave={saveTeams}
busy={saving.teams}
msg={notes.teams}
error={notes.teamsError}
/>
</div>
)
}

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'
@@ -35,6 +36,10 @@ 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
@@ -47,6 +52,8 @@ const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-1
// with `order: 0`.
export const NAV = [
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ 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 },
]
@@ -56,6 +63,8 @@ export const NAV = [
const TITLES = {
'/account': 'Account',
'/account/appeals': 'Appeals',
'/account/notifications': 'Notifications',
'/account/notifications/settings': 'Notification settings',
}
function moduleTitle(baseNav, pathname) {
@@ -183,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

@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { api } from '../../api/client.js'
// The landing page for the unsubscribe link in a Team notification email
// (TEAMS.md §6.4).
//
// **Public, and it must be**: the person reading it is in their mail client, not
// signed in, and an unsubscribe that first demands a login is one most people do
// not complete. The token in the path is what stands in for the session.
//
// **The page POSTs; the link the user clicked was a GET.** A GET must not mutate —
// mail clients and security scanners follow links in messages, and one that did
// would silently mute Teams nobody asked to leave. So the link lands here, this
// runs one POST, and the API route that shares the path answers GET with a
// redirect to exactly this page.
//
// **It says the same thing whatever the token was.** A page that distinguished a
// valid token from a forged one would be an oracle for which (user, Team) pairs
// exist, on a surface with no session behind it. The server always answers 200 and
// this always says the same sentence.
export default function Unsubscribe() {
const { token } = useParams()
const [state, setState] = useState('working')
// React 18 StrictMode mounts an effect twice in development. The POST is
// idempotent (it sets a boolean), so a second call is harmless — but it is
// still a second request for no reason, and the guard keeps the network panel
// honest for anyone debugging this page.
const fired = useRef(false)
useEffect(() => {
if (fired.current) return
fired.current = true
api.unsubscribeTeam(token)
.then(() => setState('done'))
// A network failure is the ONE case worth distinguishing, because it is the
// one where trying again helps. A rejected token is not: the server does not
// tell us, deliberately.
.catch(() => setState('failed'))
}, [token])
return (
<PublicLayout section="website" shell="narrow">
<PageHeader eyebrow="Notifications" title="Unsubscribe" />
{state === 'working' && <p className="sans dim">One moment</p>}
{state === 'done' && (
<>
<p className="sans" style={{ color: 'var(--ink)' }}>
You will not receive further notification emails about this team.
</p>
<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/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/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

@@ -298,6 +298,18 @@ button[disabled] {
}
/* ===== Rich prose (wiki / newsletter body) ===== */
.forum-embed {
/* The image a Team-forum post's URL renders as, in `remote`/`uploads` mode.
Emitted by the server (utils/forumHtml.js), never by an author — which is
what makes the operator's image policy enforceable. Block, so it sits
beneath its link rather than beside it; capped, because a remote image is
whatever size its host decided and one post must not blow out the column. */
display: block;
margin-top: 8px;
max-width: 100%;
height: auto;
border-radius: var(--radius-input);
}
.prose {
color: var(--text);
font-size: 1.06rem;

View File

@@ -185,3 +185,70 @@ test('a module id is URL-encoded on the way into the path', async () => {
await api.admin.disableModule('a b/c')
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable')
})
// ── Team forum, phase 5 ("5b") ──────────────────────────────────────────
//
// The URL shapes matter more here than they look. Replies hang off a THREAD;
// edits and post moderation hang off a POST; and the report route hangs off the
// forum rather than off either, because a report can name a thread, a post or an
// upload and is not moderation of any of them.
test('a reply hangs off its thread and an edit hangs off its post', async () => {
willReply({ body: { ok: true } })
await api.teamForumReply('ossuary', 5, { body: 'hi' })
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/posts')
assert.equal(calls[0].opts.method, 'POST')
calls = []
willReply({ body: { ok: true } })
await api.teamForumEditPost('ossuary', 80, { body: 'fixed' })
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80')
// PATCH, not POST: an edit replaces part of a post that already exists, and the
// server's route is mounted on the verb.
assert.equal(calls[0].opts.method, 'PATCH')
})
test('post moderation is a different route from thread moderation', async () => {
// Not the same route with a target kind, because the two answer to different
// rules — `pin` and `lock` mean nothing to a post at all.
willReply({ body: { ok: true } })
await api.teamForumModeratePost('ossuary', 80, { action: 'hide' })
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80/moderate')
calls = []
willReply({ body: { ok: true } })
await api.teamForumModerate('ossuary', 5, { action: 'pin' })
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/moderate')
})
test('a report goes to the forum, and its queue is under admin moderation', async () => {
willReply({ body: { ok: true } })
await api.teamForumReport('ossuary', { targetType: 'team_forum_post', targetId: 80, reason: 'abuse' })
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/report')
assert.deepEqual(JSON.parse(calls[0].opts.body), {
targetType: 'team_forum_post', targetId: 80, reason: 'abuse',
})
// Under /admin/moderation and NOT under /admin/teams: a staffer working a queue
// should have one place to work, and there is deliberately no leader-facing
// counterpart to this call anywhere in the client (TEAMS.md §5.6).
calls = []
willReply({ body: { reports: [] } })
await api.admin.contentReports({ status: 'open' })
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports?status=open')
})
test('the report queue defaults to the open work rather than to everything', async () => {
willReply({ body: { reports: [] } })
await api.admin.contentReports()
// No query string at all — the server's default is open + reviewing, and a
// client that pinned `status=all` here would put the archive in front of a
// staffer every time they opened the screen.
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports')
})
test('a Team slug is URL-encoded on every forum path', async () => {
willReply({ body: { ok: true } })
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
})

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

@@ -160,6 +160,9 @@ test('the registry object handed to modules exposes the whole surface', () => {
// window.__rg.registry is the ONLY way a module reaches any of this, so a
// member missing from the object is a member that does not exist.
assert.deepEqual(Object.keys(registry).sort(), [
// `declareModuleSlot` is the INVERTED direction added in 1.6.0: the module
// declares a place on its own page and core fills it (TEAMS.md Part 3).
'declareModuleSlot',
'featureProviderFor',
'navFor',
'registerExtension',

View File

@@ -4,6 +4,10 @@ import assert from 'node:assert/strict'
import {
registry,
declareSlot,
declareModuleSlot,
offerCoreFill,
CORE_CONTRIBUTIONS,
applyCoreFills,
registerExtension,
extensionFor,
registeredIds,
@@ -92,3 +96,114 @@ test('declareSlot and extensionFor are not on the module-facing registry', () =>
assert.equal(registry.extensionFor, undefined)
assert.equal(typeof registry.registerExtension, 'function')
})
// ── The INVERTED direction: the module declares, core fills ────────────────
//
// Added in 1.6.0 for Teams (TEAMS.md Part 3). Teams are a core primitive with no
// core surface — core owns the tables and the activity feed, the module owns the
// page and the word "guild" — so the content flows the other way for the first
// time. The rules below are the ones that direction gets wrong.
const Feed = () => null
test('a module-declared slot must be namespaced under the declaring module', () => {
// Enforced rather than conventional: this is the only thing keeping two
// modules from claiming the same slot name.
assert.throws(() => declareModuleSlot('uo', 'guild.detail'), /must be namespaced/)
assert.doesNotThrow(() => declareModuleSlot('uo', 'uo.guild.detail'))
})
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('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())
})
test('a module that fills its own slot first keeps it', () => {
const Own = () => null
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
registerExtension('uo', 'uo.guild.detail', Own)
offerCoreFill('team.activity', Feed)
applyCoreFills()
assert.equal(extensionFor('uo.guild.detail'), Own, 'first fill wins, as everywhere else')
})
test('a module-declared slot cannot be declared twice', () => {
declareModuleSlot('uo', 'uo.guild.detail')
assert.throws(() => declareModuleSlot('uo', 'uo.guild.detail'), /already declared/)
})
test('applying the fills twice does not re-fill or throw', () => {
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 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', () => {
offerCoreFill('team.activity', Feed)
_reset()
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,59 @@
import { test } 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 { shellClass, SHELL_WIDTHS } from '../src/lib/pageShell.js'
// `PublicLayout`'s `shell` prop (MODULE_API.md §3.4, MODULE_API_VERSION 1.5.0).
// The component itself is .jsx and unreachable from this runner — there is no DOM
// here — so the rule lives in lib/pageShell.js and is asserted here, and the
// rendering is proved in a browser (MODULE_API.md §7.7), which is where the
// defect that produced this prop was found in the first place.
const HERE = path.dirname(fileURLToPath(import.meta.url))
test('no shell means no wrapper — the behaviour every page had before 1.5.0', () => {
// null, not an empty string: PublicLayout branches on it to render `children`
// bare, and '' would render a <div class=""> that changes core's nine pages.
assert.equal(shellClass(undefined), null)
assert.equal(shellClass(null), null)
assert.equal(shellClass(''), null)
assert.equal(shellClass(false), null)
})
test('each documented width maps to its theme.css class, plus page-body', () => {
assert.equal(shellClass('narrow'), 'shell-narrow page-body')
assert.equal(shellClass('mid'), 'shell-mid page-body')
assert.equal(shellClass('wide'), 'shell-wide page-body')
})
test('page-body is always present — it is what pushes the footer down', () => {
// `.page` is a flex column and `.page-body { flex: 1 }` is the only thing
// filling it. A width class on its own centres the content and still lets the
// footer ride up under it, which is half the reported defect and the half that
// is easy to lose in a refactor.
for (const w of SHELL_WIDTHS) {
assert.match(shellClass(w), /\bpage-body\b/)
}
})
test('an unknown width still renders a wrapper, at the narrow default', () => {
// The value can arrive from a module built against a different version of this
// list, so the failure mode has to be "wrong width" and never "no wrapper".
assert.equal(shellClass('enormous'), 'shell-narrow page-body')
assert.equal(shellClass(true), 'shell-narrow page-body')
assert.equal(shellClass('NARROW'), 'shell-narrow page-body')
})
test('every width this module offers is a class theme.css actually defines', () => {
// The contract now names these widths to module authors, so a rename in
// theme.css has to fail here rather than silently in a module's page.
const css = fs.readFileSync(path.join(HERE, '../src/styles/theme.css'), 'utf8')
for (const w of SHELL_WIDTHS) {
const cls = shellClass(w).split(' ')[0]
assert.ok(css.includes(`.${cls} {`), `theme.css defines .${cls}`)
}
assert.ok(css.includes('.page-body {'), 'theme.css defines .page-body')
})

View File

@@ -0,0 +1,78 @@
// What core's Team activity feed says (client/src/lib/teamActivity.js).
//
// The test that earns this file: a projection nobody can tell is stale, and a
// feed nobody can tell is filtered, both look like complete information. Every
// case below is about saying which one the reader is looking at.
//
// Note the wording assertions avoid core's own noun. The feed renders inside a
// page a MODULE titled — Guilds today, Clans next — so "this Team" would be
// core's vocabulary leaking onto a surface that deliberately does not use it.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { activityScopeNote, freshnessNote, groupByDay, relativeTime } from '../src/lib/teamActivity.js'
const NOW = new Date('2026-08-17T12:00:00Z').getTime()
const ago = (ms) => new Date(NOW - ms).toISOString()
test('a deployment with no provider is not stale, it is uninvolved', () => {
assert.equal(freshnessNote({ configured: false }, NOW), null)
})
test('never synced is a warning, and never reads as a confirmed empty shard', () => {
const note = freshnessNote({ configured: true, lastSyncAt: null }, NOW)
assert.equal(note.tone, 'warn')
assert.match(note.text, /Not yet confirmed/)
})
test('a stale projection says how old it is and that the game may have moved on', () => {
const note = freshnessNote({ configured: true, lastSyncAt: ago(14 * 60_000), stale: true }, NOW)
assert.equal(note.tone, 'warn')
assert.equal(note.text, 'Last confirmed 14 minutes ago — the game may have moved on.')
})
test('a current projection is stated quietly', () => {
const note = freshnessNote({ configured: true, lastSyncAt: ago(90_000), stale: false }, NOW)
assert.equal(note.tone, 'idle')
assert.equal(note.text, 'Last confirmed 1 minute ago.')
})
test('relative time singularises and steps through the units', () => {
assert.equal(relativeTime(ago(5_000), NOW), 'just now')
assert.equal(relativeTime(ago(60_000), NOW), '1 minute ago')
assert.equal(relativeTime(ago(3 * 3_600_000), NOW), '3 hours ago')
assert.equal(relativeTime(ago(2 * 86_400_000), NOW), '2 days ago')
assert.equal(relativeTime(null, NOW), null)
assert.equal(relativeTime('not a date', NOW), null)
})
test('items group into days, newest day first, order kept within a day', () => {
const days = groupByDay([
{ id: 3, occurredAt: '2026-08-17T09:00:00' },
{ id: 2, occurredAt: '2026-08-17T08:00:00' },
{ id: 1, occurredAt: '2026-08-16T22:00:00' },
], 'en-US')
assert.equal(days.length, 2)
assert.deepEqual(days[0].items.map((i) => i.id), [3, 2])
assert.deepEqual(days[1].items.map((i) => i.id), [1])
})
test('an unparseable timestamp is skipped rather than making a day called Invalid Date', () => {
assert.deepEqual(groupByDay([{ id: 1, occurredAt: 'nonsense' }], 'en-US'), [])
})
test('a caller who saw everything is told nothing', () => {
assert.equal(activityScopeNote({ scope: 'members' }, true), null)
})
test('a filtered feed says so, and invites an anonymous caller to sign in', () => {
assert.match(activityScopeNote({ scope: 'public' }, false), /Sign in/)
assert.match(activityScopeNote({ scope: 'public' }, true), /members only/)
})
test('the wording never says "Team" — that is core\'s noun, not the page\'s', () => {
for (const signedIn of [true, false]) {
assert.doesNotMatch(activityScopeNote({ scope: 'public' }, signedIn), /Team/)
}
assert.doesNotMatch(freshnessNote({ configured: true, lastSyncAt: null }, NOW).text, /Team/)
})

View File

@@ -0,0 +1,140 @@
// What Admin → Teams says (client/src/lib/teamAdmin.js).
//
// The test that earns this file: "no Teams" and "core has not been able to ask"
// must never read the same. They produce almost identical screens — an empty
// table — and one is fine while the other is an outage an operator needs to act
// on. Everything else here is in service of that distinction.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
freshnessOf, ago, statusOf, gateLabelFor, describeRequest, parsePayload, leadershipOf, TONE,
} from '../src/lib/teamAdmin.js'
const minutesAgo = (n) => new Date(Date.now() - n * 60_000).toISOString()
// ── Freshness: four states that must not be confused ───────────────────────
test('no provider is idle, not a fault', () => {
const f = freshnessOf({ configured: false })
assert.equal(f.tone, TONE.idle)
assert.match(f.label, /No Team provider/)
})
test('never synced is reported as never synced, not as an empty shard', () => {
// The failure this prevents: an empty projection core has never confirmed,
// rendered as though the game genuinely has no Teams.
const f = freshnessOf({ configured: true, lastSyncAt: null })
assert.equal(f.tone, TONE.bad)
assert.equal(f.label, 'Never synced')
assert.match(f.detail, /not a confirmed empty shard/)
})
test('stale says how old it is', () => {
const f = freshnessOf({ configured: true, stale: true, lastSyncAt: minutesAgo(14) })
assert.equal(f.tone, TONE.warn)
assert.equal(f.label, 'Stale')
assert.match(f.detail, /14 minutes ago/)
})
test('current says so plainly', () => {
const f = freshnessOf({ configured: true, stale: false, lastSyncAt: minutesAgo(2) })
assert.equal(f.tone, TONE.ok)
assert.equal(f.label, 'Current')
})
test('ago is deliberately coarse', () => {
// Second-level precision would be false comfort about a projection whose poll
// interval is fifteen minutes.
assert.equal(ago(null), 'never')
assert.equal(ago(new Date().toISOString()), 'just now')
assert.equal(ago(minutesAgo(14)), '14 minutes ago')
assert.equal(ago(minutesAgo(60)), '1 hour ago')
assert.equal(ago(minutesAgo(180)), '3 hours ago')
assert.equal(ago(minutesAgo(60 * 72)), '3 days ago')
})
// ── Status ─────────────────────────────────────────────────────────────────
test('the four Team statuses are distinguishable', () => {
assert.equal(statusOf({ status: 'active' }).label, 'Public')
assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'reserved_name' }).label, 'Hidden — reserved name')
assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'staff' }).label, 'Hidden by staff')
assert.equal(statusOf({ status: 'archived', archivedReason: 'disbanded' }).label, 'Archived')
assert.equal(statusOf({ status: 'archived', archivedReason: 'renamed' }).label, 'Renamed')
})
test('a reserved-name hide is the loudest tone', () => {
assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'reserved_name' }).tone, TONE.bad)
assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'staff' }).tone, TONE.warn)
})
// ── The gate, described honestly ───────────────────────────────────────────
test('the button says what will actually happen for this role', () => {
// The server decides from the live role; this only describes it. Saying
// "Publish" to a moderator would make the pending result a surprise.
assert.equal(gateLabelFor('admin', 'Publish'), 'Publish')
assert.equal(gateLabelFor('moderator', 'Publish'), 'Request publish')
})
// ── The approval queue ─────────────────────────────────────────────────────
test('a request describes itself, including the name being published', () => {
assert.equal(
describeRequest({ action: 'unhide', requested_username: 'mod1', team_name: 'Admin' }),
'mod1 asks to publish “Admin”',
)
assert.equal(
describeRequest({
action: 'display_name_override', requested_username: 'mod1', team_name: 'Admin',
payload: { displayName: 'The Old Guard' },
}),
'mod1 asks to display “Admin” as “The Old Guard”',
)
assert.equal(
describeRequest({ action: 'clear_display_name_override', requested_username: 'mod1', team_name: 'X' }),
'mod1 asks to clear the display name on “X”',
)
})
test('a deleted requester still reads as a sentence', () => {
// §2.10 sets requested_by to NULL and keeps the username snapshot; when even
// that is gone the queue must not render "null asks to publish".
assert.match(describeRequest({ action: 'unhide', team_name: 'Admin' }), /^a deleted user asks/)
})
test('a payload arrives parsed or as a string, and both work', () => {
assert.deepEqual(parsePayload({ displayName: 'X' }), { displayName: 'X' })
assert.deepEqual(parsePayload('{"displayName":"X"}'), { displayName: 'X' })
assert.deepEqual(parsePayload(null), {})
assert.deepEqual(parsePayload('not json'), {})
})
// ── Leadership shows the decision, not just the answer ─────────────────────
test('an unoverridden member reads straight from the projection', () => {
const l = leadershipOf({ isLeader: true, isLeaderSynced: true })
assert.equal(l.isLeader, true)
assert.equal(l.overridden, false)
assert.equal(l.note, null)
})
test('an override is shown AS an override, with what the game says', () => {
// Staff looking at a roster need to see that a decision was made, not a fact
// that looks like the game's.
const l = leadershipOf({
isLeaderSynced: true,
leaderOverride: { effect: 'deny', by: 'mod1', reason: 'harassment' },
})
assert.equal(l.isLeader, false)
assert.equal(l.overridden, true)
assert.match(l.note, /Denied by mod1 — harassment/)
assert.match(l.note, /the game says leader/)
})
test('a grant override says the game disagrees', () => {
const l = leadershipOf({ isLeaderSynced: false, leaderOverride: { effect: 'grant', by: 'root' } })
assert.equal(l.isLeader, true)
assert.match(l.note, /the game says not a leader/)
})

View File

@@ -0,0 +1,120 @@
// What the Team forum's client half decides for itself (client/src/lib/teamForum.js).
//
// The point of this file is how LITTLE that is. Who may post, who may moderate,
// whether an image renders and whether a post may be edited are all server
// answers the panel reads. What is tested here is the three places the client
// turns those answers into what a reader sees — and one property that is easy to
// break by accident: the edit offer can only ever be withdrawn here, never
// granted.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../src/lib/teamForum.js'
const NOW = new Date('2026-08-18T12:00:00Z').getTime()
const inMinutes = (n) => new Date(NOW + n * 60_000).toISOString()
// ── the edit offer ─────────────────────────────────────────────────────────
test('the client can withdraw an edit offer and can never create one', () => {
// The server said no. Nothing about a deadline changes that — a future
// `editableUntil` on a post the server refused must not become an offer, or
// the client would be granting a permission.
assert.equal(editOfferOpen({ canEdit: false, editableUntil: inMinutes(10) }, NOW), false)
assert.equal(editOfferOpen({ canEdit: false, editableUntil: null }, NOW), false)
})
test('a deadline that has passed while the page sat open withdraws the offer', () => {
assert.equal(editOfferOpen({ canEdit: true, editableUntil: inMinutes(5) }, NOW), true)
// Same post, fifteen minutes of the reader staring at it later.
assert.equal(editOfferOpen({ canEdit: true, editableUntil: inMinutes(5) }, NOW + 15 * 60_000), false)
})
test('no deadline means no deadline, not no permission', () => {
// Staff are not time-bounded, and `editableUntil: null` is how the server says
// so. Reading it as "expired" would take the edit control away from exactly the
// people whose authority does not expire.
assert.equal(editOfferOpen({ canEdit: true, editableUntil: null }, NOW), true)
})
test('an unparseable deadline closes the offer rather than opening it', () => {
assert.equal(editOfferOpen({ canEdit: true, editableUntil: 'not a date' }, NOW), false)
assert.equal(editOfferOpen(null, NOW), false)
assert.equal(editOfferOpen(undefined, NOW), false)
})
// ── round-tripping a body back into the composer ───────────────────────────
test('the image core generated is stripped, and the URL that made it survives', () => {
// §5.5.3: the author wrote a URL, core emitted the <img> at read time. Handing
// the <img> back would let an author edit markup they never wrote — and the
// URL is what re-renders it, so nothing is lost by removing it.
const rendered = '<p><a href="https://x/a.png" rel="noopener noreferrer">https://x/a.png</a>'
+ '<img src="https://x/a.png" class="forum-embed" referrerpolicy="no-referrer" /></p>'
const text = stripToText(rendered)
assert.ok(!text.includes('<img'))
assert.ok(text.includes('https://x/a.png'))
})
test('paragraphs become blank lines and breaks become newlines', () => {
assert.equal(stripToText('<p>One</p><p>Two</p>'), 'One\n\nTwo')
assert.equal(stripToText('<p>One<br>Two</p>'), 'One\nTwo')
// A paragraph carrying attributes is still a paragraph.
assert.equal(stripToText('<p>One</p>\n<p class="x">Two</p>'), 'One\n\nTwo')
})
test('entities decode to what the author typed, and only once', () => {
assert.equal(stripToText('<p>Tom &amp; Jerry</p>'), 'Tom & Jerry')
assert.equal(stripToText('<p>&quot;quoted&quot;</p>'), '"quoted"')
// The one that bites: an author who typed a literal "<script>" has it stored
// escaped. Decoding entities BEFORE stripping tags would turn it into a real
// tag that the strip pass then deletes — silently losing text the author wrote
// and which was never dangerous.
assert.equal(stripToText('<p>&lt;script&gt;</p>'), '<script>')
// And decoding &amp; first would turn "&amp;lt;" into "<" in two steps.
assert.equal(stripToText('<p>&amp;lt;</p>'), '&lt;')
})
test('an empty or absent body is an empty string, never a crash', () => {
assert.equal(stripToText(''), '')
assert.equal(stripToText(null), '')
assert.equal(stripToText(undefined), '')
assert.equal(stripToText('<p></p>'), '')
})
// ── the thread list line ───────────────────────────────────────────────────
test('a discussion counts REPLIES, which is one fewer than its posts', () => {
// postCount includes the opening post. Showing it raw would tell a reader a
// brand-new thread already has one reply.
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 1 }), 'ada')
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 2 }), 'ada · 1 reply')
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 4 }), 'ada · 3 replies')
})
test('an announcement says so and never counts replies, because it takes none', () => {
const line = threadSummary({ type: 'announcement', author: 'aldric', postCount: 1 })
assert.equal(line, 'Announcement · aldric')
assert.ok(!line.includes('repl'))
})
test('hidden is said out loud — it is only shown to whoever can unhide it', () => {
assert.equal(
threadSummary({ type: 'discussion', author: 'ada', postCount: 1, status: 'hidden' }),
'ada · hidden',
)
})
// ── the report control ─────────────────────────────────────────────────────
test('every reason the server accepts is offered, and no others', () => {
// The server validates against its own list; a client offering a reason the
// server rejects produces a 400 the reporter cannot act on, and one MISSING a
// reason quietly funnels those reports into "other".
assert.deepEqual(
REPORT_REASONS.map(([value]) => value).sort(),
['abuse', 'illegal', 'impersonation', 'other', 'sexual', 'spam'],
)
assert.ok(REPORT_REASONS.every(([, label]) => typeof label === 'string' && label.length > 0))
})

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,87 @@
import { test, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { api } from '../src/api/client.js'
// The client half of Team notifications (docs/website/TEAMS.md Part 6, phase 6).
//
// There is no DOM in this runner, so what is asserted here is the WIRE — which is
// where this feature's client-side mistakes actually live. Two of them have
// already been made once in this repo and are recorded rather than re-derived:
//
// 1. **A PUT-the-whole-set body must always carry its array**, empty included.
// `docs/android/PLAN.md` §11: a DTO field with a default is dropped by
// kotlinx when it equals that default, so "clear the last entry" arrives as a
// body with no array at all and 400s. The web client has no such
// serialisation quirk, but it shares the endpoint's contract, and a test that
// pins the shape here is what keeps the two clients honest about the same
// rule.
// 2. **The unsubscribe call is a POST**, not the GET the link in the mail was.
// A GET that mutated would be triggered by every mail-client link scanner.
let calls
const realFetch = global.fetch
function reply(body = {}) {
return {
ok: true,
status: 200,
statusText: 'OK',
text: async () => JSON.stringify(body),
}
}
beforeEach(() => {
calls = []
global.fetch = async (url, opts = {}) => {
calls.push({ url, opts })
return reply({ teams: [], streams: [], ok: true })
}
})
afterEach(() => { global.fetch = realFetch })
const body = (i = 0) => JSON.parse(calls[i].opts.body)
test('the per-Team preference endpoints sit under /auth/me, not /player', async () => {
await api.teamNotificationPrefs()
// Role-agnostic self-service, the same rule that put the Team forum under
// /player rather than behind a staff gate: staff are a superset of players and
// manage their own notifications like anyone else.
assert.match(calls[0].url, /\/auth\/me\/notifications\/teams$/)
assert.equal(calls[0].opts.method ?? 'GET', 'GET')
})
test('saving preferences PUTs the whole set under a `teams` key', async () => {
await api.setTeamNotificationPrefs([{ teamId: 3, muted: true, emailMode: 'digest' }])
assert.equal(calls[0].opts.method, 'PUT')
assert.deepEqual(body(), { teams: [{ teamId: 3, muted: true, emailMode: 'digest' }] })
})
test('clearing every preference still sends the array, never an absent key', async () => {
await api.setTeamNotificationPrefs([])
assert.deepEqual(body(), { teams: [] })
assert.equal('teams' in body(), true)
})
test('the same rule holds for the stream subscriptions beside them', async () => {
await api.setNotificationSubscriptions([])
assert.deepEqual(body(), { streams: [] })
})
test('unsubscribe is a POST to the public tier, with the token encoded into the path', async () => {
await api.unsubscribeTeam('1.7.3.abcDEF')
assert.equal(calls[0].opts.method, 'POST')
assert.match(calls[0].url, /\/public\/teams\/unsubscribe\/1\.7\.3\.abcDEF$/)
})
test('a token with url-unsafe characters is encoded rather than pasted in', async () => {
await api.unsubscribeTeam('a/b c')
assert.match(calls[0].url, /unsubscribe\/a%2Fb%20c$/)
})
test('the streams catalog and subscriptions are separate reads', async () => {
await api.notificationStreams()
await api.notificationSubscriptions()
assert.match(calls[0].url, /\/notifications\/streams$/)
assert.match(calls[1].url, /\/notifications\/subscriptions$/)
})

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

File diff suppressed because it is too large Load Diff

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": [

File diff suppressed because it is too large Load Diff

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"
@@ -145,6 +213,14 @@
"method": "GET",
"path": "/api/v1/admin/moderation/recent"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/reports"
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/reports/:id/handle"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/search"
@@ -293,6 +369,98 @@
"method": "PUT",
"path": "/api/v1/admin/site-mode"
},
{
"method": "GET",
"path": "/api/v1/admin/teams"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/archive"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/display-name"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id/forum/moderation"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id/grants"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/hide"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/leader-override"
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/:id/leader-override/:memberKey"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/unhide"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/forum/settings"
},
{
"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"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/requests/:id/decide"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/resync"
},
{
"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"
@@ -333,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"
@@ -389,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"
@@ -417,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"
@@ -465,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"
@@ -477,6 +693,18 @@
"method": "PUT",
"path": "/api/v1/auth/me/notifications/subscriptions"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/teams"
},
{
"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"
@@ -557,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"
@@ -605,10 +801,78 @@
"method": "GET",
"path": "/api/v1/player/appeals/eligible"
},
{
"method": "GET",
"path": "/api/v1/player/teams"
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/access"
},
{
"method": "PATCH",
"path": "/api/v1/player/teams/:slug/forum/posts/:id"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/posts/:id/moderate"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/report"
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/forum/threads"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads"
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/forum/threads/:id"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads/:id/posts"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/uploads"
},
{
"method": "DELETE",
"path": "/api/v1/player/teams/:slug/forum/uploads/:id"
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/grants"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/grants"
},
{
"method": "DELETE",
"path": "/api/v1/player/teams/:slug/grants/:userId"
},
{
"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"
@@ -637,6 +901,34 @@
"method": "GET",
"path": "/api/v1/public/status"
},
{
"method": "GET",
"path": "/api/v1/public/teams"
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug"
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/activity"
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/members"
},
{
"method": "GET",
"path": "/api/v1/public/teams/by-external/:moduleId/:externalId"
},
{
"method": "GET",
"path": "/api/v1/public/teams/unsubscribe/:token"
},
{
"method": "POST",
"path": "/api/v1/public/teams/unsubscribe/:token"
},
{
"method": "GET",
"path": "/api/v1/public/version"
@@ -674,6 +966,14 @@
{
"method": "GET",
"path": "/internal/bot-config"
},
{
"method": "GET",
"path": "/internal/commands"
},
{
"method": "POST",
"path": "/internal/commands/dispatch"
}
]
}

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

@@ -79,6 +79,44 @@ async function requireAuth(req, res, next) {
}
}
// Best-effort AUTHENTICATION, as opposed to attachSession's best-effort decode.
//
// For a PUBLIC route whose content — not merely its presentation — depends on who
// is asking. The Team activity feed is the first: `public` items go to everyone
// and `members` items only to members and forum-granted users (TEAMS.md §4.3), so
// an anonymous caller must be served, not rejected, and an authenticated one must
// be identified properly.
//
// "Properly" is why this is not attachSession. That one decodes the token and
// stops, which is right for reading back your own session but wrong here: a
// banned account, a password change, or a logout would all keep working against
// the private half of the feed until the JWT expired. This runs the same
// database re-validation requireAuth does — status, cutoff, revocation — and on
// any failure continues ANONYMOUSLY rather than 401ing. A caller whose session is
// no longer good sees the public feed, which is exactly what they are entitled to.
//
// A database error also degrades to anonymous. On a public route the safe
// direction is to serve less, and 500ing a page because a session lookup failed
// would take the whole Team page down for callers who never sent a token.
async function optionalAuth(req, res, next) {
const session = sessionService.validateSession(req)
if (!session) return next()
try {
const user = await users.getById(session.userId)
if (!user) return next()
if (user.status && user.status !== 'active') return next()
if (isBeforeCutoff(session, user.tokens_valid_after)) return next()
if (await sessionService.isSessionRevoked(session.sessionId)) return next()
req.user = user
req.session = session
req.authMethod = session.authMethod
} catch (err) {
log.warn('optionalAuth: continuing anonymously', { message: err.message })
}
return next()
}
// Gate middleware factory: allow only the listed roles. Assumes requireAuth ran
// first so req.user is populated. Use for admin-only endpoints (users, site
// mode, settings) so a lower-privilege editor cannot reach them.
@@ -91,6 +129,7 @@ function requireRole(...roles) {
module.exports = {
attachSession,
optionalAuth,
requireAuth,
requireRole,
}

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

@@ -2,13 +2,18 @@
//
// What is left of config/notificationStreams.js once the shard-derived catalog
// moved to config/shardStreams.js (MODULE_SYSTEM.md §1.8: push INFRASTRUCTURE is
// core, the CATALOG is content). Exactly one stream is core's: `news.post` is
// produced by the website's own posts path, not by any game feed.
// core, the CATALOG is content). `news.post` is produced by the website's own
// posts path, not by any game feed, and the four `team.*` streams by core's own
// Team sync and forum.
//
// Registered through modules/registries.js like any module's, and read back
// through it — nothing imports this file to get "the catalog", because the
// catalog is core's plus every module's.
//
// Phase 6 added the four Team streams below. They are core's for the same reason
// the Team tables are: a module supplies who is in a Team, but who may be told
// about it is the access resolver's answer, and that is core's (TEAMS.md Part 6).
//
// The payload that ever leaves the server is a CONTENT-FREE tickle
// ({ stream, ref }); the app wakes and PULLS the real, ownership-checked content
// over the authenticated API (docs/android/PLAN.md §11).
@@ -21,6 +26,55 @@ const STREAMS = [
personal: false,
requiresLinkedAccount: false,
},
// ── Teams (TEAMS.md §6.2, phase 6) ───────────────────────────────────────
//
// FOUR streams, and not one per Team. The catalog is a static registration
// validated at boot; it has no way to express an unbounded runtime-created set,
// and a stream id per Team would leave rows in notification_subscriptions to
// collect every time a Team archived. Which Team an event came from lives in
// the RECIPIENT SET (utils/teamNotify.js) and in the `ref`, never in the id.
//
// `requiresLinkedAccount: false` on all four is deliberate and reads oddly.
// These are game-sourced events, so the instinct is to demand a linked game
// account — but a forum-granted user with no game identity at all is exactly
// the population §2.5 path 3 exists for, and they are a legitimate recipient of
// `team.forum.post`. The flag would refuse them a toggle they have every right
// to. What enforces who gets what is the recipient computation, which asks the
// access resolver; the stream flag is not a second, weaker copy of that rule.
//
// `personal: false` for the same reason it is false on news.post: these are not
// owner-keyed events about one account's own property. `publishToUsers` is a
// third fan-out shape alongside "everyone subscribed" and "this one owner", and
// the catalog has no flag for it because the flag would say nothing a caller
// does not already know by choosing the function.
{
id: 'team.member.joined',
label: 'Team — new member',
description: 'Someone joined a Team you belong to.',
personal: false,
requiresLinkedAccount: false,
},
{
id: 'team.leadership.changed',
label: 'Team — leadership change',
description: 'Leadership changed in a Team you belong to.',
personal: false,
requiresLinkedAccount: false,
},
{
id: 'team.forum.post',
label: 'Team — new forum post',
description: 'A new thread or reply in a Team forum you can read.',
personal: false,
requiresLinkedAccount: false,
},
{
id: 'team.announcement',
label: 'Team — announcements',
description: 'A leader posted an announcement in a Team you can read.',
personal: false,
requiresLinkedAccount: false,
},
]
module.exports = { STREAMS }

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,
}

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