73 Commits

Author SHA1 Message Date
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
217 changed files with 39561 additions and 2136 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 listener’s 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 Discord’s numeric option types, not the contract’s 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 Discord’s 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: 'Blackthorn’s Legion',
teamUrl: 'https://site/guilds/blackthorns-legion',
title: 'Siege tonight',
body: 'Meet at the moongate.',
url: 'https://site/guilds/blackthorns-legion?thread=41',
...over,
})
// ── 1. The channel is the app's decision ───────────────────────────────────
test('the message goes to the channel the app named', async () => {
const channel = fakeChannel()
await post(fakeClient(channel))
assert.equal(channel.sends.length, 1)
const [embed] = channel.sends[0].embeds
assert.equal(embed.data.title, 'Siege tonight')
assert.equal(embed.data.author.name, 'Blackthorn’s Legion')
assert.equal(embed.data.url, 'https://site/guilds/blackthorns-legion?thread=41')
})
test('no channel id at all is refused before anything is fetched', async () => {
await assert.rejects(() => post(fakeClient(fakeChannel()), { channelId: '' }), /No channel id/)
})
// ── 2. A channel the bot cannot use ────────────────────────────────────────
test('a channel the bot cannot see is a clear error, not a silent no-op', async () => {
await assert.rejects(() => post(fakeClient(null)), /missing, not text-based, or not visible/)
})
test('a fetch that throws is reported the same way — the bot does not distinguish gone from hidden', async () => {
await assert.rejects(() => post(fakeClient(fakeChannel(), { throws: true })), /missing, not text-based/)
})
test('a voice channel is refused', async () => {
await assert.rejects(() => post(fakeClient(fakeChannel({ textBased: false }))), /not text-based/)
})
// ── 3. Discord's limits, and the heading ───────────────────────────────────
test('an over-long title is truncated rather than rejected by Discord as a whole', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { title: 'y'.repeat(400) })
const [embed] = channel.sends[0].embeds
assert.equal(embed.data.title.length, teamNotify.TITLE_MAX)
assert.ok(embed.data.title.endsWith('…'))
})
test('an over-long body is truncated to the description limit', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { body: 'z'.repeat(9000) })
const [embed] = channel.sends[0].embeds
assert.ok(embed.data.description.length <= teamNotify.DESCRIPTION_MAX + 32)
})
test('a titled event keeps its heading, so a post and an announcement stay distinguishable', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { stream: 'team.announcement' })
const [embed] = channel.sends[0].embeds
assert.match(embed.data.description, /^\*\*Announcement\*\*/)
assert.match(embed.data.description, /Meet at the moongate\./)
})
test('a roster event has no title, so the heading becomes the title', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { stream: 'team.member.joined', title: null, body: '3 new members joined.' })
const [embed] = channel.sends[0].embeds
assert.equal(embed.data.title, 'New member')
assert.equal(embed.data.description, '3 new members joined.', 'no heading prefix when the title already is one')
})
test('an unknown stream still posts, under a neutral heading', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { stream: 'team.something.new', title: null })
const [embed] = channel.sends[0].embeds
assert.equal(embed.data.title, 'Team update')
})
test('a missing team name does not produce an embed with an empty author line', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { teamName: '', teamUrl: null })
const [embed] = channel.sends[0].embeds
assert.equal(embed.data.author.name, 'A team')
assert.equal(embed.data.author.url, undefined)
})
test('clamp treats whitespace-only as absent, which is what keeps an empty description off the embed', async () => {
assert.equal(teamNotify.clamp(' ', 100), null)
assert.equal(teamNotify.clamp('ok', 100), 'ok')
})

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

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

View File

