diff --git a/android/PLAN.md b/android/PLAN.md index 483996e..3397b62 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -1112,6 +1112,32 @@ push, and Play (M6–M8) follow the designed app. ### Deferred (not a milestone) +- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is + building Teams as a core platform primitive (pages, roster, activity feed, forums, notifications — + [`../website/TEAMS.md`](../website/TEAMS.md)), and the app has no story for any of it. Deferred + deliberately rather than scheduled: this milestone list is already behind, and adding a large new + surface ahead of the outstanding work would make that worse. + + **What lands anyway, with no app change**, when the website ships its Phase 6: the four Team + notification streams (`team.member.joined`, `team.leadership.changed`, `team.forum.post`, + `team.announcement`) appear in `GET /auth/me/notifications/streams`, so the existing Notifications + screen renders toggles for them automatically — the catalog is server-driven. + + **The stated limitation, so it is not mistaken for a bug:** a Team tickle opens the app and no more. + There is no Team screen and no deep-link target, and the per-Team mute list + (`GET|PUT /auth/me/notifications/teams`) has no UI — a user who wants to mute one Team has to do it + on the website. Whether to ship the toggles in that state, or hide the four streams behind a + capability check until there is somewhere to land, is the first decision this milestone makes when + it is picked up. + + **Revival trigger:** website Phases 3–5 landed (Team pages, roster and forums exist to link to) *and* + the current milestone backlog is clear. Scope when it comes back: a Teams tab reading + `/public/teams` + `/player/teams`, deep links for the four streams, and the mute list. + + > **Note the PUT-the-whole-set trap below applies to `/auth/me/notifications/teams` too** — the mute + > list is a replace-the-set endpoint, so its request DTO field must have **no default** or kotlinx + > drops it when the set is empty. + - **`/api/mobile` facade migration + app-version floor** — briefly planned as its own milestone (2026-07-22), now **deferred with no app work scheduled**. The website's router refactor is being done in place with every URL byte-identical and `/api/v1` is not being retired, so the app's ~70 hardcoded `api/v1/…` diff --git a/website/TEAMS.md b/website/TEAMS.md new file mode 100644 index 0000000..43da912 --- /dev/null +++ b/website/TEAMS.md @@ -0,0 +1,2015 @@ +# Platform Teams & Community Integration — design of record + +> **Status:** design, not built. Written 2026-08-17 against the working tree at `website` `origin/main` +> `e0c961c` (the module-system cutover), `module-uo` `main` `97e2fdd`, `link` `main` `7b65840`, +> `servuo-plugins` `main` `c045bdd`. Nothing below is implemented. +> +> Companion to [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) (what the module system is), +> [`MODULE_API.md`](MODULE_API.md) (the normative core↔module contract), +> [`THEMING_AND_NAV.md`](THEMING_AND_NAV.md) (nav registration and its gates), +> [`BACKEND_DESIGN.md`](BACKEND_DESIGN.md) (API/schema/security conventions) and +> [`../link/PROTOCOL_2.md`](../link/PROTOCOL_2.md) §10.1 (the guild stream this depends on). + +**The one sentence.** A **Team** is a core platform entity with a durable identity, owned by the +website; a game **module** is authoritative for who the Team is and who belongs to it; everything the +platform attaches to a Team — pages, roster, activity, forums, notifications, external integrations — +is core's, and Discord is one optional consumer of it rather than the place Teams live. + +``` +Game → Module → Team (core) → pages · roster · activity · forums · notifications + → optional integrations (Discord today; another platform later) +``` + +> **Where the boundary is: [Part 10](#part-10--ownership-what-is-contract-what-is-core-internal-and-what-is-the-modules).** +> This document spans a versioned contract, a large body of core-internal code, and a module, and the +> three appear in the same paragraphs throughout. Part 10 sorts every artifact into one of four +> classes and is the section to check a specific question against. The short version: **almost none of +> this is contract.** The contract is the ten members in §10.2; the ~15 tables are core-internal and a +> module may never touch them, even though the module is what populates them. + +--- + +## Part 0 — What is actually there today + +Five findings changed the design. They are recorded first because three of them contradict the brief +this document was written from, and a plan built on the brief's version would have been wrong. + +### 0.1 The guild stream carries counts, not a roster — this is the largest single gap + +`servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeSocial.cs` sweeps `BaseGuild.List` every +`GuildSweepSeconds` (60s), folds each guild to a signature, and emits `guild.update` on change: + +```json +{"kind":"guild.update","id":1234,"name":"The Silver Hand","abbr":"TSH", + "members":14,"online":3,"alliance":null,"leader":{"serial":"0x42","name":"Aldric","acct":"…","webId":7}} +``` + +`members` and `online` are **integers**. There is no member list on the wire, none in the sidecar's +`guilds` board, and none in `module-uo`'s `shard_guilds` table (`module-uo/server/db/schema.sql:206`, +whose columns are `members INT` / `online INT`). `guild.remove` carries only an id. There is a +real-time `guild.join` (from `EventSink.JoinGuild`) but **no `guild.leave`** — the file's own comment +says a leave "surfaces as the member count dropping in the next `guild.update`". + +There is also exactly **one** leader: `leader_*` columns flattened from `Guild.Leader`. + +So `getTeamMembers()`, `getTeamLeaders()`, `team.member.added`, `team.member.removed`, the roster, the +linked/unlinked split and reconciliation **have no data source at all today**. Every one of them +requires new emitters in `servuo-plugins/`, new board storage and a REST projection in `link/`, ingest +in `module-uo/`, and a spec change in `docs/` — a coordinated four-repo change with a +`PROTOCOL_VERSION` bump (§9, Phase 1). + +Two things make this cheaper than it sounds. ServUO's `Guild.Id` is a **persistent integer** that +survives a rename, so the module can distinguish rename-vs-different-guild exactly as Part 2 requires. +And ServUO guilds already carry **per-member ranks** (`RankDefinition.Ranks`, `Scripts/Misc/Guild.cs:38` +— Ronin/Member/Emissary/Warlord/Leader, and any number of members may hold rank 4), so "multiple +leaders from the start" is a `GuildRank.Rank >= 4` read, not a modelling problem. + +### 0.2 There is no module→core news hook to generalize + +The brief's Part 1.3 assumes "modules already push news items that the site delivers". They do not. +Every existing news seam runs **core → module**: + +- `ctx.posts` is read-only — `listAll`, `getById`, `linkAnnounceJob`, `markAnnounced`. `create`, + `update` and `remove` are deliberately withheld (`MODULE_API.md` §2.3): "the CMS is not a module's." +- `registerPostHook({ onSaved, onDeleted })` is core *telling* a module a post changed. +- `registerAnnounceLeg({ dispatch, classify })` is core asking a module to *deliver* a core post + somewhere else — that is how module-uo's town crier gets news into the game. + +The only module→core content seam that exists is `ctx.push.publish(streamId, { ref, ownerUserId })`. +So the Team activity feed **cannot** reuse the news hook; it needs its own ingestion member, modelled +on `ctx.push.publish` (§4). This is recorded as a correction, not a preference. + +### 0.3 SSE is entirely module-owned; core has none + +`grep -rn "text/event-stream"` across the working tree returns **zero** hits in `website/server/` and +three in `module-uo/` (`server/utils/shardBroadcast.js` is the whole implementation). The +public/admin fan-out, the audience-rung resolution and the field projection all left core with the +module-system cutover. + +So a core-owned Team roster cannot subscribe to a core live channel — there isn't one. Roster +online-status is therefore **module-projected data delivered through the Team provider** (§2.3), and +the *live* half of it is a module-owned client concern: `module-uo` already publishes +`useShardFeed`/`shardEvents` in its client chunk and already renders live boards. Core's Team page +renders a last-known online count from the sync and declares a `team.overview` extension slot (§3) the +module fills with anything live. Core does not grow an SSE stack for this. + +### 0.4 The Discord bot is a separate container that cannot load module code + +`website/docker-compose.yml`: the `bot` service is a distinct prebuilt image with its own DB pool +(`bot/src/db.js`), **no `./modules` bind mount**, and no published port. The two processes talk over +exactly two shared-secret HTTP channels: + +- **app → bot**, `utils/botInternalClient.js` → `bot/src/internal/internal.routes.js` + (`/internal/config`, `/internal/status`, `/internal/announce`, `/internal/mod-reverse`), 4s timeout, + never throws, always returns `{ ok, status, data, error }`. +- **bot → app**, `SITE_INTERNAL_URL=http://app:3001/internal/bot-config` on the app's *unpublished* + internal listener (`server/src/internalApp.js`), with a retry-with-backoff bootstrap so a bot + restart self-heals. + +Slash commands are registered from a static array (`bot/src/discord/commands/index.js`) and pushed +with `REST.put(Routes.applicationGuildCommands(...))` on ready (`discordManager.js:25`) — a **whole-set +PUT**, which means deregistration is already free. + +A module therefore **physically cannot** put a handler function in the bot process. The command +contract has to cross a process boundary (§6.1), and that is a constraint of the deployment, not a +design choice. + +### 0.5 Notification subscriptions have no scope dimension + +`notification_subscriptions` is `PRIMARY KEY (user_id, stream_id)` — nothing else. The stream catalog +is a **static registration** validated at boot (`registries.js:checkStreamShape`, id pattern +`.`), so "one stream per Team" is not expressible: the catalog is fixed before any +Team exists. `pushDispatch.publish` fans out to either *every* subscriber of a stream or *one* +`ownerUserId`'s devices. There is no "these N users" path. + +The good news is that the ntfy layer itself needs **nothing**: a topic is a per-device UnifiedPush +endpoint, not a per-subject channel, and the payload is a content-free tickle. Team scoping is a +recipient-set problem inside the website, not a topic problem in the relay (§5). + +### 0.6 What is reusable as-is + +| Thing | Where | Used for | +| --- | --- | --- | +| Account link (game ↔ site) | `module-uo` `shard_account_links (account → user_id)` | the first hop of the identity chain (§2.5) | +| External identity (site ↔ Discord) | core `user_identities (provider, subject, user_id)` | the last hop; already how appeals matches a player to a `mod_actions` target | +| Online characters + linked user | `module-uo` `shard_online (serial, name, acct, web_id)` | roster online-status without any new transport | +| Audience rungs + field projection | `module-uo/server/utils/shardVisibility.js` | roster field-level gating (§3.3) | +| Audit log | core `activity_log` + `ctx.activity.log` | forum grant/revoke audit (§2.6) | +| HTML sanitizer | core `utils/sanitizeHtml.js` (wiki, posts) | forum post bodies — but via a **derived, stricter profile**, never `cleanBody` itself (§5.5.3) | +| Rate limiting | `ctx.middleware.rateLimit` | forum write paths | +| Push fan-out | `utils/pushDispatch.js` | Team notifications (§5) | +| Best-effort bot channel | `utils/botInternalClient.js` | the notifications bridge (§6.2) | +| Client extension slots | `MODULE_API.md` §3.7 | module content on a core Team page (§3.4) | +| Nav registration + override merge | `MODULE_API.md` §3.3, `THEMING_AND_NAV.md` §7 | the Teams nav entry (§3.5) | + +### 0.7 Decisions taken by the org lead before this was written + +1. **Teams are module-sourced only.** Bare core has no Teams and renders no Team UI. There is exactly + one writer of the membership projection. +2. **Slash commands: definition + handler registered in the website, execution dispatched back over + internal HTTP.** The bot owns every Discord-specific concern. +3. **Roster on the wire: full member list, but site identity only for linked members.** Every member + contributes `serial`, `name`, `rank`, `online`; `acct`/`webId` are present only for members whose + game account is linked. +4. **Team pages and rosters are public**, projected per the existing shard-visibility audience rungs. + Forums and the activity feed are members-only. + +--- + +## Part 1 — Scope, and the invariants everything else serves + +Six invariants. Each has a test named against it in the phase that introduces it. + +1. **Module unavailability is staleness, never emptiness.** No Team subsystem — sync, roster, leaders, + online status, integration reconciliation — may apply a destructive result derived from a failed, + timed-out or unanswered module call. §2.4 is the mechanism. +2. **Four authority paths stay four.** Game membership, leadership, forum access and external-platform + access are separate tables answering separate questions, resolved by separate predicates. No + predicate reads another's table. +3. **Non-contamination.** A manual forum grant never writes the membership projection, in either + direction, ever. Both facts coexist; neither migrates into the other. +4. **A Team's name is immutable for the life of its record.** A rename is an archive plus a create. +5. **Core never interprets module vocabulary.** Activity `kind`s, Team metadata and capability strings + are opaque. Core stores, gates and displays; it never branches on content it does not own. +6. **The game never touches the website.** Everything crosses the sidecar. Unchanged from + `MODULE_API.md` §2.7. + +**Out of scope, explicitly:** multi-module namespacing (single active module per deployment — +`module_id` columns exist so this is later-friendly, and nothing is built to exercise them); Team +hierarchies/alliances; cross-Team messaging; a Matrix implementation (§7 is research only); +platform-only Teams with no game backing (§0.7 decision 1 — the `source` discriminator is *not* added +speculatively, since decision 1 was "module-sourced only", and adding it later is one additive column). + +--- + +## Part 2 — Team core + +### 2.1 Persistence + +New core tables, all in `server/db/schema.sql`, all forward-only and idempotent per `MODULE_API.md` +§2.6's rules (`CREATE TABLE IF NOT EXISTS`, `ALTER TABLE … ADD COLUMN IF NOT EXISTS`, no `DROP`). +Core tables carry no prefix requirement — that rule (§2.6) binds modules, not core. + +```sql +-- The Team itself. `external_id` is the module's own stable identity for it +-- (module-uo sends the persistent ServUO Guild.Id). `name` is immutable: a +-- rename archives this row and creates a new one (§2.2). +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 + -- Staff may change what is DISPLAYED without touching identity (§2.8.3). + display_name_override VARCHAR(160) 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) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- The membership PROJECTION. Module-authoritative; core only mirrors it. +-- Rows are soft-departed rather than deleted so history and rejoin detection +-- survive, and so the activity feed can still name a departed member. +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), + 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 +-- 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; +``` + +`teams.slug` exists because the Team page URL must be stable and readable, and the game's name is not +URL-safe. It is derived once at create (slugify + numeric suffix on collision) and, like `name`, never +changes for the life of the row. + +### 2.2 Identity and the rename rule + +Core's key is **(`module_id`, `external_id`, `name`) taken together**, not `external_id` alone. + +- New `external_id` → create a Team. +- Known `external_id`, same `name` → update in place (`abbr`, `meta`, counts, roster). +- Known `external_id`, **different `name`** → archive the existing row + (`status='archived'`, `archived_reason='renamed'`) and create a new one. The old Team keeps its + forum, its activity history, its grants and its integration record; all become read-only. +- `external_id` absent from an authoritative full list → archive + (`archived_reason='disbanded'`), subject to §2.4's guard. + +This puts the whole of "is this a rename or a different guild?" inside the module: if a game has no +persistent guild id, its module can synthesise `external_id` from whatever *is* stable, or fold the +name into it so every rename is a fresh id. Core sees only "an id appeared / an id's name changed / an +id is gone". + +An archived Team is reachable at its old slug (read-only, `noindex`), so a Discord message or a +bookmark from before the rename still lands somewhere that explains itself rather than 404ing. Core +renders a banner linking to the successor when one exists — recorded via a nullable +`teams.succeeded_by INT NULL` written at archive time. + +### 2.3 The module-facing interface + +A new registration, staged and committed like every other (`MODULE_API.md` §2.4). One provider per +deployment; a second registration is a collision and is rejected. + +```js +api.registerTeamProvider({ + getTeams, // () => Promise<{ ok, complete, teams }> + getTeamMembers, // (externalId) => Promise<{ ok, complete, members }> + getTeamLeaders, // (externalId) => Promise<{ ok, leaders }> // leaders = [memberKey] +}) +``` + +**Every method returns an envelope, never a bare array.** This is the mechanism for invariant 1 and it +is why the signature does not look like the brief's `getTeams()`: + +```js +// authoritative +{ ok: true, complete: true, teams: [ { externalId, name, abbr, meta } ] } +// the module knows it cannot answer — sidecar down, cache cold, boot not finished +{ ok: false, reason: 'sidecar unreachable' } +``` + +A rejected promise, a timeout (core budget: 10s), a non-object, or a missing `ok` is treated exactly +as `{ ok: false }`. **There is no shape a failure can take that core reads as "zero teams".** A bare +array would have had one — `[]` — and that is the whole argument for the envelope. + +`complete: false` means "this is a valid but partial answer": core applies additions and updates and +performs **no** removals. It exists for a module that can enumerate cheaply but not exhaustively. + +A member: + +```js +{ memberKey: '0x40012ab3', // stable, module-owned + displayName: 'Aldric', + rankLabel: 'Warlord', // opaque + leader: false, + online: true, + userId: 7 | null } // resolved BY THE MODULE — it owns the link table +``` + +`userId` is resolved module-side deliberately. `shard_account_links` is module-owned +(`module-uo/server/db/schema.sql:144`), and a core that resolved it would be core reading a module's +table by name. The cost is that a *newly* linked account does not appear as linked until the next +sync — closed by the module calling `ctx.teams.reconcile()` immediately after a successful link, which +it already has the hook for. + +Alongside the pull interface, three `ctx` members for push: + +```js +ctx.teams.publish(event) // team.created | team.disbanded | team.member.added | team.member.removed + // + team.leader.added | team.leader.removed +ctx.teams.reconcile({ reason }) // request an immediate reconciliation; debounced, never awaited by the caller +ctx.teams.activity.push(items) // §4 +``` + +Six event kinds, not the brief's four: leadership is its own authority path (§2.5), so a leadership +change must be expressible without pretending someone joined or left. + +Events are an **optimisation, never the source of truth.** They make the common case immediate; +reconciliation is what makes it correct. An event for an unknown `externalId` schedules a +reconciliation rather than inventing a Team, because a Team created from a delta has no name, no +roster and no leaders. + +### 2.4 Reconciliation, and the stale/empty rule + +**When it runs:** + +| Trigger | Why | +| --- | --- | +| `onBoot`, after every module started | the website may have been down across a whole guild war | +| Poll, default 900s (`teams_reconcile_interval_s` setting) | the backstop for a missed event | +| `ctx.teams.reconcile()` | the module knows something core cannot — a sidecar reconnect, a fresh account link | +| Admin → Teams → **Resync** | the operator's escape hatch | +| An event naming an unknown Team | a delta arrived before its subject existed | + +Runs are serialised per module (an in-process lock) and debounced to at most one per 30s, so a +sidecar flapping cannot turn into a reconciliation storm. + +**The algorithm, and the four places it refuses to act:** + +1. Call `getTeams()`. **Not `ok`** → write `team_sync_state` (increment failures, record the error), + log at `warn`, **return without touching a single row**. Backoff is exponential on + `consecutive_failures`, capped at the poll interval. +2. `ok` but the list is **empty while core holds ≥1 active Team** → do not apply. Stamp + `pending_empty_since` and return. Apply only if the *next* authoritative answer, at least one full + interval later, is also empty. Rationale: a module that answers `ok:true` with an empty list during + a sidecar cold start is the one failure indistinguishable from a real wipe, and "every Team on the + shard disbanded simultaneously" costs one interval of delay to confirm. Any non-empty answer clears + the quarantine. +3. For each Team in the answer, call `getTeamMembers()`. **Not `ok` for that Team** → leave that + Team's roster **entirely untouched**, mark it stale, continue with the others. One Team's + unanswerable roster is not the other Teams' problem, and it is certainly not an empty roster. +4. `ok` but zero members for a Team that currently has members → same two-strikes quarantine as (2), + per Team. + +Only after those gates: upsert Teams, upsert members (`last_seen_at` bumped), mark unseen members +`departed`, archive unseen Teams, recompute the three counts, and write `last_success_at`. + +**Staleness is surfaced, not silent.** `GET /api/v1/public/teams` and every Team page carry +`{ stale: bool, lastSyncAt }`; the UI renders "roster last confirmed 14 minutes ago" once past a +threshold (2× the poll interval). Admin → Teams shows `team_sync_state` verbatim, including the last +error. A stale sync also **suspends every integration reconciliation** (§6.3) — a voice channel is +never created or destroyed on data core does not trust. + +### 2.5 The four authority paths + +The single most important structural rule in this document: these are four tables answering four +questions, and **no resolver reads another path's table.** + +| # | Question | Authoritative source | Table | Who can change it | +| --- | --- | --- | --- | --- | +| 1 | Is this account a member of the Team? | module | `team_members` | the sync, and only the sync | +| 2 | Does this account lead the Team? | module | `team_members.is_leader` | the sync; **plus staff override** (§2.5.1) | +| 3 | May this account use the Team forum? | **core** | `team_forum_grants` **OR** path 1 | staff, and leaders (grant only ordinary access) | +| 4 | May this account get external-platform access? | **core**, derived | none of its own | nobody directly — it is computed | + +**Path 2 — leadership.** The sync writes `is_leader` from `getTeamLeaders()`. Multiple leaders are the +normal case. **A leader can never promote or demote another leader**: there is no endpoint that writes +`is_leader`, at all, other than the sync and the staff override below. This is enforced structurally — +the leader-facing controller has no code path to that column — and asserted by a test named for the +rule. + +#### 2.5.1 Staff override on leadership + +Staff may set a `team_leader_overrides (team_id, member_key, effect ENUM('grant','deny'), actor_user_id, +reason, created_at)` row, applied **on top of** the synced value at read time. The projection is never +mutated (invariant 3 generalised): the sync keeps writing what the game says, and the override keeps +saying what staff decided. Overrides are listed on the Team's admin page with who set them and why, +and they survive a resync — which is the entire point, since a sync that clobbered a staff decision +every 15 minutes would be useless. This is the "staff can intervene where the platform already permits +staff intervention" clause, made concrete. + +**Path 3 — forum access.** + +```sql +-- Append-only grant/revoke ledger AND current state. An active grant is one with +-- revoked_at IS NULL; the generated column is again how "one active grant per +-- (team,user)" is expressed without a partial index. +CREATE TABLE IF NOT EXISTS team_forum_grants ( + id INT AUTO_INCREMENT PRIMARY KEY, + team_id INT NOT NULL, + user_id INT NOT NULL, + granted_by INT NULL, -- NULL only for a system grant + granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + reason VARCHAR(255) NULL, + revoked_by INT NULL, + revoked_at DATETIME NULL, + revoke_reason VARCHAR(255) NULL, + active_user INT AS (IF(revoked_at IS NULL, user_id, NULL)) STORED, + UNIQUE KEY uq_team_forum_grant_active (team_id, active_user), + 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 CASCADE, + INDEX idx_tfg_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +The resolver, which is the whole of path 3: + +```js +// server/src/model/teams/teamAccess.model.js +async function forumAccess(teamId, userId) { + const grant = await grants.activeFor(teamId, userId) // team_forum_grants + const member = await members.activeByUser(teamId, userId) // team_members + return { + allowed: Boolean(grant) || Boolean(member), + viaMembership: Boolean(member), + viaGrant: Boolean(grant), // retained even when viaMembership is also true + isLeader: Boolean(member && member.is_leader) || staffOverrideGrant, + } +} +``` + +Two reads, OR'd. `viaGrant` is reported **even when membership also holds** — that is the "both facts +coexist; the UI presents membership as the current reason while the grant is kept as audit history" +requirement, and it falls out of not collapsing the two booleans into one. + +- A grant may name **any** RunicGateway account, including one with no linked game identity of any + kind. That is the point: letting an unlinked guildmate into the forum must not be a staff ticket. +- Granting **never** writes `team_members`. Revoking never writes `team_members`. A test asserts a + byte-identical `team_members` row set across a grant/revoke cycle. +- `has forum access` is **never** read as `is a Team member`. A test asserts that a granted, unlinked + user is absent from `GET /teams/:slug/members` and from every membership count. + +**Who may grant.** Staff (`admin`, `moderator`) always. A leader may grant and revoke **ordinary** +forum access on their own Team, and cannot grant leadership or touch membership — there is no +parameter that would let them try. A leader cannot revoke a *staff-issued* grant +(`granted_by` is staff), which stops a leader undoing a moderation decision. + +**Leader grants are capped and rate-limited.** A per-Team ceiling on active grants +(`teams_max_grants_per_team`, default 50) plus core's `rateLimit` middleware on the grant route. +Without it a leader can admit unlimited arbitrary accounts to a private space on the operator's site, +which is a quiet way to turn a Team forum into open hosting. Staff are exempt from the cap and see a +warning when they cross it. + +**Path 4 — external access.** Computed, with no table of its own, and deliberately blind to path 3: + +```js +async function externalEligible(teamId, userId, platform) { + const member = await members.activeByUser(teamId, userId) // path 1 ONLY + if (!member || member.user_id == null) return false // must be a LINKED game member + return identities.has(userId, platform) // core user_identities +} +``` + +A test asserts `externalEligible` returns `false` for a user with an active forum grant and no +membership. 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. + +### 2.6 The identity-binding chain + +Four hops, three of them stored in different places, and the design's most common source of +confusion — "this account is linked" answers only *one* of them. + +``` +RunicGateway account users.id + │ hop 1: linked GAME identity module-owned: shard_account_links(account → user_id) + ▼ +game account / character team_members.member_key, team_members.user_id + │ hop 2: Team membership core: team_members (the sync's projection) + ▼ +Team member teams.id + │ hop 3: linked EXTERNAL identity core: user_identities(provider='discord', subject) + ▼ +Discord user id the thing the bot can actually act on + │ hop 4: the integration's own grant + ▼ +channel permission overwrite / role +``` + +Hops 1 and 3 are **independent opt-ins by the same person**. Every one of the four failure modes is a +real state with a distinct surface: + +| Broken hop | What it looks like | What the UI says | +| --- | --- | --- | +| 1 | in the guild, invisible to the site | roster row, greyed, "not linked" | +| 2 | linked account, not in this Team | not on the roster at all | +| 3 | linked Team member, no Discord | roster row normal; voice access absent, "link Discord to join voice" | +| 4 | everything linked, bot could not act | Admin → Teams shows the integration error | + +### 2.7 Audit + +Every forum grant, forum revoke, leadership override, staff archive and manual resync writes a core +`activity_log` row through the existing `activity.log({ req, action, detail })` — the same log staff +already read, so a Team action is not in a second place nobody checks: + +``` +team.forum.grant detail: 'Leader Bob (#12) granted forum access to Alice (#88) on team "The Silver Hand" (#3): "recruit, not linked yet"' +team.forum.revoke … +team.leader.override … +``` + +`team_forum_grants` remains the structured record (it is what the resolver reads); +`activity_log` is the human-readable trail. Both, not one — the ledger cannot be paged through by an +admin looking for "what did this moderator do last week", and the activity log cannot answer "does +Alice have access right now". + +### 2.8 Reserved-name screening — the one place untrusted game data becomes a public page + +**The threat.** A Team's name is written by a player, inside the game, with no review, and this design +then turns it into a public page, a URL, a nav-reachable entity, a Discord message and eventually a +voice-channel name. Someone naming their guild **"Admin"**, **"Moderator"** or **"UOMysticmoon Staff"** +gets an official-looking page on the operator's own site, for free, by typing a name into a guild +stone. That is an impersonation vector, and it is the *only* place in this design where unsanitized +game data acquires platform authority — which is exactly why the approval gate in §2.9 is scoped to +it rather than to staff actions generally. + +#### 2.8.1 What is reserved + +A core utility, `utils/reservedNames.js`, resolved at check time (never baked in, since the brand is +runtime configuration): + +| Source | Terms | +| --- | --- | +| Role names | the `users.role` enum — `admin`, `editor`, `moderator`, `player` — plus `staff`, `administrator`, `mod`, `owner`, `gm` | +| The deployment's brand | `settings.site_title`, `BRAND_NAME`, `BRAND_SHORT_NAME` — i.e. whatever `settings.getInstanceName()` resolves to, plus `brand.shortName` | +| The project | `Runic Gateway` — impersonating the software project is as much a problem as impersonating the operator | +| Operator additions | a `teams_reserved_terms` setting, comma-separated, for anything a particular community needs | + +**Not `filter_words`.** That table exists but is **bot-owned** (its own pool, `bot/src/model/`, +never read by the website — `MODERATION_APPEALS.md` §2 is explicit that the two sides share no live +FK) and it is a profanity filter, which is a different question with a different answer. Reusing it +would cross an ownership boundary to get the wrong list. + +#### 2.8.2 How it matches + +Whole **words**, after normalisation — not substrings. Core already has the precedent and the scar +tissue for this: `scripts/checkModuleIdentifiers.js` tokenises on camelCase humps and on +`-`/`_`/`.`/`/` and compares word by word, precisely so `defaultImage` does not match "ultIma" +(`MODULE_API.md` §5.2). The same discipline applies here, for the same reason: a substring match +flags **"Badminton"** for containing "admin", and a check that cries wolf is a check people switch +off. + +Normalisation: case-fold, strip punctuation and repeated characters, collapse whitespace. Deliberately +**no leet-speak folding** in v1 (`4dm1n`) — it multiplies false positives, and the consequence of a +miss is a hidden-by-a-human Team rather than a breach. + +#### 2.8.3 What a match does: hide, never reject + +A Team whose name trips the list is created **normally** and then **auto-hidden**: `hidden = 1`, +`hidden_reason = 'reserved_name'`, `hidden_term` recording which term matched. + +- Hidden means **absent from every public surface** — the `/teams` index, search, the roster, + activity, the Discord bridge, and any voice provisioning. It is not archived and not deleted. +- The Team **works completely for its own members**: its forum, its grants and its notifications are + untouched. The people in it are not being punished for a name their leader chose. +- It lands in a **review queue** on Admin → Teams, with the matched term shown. + +**Hide rather than reject, and this is the point.** Core cannot refuse a name — the guild already +exists in the game and core is a mirror, not an authority over it. And because the failure mode of an +over-eager match is "a legitimate guild is invisible until a human looks", **false positives are cheap +and recoverable while false negatives are not.** That asymmetry is what lets the matcher be +conservative without being clever. + +**The staff override to allow.** Staff clear the hide (`hidden = 0`), which is recorded with who, +when and why. A `display_name_override` may be set instead of, or alongside, un-hiding — that changes +what is **rendered** everywhere (page, nav, Discord channel name) while the identity `name` stays +frozen, so §2.2's immutability rule is untouched. Identity and display are different things; only +identity is immutable. + +Re-screening runs on every sync, but **an explicit staff decision is sticky**: once staff have allowed +a name, a later sweep does not re-hide it. Otherwise the override would be undone every 15 minutes. + +> **The same exposure already exists elsewhere, and this does not close it.** Character names reach +> public pages today through `shard_online` and the existing guilds board — a character called "Admin" +> is already renderable. `utils/reservedNames.js` is written as a general core utility so module-uo can +> adopt it for those surfaces, but doing so is **not** part of this workstream and is noted here so the +> gap is recorded rather than implied to be fixed. + +### 2.9 The approval gate on game-sourced overrides + +**Scope, decided:** approval applies **only to actions that release untrusted game-sourced data onto +public surfaces** — not to staff actions generally. Concretely, three actions: + +| Action | Why it is gated | +| --- | --- | +| Clearing a `reserved_name` hide | publishes a name that tripped the impersonation list | +| Setting a `display_name_override` | substitutes free text into the same public surfaces | +| Un-hiding a `staff`-hidden Team | reverses a deliberate suppression | + +Everything else staff can do — ordinary forum grants, leadership overrides, archive, forum moderation +— applies immediately and is audited, as before. Gating them would be a general staff-approval +workflow, which is a different (and much larger) idea; §9 answer 6 records why that was not what was +asked for. + +**The rule:** a **moderator** initiating one of the three creates a `pending` request that takes effect +only when an **admin** approves it. An **admin** initiating one applies it immediately, logged. This +mirrors the appeals queue's existing claim → resolve shape rather than inventing a second workflow +vocabulary, and it cannot deadlock a single-admin deployment — which matters, because `users.role` +defaults to `admin` and `npm run seed` creates exactly one, so most deployments have precisely one +admin and a four-eyes rule would wedge them. + +```sql +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_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + status ENUM('pending','approved','rejected','withdrawn') NOT NULL DEFAULT 'pending', + decided_by INT NULL, + decided_at DATETIME NULL, + decision_note VARCHAR(255) NULL, + CONSTRAINT fk_tmr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + INDEX idx_tmr_queue (status, requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +Rows are kept after a decision — the record of "a moderator asked to publish this name and an admin +refused" is the part worth having. Every transition also writes `activity_log` (§2.7). + +**The gate is server-side and role-checked per request**, not a UI affordance: core's admin routes are +already re-validated against the database on every request (a demoted user loses access at once), so +"is this caller an admin" is answered live rather than from a token claim. + +**Built generically enough to extend, not generalised speculatively.** `action` + `payload` means a +fourth gated action is an enum value, not a schema change — but no other action is gated today, and +none should be added without the same question being asked: *does this publish untrusted game data?* + +### 2.10 Account deletion + +Settled here rather than inherited from whatever the foreign keys happen to say, because retrofitting +it is painful and two of the obvious defaults are wrong: + +| Table | On user delete | Why | +| --- | --- | --- | +| `team_members.user_id` | `SET NULL` | the member still exists in the game; only the site link goes | +| `team_forum_posts.author_user_id` | `SET NULL` | a thread others replied to must not lose its posts | +| `team_forum_grants.user_id` | **`SET NULL` + retained username snapshot** | `CASCADE` would destroy the audit trail of who granted whom — exactly what an audit exists to survive. A `granted_username` / `revoked_username` snapshot column keeps the record readable. | +| `team_forum_grants.granted_by` / `revoked_by` | `SET NULL` + snapshot | same reason, for the actor | +| `team_activity.actor_user_id` | `SET NULL` | the event happened; the feed keeps the module-supplied `summary` | +| `team_forum_uploads.user_id` | `SET NULL`, **and mark `deleted_at`** | an orphaned file with no owner and no deletion trigger is the worst outcome; deleting the account removes the files | +| `team_moderation_requests.requested_by` / `decided_by` | `SET NULL` + snapshot | as above | +| `team_notification_prefs` | `CASCADE` | a preference with no user is meaningless | + +The rule the table encodes: **content and audit survive; preferences and links do not.** The one +place it bites is uploads, where "forget me" has to mean the bytes go too, not just the row. + +### 2.11 API surface + +``` +GET /api/v1/public/teams list (active), paged, {stale,lastSyncAt} +GET /api/v1/public/teams/:slug overview + counts +GET /api/v1/public/teams/:slug/members roster, field-projected per audience rung (§3.3) + +GET /api/v1/player/teams the caller's Teams (membership + grants), with the + reason for each: 'membership' | 'grant' | both +GET /api/v1/player/teams/:slug/access the caller's own resolved access on one Team + +GET /api/v1/admin/teams incl. archived, sync state, per-Team integration state +POST /api/v1/admin/teams/resync manual reconciliation +POST /api/v1/admin/teams/:id/archive staff archive (archived_reason='staff') +GET /api/v1/admin/teams/:id/grants the full grant ledger, incl. revoked +POST /api/v1/admin/teams/:id/leader-override { memberKey, effect, reason } +DELETE /api/v1/admin/teams/:id/leader-override/:memberKey + +GET /api/v1/admin/teams/review the reserved-name review queue (§2.8.3) +POST /api/v1/admin/teams/:id/unhide admin: applies · moderator: creates a pending request +POST /api/v1/admin/teams/:id/display-name same gate; { displayName, reason } +POST /api/v1/admin/teams/:id/hide staff hide — NOT gated (suppression is always safe) +GET /api/v1/admin/teams/requests the §2.9 approval queue +POST /api/v1/admin/teams/requests/:id/decide admin only; { status: 'approved'|'rejected', note? } + +POST /api/v1/player/teams/:slug/grants leader-grantable forum access { userId | username, reason } +DELETE /api/v1/player/teams/:slug/grants/:userId leader revoke (not of a staff-issued grant) +``` + +Leader-exercised actions live under `/player` rather than `/admin` deliberately: a leader is a player, +the `/admin` tier gate is `requireRole('admin','editor','moderator')` +(`MODULE_API.md` §2.4), and putting a leader endpoint behind it would mean widening that gate. The +leader check is a per-route gate on top of the `/player` tier's `requireAuth`. + +Process contract for every route above, per `CLAUDE.md`: `#swagger.*` annotations → +`npm run swagger`, `npm run routes:manifest -- --check` zero-line diff (the additions are core's, so +they land in core's committed manifest), and a matching `BACKEND_DESIGN.md` edit. + +--- + +## Part 3 — Team pages and roster + +### 3.1 Routes and shell + +Core client routes, not module ones: + +| Path | Page | +| --- | --- | +| `/teams` | index — searchable list, name/abbr/member/online counts | +| `/teams/:slug` | overview — counts, leaders, recent activity (§4), forum entry point (§5) | +| `/teams/:slug/roster` | full roster | +| `/teams/:slug/forum/*` | §5 | +| `/player/teams` | the caller's Teams and what each grants them | +| `/admin/teams`, `/admin/teams/:id` | sync state, grants ledger, overrides, integrations | + +Public pages use `PublicLayout shell="wide"` for the roster and `"mid"` elsewhere — the `shell` prop +added in `MODULE_API_VERSION` 1.5.0 exists precisely so a page renders inside the site's column, and +core's own pages are the ones that most often forget it. + +### 3.2 Roster + +Rows come from `team_members` (`status='active'`), sorted leaders first then by `display_name`. Each +row carries a **link state** — this is the surfaced divergence Part 2 asks for, and it is a first-class +column rather than an absence: + +| State | Condition | Rendered | +| --- | --- | --- | +| `linked` | `user_id IS NOT NULL` | name links to the public profile | +| `unlinked` | `user_id IS NULL` | name in muted text + an "not linked" chip | +| `granted` | not a member; active forum grant | listed **separately**, under "Forum guests", never counted as members | + +The header states it plainly — *"37 members · 21 linked · 4 forum guests"* — so the 37/21 gap reads as +information rather than as a bug. `linked_count` is denormalised on `teams` so the index page does not +need a join per row. + +### 3.3 Online status, without new transport + +`team_members.online` is written by the sync from the module's `online` field, which module-uo +computes from `shard_online` — data it already holds. Refresh cadence is the reconcile interval, so +the *stored* value is coarse. + +Live refinement is the module's, through the `team.overview` extension slot (§3.4): module-uo's client +chunk already has `useShardFeed` and a live `presence.online` view, and it can render a live "online +now" strip on the Team page without core acquiring an SSE stack for it (§0.3). Core's number is the +durable floor; the module's is live. If the module is absent or its slot throws, the page shows the +stored value and the error boundary contains the rest (`MODULE_API.md` §3.7). + +Field projection: the roster response passes through the module's audience-rung projection before it +leaves the server. `acct` and `webId`-equivalents never reach a caller below the configured rung — +this is the same boundary `shardBroadcast` enforces on the live feed, applied to a core response, and +it is why the roster endpoint asks the module to project rather than serialising `team_members` +directly. Concretely: core hands the module the rows and the viewer, the module returns the rows it +permits. A module that declines (unavailable) yields **the public projection**, not the full one — fail +closed. + +### 3.4 Extension slots + +Two new core-declared slots (`MODULE_API.md` §3.7 — core declares, one module fills, errors contained, +named for a *place* and never for a meaning): + +| Slot | Rendered in | Props | +| --- | --- | --- | +| `team.overview` | the Team overview page, below the counts | `{ teamId, externalId, moduleId }` | +| `team.member.row` | each roster row, trailing cell | `{ memberKey, userId, displayName }` | + +Both unfilled on bare core, which renders exactly the page core writes. Neither is typed by content — +`team.overview` is "the spot under the counts", not "where the game puts guild stats". + +### 3.5 Nav + +One coded public header entry, `{ label: 'Teams', to: '/teams' }`, plus `{ label: 'My Teams', +to: '/player/teams' }` in the player portal and `{ label: 'Teams', to: '/admin/teams', group: +'Community' }` in the admin sidebar. All three flow through the existing registered-defaults → admin +overrides → role/feature filtering pipeline unchanged (`THEMING_AND_NAV.md` §7); the override layer can +relabel, reorder, re-section and hide them and — structurally — cannot change their `to` or their +gates. + +**Gating on "is a Team provider registered".** With no module installed there are no Teams, so the +entry would lead to a permanently empty page. Rather than a new mechanism, core registers a `feature` +flag (`teams`) against its own `core` feature provider — the seam `useShardFlags` already uses +(`MODULE_API.md` §3.3) — resolved from `GET /api/v1/public/teams`'s `enabled` field. Per that section's +fail-open rule, an unknown answer **shows** the link; the page itself is the gate. + +--- + +## Part 4 — Team activity feed + +### 4.1 The seam + +Not the news hook — see §0.2, which is why. A new `ctx` member, modelled on `ctx.push.publish`: + +```js +await ctx.teams.activity.push([{ + externalId: '1234', // the module's Team id; core maps to team_id + kind: 'uo.champion.completed', // namespaced ., opaque to core + summary: 'Completed Champion Neira', // already-rendered text; core never composes one + occurredAt: 1755400000000, // epoch ms + visibility: 'public', // 'public' | 'members' — default 'members' + actorMemberKey: '0x40012ab3', // optional + payload: { … }, // optional, opaque, rendered only by the module's slot + dedupeKey: 'champ:0x77:1755400000000', // optional; makes replay idempotent +}]) +``` + +`summary` is module-rendered because core cannot compose "gained 15,000 gold" for a game whose +vocabulary it does not know, and a core that templated it would have acquired exactly the semantics +the module system exists to remove. + +`visibility` defaults to `'members'` — fail closed. Core enforces it; the module chooses it. This is +the same shape as the module owning the public-safety filter for push streams +(`MODULE_API.md` §2.4: "the kinds, the streams and the filter are then one file that moves together"). + +### 4.2 Storage + +```sql +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, + summary VARCHAR(255) NOT NULL, + visibility ENUM('public','members') NOT NULL DEFAULT 'members', + actor_member_key VARCHAR(191) NULL, + actor_user_id INT NULL, + payload JSON NULL, + occurred_at DATETIME NOT NULL, + 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, + UNIQUE KEY uq_team_activity_dedupe (team_id, dedupe_key), + INDEX idx_team_activity_feed (team_id, occurred_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +`INSERT IGNORE` on `dedupe_key` — the same idempotence trick `shard_events` already uses — so a +sidecar reconnect backfill never double-posts. + +Core writes its own items through the same table with `source='core'`: `core.member.joined`, +`core.member.left`, `core.leader.changed`, `core.forum.thread`, `core.team.renamed`. So the feed is +never empty on a module that pushes nothing, and the rendering path is exercised by core's own content +from day one. + +**Retention** is a setting (`team_activity_retain_days`, default 90) plus a per-Team row cap (default +2000), pruned by a nightly job in the same scheduler the announce worker uses. Unbounded growth on a +per-Team feed fed by a game loop is the obvious failure and it is cheaper to bound it now. + +### 4.3 Rendering + +A timestamped list of `summary` strings, grouped by day, filtered by the viewer's resolved access +(`public` items to anyone who may see the Team page; `members` items to members and forum-granted +users). Core renders text; the `team.overview` slot is where a module renders anything richer from +`payload`. + +--- + +## Part 5 — Team forums + +### 5.1 Recommendation: split into two phases + +**Yes, split.** A full threaded forum is threads + posts + editing + soft-delete + pinning + locking + +moderation + notification + permission + audit + admin UI + player UI + OpenAPI + manifest, and it is +the single largest piece of work in this document. But the split should **not** be "announcements-only +then generalise", because an announcement is a degenerate thread and building it as its own thing then +replacing it wastes the work. + +The split that does not waste anything is by **layer**, not by feature: + +- **5a — access + announcements + the operator's controls.** `team_forum_grants` (§2.5), the four-path + resolver and its tests, the grant/revoke flow with audit, the leader/staff UI for it, a **single + announcements stream per Team** (threads of `type='announcement'`, leader-authored, replies + disabled), and the two admin settings with their enforcement (§5.5). Ships the entire permission + model — which everything else in this document depends on — behind a small, low-risk surface. +- **5b — discussion + moderation.** `type='discussion'` threads with replies, editing, pinning, + locking, hiding, and the moderation ledger. **Additive**: the `type` column and the full + thread/post schema land in 5a, so 5b enables paths rather than migrating data. + +### 5.2 Schema (all of it lands in 5a) + +```sql +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 on user delete; the post body survives + 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; + +CREATE TABLE IF NOT EXISTS team_forum_posts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + thread_id INT NOT NULL, + author_user_id INT NULL, + body_html MEDIUMTEXT NOT NULL, -- sanitised on write via utils/sanitizeHtml.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, + INDEX idx_tfp_thread (thread_id, status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Append-only. Never updated, never deleted. +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_role ENUM('leader','staff') NOT NULL, -- WHICH authority was exercised + 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, + INDEX idx_tfm_target (target_type, target_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +Bodies are sanitised on **write** with core's existing `utils/sanitizeHtml.js` (already the wiki's and +the CMS's sanitiser), stored sanitised, and served without re-sanitising — the same contract those two +already follow. Writes go through `ctx.middleware.rateLimit`-equivalent core middleware. + +`ON DELETE SET NULL` on authors rather than `CASCADE`: deleting a user must not silently blow holes in +a thread other people replied to. The post renders as "[deleted account]". + +### 5.3 Moderation stays separate from the site's moderation system + +Deliberately, and it is worth writing down because merging them looks tidy and is wrong. + +The existing `mod_actions` / `appeals` pair is **Discord-sanction-shaped**: `mod_actions` is a +**bot-owned** table (`bot/src/model/*`, its own pool; the website never writes it), keyed on a Discord +user id, and `appeals` exists so a *player* can contest a *staff* sanction and have it reversed in +Discord (`MODERATION_APPEALS.md` §2, §6). Routing a guild leader locking a thread into that pipeline +would make a leader's ordinary housekeeping an appealable Discord sanction with a reversal path into +the bot. That is not what it is. + +So: `team_forum_moderation` is its own ledger, and the two are **cross-referenced, not merged**. Every +**staff**-exercised forum moderation additionally writes an `activity_log` row (§2.7), so the site's +existing staff-accountability trail sees it. A leader-exercised one writes only the forum ledger, +visible to the Team and to staff on the admin Team page. If a forum post warrants a site-wide sanction, +that is a separate, existing action a staffer takes against the *account*. + +### 5.4 API + +``` +GET /api/v1/player/teams/:slug/forum/threads +POST /api/v1/player/teams/:slug/forum/threads 5a: announcement (leader); 5b: discussion (any member) +GET /api/v1/player/teams/:slug/forum/threads/:id +POST /api/v1/player/teams/:slug/forum/threads/:id/posts 5b +PATCH /api/v1/player/teams/:slug/forum/posts/:id 5b — author, within an edit window; staff any time +POST /api/v1/player/teams/:slug/forum/threads/:id/moderate { action, reason } — leader or staff +GET /api/v1/admin/teams/:id/forum/moderation the ledger + +POST /api/v1/player/teams/:slug/forum/uploads multipart; 404 unless teams_forum_images='uploads' +DELETE /api/v1/player/teams/:slug/forum/uploads/:id uploader (within the edit window) or staff +GET /api/v1/admin/teams/forum/uploads attribution view: who uploaded what, when, how much +``` + +The forum settings themselves ride the **existing** admin settings endpoint rather than getting one of +their own — they are ordinary `settings` keys. The only special handling is server-side validation of +the acknowledgement (§5.5.5) when `teams_forum_images` is set to `uploads`. + +Every forum route above answers **404** while `teams_forums_enabled` is off, and the upload routes +answer 404 in any image mode but `uploads` — the same guard, applied at two levels, for the same +reason (§5.5.1). + +Under `/player` for the same reason as §2.11: a forum participant may be a plain player, and the tier +gate is `requireAuth`. Every route resolves access through the §2.5 resolver — never by checking +membership directly, which is how paths 1 and 3 would drift back together. + +### 5.5 Admin controls — the forum switch and the image policy + +Both land in **Phase 4 (5a)**, because a forum that ships without an off switch is one an operator +cannot ship at all, and because announcements carry images from the first day the forum exists. + +#### 5.5.1 `teams_forums_enabled` — the operator's switch + +A core `settings` key (`VARCHAR(64)` key, `TEXT` value, with `updated_by` / `updated_at` recorded by +the existing schema). Default **`'0'` — off**. Turned on from Admin → Settings. + +**Off means guarded, never destroyed.** The same principle as the module disabled guard +(`MODULE_API.md` §4.5): routes stay mounted and answer **404**, the forum panel is absent from the +Team page, and the nav entry is filtered by the `teams.forums` feature flag (§3.5). Not 403 — a 403 +says "this exists and you may not have it", which advertises a feature the operator deliberately +turned off; 404 says "not a thing on this site", which is the true statement. + +Three things a toggle-off must **not** do, each because the operator will toggle it back on: + +- **No data is deleted.** Threads, posts, grants and the moderation ledger are untouched. Re-enabling + restores the forum exactly as it was. +- **No subscription is cleared.** The forum notification streams are suppressed at the + recipient-computation step (§6.2), not by unsubscribing users. An operator switching the forum off + for a fortnight must not silently wipe everyone's notification preferences. +- **No grant is revoked.** `team_forum_grants` rows survive and stay authoritative for path 3; they + simply have nothing to grant access *to* while the switch is off. + +#### 5.5.2 `teams_forum_images` — the image policy + +A core `settings` key with three values, default **`'disabled'`**: + +| Value | Uploads | A remote image URL in a post | +| --- | --- | --- | +| **`disabled`** (default) | rejected | stays a plain link | +| `remote` | rejected | link **plus** the image rendered beneath it | +| `uploads` | allowed, to this server's `/uploads` | link **plus** the image rendered beneath it | + +> **One judgement call, flagged rather than buried.** `uploads` implies remote rendering too, so the +> three values are an escalating scale. An operator might reasonably want the opposite pairing — +> uploads (which they host, moderate and can delete) but **no** hotlinking (which they cannot control +> and which leaks viewer IPs, §5.5.3). That combination is not expressible here. It costs one extra +> enum value (`uploads_only`) if wanted; it is left out because it was not asked for and every value +> is a state the UI, the renderer and the tests all have to cover. + +**The setting is a ceiling, not an assignment.** Nothing else may widen it. That is stated now so a +later per-Team image preference — if one is ever wanted — can only ever be *more* restrictive than +what the operator allowed, and needs no rethink of this key. + +#### 5.5.3 Enforcement: the author never writes an `` tag + +This is the load-bearing decision of the whole section, and it is not how the rest of the site works. + +Core's shared sanitizer (`utils/sanitizeHtml.js`) **already allows `` from any `http`/`https` +host** — `allowedTags` includes it, `allowedSchemesByTag` permits both schemes, and the file's own +comment says the profile is "tuned for rich-text content from the admin editor". Handing `cleanBody` +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 uses its **own sanitizer profile**, derived from the shared one, 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 **core's renderer**, at render time: + +``` +author types: https://example.com/banner.png +stored HTML: https://example.com/banner.png +rendered: that link, and — in `remote` / `uploads` mode only — a core-generated + beneath it +``` + +Five properties fall out of that, and they are the reason for the design: + +1. **The policy is enforceable**, because the only code that can emit an `` is core's. +2. **Flipping the setting back to `disabled` retroactively un-renders every image**, on every existing + post, with **no data migration** — the images were never in the stored HTML. +3. **No attribute smuggling.** There is no author-supplied `srcset`, `onerror`, `width=99999`, + `style`, or anything else; core emits a fixed attribute set. +4. **The link always survives.** A blocked, dead or 404ing image degrades to the URL the author + actually wrote, which is what the reader wanted anyway. +5. It matches how forums conventionally behave, which is what the ask described. + +Concrete rules for what gets embedded: + +- **`https:` only.** CSP is `img-src 'self' data: https:` (`config/csp.js:52`) — an `http:` image is + blocked by the browser and renders as a broken picture, so an `http:` URL stays a plain link. This + is a real mismatch with the shared sanitizer, which permits `http` for `img`, and it is exactly the + sort of thing that presents as "images are broken on my forum" with nothing in any log. +- **Extension allowlist on the URL path**: `.png .jpg .jpeg .gif .webp .avif`. Anything else stays a + plain link. Conservative on purpose — guessing wrong renders an `` pointed at a non-image. +- **`referrerpolicy="no-referrer"` and `loading="lazy"`** on every generated tag. +- **Never proxy or cache a remote image server-side.** The moment the server fetches a user-supplied + URL it is an SSRF vector — core already carries the guard pattern for that + (`pushDispatch.isAllowedEndpoint`), and an allow-set is useless here because the whole point is + arbitrary hosts. The browser fetches; the server never does. Written down so nobody adds a proxy + "for performance" later. +- **The privacy cost is stated in the admin help text, not hidden**: a remote embed makes each + *viewer's* browser contact a third-party host, disclosing their IP and User-Agent to whoever runs + it. `no-referrer` limits what else leaks; it cannot prevent the request. + +#### 5.5.4 `uploads` mode: what has to harden first + +The existing 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 (`app.js:107`). All +of that is kept. + +What it does not have is anything that assumes a hostile uploader — because until now it has not had +one. Four additions, all in Phase 4: + +- **Magic-byte sniffing.** `file.mimetype` is the client's `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: sniff the leading bytes on write and reject on mismatch with the declared type. +- **Quotas and rate limits.** A per-user upload rate limit (core's `rateLimit` middleware), a + per-post attachment cap, and a per-user daily byte quota. Community uploads with no cap is a + disk-exhaustion vector on the operator's own host. +- **Attribution.** A `team_forum_uploads` table (§5.2a) recording uploader, team, post, byte size and + stored filename. This is not bookkeeping — the acknowledgement below is meaningless if "who + uploaded this" cannot be answered. +- **Lifecycle.** Deleting a post soft-deletes its uploads; a nightly sweep removes files whose rows + are soft-deleted past a retention window, and orphaned files with no row at all. The existing + admin upload path never deletes anything, which is fine at admin volume and is not fine here. + +#### 5.5.5 The acknowledgement + +Required to select **`uploads`**, and only `uploads` — that is the mode where third-party material +comes to rest on the operator's own disk. `remote` gets a non-blocking advisory in the settings help +text instead, since nothing is stored, though the operator is still displaying it. + +**It is recorded, not merely displayed.** An acknowledgement nobody can produce afterwards is +decoration. One settings key does the whole job, because `settings` already stores `updated_by` and +`updated_at` per key: + +``` +teams_forum_uploads_ack = "" e.g. "1" + → updated_by = the admin who acknowledged (existing column) + → updated_at = when (existing column) +``` + +Plus an `activity_log` row (`team.forum.uploads.acknowledged`), so it lands in the staff audit trail +with the acting admin's IP alongside every other consequential admin action (§2.7). + +**Server-side is the gate.** `PUT` of `teams_forum_images = 'uploads'` is rejected `400` unless the +same request carries `acknowledge: `. A checkbox in the admin UI is not the gate — it +is how the gate is presented. + +**The warning text is versioned.** If it is ever reworded, the stored version no longer matches and +the acknowledgement is stale. What happens then matters, and neither obvious answer is right: +silently downgrading a live feature because a legal text changed strands users mid-conversation, and +honouring a stale acknowledgement forever defeats versioning. So: **uploads keep working**, a +persistent admin banner requires re-acknowledgement, and **no other forum setting may be saved until +it is given**. Non-destructive, and impossible to ignore. + +**Draft warning text — the org lead should own the final wording.** This is liability language for +the project, not something to settle in a design doc; it is drafted here so the mechanism can be +built against something real: + +> **Enabling uploads means your community can store files on your server.** +> +> Runic Gateway is software you are running yourself. The Runic Gateway project has no access to, +> control over, or responsibility for any material your community uploads to your server. You — the +> operator of this deployment — are solely responsible for what is stored and served from it, +> including moderating it and complying with the law in your jurisdiction. +> +> Uploads are attributed to the account that made them and can be removed by your staff at any time. +> +> ☐ I understand and accept responsibility for material uploaded to this server. + +#### 5.5.6 Publication + +`teams_forums_enabled` and `teams_forum_images` join `settings.getPublic()`'s `PUBLIC_KEYS` allowlist +— the client needs the first for the nav feature flag and the second to decide whether to show an +upload control in the composer. Neither is sensitive. **`teams_forum_uploads_ack` is not published**: +who accepted a liability notice is operator detail, exactly as `failure_reason` is in +`MODULE_API.md` §2.9. + +The *rendering* decision is still made server-side. The client is told the mode so it can present the +right composer; it is never the thing that decides whether an image appears. + +### 5.6 Abuse reports — the missing half of moderation + +**Core has no user-facing report flow of any kind today.** `moderation`, `mod_notes` and `appeals` are +all either staff-initiated or Discord-sanction-shaped; nothing anywhere lets 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 §5.5's `uploads` mode lets players 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.** A private Team forum with uploads +enabled, no report control and no staff visibility is a space the operator has formally accepted +responsibility for and has no mechanism to learn about. + +```sql +-- Deliberately generic. Team forum content is the first consumer; wiki pages, +-- news comments and profile fields can be added as target_type values with no +-- schema change. +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, + 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_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_cr_reporter FOREIGN KEY (reporter_user_id) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uq_cr_one_open (target_type, target_id, reporter_user_id, status), + INDEX idx_cr_queue (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +Four rules: + +- **Reports go to site staff, not to Team leaders.** A leader may also see and act on reports for + their own Team, but staff always receive them — the whole point is a path that routes *around* a + Team's own leadership. +- **Reporting is not a moderation action.** A report changes nothing about the content; it opens a + queue item. This keeps it clear of §5.3's leader/staff moderation ledger, which records things that + actually happened. +- **Rate-limited and deduplicated.** One open report per (target, reporter) — the unique key — plus + core's `rateLimit` middleware, so the queue cannot be used as a harassment tool. +- **Reports on uploads carry the `team_forum_uploads` row**, so a staffer sees uploader, size and + sniffed type without hunting. This is why §5.5.4's attribution table is load-bearing rather than + bookkeeping. + +``` +POST /api/v1/player/teams/:slug/forum/report { targetType, targetId, reason, detail? } +GET /api/v1/admin/moderation/reports the queue, alongside the existing appeals queue +POST /api/v1/admin/moderation/reports/:id/handle { status, note? } +``` + +Mounted under the **existing** admin moderation section rather than under Teams: 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. + +### 5.2a Upload attribution table (lands with §5.2) + +```sql +CREATE TABLE IF NOT EXISTS team_forum_uploads ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + team_id INT NOT NULL, + post_id BIGINT NULL, -- NULL while a draft upload is unattached + user_id INT NULL, -- SET NULL on account delete; attribution survives in activity_log + filename VARCHAR(191) NOT NULL, -- the stored random name, not originalname + byte_size INT NOT NULL, + mime VARCHAR(64) NOT NULL, -- the SNIFFED type, not the declared one + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, -- soft; the nightly sweep removes the file + 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 (user_id) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uq_tfu_filename (filename), + INDEX idx_tfu_user (user_id, created_at), + INDEX idx_tfu_sweep (deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +--- + +## Part 6 — Notifications + +### 6.1 What the existing pipeline gives us, and the one thing it does not + +Reusable unchanged: ntfy itself (a compose service, declarative config, no per-user accounts), the +device registry (`push_devices`), the opt-in model (`notification_subscriptions`), the content-free +tickle, the SSRF allow-set, and `pushDispatch.publish`. + +The gap is §0.5: `publish` fans out to **all** subscribers of a stream or to **one** `ownerUserId`. +Team notifications need "these N users". + +### 6.2 The design: fixed streams, computed recipients + +**Do not create a stream per Team.** The catalog is a static registration validated at boot with a +namespaced-id pattern; it cannot express an unbounded, runtime-created set, and stream ids are stored +in `notification_subscriptions` rows that would then need garbage-collecting when a Team archives. + +Instead: **four fixed core streams, and Team scoping lives entirely in the recipient set.** + +```js +// server/src/config/coreStreams.js — added alongside news.post +{ id: 'team.member.joined', label: 'Team — new member' } +{ id: 'team.leadership.changed', label: 'Team — leadership change' } +{ id: 'team.forum.post', label: 'Team — new forum post' } +{ id: 'team.announcement', label: 'Team — announcements' } +// all: personal: false, requiresLinkedAccount: false +``` + +`requiresLinkedAccount: false` is correct and slightly counter-intuitive: a forum-granted user with no +game account is a legitimate recipient of `team.forum.post`. The recipient computation, not the stream +flag, is what enforces who gets what. + +One new dispatch signature and one new query: + +```js +// utils/pushDispatch.js +publishToUsers(streamId, { ref, userIds }) // honours each recipient's own subscription +// model/pushDevices/pushDevices.db.js +endpointsForUsersStream(userIds, streamId) // WHERE user_id IN (?) AND subscribed +``` + +Recipients per event: + +| Stream | Recipients | +| --- | --- | +| `team.member.joined` | active members with `user_id`, plus active forum grants | +| `team.leadership.changed` | same | +| `team.forum.post` | everyone with resolved forum access to that Team, **minus the author** | +| `team.announcement` | same, minus the author | + +The tickle stays content-free — `{ stream: 'team.forum.post', ref: 'team:3:thread:41' }` — and the app +pulls the real content over the authenticated, access-checked API. ntfy remains an untrusted relay and +needs no change of any kind. + +### 6.3 The one new granularity: per-Team mute + +Per-stream opt-in already exists and is per-user. The thing it cannot express is "I'm in five Teams and +want notifications from one". One small table, **opt-out** rather than opt-in, so a user in a single +Team never has to configure anything: + +```sql +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, + 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 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +Applied as a subtraction from the computed recipient set. Surfaced as a mute toggle on the Team page +and as a list under the existing notification settings screen (`GET|PUT +/auth/me/notifications/teams`), which the Android app can adopt without a new screen concept. + +### 6.4 Email — the third sink, already built and unused + +Push needs the Android app. The Discord bridge (§7.2) needs Discord. **A web-only user on a deployment +running neither currently 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. + +Core already has `utils/mailer.js` and an admin-configured `email_config`. The expensive part of +notifications is computing the recipient set, and §6.2 builds it; email is a **third consumer of the +same event**, not a fourth pipeline. + +- Same recipient computation, same per-Team mute, same suppression while `teams_forums_enabled` is off. +- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge + (§7.2): the recipient's mailbox is a destination they chose, not an untrusted relay reached by an + unguessable topic. It carries the thread title, an excerpt and a link; never the full post. +- **Digest, not per-event, by default.** A busy Team forum sending one email per reply is how a + notification feature gets marked as spam. Default to a daily digest per Team with an immediate + option, stored in `team_notification_prefs` as a `email_mode ENUM('off','digest','immediate')` + column. +- **Off unless email is configured.** No `email_config` row means the sink is absent, not broken. +- One-click unsubscribe link honouring the same per-Team mute, so an unsubscribe from the mail client + writes the preference the site shows. + +Folded into **Phase 6** rather than getting a phase of its own: the recipient set is the work, and it +is already being built there. + +> **Android gotcha, carried forward.** The existing PUT-the-whole-set endpoints require the array field +> even when empty (`docs/android/PLAN.md` §11). Any new "replace the set" endpoint here must be +> specified the same way, and the app-side DTO field must have **no default**, or kotlinx drops it and +> clearing the last entry 400s. + +--- + +## Part 7 — Discord integration + +### 7.1 Slash-command registration + +**Ownership, decided:** the registrant owns the **definition and the handler**; the handler runs **in +the website process** and returns a **response envelope**; the **bot owns every Discord-specific +concern** — deferral, the 3-second ack, ephemerality, follow-ups, interaction tokens, embeds. This is +forced by §0.4 (the bot cannot load module code) and it is also the right boundary: a module writing +`interaction.deferReply()` would be a module holding a Discord handle. + +```js +api.registerSlashCommands([{ + name: 'team', + description: 'Show a team summary', + options: [ // restricted schema, §7.1.1 + { name: 'name', type: 'string', description: 'Team name or abbreviation', required: false }, + ], + access: 'everyone', // 'everyone' | 'linked' | 'staff' + async handler({ command, options, actor }) { + return { text: '…', fields: [ … ], url: `${siteBaseUrl}/teams/${slug}`, ephemeral: false } + }, +}]) +``` + +- **`actor` is resolved by core before the handler runs**: `{ platform: 'discord', platformUserId, + guildId, userId | null, isLinked, isStaff }`. `userId` comes from `user_identities`. A handler never + parses a Discord payload and never learns anything platform-shaped beyond `platform`. +- **`access` is enforced twice** — the bot sets Discord-side default member permissions from it where + it can, and core re-checks it in the dispatcher, which is the actual gate. Client-side is about not + advertising a dead end; the server is the boundary. Same principle as the nav. + +#### 7.1.1 The option schema is deliberately small + +`string | integer | boolean | user`, each with `required` and optional `choices`. **No** subcommand +groups, autocomplete, attachments, modals or component interactions in v1. Those are exactly the +features whose semantics do not survive a second platform, and admitting one of them into the +registration API is how Discord specifics leak in by accident. A command needing more is a bot-side +command, written in the bot, and that stays available. + +#### 7.1.2 Transport + +``` +bot → app GET /internal/commands → { version, commands: [ {name, description, options, access} ] } +bot → app POST /internal/commands/dispatch → { text?, embed?, fields?, url?, ephemeral? } +``` + +On the app's existing unpublished internal listener (port 3001), behind the existing +`requireInternalKey`. `/internal/*` is `#swagger.ignore`d today and stays so — it is not public API. + +**Lifecycle.** The bot pulls `/internal/commands` on `ready` and merges the result with its own static +array before the single `REST.put(applicationGuildCommands)` it already does. Because that call is a +whole-set PUT, **deregistration on module unload is free** — a module that is gone is simply absent +from the next pull. Core exposes a `version` counter bumped on any module state change (the same trick +`modules.version()` already uses for the OpenAPI cache); the app pushes a re-register nudge through the +existing `/internal/config` path when it changes, and the bot re-pulls. + +**Timing.** The bot **defers immediately** on receiving an interaction (well inside Discord's 3s ack +budget), then POSTs the dispatch with a 4s timeout — the same budget `botInternalClient` already uses — +and edits the deferred reply with the envelope. The website is never in the 3s critical path. + +**Failure isolation.** The dispatcher try/catches per handler; a throw becomes +`{ ok: false }` and the bot posts an ephemeral "that command failed". A timeout is identical. A module +whose handler wedges costs its own command and nothing else — the bot process is never at risk because +the handler does not run in it. Cross-module isolation is not required (single-module deployment) and +none is built. + +**The example command.** `/team [name]` → member/linked/online counts, leaders, and a deep link to +`/teams/:slug`. For a caller with no linked account it returns the **public** projection plus an +ephemeral "link your account for more" — see §9 answer 5. + +### 7.2 Notifications bridge + +The same Team events as §6, delivered to a second consumer. Core emits each Team notification to an +internal fan-out with two subscribers: push (§6) and the integration bridge. **Not a second pipeline** — +one event, two deliveries. + +```sql +CREATE TABLE IF NOT EXISTS team_integration_config ( + platform VARCHAR(32) NOT NULL, -- 'discord' + 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 + enabled TINYINT(1) NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (platform, team_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +Admin-configurable per event type, globally and per Team (a per-Team row overrides the `team_id IS +NULL` default). Delivered via `POST /internal/team-notify` on the bot, best-effort, never throwing — +identical to `announce` and `mod-reverse`. + +**A Discord message carries content; a push tickle does not.** Stated explicitly because the two look +like the same event and are not: ntfy is an untrusted relay reached by an unguessable topic, so the +tickle is content-free by design; the Discord server is an operator-configured, trusted destination +where an empty "something happened, go look" message would be useless. What *is* shared is the +allowlist discipline — an event is bridged only if its `visibility` is `public`, or its destination +channel is configured for a members-only Team context. + +### 7.3 One voice channel per Team + +**Shape.** One voice channel per qualifying Team, under a single shared parent category +(`Teams`), created by the bot. **No per-Team role by default**, no auto-created category per Team. + +**Access, and why overwrites are enough — with a stated fallback.** Access is `@everyone` deny + +`VIEW_CHANNEL`/`CONNECT` allow per **linked** Team member (path 4, §2.5) + the staff role. Discord's +practical per-channel overwrite budget is ~100. A Team of up to ~95 linked members fits with room for +`@everyone` and staff. **Above a configurable threshold (`voice_overwrite_max`, default 90) the +integration escalates that one Team to a per-Team role** — one overwrite for the role instead of N for +members — because roles are the scarcer guild-wide resource (250 cap) and should be spent only where +overwrites actually run out. So: overwrites by default, role on demand, and the escalation is recorded +in `team_integrations.mode`. + +**Provisioning gate.** Admin opt-in per deployment, plus `voice_min_linked_members` (default 5). +Counted on **linked** members only, since an unlinked member cannot be granted anything on Discord +anyway. + +**Lifecycle: delete, but after a grace window.** Justification, since the brief asks for one: + +- A voice channel holds **no message history**, so deletion destroys nothing recoverable. The + archive-don't-delete caution from the earlier full-category proposal was about text channels with + history, and it does not transfer. +- What *does* transfer is churn. A Team hovering around the threshold — one member unlinks, one + rejoins — would delete-and-recreate, changing the channel id, breaking every pinned link to it, and + filling the audit log. That is a real harm with no content loss at all, which is exactly the case a + grace window fixes and an archive does not. + +So: drop below threshold → `state='pending_removal'`, `remove_after` = now + `voice_grace_days` +(default 7). Recover above threshold inside the window → cancel, no Discord call made. Still below at +expiry → delete. A Team **archived** (disbanded or renamed) takes the same window, because "disbanded" +can be a missed event and 7 days is cheap insurance. + +**And never on stale data.** If `team_sync_state` is stale for the module (§2.4), the integration +reconciler **skips entirely** — no creation, no deletion, no overwrite changes. A voice channel is never +destroyed because a sidecar was down. + +```sql +CREATE TABLE IF NOT EXISTS team_integrations ( + id INT AUTO_INCREMENT PRIMARY KEY, + team_id INT NOT NULL, + platform VARCHAR(32) NOT NULL, + resource VARCHAR(32) NOT NULL, -- 'voice' + external_ref VARCHAR(64) NULL, -- the channel id + mode ENUM('overwrites','role') NOT NULL DEFAULT 'overwrites', + role_ref VARCHAR(64) NULL, + state ENUM('none','active','pending_removal','error') NOT NULL DEFAULT 'none', + remove_after DATETIME NULL, + last_error VARCHAR(500) NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_team_integration (team_id, platform, resource), + CONSTRAINT fk_ti_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +**Sync** rides the same reconciliation as membership: after a successful Team reconcile, the +integration reconciler diffs the desired access set (path 4) against what the bot reports and issues +the minimum set of calls. Every call is best-effort; a failure records `state='error'` with the message +and retries on the next pass. It never blocks the Team sync. + +--- + +## Part 8 — Keeping the integration layer platform-agnostic + +### 8.1 What a Matrix integration would actually need + +Researched rather than assumed, and the answer is that Matrix's model diverges from Discord's on +**every** axis this design touches: + +| Discord | Matrix | +| --- | --- | +| Guild → channels; per-channel permission **overwrites** (~100/channel) | Rooms, optionally grouped in a **Space**. Access is room membership (invite/knock/join rules) plus `m.room.power_levels` — an integer map, not per-resource ACLs. No overwrite concept. | +| Roles (250/guild), assignable, mentionable | No role object. The nearest analogue is "membership of a room/Space", or a power level. | +| **Voice channel** as a first-class persistent resource | **No voice channel exists.** Real-time voice is MatrixRTC — a session held *inside an ordinary room*, surfaced by Element Call. Self-hosting it needs infrastructure the homeserver does not ship: a LiveKit SFU plus `lk-jwt-service`. "Provision a voice channel" maps to "create a room and hope the operator deployed an SFU". | +| Slash commands: a registered application command, an interaction API, a 3s ack, ephemeral replies, follow-ups | **None of it.** A Matrix bot reads message bodies (or runs as an Application Service acting as virtual users). Commands are a *convention* in message text, with no registration, no ack deadline, no ephemerality. | +| Identity: OAuth2 link → a stable `user_identities` row, already built | `@user:server` MXIDs come from the homeserver. There is no equivalent OAuth link flow unless the homeserver speaks OIDC; otherwise binding is a challenge-DM. | + +### 8.2 Conclusion: capability-based contract, not a shared interface + +A shared `ExternalChatIntegration` would have to name roughly five methods — `deliverNotification`, +`registerCommands`, `dispatchInteraction`, `provisionVoice`, `syncAccess`. Matrix implements +`deliverNotification` cleanly, `syncAccess` differently (invite/kick rather than ACL edits), and +**cannot honestly implement** `registerCommands`, `dispatchInteraction` or `provisionVoice`. Three of +five would be no-ops or lies, and the two that survive would be pushed through argument shapes designed +for Discord's resources. + +So: **capabilities, declared per integration**, and core's UI and reconciler read the declaration. + +```js +integrations.register('discord', { + capabilities: ['notify.deliver', 'command.dispatch', 'identity.link', 'voice.provision', 'access.sync'], + … }) +``` + +| Capability | Core exposes | Discord | (hypothetical) Matrix | +| --- | --- | --- | --- | +| `notify.deliver` | the Team notification event + a rendered payload | ✅ channel message | ✅ room message | +| `identity.link` | `user_identities` lookup by provider | ✅ (built) | ✅ if the homeserver speaks OIDC | +| `access.sync` | the path-4 eligible set for a Team | ✅ overwrites/role | ✅ room invite/kick — different mechanism, same question | +| `command.dispatch` | command definitions + the actor-resolved dispatcher | ✅ | ❌ not declared | +| `voice.provision` | "this Team qualifies / no longer qualifies" | ✅ | ❌ not declared | + +**Core's surface is the capability, not the mechanism.** For `access.sync` core says *"these user ids +are eligible for Team 3"* and never *"set these overwrites"* — Discord turns that into overwrites (or a +role), Matrix would turn it into invites. That is the difference between a capability contract and a +thin wrapper around Discord's API, and it is where the abstraction actually earns its keep. + +**What core does with an undeclared capability:** nothing at all, silently, and the corresponding UI is +absent. Admin → Teams renders the voice panel only when the enabled integration declares +`voice.provision`. No stubs, no "not supported on this platform" placeholders for a platform nobody has +installed. + +**No Matrix implementation is built.** §8 is research to shape the Discord contract, exactly as the +brief asks. + +--- + +## Part 9 — The explicit answers + +**1. Is a full threaded forum one phase, or should Part 5 split?** +**Split — into two, by layer rather than by feature.** 5a is the access-control model (grants, the +four-path resolver, audit, leader/staff UI) plus announcement threads; 5b is discussion threads, +replies, editing and the moderation ledger. The full schema — including `threads.type` and the +moderation table — lands in **5a**, so 5b enables code paths and never migrates data. This split is +chosen over "announcements first, generalise later" because an announcement is a degenerate thread, and +building it as its own thing then replacing it wastes the work. See §5.1. + +**2. Does the notifications bridge need new ntfy topic/routing work?** +**No ntfy work at all; a small amount of website work.** ntfy topics are per-*device* UnifiedPush +endpoints, not per-subject channels, and the payload is a content-free tickle — so Team scoping is a +recipient-set problem inside the website, not a topic problem in the relay. What *is* missing is a +"these N users" fan-out: today `pushDispatch.publish` does all-subscribers or one `ownerUserId`. The +gap closes with **one new query** (`endpointsForUsersStream`) and **one new signature** +(`publishToUsers`), four fixed stream ids in `coreStreams.js`, and one small opt-out table for per-Team +mute. No new topics, no ntfy ACLs, no per-user accounts, no second pipeline. See §6. + +**3. Voice channel: straight delete below threshold, or archive first?** +**Delete — but only after a 7-day grace window, and never while the sync is stale.** A voice channel +holds no message history, so the archive-don't-delete caution from the earlier text-channel proposal +does not transfer: deletion destroys nothing recoverable. What *does* apply is churn — a Team +oscillating around the threshold would delete-and-recreate, changing the channel id, breaking pinned +links and spamming the audit log, all with zero content loss. A grace window fixes exactly that and an +archive would not. The stale-sync guard is separate and stricter: if `team_sync_state` is stale, the +reconciler makes **no** integration decisions at all, so a channel is never removed because a sidecar +was down. See §7.3. + +**4. Shared `ExternalChatIntegration` interface, or a capability contract?** +**Capability contract.** Matrix has no channel-with-overwrites, no role object, **no voice channel at +all** (voice is a MatrixRTC session inside an ordinary room, needing a LiveKit SFU the homeserver does +not ship), and **no slash-command registration** — a Matrix bot parses message text with no interaction +API, no 3-second ack and no ephemeral replies. Of the five methods a shared interface would name, a +Matrix implementation could honestly provide two. Forcing the other three would mean no-ops on one side +and Discord-shaped arguments on both. Core therefore exposes **capabilities** — `notify.deliver`, +`identity.link`, `access.sync`, `command.dispatch`, `voice.provision` — stated as questions +("these users are eligible for Team 3") rather than mechanisms ("set these overwrites"); each platform +implements what it can and declares the rest absent, and core renders UI only for declared +capabilities. See §8. + +**5. What if none of a Team's leaders has a linked Discord account?** +**Nothing breaks, because no integration action is leader-scoped.** The two classes of leader action +answer differently: + +- **Platform-side leader actions** — grant/revoke forum access, post an announcement, pin or lock a + thread — depend on path 2 (leadership, from the module) and path 3 (forum access, core's). Discord is + irrelevant to all of them, and they keep working unchanged. +- **Integration-side actions** are performed by the **bot with its own permissions**, on a decision core + made from the Team's linked-member count. No design here asks a leader to authorise, own or execute a + Discord action, and none should be added — a leader-scoped Discord action would create exactly this + single point of failure. + +So a Team with zero Discord-linked leaders is not a degraded Team. Its leaders simply do not personally +get voice access, exactly like any other unlinked member (§2.6 hop 3). Three consequences worth naming: +`/team` run by an unlinked caller returns the public projection plus an ephemeral prompt to link; +**staff can always act on any Team from the site**, which is the standing escape hatch; and the +condition is **surfaced, not silent** — the Team's admin page shows "0 of 3 leaders have linked +Discord" as an informational row, because otherwise it looks like a bug the first time someone notices +it. + +**6. Why the admin-approval gate covers three actions and not every staff action.** +Asked and answered during review (2026-08-17): the gate exists because **untrusted game-sourced data +becomes a public page**, not because staff actions are inherently risky. A guild name is written by a +player, unreviewed, and this design turns it into a page, a URL, a Discord message and a voice-channel +name — so the actions that *publish* such a string (clearing a reserved-name hide, setting a display +name, un-hiding) need a second pair of eyes, and every other staff action does not. + +The alternative — approval on all staff actions site-wide — was considered and rejected as a different +workstream: it would touch nearly every admin controller (moderation, appeals, roles, bans, wiki, news, +modules, settings) and deserves its own design doc rather than riding in on Teams. `team_moderation_requests` +carries an `action` enum and a `payload`, so extending it is adding a value, but no action should be +added without asking the same question: *does this publish untrusted game data?* + +The gate is **moderator-initiated only**, with admins applying immediately. Four-eyes on admin actions +was rejected for a concrete reason: `users.role` defaults to `admin`, `npm run seed` creates exactly +one, and most deployments have precisely one admin — a second-admin requirement would wedge them +completely. + +--- + +## Part 10 — Ownership: what is contract, what is core-internal, and what is the module's + +Read this part before building anything. Everything above describes a system that spans a versioned +contract, a large body of core-internal code, and a module — and the three are easy to confuse +because they all appear in the same paragraphs. This part draws the lines; §10.5 is the one to check +a specific question against. + +**This document is not normative.** [`MODULE_API.md`](MODULE_API.md) is the contract. Where the two +ever disagree, `MODULE_API.md` wins and this file is wrong. Part 11 proposes what to add there; until +that lands, nothing in §10.2 exists. + +### 10.1 Three ownership classes, and one that is a different contract entirely + +| Class | Who owns it | May a module rely on it? | What breaking it costs | +| --- | --- | --- | --- | +| **A. Module API contract** | core, published in `MODULE_API.md` | **yes — this is the entire list** | a `MODULE_API_VERSION` major bump | +| **B. Core-internal** | core | **no. Never.** | nothing — core may change it freely | +| **C. Module-owned** | the module | it *is* the module's | the module's own version | +| **D. Wire protocol** | the shard↔sidecar boundary | different contract, different number | a `PROTOCOL_VERSION` bump | + +**Class B is the one that causes trouble here**, because Teams are a *core* feature and "core owns +it" reads like "it is available". It is not. `MODULE_API.md` §1.2 is explicit that core's file layout, +**table names**, middleware ordering and components are not contract. Every Team table in this +document is class B: core-owned, and **out of bounds to a module even though the module is what +populates them**. A module reaches Team state only through the class A members in §10.2 — and mostly +it does not need to, because the module is the one being *asked*, not the one asking. + +**Class D is a genuinely separate contract with its own version number.** `MODULE_API.md` §1.1 says so +directly: `PROTOCOL_VERSION` "versions the shard wire and has nothing to say about a website module." +Phase 1 bumps D; Phase 2 bumps A. They are independent, they land in different repos, and neither +implies the other. + +### 10.2 Class A — the contract surface this design adds + +Everything here is proposed for `MODULE_API.md` in Part 11. Nothing else in this document is contract. + +| Member | Shape | Defined in | +| --- | --- | --- | +| `api.registerTeamProvider(provider)` | `{ getTeams, getTeamMembers, getTeamLeaders }` | §2.3 | +| — the **envelope** every provider method returns | `{ ok, complete?, … }` / `{ ok: false, reason }` | §2.3 | +| — the **team** shape | `{ externalId, name, abbr, meta }` | §2.3 | +| — the **member** shape | `{ memberKey, displayName, rankLabel, leader, online, userId }` | §2.3 | +| `ctx.teams.publish(event)` | the six event kinds and their payloads | §2.3 | +| `ctx.teams.reconcile({ reason })` | debounced; returns immediately | §2.4 | +| `ctx.teams.activity.push(items)` | `{ externalId, kind, summary, occurredAt, visibility, actorMemberKey?, payload?, dedupeKey? }` | §4.1 | +| `api.registerSlashCommands(commands)` | `{ name, description, options, access, handler }` | §7.1 | +| — the **option** schema | `string \| integer \| boolean \| user`, `required`, `choices` | §7.1.1 | +| — the **actor** handed to a handler | `{ platform, platformUserId, guildId, userId, isLinked, isStaff }` | §7.1 | +| — the **response envelope** a handler returns | `{ text?, embed?, fields?, url?, ephemeral? }` | §7.1 | +| client slot `team.overview` | props `{ teamId, externalId, moduleId }` | §3.4 | +| client slot `team.member.row` | props `{ memberKey, userId, displayName }` | §3.4 | + +**The provider is core calling the module, which is new for this contract.** Every existing +registration is either the module claiming a mount (`registerRoutes`) or core notifying it +(`registerPostHook`). `registerTeamProvider` is core *asking a question and waiting for an answer* — +the same direction `registerAnnounceLeg`'s `dispatch` already goes, which is why it is modelled on it +rather than invented. The consequences (a 10s budget, the envelope, failure being staleness rather +than emptiness) are all in §2.3–2.4 and all of them are contract. + +### 10.3 Class B — core-internal, and off limits + +Core owns and may change all of this without a bump. **A module must not read, write, `require` or +name any of it.** `MODULE_API.md` §5.1's CI grep (no relative path escaping the module root) catches +the import case; the table case is caught by §2.6's namespace rule and by review. + +- **Every table in this document.** `teams`, `team_members`, `team_sync_state`, + `team_leader_overrides`, `team_forum_threads`, `team_forum_posts`, `team_forum_moderation`, + `team_forum_grants`, `team_forum_uploads`, `team_activity`, `team_integrations`, + `team_integration_config`, `team_notification_prefs`, `team_moderation_requests`, + `content_reports`. Note these carry **no `_` prefix** — correctly: §2.6's prefix rule + binds modules, and these are core's. +- **The reconciler** and every refusal gate, the quarantine, the backoff, the debounce (§2.4). +- **The four-path resolver** — `forumAccess()`, `externalEligible()`, the leadership override + application (§2.5). A module never answers an access question and never asks one. +- **`utils/reservedNames.js`**, the auto-hide, the review queue, the approval gate (§2.8–2.9). +- **The forum sanitizer profile and the image renderer** (§5.5.3). The module has no say in whether an + image renders. +- **`pushDispatch.publishToUsers`, `endpointsForUsersStream`, the recipient computation, the email + sink** (§6). A module publishes through the *existing* `ctx.push.publish`; the Team fan-out is + core's and is not exposed. +- **Every settings key** — `teams_forums_enabled`, `teams_forum_images`, + `teams_forum_uploads_ack`, `teams_reserved_terms`, `teams_reconcile_interval_s`, + `teams_max_grants_per_team`, `voice_*`. `ctx.settings` is three functions over *arbitrary* keys + (`MODULE_API.md` §2.3) and a module reading core's policy keys is out of contract even though the + call would succeed. +- **The bot↔app internal API** — `/internal/commands`, `/internal/commands/dispatch`, + `/internal/team-notify`. Not contract in any versioned sense: `app` and `bot` ship from one repo and + release together, and the shared secret means only they can call it. A module never sees it; it + registers a command and core does the rest. + +### 10.4 Class B′ — core-internal to a module, but a *public* API surface + +A separate thing that is easy to conflate with class A: the REST routes and stream ids in this +document are **not** module contract, but they **are** an external surface with its own process +obligations — OpenAPI annotations, `npm run swagger`, the `routes:manifest --check` zero-line diff, +and a `BACKEND_DESIGN.md` edit (`CLAUDE.md`'s standing rule). + +- Every route in §2.11, §5.4, §5.6 and §6.3 — consumed by the SPA and, eventually, the app. +- The four `team.*` **stream ids** in §6.2. These are core catalog entries, not a module's, so §2.4's + `.` namespacing does not apply — the same way core's existing `news.post` does not + carry one. They are consumed by the Android app, which makes renaming one a client break, so treat + them as frozen once shipped. + +### 10.5 Class C — the module's, and core never reaches in + +If a question below has a game-specific answer, it is the module's and core must not acquire an +opinion about it. This list is the practical test for "did we just put game semantics in core?" + +| The module owns | Why core cannot | +| --- | --- | +| What `externalId` *is* | only the game knows what identity survives a rename (§2.2) | +| What `memberKey` *is* | a character serial, an account, something else entirely | +| **Rename vs. genuinely-different-team detection** | core sees only "an id appeared / a name changed" | +| Resolving `userId` from a game account | `shard_account_links` is module-owned; core reading it by name is core naming a module's table (§2.3) | +| Whether a member is a leader | game rank semantics — for UO, `GuildRank.Rank >= 4` | +| `rankLabel`, `meta`, and everything in them | opaque strings and JSON; core stores and displays, never branches | +| Online status, and its freshness | derived from the module's own live state (§3.3) | +| **Roster field projection per audience rung** | the visibility framework and its config are module-owned (§3.3); core hands over rows and a viewer and takes back what the module permits | +| Activity `kind` vocabulary | §4.1 — core stores `kind` as an opaque string | +| Activity `summary` **text** | core cannot compose "gained 15,000 gold" for a game it knows nothing about | +| Activity `visibility` | the module knows which of its own events are public-safe; core enforces the answer | +| Anything rendered into `team.overview` / `team.member.row` | slots are named for a *place*, never a meaning (§3.4) | +| Ingesting `guild.roster` / `guild.leave` | class D data; core never sees a shard event | + +**The failure this list prevents** is the one `MODULE_SYSTEM.md` Phase 3 existed to undo: core +acquiring a UO-shaped opinion. Concretely, if core ever needs to *parse* a `kind`, *compose* a +`summary`, or *decide* what a rank means, the design has gone wrong — and `scripts/checkModuleIdentifiers.js` +(`MODULE_API.md` §5.2) will fail the build the moment one of those opinions is spelled with a UO word. + +### 10.6 Class D — the wire protocol, on its own number + +Phase 1 only. Governed by [`../link/PROTOCOL_2.md`](../link/PROTOCOL_2.md) and +[`../link/v3.md`](../link/v3.md), **not** by `MODULE_API.md`. + +| Artifact | Repo | Note | +| --- | --- | --- | +| `guild.roster`, `guild.leave` event kinds | `servuo-plugins` (emit), `link` (persist), `module-uo` (ingest) | new kinds; `guild.update` unchanged | +| `PROTOCOL_VERSION` 3 → 4 | `link/sidecar/src/main.rs:49` | every response carries `X-UOLink-Version`; a mismatch is `409` | +| `protocol = 4` | `servuo-plugins/overlay.toml:27` | **same PR as the emitters** — CI copies it into the release manifest and the installer refuses a mismatched pair | +| `members` on the sidecar `guilds` board + `GET /guilds` | `link` | the snapshot rule, so a fresh website gets a roster without waiting for a change | + +**A module does not participate in class D at all.** `MODULE_API.md` §2.7 forbids a module opening a +connection to a game server; the module reads the *sidecar*, which is the only thing that ever speaks +this protocol. + +--- + +## Part 11 — `MODULE_API_VERSION` bump proposal + +**1.6.0 — minor.** Every change is an addition; no member is removed and no existing signature changes, +so `MODULE_API.md` §1.1's table gives minor, and `module-uo`'s `coreApi: "^1.3.0"` still resolves. + +| Addition | Half | Section | +| --- | --- | --- | +| `ctx.teams.publish(event)` — six Team events | server | §2.3 | +| `ctx.teams.reconcile({ reason })` | server | §2.4 | +| `ctx.teams.activity.push(items)` | server | §4.1 | +| `api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })` | server | §2.3 | +| `api.registerSlashCommands([...])` | server | §7.1 | +| Core declares slots `team.overview`, `team.member.row` | client | §3.4 | +| `capabilities` may include `teams` (opaque, as always) | manifest | — | + +Two things this bump does **not** do, deliberately: + +- **It does not add a `getTeamRoster` to `ctx`.** A module reads its own data; core does not offer to + read it back. +- **It does not let a module write `team_members`, `team_forum_grants` or any Team table.** The module + answers questions; core owns the storage. `MODULE_API.md` §2.6's table-prefix rule already forbids a + module touching a core table, and this is the same boundary stated for the new tables. + +**Also required, separately: `PROTOCOL_VERSION` 3 → 4** (`link/sidecar/src/main.rs:49`, mirrored in +`servuo-plugins/overlay.toml:27`, bumped in the **same PR** as the emitters so the next bundle +composes). This is a different number with a different job (`MODULE_API.md` §1.1) and it is what +makes §0.1's roster possible: + +- `guild.roster` — **new kind**: `{ id, members: [ { serial, name, rank, leader, online, acct?, webId? } ] }`. + Emitted when the member set changes (the sweep already computes a member-serial sum; it holds the + actual set instead and diffs it) and on the reconnect baseline. Per §0.7 decision 3, `acct`/`webId` + are present **only** for members whose game account is linked. +- `guild.leave` — **new kind**, the missing counterpart to the existing `guild.join`, now computable + from that same set diff. +- `guild.update` — **unchanged**, so nothing that reads it today has to change. +- Sidecar: `members` JSON on the existing `guilds` board table, served from `GET /guilds` — the §12.2 + snapshot rule, so a website that connects fresh gets a roster without waiting for the next membership + change. +- Multiple leaders on the wire come from `PlayerMobile.GuildRank.Rank >= 4` + (`Scripts/Misc/Guild.cs:38`), which ServUO already maintains per member. + +--- + +## Part 12 — Phased plan + +Every phase is independently shippable and leaves the site working. Phases 1 and 2 are the only hard +serial dependency in the list. + +### Phase 0 — a one-guild roster spike (`servuo-plugins` + `link`, throwaway) + +**Not a deliverable — insurance on the phase that gates everything else.** `servuo-plugins` has no CI +build: the plugin compiles only inside ServUO, and the dynamic rebuild can silently reload a stale +`Scripts.dll`, so "it booted clean" is not evidence the new code is live. Phase 1 is a four-repo +protocol bump with that verification story, and discovering a problem at the end of it is expensive. + +Emit `guild.roster` for a **single** guild against the local ServUO tree +(`C:\Users\colby\Desktop\ServUO`) and the **real** Rust sidecar — not a stub — confirm it lands in the +store, survives a sidecar restart, and comes back out of `GET /guilds`. Then build Phase 1. Days, not +weeks, and it retires the only unknown in the plan. + +### Phase 1 — the roster on the wire (`servuo-plugins` + `link` + `module-uo` + `docs`) + +The prerequisite for everything. Nothing in Team core can be built against counts. + +`BridgeSocial.cs` holds the member serial **set** rather than its sum and diffs it → `guild.roster` +and `guild.leave`; `overlay.toml` protocol → 4. Sidecar: `members` on the `guilds` board, +`PROTOCOL_VERSION` → 4, `GET /guilds` projection. `module-uo`: ingest both kinds, a +`shard_guild_members` table, the kind→feature map entry and field projection for the new fields. +`docs/link/PROTOCOL_2.md` + `v3.md` + `INTEGRATION.md`. + +**Ships:** a richer public Guilds page (real rosters) on its own merit, with no Team code anywhere. +**Verify:** the five-rung shard visibility walk against a live ServUO + sidecar, confirming `acct`/`webId` +never reach a caller below their rung. + +**Stagger the reconnect baseline.** `BridgeLink.OnConnected` clears the diff cache, so every guild +re-emits on reconnect. Checked rather than assumed: the outbound queue cap is 10,000 *lines* +(`BridgeConfig.QueueCap`) and 200 guilds is 200 lines, so there is **no drop risk** — but each line is +now fat (a 200-member roster ≈ 16 KB) and the sidecar's `read_line` has no length bound. Spread the +baseline re-emit across several ticks rather than firing it in one, and cap members per `guild.roster` +line with a continuation flag for the pathological guild. + +### Phase 2 — Team core (`website` + `module-uo` + `docs`) + +`teams`, `team_members`, `team_sync_state`; `registerTeamProvider` + the three `ctx.teams` members +(**`MODULE_API_VERSION` → 1.6.0**); the reconciler with all four refusal gates; the four-path +resolver with its non-contamination tests; `team_leader_overrides`; the public/player/admin read API; +Admin → Teams (sync state, resync, archive, overrides). `module-uo` implements the provider over +Phase 1's data. + +Plus the impersonation controls, which belong here because this is the phase where game-sourced names +first become platform entities: `utils/reservedNames.js`, auto-hide with the review queue (§2.8), the +`hidden` / `display_name_override` columns, `team_moderation_requests` and the admin-approval gate on +the three publishing actions (§2.9), and the §2.10 account-deletion FK decisions — settled now, while +the tables are being created, rather than migrated later. + +**Ships:** Teams exist, are visible in the admin panel, stay correct across a sidecar outage, and a +guild called "Admin" cannot put an official-looking page on the site. + +**Acceptance, three:** +1. Kill the sidecar mid-reconcile — zero rows change, state goes stale, the UI says so. +2. A guild named for a reserved term (a role name, the deployment's brand, or "Runic Gateway") is + created, auto-hidden, absent from every public surface, fully working for its own members, and + listed in the review queue with the matched term. +3. A **moderator** un-hiding it produces a `pending` request and no public change; an **admin** + approving it publishes; an admin doing it directly publishes at once. All three land in + `activity_log`. + +### Phase 3 — Team pages, roster, nav, activity feed (`website` + `module-uo`) + +`/teams`, `/teams/:slug`, `/teams/:slug/roster`, `/player/teams`; the linked/unlinked/guest surface; +`team.overview` + `team.member.row` slots; nav registration; `team_activity` + +`ctx.teams.activity.push` + core's own five activity kinds + the retention prune. + +**Ships:** the whole public Team experience. Independently valuable with no forum and no Discord. + +### Phase 4 — Forum 5a: access model + announcements + admin controls (`website`) + +`team_forum_grants`, the grant/revoke flow with audit into `activity_log`, leader vs staff authority, +the full forum schema, announcement threads, and the leader/staff grant UI. + +Plus the operator's controls (§5.5), which land here because a forum without an off switch is one an +operator cannot ship: `teams_forums_enabled` and its 404 guard; `teams_forum_images` and the +**renderer-owned** image path (the forum's own sanitizer profile with `img` excluded in every mode, +URL detection, the https + extension rules); `uploads` mode with magic-byte sniffing, quotas, rate +limits, `team_forum_uploads` attribution and the deletion sweep; and the versioned, server-enforced, +`activity_log`-recorded upload acknowledgement. + +**Ships:** the permission model everything downstream depends on, plus the switches an operator needs +to run it, at low surface area. + +**Acceptance, four:** +1. A granted, unlinked user reads the forum, appears under "Forum guests", and is absent from every + membership count and from `externalEligible`. +2. `teams_forums_enabled='0'` → every forum route 404s, and no thread, post, grant or subscription is + touched; flipping it back restores the forum unchanged. +3. A post containing an image URL renders as a plain link under `disabled`, as a link **plus** an + embed under `remote` — **with no change to the stored HTML between the two**, which is the property + the renderer-owned design exists to give. +4. `PUT teams_forum_images='uploads'` without a matching `acknowledge` version is rejected `400` + server-side, with the admin UI checkbox bypassed. + +### Phase 5 — Forum 5b: discussion + moderation + reports (`website`) + +Discussion threads, replies, the edit window, pin/lock/hide/delete, `team_forum_moderation`, the admin +ledger view, and **abuse reporting** (§5.6): `content_reports`, the report control, and the queue in +the existing admin moderation section. + +Reports land here rather than in Phase 4 only because discussion is what generates them at volume — if +Phase 4 ships `uploads` mode enabled anywhere before Phase 5, **pull reports forward into Phase 4**. +An upload path with a liability acknowledgement and no way for a member to raise a problem is the one +combination this plan should not ship. + +### Phase 6 — Team notifications (`website`) + +Four core streams, `publishToUsers` + `endpointsForUsersStream`, the recipient computation, +`team_notification_prefs`, its settings screen, and **email as the third sink** (§6.4) with digest +mode and one-click unsubscribe. + +**Android is deliberately not in this phase** — see the deferred note in +[`../android/PLAN.md`](../android/PLAN.md). The streams exist in the catalog and the app will show +them as toggles automatically, but nothing here builds a Team screen or a deep-link target for the +app, so a Team tickle on mobile opens the app and no more. That is a stated limitation, not an +oversight. + +### Phase 7 — Discord: slash commands (`website` + `bot` + `docs`) + +`api.registerSlashCommands`, `/internal/commands` + `/internal/commands/dispatch`, the bot's +defer→dispatch→edit path, the actor resolver, the version-bump re-register, and `/team` as the first +command through it. + +**Ships:** a working `/team`, and the seam a module needs for its own commands. + +### Phase 8 — Discord: notifications bridge (`website` + `bot`) + +`team_integration_config`, the internal fan-out with push and bridge as two consumers, +`POST /internal/team-notify`, the admin per-event configuration. + +### Phase 9 — Discord: voice channels (`website` + `bot`) + +`team_integrations`, the threshold gate, the shared category, overwrite management with role +escalation above `voice_overwrite_max`, the grace-window lifecycle, and the stale-sync suspension. + +### Phase 10 — the capability layer (`website` + `docs`) + +Refactor Phases 7–9's Discord code behind the declared-capability registry (§8.2) and prove it by +rendering the admin UI from the declaration rather than from a hardcoded "Discord" assumption. Last on +purpose: extracting a capability surface from one working implementation is honest; designing it before +one exists is speculation. + +### Cross-cutting, every phase that touches the server + +`npm run swagger` regenerated and committed · `npm run routes:manifest -- --check` zero-line diff · +`BACKEND_DESIGN.md` updated · forward-only idempotent schema fragments · scoped-router isolation · +`npm test` green in `website/server` and `website/client`. Phase 1 additionally needs the installer +bundle to pair a protocol-4 sidecar with a protocol-4 overlay.