The phase ran before the cutover as Part 12 planned, and found what it was meant to: the inverted slot direction worked for module-uo and nobody else. Recorded beside the phase entry, with the org lead's two decisions of the day - core offers a contribution rather than naming a slot, and the template grows a real provider rather than a snippet. Also records the live-rig walk and the one defect it found that no test could: PageHeader takes `lead`, not `subtitle`, and React drops an unknown prop in silence, so every page built from the kit's template had been rendering its heading with nothing under it. Co-Authored-By: Claude <noreply@anthropic.com>
2987 lines
189 KiB
Markdown
2987 lines
189 KiB
Markdown
# 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`, and since
|
||
phase 7 `/internal/refresh-commands`), 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. Phase 7 added `bot/src/site/appInternalClient.js` for `/internal/commands` and
|
||
`/internal/commands/dispatch` on that same listener — it derives the base from `SITE_INTERNAL_URL`'s
|
||
origin rather than taking a second variable naming the same host.
|
||
|
||
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
|
||
`<moduleId>.<name>`), 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** Runic Gateway 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.
|
||
|
||
```
|
||
Runic Gateway 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 |
|
||
|
||
> **The project's name has two legitimate presentations, and both must match.** **Runic Gateway** (two
|
||
> words) is the correct form; **RunicGateway** is accepted only where the name has to condense to a
|
||
> single token — the Gitea org, a package name, a URL segment. A reserved term is therefore stored in
|
||
> its correct two-word form and matched against *both*, which §2.8.2's whitespace-insensitive
|
||
> comparison gives for free. Listing the condensed form as a separate term would be a second thing to
|
||
> keep in sync, and it would still miss `runic-gateway` and `Runic_Gateway`.
|
||
>
|
||
> The same applies to a deployment's own brand: `BRAND_NAME` is free text and an operator may well
|
||
> have set a spaced name whose condensed form is what a would-be impersonator types.
|
||
|
||
**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.
|
||
|
||
**A multi-word term is additionally compared with whitespace removed on both sides**, so a term stored
|
||
as `Runic Gateway` matches `RunicGateway`, `runic-gateway`, `Runic_Gateway` and `RUNIC GATEWAY`
|
||
alike. Without this the whole-word rule fails on exactly the case that matters: `RunicGateway` is a
|
||
*single* word and would never match a two-word term, so the condensed form — the one an impersonator
|
||
would reach for, because it is what the Gitea org and every URL already use — would sail straight
|
||
through.
|
||
|
||
That widening applies **only to terms containing whitespace**, which keeps it away from the
|
||
single-word terms where whole-word matching is doing the false-positive work: `admin` is still
|
||
compared as a word and still does not fire on "Badminton". A two-word term is specific enough that
|
||
running the letters together cannot collide with ordinary vocabulary.
|
||
|
||
#### 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, row-projected per audience rung (§3.3)
|
||
GET /api/v1/public/teams/:slug/activity the feed, paged, filtered to what the caller may
|
||
see (§4.3) — added in phase 3; a session is
|
||
optional on this route and on /members
|
||
|
||
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
|
||
|
||
> **Superseded 2026-08-17 (phase 3, org lead).** The four public/player rows below are NOT core's.
|
||
> Teams is a contract primitive and core does not own the vocabulary, so the module that owns the word
|
||
> owns the page: `module-uo` renders these under `/uo/guilds`. Only the two `/admin/teams` rows are
|
||
> core's. See the phase 3 amendment in Part 12.
|
||
|
||
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 | `{ displayName, isLeader, linked }` |
|
||
|
||
> **Superseded 2026-08-17 (phase 3, org lead).** Both slots are gone, and the DIRECTION is what
|
||
> changed. They assumed core rendered the Team page; core renders no Team page. The replacement is
|
||
> `registry.declareModuleSlot(id, name, { core })` — a **module** declares a place on its own page,
|
||
> namespaced under its own id, naming which of core's contributions goes there, and **core** offers it:
|
||
>
|
||
> | Slot | Declared by | Rendered in | Filled by core with | Props |
|
||
> | --- | --- | --- | --- | --- |
|
||
> | `uo.guild.detail` | `module-uo` | its guild detail page | `team.activity` — the Team activity feed (§4.3) | `{ externalId, moduleId }` |
|
||
> | `uo.guild.forum` | `module-uo` | the same page, below the feed | `team.forum` — the Team forum (Part 5), added in phase 4 | `{ externalId, moduleId }` |
|
||
>
|
||
> Core's contributions are applied at MOUNT, not eagerly: core's bundle evaluates before every module
|
||
> chunk, so when core offers one, no module-declared slot exists yet. A contribution nothing asks for
|
||
> is a no-op, not an error — the mirror of an unfilled slot rendering nothing. **Core names the
|
||
> contribution and never the slot** (amended phase 11, inside 1.6.0: as first built it filled the
|
||
> literal names above, which reached `module-uo` and no other game). `Slot` becomes
|
||
> the ninth member of the shared UI kit so a module renders the place with core's own error boundary,
|
||
> which matters here because the thing being contained is CORE's content failing inside the MODULE's
|
||
> page.
|
||
>
|
||
> The props are the module's own vocabulary. `memberKey`/`userId` are not among them and could not
|
||
> be: §3.2 withholds both from every public roster, and a client slot only receives what the browser
|
||
> was already sent.
|
||
|
||
### 3.5 Nav
|
||
|
||
> **Superseded 2026-08-17 (phase 3, org lead).** None of the three entries below is registered, and
|
||
> the `teams` feature flag is not either. Core publishes no Team nav row because a core row would name
|
||
> a surface core does not own, sitting beside the module's own row for the same thing in a different
|
||
> word. `/admin/teams`'s sidebar entry, which landed in phase 2 and is an operator view of the
|
||
> primitive, is unaffected and stays.
|
||
|
||
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 <moduleId>.<name>, 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).
|
||
|
||
> **Amended 2026-08-18 (phase 4).** §3.1's `/teams/:slug/forum/*` core page is gone with the rest of
|
||
> them. The routes below are unchanged — every one is `/player` or `/admin` — but the participant
|
||
> surface is core's fill of the module-declared `uo.guild.forum` slot (§3.4), so a reader is on
|
||
> `module-uo`'s guild page throughout. Two routes were added that this table did not have:
|
||
> `GET /api/v1/player/teams/:slug/grants` (a leader has to SEE the guests before managing them) and
|
||
> `GET /api/v1/admin/teams/forum/settings`, which serves the one piece of forum state that is not a
|
||
> public settings key — whether the uploads acknowledgement has been given, by whom, and whether the
|
||
> notice has been reworded since (§5.5.6 keeps that key unpublished).
|
||
>
|
||
> The grant routes deliberately answer while the forum is switched OFF, which no line below says: a
|
||
> toggle-off revokes no grant and the rows stay authoritative (§5.5.1), so the access list has to stay
|
||
> manageable during one. What the switch guards is the forum's CONTENT.
|
||
|
||
> **Amended 2026-08-18 (phase 5).** The 5b routes are as tabled, with three notes the table does not
|
||
> carry.
|
||
>
|
||
> **`POST /forum/threads` splits its authority BY TYPE rather than widening the leader gate.** An
|
||
> `announcement` stays leader-authored; a `discussion` may be opened by any forum participant —
|
||
> including a granted non-member with no game identity, which is path 3 doing its job. `type` defaults
|
||
> to `announcement`, so a phase-4 client keeps meaning what it meant; defaulting the other way would
|
||
> silently turn its announcements into discussions. The list response reports the split as **two**
|
||
> booleans, `canPost` (may open a discussion) and `canAnnounce` (leader), because a client reading one
|
||
> boolean would have to guess which right it described.
|
||
>
|
||
> **Post-level moderation is its own route**, `POST /forum/posts/:id/moderate`, rather than the thread
|
||
> route with a target kind: `pin` and `lock` describe a thread's place in a list and its openness to
|
||
> replies, neither of which a post has. The route's validator deliberately accepts **all eight**
|
||
> actions so the model can answer `pin` with *"pin applies to a thread, not to a post"* — restricting
|
||
> it to the four a post takes turns a nameable mistake into a generic validation error, which is what
|
||
> the live rig found.
|
||
>
|
||
> **Three refusal codes on a reply, chosen to be distinguishable.** 404 for a thread that is absent or
|
||
> hidden from this caller; **400** for an announcement, which takes no replies by TYPE and no retry
|
||
> fixes; **409** for a locked thread, where the request is well-formed and the resource's state is what
|
||
> refuses. Locked refuses **staff too** — they hold `unlock`, so unlock/post/relock reaches the same
|
||
> place leaving three ledger rows that say what happened, whereas a moderator's reply in a thread
|
||
> nobody else may answer is the last word by fiat.
|
||
|
||
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 `<img>` 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 `<img>` 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: <a href="https://example.com/banner.png" rel="noopener noreferrer nofollow">https://example.com/banner.png</a>
|
||
rendered: that link, and — in `remote` / `uploads` mode only — a core-generated
|
||
<img src="…" loading="lazy" referrerpolicy="no-referrer" alt=""> 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 `<img>` 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 `<img>` 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 = "<warning text version>" "1" — the wording in §5.5.5
|
||
→ 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: <currentVersion>`. 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.
|
||
|
||
**The wording, version 1** — supplied by the org lead 2026-08-17 and normative for the build. It is
|
||
**two surfaces, not one**, and the split matters: the first is always on screen and explains what the
|
||
setting is, the second appears only at the moment of change and is what the acknowledgement records.
|
||
|
||
**(a) Settings help text** — rendered beneath the image-mode selector at all times, in every mode:
|
||
|
||
> Image uploads are disabled by default.
|
||
>
|
||
> Enabling uploads allows users to store files on infrastructure that you control.
|
||
>
|
||
> By enabling this feature, you acknowledge that you are responsible for:
|
||
>
|
||
> - Moderating uploaded content
|
||
> - Managing storage and backups
|
||
> - Complying with applicable laws and regulations
|
||
> - Establishing policies for your community
|
||
>
|
||
> Runic Gateway does not provide hosted storage or content moderation services. All uploaded content
|
||
> is stored on your own infrastructure.
|
||
|
||
**(b) Confirmation dialog** — shown only when changing the mode *to* `uploads`:
|
||
|
||
> ⚠ Image uploads are currently disabled.
|
||
>
|
||
> Enabling uploads will allow users to store files on your server.
|
||
>
|
||
> ☐ I understand that uploaded files will be stored on infrastructure that I control.
|
||
> ☐ I understand that I am responsible for community moderation policies on this installation.
|
||
>
|
||
> `Cancel` · `Enable uploads`
|
||
|
||
**Two checkboxes, one recorded acknowledgement.** `Enable uploads` stays disabled until both are
|
||
ticked, but the request still carries a single `acknowledge: 1` and the stored value is still the
|
||
text **version**. Recording two booleans would add nothing — there is no reachable state where an
|
||
operator consented to one clause and not the other and proceeded anyway — while the version is what
|
||
actually answers the question that matters later: *which text did they agree to?*
|
||
|
||
> **Settled 2026-08-18 (org lead): all three additions below are IN**, and the build ships them —
|
||
> 1 and 3 in the help text, 2 in the dialog.
|
||
|
||
**Three additions proposed on top, marked so they can be dropped.** Each closes a gap the text above
|
||
does not currently cover; none is liability language, so none changes what is being agreed to:
|
||
|
||
1. **In the help text, after the bullets** — the reassuring counterpart, and the reason §5.5.4's
|
||
attribution table exists at all:
|
||
> Uploads are attributed to the account that made them, and your staff can remove them at any time.
|
||
2. **In the dialog** — the expectation gap most likely to bite. An operator who turns uploads off
|
||
because of a problem will assume the problem goes with it, and it does not:
|
||
> Disabling uploads later stops new files being accepted. It does not delete files already
|
||
> uploaded — remove those from the forum moderation tools.
|
||
3. **In the help text** — the blast radius, since "users" is doing a lot of work. Forum access is not
|
||
the same as game membership (§2.5 path 3), so this genuinely surprises:
|
||
> Anyone with access to a team forum can upload, including members granted access manually who have
|
||
> no linked game account.
|
||
|
||
**Not covered by this text, and needing its own line: `remote` mode.** The wording above is
|
||
upload-specific, and correctly so — nothing is stored in `remote` mode, which is why it takes a
|
||
non-blocking advisory rather than an acknowledgement. It still needs one, because the operator's
|
||
server is doing the displaying:
|
||
|
||
> Images hosted elsewhere are loaded by each visitor's browser directly from the site hosting them.
|
||
> That site can see your visitors' IP addresses, and you do not control whether the image changes or
|
||
> disappears.
|
||
|
||
#### 5.5.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.5.7 `teams_forum_edit_window_minutes` — how long an author may edit (phase 5)
|
||
|
||
An ordinary `settings` key, `0`–`1440`, **default 15**, on the same admin screen as the other two. Set
|
||
to `0` it makes posts permanent once written, which is a legitimate operator choice rather than an
|
||
off switch — there is no state in which editing is "disabled" as opposed to "bounded at zero", and
|
||
inventing one would only give the resolver a decision to get wrong.
|
||
|
||
**Staff are not bound by it.** The window exists so a post cannot be rewritten out from under someone
|
||
quoting it, or under a moderator about to act on a report; a staffer editing another member's post is
|
||
already an intervention that writes `activity_log` (§5.3), and time-bounding it would only mean
|
||
waiting.
|
||
|
||
**It is evaluated on the server twice, on purpose.** The read path stamps every post with `canEdit`
|
||
and `editableUntil` so a client knows whether to draw the control; the write re-derives it from
|
||
`created_at` before allowing anything. Two evaluations of one rule: the read one is advice and the
|
||
write one is enforcement. A client may use `editableUntil` to WITHDRAW an offer whose deadline passed
|
||
while a page sat open, and can never create one — **a time-bounded permission must not take its clock
|
||
from the party it bounds**, which is why the window itself is not a published setting (§5.5.6) and is
|
||
served only to the admin screen that edits it.
|
||
|
||
A hidden or deleted post is editable by nobody, staff included. Restoring it is a moderation action
|
||
with a ledger row; quietly rewriting it while it is out of sight is the same act with no record.
|
||
|
||
The read fails closed to **zero**, not to the default — the opposite of what it looks like it should
|
||
do. The risk the window bounds is an author rewriting a post out from under a reader, so the safe
|
||
answer during a DB fault is "nobody may edit for the next minute". A stale uploads acknowledgement
|
||
freezes this key along with the other two: it is a forum setting.
|
||
|
||
### 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;
|
||
```
|
||
|
||
> **Amended 2026-08-18 (phase 5). The table as shipped departs from the block above in four places,
|
||
> three of them corrections and one an addition.**
|
||
>
|
||
> **The unique key is on a generated `open_marker`, not on `status`, and the spelling above has a
|
||
> defect worth recording rather than quietly fixing.** With `status` in the key, CLOSED rows collide
|
||
> with each other too: a reporter reports a post, staff dismiss it, the behaviour recurs, they report
|
||
> it again — and the second dismissal is an `UPDATE` into a `(…, 'dismissed')` tuple that already
|
||
> exists, so working the queue starts throwing duplicate-key errors on the first repeat reporter. The
|
||
> shipped column is `open_marker TINYINT(1) AS (IF(status IN ('open','reviewing'), 1, NULL)) STORED`,
|
||
> the same trick `team_forum_grants.active_marker` uses: 1 while open, NULL once closed, and MySQL
|
||
> treats NULLs as distinct — so any number of closed reports coexist while at most one open one can.
|
||
> That is what the prose above actually asks for.
|
||
>
|
||
> **`handled_note VARCHAR(500)`** was added. §5.6's API takes `{ status, note? }` and the table had
|
||
> nowhere to put the note. A queue whose resolution reason lives only in an `activity_log` line is one
|
||
> where the next staffer to see a repeat report about the same content cannot find out why the last
|
||
> one was closed.
|
||
>
|
||
> **`reporter_username` and `handled_username` snapshots** were added, per §2.10: who raised a report
|
||
> and who decided it must survive the account, exactly as every other Team table already does.
|
||
>
|
||
> **`team_id` gained a real FK with `ON DELETE CASCADE`.** The block above leaves it a bare
|
||
> denormalised column; a deleted Team then leaves a queue full of reports about content that cascaded
|
||
> away with it.
|
||
>
|
||
> Every transition writes `activity_log`, **`dismissed` included**. A queue where acting is audited and
|
||
> declining to act is not is one where the cheapest way to make a report vanish leaves no trace — and
|
||
> the reports most worth auditing are exactly the ones somebody wanted gone.
|
||
|
||
Four rules:
|
||
|
||
- **Reports go to site staff, and to nobody else.** *(Amended 2026-08-18, org lead, when phase 5 was
|
||
built.)* This section originally added "a leader may also see and act on reports for their own
|
||
Team". **That half is not implemented and is not deferred — it is decided against.** The gap this
|
||
whole section exists to close is that leaders moderate their own Team's forum and a Team's leaders
|
||
are exactly the people who will not report their own Team; a leader-visible queue hands a complaint
|
||
*about* a leader straight back to them, and a read-only leader view still tells them who reported
|
||
what. There is one queue, under `/admin/moderation`, gated to admin + moderator. If a leader-facing
|
||
surface is ever wanted it is a fresh design decision, not a refactor — `content_reports.team_id`
|
||
makes it *possible*, which is not the same as intended.
|
||
- **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? }
|
||
```
|
||
|
||
The player route sits behind the same `resolveForum` guard as the rest of §5.4, so a reporter is by
|
||
construction someone who can already see what they are reporting — and the model additionally checks
|
||
the target really belongs to the Team the request came through, or the queue's per-Team filter would
|
||
quietly be lying. A duplicate answers **409** rather than pretending to succeed: silently accepting is
|
||
friendlier for one tap and dishonest for the second, and a member who reports twice because nothing
|
||
seemed to happen deserves to be told the first is already in the queue.
|
||
|
||
The queue resolves every row's target in **three batched reads** keyed by target type, never one read
|
||
per row — that is rule 4 above actually paying for §5.5.4's attribution table, and the N+1 version is
|
||
how a queue becomes a thing staff avoid opening. A target that has since been hard-deleted comes back
|
||
as `null` and the report still lists: "somebody reported this and by the time we looked it was gone"
|
||
is a fact a moderator needs, and dropping the row would hide the pattern of a member deleting their
|
||
own content the moment it is reported.
|
||
|
||
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
|
||
|
||
> **Built 2026-08-18 (phase 6).** As-built, and it deviates from what is written below in four
|
||
> places. Each is recorded here rather than by rewriting the section, so the reasoning that produced
|
||
> the original design stays legible next to what the build learned:
|
||
>
|
||
> 1. **There was no web notification settings screen to add the Team list to.** §6.3 says the per-Team
|
||
> mute list is surfaced "under the existing notification settings screen". No such screen existed:
|
||
> `/auth/me/notifications/*` had been built for the Android app in M7 and had **zero** web
|
||
> consumers. Tolerable while push was the only sink — push needs the app anyway. Not tolerable for
|
||
> email, whose entire argument (§6.4) is the web-only user, so the sink and the screen to configure
|
||
> it shipped together as `/account/notifications`.
|
||
> 2. **Email defaults to `off`, not to `digest`.** §6.4 specifies digest-by-default; on the org lead's
|
||
> decision it is opt-IN, because digest-by-default means every member of every Team starts
|
||
> receiving daily mail the moment an operator connects Gmail — a decision about other people's
|
||
> inboxes, made on their behalf. **Push stays opt-out.** The two sinks now default opposite ways;
|
||
> the asymmetry lives in the schema's column defaults and nowhere else.
|
||
> 3. **Roster events do not email.** All four streams exist and all four tickle. Only the two forum
|
||
> streams reach the email sink: §6.4's argument is the reply nobody hears about, and "someone
|
||
> joined the guild" arrives from a fifteen-minute sweep, is already on the activity feed, and is
|
||
> how a notification feature earns a spam complaint.
|
||
> 4. **A ninth member joined `MODULE_API_VERSION` 1.6.0** — `pageUrlTemplate` on the team provider.
|
||
> Phase 3 left core with no Team page and therefore no way to *link* to one, so an email could name
|
||
> a Team and not take you to it. The module that owns the page now says where it is. See
|
||
> [`MODULE_API.md`](MODULE_API.md) `registerTeamProvider`.
|
||
>
|
||
> Two further build decisions, neither contradicting anything above: the digest **computes at send
|
||
> time** and keeps no queue (§6.4 as-built, below), and one-click unsubscribe is a **stateless
|
||
> HMAC** rather than a token table.
|
||
|
||
|
||
### 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 — **in SQL, not in the caller**: there is no
|
||
function in `model/teams/teamNotify.db.js` that returns an unfiltered recipient list, because one
|
||
would be a refactor away from being used.
|
||
|
||
**As built**, the column is `email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off'` plus
|
||
a `last_digest_at DATETIME NULL` (the digest's only state, see §6.4), and it is surfaced in two
|
||
places:
|
||
|
||
- **`/account/notifications`**, a new core page in the player portal — stream subscriptions, the
|
||
per-Team mute list, and the email mode per Team. `GET|PUT /auth/me/notifications/teams`; the `teams`
|
||
array is required on PUT even when empty, per the Android gotcha below.
|
||
- **A mute toggle on the Team page**, filled into a THIRD module-declared slot, `uo.guild.header`.
|
||
Above the roster rather than below it, because muting is an action *on* the page and the feed and
|
||
forum are content *in* it — which is exactly the placement decision a module cannot make if core
|
||
stacks everything into one fill. It renders nothing for a viewer with no preference row available,
|
||
which is a privacy property and not a tidiness one: whether a preference *exists* for a Team answers
|
||
"is this person in it", and the guild page is public.
|
||
|
||
### 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, when email is on at all.** A busy Team forum sending one email per reply is
|
||
how a notification feature gets marked as spam. `email_mode ENUM('off','digest','immediate')` in
|
||
`team_notification_prefs`.
|
||
> **As built, the default is `off` and not `digest`** (org lead, 2026-08-18): digest-by-default
|
||
> would start mailing every member of every Team the moment an operator connects Gmail. Email is
|
||
> the one opt-IN sink here. Push stays opt-out, because a mute silences something the user already
|
||
> has.
|
||
- **The digest computes at send time and keeps no queue** (as built). The only state is
|
||
`last_digest_at`; the worker asks what arrived after it and re-runs the access resolver. Three
|
||
properties fall out, and the third is why it was chosen over a pending-items table: a deployment
|
||
down for two days sends **one** correct digest rather than replaying a backlog; a post a moderator
|
||
hid after it was written is simply not in the query; and **a user who lost forum access between the
|
||
post and the send is no longer in the recipient set**, so they are not emailed content they can no
|
||
longer read. `since` is clamped to at most seven days so a long outage cannot produce one enormous
|
||
mail, and `last_digest_at` is stamped **only on a successful send** — stamping first would quietly
|
||
eat a day of somebody's notifications every time the mail provider had a bad minute.
|
||
- **Roster events do not email** (as built). `team.member.joined` and `team.leadership.changed`
|
||
tickle and stop there; only `team.forum.post` and `team.announcement` reach this sink.
|
||
- **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.
|
||
> **As built: a stateless HMAC over `(version, userId, teamId)`, not a token table.** Every property
|
||
> that makes a password-reset token a row is absent here — the link sits in a mailbox for months so
|
||
> it has no useful expiry, and clicking it twice must mean what clicking it once meant. The
|
||
> capability it carries is deliberately the narrowest that does the job: set `muted` for **one**
|
||
> (user, Team) pair. It reads nothing, cannot un-mute, and names no other Team. `version` is the
|
||
> only revocation a stateless design can offer — bumping it invalidates every outstanding link at
|
||
> once — and it exists before it is needed rather than after.
|
||
>
|
||
> **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the
|
||
> site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The
|
||
> `List-Unsubscribe` *header* carries `POST /api/v1/public/teams/unsubscribe/:token`, because RFC
|
||
> 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and
|
||
> does not act** — a mail client's link scanner would otherwise silently mute Teams nobody asked to
|
||
> leave. The endpoint answers `200` whatever the token was: a response that distinguished a valid
|
||
> token from a forgery would be an oracle for which (user, Team) pairs exist, on a surface with no
|
||
> session behind it.
|
||
|
||
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
|
||
|
||
> **Amended 2026-08-18 (phase 7), as built.** Five changes, four of them forced by what the tree
|
||
> already looked like.
|
||
>
|
||
> **The example command is `/guild`, registered by module-uo, and core registers none.** §7.1 wrote
|
||
> `/team` as a core command against core's own Team rows. Phase 3 settled that **Teams is a contract
|
||
> primitive with no core surface** — core does not own the word for a Team, which is why four core
|
||
> Team pages were deleted — and a core `/team` publishes that same invented noun into a channel. The
|
||
> module owns the vocabulary, so the module owns the command. Core ships the dispatcher, the actor
|
||
> resolver and the transport, and zero commands.
|
||
>
|
||
> **The deep link comes from `pageUrlTemplate`, because `/teams/:slug` does not exist.** The snippet
|
||
> below still says `${siteBaseUrl}/teams/${slug}`; there is no such page. A handler builds its own
|
||
> link — module-uo's is `/uo/guilds/{externalId}` — which is the same hole phase 6 found in the mail
|
||
> path and closed with the ninth contract member.
|
||
>
|
||
> **The re-register nudge is its own endpoint, `POST /internal/refresh-commands` on the bot**, not a
|
||
> ride on `/internal/config`. That body carries the DECRYPTED bot token: telling the bot that a
|
||
> module changed should not require reading a secret out of the database to say it.
|
||
>
|
||
> **`actor` carries `role` as well as `isStaff`.** The two answer different questions and a boolean
|
||
> loses one — `isStaff` is core's gate for `access: 'staff'`, `role` is what a module with its own
|
||
> audience rungs needs to place the caller on them. It is the pair `projectRoster`'s viewer already
|
||
> carried (§3.3), not a new class of disclosure.
|
||
>
|
||
> **Deregistration needed a second half this section did not consider.** "A module that is gone is
|
||
> simply absent from the next pull" holds across the restart an uninstall asks for. It does not hold
|
||
> for the runtime toggle: the registries have no removal path, so a module an operator disables would
|
||
> keep a live handler behind a command Discord still advertises. Liveness is therefore asked at both
|
||
> the pull and the dispatch, and a disabled owner's command answers `unknown`.
|
||
>
|
||
> The envelope also gained **`notice`** — a private aside delivered beside a public answer, which is
|
||
> how §9 answer 5's "public projection plus an ephemeral prompt to link" is actually expressible: one
|
||
> reply cannot be both public and ephemeral, and that it becomes a follow-up is the platform's
|
||
> decision, not the handler's.
|
||
|
||
|
||
**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
|
||
|
||
> **Amended after building it (phase 8, 2026-08-18).** The shape below is what was designed; five
|
||
> things about it did not survive contact with the tree, and the amendments are inline. The largest
|
||
> is that **this section's own visibility gate has no data source and cannot have one** — see "The
|
||
> gate, as built" below. The phase entry in Part 12 carries the full list.
|
||
|
||
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.
|
||
|
||
> **As built**, there is no new fan-out object: `utils/teamNotify.js` already computed the recipient
|
||
> set once and handed the event to push and to email, so the bridge is a **third sink in that same
|
||
> file** rather than a subscriber to something new. `utils/teamBridge.js` is the sink; the file that
|
||
> calls it is unchanged in structure.
|
||
|
||
```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;
|
||
```
|
||
|
||
> **This DDL cannot hold its own default row.** MariaDB coerces every `PRIMARY KEY` column to
|
||
> `NOT NULL`, so `team_id NULL` — the deployment-wide default that every override overrides — is
|
||
> unrepresentable, and the whole mechanism has no base case. **As built:** a surrogate `id` primary
|
||
> key, a generated `team_key INT AS (IFNULL(team_id, 0)) STORED` carrying
|
||
> `UNIQUE KEY (platform, team_key)`, and a real `FOREIGN KEY (team_id) … ON DELETE CASCADE` that the
|
||
> original had no room for — without it a deleted Team leaves its configuration behind for whichever
|
||
> Team next lands on that id. The generated-column trick is the one `teams.active_key` and
|
||
> `content_reports.open_marker` already use. Three further columns carry the gate: `members_ack`,
|
||
> `members_ack_by` and `members_ack_at`.
|
||
|
||
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`.
|
||
|
||
> **`announce` and `mod-reverse` are not the same thing.** `announce` rides `announce_jobs` with
|
||
> backoff, retries and a per-leg retry button in the admin panel; `mod-reverse` is a one-shot call
|
||
> that records failure and stops. **The bridge is one-shot.** A news post is a durable artifact whose
|
||
> Discord copy is expected to exist; a Team notification is the moment it describes, and one that
|
||
> arrives twenty minutes late is worse than one that never arrives. A second job table and a second
|
||
> worker is a great deal of machinery to buy the opposite outcome.
|
||
>
|
||
> **The admin surface is its own panel under Admin → Teams**, beside the forum settings, and not an
|
||
> extension of the Discord Bot panel — phase 10 makes the platform a registry lookup, and what should
|
||
> change then is what fills the panel, not where it is. It is **admin-only**, the one such corner of a
|
||
> staff-wide router: configuring where a Team's content leaves the site for is deployment
|
||
> configuration rather than the §2.9 kind of decision a moderator files a request for.
|
||
|
||
**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.
|
||
|
||
#### The gate, as built
|
||
|
||
**Neither half of that last sentence has a data source, and neither can have one.**
|
||
|
||
- The four `team.*` streams carry **no `visibility`**. Only `team_activity` rows do, and a
|
||
notification is not an activity row.
|
||
- Forum threads have **no public/members column**, because a forum is members-only by construction —
|
||
every thread in it sits behind `team_forum_grants`. So §7.2's own example configuration,
|
||
`['team.announcement','team.forum.post']`, names exactly the two events that can never be public.
|
||
- Core **cannot see a Discord channel's permissions**, so "configured for a members-only Team
|
||
context" is not a fact core can check. Only the operator can see it.
|
||
|
||
So the gate becomes an **attributed acknowledgement**: enabling an event that carries members-only
|
||
content requires an explicit confirmation that the destination channel is restricted to that Team's
|
||
members, recorded with who gave it and when — the same shape `teams_forum_uploads_ack` uses for the
|
||
image policy (§5.5.5). Four properties make it a gate rather than a checkbox:
|
||
|
||
1. **It is a precondition, not a preference.** A save that would enable a members-only event without
|
||
it is refused **422**, not accepted-and-quietly-degraded. A configuration that silently does less
|
||
than it says is worse than one that will not save.
|
||
2. **It is re-asked at delivery**, not only at the save, so a row that loses the tick — an admin
|
||
repoints it, or a future change reclassifies a stream it already carries — stops carrying those
|
||
events immediately rather than at the next save.
|
||
3. **Changing the channel clears it.** An acknowledgement is about a *destination*; it cannot survive
|
||
the destination changing underneath it, or an operator could confirm a private channel and then
|
||
repoint the row at a public one while keeping the permission granted for somewhere else.
|
||
4. **A roster-only bridge needs no acknowledgement at all**, and a *disabled* row may carry forum
|
||
events without one — drafting a configuration is not publishing to a channel, and a dialog that
|
||
appears on saves that did not need it is one people learn to click through.
|
||
|
||
Two smaller consequences of the same asymmetry:
|
||
|
||
- **The author exclusion stops at the channel.** Push and email both subtract the post's author; the
|
||
bridge does not. Excluding is a per-recipient idea, and a channel has no per-recipient anything —
|
||
suppressing the message because the author reads that channel would deprive everyone else in it.
|
||
- **A roster event carries a count and never a name.** The sync notifies once per run rather than
|
||
once per member (§6.2), so a count is all the caller holds. It is also all it should say: a
|
||
character name is game-sourced text screened for a *page*, not for a channel.
|
||
|
||
### 7.3 One voice channel per Team
|
||
|
||
> **Amended 2026-08-19, as built (phase 9).** The org lead settled the access model as **a per-Team
|
||
> role, always** — the escalation below is gone, and with it `voice_overwrite_max` and the `mode`
|
||
> column. Three things this section names turned out not to exist in the tree at all, and one number
|
||
> it relies on counts something different from what it says. Each is marked inline; "as built" wins
|
||
> over the original wording wherever they disagree.
|
||
|
||
**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.
|
||
|
||
> **As built: a per-Team role, always.** Overwrites-by-default with escalation was designed to spend
|
||
> the scarcer guild-wide resource only where the per-channel budget actually ran out. Roles-always is
|
||
> one code path instead of two plus a transition, and it makes the grant a thing a member can be given
|
||
> and taken rather than a channel-shaped list — but it moves the ceiling, and that is the part worth
|
||
> stating plainly:
|
||
>
|
||
> | | overwrites (designed) | roles (as built) |
|
||
> | --- | --- | --- |
|
||
> | Limit | ~100 overwrites **per channel** | 250 roles **per guild** |
|
||
> | So the ceiling is | how big ONE Team can be | how many TEAMS can have voice |
|
||
> | Visible to other members | no | yes — a role shows on a profile |
|
||
>
|
||
> A limit on the number of Teams is one an operator has to be told about *before* they reach it, so
|
||
> the admin panel reports the guild's role count against the cap and the reconciler refuses the create
|
||
> rather than letting Discord reject it. The count comes from the bot, not from core's own rows: the
|
||
> cap is shared with every role the operator made themselves.
|
||
>
|
||
> That a Team's membership becomes visible guild-wide on each member's profile is the trade this
|
||
> bought. It is not per-deployment configurable.
|
||
|
||
**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`.
|
||
|
||
> **As built.** The channel carries exactly three kinds of overwrite: `@everyone` denied, the Team's
|
||
> own role allowed, and one allow per operator-designated staff role. Membership is the role's member
|
||
> list. There is no `mode`, no `voice_overwrite_max` and no escalation.
|
||
>
|
||
> **"the staff role" does not exist in this codebase.** `guild_config` knows a news channel, a modlog
|
||
> channel, an autorole and a filter allowlist; none of them means "staff", and core has no way to
|
||
> derive one. Guild administrators bypass channel overwrites anyway, so what is actually missing is a
|
||
> way to let **non-admin** staff in — and only the operator can say which of their roles those are.
|
||
> As built: `teams_voice_staff_roles`, a list of role ids, **empty by default and a perfectly ordinary
|
||
> answer**. A role the operator has since deleted is filtered out by the bot rather than sent, because
|
||
> Discord rejects an entire overwrite set for one bad id and that would take the Team's own grant down
|
||
> with it.
|
||
>
|
||
> **The grant set is hop 3, not path 4's "linked".** A role can only be given to somebody Discord
|
||
> knows, so the set is Team members who have a site account *and* a `user_identities` row for Discord
|
||
> *and* are in the guild. A member missing the last of those is skipped silently — it is §2.6's hop 3
|
||
> without hop 4, an ordinary state, not an error worth a hundred log lines.
|
||
|
||
**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.
|
||
|
||
> **As built: `teams_voice_min_members`, counting EVERY active member** (org lead, 2026-08-19). The
|
||
> question an operator is answering with this number is "is this Team real enough to deserve a
|
||
> channel", and link state answers a different one. Note that this is deliberately *not*
|
||
> `teams.linked_count` either — that column counts hop 1 (has a site account), which is a third
|
||
> quantity again.
|
||
>
|
||
> **Two more gates the original does not mention, both required:**
|
||
>
|
||
> - **A hidden Team is never provisioned.** A channel name is a game-sourced string published outside
|
||
> the site, which is exactly §2.8's concern — `utils/reservedNames.js` already names "and eventually
|
||
> a Discord channel name" among the surfaces it protects. So the screen that suppresses a Team's
|
||
> public page suppresses its channel, and a Team that *becomes* hidden takes the grace window like
|
||
> any other removal. The interlock costs one `hidden = 0` in one query rather than a second policy
|
||
> that could drift from the first. The name published is `display_name_override || name` — §2.8.3
|
||
> lets staff change what is displayed, and a channel is a display surface.
|
||
> - **The bot must actually be able to act.** This section assumes it can manage channels and roles;
|
||
> nothing in this project has ever checked. The operator invites the bot by hand and there is no
|
||
> invite URL with a permission integer anywhere in the tree, so a deployment can sit one unticked
|
||
> box away from every call failing with only a column of identical per-Team errors to show for it.
|
||
> As built, a **preflight is a precondition**: `PUT /admin/teams/voice` with `enabled: true` is
|
||
> refused **422** while the bot is disconnected or missing Manage Channels or Manage Roles, in the
|
||
> same shape §7.2's acknowledgement refuses. It is asked again at the top of every pass. Switching
|
||
> voice OFF is never gated — an operator disabling a feature because it is misbehaving must not be
|
||
> blocked by the misbehaviour.
|
||
>
|
||
> The preflight also reports the **bot's own role position**, because that is the second, quieter
|
||
> failure: Manage Roles lets the bot create a role, but it can only grant roles *below* its own
|
||
> highest. A bot at the bottom of the list creates roles it cannot hand to anybody, which looks exactly
|
||
> like a channel nobody can enter.
|
||
|
||
**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.
|
||
|
||
> **As built, with one narrowing.** "Recover inside the window → **no Discord call made**" is not
|
||
> quite what happens, and the truer promise is **no DESTRUCTIVE call**. A Team that climbed back above
|
||
> the threshold has members who need granting, and the ordinary membership diff is what grants them;
|
||
> refusing to call at all would leave the very people who brought it back outside the channel. What
|
||
> the recovery cancels is the deletion, and the channel id is unchanged — which is the whole point.
|
||
>
|
||
> A **failed teardown keeps the expired window** rather than being rescheduled. Granting another seven
|
||
> days each time a delete fails means it never happens.
|
||
>
|
||
> **Switching voice off tears nothing down.** The pass suspends in both directions and existing
|
||
> channels are left standing, inert; the panel says how many remain and offers to remove them one at a
|
||
> time. A checkbox must not delete structure in somebody's guild, and an operator trying the feature
|
||
> out must be able to stop trying it without consequences. Per-row removal is also the only way to
|
||
> clean up while voice is off, since no pass will ever reach those rows.
|
||
|
||
**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.
|
||
|
||
> **As built, and proved on the rig** — a stale projection stops the pass before a single Discord call,
|
||
> in both directions, with the row not even scheduled for removal.
|
||
>
|
||
> One boundary worth knowing: `teams.model.syncStatus()` reports `stale: false` when **no** Team
|
||
> provider is registered, on the reasoning that a deployment with no game module is not a broken one.
|
||
> So on a deployment whose module has been uninstalled this suspension is inactive — which is benign,
|
||
> because with nothing updating the projection the member counts do not move and the reconciler has
|
||
> nothing to act on.
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS team_integrations (
|
||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||
team_id INT NOT NULL,
|
||
platform VARCHAR(32) NOT NULL, -- 'discord'
|
||
resource VARCHAR(32) NOT NULL, -- 'voice'
|
||
external_ref VARCHAR(64) NULL, -- the channel id
|
||
role_ref VARCHAR(64) NULL, -- the Team's role: the grant itself
|
||
state ENUM('none','active','pending_removal','error') NOT NULL DEFAULT 'none',
|
||
remove_after DATETIME NULL,
|
||
last_error VARCHAR(500) NULL,
|
||
synced_at DATETIME NULL,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
UNIQUE KEY uq_team_integration (team_id, platform, resource),
|
||
INDEX idx_ti_pending (state, remove_after),
|
||
CONSTRAINT fk_ti_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
```
|
||
|
||
> **As built** — `mode` and `role_ref`-as-escalation are gone; `role_ref` is now the grant itself, so a
|
||
> row with a channel and no role is a broken row. `synced_at` is added: `updated_at` moves whenever
|
||
> core writes a belief, including an error, and "when did this last actually reach Discord" is a
|
||
> different question. Unlike §7.2's DDL, this one applied to real MariaDB exactly as written.
|
||
|
||
**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.
|
||
|
||
> **As built, with the diff on the bot's side.** Core sends the DESIRED STATE for one Team — name,
|
||
> category, channel, role, staff roles, the member id list — and the bot works out the calls. That is
|
||
> the opposite of the split §7.1 and §7.2 use, and it is deliberate: every *decision* is still core's,
|
||
> but the diff is a comparison against live guild state that only the bot can see, and doing it in core
|
||
> would mean shipping the guild's whole role membership over the wire to compare it and shipping the
|
||
> answer back.
|
||
>
|
||
> **The membership diff is bounded per pass** (50 operations) and the remainder is reported, because
|
||
> each grant is its own API call under its own rate limit and an unbounded first pass on a large guild
|
||
> outlives its own request timeout — the one failure that leaves core not knowing what was applied. A
|
||
> non-zero remainder asks for another pass rather than waiting out the interval.
|
||
>
|
||
> **A failure is per-Team and never aborts the pass**, the same shape as §2.4's gate 3. A failed sync
|
||
> **keeps the refs it could not confirm**: a failure is core failing to confirm a channel, not learning
|
||
> it is gone, and clearing them would orphan a real channel and have the next pass build a second one
|
||
> beside it.
|
||
>
|
||
> The pass is **requested, not awaited**, by the Team reconciler — it makes Discord calls, and a roster
|
||
> sync must never be slowed, failed or held open by an integration hanging off it. It has its own
|
||
> 30-second debounce.
|
||
|
||
## 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 `<moduleId>_` 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
|
||
`<moduleId>.<name>` 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
|
||
|
||
> **Amended 2026-08-17, on the org lead's decision.** The seven additions below land under **one**
|
||
> 1.6.0, declared in phase 2, rather than a minor bump per phase. `MODULE_API.md` therefore documents
|
||
> members before they work, so each is marked with the phase that implements it, and the two that do
|
||
> not yet — `ctx.teams.activity.push` (§4, phase 3) and `api.registerSlashCommands` (§7.1, phase 7) —
|
||
> are **present and throw** with an error naming that phase. Present rather than absent so a module
|
||
> written against the published version fails at registration with an explanation, instead of at
|
||
> whatever moment someone first exercises the feature.
|
||
|
||
**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 11 is the exception to "independently shippable", and it is last on purpose** (org lead,
|
||
2026-08-18). The integration kit teaches an outside audience to build against this contract; Teams
|
||
expands the contract, so the book is the last thing owed before `edge` becomes `main`. It is also the
|
||
only phase that cannot merge until the cutover exists — see its own note.
|
||
|
||
### 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` + `installer` + `docs`)
|
||
|
||
> **Amended 2026-08-17, after Phase 0 and while building this.** Two corrections to what follows.
|
||
>
|
||
> **The sidecar had no schema-migration mechanism, and this phase is the first change that needs
|
||
> one.** `store.rs`'s `SCHEMA` is `CREATE TABLE IF NOT EXISTS`, which can add a table but cannot add a
|
||
> column to one that already exists — and every schema change up to Protocol 3.0 happened to add
|
||
> whole tables, so `ALTER TABLE` appears nowhere in `link`'s history and the gap was invisible until
|
||
> `guilds.members`. Settled by the org lead: **`PRAGMA user_version` stepped migrations**, each step
|
||
> transactional with the bump recording it; a failure aborts startup (already the behaviour, and safe
|
||
> because the shard dials *out*), while a database from a *newer* sidecar warns and continues so a
|
||
> binary rollback stays a recovery path. Not `sqlx::migrate!`, whose per-file checksums hard-fail
|
||
> startup if a released migration is ever edited.
|
||
>
|
||
> **`installer` joins the phase**, which is why the heading names five repos rather than four.
|
||
> `backup.rs` justifies not copying the sidecar database on two claims: that every table is
|
||
> `IF NOT EXISTS` (which the migration above falsifies) and that the sweeps repopulate everything
|
||
> (already false — `events` is never pruned and the website backfills from `GET /history` on every
|
||
> reconnect). The behaviour is unchanged and correct; only its stated reason needed fixing, and a
|
||
> wrong reason left in place is what lets someone extend it to a case it never covered.
|
||
>
|
||
> The spec for all of it is [`../link/v4.md`](../link/v4.md).
|
||
|
||
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.
|
||
A new `docs/link/v4.md` as the spec of record, plus `PROTOCOL_2.md` §10.1 (which sketched this design
|
||
in 2.0 and had it half-built) and `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`)
|
||
|
||
> **Amended 2026-08-17, while building this.** Five corrections, all found by building or testing the
|
||
> thing described below.
|
||
>
|
||
> **§2.5's SQL and §2.10's decision cannot both hold as written.** §2.5 gives `team_forum_grants` a
|
||
> generated column `active_user AS (IF(revoked_at IS NULL, user_id, NULL))` and a `CASCADE` foreign
|
||
> key; §2.10 later settles that key as `SET NULL` so the audit trail survives an account deletion.
|
||
> MariaDB refuses `ON DELETE SET NULL` on a foreign key whose column is a base column of a STORED
|
||
> generated column (error 1901), so the generated column forces the `CASCADE` — and with it, the loss
|
||
> §2.10 exists to prevent. **Settled: §2.10 wins.** The marker is derived from `revoked_at` alone and
|
||
> `user_id` moves into the unique KEY, which gives identical semantics — at most one active grant per
|
||
> (team, user), unlimited revoked rows — with `user_id` free to be `SET NULL`.
|
||
>
|
||
> **`team_forum_grants` is created in this phase**, not in phase 4, so the four-path resolver is
|
||
> written once and its non-contamination tests are real. Nothing writes it yet; the grant flow, the
|
||
> per-Team cap and the leader UI stay phase 4's.
|
||
>
|
||
> **Two columns on `teams` that this document did not contemplate**, both serving §2.4's gates.
|
||
> `roster_synced_at`: `team_sync_state` holds one row per *module*, and gate 3 leaves one Team's roster
|
||
> untouched while the others sync — without a per-Team stamp that Team's page would report the
|
||
> module's last success as its own, which is exactly the staleness the gate exists to surface.
|
||
> `members_empty_since`: gate 4's per-Team quarantine, the twin of `pending_empty_since`.
|
||
>
|
||
> **`leader` on the member shape is not path 2.** §2.3 puts `leader` on a member and §2.5 says the sync
|
||
> writes `is_leader` from `getTeamLeaders()`; taking both literally gives one column two writers, and
|
||
> the roster's write lands *first* — so a refused `getTeamLeaders()` silently demoted everyone. The
|
||
> roster now **seeds** `is_leader` on insert only, so a Team is not leaderless while that call is
|
||
> failing, and `getTeamLeaders()` alone moves it afterwards.
|
||
>
|
||
> **The §2.8.2 matcher needed two narrow widenings**, both real impersonation vectors the whole-word
|
||
> rule missed: a single-word term also matches a name word's singular ("Guild of Moderators"), and a
|
||
> run of two or more single-letter words is also compared joined ("G.M."). Neither re-admits substring
|
||
> matching — only a trailing `s` off the *whole* term is stripped, and the join is of single letters,
|
||
> never of the whole name.
|
||
|
||
`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`)
|
||
|
||
> **Amended 2026-08-17, while building this.** Six corrections. The first is the org lead's, and it
|
||
> changes what this phase ships; the rest were found by building the thing described below.
|
||
>
|
||
> **THERE IS NO CORE TEAM SURFACE. Teams is a contract primitive, not a page.** §3.1 puts `/teams`,
|
||
> `/teams/:slug`, `/teams/:slug/roster` and `/player/teams` in core and §3.5 registers three core nav
|
||
> entries for them. **Settled (org lead): all seven are dropped.** Core does not own the word for a
|
||
> Team — a UO shard calls them guilds, and the Rust module that comes next will call them clans — so a
|
||
> core page under a noun core invented would sit beside `module-uo`'s existing `/uo/guilds` saying the
|
||
> same thing twice, in the wrong vocabulary. Core keeps the tables, the sync, the access resolver, the
|
||
> activity feed and the whole API; the **module** builds the pages on that contract. `/admin/teams`
|
||
> stays: an operator inspecting the primitive is looking at the primitive.
|
||
>
|
||
> **So the extension slots invert, and that is a new `MODULE_API` §3.7 direction.** `team.overview`
|
||
> and `team.member.row` assumed core rendered the page. They are replaced by
|
||
> `registry.declareModuleSlot(id, name, { core })`: a **module** declares a place on its own page,
|
||
> namespaced under its own id, and names which of core's contributions belongs there. `module-uo`
|
||
> declares `uo.guild.detail` and asks for `team.activity`; core offers the activity feed, because only
|
||
> core can resolve whether a viewer is inside the Team and the public/members split is a security
|
||
> boundary. **Core names the contribution, never the slot** — amended in phase 11, inside 1.6.0, after
|
||
> the integration kit found that the literal-name version worked for one module and silently did
|
||
> nothing for any other. Core's contributions are applied at mount rather than eagerly — core's bundle
|
||
> evaluates before every module chunk, so at the moment core offers one, no module-declared slot exists
|
||
> yet. `Slot` joins the shared UI kit as its ninth member so the module renders the place with core's
|
||
> own error boundary.
|
||
>
|
||
> **A module names a Team in its own vocabulary**, so `GET /public/teams/by-external/:moduleId/:externalId`
|
||
> is added: core's row id and slug are core-internal and handing them to a module is how a module ends
|
||
> up storing them. The module id is matched rather than trusted — an external id is unique only within
|
||
> a module.
|
||
>
|
||
> **§3.3's projection is an EIGHTH `MODULE_API` member and 1.6.0's list said seven.** Settled by the
|
||
> org lead: **1.6.0 is amended in place** rather than bumped, applying the same rule Protocol 4 got in
|
||
> phase 2 — a contract owes a bump only once it has landed on `main`, and 1.6.0 is on `edge` only.
|
||
>
|
||
> **"The module declines" needed splitting in two before it could be implemented.** §3.3 says a module
|
||
> that declines yields the public projection, fail closed. But *no module at all* and *a module whose
|
||
> rung system could not be consulted* are opposite situations: the first is withholding nothing and
|
||
> must serve the roster whole, the second must serve none of it. The refusal therefore carries
|
||
> `projects` — `false` for "there is no audience model here", `true` for "there is one and I could not
|
||
> ask it" — and only the second fails closed. Also, the module answers with member **keys**, not rows:
|
||
> returning rows would let a module widen what is published by handing back a `userId` core had
|
||
> withheld, leaving core's field guarantee resting on every module's good behaviour.
|
||
>
|
||
> **Core's five activity kinds are four here.** `core.forum.thread` has nothing to emit it until the
|
||
> forum lands in phase 4. Separately, and not in the doc at all: **the first roster for a Team emits
|
||
> no join items.** Importing a 155-member guild is one Team arriving, not 155 people joining, and
|
||
> emitting a join per member would bury every real event under the import and reach the row cap on day
|
||
> one. `roster_synced_at IS NULL` is the condition, which covers both a new Team and a newly installed
|
||
> module adopting an existing one.
|
||
>
|
||
> **§2.11's route table has no activity endpoint** though §4.3 describes a feed filtered by the
|
||
> viewer's access. Added on the org lead's decision: `GET /api/v1/public/teams/:slug/activity`, paged,
|
||
> with the visibility resolved from the session and never from a parameter. It is the first public
|
||
> route whose *content* depends on identity, which needed a new `optionalAuth` middleware —
|
||
> `attachSession` only decodes a token, so a banned or logged-out account would have kept reading the
|
||
> members-only half until its JWT expired.
|
||
|
||
`/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` + `module-uo`)
|
||
|
||
> **Amended 2026-08-18, while building this.** Six corrections. The first is structural and follows
|
||
> from phase 3; the rest were found by building the thing described below.
|
||
>
|
||
> **The forum had nowhere to live, and §5.4's route table did not notice.** §3.1 gave it
|
||
> `/teams/:slug/forum/*` — a CORE page — and phase 3 deleted every core Team page. The routes are
|
||
> unaffected (they are all `/player` and `/admin`), but the participant SURFACE had no home. Settled
|
||
> by the org lead the same way phase 3 settled the activity feed: **`module-uo` declares a second
|
||
> place on its guild page, `uo.guild.forum`, and core fills it.** So this phase spans two repos, not
|
||
> the one named above.
|
||
>
|
||
> **Two slots rather than one**, because a slot holds one component and the first fill wins. Stacking
|
||
> the feed and the forum into a single fill would take from the module the ability to place core's
|
||
> two contributions separately on its own page, which is the whole point of the module owning it.
|
||
>
|
||
> **The forum panel navigates by SEARCH PARAM (`?thread=12`), not by route.** A thread has to be
|
||
> linkable and core cannot mount a route for one — the route belongs to the module's page. A search
|
||
> param gives a shareable URL under whatever path the module chose, with no core route anywhere in
|
||
> it. It is why the fill is one component holding both a list view and a detail view.
|
||
>
|
||
> **`rel` had to be added to the forum sanitiser's allowed attributes to make links SAFER, not
|
||
> laxer.** The profile writes `rel="noopener noreferrer nofollow"` through a transform, and
|
||
> sanitize-html strips any attribute not in the allowlist — including one its own transform just
|
||
> added. Without the entry every forum link shipped without `noopener`, silently.
|
||
>
|
||
> **The bare-URL linkifier is a second pass, and its ordering is the security property.** §5.5.3 says
|
||
> an author writes a URL and core renders the picture, which requires the URL to have become an
|
||
> anchor on the way in. Linkifying runs AFTER sanitising, over the sanitiser's own output and only on
|
||
> text outside tags: every text node is HTML-escaped by then, so the matched URL is safe in both the
|
||
> href and the link text. Running it first would be an injection point.
|
||
>
|
||
> **The upload sweep runs whether or not `uploads` is the current mode**, which is not obvious and is
|
||
> the point. An operator who turns uploads off after a problem still has the files; a sweep that
|
||
> switched itself off with the setting would strand exactly the bytes they were trying to be rid of —
|
||
> and it is the mechanism behind the dialog's promise that disabling does not delete.
|
||
|
||
|
||
`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`)
|
||
|
||
> **Amended 2026-08-18, while building this.** Five notes. The first is the org lead's decision; the
|
||
> rest were found by building the thing described below, or on the live rig afterwards.
|
||
>
|
||
> **Reports are site administration only.** §5.6's "a leader may also see and act on reports for their
|
||
> own Team" is decided against, not deferred — see the amendment there. It is the phase's most
|
||
> important property and it is a NEGATIVE one, so it is asserted directly in the test suite rather
|
||
> than left to be noticed: the report model's whole function surface is pinned, and `queue`/`handle`
|
||
> are checked not to mention leadership at all.
|
||
>
|
||
> **The edit window is an admin setting, not a constant** (§5.5.7), and it is evaluated on the server
|
||
> twice — once as advice on the read path, once as enforcement on the write. That is the phase's other
|
||
> structural rule: a time-bounded permission must not take its clock from the party it bounds.
|
||
>
|
||
> **§5.6's unique key does not work as written**, and the shipped table uses a generated `open_marker`
|
||
> instead. See the amendment there; it is the one place in this document where the SQL and the prose
|
||
> beside it disagreed.
|
||
>
|
||
> **This phase spans ONE repo, which is worth saying because phase 4 did not.** Phase 4 needed
|
||
> `module-uo` because the forum had no surface after phase 3 and a slot had to be declared. Phase 5
|
||
> grows the component that fills that slot, so `uo.guild.forum` is untouched and nothing in the module
|
||
> changes.
|
||
>
|
||
> **The live rig found one defect, and it was a message rather than a behaviour.** The post-moderation
|
||
> route's validator listed only the four actions a post accepts, so `pin` returned a generic
|
||
> "Validation failed" instead of the sentence written for it — leaving that branch reachable only from
|
||
> its own unit test. Walking the surface for real is what turns "documented, tested and unreachable"
|
||
> into something anyone notices.
|
||
|
||
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. *(In the event, phase 4 shipped `uploads` mode with the default
|
||
off, so nothing was pulled forward.)*
|
||
|
||
Also lands here, because both had existed since phase 4 with nothing rendering them: the **per-Team
|
||
forum moderation ledger** on the admin Teams screen — the `actor_role` column that keeps a leader's
|
||
housekeeping distinguishable from a staff intervention was readable only from a DB client — and
|
||
`softDeleteUploadsForPost`, which post deletion is the first caller of and which needed an inverse so
|
||
`delete` → `restore` does not return a post's words while silently losing its pictures a retention
|
||
window later.
|
||
|
||
**Acceptance, four:**
|
||
1. A member opens a discussion and a granted non-member replies to it; the same member is refused an
|
||
announcement `403` while a leader is allowed one.
|
||
2. A locked thread refuses replies at `409` from every identity **including staff**, and unlock →
|
||
reply → relock leaves three rows in the Team's ledger saying so.
|
||
3. An author edits their own post inside the window and is refused `403` outside it; staff edit the
|
||
same post at any time, and a staff edit of somebody else's post writes `activity_log` while a
|
||
member's own edit does not.
|
||
4. A member reports a post; the report reaches `/admin/moderation/reports` and answers `403` to the
|
||
Team's own leader, to the reporting member and to every other participant; handling it changes the
|
||
report's status and **nothing at all** about the content.
|
||
|
||
### Phase 6 — Team notifications (`website` + `module-uo`) — **DONE 2026-08-18**
|
||
|
||
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.
|
||
|
||
**TWO repos, not the plan's one.** `module-uo` joined for two lines it alone can supply: a third
|
||
declared slot (`uo.guild.header`, for the mute toggle) and `pageUrlTemplate` on its team provider,
|
||
without which core cannot write a link to a Team page at all — see the four amendments at the head of
|
||
[Part 6](#part-6--notifications).
|
||
|
||
**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` + `module-uo` + `docs`) — **DONE 2026-08-18**
|
||
|
||
`api.registerSlashCommands`, `/internal/commands` + `/internal/commands/dispatch`, the bot's
|
||
defer→dispatch→edit path, the actor resolver, the version-bump re-register, and the first command
|
||
through it.
|
||
|
||
**Ships:** a working `/guild`, and the seam a module needs for its own commands.
|
||
|
||
**THREE repos, not the plan's `website` + `bot` + `docs` — `bot` is not a repo.** It is a workspace
|
||
inside `website`, so the bot half lands in the same PR as the server half; `module-uo` joins instead,
|
||
because the command that proves the seam belongs to the module and not to core (see the amendment at
|
||
the head of [§7.1](#71-slash-command-registration)).
|
||
|
||
**Walked on the live rig before the PRs opened** — real ServUO + real sidecar (protocol 4) + the app
|
||
with module-uo installed, with the bot's own pull/execute path driven against it and a fake standing
|
||
in for Discord. It proved the audience rung holding over the chat surface (guilds gated to `staff`:
|
||
anonymous and linked-player refused, linked admin served, same command), the Discord provider
|
||
resolving by `kind` on a deployment whose provider slug is `my-discord`, a banned account resolving as
|
||
unlinked, the disable nudge firing with its reason and degrading to a log line with no bot running,
|
||
and the pull emptying plus dispatch answering `unknown` for a module switched off at runtime.
|
||
|
||
**It found two defects, both folded in.** A refusal was posted PUBLICLY — ephemerality is fixed at the
|
||
deferral, before the handler has said anything, so the envelope's flag was read and ignored, and "not
|
||
shown to your account" announced a member's access level to the channel. And the refusal offered
|
||
linking on a shard gated to `staff`, where linking reaches `player` and stops.
|
||
|
||
**The bot got its first test harness.** It had no `test` script and no tests at all — CI ran
|
||
`npm ci --prefix bot` and nothing else — which was defensible while the bot only wired up its own
|
||
static commands. It is not defensible now that it merges a pulled set into a single all-or-nothing
|
||
registration and runs the interaction path, and phases 8 and 9 add more. `bot/test/` and a
|
||
`bot-tests` job replace `bot-install`.
|
||
|
||
### Phase 8 — Discord: notifications bridge (`website` + `docs`) — **DONE 2026-08-18**
|
||
|
||
`team_integration_config`, the bridge as a third sink beside push and email, `POST
|
||
/internal/team-notify`, and the admin per-event configuration.
|
||
|
||
**Ships:** a Team's forum posts, announcements and roster changes arriving in a Discord channel the
|
||
operator chose, per Team or deployment-wide.
|
||
|
||
**ONE code repo, not the plan's `website` + `bot`.** `bot` is a workspace inside `website`, the same
|
||
correction phase 7 made — but unlike phase 7 nothing here belongs to a module, so `module-uo` is
|
||
untouched: the four streams are core's own and the bridge reads core's own forum. `MODULE_API_VERSION`
|
||
does not move.
|
||
|
||
**Walked on the live rig before the PRs opened**, per the order phase 5 set.
|
||
|
||
#### Five things the tree disagreed with §7.2 about
|
||
|
||
1. **`PRIMARY KEY (platform, team_id)` cannot hold the default row.** MariaDB coerces every primary
|
||
key column to `NOT NULL`, so `team_id NULL` — the deployment-wide default, and the base case of the
|
||
whole override mechanism — is unrepresentable. As built: a surrogate `id`, a generated
|
||
`team_key AS (IFNULL(team_id, 0)) STORED` in the unique key, and the foreign key the original DDL
|
||
had no room for. Same idiom as `teams.active_key` and `content_reports.open_marker`.
|
||
2. **The visibility gate has no data source on either side, and cannot have one.** §7.2 bridges an
|
||
event only if "its `visibility` is `public`, or its destination channel is configured for a
|
||
members-only Team context". The four `team.*` streams carry no visibility — only `team_activity`
|
||
rows do, and a notification is not an activity row — and forum threads have no public/members
|
||
column because a forum is members-only by construction, everything in it sitting behind
|
||
`team_forum_grants`. So §7.2's own example config, `['team.announcement','team.forum.post']`,
|
||
names exactly the two events that are never public. Nor can core see a Discord channel's
|
||
permissions to check the other half.
|
||
|
||
**As built: an attributed operator acknowledgement**, `members_ack` / `members_ack_by` /
|
||
`members_ack_at`, in the shape `teams_forum_uploads_ack` already uses. Enabling a members-only
|
||
event without it is refused **422** rather than dropped at delivery, because a configuration that
|
||
silently does less than it says is worse than one that will not save. It is re-asked at delivery as
|
||
well as at the save, so a row that loses the tick stops carrying those events at once — and
|
||
**changing the channel clears it**, since an acknowledgement is about a destination and cannot
|
||
survive the destination changing underneath it.
|
||
3. **"Identical to `announce` and `mod-reverse`" names two different things.** `announce` rides
|
||
`announce_jobs` with backoff, retries and a per-leg retry button; `mod-reverse` is one-shot. The
|
||
bridge is **one-shot**: a news post is a durable artifact whose Discord copy is expected to exist,
|
||
while a Team notification is the moment it describes, and a message arriving twenty minutes after
|
||
the conversation moved on is worse than one that never arrives. A bot that is down drops it, which
|
||
is the deal the push tickle already takes.
|
||
4. **The author exclusion stops at the channel.** Push and email both subtract the author; the bridge
|
||
does not. Excluding is a per-recipient idea and a channel has no per-recipient anything —
|
||
suppressing the message because the author happens to read that channel would deprive everyone
|
||
else in it.
|
||
5. **A roster event has a count and no name.** The sync notifies once per run rather than once per
|
||
member (§6.2), so a count is all the caller holds; it is also all it should say. `memberJoined`
|
||
grew an optional `{ count }` **for the bridge only** — a channel has no app on the other end to
|
||
pull anything after a content-free nudge — and the tickle beside it is unchanged.
|
||
|
||
#### Where the admin surface lives, and why it is not in the Discord panel
|
||
|
||
Its own panel under **Admin → Teams**, beside the forum settings, rather than an extension of
|
||
`DiscordBotAdmin`. Phase 10 replaces "Discord" with whatever the capability registry declares; what
|
||
should change then is what fills the panel, not where an operator goes to find it. It is the one
|
||
**admin-only** corner of a staff-wide router: this is not the §2.9 kind of decision a moderator files
|
||
a request for, it is deployment configuration, and it sits with the role that already holds the bot
|
||
token.
|
||
|
||
#### What the rig proved, and the two defects it found
|
||
|
||
Real ServUO + real sidecar (protocol 4) + the app with module-uo installed, with a fake standing in
|
||
for Discord. It proved the default row governing a Team with no row of its own, a per-Team override
|
||
beating it (including an override that switches the bridge OFF for one Team while the default stays
|
||
on), the 422 on an unacknowledged forum bridge, the acknowledgement clearing on a repoint, forums
|
||
switched off silencing the bridge along with the push, and a bot that is down costing the forum reply
|
||
nothing.
|
||
|
||
**Both defects came out of tests written against the rig's shapes.** A re-acknowledgement given for a
|
||
NEW channel kept the OLD attribution — the column was already 1, so "freshly acknowledged" read false
|
||
and the row went on naming whoever vetted the previous destination, which is the entire audit value of
|
||
the column. And the embed description was clamped to Discord's limit **before** the heading was
|
||
prepended, producing a description one heading over the limit; discord.js rejects that outright, so an
|
||
over-long forum post would not have arrived at all rather than arriving truncated.
|
||
|
||
**Not done here.** No real Discord guild was involved — `channels.fetch` and a real `channel.send`
|
||
are the two things this walk could not exercise, the same gap phase 7 recorded for `REST.put`.
|
||
|
||
### Phase 9 — Discord: voice channels (`website`) — **DONE 2026-08-19**
|
||
|
||
`team_integrations`, the threshold gate, the shared category, **a per-Team role** (not overwrite
|
||
management with escalation — see §7.3's amendment), the grace-window lifecycle, and the stale-sync
|
||
suspension.
|
||
|
||
**Ships:** every Team above the operator's size threshold gets a voice channel of its own in Discord,
|
||
visible and joinable by its members and nobody else.
|
||
|
||
**ONE code repo, not the plan's `website` + `bot`.** `bot` is a workspace inside `website` — the same
|
||
correction phases 7 and 8 made. `module-uo` is untouched and `MODULE_API_VERSION` does not move.
|
||
|
||
**Org-lead decisions (2026-08-19), all four settled before any code:** **roles always**, no overwrite
|
||
escalation · the bot creates the parent category and the server stores its id in settings · the
|
||
threshold counts **every** active member, not linked ones · "staff" is **a list of Discord roles the
|
||
admin designates**, because the concept does not otherwise exist.
|
||
|
||
**Walked on the live rig before the PRs opened**, per the order phase 5 set — real MariaDB, the real
|
||
app, and a fake standing in for Discord that mounts the bot's real internal routes, so everything up
|
||
to the Discord API call was production code. 47 assertions.
|
||
|
||
#### What the walk proved, and the two defects it found
|
||
|
||
It proved: the preflight refusing an enable three different ways and the panel still rendering with a
|
||
broken bot; a category, role and channel created with `@everyone` denied and the Team role allowed;
|
||
the hidden Team and the below-threshold Team getting nothing; the role granted to the two members in
|
||
the guild and **not** to the one who linked Discord without joining it; a drop below the threshold
|
||
scheduling a removal **with zero Discord calls**; a recovery inside the window keeping the same
|
||
channel id; an expired window deleting the channel *and* the role and forgetting the row; a stale
|
||
projection suspending the pass in both directions; voice switched off leaving the channels standing;
|
||
and an admin removal working anyway, with a 404 for a Team that has none.
|
||
|
||
1. **Every query failed on a duplicate result column.** `desiredTeams` and `holdersWithoutClaim` both
|
||
select `t.id AS team_id`, and the shared column list added `i.team_id` beside it — which the
|
||
`mariadb` driver refuses outright ("Error in results, duplicate field name `team_id`"). The pass
|
||
died at its first query, on the one code path every unit test stubs. It was also the wrong column:
|
||
`desiredTeams` LEFT JOINs, so `i.team_id` is NULL for exactly the Teams that have no channel yet.
|
||
2. **"Sync now" reported "Nothing was done" while it was doing it.** Saving the settings with voice on
|
||
asks for a pass; an operator pressing Sync now next — the obvious thing — got "a pass is already
|
||
running" and a panel saying nothing had happened, while the pass they triggered created their
|
||
channels. A pass in flight is now joined and its real outcome returned, as `reconcileNow` does.
|
||
|
||
#### Two things outside this phase that it had to work around
|
||
|
||
- **`npm run swagger` could not run at all on `edge`.** Phase 8 shipped a regex literal followed
|
||
directly by `.test(` in a route validator, which makes swagger-autogen's parser run away and the
|
||
process die out of memory. Hoisted to a const. Underneath it, `teams.router.js` sits exactly at that
|
||
parser's **per-file limit**: at twenty `teamsRouter.*` statements it dies and at nineteen it
|
||
generates, and one more statement of any shape tips it — an unannotated route does, and so does a
|
||
bare `use`. The voice routes are therefore their own router file, mounted from `admin/index.js`.
|
||
- **`last_success_at` is written by MariaDB's `NOW()` and compared against JS `Date.now()`**, so an app
|
||
process and a database in different timezones skew every staleness judgement by the offset — which
|
||
moves §3's public freshness banner as much as this phase's suspension. Pre-existing and not fixed
|
||
here; recorded because it is invisible until something depends on it.
|
||
|
||
### 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.
|
||
|
||
### Phase 11 — the integration kit (`integration-kit`) — **the last phase before the cutover**
|
||
|
||
The kit is the instruction book for putting a *different* game on this platform, written for an
|
||
audience outside this org. Teams expands the contract that book teaches against, so the book is the
|
||
last thing this bet owes before `edge` becomes `main`.
|
||
|
||
**One sentence in it is already wrong.** `book/02-website-module.md` states, of extension slots,
|
||
"**core declares a slot; a module may only fill one**". Phase 3 inverted exactly that: with
|
||
`declareModuleSlot` a MODULE declares a place on its own page and CORE fills it, and by phase 6
|
||
`module-uo` declares three. A new game's module cannot implement Teams at all without the inverted
|
||
direction, so this is not a stale detail — it is the shape the reader needs and does not have.
|
||
|
||
**Two genuinely new shapes to teach, and only two:**
|
||
|
||
- **The inverted slot** (§3.7a) — a module declaring a place for core, why the name is namespaced under
|
||
the module's own id, and why a module wants *separate* slots rather than one (it decides where each
|
||
of core's contributions sits on a page it owns).
|
||
- **`registerTeamProvider`** — the first registration where **core calls the module and waits**. Every
|
||
other one is the module claiming a mount or core notifying it. The envelope, the 10-second budget,
|
||
and the asymmetry that matters: every call fails **stale** (core keeps what it has) except
|
||
`projectRoster`, which fails **closed**, because for a visibility question "keep what you have"
|
||
means serving the roster unprojected.
|
||
|
||
`pageUrlTemplate` is a footnote beside those — one optional string, and the reader meets it while
|
||
reading the provider.
|
||
|
||
**What this phase explicitly does NOT do: enumerate the contract.** The kit already teaches only four
|
||
members and has never mentioned `registerNotificationStreams`, `registerAnnounceLeg` or
|
||
`registerPostHook`, all of which predate Teams. That is the design, not a gap:
|
||
[`MODULE_API.md`](MODULE_API.md) is normative and the kit teaches one path end to end and links out.
|
||
The question this phase answers is "did the teaching path change", and the answer is yes in two
|
||
places and no everywhere else.
|
||
|
||
> **Amended 2026-08-19, as built.** The phase ran before the cutover, as planned, and it found what it
|
||
> was meant to find. Four notes.
|
||
>
|
||
> **The inverted slot did not work for anyone but `module-uo`, and the kit is what proved it.** Core
|
||
> filled three literal `uo.guild.*` names, so a second game's module declared its places under its own
|
||
> id and core filled none of them — an empty page, no error, nothing logged, because *"a fill for a
|
||
> slot nobody declared is not an error"* is exactly the rule that hides an unknown name. Settled by the
|
||
> org lead the same day: **core offers a CONTRIBUTION and never names a slot**, amended into 1.6.0 in
|
||
> place since it has only ever been on `edge` (website#160, Module-uo#15, docs#165). The kit could not
|
||
> have taught the shape honestly without this, which is the argument for having written the book
|
||
> before the cutover rather than after it.
|
||
>
|
||
> **The template grew a real provider rather than a snippet** (org lead, 2026-08-19). It registers
|
||
> `registerTeamProvider` over two tables of its own, declares three slots on a clan page, and serves
|
||
> its own `/clans` — deliberately not `/teams`, which is core's and which the loader would refuse. The
|
||
> guards that matter are the ones a reader would otherwise omit: an unreachable game refuses rather
|
||
> than reporting no clans, an empty roster is refused unless the game says the clan is empty, and one
|
||
> audience rule serves both `projectRoster` and the module's own page.
|
||
>
|
||
> **It was walked on a live rig before the PRs opened** — real MariaDB, the real loader, a browser.
|
||
> Core reconciled two Teams out of the provider on the first boot, `/public/teams/<slug>/members`
|
||
> answered `projected: true`, and the clan page rendered core's activity feed and forum in the slots
|
||
> the module declared. `module-uo`'s guild page was walked on the same core and is unchanged. The walk
|
||
> found one defect no test could: `PageHeader` takes `lead`, not `subtitle`, and React drops an unknown
|
||
> prop silently — so every page built from the template had rendered its heading with nothing under it
|
||
> since the template was written.
|
||
>
|
||
> **Phase 10's cancellation makes this the last phase**, and nothing in it changed as a result.
|
||
|
||
**Then the two mechanical lines:** `ci/core-ref.json`'s sha moves to the cutover commit and
|
||
`template/module.json`'s `coreApi` becomes `^1.6.0`, which puts `scripts/checkCoreApi.js` back to
|
||
green. That check is an **equality**, and its going red is the mechanism rather than a bug — a
|
||
contract bump is meant to turn that repo red until someone has re-read the chapters. Moving the pin is
|
||
that person saying they have.
|
||
|
||
> **Ordering, stated because it is genuinely awkward.** This phase is written *before* the cutover and
|
||
> can only *merge after* it. CI clones the pinned sha and checks the template against that core's
|
||
> `MODULE_API_VERSION` — and 1.6.0 does not exist on `main` until the cutover lands, so there is no sha
|
||
> to pin and no core for the template to build against until then. Write the chapters last, open the
|
||
> PR once the cutover merge exists, and put the pin move in it.
|
||
|
||
**Checks that gate it** (all dependency-free Node scripts, run from the repo root — which is also how a
|
||
reader runs them): `checkLinks`, `checkRenameSites`, `checkChapterPaths`, `checkCoreApi --core .core`,
|
||
plus the template's own `npm ci` / `check:imports` / `build` / `check:externals` / `npm test` on both
|
||
halves. Build the client **before** the client tests; two of them read the built chunk.
|
||
|
||
### 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.
|