@@ -42,19 +42,24 @@ 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 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 Unsubscribe from './routes/player/Unsubscribe.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
export default function App() {
@@ -162,6 +167,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,6 +180,10 @@ 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 />} />
<Route path="account" element={<AccountAdmin />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
@@ -196,7 +206,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 +228,7 @@ export default function App() {
<Route path="/player" element={<PlayerIndex />} />
<Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} />
<Route path="/account/notifications" element={<PlayerNotifications />} />
{/* 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,80 @@ 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 } }),
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 +324,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 +357,61 @@ export const api = {
setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
restartServer: () => req('/admin/modules/restart', { method: 'POST' }),
// 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 +471,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 +481,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

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

@@ -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,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 moderator’s 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 bot’s status is unknown.'
if (!preflight.connected) return preflight.reason || 'The Discord bot is not connected.'
if (preflight.missingPermissions && preflight.missingPermissions.length > 0) {
return `The bot is missing ${preflight.missingPermissions.join(' and ')} in this guild.`
}
if (!preflight.ready) return preflight.reason || 'The bot cannot manage channels and roles yet.'
return null
}
// Below this many free roles the panel starts saying so. Not a server rule and
// deliberately not one: it is a warning, and the server's only hard behaviour is
// to refuse the create that would exceed the cap.
const HEADROOM_WARNING = 25
/**
* How much room is left, and whether to say something about it.
*
* The 250-role cap is the ceiling this phase's shape brings with it. Access is a
* per-Team role, so it is not "how big can a Team be" — the old overwrite design's
* limit — but "how many Teams can have voice at all", and the difference matters
* to an operator with sixty guilds on their shard. It is guild-wide and shared
* with every role they created themselves, which is why the count comes from the
* bot rather than from core's own rows.
*/
export function roleHeadroom(preflight) {
if (!preflight || !preflight.roleCap) return null
const used = Number(preflight.roleCount) || 0
const cap = Number(preflight.roleCap)
const free = Math.max(0, cap - used)
return { used, cap, free, tight: free <= HEADROOM_WARNING, exhausted: free === 0 }
}
/** How a row's grace window reads while it is running. */
export function removalCountdown(row, now = new Date()) {
if (!row || row.state !== 'pending_removal' || !row.removeAfter) return null
const ms = new Date(row.removeAfter).getTime() - now.getTime()
if (ms <= 0) return 'due for removal on the next pass'
const days = Math.floor(ms / 86400000)
if (days >= 1) return `in ${days} day${days === 1 ? '' : 's'}`
const hours = Math.max(1, Math.round(ms / 3600000))
return `in ${hours} hour${hours === 1 ? '' : 's'}`
}
/**
* Parse the staff-role field an operator types.
*
* Comma-separated ids, because that is what a person copying role ids out of
* Discord ends up with. Validated rather than filtered, mirroring the server: a
* quietly dropped id is a settings screen showing a save that did not happen.
*/
export function parseStaffRoles(text) {
const parts = String(text || '')
.split(',')
.map((part) => part.trim())
.filter(Boolean)
const bad = parts.filter((part) => !/^[0-9]{5,32}$/.test(part))
return { roles: parts, invalid: bad }
}
export const formatStaffRoles = (roles) => (roles || []).join(', ')
/**
* The sentence under the enable switch, which changes meaning with the state.
*
* "Off" is not "nothing is provisioned": switching voice off suspends the
* reconciler in BOTH directions and leaves existing channels in place, which is
* deliberate — a checkbox must not delete structure in somebody's guild — but it
* is also surprising unless the screen says so.
*/
export function statusSummary(settings, rows) {
const provisioned = (rows || []).filter((row) => row.channelRef).length
if (!settings || !settings.enabled) {
return provisioned > 0
? `Off. ${provisioned} channel${provisioned === 1 ? '' : 's'} remain in Discord and are no longer being kept in step — remove them below if they are not wanted.`
: 'Off. No channels are provisioned.'
}
return `On. Teams with at least ${settings.minMembers} member${settings.minMembers === 1 ? '' : 's'} get a voice channel and a role; ${provisioned} provisioned.`
}

View File

@@ -3,7 +3,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" 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,33 @@
// 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.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 +53,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.7.0'

View File

@@ -76,6 +76,17 @@ 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'] },
],
},
{
@@ -134,6 +145,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',

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

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

View File

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

View File

@@ -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,268 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { api } from '../../api/client.js'
// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6).
//
// **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.
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 ──────────────────────────────────────────────────
function Streams({ streams, subscribed, onSave, busy, msg, error }) {
const [set, setSet] = useState(() => new Set(subscribed))
useEffect(() => { setSet(new Set(subscribed)) }, [subscribed])
const toggle = (id) => {
const next = new Set(set)
if (next.has(id)) next.delete(id)
else next.add(id)
setSet(next)
}
const team = streams.filter((s) => isTeamStream(s.id))
const rest = streams.filter((s) => !isTeamStream(s.id))
const row = (s) => (
<label key={s.id} className="sans" style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: '0.92rem' }}>
<input type="checkbox" checked={set.has(s.id)} onChange={() => toggle(s.id)} style={{ marginTop: 3 }} />
<span>
<span style={{ color: 'var(--ink)' }}>{s.label}</span>
{s.description && <span className="dim" style={{ display: 'block', fontSize: '0.82rem' }}>{s.description}</span>}
</span>
</label>
)
return (
<Section
title="What to notify me about"
hint="Applies to every device you have signed in on. Notifications are delivered to the app; the website itself does not pop anything up."
>
<div style={{ display: 'grid', gap: 12 }}>{rest.map(row)}</div>
{team.length > 0 && (
<>
<h3 className="sans dim" style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '20px 0 10px' }}>
Teams
</h3>
<div style={{ display: 'grid', gap: 12 }}>{team.map(row)}</div>
</>
)}
<div style={{ marginTop: 18 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave([...set])}>
{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 [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [streams, setStreams] = useState([])
const [subscribed, setSubscribed] = useState([])
const [teams, setTeams] = useState([])
const [saving, setSaving] = useState({ streams: false, teams: false })
const [notes, setNotes] = useState({ streams: '', teams: '', streamsError: '', teamsError: '' })
const load = useCallback(async () => {
setLoading(true)
try {
// Three reads in parallel: the catalog is boot-fixed, the subscriptions and
// the Team list are this user's. None depends on another.
const [cat, subs, prefs] = await Promise.all([
api.notificationStreams(),
api.notificationSubscriptions(),
api.teamNotificationPrefs(),
])
setStreams(cat.streams || [])
setSubscribed(subs.streams || [])
setTeams(prefs.teams || [])
setError('')
} catch {
setError('Could not load your notification settings.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const saveStreams = useCallback(async (ids) => {
setSaving((s) => ({ ...s, streams: true }))
setNotes((n) => ({ ...n, streams: '', streamsError: '' }))
try {
const { streams: stored } = await api.setNotificationSubscriptions(ids)
setSubscribed(stored || [])
setNotes((n) => ({ ...n, streams: 'Saved.' }))
} catch {
setNotes((n) => ({ ...n, streamsError: 'Could not save that.' }))
} finally {
setSaving((s) => ({ ...s, streams: 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. Nothing here is on by default except team
notifications to the app, which you can mute per team below.
</p>
<Streams
streams={streams}
subscribed={subscribed}
onSave={saveStreams}
busy={saving.streams}
msg={notes.streams}
error={notes.streamsError}
/>
<Teams
teams={teams}
onSave={saveTeams}
busy={saving.teams}
msg={notes.teams}
error={notes.teamsError}
/>
</div>
)
}

View File

@@ -35,6 +35,7 @@ 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>
// 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 +48,7 @@ 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', icon: IconBell },
{ to: '/account', label: 'Account', end: true, icon: IconGear },
]
@@ -56,6 +58,7 @@ export const NAV = [
const TITLES = {
'/account': 'Account',
'/account/appeals': 'Appeals',
'/account/notifications': 'Notifications',
}
function moduleTitle(baseNav, pathname) {

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">notification settings</Link>.
</p>
</>
)}
{state === 'failed' && (
<p className="sans" style={{ color: 'var(--ink)' }}>
We could not reach the site to record that. Please try the link again, or change the
setting yourself under <Link to="/account/notifications">notification settings</Link>.
</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

@@ -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,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,185 @@
#!/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.
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)\b/i
// Hosts that are not destinations: the loopback family, and the RFC 2606 names
// reserved for documentation. A placeholder in an admin form's help text is the
// opposite of a phone-home — it shows the operator the SHAPE of a value they
// must supply, and blanking it would make the form worse to hold the rule.
const ALLOWED = [
/^(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)$/i,
/(?:^|\.)example\.(?:com|net|org)$/i,
/(?:^|\.)(?:invalid|test|localhost)$/i,
]
const isAllowed = (host) => ALLOWED.some((re) => re.test(host))
const hostOf = (literal) => {
const withoutScheme = literal.replace(/^[a-z]+:\/\//i, '')
return withoutScheme.split(/[/?#:]/)[0]
}
/**
* Blank comments and mask string bodies in one left-to-right pass, keeping every
* offset aligned so reported line numbers stay honest.
*
* Lifted from `checkModuleIdentifiers.maskCode` deliberately rather than
* imported: that file's masking is tuned to ITS four checks (it keeps quotes so a
* route-path check can re-read the original at the same offsets), and coupling
* two checks through a shared helper means a change made for one silently
* re-scopes the other. Both are ~40 lines and both are tested.
*/
function maskComments(src) {
const out = Array.from(src)
const blank = (from, to) => {
for (let i = from; i < to && i < out.length; i++) if (out[i] !== '\n') out[i] = ' '
}
let i = 0
while (i < src.length) {
const c = src[i]
const next = src[i + 1]
if (c === '/' && next === '/') {
let j = i
while (j < src.length && src[j] !== '\n') j++
blank(i, j)
i = j
continue
}
if (c === '/' && next === '*') {
const end = src.indexOf('*/', i + 2)
const j = end === -1 ? src.length : end + 2
blank(i, j)
i = j
continue
}
if (c === '"' || c === "'" || c === '`') {
let j = i + 1
while (j < src.length) {
if (src[j] === '\\') { j += 2; continue }
if (src[j] === c) break
j++
}
// Keep the string body: it is what this check reads. Only the delimiters
// matter for finding it, and comments are what has to go.
i = j + 1
continue
}
i++
}
return out.join('')
}
// Every string literal in the (comment-free) source, with its line number.
const STRING = /(['"`])((?:\\.|(?!\1)[^\\])*)\1/g
function lineOf(src, index) {
return src.slice(0, index).split('\n').length
}
/** Check one file's contents. Returns [{ file, line, literal, host }]. */
function checkFile(rel, src) {
const hits = []
const code = maskComments(src)
for (const m of code.matchAll(STRING)) {
const value = m[2]
if (!value) continue
const urlMatch = value.match(URL_LITERAL)
const hostMatch = urlMatch ? null : value.match(HOSTNAME_LITERAL)
const literal = urlMatch ? urlMatch[0] : hostMatch ? hostMatch[0] : null
if (!literal) continue
const host = hostOf(literal)
if (isAllowed(host)) continue
hits.push({ file: rel, line: lineOf(src, m.index), literal, host })
}
return hits
}
function walk(dir, out = []) {
if (!fs.existsSync(dir)) return out
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walk(full, out)
else out.push(full)
}
return out
}
function run() {
const files = [...TREES.flatMap((t) => walk(t)), ...FILES.filter((f) => fs.existsSync(f))]
const hits = []
for (const file of files) {
if (!CODE.has(path.extname(file))) continue
const rel = path.relative(ROOT, file).split(path.sep).join('/')
hits.push(...checkFile(rel, fs.readFileSync(file, 'utf8')))
}
return hits
}
module.exports = { run, checkFile, maskComments, isAllowed, hostOf }
if (require.main === module) {
const hits = run()
if (hits.length === 0) {
console.log('OK — the engagement subsystem names no external host (ENGAGEMENT.md §3.2 rule 4).')
process.exit(0)
}
console.error(
`\nThe engagement subsystem names ${hits.length} external host${hits.length === 1 ? '' : 's'} ` +
'in code (ENGAGEMENT.md §3.2 rule 4). A destination belongs in operator-supplied ' +
'configuration, never in a literal:\n',
)
for (const h of hits) {
console.error(` ${h.file}:${h.line} "${h.literal}"`)
}
console.error(
'\nIf this is help text or documentation rather than a destination, put it in a comment or ' +
'use an example.com placeholder — the check masks comments and allows the reserved ' +
'documentation names on purpose.\n',
)
process.exit(1)
}

View File

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

View File

@@ -21,11 +21,30 @@ CREATE TABLE IF NOT EXISTS users (
-- (validatePassword returns false).
password_hash VARCHAR(72) NULL,
role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin',
-- Optional contact email (players). Not unique — SSO emails may repeat. Used
-- only for display + a future self-serve reset. email_verified is wired now so
-- an eventual SMTP verification flow needs no schema change.
-- The account's ONE contact address, and the destination for password-reset
-- mail. Unique since engagement Phase 1b — but the index is on email_norm
-- below, never on this column, and the reason is not stylistic:
--
-- Every case-insensitive (_ci) collation this server offers is ALSO
-- accent-insensitive, so a UNIQUE index on `email` would refuse
-- jose@x.com once josé@x.com exists. Those are two different mailboxes.
--
-- LOWER() under a _bin collation folds case WITHOUT folding accents, which is
-- exactly the equivalence a mail system uses. Keeping the fold in a generated
-- column rather than in application code means it cannot be bypassed by a
-- caller that forgets to normalize.
email VARCHAR(255) NULL,
-- The uniqueness key. STORED (not VIRTUAL) because a UNIQUE index over it must
-- be materialized. Multiple NULLs are legal under a UNIQUE index, which is what
-- lets the Phase 1b de-duplication null the losers without deleting an account.
email_norm VARCHAR(255) COLLATE utf8mb4_bin AS (LOWER(email)) STORED,
email_verified TINYINT(1) NOT NULL DEFAULT 0,
-- An address the user has asked for but not yet proved. It does NOT displace
-- `email` until the verification link is used, so a typo cannot silently
-- redirect this account's password-reset mail. Deliberately NOT unique: a
-- pending address reserves nothing, and two users may both be pending on one
-- address — the second to verify loses, with the same generic failure.
email_pending VARCHAR(255) NULL,
-- Account lifecycle, independent of role: staff can disable/ban a player
-- without changing their role. active = normal; disabled = admin-locked;
-- banned = moderation ban; pending = reserved for future email-verify gating.
@@ -38,7 +57,12 @@ CREATE TABLE IF NOT EXISTS users (
tokens_valid_after DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME NULL,
last_login_ip VARCHAR(45) NULL -- IPv6-capable, set on each login
last_login_ip VARCHAR(45) NULL, -- IPv6-capable, set on each login
-- One account per mailbox (engagement Phase 1b). On the generated column, not
-- on `email` — see the note there. Upgraded databases get this in the migration
-- block at the foot of this file, AFTER the de-duplication that makes it
-- addable; adding it here too is what gives a FRESH install the same shape.
UNIQUE KEY uq_users_email_norm (email_norm)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS posts (
@@ -327,19 +351,33 @@ CREATE TABLE IF NOT EXISTS bot_config (
CONSTRAINT chk_bot_config_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Outbound email configuration (Gmail over OAuth2 / SMTP XOAUTH2). Singleton row
-- (id = 1), mirroring bot_config: the DB only ever holds the AES-256-GCM-encrypted
-- refresh token, never plaintext, and the client id/secret are NOT stored here —
-- they are read live from the `google` auth_providers row. The refresh token is
-- captured by the in-app "Connect Gmail" consent flow and is write-only over the
-- admin API (never returned; responses expose only hasRefreshToken).
-- Outbound email configuration. Singleton row (id = 1), mirroring bot_config: the
-- DB only ever holds the AES-256-GCM-encrypted credential, never plaintext, and it
-- is write-only over the admin API (never returned; responses expose only
-- hasCredential and the non-secret fields the transport declares).
--
-- `transport` names a registered mail transport (server/src/engagement/transports).
-- `credential_enc` is that transport's whole credential set as one encrypted JSON
-- blob rather than a column per field, because the field list is the transport's to
-- declare — SMTP wants host/port/secure/user/password, an API relay wants a domain
-- and a key, and a column per union member would make adding a transport a schema
-- change. ENGAGEMENT.md §3.1.
--
-- `provider` and `refresh_token_enc` are DEPRECATED and no longer read: they held
-- the removed Gmail OAuth2 connection (ENGAGEMENT.md §1.2a). They are kept rather
-- than dropped under the additive-only discipline, and `refresh_token_enc` earns
-- its keep in the meantime as the marker for "this deployment had working mail
-- before the upgrade" — which is what the admin dashboard warning reads.
CREATE TABLE IF NOT EXISTS email_config (
id INT PRIMARY KEY DEFAULT 1,
provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2',
provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2', -- DEPRECATED, unread
transport VARCHAR(32) NOT NULL DEFAULT 'smtp',
enabled TINYINT(1) NOT NULL DEFAULT 0,
sender_email VARCHAR(255) NULL, -- connected Gmail address (from userinfo)
sender_email VARCHAR(255) NULL, -- envelope From, operator-typed
sender_name VARCHAR(120) NULL, -- optional From display name
refresh_token_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed
reply_to VARCHAR(255) NULL, -- optional Reply-To for sent mail
credential_enc TEXT NULL, -- AES-256-GCM ciphertext (JSON), never exposed
refresh_token_enc TEXT NULL, -- DEPRECATED, unread; see above
status VARCHAR(20) NOT NULL DEFAULT 'unconfigured',
status_detail VARCHAR(500) NULL,
last_verified_at DATETIME NULL,
@@ -401,6 +439,54 @@ CREATE TABLE IF NOT EXISTS password_resets (
INDEX idx_password_resets_status (status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Self-service email verification (engagement Phase 1b). The same shape as
-- password_resets, deliberately: an opaque random token whose sha256 is all that
-- is stored, single-use, short-lived. The design of record calls this link
-- "signed"; every comparable flow in this codebase (user_invites,
-- password_resets, mobile_refresh_tokens) uses a hashed random token instead, and
-- matching them beats introducing a second token mechanism for one caller.
--
-- The address lives on the ROW, not just on the user: a token proves control of
-- the address it was mailed to, so if the user changes their mind and requests a
-- different address, the older token must not be able to confirm the newer one.
CREATE TABLE IF NOT EXISTS email_verifications (
id INT AUTO_INCREMENT PRIMARY KEY,
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token
user_id INT NOT NULL,
email VARCHAR(255) NOT NULL, -- the address THIS token proves
status ENUM('pending','used') NOT NULL DEFAULT 'pending',
requested_ip VARCHAR(64) NULL, -- who asked (audit only)
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
used_at DATETIME NULL,
CONSTRAINT fk_email_verifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_email_verifications_user (user_id),
INDEX idx_email_verifications_status (status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Who lost an address to the Phase 1b de-duplication, and what they lost.
--
-- These accounts are exactly the ones an operator must contact: they can no
-- longer receive password-reset or engagement mail until they set a new address.
-- Written by the migration below in pure SQL (ensureSchema() reads this file
-- statement-by-statement and there is no JS migration hook), surfaced as a
-- dashboard warning until acknowledged.
--
-- No foreign key to users, on purpose: the same reasoning as posts.announce_job_id
-- — a constraint re-added on every boot is a constraint that can fail a boot, and
-- this table is a historical record rather than a live relation.
CREATE TABLE IF NOT EXISTS email_dedupe_report (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
username VARCHAR(32) NOT NULL, -- captured at clear time
lost_address VARCHAR(255) NOT NULL,
cleared_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
acknowledged_at DATETIME NULL, -- set when an admin dismisses the warning
-- Makes the migration's INSERT strictly idempotent: an account cleared once is
-- never reported twice, however many times ensureSchema() runs.
UNIQUE KEY uq_edr_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Push notifications (opt-in) ─────────────────────────────────────────────
-- One row per registered push endpoint (Android/UnifiedPush v1; FCM later). The
-- `endpoint` is the UnifiedPush distributor URL the app's ntfy topic was handed —
@@ -845,6 +931,561 @@ CREATE TABLE IF NOT EXISTS installed_modules (
INDEX idx_installed_modules_state (state)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Teams (docs/website/TEAMS.md Part 2, phase 2) ─────────────────────────────
--
-- A Team is a core platform entity POPULATED by a module and owned by core. The
-- module answers "what teams exist and who is in them" through the team provider
-- (MODULE_API.md — registerTeamProvider); core stores the answer, gates it and
-- displays it. Every table below is core-internal (TEAMS.md §10.3): a module must
-- never read or write one, even though a module is what fills them.
--
-- Note the tables carry no `<moduleId>_` prefix, correctly — MODULE_API.md §2.6's
-- prefix rule binds modules, and these are core's.
-- The Team itself. `external_id` is the module's own stable identity for it
-- (module-uo sends the persistent ServUO Guild.Id) and is opaque to core.
--
-- `name` is IMMUTABLE for the life of the row (§2.2): a rename archives this row
-- with archived_reason='renamed' and creates a new one, so the old Team keeps its
-- activity, its grants and its forum as a read-only record. What staff can change
-- is display_name_override, which changes what is RENDERED and never what the row
-- IS — identity and display are different things and only identity is frozen.
CREATE TABLE IF NOT EXISTS teams (
id INT AUTO_INCREMENT PRIMARY KEY,
module_id VARCHAR(32) NOT NULL, -- which module is authoritative
external_id VARCHAR(191) NOT NULL, -- opaque to core
name VARCHAR(160) NOT NULL,
abbr VARCHAR(32) NULL,
slug VARCHAR(191) NOT NULL, -- derived from name, unique among ACTIVE teams
status ENUM('active','archived') NOT NULL DEFAULT 'active',
meta JSON NULL, -- module-supplied, opaque (alliance, crest, …)
member_count INT NOT NULL DEFAULT 0, -- denormalised from team_members
linked_count INT NOT NULL DEFAULT 0, -- members whose user_id is not null
online_count INT NOT NULL DEFAULT 0, -- last known; refreshed by sync
-- Public suppression, independent of status. A hidden Team still works
-- completely for its own members; it is absent from public surfaces (§2.8).
hidden TINYINT(1) NOT NULL DEFAULT 0,
hidden_reason ENUM('reserved_name','staff') NULL,
hidden_term VARCHAR(64) NULL, -- which reserved term matched, for the review queue
-- Set once staff have made an explicit decision about the name. Re-screening
-- runs on every sync, and this is what stops it re-hiding a Team a human has
-- already allowed — without it the override would be undone every 15 minutes.
name_reviewed_at DATETIME NULL,
-- PER-TEAM freshness, which team_sync_state cannot express: it holds one row per
-- MODULE, and §2.4 gate 3 leaves one Team's roster untouched while the others
-- sync normally. Without a per-Team stamp that Team's page would claim the
-- module's last success as its own, which is precisely the staleness the rule
-- exists to surface. Bumped only when a roster is actually applied.
roster_synced_at DATETIME NULL,
-- §2.4 gate 4's per-Team quarantine, the twin of team_sync_state.pending_empty_
-- since: an authoritative-but-empty ROSTER for a Team that currently has members
-- is remembered here and applied only if the next answer agrees.
members_empty_since DATETIME NULL,
-- Staff may change what is DISPLAYED without touching identity (§2.8.3).
display_name_override VARCHAR(160) NULL,
-- The successor row written at archive time when this Team was renamed, so the
-- old slug can still resolve and explain itself rather than 404 (§2.2).
succeeded_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
archived_at DATETIME NULL,
archived_reason VARCHAR(64) NULL, -- 'disbanded' | 'renamed' | 'staff'
-- A generated column is how "unique among ACTIVE rows only" is expressed without
-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key, so
-- any number of archived rows may share an external_id.
active_key VARCHAR(191) AS (IF(status='active', external_id, NULL)) STORED,
active_slug VARCHAR(191) AS (IF(status='active', slug, NULL)) STORED,
UNIQUE KEY uq_teams_active (module_id, active_key),
UNIQUE KEY uq_teams_active_slug (active_slug),
INDEX idx_teams_status (status),
INDEX idx_teams_slug (slug),
INDEX idx_teams_review (hidden, hidden_reason),
-- Self-referential and deliberately SET NULL: a successor may itself be archived
-- and eventually pruned, and losing the pointer must not take the old row with it.
CONSTRAINT fk_teams_succeeded_by FOREIGN KEY (succeeded_by) REFERENCES teams(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The membership PROJECTION. Module-authoritative; core only mirrors it, and the
-- sync is the ONLY writer (§2.5 path 1). Rows are soft-departed rather than
-- deleted so history and rejoin detection survive, and so the activity feed can
-- still name a departed member.
--
-- user_id is resolved BY THE MODULE (it owns the game↔site link table); core never
-- resolves it, because doing so would be core reading a module's table by name.
CREATE TABLE IF NOT EXISTS team_members (
team_id INT NOT NULL,
member_key VARCHAR(191) NOT NULL, -- module's stable member id (UO: character serial)
display_name VARCHAR(160) NULL, -- in-game name
user_id INT NULL, -- resolved by the MODULE; NULL = unlinked
is_leader TINYINT(1) NOT NULL DEFAULT 0,
rank_label VARCHAR(48) NULL, -- module vocabulary, opaque to core
online TINYINT(1) NOT NULL DEFAULT 0,
status ENUM('active','departed') NOT NULL DEFAULT 'active',
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
departed_at DATETIME NULL,
PRIMARY KEY (team_id, member_key),
-- SET NULL, not CASCADE (§2.10): deleting a site account does not remove the
-- character from the guild — only the link to the site goes.
CONSTRAINT fk_team_members_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_team_members_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_team_members_user (user_id),
INDEX idx_team_members_status (team_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Freshness of the module's answer. One row per module. THE table invariant 1
-- ("module unavailability is staleness, never emptiness") is enforced against.
CREATE TABLE IF NOT EXISTS team_sync_state (
module_id VARCHAR(32) NOT NULL PRIMARY KEY,
last_attempt_at DATETIME NULL,
last_success_at DATETIME NULL,
consecutive_failures INT NOT NULL DEFAULT 0,
last_error VARCHAR(500) NULL,
-- The quarantine for §2.4's mass-deletion guard: an authoritative-but-empty
-- answer is remembered here and applied only if the NEXT one agrees.
pending_empty_since DATETIME NULL,
INDEX idx_team_sync_success (last_success_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Staff leadership overrides (§2.5.1), applied ON TOP of the synced value at read
-- time. The projection is never mutated: the sync keeps writing what the game
-- says and this keeps saying what staff decided, which is the whole point — an
-- override the sync clobbered every 15 minutes would be useless.
CREATE TABLE IF NOT EXISTS team_leader_overrides (
team_id INT NOT NULL,
member_key VARCHAR(191) NOT NULL,
effect ENUM('grant','deny') NOT NULL,
actor_user_id INT NULL,
actor_username VARCHAR(32) NULL, -- snapshot, so the record survives the account
reason VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (team_id, member_key),
CONSTRAINT fk_tlo_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tlo_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Forum access grants (§2.5 path 3) — an append-only grant/revoke ledger that is
-- ALSO the current state. An active grant is one with revoked_at IS NULL, and a
-- generated column is how "one active grant per (team,user)" is expressed without
-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key.
--
-- The table lands here, in the phase that builds the resolver, so forumAccess() is
-- written once and its non-contamination tests are real. The grant/revoke FLOW,
-- the per-Team cap and the leader UI are phase 4's; nothing writes this table yet.
--
-- user_id is NULLABLE and SET NULL, which contradicts the sketch in TEAMS.md §2.5
-- and follows §2.10, which settled it deliberately: CASCADE would delete the audit
-- trail of who granted whom, which is exactly what an audit exists to survive. The
-- username snapshots keep the record readable after the account is gone.
--
-- THE TWO CANNOT BOTH BE HAD AS §2.5 WROTE THEM, and this is why the marker below
-- is a bare flag rather than §2.5's `active_user AS (IF(revoked_at IS NULL,
-- user_id, NULL))`. MariaDB refuses `ON DELETE SET NULL` on a foreign key whose
-- column is a base column of a STORED generated column (ER_GENERATED_COLUMN_
-- FUNCTION_IS_NOT_ALLOWED, 1901) — so §2.5's generated column forces §2.10's
-- CASCADE, and the audit trail with it. Deriving the marker from `revoked_at`
-- ALONE and putting user_id in the KEY instead gives identical semantics: at most
-- one active row per (team_id, user_id), unlimited revoked rows, and user_id free
-- to be a SET NULL foreign key. Verified against MariaDB 11 both ways.
CREATE TABLE IF NOT EXISTS team_forum_grants (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
user_id INT NULL,
username VARCHAR(32) NULL, -- snapshot of the grantee at grant time
granted_by INT NULL, -- NULL for a system grant, or a deleted actor
granted_username VARCHAR(32) NULL, -- snapshot of the actor
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
reason VARCHAR(255) NULL,
revoked_by INT NULL,
revoked_username VARCHAR(32) NULL,
revoked_at DATETIME NULL,
revoke_reason VARCHAR(255) NULL,
active_marker TINYINT(1) AS (IF(revoked_at IS NULL, 1, NULL)) STORED,
UNIQUE KEY uq_team_forum_grant_active (team_id, user_id, active_marker),
CONSTRAINT fk_tfg_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tfg_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_tfg_granted_by FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_tfg_revoked_by FOREIGN KEY (revoked_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tfg_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Team forums (TEAMS.md Part 5, phase 4 "5a") ────────────────────────────
--
-- The WHOLE forum schema lands here, in 5a, including the columns only 5b uses.
-- That is §5.1's split-by-layer: 5a ships the access model and announcements, 5b
-- enables discussion by opening paths rather than by migrating data. `type`,
-- `locked`, `pinned` and the whole post table exist from day one so that the
-- second half adds no ALTER.
--
-- Every table here is guarded by `teams_forums_enabled` at the ROUTE level and
-- never at the data level (§5.5.1). Switching the forum off must not delete a
-- thread, revoke a grant or clear a subscription, because the operator will
-- switch it back on and expects what they had.
CREATE TABLE IF NOT EXISTS team_forum_threads (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
type ENUM('announcement','discussion') NOT NULL DEFAULT 'discussion',
title VARCHAR(200) NOT NULL,
created_by INT NULL, -- SET NULL: the body survives the account (§2.10)
created_username VARCHAR(32) NULL, -- snapshot, so a deleted author still reads
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_post_at DATETIME NULL,
post_count INT NOT NULL DEFAULT 0,
pinned TINYINT(1) NOT NULL DEFAULT 0,
locked TINYINT(1) NOT NULL DEFAULT 0,
status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible',
CONSTRAINT fk_tft_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tft_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tft_team_feed (team_id, status, pinned, last_post_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- `body_html` is sanitised ON WRITE and served without re-sanitising, the same
-- contract the wiki and the CMS already follow — but through the FORUM's own
-- profile (utils/forumHtml.js), not the shared one. The shared profile allows
-- `<img>` from any host, which would make `teams_forum_images` unenforceable:
-- every post could hotlink in every mode and the setting would be decoration.
-- No stored body ever contains an `<img>`; core's renderer emits those at read
-- time from the URLs the author wrote (§5.5.3), which is why flipping the policy
-- back to `disabled` un-renders every image on every existing post with no
-- migration at all.
CREATE TABLE IF NOT EXISTS team_forum_posts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
thread_id INT NOT NULL,
author_user_id INT NULL,
author_username VARCHAR(32) NULL, -- snapshot; renders as "[deleted account]" when both are gone
body_html MEDIUMTEXT NOT NULL, -- sanitised on write via utils/forumHtml.js
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
edited_at DATETIME NULL,
edited_by INT NULL,
status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible',
CONSTRAINT fk_tfp_thread FOREIGN KEY (thread_id) REFERENCES team_forum_threads(id) ON DELETE CASCADE,
CONSTRAINT fk_tfp_user FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_tfp_editor FOREIGN KEY (edited_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tfp_thread (thread_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Append-only. Never updated, never deleted.
--
-- Deliberately NOT merged into the site's mod_actions/appeals pair (§5.3), which
-- 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. The two are cross-referenced instead — every
-- STAFF-exercised action here additionally writes an activity_log row, so the
-- site's staff-accountability trail sees it; a LEADER-exercised one writes only
-- this ledger. `actor_role` records WHICH authority was exercised, which is the
-- column that makes that distinction auditable after the fact.
CREATE TABLE IF NOT EXISTS team_forum_moderation (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
target_type ENUM('thread','post') NOT NULL,
target_id BIGINT NOT NULL,
action ENUM('pin','unpin','lock','unlock','hide','unhide','delete','restore') NOT NULL,
actor_user_id INT NULL,
actor_username VARCHAR(32) NULL, -- snapshot (§2.10)
actor_role ENUM('leader','staff') NOT NULL,
reason VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_tfm_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tfm_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tfm_target (target_type, target_id),
INDEX idx_tfm_team (team_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Upload attribution (§5.2a, §5.5.4). Not bookkeeping: the acknowledgement an
-- operator gives before enabling uploads is meaningless if "who uploaded this"
-- cannot be answered afterwards, and the deletion sweep needs a row to sweep.
--
-- `post_id` is NULL between the upload and the post that embeds it — the composer
-- uploads first and references the URL in the body — and that is exactly the state
-- the orphan sweep looks for. `deleted_at` is a soft delete: the file survives a
-- retention window so a mis-click is recoverable, then the nightly sweep removes
-- the bytes.
CREATE TABLE IF NOT EXISTS team_forum_uploads (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
post_id BIGINT NULL,
uploader_user_id INT NULL,
uploader_username VARCHAR(32) NULL, -- snapshot: attribution must survive the account
filename VARCHAR(255) NOT NULL, -- the STORED name, never originalname
mimetype VARCHAR(64) NOT NULL, -- the SNIFFED type, never the client's header
byte_size INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
deleted_by INT NULL,
CONSTRAINT fk_tfu_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tfu_post FOREIGN KEY (post_id) REFERENCES team_forum_posts(id) ON DELETE SET NULL,
CONSTRAINT fk_tfu_user FOREIGN KEY (uploader_user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_tfu_deleter FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_tfu_filename (filename),
INDEX idx_tfu_uploader (uploader_user_id, created_at),
INDEX idx_tfu_sweep (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Member-raised abuse reports (§5.6). **Core had no user-facing report flow of
-- any kind before this**: `moderation`, `mod_notes` and `appeals` 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. It stops being survivable the moment a Team forum
-- lets players write to each other, and stops twice over when `uploads` mode lets
-- them put files on the operator's disk under a signed liability acknowledgement.
--
-- The gap has a specific shape worth naming: leaders moderate their own Team's
-- forum, and a Team's leaders are exactly the people who will not report their own
-- Team. So this table's whole point is a path that routes AROUND a Team's own
-- leadership — **reports go to site staff and to nobody else.** There is
-- deliberately no leader-facing view of this queue (org lead, 2026-08-18); a
-- leader-visible report about a leader is not a report.
--
-- Not a `team_*` table, and not named for the forum: `target_type` is a plain
-- VARCHAR so wiki pages, news comments and profile fields become new values
-- rather than new tables. Team forum content is only the first consumer.
--
-- **The unique key is on an `open_marker`, not on `status`.** §5.6 writes the key
-- as (target_type, target_id, reporter_user_id, status), and that spelling has a
-- defect worth recording rather than quietly fixing: it makes CLOSED rows collide
-- with each other too. A reporter reports a post, staff dismiss it, the behaviour
-- recurs, they report it again — and the second dismissal is an UPDATE into a
-- (…, 'dismissed') tuple that already exists, so working the queue would start
-- throwing duplicate-key errors after the first repeat reporter.
--
-- The generated marker is the same trick `team_forum_grants.active_marker` uses:
-- it is 1 while the report is OPEN and NULL once it is closed, and MySQL treats
-- NULLs as distinct, so any number of closed reports coexist while at most one
-- open one can. That is what §5.6's prose actually asks for — "one open report per
-- (target, reporter)".
--
-- NULL reporters (deleted accounts) are distinct for the same reason, which is
-- also wanted: nothing should collapse two dead accounts' reports into one.
--
-- `handled_note` is not in the design doc and earns its place: 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 one was dismissed.
CREATE TABLE IF NOT EXISTS content_reports (
id INT AUTO_INCREMENT PRIMARY KEY,
target_type VARCHAR(32) NOT NULL, -- 'team_forum_post' | 'team_forum_thread' | 'team_forum_upload'
target_id BIGINT NOT NULL,
team_id INT NULL, -- denormalised for the queue's filters
reporter_user_id INT NULL,
reporter_username VARCHAR(32) NULL, -- snapshot (§2.10): who raised it survives the account
reason ENUM('spam','abuse','sexual','illegal','impersonation','other') NOT NULL,
detail VARCHAR(500) NULL,
status ENUM('open','reviewing','actioned','dismissed') NOT NULL DEFAULT 'open',
handled_by INT NULL,
handled_username VARCHAR(32) NULL, -- snapshot, same reason
handled_note VARCHAR(500) NULL,
handled_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
open_marker TINYINT(1) AS (IF(status IN ('open','reviewing'), 1, NULL)) STORED,
CONSTRAINT fk_cr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_cr_reporter FOREIGN KEY (reporter_user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_cr_handler FOREIGN KEY (handled_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_cr_one_open (target_type, target_id, reporter_user_id, open_marker),
INDEX idx_cr_queue (status, created_at),
INDEX idx_cr_team (team_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The §2.9 approval queue. A MODERATOR performing one of the three actions that
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
-- performing one applies it immediately. Rows are kept after a decision — "a
-- moderator asked to publish this name and an admin refused" is the record worth
-- having.
--
-- `action` + `payload` means a fourth gated action is an enum value rather than a
-- schema change. That is room to extend, not an invitation: nothing else is gated
-- today, and nothing should be without asking §2.9's question first.
CREATE TABLE IF NOT EXISTS team_moderation_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
action ENUM('unhide','display_name_override','clear_display_name_override') NOT NULL,
payload JSON NULL, -- e.g. { "displayName": "…" }
reason VARCHAR(255) NULL,
requested_by INT NULL,
requested_username VARCHAR(32) NULL, -- snapshot (§2.10)
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
status ENUM('pending','approved','rejected','withdrawn') NOT NULL DEFAULT 'pending',
decided_by INT NULL,
decided_username VARCHAR(32) NULL,
decided_at DATETIME NULL,
decision_note VARCHAR(255) NULL,
CONSTRAINT fk_tmr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tmr_requested_by FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_tmr_decided_by FOREIGN KEY (decided_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tmr_queue (status, requested_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The per-Team activity feed (TEAMS.md §4.2, phase 3). Two writers, one table:
-- core writes its own membership and rename items with source='core', and a module
-- pushes game items through ctx.teams.activity.push with source=<moduleId>. That
-- core writes 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.
--
-- `summary` is ALREADY-RENDERED text and core never composes one (§4.1). Core
-- cannot phrase "gained 15,000 gold" for a game whose vocabulary it does not know,
-- and a core that templated it would have re-acquired exactly the game semantics
-- the module system exists to remove. `kind` and `payload` are likewise opaque:
-- core stores and filters them, and only the module's `team.overview` slot renders
-- anything richer than the text.
CREATE TABLE IF NOT EXISTS team_activity (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
source VARCHAR(32) NOT NULL, -- 'core' or a module id
kind VARCHAR(64) NOT NULL, -- namespaced <source>.<name>, opaque to core
summary VARCHAR(255) NOT NULL, -- module-rendered; core never composes one
-- Defaults to 'members' — fail closed. The module CHOOSES visibility per item;
-- core ENFORCES it on the read path. Same shape as a module owning the
-- public-safety filter for its push streams (MODULE_API.md §2.4).
visibility ENUM('public','members') NOT NULL DEFAULT 'members',
actor_member_key VARCHAR(191) NULL,
actor_user_id INT NULL,
payload JSON NULL, -- opaque; rendered only by the module's slot
occurred_at DATETIME NOT NULL, -- when it happened in the game, not when it arrived
-- Optional idempotence key. INSERT IGNORE against this unique index is the same
-- trick shard_events already uses, and it is what makes a sidecar reconnect
-- backfill safe: replaying a window of events re-posts nothing.
dedupe_key CHAR(40) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_team_activity_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
-- Actor is SET NULL, not CASCADE (§2.10): deleting an account must not delete the
-- Team's history of what happened, only the attribution.
CONSTRAINT fk_team_activity_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_team_activity_dedupe (team_id, dedupe_key),
INDEX idx_team_activity_feed (team_id, occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Per-Team notification preference (TEAMS.md §6.3/§6.4, phase 6). OPT-OUT, not
-- opt-in: a user in a single Team must never have to configure anything, so the
-- absence of a row is the default and every column here is a deviation from it.
--
-- Team scoping lives HERE and in the recipient computation, never in a stream id.
-- The push catalog is a static registration validated at boot against a namespaced
-- pattern; it cannot express one stream per Team, and stream ids are stored in
-- notification_subscriptions rows that would then need garbage-collecting every
-- time a Team archived. Four fixed streams plus this table is the same feature
-- with nothing to collect.
--
-- `last_digest_at` is the digest's ONLY state. There is no queue of pending items:
-- the worker asks what arrived after this timestamp and re-runs the access
-- resolver, so a deployment that was down for a day sends one correct digest
-- rather than replaying a backlog, and a user who lost forum access between the
-- post and the send is not emailed content they can no longer read.
CREATE TABLE IF NOT EXISTS team_notification_prefs (
user_id INT NOT NULL,
team_id INT NOT NULL,
muted TINYINT(1) NOT NULL DEFAULT 0,
-- 'off', and NOT the design-of-record's 'digest'. Digest-by-default would mean
-- every member of every Team starts receiving daily mail the moment an operator
-- connects Gmail, which is a decision about other people's inboxes made on their
-- behalf. Email is therefore the one sink here that is opt-IN; the mute is still
-- opt-out, because a mute silences something the user already asked for.
--
-- It also keeps this column honest as a deviation-from-default: a row written to
-- set `muted` alone leaves email exactly where it was.
email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off',
last_digest_at DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, team_id),
CONSTRAINT fk_tnp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_tnp_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
-- The digest worker's driving query is "rows in digest mode, oldest send first",
-- which is a scan of this index rather than of every preference ever written.
INDEX idx_tnp_digest (email_mode, last_digest_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── The integration bridge's configuration (TEAMS.md §7.2, phase 8) ─────────
--
-- The SAME events as §6, delivered to a second consumer. Not a second pipeline:
-- `utils/teamNotify.js` computes the recipient set once and hands the event to
-- push, to email and now to this bridge.
--
-- `team_id NULL` is the deployment-wide default and a per-Team row overrides it,
-- which is what §7.2 asks for — but its `PRIMARY KEY (platform, team_id)` cannot
-- express it: MariaDB coerces every PRIMARY KEY column to NOT NULL, so the
-- default row is unrepresentable and the whole override mechanism has no base
-- case. Hence the surrogate key plus a generated `team_key`, the same trick
-- `teams.active_key` and `content_reports.open_marker` use: IFNULL folds the
-- default row onto 0, which no `teams.id` can be, so one default and one row per
-- Team coexist under a single UNIQUE key. It also buys the foreign key the
-- original DDL had no room for — without it, deleting a Team leaves its bridge
-- config behind to be inherited by the next Team that lands on the id.
--
-- **`members_ack` is a precondition, not a preference.** Forum posts and
-- announcements are members-only ALWAYS — there is no public forum thread, and
-- §7.2's gate ("visibility is public, or the channel is configured for a
-- members-only context") has no data source on either side: the streams carry no
-- visibility and core cannot see a Discord channel's permissions. Only the
-- operator can. So enabling a members-only event requires an explicit, attributed
-- acknowledgement that the destination is restricted to that Team, recorded the
-- way `teams_forum_uploads_ack` records the image-policy one. Changing the channel
-- CLEARS it (see the model): an acknowledgement is about a destination, and it
-- cannot survive the destination changing underneath it.
CREATE TABLE IF NOT EXISTS team_integration_config (
id INT AUTO_INCREMENT PRIMARY KEY,
platform VARCHAR(32) NOT NULL, -- 'discord'; opaque here, phase 10 makes it a registry key
team_id INT NULL, -- NULL = the deployment-wide default
events JSON NOT NULL, -- ['team.announcement','team.forum.post']
channel_ref VARCHAR(64) NULL, -- destination on that platform, opaque to core
enabled TINYINT(1) NOT NULL DEFAULT 0,
-- The §7.2 gate, as an operator assertion with a name against it.
members_ack TINYINT(1) NOT NULL DEFAULT 0,
members_ack_by INT NULL,
members_ack_at DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
team_key INT AS (IFNULL(team_id, 0)) STORED,
UNIQUE KEY uq_tic_platform_team (platform, team_key),
CONSTRAINT fk_tic_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
-- SET NULL rather than CASCADE, for the same reason every other snapshot in
-- this file is: deleting the admin's account must not silently un-acknowledge a
-- policy and start withholding messages the deployment is configured to send.
CONSTRAINT fk_tic_ack_by FOREIGN KEY (members_ack_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Per-Team external resources: the voice channel (TEAMS.md §7.3, phase 9) ─
--
-- One row per (Team, platform, resource). Today the only resource is 'voice',
-- and the column exists because the NEXT one — a text channel, a Matrix room —
-- is the same lifecycle with a different noun, and phase 10's capability
-- registry needs somewhere to say which resources a platform declares.
--
-- **Access is a per-Team ROLE, not per-member overwrites.** §7.3 designed
-- overwrites-by-default with escalation to a role above ~90 members; the org lead
-- settled on roles always (2026-08-18). That deletes `voice_overwrite_max` and the
-- mode transition, and it moves the ceiling: the binding limit is no longer ~100
-- overwrites on one channel but Discord's guild-wide cap of 250 roles, which the
-- admin panel surfaces rather than letting a create fail into `state='error'`.
-- `role_ref` is therefore NOT the escalation artefact it was in §7.3 — it is the
-- grant itself, and a row with a channel and no role is a broken row.
--
-- **Two external refs, two lifetimes, and the pair is why this is a table rather
-- than two columns on `teams`.** A channel can be deleted in Discord while the
-- role survives, and vice versa; the reconciler has to be able to say "the role is
-- there, the channel is not" and repair one without touching the other.
--
-- `state` is core's belief about Discord, never Discord's own answer: the
-- reconciler writes what it just did, and the next pass re-derives the truth. A
-- Team dropping below the threshold goes to 'pending_removal' with `remove_after`
-- set rather than being deleted at once (§7.3's grace window) — a Team hovering
-- around the threshold would otherwise delete-and-recreate, changing the channel
-- id and breaking every pinned link to it, and a voice channel holds no message
-- history, so the window costs nothing to keep.
CREATE TABLE IF NOT EXISTS team_integrations (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
platform VARCHAR(32) NOT NULL, -- 'discord'; opaque here, a registry key in phase 10
resource VARCHAR(32) NOT NULL, -- 'voice'
external_ref VARCHAR(64) NULL, -- the channel id
role_ref VARCHAR(64) NULL, -- the Team's own role; the grant itself, not an escalation
state ENUM('none','active','pending_removal','error') NOT NULL DEFAULT 'none',
remove_after DATETIME NULL, -- set with 'pending_removal'; the grace window's expiry
last_error VARCHAR(500) NULL,
synced_at DATETIME NULL, -- last pass that reached Discord and was believed
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_team_integration (team_id, platform, resource),
-- Expiry is swept across every Team, so the index is on the pair the sweep
-- filters by rather than on the Team the unique key already covers.
INDEX idx_ti_pending (state, remove_after),
CONSTRAINT fk_ti_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here.
@@ -875,6 +1516,12 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL;
-- so the system behaves exactly as today until an admin opts in.
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
-- Team forum post edit window, in minutes (TEAMS.md §5.4, phase 5). Seeded rather
-- than left absent so the value an operator sees on the settings screen is the
-- value in force — an empty field that silently behaves as 15 is a field nobody
-- trusts. INSERT IGNORE, so an operator who has already changed it keeps theirs.
INSERT IGNORE INTO settings (`key`, value) VALUES ('teams_forum_edit_window_minutes', '15');
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;
@@ -903,3 +1550,95 @@ ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME
-- trust token. A boolean only — the token is returned over that app→server call
-- and never persisted here (only its sha256 lands in trusted_devices).
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;
-- ── Engagement Phase 1: Gmail OAuth2 removed, SMTP is the baseline ──────────
-- (ENGAGEMENT.md §1.2a). Additive on an upgraded database: `transport` backfills
-- to 'smtp' for every existing row, and `credential_enc` starts NULL — so an
-- upgraded deployment is deliberately CREDENTIAL-LESS until its operator supplies
-- SMTP settings. That is the whole point of the G22 warning below: nothing about
-- this fails loudly, so something has to say it out loud.
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS transport VARCHAR(32) NOT NULL DEFAULT 'smtp';
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS credential_enc TEXT NULL;
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS reply_to VARCHAR(255) NULL;
-- ── Engagement Phase 1b: one account per mailbox ───────────────────────────
-- (ENGAGEMENT.md Phase 1b / §0.6.) ORDER IS LOAD-BEARING and every statement here
-- is idempotent — after the first successful boot each one matches zero rows.
--
-- Why the generated column is added BEFORE the de-duplication rather than after:
-- the de-dupe must group addresses exactly the way the index will, and it cannot
-- do that with LOWER(email) = LOWER(email) in SQL, because that comparison uses
-- the COLUMN's collation, which is accent-insensitive. Grouping on email_norm —
-- the very column the UNIQUE index goes on — makes the two agree by construction
-- instead of by a hand-matched COLLATE clause someone can get wrong later.
-- (Tested: with the LOWER()=LOWER() form, jose@x.com was nulled as a "duplicate"
-- of josé@x.com. They are different mailboxes.)
-- 1. An empty string is a value, not an absence, so two accounts holding '' would
-- collide under the index and stop the boot. Unreachable through the current
-- routes (isEmail() rejects ''), but this runs against databases whose history
-- we do not control.
UPDATE users SET email = NULL WHERE email = '';
-- 2. The pending-address column and the uniqueness key. No index yet — a UNIQUE
-- index here, before step 3, is precisely the ALTER that fails and takes the
-- site down with it (§0.6 finding 1).
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_pending VARCHAR(255) NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_norm VARCHAR(255) COLLATE utf8mb4_bin AS (LOWER(email)) STORED;
-- 3. Record every account about to lose its address, BEFORE nulling it — the
-- report is the only place the lost value survives. Oldest-wins (§7.1 Q1):
-- the earliest-created account keeps the address, ties broken by id so the
-- outcome is deterministic. Verified status deliberately does NOT arbitrate —
-- SSO set email_verified from the mere presence of an address, so it is too
-- weak a signal to decide who keeps a mailbox (§0.6 finding 3).
INSERT IGNORE INTO email_dedupe_report (user_id, username, lost_address)
SELECT l.id, l.username, l.email FROM (
SELECT u.id, u.username, u.email FROM users u
WHERE u.email_norm IS NOT NULL
AND u.id <> (SELECT u2.id FROM users u2
WHERE u2.email_norm = u.email_norm
ORDER BY u2.created_at ASC, u2.id ASC LIMIT 1)
) AS l;
-- 4. Clear the losers. NEVER deletes a row: multiple NULLs are legal under a
-- UNIQUE index, so every account survives with its login intact and simply has
-- no contact address until its owner sets one. The extra derived table is not
-- decoration — MariaDB refuses a subquery on the table being updated (error
-- 1093) without it.
UPDATE users SET email = NULL, email_verified = 0
WHERE id IN (SELECT id FROM (
SELECT u.id FROM users u
WHERE u.email_norm IS NOT NULL
AND u.id <> (SELECT u2.id FROM users u2
WHERE u2.email_norm = u.email_norm
ORDER BY u2.created_at ASC, u2.id ASC LIMIT 1)
) AS losers);
-- 5. Now the table can hold it.
ALTER TABLE users ADD UNIQUE INDEX IF NOT EXISTS uq_users_email_norm (email_norm);
-- 6. The verification gate: may an UNVERIFIED address receive opt-in engagement
-- mail? ON for a fresh install, OFF for an upgrade — the asymmetry is the G22
-- lesson, not an oversight. Turning it on retroactively would silently stop
-- mailing every existing opted-in user on upgrade day, which is exactly the
-- kind of quiet breakage Phase 1 had to write a dashboard warning to undo.
-- "Fresh" is read off the users table: a database with no users has no one to
-- surprise. Both statements are INSERT IGNORE, so an operator who has since
-- changed the value keeps theirs.
INSERT IGNORE INTO settings (`key`, value)
SELECT 'email_verification_required', 'on' FROM DUAL WHERE (SELECT COUNT(*) FROM users) = 0;
INSERT IGNORE INTO settings (`key`, value) VALUES ('email_verification_required', 'off');
-- The status a Gmail-connected deployment carries is 'connected', and after the
-- upgrade that is a lie: nothing can send. Correct it once, narrowly. The WHERE
-- makes this idempotent and self-limiting — it matches only a row that still holds
-- a Gmail refresh token AND has no replacement credential, so re-running it after
-- the operator configures SMTP touches nothing, and it can never overwrite a real
-- status recorded by a later send.
UPDATE email_config
SET status = 'unconfigured',
status_detail = 'Gmail OAuth2 was removed. Configure SMTP credentials in Admin - Settings - Email.'
WHERE refresh_token_enc IS NOT NULL
AND credential_enc IS NULL
AND status <> 'unconfigured';

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.7.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": "/news/five-on-friday-yew-invasion",
"description": "Site-relative path to the post."
}
]
},
{
"id": "team.announcement",
"owner": "core",
"label": "Team — announcement",
"description": "A leader posted an announcement in a Team.",
"kind": "event",
"subjectKey": "teamName",
"audience": "members",
"ceiling": "members",
"version": 1,
"variables": [
{
"name": "teamName",
"type": "string",
"required": true,
"example": "The Silver Anvil",
"description": "The Team the event is about. Also the cooldown subject."
},
{
"name": "authorName",
"type": "string",
"required": true,
"example": "Marisol",
"description": "Display name of the leader who posted."
},
{
"name": "title",
"type": "string",
"required": true,
"example": "Siege practice moved to Sunday",
"description": "The announcement title."
},
{
"name": "excerpt",
"type": "string",
"required": false,
"example": "We are moving practice to Sunday 8pm…",
"description": "Plain-text excerpt of the announcement body."
},
{
"name": "postUrl",
"type": "url",
"required": false,
"example": "/guilds/the-silver-anvil/forum/419",
"description": "Site-relative path to the announcement."
}
]
},
{
"id": "team.forum.post",
"owner": "core",
"label": "Team — new forum post",
"description": "A new thread or reply in a Team forum.",
"kind": "event",
"subjectKey": "teamName",
"audience": "members",
"ceiling": "members",
"version": 1,
"variables": [
{
"name": "teamName",
"type": "string",
"required": true,
"example": "The Silver Anvil",
"description": "The Team the event is about. Also the cooldown subject."
},
{
"name": "authorName",
"type": "string",
"required": true,
"example": "Darrow",
"description": "Display name of the poster."
},
{
"name": "threadTitle",
"type": "string",
"required": true,
"example": "Tuesday champ rotation",
"description": "Title of the thread the post belongs to."
},
{
"name": "excerpt",
"type": "string",
"required": false,
"example": "Moving the Tuesday run an hour later…",
"description": "Plain-text excerpt of the post body, already stripped of markup."
},
{
"name": "postUrl",
"type": "url",
"required": false,
"example": "/guilds/the-silver-anvil/forum/412",
"description": "Site-relative path to the post."
}
]
},
{
"id": "team.leadership.changed",
"owner": "core",
"label": "Team — leadership change",
"description": "Leadership changed in a Team.",
"kind": "event",
"subjectKey": "teamName",
"audience": "members",
"ceiling": "members",
"version": 1,
"variables": [
{
"name": "teamName",
"type": "string",
"required": true,
"example": "The Silver Anvil",
"description": "The Team the event is about. Also the cooldown subject."
},
{
"name": "leaderName",
"type": "string",
"required": true,
"example": "Marisol",
"description": "Display name of the new leader."
},
{
"name": "teamUrl",
"type": "url",
"required": false,
"example": "/guilds/the-silver-anvil",
"description": "Site-relative path to the Team page."
}
]
},
{
"id": "team.member.joined",
"owner": "core",
"label": "Team — new member",
"description": "Someone joined a Team.",
"kind": "event",
"subjectKey": "teamName",
"audience": "members",
"ceiling": "members",
"version": 1,
"variables": [
{
"name": "teamName",
"type": "string",
"required": true,
"example": "The Silver Anvil",
"description": "The Team the event is about. Also the cooldown subject."
},
{
"name": "memberName",
"type": "string",
"required": true,
"example": "Darrow",
"description": "Display name of the member who joined."
},
{
"name": "teamUrl",
"type": "url",
"required": false,
"example": "/guilds/the-silver-anvil",
"description": "Site-relative path to the Team page. Absent when no module supplies a pageUrlTemplate."
}
]
}
]
}

View File

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

View File

@@ -27,66 +27,6 @@
"handlers": 1,
"gates": []
},
{
"method": "GET",
"path": "/api/v1/admin/account",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/account/identities",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/account/identities/:provider",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/disable",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/enable",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/setup",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/activity",
@@ -199,7 +139,7 @@
{
"method": "PUT",
"path": "/api/v1/admin/email/config",
"handlers": 5,
"handlers": 9,
"gates": [
"noindex",
"requireAuth",
@@ -207,24 +147,6 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/email/connect/callback",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/email/connect/start",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/email/disconnect",
@@ -245,6 +167,24 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/audiences",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/triggers",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/invites",
@@ -345,6 +285,26 @@
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/reports",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/reports/:id/handle",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/search",
@@ -730,6 +690,247 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/archive",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/display-name",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id/forum/moderation",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id/grants",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/hide",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/leader-override",
"handlers": 6,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/:id/leader-override/:memberKey",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/unhide",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/forum/settings",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/forum/uploads",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/integrations",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/teams/integrations",
"handlers": 8,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/integrations/:teamId",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/requests",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/requests/:id/decide",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/resync",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/review",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/voice",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/teams/voice",
"handlers": 7,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/voice/:teamId",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/voice/sync",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/uploads",
@@ -837,6 +1038,24 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/users/email-dedupe-report",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/users/email-dedupe-report/acknowledge",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/wiki",
@@ -979,6 +1198,24 @@
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/auth/email/verify/:token",
"handlers": 3,
"gates": [
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/auth/email/verify/:token",
"handlers": 4,
"gates": [
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/invite/:token",
@@ -1043,6 +1280,35 @@
"requireAuth"
]
},
{
"method": "PATCH",
"path": "/api/v1/auth/me/account/email",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/account/email/pending",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/auth/me/account/email/resend",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/account/identities",
@@ -1197,6 +1463,26 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/teams",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/auth/me/notifications/teams",
"handlers": 6,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/sessions",
@@ -1377,88 +1663,6 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/player/account",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/player/account/identities",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/player/account/identities/:provider",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "PATCH",
"path": "/api/v1/player/account/password",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/disable",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/enable",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/setup",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PATCH",
"path": "/api/v1/player/account/username",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/player/appeals",
@@ -1499,6 +1703,162 @@
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/player/teams",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/access",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PATCH",
"path": "/api/v1/player/teams/:slug/forum/posts/:id",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/posts/:id/moderate",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/report",
"handlers": 7,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/forum/threads",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads",
"handlers": 7,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/forum/threads/:id",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads/:id/posts",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/uploads",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"multerMiddleware"
]
},
{
"method": "DELETE",
"path": "/api/v1/player/teams/:slug/forum/uploads/:id",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/grants",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/grants",
"handlers": 6,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/player/teams/:slug/grants/:userId",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/public/contact",
@@ -1556,6 +1916,60 @@
"handlers": 1,
"gates": []
},
{
"method": "GET",
"path": "/api/v1/public/teams",
"handlers": 2,
"gates": [
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug",
"handlers": 2,
"gates": [
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/activity",
"handlers": 3,
"gates": [
"siteMode",
"optionalAuth"
]
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/members",
"handlers": 3,
"gates": [
"siteMode",
"optionalAuth"
]
},
{
"method": "GET",
"path": "/api/v1/public/teams/by-external/:moduleId/:externalId",
"handlers": 2,
"gates": [
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/public/teams/unsubscribe/:token",
"handlers": 1,
"gates": []
},
{
"method": "POST",
"path": "/api/v1/public/teams/unsubscribe/:token",
"handlers": 1,
"gates": []
},
{
"method": "GET",
"path": "/api/v1/public/version",
@@ -1627,6 +2041,22 @@
"gates": [
"requireInternalKey"
]
},
{
"method": "GET",
"path": "/internal/commands",
"handlers": 1,
"gates": [
"requireInternalKey"
]
},
{
"method": "POST",
"path": "/internal/commands/dispatch",
"handlers": 1,
"gates": [
"requireInternalKey"
]
}
]
}

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,14 @@
"method": "POST",
"path": "/api/v1/admin/email/test"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/audiences"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/triggers"
},
{
"method": "GET",
"path": "/api/v1/admin/invites"
@@ -145,6 +121,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 +277,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 +409,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 +473,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 +509,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"
@@ -477,6 +581,14 @@
"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/sessions"
@@ -557,38 +669,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,6 +685,66 @@
"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"
@@ -637,6 +777,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 +842,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

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

@@ -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,147 @@
// ── 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.' },
{ name: 'postUrl', type: 'url', required: true, example: '/news/five-on-friday-yew-invasion',
description: 'Site-relative path to the post.' },
],
},
// ── 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,22 @@
// ── The engagement subsystem — one door ────────────────────────────────────
//
// ENGAGEMENT.md Phase 1. Today this is the mail transport registry and core's
// own transports; the trigger registry, the rules engine and the delivery
// channels arrive in later phases and hang here too.
//
// **Core's transports register through the same door a module's would**, and
// they register HERE rather than at the bottom of the registry file. That keeps
// the registry free of any knowledge of its registrants — the same reason
// `registerCore()` is called from app.js rather than from inside
// `modules/registries.js` (MODULE_API.md §7.6) — and it means requiring the
// registry never has the side effect of populating it.
//
// Requiring this module is what makes `smtp` available. Everything that resolves
// a transport goes through here, so there is exactly one place a transport can
// come into existence.
require('./transports/smtp')
const transports = require('./transports')
module.exports = { transports }

View File

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

View File

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

View File

@@ -118,6 +118,19 @@ const passwordResetConfirmLimiter = makeLimiter({
message: 'Too many attempts. Please try again later.',
})
// Email-verification confirmations (engagement Phase 1b). Same reasoning as the
// password-reset confirm limiter: the token is 256-bit random, but an
// unauthenticated token-bearing endpoint should not be free to hammer. The
// REQUEST side is authenticated and limited separately — accountChangeLimiter per
// IP, plus a per-user ceiling in the model, because the mail goes to an address
// its recipient did not ask to hear from.
const emailVerifyConfirmLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 15,
label: 'email-verify-confirm',
message: 'Too many attempts. Please try again later.',
})
// CSP violation reports. Unauthenticated by necessity (browsers send them with no
// session), and every accepted report writes a log line — so an attacker who can get
// a victim to load a page could otherwise use it as a log-flood amplifier. Generous
@@ -149,5 +162,6 @@ module.exports = {
mobileSsoExchangeLimiter,
passwordResetRequestLimiter,
passwordResetConfirmLimiter,
emailVerifyConfirmLimiter,
cspReportLimiter,
}

View File

@@ -1,7 +1,8 @@
const singletonConfigDb = require('../singletonConfigDb')
const COLS =
'id, provider, enabled, sender_email, sender_name, refresh_token_enc, status, status_detail, last_verified_at, updated_by, created_at, updated_at'
'id, provider, transport, enabled, sender_email, sender_name, reply_to, credential_enc, refresh_token_enc, ' +
'status, status_detail, last_verified_at, updated_by, created_at, updated_at'
// Singleton row (id = 1). See ../singletonConfigDb for the get/upsert contract.
module.exports = singletonConfigDb('email_config', COLS)

View File

@@ -1,30 +1,69 @@
// Outbound email config store (Gmail OAuth2). Mirrors the botConfig model split:
// the DB layer only ever sees ciphertext, and only getWithSecret() (used by the
// mailer at send time) decrypts the refresh token. The admin-facing getSafe()
// never includes it — callers see only `hasRefreshToken`.
// Outbound email config store. Mirrors the botConfig model split: the DB layer
// only ever sees ciphertext, and only getWithSecret() (used by the mailer at send
// time) decrypts the credential. The admin-facing getSafe() never includes it —
// callers see the non-secret fields plus which secrets are set.
//
// The credential is ONE encrypted JSON blob, not a column per field, because the
// field list belongs to the transport (ENGAGEMENT.md §3.1). `credential_enc`
// holds `{ host, port, secure, user, password }` for `smtp`; a future relay's
// blob would hold different keys against the same column.
//
// `provider` and `refresh_token_enc` are the removed Gmail OAuth2 connection
// (§1.2a). They are no longer read as configuration — `hadLegacyConnection`
// exposes the token column's presence for one purpose only: telling an upgraded
// deployment that its mail just stopped.
const db = require('./emailConfig.db')
const secretBox = require('../../utils/secretBox')
const { transports } = require('../../engagement')
function toSafe(row) {
const DEFAULT_TRANSPORT = 'smtp'
// A stored blob that will not parse is treated as ABSENT, never as an error —
// the same fail-safe rule utils/settingsJson.js applies. A deployment whose
// SECRET_ENC_KEY was rotated must degrade to "unconfigured" and say so on the
// admin screen, not 500 the settings page and the contact form with it.
function readCredential(row) {
if (!row || !row.credential_enc) return null
try {
const parsed = JSON.parse(secretBox.decrypt(row.credential_enc))
return parsed && typeof parsed === 'object' ? parsed : null
} catch {
return null
}
}
function toSafe(row, credential) {
const transport = (row && row.transport) || DEFAULT_TRANSPORT
if (!row) {
return {
provider: 'gmail_oauth2',
transport: DEFAULT_TRANSPORT,
enabled: false,
senderEmail: null,
senderName: null,
hasRefreshToken: false,
replyTo: null,
credential: {},
secretsSet: transports.secretsPresent(DEFAULT_TRANSPORT, null),
hasCredential: false,
hadLegacyConnection: false,
status: 'unconfigured',
statusDetail: null,
lastVerifiedAt: null,
}
}
return {
provider: row.provider || 'gmail_oauth2',
transport,
enabled: Boolean(row.enabled),
senderEmail: row.sender_email || null,
senderName: row.sender_name || null,
hasRefreshToken: Boolean(row.refresh_token_enc),
replyTo: row.reply_to || null,
credential: transports.publicCredential(transport, credential),
secretsSet: transports.secretsPresent(transport, credential),
hasCredential: transports.isComplete(transport, credential),
// Deliberately the raw column, not "is Gmail configured": nothing reads the
// token any more. It answers "did this deployment have working mail before
// the upgrade?", which is the G22 warning's whole condition.
hadLegacyConnection: Boolean(row.refresh_token_enc),
status: row.status || 'unconfigured',
statusDetail: row.status_detail || null,
lastVerifiedAt: row.last_verified_at || null,
@@ -32,38 +71,53 @@ function toSafe(row) {
}
async function getSafe() {
return toSafe(await db.get())
const row = await db.get()
return toSafe(row, readCredential(row))
}
// Decrypted refresh token included — server-side only (building the mailer's
// OAuth2 transport). Returns null when no row exists yet.
// Decrypted credential included — server-side only (building the transport at
// send time). Returns null when no row exists yet.
async function getWithSecret() {
const row = await db.get()
if (!row) return null
return {
...toSafe(row),
refreshToken: row.refresh_token_enc ? secretBox.decrypt(row.refresh_token_enc) : null,
}
const credential = readCredential(row)
return { ...toSafe(row, credential), credentialSecret: credential || {} }
}
// Save admin-supplied / connect-flow config. `refreshToken` undefined or '' means
// "leave the existing token unchanged" (same convention as botConfig.save).
async function save({ senderEmail, senderName, refreshToken, enabled, status, statusDetail, updatedBy }) {
// Save admin-supplied config. `credential` is a PATCH, merged over the stored
// blob: a secret field submitted empty is omitted by the registry's sanitizer and
// therefore keeps its stored value (same convention as botConfig.save).
async function save({ transport, senderEmail, senderName, replyTo, credential, enabled, status, statusDetail, updatedBy }) {
const row = await db.get()
const fields = {}
const nextTransport = transport !== undefined ? transport : (row && row.transport) || DEFAULT_TRANSPORT
if (transport !== undefined) fields.transport = transport
if (senderEmail !== undefined) fields.sender_email = senderEmail
if (senderName !== undefined) fields.sender_name = senderName
if (refreshToken) fields.refresh_token_enc = secretBox.encrypt(refreshToken)
if (replyTo !== undefined) fields.reply_to = replyTo
if (credential !== undefined) {
// Changing transport starts from an empty credential rather than merging one
// transport's fields into another's — an SMTP password left inside a relay's
// blob is a stored secret nobody can see and nothing will ever use.
const base = row && row.transport === nextTransport ? readCredential(row) : null
const merged = transports.mergeCredential(nextTransport, base, transports.sanitizeCredential(nextTransport, credential))
fields.credential_enc = Object.keys(merged).length ? secretBox.encrypt(JSON.stringify(merged)) : null
}
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
if (status !== undefined) fields.status = status
if (statusDetail !== undefined) fields.status_detail = statusDetail
if (updatedBy !== undefined) fields.updated_by = updatedBy
const row = await db.upsert(fields)
return toSafe(row)
const saved = await db.upsert(fields)
return toSafe(saved, readCredential(saved))
}
// Clear the stored credential and disable sending (admin "Disconnect").
// Clear the stored credential and disable sending (admin "Clear credentials").
// The legacy Gmail token goes too: this is the operator saying "there is no
// mailbox here", and leaving the deprecated column set would keep the G22 warning
// on screen for a deployment that has deliberately turned mail off.
async function disconnect(updatedBy) {
const row = await db.upsert({
credential_enc: null,
refresh_token_enc: null,
sender_email: null,
enabled: 0,
@@ -72,7 +126,7 @@ async function disconnect(updatedBy) {
last_verified_at: null,
updated_by: updatedBy ?? null,
})
return toSafe(row)
return toSafe(row, readCredential(row))
}
// Record the outcome of the last send / verification so the admin panel has
@@ -86,7 +140,7 @@ async function recordStatus({ status, statusDetail, lastVerifiedAt } = {}) {
}
if (Object.keys(fields).length === 0) return getSafe()
const row = await db.upsert(fields)
return toSafe(row)
return toSafe(row, readCredential(row))
}
module.exports = { getSafe, getWithSecret, save, disconnect, recordStatus }
module.exports = { getSafe, getWithSecret, save, disconnect, recordStatus, DEFAULT_TRANSPORT }

View File

@@ -0,0 +1,22 @@
const { query } = require('../../utils/db')
const COLS = 'id, user_id, username, lost_address, cleared_at, acknowledged_at'
// Accounts cleared by the Phase 1b de-duplication, newest first.
async function list() {
return query(`SELECT ${COLS} FROM email_dedupe_report ORDER BY cleared_at DESC, id DESC`)
}
async function countUnacknowledged() {
const rows = await query('SELECT COUNT(*) AS n FROM email_dedupe_report WHERE acknowledged_at IS NULL')
return Number(rows[0] ? rows[0].n : 0)
}
// Dismiss the whole report. Idempotent — an already-acknowledged row is skipped
// so a second dismissal cannot rewrite when it happened.
async function acknowledgeAll() {
const res = await query('UPDATE email_dedupe_report SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL')
return res.affectedRows || 0
}
module.exports = { list, countUnacknowledged, acknowledgeAll }

View File

@@ -0,0 +1,18 @@
// The Phase 1b de-duplication report: who lost an email address when the UNIQUE
// index went on, and what they lost.
//
// The rows are written by schema.sql's migration in pure SQL — ensureSchema()
// executes that file statement-by-statement and there is no JS migration hook —
// so this model only ever READS and acknowledges. Nothing here creates a row.
//
// It matters because these accounts are exactly the ones an operator must
// contact: each can still log in, but has no contact address, so password-reset
// and engagement mail have nowhere to go until its owner sets a new one.
const db = require('./emailDedupe.db')
const list = () => db.list()
const countUnacknowledged = () => db.countUnacknowledged()
const acknowledgeAll = () => db.acknowledgeAll()
module.exports = { list, countUnacknowledged, acknowledgeAll }

View File

@@ -0,0 +1,52 @@
const { query } = require('../../utils/db')
const COLS = 'id, token_hash, user_id, email, status, requested_ip, expires_at, created_at, used_at'
async function insert({ tokenHash, userId, email, requestedIp, expiresAt }) {
const res = await query(
`INSERT INTO email_verifications (token_hash, user_id, email, requested_ip, expires_at)
VALUES (?, ?, ?, ?, ?)`,
[tokenHash, userId, email, requestedIp ?? null, expiresAt],
)
return res.insertId
}
async function findByTokenHash(tokenHash) {
const rows = await query(`SELECT ${COLS} FROM email_verifications WHERE token_hash = ? LIMIT 1`, [tokenHash])
return rows[0] || null
}
// Mark used only if still pending (atomic guard against a double-use race).
// Returns rows changed (1 = we won, 0 = already used).
async function markUsed(id) {
const res = await query(
`UPDATE email_verifications SET status = 'used', used_at = NOW()
WHERE id = ? AND status = 'pending'`,
[id],
)
return res.affectedRows || 0
}
// Retire every still-pending verification for a user. Called when a fresh request
// supersedes older links and after a successful verification, so an address the
// user changed their mind about can never be installed by an old email.
async function invalidatePendingForUser(userId) {
const res = await query(
`UPDATE email_verifications SET status = 'used', used_at = NOW()
WHERE user_id = ? AND status = 'pending'`,
[userId],
)
return res.affectedRows || 0
}
// How many verification mails this user has asked for since `since`. Backs the
// per-user resend ceiling, which the IP rate limiter cannot provide on its own.
async function countRecentForUser(userId, since) {
const rows = await query(
'SELECT COUNT(*) AS n FROM email_verifications WHERE user_id = ? AND created_at >= ?',
[userId, since],
)
return Number(rows[0] ? rows[0].n : 0)
}
module.exports = { insert, findByTokenHash, markUsed, invalidatePendingForUser, countRecentForUser }

View File

@@ -0,0 +1,76 @@
// Self-service email verification (engagement Phase 1b). A user asks to set or
// change their address; a tokened link goes to the address they typed, and only
// opening that link installs it. The opaque token lives only in the emailed link —
// the DB stores its sha256 — so a DB read never yields a usable link. Same shape
// as password_resets and user_invites, deliberately: the design of record calls
// this link "signed", but every comparable flow here uses a hashed random token,
// and matching them beats adding a second token mechanism for one caller.
//
// The address is stored ON THE ROW rather than read from the user at confirm
// time, because a token proves control of the address it was mailed to and
// nothing else.
const crypto = require('crypto')
const db = require('./emailVerifications.db')
// A day, not an hour. Unlike a password reset this is not a live credential-reset
// capability — the worst a leaked token does is attach an address its holder
// already controls — and a verification mail is routinely opened on another
// device, hours later.
const DEFAULT_TTL_MINUTES = 24 * 60
// Per-user ceiling on verification sends, independent of the per-IP limiter: the
// mail goes to an address the RECIPIENT did not choose to hear from, so an
// attacker with one account must not be able to use it to pester a mailbox.
const MAX_SENDS_PER_WINDOW = 5
const SEND_WINDOW_MINUTES = 60
function hashToken(raw) {
return crypto.createHash('sha256').update(String(raw)).digest('hex')
}
// Create a verification for one user + address. Returns { id, token } — the
// plaintext token is returned ONCE, for the link, and is never recoverable after.
async function create({ userId, email, requestedIp, ttlMinutes = DEFAULT_TTL_MINUTES }) {
const token = crypto.randomBytes(32).toString('base64url')
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000)
const id = await db.insert({ tokenHash: hashToken(token), userId, email, requestedIp, expiresAt })
return { id, token }
}
// Resolve a pending, unexpired verification from its plaintext token, else null.
// Returns the RAW row (incl. user_id and the address it proves).
async function findValidByToken(token) {
if (!token) return null
const row = await db.findByTokenHash(hashToken(token))
if (!row || row.status !== 'pending') return null
if (new Date(row.expires_at).getTime() < Date.now()) return null
return row
}
// Atomically consume a pending verification (double-use-safe). True if this call
// won the race.
async function consume(id) {
return (await db.markUsed(id)) === 1
}
const invalidatePendingForUser = (userId) => db.invalidatePendingForUser(userId)
// True when this user has already asked for as many verification mails as the
// window allows.
async function sendQuotaExhausted(userId) {
const since = new Date(Date.now() - SEND_WINDOW_MINUTES * 60 * 1000)
return (await db.countRecentForUser(userId, since)) >= MAX_SENDS_PER_WINDOW
}
module.exports = {
create,
findValidByToken,
consume,
invalidatePendingForUser,
sendQuotaExhausted,
hashToken,
DEFAULT_TTL_MINUTES,
MAX_SENDS_PER_WINDOW,
SEND_WINDOW_MINUTES,
}

View File

@@ -48,4 +48,44 @@ const endpointsForUserStream = (userId, streamId) =>
[userId, streamId],
)
module.exports = { upsert, getByUserEndpoint, listByUser, remove, endpointsForStream, endpointsForUserStream }
// `Number.isInteger` alone is not enough: `Number(null)` is 0 and 0 is an
// integer, so a null slipping into a caller's list would become user id 0 and
// ride into an IN clause. No row has id 0, so it is harmless today — which is
// exactly why it would never be noticed.
const isUserId = (n) => Number.isInteger(n) && n > 0
// Endpoints of a COMPUTED SET of users' devices, each still gated on that user's
// own subscription (TEAMS.md §6.2's third fan-out shape).
//
// The set is the whole Team-scoping mechanism: the four `team.*` streams are
// global, and which Team an event belongs to is expressed by who is in `userIds`
// rather than by a stream id per Team. The caller has already resolved access and
// subtracted mutes; this function's only remaining job is to honour each
// recipient's own opt-in, which is why the JOIN is here and not left to the
// caller — a fan-out that skipped it would deliver to a user who had turned the
// stream off.
//
// Returns [] for an empty set rather than building `IN ()`, which is a syntax
// error in MariaDB. That case is common, not exceptional: most Team events have
// no subscribed recipients on a deployment with no app installed at all.
async function endpointsForUsersStream(userIds, streamId) {
const ids = [...new Set((userIds || []).map(Number).filter(isUserId))]
if (ids.length === 0) return []
return query(
`SELECT d.endpoint, d.transport
FROM push_devices d
JOIN notification_subscriptions s ON s.user_id = d.user_id
WHERE s.stream_id = ? AND d.user_id IN (${ids.map(() => '?').join(',')})`,
[streamId, ...ids],
)
}
module.exports = {
upsert,
getByUserEndpoint,
listByUser,
remove,
endpointsForStream,
endpointsForUserStream,
endpointsForUsersStream,
}

View File

@@ -28,5 +28,13 @@ const remove = async (id, userId) => (await db.remove(id, userId)) > 0
// Fan-out helpers: raw { endpoint, transport } rows (not toSafe-shaped).
const endpointsForStream = (streamId) => db.endpointsForStream(streamId)
const endpointsForUserStream = (userId, streamId) => db.endpointsForUserStream(userId, streamId)
const endpointsForUsersStream = (userIds, streamId) => db.endpointsForUsersStream(userIds, streamId)
module.exports = { register, listForUser, remove, endpointsForStream, endpointsForUserStream }
module.exports = {
register,
listForUser,
remove,
endpointsForStream,
endpointsForUserStream,
endpointsForUsersStream,
}

View File

@@ -0,0 +1,154 @@
// SQL for `content_reports` (TEAMS.md §5.6).
//
// Not under model/teams/ even though Team forum content is its only consumer
// today: the table is deliberately generic — `target_type` is a VARCHAR so that a
// wiki page or a news comment becomes a new value rather than a new table — and
// filing it under a feature it will outgrow is how the next consumer ends up
// building its own.
//
// Nothing here decides who may read a report. That is the route's job, and there
// is exactly one answer: site staff (§5.6, and the org lead's 2026-08-18 ruling
// that reports are site administration only).
const { query } = require('../../utils/db')
const COLUMNS = `
id, target_type, target_id, team_id, reporter_user_id, reporter_username,
reason, detail, status, handled_by, handled_username, handled_note, handled_at,
created_at`
const OPEN_STATUSES = ['open', 'reviewing']
/**
* File a report.
*
* The duplicate is caught by the unique key rather than by a SELECT first, which
* is the difference between "usually not a duplicate" and "never a duplicate":
* two taps of a report button race, and only the index settles it. ER_DUP_ENTRY
* comes back as a clean `null` so the caller can answer 409 without knowing what
* a MySQL error code looks like.
*/
async function insert({ targetType, targetId, teamId, reporterUserId, reporterUsername, reason, detail }) {
try {
const res = await query(
`INSERT INTO content_reports
(target_type, target_id, team_id, reporter_user_id, reporter_username, reason, detail)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[targetType, targetId, teamId ?? null, reporterUserId, reporterUsername, reason, detail ?? null],
)
return res.insertId
} catch (err) {
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) return null
throw err
}
}
async function byId(id) {
const rows = await query(`SELECT ${COLUMNS} FROM content_reports WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
/**
* The queue.
*
* `status` defaults to the two OPEN statuses rather than to everything: a staffer
* opening the queue wants the work, not the archive. 'all' is the explicit escape
* hatch and every single status is selectable, so nothing is unreachable.
*/
async function list({ status, teamId, limit = 100, offset = 0 } = {}) {
const where = []
const args = []
if (status && status !== 'all') {
where.push('status = ?')
args.push(status)
} else if (!status) {
where.push(`status IN (${OPEN_STATUSES.map(() => '?').join(',')})`)
args.push(...OPEN_STATUSES)
}
if (teamId) {
where.push('team_id = ?')
args.push(teamId)
}
args.push(limit, offset)
return query(
`SELECT ${COLUMNS} FROM content_reports
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
args,
)
}
/** How many are waiting, for the dashboard badge. */
async function openCount() {
const rows = await query(
`SELECT COUNT(*) AS n FROM content_reports WHERE status IN (${OPEN_STATUSES.map(() => '?').join(',')})`,
OPEN_STATUSES,
)
return Number(rows[0]?.n || 0)
}
/**
* Record a staffer's decision.
*
* `handled_*` is stamped for every status including `reviewing`, so "who has this"
* is answerable while it is in progress and not only after it is closed — that is
* what stops two staffers working the same report.
*/
async function handle(id, { status, handledBy, handledUsername, note }) {
const res = await query(
`UPDATE content_reports
SET status = ?, handled_by = ?, handled_username = ?, handled_note = ?, handled_at = NOW()
WHERE id = ?`,
[status, handledBy, handledUsername, note ?? null, id],
)
return res.affectedRows > 0
}
// ── target enrichment ──────────────────────────────────────────────────────
//
// Three batched reads rather than one per row. §5.6's fourth rule — "reports on
// uploads carry the team_forum_uploads row, so a staffer sees uploader, size and
// sniffed type without hunting" — is the reason the queue enriches at all, and a
// queue that N+1s to do it would be the version that gets turned off.
async function threadsByIds(ids) {
if (!ids.length) return []
return query(
`SELECT id, team_id, title, type, status, created_username FROM team_forum_threads
WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)
}
async function postsByIds(ids) {
if (!ids.length) return []
return query(
`SELECT p.id, p.thread_id, p.author_user_id, p.author_username, p.body_html, p.status,
p.created_at, t.team_id, t.title AS thread_title
FROM team_forum_posts p JOIN team_forum_threads t ON t.id = p.thread_id
WHERE p.id IN (${ids.map(() => '?').join(',')})`,
ids,
)
}
async function uploadsByIds(ids) {
if (!ids.length) return []
return query(
`SELECT id, team_id, post_id, uploader_user_id, uploader_username, filename,
mimetype, byte_size, created_at, deleted_at
FROM team_forum_uploads WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)
}
module.exports = {
OPEN_STATUSES,
insert,
byId,
list,
openCount,
handle,
threadsByIds,
postsByIds,
uploadsByIds,
}

View File

@@ -0,0 +1,243 @@
// ── Abuse reports: the missing half of moderation (TEAMS.md §5.6) ──────────
//
// Two rules shape everything in this file, and both are easier to break than to
// notice broken:
//
// 1. **A report is not a moderation action.** Filing one changes nothing about
// the content — it opens a queue item. That keeps it clear of §5.3's
// leader/staff moderation ledger, which records things that actually
// happened. If reporting hid a post, reporting would BE moderation, and the
// first person to work that out would have found a way to hide anything.
//
// 2. **Reports go to site staff and to nobody else.** 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. The org lead settled this on 2026-08-18 — reports are **site
// administration only**, with no leader-facing view at all, not even a
// read-only one scoped to their own Team.
//
// The reporter's ACCESS is the caller's business, not this file's: the player
// route resolves the forum first, so anyone reaching `file()` is someone who can
// already see the thing they are reporting. What this file does check is that the
// target is really in the Team the caller reached it through — otherwise a
// participant in one Team could file reports carrying another Team's id, and the
// queue's per-Team filter would quietly be lying.
const reportsDb = require('./contentReports.db')
const forumDb = require('../teams/teamForum.db')
const TARGET_TYPES = ['team_forum_thread', 'team_forum_post', 'team_forum_upload']
const REASONS = ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other']
const STATUSES = ['open', 'reviewing', 'actioned', 'dismissed']
// A body excerpt for the queue, not a rendered post. Staff triage on what was
// written, and `body_html` is stored already sanitised — but the queue is a list,
// so it gets text and a length cap rather than markup.
const EXCERPT_CHARS = 300
const excerpt = (html) => String(html || '')
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, EXCERPT_CHARS)
/**
* Does this target exist, and is it in this Team?
*
* Returns the team id the target really belongs to, or null. The caller compares
* it with the Team the request came through — a mismatch is a 404 for the same
* §5.5.1 reason a foreign thread id is: confirming a target exists somewhere else
* on the site is itself a disclosure.
*/
async function targetTeamId(targetType, targetId) {
if (targetType === 'team_forum_thread') {
const thread = await forumDb.threadById(targetId)
return thread ? thread.team_id : null
}
if (targetType === 'team_forum_post') {
const post = await forumDb.postById(targetId)
if (!post) return null
const thread = await forumDb.threadById(post.thread_id)
return thread ? thread.team_id : null
}
if (targetType === 'team_forum_upload') {
const upload = await forumDb.uploadById(targetId)
return upload ? upload.team_id : null
}
return null
}
/**
* File a report.
*
* A duplicate answers 409 rather than pretending to succeed. Silently accepting
* it would be friendlier for one tap and dishonest for the second: a member who
* reports twice because nothing seemed to happen deserves to be told the first
* one is already in the queue.
*/
async function file({ team, actor, targetType, targetId, reason, detail }) {
if (!TARGET_TYPES.includes(targetType)) {
return { ok: false, status: 400, error: 'Unknown report target' }
}
if (!REASONS.includes(reason)) {
return { ok: false, status: 400, error: 'Unknown report reason' }
}
const owner = await targetTeamId(targetType, targetId)
if (owner == null || owner !== team.id) {
return { ok: false, status: 404, error: 'Not found' }
}
const id = await reportsDb.insert({
targetType,
targetId,
teamId: team.id,
reporterUserId: actor.id,
reporterUsername: actor.username,
reason,
detail,
})
if (id == null) {
return { ok: false, status: 409, error: 'You have already reported this. Staff are looking at it.' }
}
return { ok: true, reportId: id }
}
/**
* The staff queue, with each row's target attached.
*
* Enrichment is three batched reads keyed by target type, not one read per row.
* The alternative N+1s a page of a hundred into three hundred queries, which is
* how a queue becomes a thing staff avoid opening.
*
* A target that has since been hard-deleted comes back as `null`, and the report
* still lists. That is deliberate: "somebody reported this and by the time we
* looked it was gone" is a fact a moderator needs, and dropping the row would
* hide the pattern of a member deleting their own content the moment it is
* reported.
*/
async function queue({ status, teamId, limit, offset } = {}) {
const rows = await reportsDb.list({ status, teamId, limit, offset })
if (!rows.length) return []
const idsOf = (type) => rows.filter((r) => r.target_type === type).map((r) => Number(r.target_id))
const [threads, posts, uploads] = await Promise.all([
reportsDb.threadsByIds([...new Set(idsOf('team_forum_thread'))]),
reportsDb.postsByIds([...new Set(idsOf('team_forum_post'))]),
reportsDb.uploadsByIds([...new Set(idsOf('team_forum_upload'))]),
])
const byId = (list) => new Map(list.map((row) => [Number(row.id), row]))
const threadMap = byId(threads)
const postMap = byId(posts)
const uploadMap = byId(uploads)
return rows.map((r) => ({ ...publicReport(r), target: describeTarget(r, { threadMap, postMap, uploadMap }) }))
}
/**
* The reported content, resolved.
*
* **Every miss returns `null`, never `undefined`.** They look interchangeable in
* JavaScript and are not in JSON: `undefined` is dropped by `JSON.stringify`, so
* a hard-deleted target would reach the client as an ABSENT `target` key rather
* than as an explicit null, and the queue's own contract says nullable. A client
* distinguishing "gone" from "not resolved yet" would get it wrong.
*/
function describeTarget(report, { threadMap, postMap, uploadMap }) {
const id = Number(report.target_id)
if (report.target_type === 'team_forum_thread') {
const t = threadMap.get(id)
if (!t) return null
return {
kind: 'thread',
threadId: t.id,
title: t.title,
type: t.type,
status: t.status,
author: t.created_username,
}
}
if (report.target_type === 'team_forum_post') {
const p = postMap.get(id)
if (!p) return null
return {
kind: 'post',
postId: p.id,
threadId: p.thread_id,
threadTitle: p.thread_title,
author: p.author_username,
status: p.status,
excerpt: excerpt(p.body_html),
createdAt: p.created_at,
}
}
if (report.target_type === 'team_forum_upload') {
const u = uploadMap.get(id)
if (!u) return null
// §5.6's fourth rule: uploader, size and the SNIFFED type, without hunting.
// This is the payoff for §5.5.4's attribution table being load-bearing rather
// than bookkeeping.
return {
kind: 'upload',
uploadId: u.id,
postId: u.post_id,
uploader: u.uploader_username,
filename: u.filename,
url: `/uploads/${u.filename}`,
mimetype: u.mimetype,
byteSize: u.byte_size,
createdAt: u.created_at,
deleted: u.deleted_at != null,
}
}
return null
}
function publicReport(row) {
return {
id: row.id,
targetType: row.target_type,
targetId: Number(row.target_id),
teamId: row.team_id,
reporter: row.reporter_username || '[deleted account]',
reporterDeleted: row.reporter_user_id == null,
reason: row.reason,
detail: row.detail,
status: row.status,
handledBy: row.handled_username,
handledNote: row.handled_note,
handledAt: row.handled_at,
createdAt: row.created_at,
}
}
/** Move a report along the queue. Staff-only by its route. */
async function handle({ id, actor, status, note }) {
if (!STATUSES.includes(status)) {
return { ok: false, status: 400, error: 'Unknown report status' }
}
const report = await reportsDb.byId(id)
if (!report) return { ok: false, status: 404, error: 'Report not found' }
await reportsDb.handle(id, {
status,
handledBy: actor.id,
handledUsername: actor.username,
note,
})
return { ok: true, report: publicReport(await reportsDb.byId(id)) }
}
module.exports = {
TARGET_TYPES,
REASONS,
STATUSES,
EXCERPT_CHARS,
file,
queue,
handle,
openCount: reportsDb.openCount,
publicReport,
targetTeamId,
}

View File

@@ -17,6 +17,20 @@ async function set(key, value, updatedBy = null) {
)
}
// One row WITH its provenance. `updated_by`/`updated_at` are already stored for
// every key; this is the only reader that needs them, because TEAMS.md §5.5.5
// makes the uploads acknowledgement a RECORDED consent rather than a displayed
// one, and "which admin accepted it, and when" is the question that has to be
// answerable afterwards.
async function getRow(key) {
const rows = await query(
'SELECT s.`key`, s.value, s.updated_by, s.updated_at, u.username AS updated_by_username '
+ 'FROM settings s LEFT JOIN users u ON u.id = s.updated_by WHERE s.`key` = ? LIMIT 1',
[key],
)
return rows[0] || null
}
// Insert a default only if the key does not already exist.
async function seedDefault(key, value) {
await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
@@ -30,4 +44,4 @@ async function remove(key) {
await query('DELETE FROM settings WHERE `key` = ?', [key])
}
module.exports = { getAll, get, set, seedDefault, remove }
module.exports = { getAll, get, getRow, set, seedDefault, remove }

View File

@@ -16,6 +16,18 @@ const PUBLIC_KEYS = [
'theme_visual', // preset/custom colors, fonts, radii (JSON). See THEMING_AND_NAV.md §6.1.
'brand_assets', // uploaded logo/hero/favicon overrides (JSON). §6.3.
'nav_public', // public site nav overrides (JSON). §6.4.
// The two Team-forum controls (TEAMS.md §5.5.6). The client needs the first to
// know whether to render the forum panel at all, and the second to decide which
// composer to show — an upload control that 404s is worse than no control.
// Neither is sensitive.
//
// `teams_forum_uploads_ack` is deliberately NOT here: who accepted a liability
// notice is operator detail, exactly as `failure_reason` is in MODULE_API.md
// §2.9. And publishing the mode does not move the DECISION client-side — the
// server still resolves what renders (§5.5.3); the client is only told which
// composer to draw.
'teams_forums_enabled',
'teams_forum_images',
]
// Admin-configurable theming & navigation (docs/website/THEMING_AND_NAV.md).
@@ -55,6 +67,30 @@ function registrationFlags(mode) {
}
}
// Engagement Phase 1b — may an UNVERIFIED address receive opt-in engagement mail?
// Stored as 'on'/'off'. Seeded by schema.sql ASYMMETRICALLY on purpose: 'on' for a
// fresh install, 'off' for an upgrade. Turning it on retroactively would silently
// stop mailing every already-opted-in user on the day the operator upgraded, which
// is the G22 mistake — a safe default must not be applied backwards to a running
// system without telling anyone.
//
// Nothing CONSUMES this yet: the engine that would honour it arrives in Phase 4
// and the deliverability rules in Phase 9. It is seeded and editable here because
// the fresh-vs-upgrade distinction is only knowable at the migration that adds it,
// and reconstructing "was this install fresh?" later is guesswork.
const EMAIL_VERIFICATION_KEY = 'email_verification_required'
// Fail-safe direction is 'off': an unreadable or missing value must not silently
// suppress mail an operator believes is going out. The loud failure mode (mail
// reaching an unverified address) is recoverable; the quiet one is not.
async function isEmailVerificationRequired() {
try {
return String(await settingsDb.get(EMAIL_VERIFICATION_KEY)) === 'on'
} catch {
return false
}
}
// Android App Links opt-in (M9 follow-up). When on, the shard auto-serves
// /.well-known/assetlinks.json and the mobile SSO bridge additionally accepts the
// self-origin https://<host>/mobile/callback redirect. Stored as the string
@@ -244,6 +280,8 @@ module.exports = {
REGISTRATION_KEY,
REGISTRATION_MODES,
getRegistrationMode,
EMAIL_VERIFICATION_KEY,
isEmailVerificationRequired,
registrationFlags,
MOBILE_APP_LINKS_KEY,
isMobileAppLinksEnabled,

View File

@@ -0,0 +1,140 @@
// SQL for the two tables the access resolver reads: forum grants (path 3) and
// staff leadership overrides (§2.5.1).
//
// Kept separate from teams.db.js on purpose. The four authority paths are four
// tables answering four questions, and the single most important structural rule
// in TEAMS.md is that no resolver reads another path's table — a file boundary is
// a cheap way to make crossing one visible in a diff.
const { query } = require('../../utils/db')
// ── team_forum_grants (path 3) ─────────────────────────────────────────────
const GRANT_COLUMNS = `
id, team_id, user_id, username, granted_by, granted_username, granted_at, reason,
revoked_by, revoked_username, revoked_at, revoke_reason`
/** The caller's ACTIVE grant on a team, or undefined. At most one, by the unique key. */
async function activeGrant(teamId, userId) {
const rows = await query(
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants
WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`,
[teamId, userId],
)
return rows[0]
}
/** The whole ledger for a team, revoked rows included — the admin grant view. */
async function grantLedger(teamId) {
return query(
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? ORDER BY granted_at DESC, id DESC`,
[teamId],
)
}
/** Active grants only, for the "Forum guests" list and the per-team cap. */
async function activeGrants(teamId) {
return query(
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL
ORDER BY granted_at`,
[teamId],
)
}
/** How many active grants a team currently holds — the §2.5 per-Team cap reads this. */
async function activeGrantCount(teamId) {
const rows = await query(
'SELECT COUNT(*) AS n FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL',
[teamId],
)
return Number(rows[0]?.n || 0)
}
/**
* Issue a grant.
*
* Writes nothing but this table — that is the non-contamination invariant, and it
* is a property of this function being the ONLY writer on the grant path rather
* than of anyone remembering it at the call site. The username snapshots are
* taken here so the ledger still reads after either account is deleted (§2.10).
*/
async function insertGrant({ teamId, userId, username, grantedBy, grantedUsername, reason }) {
const res = await query(
`INSERT INTO team_forum_grants (team_id, user_id, username, granted_by, granted_username, reason)
VALUES (?, ?, ?, ?, ?, ?)`,
[teamId, userId, username, grantedBy, grantedUsername, reason ?? null],
)
return res.insertId
}
/**
* Revoke the active grant, if there is one.
*
* An UPDATE of the existing row rather than a delete: the table is a ledger as
* well as the current state, and `revoked_at` is what moves a row out of the
* unique key (the generated `active_marker` goes NULL) while keeping the history.
*/
async function revokeGrant({ teamId, userId, revokedBy, revokedUsername, reason }) {
const res = await query(
`UPDATE team_forum_grants
SET revoked_at = NOW(), revoked_by = ?, revoked_username = ?, revoke_reason = ?
WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`,
[revokedBy, revokedUsername, reason ?? null, teamId, userId],
)
return res.affectedRows > 0
}
// ── team_leader_overrides (§2.5.1) ─────────────────────────────────────────
const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at'
async function overridesForTeam(teamId) {
return query(`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? ORDER BY member_key`,
[teamId])
}
async function overrideFor(teamId, memberKey) {
const rows = await query(
`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? AND member_key = ?`,
[teamId, memberKey],
)
return rows[0]
}
/**
* Set or replace one override.
*
* The projection is never touched by this — `team_members.is_leader` keeps saying
* what the game says and this keeps saying what staff decided, which is the entire
* point (§2.5.1). An override applied INTO the projection would be clobbered by
* the next sync, fifteen minutes later.
*/
async function setOverride({ teamId, memberKey, effect, actorUserId, actorUsername, reason }) {
await query(
`INSERT INTO team_leader_overrides (team_id, member_key, effect, actor_user_id, actor_username, reason)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
effect = VALUES(effect), actor_user_id = VALUES(actor_user_id),
actor_username = VALUES(actor_username), reason = VALUES(reason), created_at = NOW()`,
[teamId, memberKey, effect, actorUserId, actorUsername, reason],
)
}
async function clearOverride(teamId, memberKey) {
const res = await query('DELETE FROM team_leader_overrides WHERE team_id = ? AND member_key = ?',
[teamId, memberKey])
return res.affectedRows > 0
}
module.exports = {
activeGrant,
grantLedger,
activeGrants,
activeGrantCount,
insertGrant,
revokeGrant,
overridesForTeam,
overrideFor,
setOverride,
clearOverride,
}

View File

@@ -0,0 +1,131 @@
// ── The four authority paths ───────────────────────────────────────────────
//
// The single most important structural rule in TEAMS.md (§2.5): these are 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,
// plus a staff override
// 3. May it use the Team forum? CORE team_forum_grants OR path 1
// 4. May it get external-platform CORE, nothing of its own
// access? derived
//
// The temptation this file exists to resist 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 the
// forum must not require a staff ticket. Treating "has forum access" as "is a
// member" would put that person on the roster, in the member count, and into the
// external-platform grant, which is where it stops being a modelling preference
// and becomes an impersonation risk (path 4 below).
//
// Non-contamination is the invariant: a manual grant never writes the membership
// projection, in either direction, ever. Both facts coexist and neither migrates
// into the other.
const accessDb = require('./teamAccess.db')
const teamsDb = require('./teams.db')
const identities = require('../userIdentities/userIdentities.model')
/**
* Path 3 — forum access. Two reads, OR'd, and nothing else.
*
* `viaGrant` is reported even when membership also holds, deliberately: both
* facts are true, the UI presents membership as the current reason, and the grant
* survives as audit history. Collapsing them into one boolean is what loses the
* record of who let this person in and why.
*/
async function forumAccess(teamId, userId) {
if (!userId) return { allowed: false, viaMembership: false, viaGrant: false, isLeader: false }
const [grant, member] = await Promise.all([
accessDb.activeGrant(teamId, userId), // path 3's own table
teamsDb.activeByUser(teamId, userId), // path 1
])
return {
allowed: Boolean(grant) || Boolean(member),
viaMembership: Boolean(member),
viaGrant: Boolean(grant),
isLeader: member ? await isLeader(teamId, member) : false,
}
}
/**
* Path 2 — leadership, with the staff override applied ON TOP of the synced value
* at read time (§2.5.1).
*
* Applied at read rather than written into the projection because the sync owns
* that column and rewrites it every interval. An override that lived in
* `team_members` would be undone fifteen minutes after staff set it, which is the
* whole reason this is a separate table read here.
*/
async function isLeader(teamId, member) {
if (!member) return false
const override = await accessDb.overrideFor(teamId, member.member_key)
if (override) return override.effect === 'grant'
return Boolean(member.is_leader)
}
/** Leadership for a caller identified by user id rather than by a member row. */
async function isLeaderByUser(teamId, userId) {
if (!userId) return false
const member = await teamsDb.activeByUser(teamId, userId)
return isLeader(teamId, member)
}
/**
* Path 4 — external-platform eligibility. Computed, no table of its own, and
* deliberately blind to path 3.
*
* The reason, stated so nobody "fixes" it later: 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.
*/
async function externalEligible(teamId, userId, platform) {
if (!userId || !platform) return false
const member = await teamsDb.activeByUser(teamId, userId) // path 1 ONLY
if (!member || member.user_id == null) return false // must be a LINKED game member
const linked = await identities.listForUser(userId)
return linked.some((i) => i.provider === platform)
}
/**
* A team's roster with overrides folded in, for the admin view and the Team page.
*
* The rows returned carry `is_leader` as RESOLVED — synced value plus override —
* and `is_leader_synced` as what the game actually said, so the admin surface can
* show that a decision was made rather than silently presenting it as fact.
*/
async function rosterWithOverrides(teamId, { includeDeparted = false } = {}) {
const [members, overrides] = await Promise.all([
teamsDb.membersByTeam(teamId, { includeDeparted }),
accessDb.overridesForTeam(teamId),
])
const byKey = new Map(overrides.map((o) => [o.member_key, o]))
return members.map((m) => {
const override = byKey.get(m.member_key)
return {
...m,
is_leader_synced: Boolean(m.is_leader),
is_leader: override ? override.effect === 'grant' : Boolean(m.is_leader),
leader_override: override
? { effect: override.effect, reason: override.reason, by: override.actor_username, at: override.created_at }
: null,
}
})
}
module.exports = {
forumAccess,
isLeader,
isLeaderByUser,
externalEligible,
rosterWithOverrides,
setLeaderOverride: accessDb.setOverride,
clearLeaderOverride: accessDb.clearOverride,
grantLedger: accessDb.grantLedger,
activeGrants: accessDb.activeGrants,
}

View File

@@ -0,0 +1,133 @@
// SQL for the per-Team activity feed (TEAMS.md §4.2). Statements only; every
// decision about what a caller may SEE lives in teamActivity.model.js.
const { query } = require('../../utils/db')
const ACTIVITY_COLUMNS = `
id, team_id, source, kind, summary, visibility,
actor_member_key, actor_user_id, payload, occurred_at, created_at`
/**
* Insert one item, idempotently when it carries a dedupe key.
*
* INSERT IGNORE against uq_team_activity_dedupe is what makes replay safe: a
* sidecar reconnect backfills a window of events it already delivered, and
* without this every reconnect would double-post the feed. The same trick
* `shard_events` uses, for the same reason.
*
* The unique key is (team_id, dedupe_key) and MariaDB treats NULL as distinct in
* a unique index, so items WITHOUT a key never collide with each other — an
* un-keyed push is always an insert, which is the documented contract (§4.1:
* `dedupeKey` is optional and "makes replay idempotent", so omitting it opts out).
*
* IGNORE would also swallow a genuine error — a bad FK, an over-long summary. The
* model validates and truncates before calling, so what reaches here can only fail
* on the dedupe key, and `affectedRows` reports which happened.
*/
async function insert(item) {
const res = await query(
`INSERT IGNORE INTO team_activity
(team_id, source, kind, summary, visibility, actor_member_key, actor_user_id, payload, occurred_at, dedupe_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
item.teamId,
item.source,
item.kind,
item.summary,
item.visibility,
item.actorMemberKey,
item.actorUserId,
item.payload === null ? null : JSON.stringify(item.payload),
new Date(item.occurredAt),
item.dedupeKey,
],
)
return Number(res.affectedRows) > 0
}
/**
* One page of a Team's feed, already narrowed to the visibilities the caller may
* see.
*
* `visibilities` is always supplied by the model and never by a request
* parameter — a caller naming its own visibility filter is the whole bug this
* table's ENUM exists to prevent. Ordered newest first by `occurred_at`, the
* game's clock, not `created_at`: a backfill that arrives late still sorts where
* it happened.
*/
async function page(teamId, visibilities, { limit, offset }) {
const slots = visibilities.map(() => '?').join(', ')
return query(
`SELECT ${ACTIVITY_COLUMNS} FROM team_activity
WHERE team_id = ? AND visibility IN (${slots})
ORDER BY occurred_at DESC, id DESC
LIMIT ? OFFSET ?`,
[teamId, ...visibilities, limit, offset],
)
}
/** Total matching rows, for the same filter — so a client can page honestly. */
async function count(teamId, visibilities) {
const slots = visibilities.map(() => '?').join(', ')
const rows = await query(
`SELECT COUNT(*) AS n FROM team_activity WHERE team_id = ? AND visibility IN (${slots})`,
[teamId, ...visibilities],
)
return Number(rows[0] ? rows[0].n : 0)
}
/** Everything older than the retention horizon, across every Team. */
async function deleteOlderThan(days) {
const res = await query(
'DELETE FROM team_activity WHERE occurred_at < (NOW() - INTERVAL ? DAY)',
[days],
)
return Number(res.affectedRows) || 0
}
/**
* Which Teams currently exceed the per-Team row cap, and by how much.
*
* Asked first so the trim only runs for Teams that need it. A feed fed by a game
* loop is the obvious unbounded-growth failure (§4.2), and on a shard with one
* busy guild and fifty quiet ones this keeps the nightly job proportional to the
* problem rather than to the number of Teams.
*/
async function overCap(cap) {
return query(
`SELECT team_id, COUNT(*) AS n FROM team_activity
GROUP BY team_id HAVING n > ?`,
[cap],
)
}
/**
* Trim one Team back to the newest `cap` rows.
*
* Expressed as "delete everything at or below the id of the cap-th newest row"
* rather than as a correlated subquery on the same table, which MariaDB refuses
* inside a DELETE (error 1093). The derived table is what makes it legal — the
* subquery is materialised before the delete runs.
*/
async function trimToCap(teamId, cap) {
const rows = await query(
`SELECT id FROM team_activity
WHERE team_id = ? ORDER BY occurred_at DESC, id DESC LIMIT 1 OFFSET ?`,
[teamId, cap],
)
if (!rows[0]) return 0
const res = await query(
'DELETE FROM team_activity WHERE team_id = ? AND id <= ?',
[teamId, rows[0].id],
)
return Number(res.affectedRows) || 0
}
module.exports = {
insert,
page,
count,
deleteOlderThan,
overCap,
trimToCap,
}

View File

@@ -0,0 +1,312 @@
// ── The per-Team activity feed (TEAMS.md Part 4) ───────────────────────────
//
// Two writers, one table. A module pushes game items through
// `ctx.teams.activity.push` (§4.1); core writes its own membership and rename
// items directly (§4.2). Both land in `team_activity` with a `source`, and the
// read path treats them identically — which is the point of core writing here at
// all, since it means the rendering path is exercised from day one on a
// deployment whose module pushes nothing.
//
// **Three rules shape this file.**
//
// 1. *Core never composes a summary.* `summary` arrives already rendered and is
// stored verbatim (§4.1). Core cannot phrase "gained 15,000 gold" for a game
// whose vocabulary it does not know, and a core that templated it would have
// re-acquired the game semantics the module system exists to remove. Core's OWN
// five kinds are the sole exception, and they are about membership and renames
// — platform facts, not game ones.
//
// 2. *Visibility fails closed.* An item with no stated visibility is `members`,
// and the read path resolves what a caller may see from their access rather
// than from anything they send.
//
// 3. *A push never throws at its call site.* `ctx.teams.activity.push` is awaited
// by a module inside a game-event handler. A bad item is dropped and logged;
// an unknown Team is dropped and logged. The alternative — rejecting the batch
// — makes core's storage problem into the module's control flow, and the
// contract (MODULE_API.md §2.3) is that ctx pushes are fire-and-forget.
const activityDb = require('./teamActivity.db')
const teamsDb = require('./teams.db')
const access = require('./teamAccess.model')
const settings = require('../settings/settings.model')
const log = require('../../utils/logger')('teams')
// Column widths from schema.sql. Truncating rather than refusing: an over-long
// summary is a module being verbose, not a module being wrong, and dropping the
// item would lose a real event over a display detail.
const MAX_SUMMARY = 255
const MAX_KIND = 64
const MAX_MEMBER_KEY = 191
// CHAR(40) — a sha1 hex is the natural fit and what §4.1's example looks like,
// but the column is opaque and any stable string within the width works.
const MAX_DEDUPE = 40
const VISIBILITIES = ['public', 'members']
// Retention (§4.2). Both are settings so an operator can tighten a busy shard
// without a deploy; the defaults are the doc's.
const DEFAULT_RETAIN_DAYS = 90
const DEFAULT_ROW_CAP = 2000
/**
* Core's own kinds (§4.2).
*
* `core.forum.thread` is named in the doc and lands with the forum in phase 4 —
* there is nothing to emit it from yet. The four here are all core knows how to
* say without asking a game anything.
*/
const CORE_KINDS = {
MEMBER_JOINED: 'core.member.joined',
MEMBER_LEFT: 'core.member.left',
LEADER_CHANGED: 'core.leader.changed',
TEAM_RENAMED: 'core.team.renamed',
}
const clamp = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null)
/**
* Normalise one pushed item, or return null to drop it.
*
* `teamId` is resolved by the caller, not carried on the item: a module names its
* own `externalId` and core maps it (§4.1), so a module can never write into
* another module's Team by guessing an integer.
*/
function normalise(item, source, teamId) {
if (!item || typeof item !== 'object') return null
const kind = clamp(item.kind, MAX_KIND)
const summary = clamp(item.summary, MAX_SUMMARY)
// Both are load-bearing and neither has a safe default: an item with no kind
// cannot be filtered or rendered by a slot, and one with no summary is a blank
// row on a public page.
if (!kind || !summary) return null
// `occurredAt` is the game's clock and the feed's sort key. A missing or
// unparseable one becomes now — the item is real even when its timestamp is
// not, and dropping it would lose an event over metadata.
const occurredAt = Number.isFinite(item.occurredAt) ? Number(item.occurredAt) : Date.now()
return {
teamId,
source,
kind,
summary,
visibility: VISIBILITIES.includes(item.visibility) ? item.visibility : 'members',
actorMemberKey: clamp(item.actorMemberKey, MAX_MEMBER_KEY),
// Resolved BY THE MODULE, like every other user id crossing this boundary
// (§2.3) — core takes the number and never looks it up.
actorUserId: Number.isInteger(item.actorUserId) && item.actorUserId > 0 ? item.actorUserId : null,
payload: item.payload && typeof item.payload === 'object' ? item.payload : null,
occurredAt,
dedupeKey: clamp(item.dedupeKey, MAX_DEDUPE),
}
}
/**
* `ctx.teams.activity.push` — a module's whole write access to the feed.
*
* Items name their Team by the module's own `externalId`, and only ACTIVE Teams
* owned by THAT module resolve. An archived Team is deliberately not writable: its
* feed is a read-only record of what happened before the rename or the disband
* (§2.2), and letting a late-arriving event append to it would make a closed
* record grow.
*
* Returns the number of items actually stored. Dropped items are logged with the
* reason and never raised — see rule 3 above.
*/
async function push(source, items) {
if (!Array.isArray(items)) {
log.warn('teams activity push: not an array', { source })
return 0
}
if (!items.length) return 0
// One lookup per distinct externalId, not one per item: a champion spawn
// completing pushes a batch for a single Team, and re-resolving it per item
// would be a query per row.
const teamIds = new Map()
let stored = 0
let dropped = 0
for (const item of items) {
const externalId = item && typeof item.externalId === 'string' ? item.externalId.trim() : ''
if (!externalId) { dropped += 1; continue }
if (!teamIds.has(externalId)) {
// eslint-disable-next-line no-await-in-loop
const row = await teamsDb.findActive(source, externalId)
teamIds.set(externalId, row ? row.id : null)
}
const teamId = teamIds.get(externalId)
if (!teamId) { dropped += 1; continue }
const normalised = normalise(item, source, teamId)
if (!normalised) { dropped += 1; continue }
// eslint-disable-next-line no-await-in-loop
const inserted = await activityDb.insert(normalised)
// A dedupe collision is a SUCCESSFUL no-op, not a drop — it is the mechanism
// working. Counted as stored so a module replaying a backfill does not read
// its own idempotence as data loss.
if (inserted) stored += 1
}
if (dropped) {
log.warn('teams activity push: dropped items', { source, dropped, offered: items.length })
}
return stored
}
/**
* Core's own write path (§4.2), used by the reconciler and the rename rule.
*
* Separate from `push` because core names a Team by its own primary key — it is
* already holding the row — and because core's items are always `public`: a
* member joining or a Team being renamed is exactly what a public Team page is
* for. Nothing here is game vocabulary.
*/
async function logCore({ teamId, kind, summary, actorMemberKey = null, actorUserId = null, occurredAt = Date.now(), dedupeKey = null }) {
if (!teamId || !kind || !summary) return false
return activityDb.insert({
teamId,
source: 'core',
kind: clamp(kind, MAX_KIND),
summary: clamp(summary, MAX_SUMMARY),
visibility: 'public',
actorMemberKey: clamp(actorMemberKey, MAX_MEMBER_KEY),
actorUserId: Number.isInteger(actorUserId) && actorUserId > 0 ? actorUserId : null,
payload: null,
occurredAt,
dedupeKey: clamp(dedupeKey, MAX_DEDUPE),
})
}
/**
* Which visibilities a caller may see (§4.3).
*
* `members` items go to members and to forum-granted users — the same two
* authority paths `forumAccess` already resolves, reused rather than re-derived
* so the feed can never disagree with the forum about who is inside a Team.
* Anyone else, including every anonymous caller, sees `public` only.
*/
async function visibilitiesFor(teamId, userId) {
if (!userId) return ['public']
const resolved = await access.forumAccess(teamId, userId)
return resolved.allowed ? ['public', 'members'] : ['public']
}
/** The rendered shape. `payload` rides along for the module's slot (§4.3). */
function publicItem(row) {
return {
id: Number(row.id),
source: row.source,
kind: row.kind,
summary: row.summary,
visibility: row.visibility,
occurredAt: row.occurred_at,
payload: row.payload ?? null,
}
}
/**
* One page of a Team's feed for one viewer.
*
* A HIDDEN Team's feed is not served publicly, for the same reason its roster is
* not (§2.8.3): hidden means absent from every public surface, and a feed that
* answered while the page 404s would republish the suppressed name in every
* `core.team.renamed` summary.
*/
async function feedFor(slug, userId, { limit = 50, offset = 0 } = {}) {
const row = await teamsDb.findBySlug(slug)
if (!row) return null
const visibilities = await visibilitiesFor(row.id, userId)
// A member of a hidden Team still sees its feed — suppression is a
// public-surface rule, and a member is not a member of the public (§2.11).
if (row.hidden && visibilities.length === 1) return null
const [rows, total] = await Promise.all([
activityDb.page(row.id, visibilities, { limit, offset }),
activityDb.count(row.id, visibilities),
])
return {
items: rows.map(publicItem),
total,
limit,
offset,
// So a client can render "members-only items are hidden" rather than
// presenting a filtered feed as the whole one.
scope: visibilities.includes('members') ? 'members' : 'public',
}
}
// ── Retention (§4.2) ───────────────────────────────────────────────────────
const RETAIN_KEY = 'team_activity_retain_days'
const CAP_KEY = 'team_activity_row_cap'
/**
* Read both limits, falling back to the defaults on anything unreadable.
*
* Wrapped in a try like `teamSync.intervalSeconds`, and for the same reason: this
* runs on a timer with nobody watching, and a settings table that is briefly
* unavailable must yield the default rather than an exception that kills the
* nightly job. A misconfigured value fails the same way — a zero or a negative
* retention would delete the whole feed, so it is rejected rather than honoured.
*/
async function retentionConfig() {
let rawDays
let rawCap
try {
;[rawDays, rawCap] = await Promise.all([settings.get(RETAIN_KEY), settings.get(CAP_KEY)])
} catch {
return { days: DEFAULT_RETAIN_DAYS, cap: DEFAULT_ROW_CAP }
}
const days = Number.parseInt(rawDays, 10)
const cap = Number.parseInt(rawCap, 10)
return {
days: Number.isFinite(days) && days > 0 ? days : DEFAULT_RETAIN_DAYS,
cap: Number.isFinite(cap) && cap > 0 ? cap : DEFAULT_ROW_CAP,
}
}
/**
* The nightly prune: an age horizon AND a per-Team row cap.
*
* Both, because either alone has a hole. Age alone lets one busy guild write a
* million rows inside the window; a cap alone keeps a dead Team's feed forever.
* Unbounded growth on a per-Team feed fed by a game loop is the obvious failure
* here and it is cheaper to bound it now than to discover it at cutover.
*/
async function prune() {
const { days, cap } = await retentionConfig()
const byAge = await activityDb.deleteOlderThan(days)
let byCap = 0
const over = await activityDb.overCap(cap)
for (const row of over) {
// eslint-disable-next-line no-await-in-loop
byCap += await activityDb.trimToCap(row.team_id, cap)
}
if (byAge || byCap) log.info('teams activity prune', { byAge, byCap, days, cap })
return { byAge, byCap, days, cap }
}
module.exports = {
push,
logCore,
feedFor,
visibilitiesFor,
publicItem,
prune,
retentionConfig,
RETAIN_KEY,
CAP_KEY,
CORE_KINDS,
VISIBILITIES,
DEFAULT_RETAIN_DAYS,
DEFAULT_ROW_CAP,
}

View File

@@ -0,0 +1,294 @@
// SQL for the four forum tables (TEAMS.md §5.2, §5.2a).
//
// Kept apart from teamAccess.db.js for the same reason that file is kept apart
// from teams.db.js: forum CONTENT and forum ACCESS are different questions, and a
// query here that read `team_members` to decide who may see a thread would be the
// exact collapse §2.5 forbids. Nothing in this file resolves access; callers hand
// it a decision the resolver already made.
const { query } = require('../../utils/db')
const THREAD_COLUMNS = `
id, team_id, type, title, created_by, created_username, created_at,
last_post_at, post_count, pinned, locked, status`
const POST_COLUMNS = `
id, thread_id, author_user_id, author_username, body_html, created_at,
edited_at, edited_by, status`
// ── threads ────────────────────────────────────────────────────────────────
/**
* A Team's threads, newest activity first with pinned rows on top.
*
* `includeHidden` is the staff/leader view. Hidden is not deleted: a hidden
* thread stays in the ledger and comes back with `unhide`, which is why the
* status filter is a parameter rather than a WHERE clause everyone remembers.
*/
async function threadsByTeam(teamId, { includeHidden = false, limit = 50, offset = 0 } = {}) {
const statuses = includeHidden ? "('visible','hidden')" : "('visible')"
return query(
`SELECT ${THREAD_COLUMNS} FROM team_forum_threads
WHERE team_id = ? AND status IN ${statuses}
ORDER BY pinned DESC, COALESCE(last_post_at, created_at) DESC, id DESC
LIMIT ? OFFSET ?`,
[teamId, limit, offset],
)
}
async function threadById(id) {
const rows = await query(`SELECT ${THREAD_COLUMNS} FROM team_forum_threads WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
async function insertThread({ teamId, type, title, createdBy, createdUsername }) {
const res = await query(
`INSERT INTO team_forum_threads (team_id, type, title, created_by, created_username, last_post_at, post_count)
VALUES (?, ?, ?, ?, ?, NOW(), 0)`,
[teamId, type, title, createdBy, createdUsername],
)
return res.insertId
}
/** Apply one moderation action's effect. The LEDGER row is written separately. */
async function setThreadFlags(id, { pinned, locked, status }) {
const sets = []
const args = []
if (pinned !== undefined) { sets.push('pinned = ?'); args.push(pinned ? 1 : 0) }
if (locked !== undefined) { sets.push('locked = ?'); args.push(locked ? 1 : 0) }
if (status !== undefined) { sets.push('status = ?'); args.push(status) }
if (!sets.length) return false
args.push(id)
const res = await query(`UPDATE team_forum_threads SET ${sets.join(', ')} WHERE id = ?`, args)
return res.affectedRows > 0
}
// ── posts ──────────────────────────────────────────────────────────────────
async function postsByThread(threadId, { includeHidden = false } = {}) {
const statuses = includeHidden ? "('visible','hidden')" : "('visible')"
return query(
`SELECT ${POST_COLUMNS} FROM team_forum_posts
WHERE thread_id = ? AND status IN ${statuses} ORDER BY created_at, id`,
[threadId],
)
}
async function postById(id) {
const rows = await query(`SELECT ${POST_COLUMNS} FROM team_forum_posts WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
/**
* Append a post and move the thread's counters in the same breath.
*
* Two statements rather than a trigger: the counters are a denormalisation for
* the thread list, and a trigger would put half the write in the schema where
* nobody reading this file would find it.
*/
async function insertPost({ threadId, authorUserId, authorUsername, bodyHtml }) {
const res = await query(
`INSERT INTO team_forum_posts (thread_id, author_user_id, author_username, body_html)
VALUES (?, ?, ?, ?)`,
[threadId, authorUserId, authorUsername, bodyHtml],
)
await query(
'UPDATE team_forum_threads SET post_count = post_count + 1, last_post_at = NOW() WHERE id = ?',
[threadId],
)
return res.insertId
}
async function setPostStatus(id, status) {
const res = await query('UPDATE team_forum_posts SET status = ? WHERE id = ?', [status, id])
return res.affectedRows > 0
}
/**
* Rewrite a post's body, stamping who edited it and when.
*
* `edited_at` is set unconditionally, including when a staffer edits — the column
* answers "has this been changed since it was written", which a reader needs to
* know regardless of whose hand did it. `edited_by` is the second half of that
* answer and is why the two are separate columns rather than a boolean.
*/
async function updatePostBody(id, bodyHtml, editedBy) {
const res = await query(
'UPDATE team_forum_posts SET body_html = ?, edited_at = NOW(), edited_by = ? WHERE id = ?',
[bodyHtml, editedBy, id],
)
return res.affectedRows > 0
}
/**
* Recompute a thread's denormalised counters from the posts that are actually
* visible.
*
* Called after every post moderation rather than incrementing and decrementing,
* because hide → unhide → delete → restore is a sequence in which a counter kept
* by deltas drifts the first time any step is retried or raced. The read is one
* indexed aggregate over one thread; correctness is worth more than the write it
* saves. `last_post_at` falls back to NULL for an emptied thread, which is what
* `threadsByTeam`'s COALESCE onto `created_at` already expects.
*/
async function recountThread(threadId) {
await query(
`UPDATE team_forum_threads t
SET t.post_count = (SELECT COUNT(*) FROM team_forum_posts p
WHERE p.thread_id = t.id AND p.status = 'visible'),
t.last_post_at = (SELECT MAX(p.created_at) FROM team_forum_posts p
WHERE p.thread_id = t.id AND p.status = 'visible')
WHERE t.id = ?`,
[threadId],
)
}
// ── the moderation ledger (append-only) ────────────────────────────────────
async function insertModeration({ teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason }) {
await query(
`INSERT INTO team_forum_moderation
(team_id, target_type, target_id, action, actor_user_id, actor_username, actor_role, reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason ?? null],
)
}
async function moderationForTeam(teamId, { limit = 100, offset = 0 } = {}) {
return query(
`SELECT id, team_id, target_type, target_id, action, actor_user_id, actor_username,
actor_role, reason, created_at
FROM team_forum_moderation WHERE team_id = ?
ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
[teamId, limit, offset],
)
}
// ── uploads (§5.2a) ────────────────────────────────────────────────────────
const UPLOAD_COLUMNS = `
id, team_id, post_id, uploader_user_id, uploader_username, filename, mimetype,
byte_size, created_at, deleted_at, deleted_by`
async function insertUpload({ teamId, postId, uploaderUserId, uploaderUsername, filename, mimetype, byteSize }) {
const res = await query(
`INSERT INTO team_forum_uploads
(team_id, post_id, uploader_user_id, uploader_username, filename, mimetype, byte_size)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[teamId, postId ?? null, uploaderUserId, uploaderUsername, filename, mimetype, byteSize],
)
return res.insertId
}
async function uploadById(id) {
const rows = await query(`SELECT ${UPLOAD_COLUMNS} FROM team_forum_uploads WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
/** Bytes this account has uploaded in the trailing window — the §5.5.4 daily quota. */
async function bytesUploadedSince(userId, sinceHours) {
const rows = await query(
`SELECT COALESCE(SUM(byte_size), 0) AS bytes FROM team_forum_uploads
WHERE uploader_user_id = ? AND created_at > (NOW() - INTERVAL ? HOUR)`,
[userId, sinceHours],
)
return Number(rows[0]?.bytes || 0)
}
/** The admin attribution view: who uploaded what, when, how much, and where. */
async function listUploads({ limit = 100, offset = 0, includeDeleted = false } = {}) {
return query(
`SELECT u.id, u.team_id, u.post_id, u.uploader_user_id, u.uploader_username,
u.filename, u.mimetype, u.byte_size, u.created_at, u.deleted_at, u.deleted_by,
t.name AS team_name, t.slug AS team_slug
FROM team_forum_uploads u JOIN teams t ON t.id = u.team_id
${includeDeleted ? '' : 'WHERE u.deleted_at IS NULL'}
ORDER BY u.created_at DESC, u.id DESC LIMIT ? OFFSET ?`,
[limit, offset],
)
}
async function softDeleteUpload(id, deletedBy) {
const res = await query(
'UPDATE team_forum_uploads SET deleted_at = NOW(), deleted_by = ? WHERE id = ? AND deleted_at IS NULL',
[deletedBy, id],
)
return res.affectedRows > 0
}
/** Soft-delete every upload attached to a post — the lifecycle half of §5.5.4. */
async function softDeleteUploadsForPost(postId, deletedBy) {
await query(
'UPDATE team_forum_uploads SET deleted_at = NOW(), deleted_by = ? WHERE post_id = ? AND deleted_at IS NULL',
[deletedBy, postId],
)
}
/**
* The other half of the pair: a restored post gets its images back.
*
* Without this, `delete` then `restore` returns the words and loses the pictures —
* and loses them SILENTLY, because the soft-deleted rows survive the retention
* window before the sweep takes the bytes, so the post looks fine until the night
* it does not. Beyond that window the row itself is gone and this is a no-op;
* nothing can be done about that and nothing should pretend otherwise.
*/
async function restoreUploadsForPost(postId) {
await query(
'UPDATE team_forum_uploads SET deleted_at = NULL, deleted_by = NULL WHERE post_id = ? AND deleted_at IS NOT NULL',
[postId],
)
}
/** Rows soft-deleted longer ago than the retention window — the sweep's worklist. */
async function sweepableUploads(retentionDays) {
return query(
`SELECT id, filename FROM team_forum_uploads
WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)`,
[retentionDays],
)
}
/** Never-referenced uploads older than the grace period — a composer opened and abandoned. */
async function orphanedUploads(graceHours) {
return query(
`SELECT id, filename FROM team_forum_uploads
WHERE post_id IS NULL AND deleted_at IS NULL AND created_at < (NOW() - INTERVAL ? HOUR)`,
[graceHours],
)
}
async function deleteUploadRows(ids) {
if (!ids.length) return 0
const res = await query(
`DELETE FROM team_forum_uploads WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)
return res.affectedRows
}
module.exports = {
threadsByTeam,
threadById,
insertThread,
setThreadFlags,
postsByThread,
postById,
insertPost,
setPostStatus,
updatePostBody,
recountThread,
insertModeration,
moderationForTeam,
insertUpload,
uploadById,
bytesUploadedSince,
listUploads,
softDeleteUpload,
softDeleteUploadsForPost,
restoreUploadsForPost,
sweepableUploads,
orphanedUploads,
deleteUploadRows,
}

View File

@@ -0,0 +1,437 @@
// ── The forum: access + announcements (5a), discussion + moderation (5b) ───
//
// TEAMS.md §5.1's split is BY LAYER, not by feature: 5a shipped the whole access
// model and a single announcements stream per Team; 5b (phase 5) opens discussion
// threads, replies, editing and post-level moderation. The schema for all of it
// landed together, so this phase added 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.
//
// **Every function here takes an already-resolved access decision.** Nothing in
// this file reads `team_members` or `team_forum_grants`; the caller asks
// teamAccess.forumAccess() once and hands the answer down. That is §5.4's "never
// by checking membership directly, which is how paths 1 and 3 would drift back
// together", made structural.
//
// **The read path is where the image policy is applied**, once, in `renderPost`.
// Not in the controller and never in the client: the client is TOLD the mode so it
// can draw the right composer, and is never the thing that decides whether an
// image appears (§5.5.6).
const forumDb = require('./teamForum.db')
const forumSettings = require('./teamForumSettings.model')
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
// Announcements are leader-authored and take no replies; discussion threads are
// member-authored and do. Both have been in the enum since 5a — what phase 5
// changed is that both are now CREATABLE, and by different people.
//
// **The authority split lives in the controller, not here.** This list says what
// kinds of thread exist; who may make one is a question about the caller, which
// this file deliberately never asks (see the header on access decisions).
const CREATABLE_TYPES = ['announcement', 'discussion']
// Kept as an export because it names a real fact — the one type 5a could create —
// and because removing a name from a module's surface to save a line is how a
// consumer outside this repo breaks. It is not used to decide anything.
const CREATABLE_TYPES_5A = ['announcement']
// Which thread types accept replies. An announcement's `locked` stays false even
// though nothing may reply to it: replies are refused because the TYPE takes none,
// not because the thread was closed, and conflating the two would make "unlock"
// look like it would open replies on an announcement.
const REPLYABLE_TYPES = ['discussion']
const DELETED_AUTHOR = '[deleted account]'
/**
* Moderation actions, and what each one does to the row.
*
* A table rather than a switch because the ledger and the effect have to stay in
* step: every entry here writes one row of `team_forum_moderation` naming the
* authority that was exercised, and an action with an effect but no ledger entry
* would be a moderation nobody can audit.
*/
const THREAD_ACTIONS = {
pin: { pinned: true },
unpin: { pinned: false },
lock: { locked: true },
unlock: { locked: false },
hide: { status: 'hidden' },
unhide: { status: 'visible' },
delete: { status: 'deleted' },
restore: { status: 'visible' },
}
// Post-level moderation. A strict subset of THREAD_ACTIONS: `pin` and `lock`
// describe a thread's place in a list and its openness to replies, neither of
// which a post has. Naming them here as "not applicable" rather than as "unknown"
// is what lets `moderatePost` tell a caller which mistake they made.
const POST_ACTIONS = {
hide: { status: 'hidden' },
unhide: { status: 'visible' },
delete: { status: 'deleted' },
restore: { status: 'visible' },
}
function publicThread(row) {
return {
id: row.id,
type: row.type,
title: row.title,
author: row.created_username || DELETED_AUTHOR,
authorDeleted: row.created_by == null,
createdAt: row.created_at,
lastPostAt: row.last_post_at,
postCount: row.post_count,
pinned: Boolean(row.pinned),
locked: Boolean(row.locked),
status: row.status,
}
}
/**
* May this viewer edit this post, and until when?
*
* **Computed on the server and handed to the client, never the other way round** —
* the same rule §5.5.3 applies to the image policy, for the same reason. A client
* that decided this would be deciding it against its own clock, and a clock is the
* one input a time-bounded permission must not take from the party it bounds.
*
* Staff get `editableUntil: null`, which reads as "no deadline" rather than as "no
* permission" — `canEdit` is the permission and this is only its expiry. An author
* past their window keeps a past `editableUntil`, so the UI can say *why* the
* control is gone instead of silently dropping it.
*/
function editability(row, { userId = null, isStaff = false, windowMinutes = 0, now = Date.now() } = {}) {
// A hidden or deleted post is not editable by anybody, staff included. Restoring
// it is a moderation action with a ledger row; quietly rewriting it while it is
// out of sight is the same act with no record.
if (row.status !== 'visible') return { canEdit: false, editableUntil: null }
if (isStaff) return { canEdit: true, editableUntil: null }
if (!userId || row.author_user_id == null || row.author_user_id !== userId) {
return { canEdit: false, editableUntil: null }
}
const until = new Date(row.created_at).getTime() + windowMinutes * 60_000
return { canEdit: until > now, editableUntil: new Date(until).toISOString() }
}
/**
* One post, rendered for one image policy and one viewer.
*
* `body` is what the reader gets and `mode` decides whether it carries images.
* The STORED html is never modified — flipping the policy changes this function's
* output and nothing on disk, which is the property §5.5.3 exists to give and the
* one acceptance criterion 3 measures.
*
* `viewer` is optional so that every 5a caller keeps working unchanged; omitting
* it yields `canEdit: false`, which is the right answer for a caller that has not
* said who is reading.
*/
function renderPost(row, mode, viewer) {
return {
id: row.id,
author: row.author_username || DELETED_AUTHOR,
authorDeleted: row.author_user_id == null,
body: renderForumBody(row.body_html, mode),
createdAt: row.created_at,
editedAt: row.edited_at,
status: row.status,
mine: Boolean(viewer?.userId) && row.author_user_id === viewer.userId,
...editability(row, viewer),
}
}
/**
* The thread list for one viewer.
*
* `canModerate` widens what is returned, not just what is offered: a hidden
* thread is visible to the people who can unhide it and to nobody else, so the
* same call answers both audiences without a second endpoint that could disagree
* with this one.
*/
async function listThreads(teamId, { canModerate = false, limit = 50, offset = 0 } = {}) {
const rows = await forumDb.threadsByTeam(teamId, { includeHidden: canModerate, limit, offset })
return rows.map(publicThread)
}
/**
* One thread with its posts, rendered under the current image policy and for one
* viewer.
*
* `viewer` carries who is reading and what the edit window is, so every post comes
* back already knowing whether this caller may edit it. The alternative — shipping
* the window to the client and letting it compare timestamps — is the thing
* `editability` exists not to do.
*/
async function getThread(teamId, threadId, { canModerate = false, viewer } = {}) {
const thread = await forumDb.threadById(threadId)
// The team check is here rather than in the SQL so a thread id from another
// Team reads as "not found" and not as "found, but not yours" — a forum is a
// private room and the existence of a thread in it is itself private.
if (!thread || thread.team_id !== teamId) return null
if (thread.status === 'deleted' && !canModerate) return null
if (thread.status === 'hidden' && !canModerate) return null
const mode = await forumSettings.imageMode()
const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate })
return {
...publicThread(thread),
// A reply control is offered when the TYPE takes replies and the thread is
// open. Both halves are reported separately (`type`, `locked`) so the UI can
// say which one is why, but the decision itself is made here — a client that
// recomputed it would be a second place for the rule to live.
canReply: REPLYABLE_TYPES.includes(thread.type) && !thread.locked && thread.status === 'visible',
posts: posts.map((p) => renderPost(p, mode, viewer)),
}
}
/**
* Open a thread: the thread and its first post, in one call.
*
* An announcement is a degenerate thread rather than its own thing (§5.1), which
* is why phase 5 added no migration — a discussion thread is the same two writes
* with a different `type`. The FIRST post is an ordinary post and is moderated,
* edited and reported like any other; nothing here marks it as special, because a
* thread whose opening post could not be moderated would be a hole shaped exactly
* like the one moderation exists to close.
*/
async function createThread({ team, actor, type, title, body }) {
if (!CREATABLE_TYPES.includes(type)) {
return { ok: false, status: 400, error: 'Unknown thread type' }
}
const cleaned = cleanForumBody(body)
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
return { ok: false, status: 400, error: 'A post needs a body' }
}
const threadId = await forumDb.insertThread({
teamId: team.id,
type,
title,
createdBy: actor.id,
createdUsername: actor.username,
})
const postId = await forumDb.insertPost({
threadId,
authorUserId: actor.id,
authorUsername: actor.username,
bodyHtml: cleaned,
})
// `notify` is what the CONTROLLER needs to fan a notification out, and it is a
// separate key rather than more fields on the result because the controller
// spreads the result straight into the response body — a notification's excerpt
// is not part of the API's answer to "did my post save".
//
// The notification itself is fired from the controller and not from here, on
// this file's own rule (see the header): everything in it takes an
// already-resolved access decision and reads no membership table. The fan-out
// reads both, so importing it here would make the forum model transitively
// depend on exactly what it exists not to touch.
return { ok: true, threadId, postId, notify: { threadId, title, type, bodyHtml: cleaned } }
}
/**
* Reply to a discussion thread.
*
* Three refusals, and the status codes are chosen to be distinguishable rather
* than uniform. A thread that is not there, or is hidden from this caller, is 404
* for the §5.5.1 reason. An announcement is 400 — the request is malformed for
* this thread, and no amount of retrying fixes it. A locked thread is **409**: the
* request is fine and the resource's state is what refuses, which is exactly the
* distinction a client needs to tell "you cannot" from "not right now".
*
* **Locked refuses staff too.** They hold `unlock`, so nothing is lost — and what
* is gained is that `locked` means the same thing to every reader. A moderator's
* reply appearing in a thread nobody else may answer is the last word by fiat;
* unlock, post, relock is the same outcome with three ledger rows saying so.
*/
async function createPost({ team, threadId, actor, body }) {
const thread = await forumDb.threadById(threadId)
if (!thread || thread.team_id !== team.id || thread.status !== 'visible') {
return { ok: false, status: 404, error: 'Thread not found' }
}
if (!REPLYABLE_TYPES.includes(thread.type)) {
return { ok: false, status: 400, error: 'Announcements do not take replies' }
}
if (thread.locked) {
return { ok: false, status: 409, error: 'This thread is locked' }
}
const cleaned = cleanForumBody(body)
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
return { ok: false, status: 400, error: 'A reply needs a body' }
}
const postId = await forumDb.insertPost({
threadId,
authorUserId: actor.id,
authorUsername: actor.username,
bodyHtml: cleaned,
})
// The thread's OWN title and type, not the reply's — a reply has neither, and
// what a recipient needs to know is which conversation moved. `type` is always
// 'discussion' here (an announcement takes no replies) and is carried anyway so
// the controller has one shape to hand the fan-out from both routes.
return { ok: true, threadId, postId, notify: { threadId, title: thread.title, type: thread.type, bodyHtml: cleaned } }
}
/**
* Edit a post: the author inside the window, staff at any time (§5.4).
*
* The window is re-derived HERE from `created_at` and never trusted from the
* request, which is also why `editability` runs on the read path — the read tells
* the client whether to draw the control, and this decides whether the edit
* happens. Two evaluations of one rule, deliberately: the read one is advice and
* this one is enforcement.
*
* A staffer editing someone else's post is reported back as `staffEdit` so the
* controller can write the §5.3 accountability row. A staffer editing their OWN
* post is an ordinary edit and is not: the trail records interventions, and
* everything a staffer ever typed is not an intervention.
*/
async function editPost({ team, postId, actor, isStaff = false, windowMinutes = 0, body }) {
const post = await forumDb.postById(postId)
if (!post) return { ok: false, status: 404, error: 'Post not found' }
const thread = await forumDb.threadById(post.thread_id)
if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Post not found' }
if (post.status !== 'visible' || thread.status !== 'visible') {
return { ok: false, status: 404, error: 'Post not found' }
}
const isAuthor = post.author_user_id != null && post.author_user_id === actor.id
if (!isAuthor && !isStaff) {
return { ok: false, status: 403, error: 'You may only edit your own posts' }
}
if (!isStaff) {
if (thread.locked) return { ok: false, status: 409, error: 'This thread is locked' }
const { canEdit } = editability(post, { userId: actor.id, windowMinutes })
if (!canEdit) {
return {
ok: false,
status: 403,
error: windowMinutes > 0
? `The ${windowMinutes}-minute edit window for this post has closed`
: 'Posts cannot be edited on this site',
}
}
}
const cleaned = cleanForumBody(body)
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
return { ok: false, status: 400, error: 'A post needs a body' }
}
await forumDb.updatePostBody(postId, cleaned, actor.id)
return { ok: true, postId, threadId: post.thread_id, staffEdit: isStaff && !isAuthor }
}
/**
* Apply a moderation action to a thread, and record WHICH authority did it.
*
* `actorRole` is 'leader' or 'staff' — the column that makes a leader's ordinary
* housekeeping distinguishable from a staff intervention after the fact (§5.3).
* The caller resolves it; this function records it and never infers it, because
* an actor who is both would otherwise be recorded as whichever the code checked
* first.
*/
async function moderateThread({ team, threadId, action, actor, actorRole, reason }) {
const effect = THREAD_ACTIONS[action]
if (!effect) return { ok: false, status: 400, error: 'Unknown moderation action' }
const thread = await forumDb.threadById(threadId)
if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Thread not found' }
await forumDb.setThreadFlags(threadId, effect)
await forumDb.insertModeration({
teamId: team.id,
targetType: 'thread',
targetId: threadId,
action,
actorUserId: actor.id,
actorUsername: actor.username,
actorRole,
reason,
})
return { ok: true, action, threadId }
}
/**
* Apply a moderation action to a POST, and record which authority did it.
*
* The same ledger as `moderateThread`, with `target_type='post'` — one table, two
* target kinds, because "show me everything that was moderated in this Team" is
* the question the admin view asks and two tables would make it a union.
*
* `pin` and `unpin`, `lock` and `unlock` are refused with a message that names the
* mistake rather than a bare "unknown action": they are real actions applied to
* the wrong kind of object, and a caller who sent one has a bug worth telling
* them about precisely.
*
* **The opening post of a thread is moderatable like any other.** Hiding it leaves
* a thread with a title and its replies and no body, which looks odd and is
* correct — an abusive opener does not have to take a good discussion with it, and
* a moderator who wants the whole thing gone has `hide` on the thread.
*/
async function moderatePost({ team, postId, action, actor, actorRole, reason }) {
const effect = POST_ACTIONS[action]
if (!effect) {
return {
ok: false,
status: 400,
error: THREAD_ACTIONS[action]
? `"${action}" applies to a thread, not to a post`
: 'Unknown moderation action',
}
}
const post = await forumDb.postById(postId)
if (!post) return { ok: false, status: 404, error: 'Post not found' }
const thread = await forumDb.threadById(post.thread_id)
if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Post not found' }
await forumDb.setPostStatus(postId, effect.status)
// The counters are recomputed rather than nudged, because these four actions
// form cycles (hide → unhide → hide) that a delta gets wrong the first time one
// is retried.
await forumDb.recountThread(post.thread_id)
// Images follow their post. Soft on the way out and reversible on the way back
// in, so `delete` → `restore` inside the retention window returns the post
// whole; past it, the sweep has taken the bytes and nothing can.
if (action === 'delete') await forumDb.softDeleteUploadsForPost(postId, actor.id)
if (action === 'restore') await forumDb.restoreUploadsForPost(postId)
await forumDb.insertModeration({
teamId: team.id,
targetType: 'post',
targetId: postId,
action,
actorUserId: actor.id,
actorUsername: actor.username,
actorRole,
reason,
})
return { ok: true, action, postId, threadId: post.thread_id }
}
/** The ledger for the admin Team page. Staff-only by its route, not by this function. */
async function moderationLedger(teamId, opts) {
return forumDb.moderationForTeam(teamId, opts)
}
module.exports = {
CREATABLE_TYPES,
CREATABLE_TYPES_5A,
REPLYABLE_TYPES,
THREAD_ACTIONS,
POST_ACTIONS,
listThreads,
getThread,
createThread,
createPost,
editPost,
moderateThread,
moderatePost,
moderationLedger,
publicThread,
renderPost,
editability,
}

View File

@@ -0,0 +1,197 @@
// ── The operator's forum controls, and the acknowledgement gate ────────────
//
// TEAMS.md §5.5, plus phase 5's edit window. Four `settings` keys, and the reason
// they live in their own file rather than in settings.model.js is that only two
// of them are ordinary keys: `teams_forum_images` has a server-side precondition,
// and a precondition buried in the generic setMany() loop is one nobody reading
// that loop would know about.
//
// teams_forums_enabled '0' | '1' default '0' — off
// teams_forum_images 'disabled' | 'remote' | 'uploads' default 'disabled'
// teams_forum_uploads_ack the acknowledged TEXT VERSION absent until given
// teams_forum_edit_window_minutes 0 … 1440 default 15 (phase 5)
//
// **Every read fails closed.** A DB fault reports the forum off, images disabled
// and the edit window shut, because the alternative is a transient error opening a
// feature the operator turned off, or rendering third-party images on a site whose
// operator chose not to. The cost of failing closed here is a forum that 404s for a
// minute; the cost of failing open is a policy that is not a policy.
const settingsDb = require('../settings/settings.db')
const ENABLED_KEY = 'teams_forums_enabled'
const IMAGES_KEY = 'teams_forum_images'
const ACK_KEY = 'teams_forum_uploads_ack'
const EDIT_WINDOW_KEY = 'teams_forum_edit_window_minutes'
const IMAGE_MODES = ['disabled', 'remote', 'uploads']
// How long an author may edit their own post. Staff are not bound by it (§5.4).
const EDIT_WINDOW_DEFAULT = 15
const EDIT_WINDOW_MAX = 1440 // a day; beyond that "window" stops meaning anything
// The version of the §5.5.5 warning text currently in force. Bumping this is what
// makes every stored acknowledgement stale — see `ackState` below for what that
// then does, which is deliberately NOT "turn uploads off".
const ACK_VERSION = '1'
/** Is the forum switched on? Fail closed. */
async function forumsEnabled() {
try {
return String(await settingsDb.get(ENABLED_KEY)) === '1'
} catch {
return false
}
}
/**
* The image policy. Fail closed, and coerce any unexpected stored value back to
* 'disabled' — a hand-edited row must not be able to widen the policy by being
* unreadable.
*/
async function imageMode() {
try {
const value = await settingsDb.get(IMAGES_KEY)
return IMAGE_MODES.includes(value) ? value : 'disabled'
} catch {
return 'disabled'
}
}
/**
* How many minutes an author has to edit their own post.
*
* Fails closed to ZERO rather than to the default, and that 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 who is quoting it or a moderator who
* is about to act on a report — so the safe answer during a DB fault is "nobody
* may edit for the next minute", not "everyone may edit for fifteen". Staff are
* unaffected either way, because their authority is not time-bounded.
*
* `0` is also a legitimate STORED value, meaning an operator who wants posts
* immutable once written. There is deliberately no distinction between "off" and
* "unreadable" here: both deny, and inventing a third state would only give the
* caller a decision to get wrong.
*/
async function editWindowMinutes() {
try {
const raw = await settingsDb.get(EDIT_WINDOW_KEY)
if (raw == null || raw === '') return EDIT_WINDOW_DEFAULT
const n = Number(raw)
if (!Number.isFinite(n) || n < 0 || n > EDIT_WINDOW_MAX) return EDIT_WINDOW_DEFAULT
return Math.floor(n)
} catch {
return 0
}
}
/** Are uploads accepted? The one mode where files come to rest on the operator's disk. */
async function uploadsEnabled() {
return (await imageMode()) === 'uploads'
}
/**
* The acknowledgement's state, for the admin surface.
*
* `stale` is the case §5.5.5 spends its longest paragraph on: the text was
* reworded after an operator accepted it. Neither obvious answer is right —
* silently downgrading a live feature because a legal text changed strands users
* mid-conversation, and honouring an old acceptance forever defeats versioning.
* So uploads keep working, `stale` drives a persistent banner, and
* `assertSettingsWritable` below refuses every other forum setting until it is
* re-given. Non-destructive, and impossible to ignore.
*/
async function ackState() {
const stored = await settingsDb.get(ACK_KEY)
const row = await settingsDb.getRow(ACK_KEY)
return {
version: ACK_VERSION,
acknowledgedVersion: stored ?? null,
given: stored != null,
stale: stored != null && String(stored) !== ACK_VERSION,
...(row ? { acknowledgedBy: row.updated_by_username ?? null, acknowledgedAt: row.updated_at } : {}),
}
}
/**
* The gate. `PUT teams_forum_images = 'uploads'` is rejected 400 unless the SAME
* request carries `acknowledge: <currentVersion>`.
*
* The checkbox in the admin UI is not the gate — it is how the gate is presented.
* That distinction is the whole reason this function exists on the server: an
* acknowledgement a client could skip is not an acknowledgement.
*
* Returns `{ ok }` or `{ ok: false, error, status }`, matching the model result
* shape the Teams controllers already translate.
*/
async function assertAcknowledged(nextMode, acknowledge) {
if (nextMode !== 'uploads') return { ok: true }
if (String(acknowledge ?? '') === ACK_VERSION) return { ok: true }
// **The gate is on SELECTING uploads, not on the value being present.**
//
// A settings form sends every field it owns, so once uploads is on, every later
// save re-sends `uploads` — turning the forum off, switching back to `remote`,
// any of it. Demanding a fresh acknowledgement for those would make the mode a
// one-way door: the operator could never change a forum setting again, and the
// one thing they would most want to do in a hurry (switch the forum off) would
// be the thing refused. Found on the live rig, where unticking "Enable Team
// forums" came back 400.
//
// So an acknowledgement already ON RECORD, for the version in force, while
// uploads is ALREADY the stored mode, is what this request needs — there is no
// new consent to take. A transition INTO uploads still needs the checkbox, and
// a stale acknowledgement is caught by assertSettingsWritable, which is the
// separate rule for a reworded notice.
const [state, current] = await Promise.all([ackState(), imageMode()])
if (current === 'uploads' && state.given && !state.stale) return { ok: true }
return {
ok: false,
status: 400,
error: `Enabling uploads requires acknowledging the current notice (version ${ACK_VERSION}).`,
}
}
/**
* The stale-acknowledgement lock: while an acknowledgement is stale, NO forum
* setting may be saved until it is re-given. Not "uploads are disabled" — see
* `ackState`. The re-acknowledgement itself is exempt, or the lock would have no
* key.
*/
async function assertSettingsWritable(keys, acknowledge) {
const touchesForum = keys.some((k) => k === ENABLED_KEY || k === IMAGES_KEY || k === EDIT_WINDOW_KEY)
if (!touchesForum) return { ok: true }
const state = await ackState()
if (!state.stale) return { ok: true }
if (String(acknowledge ?? '') === ACK_VERSION) return { ok: true }
return {
ok: false,
status: 400,
error: 'The image-upload notice has changed. Re-acknowledge it before saving forum settings.',
}
}
/** Record the acknowledgement. `updated_by`/`updated_at` come free from the settings schema. */
async function recordAck(adminUserId) {
await settingsDb.set(ACK_KEY, ACK_VERSION, adminUserId)
}
module.exports = {
ENABLED_KEY,
IMAGES_KEY,
ACK_KEY,
EDIT_WINDOW_KEY,
IMAGE_MODES,
ACK_VERSION,
EDIT_WINDOW_DEFAULT,
EDIT_WINDOW_MAX,
forumsEnabled,
imageMode,
editWindowMinutes,
uploadsEnabled,
ackState,
assertAcknowledged,
assertSettingsWritable,
recordAck,
}

View File

@@ -0,0 +1,177 @@
// ── `uploads` mode, and what had to harden first (TEAMS.md §5.5.4) ─────────
//
// The existing admin upload path (router/v1/admin/imageUpload.js) 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
// `X-Content-Type-Options: nosniff` forced on serve. All of that is kept and this
// file adds the four things that path never needed, because until now it has never
// had a hostile uploader.
//
// 1. MAGIC-BYTE SNIFFING. `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`. Trusted from an admin, not from a player.
// 2. QUOTAS. A per-post attachment cap and a per-account daily byte quota.
// Community uploads with no ceiling is disk exhaustion on the operator's own
// host. (The per-request RATE limit is core's rateLimit middleware, applied
// at the route.)
// 3. ATTRIBUTION. Every accepted file gets a `team_forum_uploads` row. Not
// bookkeeping: the acknowledgement in §5.5.5 is meaningless if "who uploaded
// this" cannot be answered afterwards.
// 4. LIFECYCLE. Deleting a post soft-deletes its uploads; the sweep removes the
// bytes after a retention window, and files with no row at all. The admin
// upload path never deletes anything, which is fine at admin volume and is
// not fine here.
const fs = require('fs/promises')
const path = require('path')
const forumDb = require('./teamForum.db')
const { UPLOAD_DIR } = require('../../router/v1/admin/imageUpload')
// Leading bytes → the type they actually are. Deliberately not a library: five
// signatures, checked exactly, is less surface than a dependency that accepts
// hundreds of formats when the allowlist only wants these.
//
// WebP and AVIF are container formats, so both need a second check past the first
// four bytes — RIFF alone is also .wav, and the `ftyp` box also fronts .mp4.
const SIGNATURES = [
{ mime: 'image/png', test: (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) },
{ mime: 'image/jpeg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
{ mime: 'image/gif', test: (b) => b.subarray(0, 6).toString('latin1').match(/^GIF8[79]a$/) != null },
{
mime: 'image/webp',
test: (b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP',
},
{
mime: 'image/avif',
test: (b) => b.subarray(4, 8).toString('latin1') === 'ftyp'
&& ['avif', 'avis'].includes(b.subarray(8, 12).toString('latin1')),
},
]
// Per-post attachment cap and per-account rolling byte quota.
const MAX_ATTACHMENTS_PER_POST = 6
const DAILY_QUOTA_BYTES = 25 * 1024 * 1024
const QUOTA_WINDOW_HOURS = 24
// Lifecycle windows. A soft-deleted file survives long enough for a mis-click to
// be recoverable; an orphan is one uploaded into a composer that was never
// submitted, which is a normal thing to do and so gets a generous grace.
const RETENTION_DAYS = 30
const ORPHAN_GRACE_HOURS = 48
/**
* What do these bytes actually claim to be?
*
* Returns the sniffed mimetype, or null when nothing matches. Null is a rejection
* and never a "trust the header instead" — an unrecognised file is exactly the
* case this check exists for.
*/
function sniff(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null
return SIGNATURES.find((s) => s.test(buffer))?.mime || null
}
/**
* Accept a file multer has already written to disk.
*
* The file is on disk before it can be sniffed — multer streams it there — so the
* rejection path has to REMOVE it. A rejected upload that stays on disk is exactly
* the disk-exhaustion vector the quota exists to close, reached by a different
* route.
*/
async function accept({ team, actor, file }) {
const stored = path.join(UPLOAD_DIR, file.filename)
const discard = async () => { await fs.rm(stored, { force: true }) }
let head
try {
const handle = await fs.open(stored, 'r')
try {
head = Buffer.alloc(16)
await handle.read(head, 0, 16, 0)
} finally {
await handle.close()
}
} catch {
await discard()
return { ok: false, status: 400, error: 'Could not read the uploaded file' }
}
const sniffed = sniff(head)
if (!sniffed || sniffed !== file.mimetype) {
await discard()
return { ok: false, status: 400, error: 'That file is not the image type it claims to be' }
}
const used = await forumDb.bytesUploadedSince(actor.id, QUOTA_WINDOW_HOURS)
if (used + file.size > DAILY_QUOTA_BYTES) {
await discard()
return { ok: false, status: 429, error: 'Daily upload limit reached. Try again tomorrow.' }
}
const id = await forumDb.insertUpload({
teamId: team.id,
postId: null, // attached when the post that embeds it is written
uploaderUserId: actor.id,
uploaderUsername: actor.username,
filename: file.filename,
mimetype: sniffed, // the SNIFFED type, never the client's header
byteSize: file.size,
})
return { ok: true, id, url: `/uploads/${file.filename}`, bytes: file.size }
}
/**
* Remove an upload. The uploader may, within the edit window; staff may at any
* time. Soft — the bytes go with the sweep, not with the button.
*/
async function remove({ id, actor, isStaff }) {
const row = await forumDb.uploadById(id)
if (!row || row.deleted_at) return { ok: false, status: 404, error: 'No such upload' }
if (!isStaff && row.uploader_user_id !== actor.id) {
return { ok: false, status: 403, error: 'Not your upload' }
}
await forumDb.softDeleteUpload(id, actor.id)
return { ok: true }
}
/**
* The nightly sweep: bytes for soft-deleted rows past retention, plus files on
* disk with no row at all.
*
* The orphan half deliberately only considers files whose names match the upload
* naming scheme AND appear in no row. UPLOAD_DIR is shared with the admin upload
* path, whose files have no row here and must never be swept — so the sweep works
* from the FORUM's own rows outward and never from the directory listing inward.
*/
async function sweep({ retentionDays = RETENTION_DAYS, orphanGraceHours = ORPHAN_GRACE_HOURS } = {}) {
const expired = await forumDb.sweepableUploads(retentionDays)
const orphans = await forumDb.orphanedUploads(orphanGraceHours)
const doomed = [...expired, ...orphans]
const cleared = []
for (const row of doomed) {
try {
await fs.rm(path.join(UPLOAD_DIR, row.filename), { force: true })
cleared.push(row.id)
} catch {
// Leave the ROW as well as the file. A file we could not delete is one the
// next run should try again, and dropping its row would lose the only
// record that the bytes are still there.
}
}
await forumDb.deleteUploadRows(cleared)
return { swept: doomed.length, filesRemoved: cleared.length }
}
module.exports = {
MAX_ATTACHMENTS_PER_POST,
DAILY_QUOTA_BYTES,
QUOTA_WINDOW_HOURS,
RETENTION_DAYS,
ORPHAN_GRACE_HOURS,
sniff,
accept,
remove,
sweep,
}

View File

@@ -0,0 +1,169 @@
// ── The grant/revoke flow (TEAMS.md §2.5 path 3) ───────────────────────────
//
// The RESOLVER lives in teamAccess.model.js and answers "may this account use the
// forum". This file is the WRITE half: who may hand that access out, to whom, and
// what stops a leader turning a Team forum into open hosting on the operator's
// site.
//
// **Two authorities, and they are not the same authority with different reach.**
//
// staff (admin | moderator) — any Team, no cap, may revoke anything
// leader (path 2, on THIS Team) — own Team, capped, may not revoke a staff grant
//
// The last clause is the one worth stating: a leader who could revoke a
// staff-issued grant could undo a moderation decision, which is the whole reason
// `granted_by` is retained rather than collapsed into a boolean.
//
// **Nothing here writes `team_members`, in either direction, ever.** A grant is
// not a membership: it may name any Runic Gateway account, including one with no
// linked game identity at all — that is the point of it, since letting an unlinked
// guildmate into the forum must not be a staff ticket. `teams.model.js` keeps such
// an account off the roster and out of every membership count, and path 4 keeps it
// off external platforms.
const accessDb = require('./teamAccess.db')
const teamsDb = require('./teams.db')
const access = require('./teamAccess.model')
const usersDb = require('../users/users.db')
const settingsDb = require('../settings/settings.db')
// The per-Team ceiling on ACTIVE leader-issued grants. A leader admitting
// unlimited arbitrary accounts to a private space on the operator's host is a
// quiet way to turn a Team forum into free hosting; the cap is what makes it a
// decision the operator made rather than one a leader made for them.
const CAP_KEY = 'teams_max_grants_per_team'
const DEFAULT_CAP = 50
const STAFF_ROLES = ['admin', 'moderator']
async function grantCap() {
const raw = await settingsDb.get(CAP_KEY)
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) && n > 0 ? n : DEFAULT_CAP
}
const isStaff = (actor) => STAFF_ROLES.includes(actor?.role)
/**
* What may this actor do with grants on this Team?
*
* Resolved once and returned whole, so the controller asks a question rather than
* assembling the answer from three booleans — the shape that lets a leader check
* and a staff check drift apart.
*/
async function authorityFor(teamId, actor) {
if (isStaff(actor)) return { may: true, as: 'staff' }
const leads = await access.isLeaderByUser(teamId, actor?.id)
return { may: leads, as: leads ? 'leader' : null }
}
/**
* Issue a grant. Returns the model result shape the Teams controllers translate:
* `{ ok }` or `{ ok: false, status, error }`.
*
* `warning` on a staff grant past the cap is deliberate and is not an error:
* staff are exempt, and silently exceeding a ceiling the operator configured is
* worth saying out loud on the way past.
*/
async function grant({ team, actor, userId, username, reason }) {
const authority = await authorityFor(team.id, actor)
if (!authority.may) return { ok: false, status: 403, error: 'Not a leader of this Team' }
const target = userId
? await usersDb.findById(userId)
: await usersDb.findByUsername(username)
if (!target) return { ok: false, status: 404, error: 'No such account' }
const existing = await accessDb.activeGrant(team.id, target.id)
if (existing) return { ok: false, status: 409, error: 'That account already has an active grant' }
const cap = await grantCap()
const count = await accessDb.activeGrantCount(team.id)
let warning = null
if (count >= cap) {
if (authority.as === 'leader') {
return { ok: false, status: 409, error: `This Team has reached its limit of ${cap} forum guests` }
}
warning = `This Team is past the configured limit of ${cap} forum guests`
}
await accessDb.insertGrant({
teamId: team.id,
userId: target.id,
username: target.username,
grantedBy: actor.id,
grantedUsername: actor.username,
reason,
})
return { ok: true, as: authority.as, grantee: target.username, ...(warning ? { warning } : {}) }
}
/**
* Revoke a grant.
*
* The one asymmetry with `grant`: a leader may not revoke what staff issued.
* Checked against `granted_by`'s role AT REVOKE TIME rather than against a stored
* flag, so an account that has since lost its staff role stops protecting the
* grants it made — which is the behaviour an operator demoting someone expects.
*/
async function revoke({ team, actor, userId, reason }) {
const authority = await authorityFor(team.id, actor)
if (!authority.may) return { ok: false, status: 403, error: 'Not a leader of this Team' }
const existing = await accessDb.activeGrant(team.id, userId)
if (!existing) return { ok: false, status: 404, error: 'No active grant for that account' }
if (authority.as === 'leader' && existing.granted_by) {
const issuer = await usersDb.findById(existing.granted_by)
if (isStaff(issuer)) {
return { ok: false, status: 403, error: 'That access was granted by staff and only staff may revoke it' }
}
}
await accessDb.revokeGrant({
teamId: team.id,
userId,
revokedBy: actor.id,
revokedUsername: actor.username,
reason,
})
return { ok: true, as: authority.as, grantee: existing.username }
}
/**
* The Team's forum guests — active grants for accounts that are NOT members.
*
* The subtraction is the §3.2 "Forum guests" list: someone who is both a member
* and a grantee is a member, listed on the roster, and appears here not at all.
* Both facts stay true in the ledger; only the presentation picks one.
*/
async function forumGuests(teamId) {
const [grants, members] = await Promise.all([
accessDb.activeGrants(teamId),
teamsDb.membersByTeam(teamId, { includeDeparted: false }),
])
const memberUserIds = new Set(members.map((m) => m.user_id).filter((id) => id != null))
return grants
.filter((g) => g.user_id == null || !memberUserIds.has(g.user_id))
.map((g) => ({
userId: g.user_id,
username: g.username,
grantedBy: g.granted_username,
grantedAt: g.granted_at,
reason: g.reason,
}))
}
module.exports = {
CAP_KEY,
DEFAULT_CAP,
// Exported since phase 7: the Discord dispatcher's `access: 'staff'` has to
// mean the same two roles every other Team surface means by it, and a second
// copy of the list is a copy that drifts.
STAFF_ROLES,
grantCap,
authorityFor,
grant,
revoke,
forumGuests,
}

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