ENGAGEMENT.md gains the Phase 6 as-built: the seven decisions settled up front, the audience problem that shaped the phase, why scope_key is not subject_key, the one place decision 4 as phrased could not ship, the projection's rule and what it refuses to guess, the digest correcting Phase 4a rather than only implementing §4.2b, and the three defects the build found. §4.2b and §6.0b updated with it. TEAMS.md §6.4 rewritten: the pipeline it described no longer exists as its own thing. It now says which of the three sinks moved and which did not, where each of the four properties it always claimed lives now, that Team email is OFF until an operator turns a rule on, and what the generalized unsubscribe token does. §6.3's `last_digest_at` is marked as no longer read. BACKEND_DESIGN.md: engagement_digest_state, engagement_outbox.scope_key, and the unsubscribe routes — the canonical /public/engagement pair plus the /public/teams path kept permanently because mail is not editable once sent. Code: RunicGateway/website#TBD Co-Authored-By: Claude <noreply@anthropic.com>
3102 lines
197 KiB
Markdown
3102 lines
197 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`, and it is surfaced in two places:
|
||
|
||
> **`last_digest_at` is no longer read** (engagement Phase 6). The digest's state moved to
|
||
> `engagement_digest_state`, keyed `(user_id, channel, scope_key)` so a second digest needs no second
|
||
> column here; `schema.sql` backfills it once. The column stays as the backfill's source and as the
|
||
> record of what a row meant before the migration. **The two preference columns are unchanged and are
|
||
> still the authority for Team notifications** — the engine reads them rather than replacing them.
|
||
|
||
- **`/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
|
||
|
||
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.
|
||
|
||
> **Rewritten 2026-08-29 — the engagement system's Phase 6 took this sink over.** Everything below the
|
||
> line still describes what a recipient receives; what changed is who decides to send it. The design of
|
||
> record for the mechanism is now `docs/website/ENGAGEMENT.md` (Phase 6 as built), and this section is
|
||
> the Teams-shaped view of it. **Do not re-specify the engine here** — the same rule §6.0b applies to
|
||
> every other doc that touches a contract it does not own.
|
||
|
||
#### What moved, and what did not
|
||
|
||
`teamNotify.js` had three sinks. **One moved:**
|
||
|
||
| Sink | Where it lives now |
|
||
| --- | --- |
|
||
| The content-free push tickle | still `teamNotify.js`, unchanged. Its `deliver` on the engine is the engagement Phase 7's, with the in-app inbox that gives a tickle a `ref` worth deep-linking |
|
||
| The Discord bridge (§7.2) | still `teamNotify.js`, unchanged. A bridge is a *leg* — one-shot, to whoever can read a channel — and not a per-recipient *channel*; `ENGAGEMENT.md` §3.1 argues that distinction and it holds here |
|
||
| **Email** | **the engagement engine.** `teamNotify.forumPost` emits `team.forum.post` / `team.announcement`; a rule decides who is mailed, through which template, how often at most |
|
||
|
||
`mailer.sendTeamNotification` and `teamNotify.emailImmediate` no longer exist. The mail body is an
|
||
`engagement_templates` row an operator can edit (`notify.team-post` for a post, `notify.digest` for the
|
||
digest, `notify.event` for the two roster events and for announcements).
|
||
|
||
#### The four properties this section always claimed, and where each one lives now
|
||
|
||
- **Same recipient computation, same per-Team mute, same suppression while `teams_forums_enabled` is
|
||
off.** All three still hold, and the first is now explicit rather than incidental:
|
||
`teamNotify.recipientIds` computes the access-checked set and it travels on the event envelope as
|
||
`recipientUserIds`. A rule whose audience is `members` resolves to exactly that set — still filtered
|
||
for `users.status = 'active'`, still under the trigger's ceiling. Core does not learn what a Team is;
|
||
the event says who it is about.
|
||
- **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.
|
||
- **The per-Team preference is unchanged and is still the authority.** `team_notification_prefs` stays
|
||
exactly where it is, with exactly the meaning §6.3 gives it. The engine reads it through a
|
||
**scoped-preference** adapter: for a Team-scoped event that table *is* the preference, `muted`
|
||
silences every channel, and `email_mode` decides email and says nothing about the others. The
|
||
alternative — intersecting it with the newer per-stream preference — would have silenced every
|
||
existing subscriber on the migrating deploy, because nobody has ever expressed a stream-level opinion
|
||
about a Team trigger. The argument in full is in ENGAGEMENT.md Phase 6.
|
||
- **Off unless email is configured.** No usable `email_config` means the sink is absent, not broken —
|
||
and as of Phase 6 there is a second gate above it, below.
|
||
|
||
#### **Team email is OFF until an operator turns it on**
|
||
|
||
This is the one live behaviour change and it is deliberate. An engagement rule arrives `enabled = 0` so
|
||
that no import, restore or upgrade can start mailing on its own, and core seeds four Team rules under
|
||
that same rule. **On upgrade, Team notification emails stop until somebody opens Admin → Engagement →
|
||
Rules and switches one on.** The screen carries a banner saying so for as long as every Team rule is
|
||
off; the release note says it too. Push and the Discord bridge are unaffected.
|
||
|
||
#### The digest
|
||
|
||
**Computes at send time and keeps no queue.** The worker asks what arrived after the last stamp and
|
||
re-runs the access resolver. Three properties fall out, and the third is why it was chosen over a
|
||
pending-items table — and, in Phase 6, over the engine's own outbox:
|
||
|
||
1. a deployment down for two days sends **one** correct digest rather than replaying a backlog;
|
||
2. a post a moderator hid after it was written is simply not in the query;
|
||
3. **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 the
|
||
stamp is written **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.
|
||
|
||
**What Phase 6 changed is the state, not the design.** The stamp moved from
|
||
`team_notification_prefs.last_digest_at` into `engagement_digest_state`, keyed
|
||
`(user_id, channel, scope_key)`, backfilled once by `schema.sql`. A digest-mode recipient gets **no
|
||
outbox row** — a row would carry a snapshot taken at publish time and would have none of the three
|
||
properties above. The worker is also gated on an enabled email rule, so switching Team email off
|
||
switches off both halves of it rather than the instant half only.
|
||
|
||
- **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 a mail transport.
|
||
> Email is the one opt-IN sink here. Push stays opt-out, because a mute silences something the user
|
||
> already has.
|
||
- **Roster events do not email by default** (as built, restated by Phase 6). `team.member.joined` and
|
||
`team.leadership.changed` do now *emit*, so an operator who wants that mail can have it — but the
|
||
rules that would send it are seeded disabled and carry an hour-long cooldown, so §6.4's original
|
||
argument survives as the default rather than as a sink the code declines to call.
|
||
|
||
#### One-click unsubscribe
|
||
|
||
A link honouring the same per-Team preference, so an unsubscribe from the mail client writes what the
|
||
site shows.
|
||
|
||
> **A stateless HMAC, 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 is deliberately the narrowest that does the
|
||
> job: turn **one channel** off for **one scope** for one account. It reads nothing, cannot turn
|
||
> anything back on, and names no other scope. `version` is the only revocation a stateless design can
|
||
> offer — retiring one invalidates every outstanding link of it at once — and it exists before it is
|
||
> needed rather than after.
|
||
>
|
||
> **Phase 6 generalized the token from `(userId, teamId)` to `(userId, channel, scopeKey)`, and
|
||
> narrowed what it does.** A v1 token set `muted`, which silenced that Team's *push* as well as its
|
||
> email — a link labelled "stop these emails" quietly stopping notifications on somebody's phone. A
|
||
> token now turns off the channel it names and nothing else. **Old tokens still verify, permanently**,
|
||
> and read as the email channel for that Team, which is a reading of what they always meant.
|
||
>
|
||
> **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/engagement/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 unsubscribe people who asked for
|
||
> nothing. 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, scope) pairs exist, on a surface with no
|
||
> session behind it.
|
||
>
|
||
> **`POST|GET /api/v1/public/teams/unsubscribe/:token` still exists and always will.** It hands
|
||
> straight to the same handlers. Mail sent before Phase 6 carries that path in its header and in its
|
||
> body, mail is not editable once sent, and a route that moves is a person who cannot unsubscribe.
|
||
|
||
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 — a second integration would make the platform a registry lookup
|
||
> (§8.2), and what should change then is what fills the panel, not where it is. That holds whether or
|
||
> not the capability layer is ever built; Phase 10, which would have built it, is cancelled. 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.
|
||
|
||
> **And no capability registry is built either** (2026-08-19). Phase 10 would have extracted the one
|
||
> above from Phases 7–9's Discord code; it is cancelled and deferred until a second integration is
|
||
> wanted. Everything in §8 stays as it is — the comparison is what makes the *shape* of the Discord
|
||
> work defensible, and it did its job by keeping core's calls phrased as eligibility questions rather
|
||
> than as Discord operations. What is not there is the indirection: `discord` is named directly in the
|
||
> bridge, the voice provisioner and the command dispatcher, and a second platform is a phase, not a
|
||
> configuration change.
|
||
|
||
---
|
||
|
||
## 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 10 is cancelled** (org lead, 2026-08-19), deferred until a second integration is wanted or
|
||
it is asked for by name — see its own entry. Phase 11 is therefore the last phase of the bet.
|
||
|
||
**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`. A second platform would replace "Discord" with whatever a capability registry
|
||
declares (§8.2); what should change then is what fills the panel, not where an operator goes to find
|
||
it. Phase 10 would have built that registry and is cancelled — which changes nothing here, because
|
||
the reason this panel is not inside the Discord one is that an operator should not have to know which
|
||
platform is configured 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`) — **CANCELLED 2026-08-19**
|
||
|
||
> **Not built, and not scheduled.** The org lead cancelled this phase after Phase 9, deferring it
|
||
> until a second integration is actually wanted or it is asked for by name. What follows is what it
|
||
> would have done, kept because the argument for it survives its cancellation.
|
||
|
||
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.
|
||
|
||
**Why cancelling it costs little.** The same argument that put it last is the argument for not doing it
|
||
yet: with exactly one integration built, the refactor would extract a capability surface from a single
|
||
implementation and have nothing to check the extraction against. §8.2's Matrix column is research, not
|
||
a second implementation, and a registry whose only consumer is the thing it was extracted from is a
|
||
layer of indirection that has not yet been paid for. The work is cheaper *and* better-informed the day
|
||
a second platform exists, because that platform is what proves which of the five capabilities the
|
||
seam actually needs.
|
||
|
||
**What it leaves behind, stated so nobody has to re-derive it.** Phases 7–9 name Discord directly —
|
||
in the bridge config (`team_discord_config`), the voice provisioner, the slash-command dispatcher and
|
||
their admin panels. That is not a defect and no code is placed differently in anticipation of a layer
|
||
that may never come. Two decisions were made *for* this phase, and both stand on their own:
|
||
the notification bridge and the voice panel live under **Admin → Teams** rather than inside the
|
||
Discord Bot panel (§7.2, §7.3), because where an operator goes to find them should not depend on which
|
||
platform fills them; and core's calls are already phrased as questions about eligibility — *these user
|
||
ids are eligible for Team 3* — rather than as instructions about overwrites. A second integration
|
||
would be a new phase against that surface, not a rescue of this one.
|
||
|
||
### 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.
|