The drafted text was a placeholder the doc explicitly flagged as needing the org lead's ownership. Replaced with the supplied wording, which is better in three ways: it is factual rather than legalistic, it enumerates the specific responsibilities being accepted (moderation, storage and backups, legal compliance, community policy) instead of gesturing at them, and it states plainly that RunicGateway provides no hosted storage or content moderation services. Recorded as two surfaces rather than one, because they behave differently: a settings help text that is always on screen and explains the setting, and a confirmation dialog shown only when changing the mode to uploads, which is what the acknowledgement actually records. The dialog carries two checkboxes and the API still takes one `acknowledge: 1`. Recording two booleans would add nothing — there is no reachable state where an operator agreed to one clause and not the other and proceeded — while the stored version is what answers the question that matters later: which text did they agree to? Three additions are proposed on top and marked as droppable, since none is liability language and none changes what is being agreed to: that uploads are attributed and staff-removable (the reason the attribution table exists), that disabling uploads later does NOT delete files already uploaded, and that anyone with forum access can upload — including manually granted accounts with no linked game identity. Also adds the advisory `remote` mode needs, which the upload wording correctly does not cover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
121 KiB
Platform Teams & Community Integration — design of record
Status: design, not built. Written 2026-08-17 against the working tree at
websiteorigin/maine0c961c(the module-system cutover),module-uomain97e2fdd,linkmain7b65840,servuo-pluginsmainc045bdd. Nothing below is implemented.Companion to
MODULE_SYSTEM.md(what the module system is),MODULE_API.md(the normative core↔module contract),THEMING_AND_NAV.md(nav registration and its gates),BACKEND_DESIGN.md(API/schema/security conventions) and../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. 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:
{"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.postsis read-only —listAll,getById,linkAnnounceJob,markAnnounced.create,updateandremoveare deliberately withheld (MODULE_API.md§2.3): "the CMS is not a module's."registerPostHook({ onSaved, onDeleted })is core telling a module a post changed.registerAnnounceLeg({ dispatch, classify })is core asking a module to deliver a core post somewhere else — that is how module-uo's town crier gets news into the game.
The only module→core content seam that exists is ctx.push.publish(streamId, { ref, ownerUserId }).
So the Team activity feed cannot reuse the news hook; it needs its own ingestion member, modelled
on ctx.push.publish (§4). This is recorded as a correction, not a preference.
0.3 SSE is entirely module-owned; core has none
grep -rn "text/event-stream" across the working tree returns zero hits in website/server/ and
three in module-uo/ (server/utils/shardBroadcast.js is the whole implementation). The
public/admin fan-out, the audience-rung resolution and the field projection all left core with the
module-system cutover.
So a core-owned Team roster cannot subscribe to a core live channel — there isn't one. Roster
online-status is therefore module-projected data delivered through the Team provider (§2.3), and
the live half of it is a module-owned client concern: module-uo already publishes
useShardFeed/shardEvents in its client chunk and already renders live boards. Core's Team page
renders a last-known online count from the sync and declares a team.overview extension slot (§3) the
module fills with anything live. Core does not grow an SSE stack for this.
0.4 The Discord bot is a separate container that cannot load module code
website/docker-compose.yml: the bot service is a distinct prebuilt image with its own DB pool
(bot/src/db.js), no ./modules bind mount, and no published port. The two processes talk over
exactly two shared-secret HTTP channels:
- app → bot,
utils/botInternalClient.js→bot/src/internal/internal.routes.js(/internal/config,/internal/status,/internal/announce,/internal/mod-reverse), 4s timeout, never throws, always returns{ ok, status, data, error }. - bot → app,
SITE_INTERNAL_URL=http://app:3001/internal/bot-configon the app's unpublished internal listener (server/src/internalApp.js), with a retry-with-backoff bootstrap so a bot restart self-heals.
Slash commands are registered from a static array (bot/src/discord/commands/index.js) and pushed
with REST.put(Routes.applicationGuildCommands(...)) on ready (discordManager.js:25) — a whole-set
PUT, which means deregistration is already free.
A module therefore physically cannot put a handler function in the bot process. The command contract has to cross a process boundary (§6.1), and that is a constraint of the deployment, not a design choice.
0.5 Notification subscriptions have no scope dimension
notification_subscriptions is PRIMARY KEY (user_id, stream_id) — nothing else. The stream catalog
is a static registration validated at boot (registries.js:checkStreamShape, id pattern
<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
- Teams are module-sourced only. Bare core has no Teams and renders no Team UI. There is exactly one writer of the membership projection.
- Slash commands: definition + handler registered in the website, execution dispatched back over internal HTTP. The bot owns every Discord-specific concern.
- Roster on the wire: full member list, but site identity only for linked members. Every member
contributes
serial,name,rank,online;acct/webIdare present only for members whose game account is linked. - 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.
- 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.
- 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.
- Non-contamination. A manual forum grant never writes the membership projection, in either direction, ever. Both facts coexist; neither migrates into the other.
- A Team's name is immutable for the life of its record. A rename is an archive plus a create.
- Core never interprets module vocabulary. Activity
kinds, Team metadata and capability strings are opaque. Core stores, gates and displays; it never branches on content it does not own. - 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.
-- 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, samename→ update in place (abbr,meta, counts, roster). - Known
external_id, differentname→ 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_idabsent 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.
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():
// 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:
{ 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:
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:
- Call
getTeams(). Notok→ writeteam_sync_state(increment failures, record the error), log atwarn, return without touching a single row. Backoff is exponential onconsecutive_failures, capped at the poll interval. okbut the list is empty while core holds ≥1 active Team → do not apply. Stamppending_empty_sinceand return. Apply only if the next authoritative answer, at least one full interval later, is also empty. Rationale: a module that answersok:truewith 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.- For each Team in the answer, call
getTeamMembers(). Notokfor 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. okbut 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.
-- 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:
// server/src/model/teams/teamAccess.model.js
async function forumAccess(teamId, userId) {
const grant = await grants.activeFor(teamId, userId) // team_forum_grants
const member = await members.activeByUser(teamId, userId) // team_members
return {
allowed: Boolean(grant) || Boolean(member),
viaMembership: Boolean(member),
viaGrant: Boolean(grant), // retained even when viaMembership is also true
isLeader: Boolean(member && member.is_leader) || staffOverrideGrant,
}
}
Two reads, OR'd. viaGrant is reported even when membership also holds — that is the "both facts
coexist; the UI presents membership as the current reason while the grant is kept as audit history"
requirement, and it falls out of not collapsing the two booleans into one.
- A grant may name any RunicGateway account, including one with no linked game identity of any kind. That is the point: letting an unlinked guildmate into the forum must not be a staff ticket.
- Granting never writes
team_members. Revoking never writesteam_members. A test asserts a byte-identicalteam_membersrow set across a grant/revoke cycle. has forum accessis never read asis a Team member. A test asserts that a granted, unlinked user is absent fromGET /teams/:slug/membersand 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:
async function externalEligible(teamId, userId, platform) {
const member = await members.activeByUser(teamId, userId) // path 1 ONLY
if (!member || member.user_id == null) return false // must be a LINKED game member
return identities.has(userId, platform) // core user_identities
}
A test asserts externalEligible returns false for a user with an active forum grant and no
membership. The reason, stated so nobody "fixes" it later: an integration cannot verify that an
unlinked, forum-granted account corresponds to a real game member, so it must not hand that account a
privilege on a platform where impersonation has consequences.
2.6 The identity-binding chain
Four hops, three of them stored in different places, and the design's most common source of confusion — "this account is linked" answers only one of them.
RunicGateway account users.id
│ hop 1: linked GAME identity module-owned: shard_account_links(account → user_id)
▼
game account / character team_members.member_key, team_members.user_id
│ hop 2: Team membership core: team_members (the sync's projection)
▼
Team member teams.id
│ hop 3: linked EXTERNAL identity core: user_identities(provider='discord', subject)
▼
Discord user id the thing the bot can actually act on
│ hop 4: the integration's own grant
▼
channel permission overwrite / role
Hops 1 and 3 are independent opt-ins by the same person. Every one of the four failure modes is a real state with a distinct surface:
| Broken hop | What it looks like | What the UI says |
|---|---|---|
| 1 | in the guild, invisible to the site | roster row, greyed, "not linked" |
| 2 | linked account, not in this Team | not on the roster at all |
| 3 | linked Team member, no Discord | roster row normal; voice access absent, "link Discord to join voice" |
| 4 | everything linked, bot could not act | Admin → Teams shows the integration error |
2.7 Audit
Every forum grant, forum revoke, leadership override, staff archive and manual resync writes a core
activity_log row through the existing activity.log({ req, action, detail }) — the same log staff
already read, so a Team action is not in a second place nobody checks:
team.forum.grant detail: 'Leader Bob (#12) granted forum access to Alice (#88) on team "The Silver Hand" (#3): "recruit, not linked yet"'
team.forum.revoke …
team.leader.override …
team_forum_grants remains the structured record (it is what the resolver reads);
activity_log is the human-readable trail. Both, not one — the ledger cannot be paged through by an
admin looking for "what did this moderator do last week", and the activity log cannot answer "does
Alice have access right now".
2.8 Reserved-name screening — the one place untrusted game data becomes a public page
The threat. A Team's name is written by a player, inside the game, with no review, and this design then turns it into a public page, a URL, a nav-reachable entity, a Discord message and eventually a voice-channel name. Someone naming their guild "Admin", "Moderator" or "UOMysticmoon Staff" gets an official-looking page on the operator's own site, for free, by typing a name into a guild stone. That is an impersonation vector, and it is the only place in this design where unsanitized game data acquires platform authority — which is exactly why the approval gate in §2.9 is scoped to it rather than to staff actions generally.
2.8.1 What is reserved
A core utility, utils/reservedNames.js, resolved at check time (never baked in, since the brand is
runtime configuration):
| Source | Terms |
|---|---|
| Role names | the users.role enum — admin, editor, moderator, player — plus staff, administrator, mod, owner, gm |
| The deployment's brand | settings.site_title, BRAND_NAME, BRAND_SHORT_NAME — i.e. whatever settings.getInstanceName() resolves to, plus brand.shortName |
| The project | Runic Gateway — impersonating the software project is as much a problem as impersonating the operator |
| Operator additions | a teams_reserved_terms setting, comma-separated, for anything a particular community needs |
Not filter_words. That table exists but is bot-owned (its own pool, bot/src/model/,
never read by the website — MODERATION_APPEALS.md §2 is explicit that the two sides share no live
FK) and it is a profanity filter, which is a different question with a different answer. Reusing it
would cross an ownership boundary to get the wrong list.
2.8.2 How it matches
Whole words, after normalisation — not substrings. Core already has the precedent and the scar
tissue for this: scripts/checkModuleIdentifiers.js tokenises on camelCase humps and on
-/_/.// and compares word by word, precisely so defaultImage does not match "ultIma"
(MODULE_API.md §5.2). The same discipline applies here, for the same reason: a substring match
flags "Badminton" for containing "admin", and a check that cries wolf is a check people switch
off.
Normalisation: case-fold, strip punctuation and repeated characters, collapse whitespace. Deliberately
no leet-speak folding in v1 (4dm1n) — it multiplies false positives, and the consequence of a
miss is a hidden-by-a-human Team rather than a breach.
2.8.3 What a match does: hide, never reject
A Team whose name trips the list is created normally and then auto-hidden: hidden = 1,
hidden_reason = 'reserved_name', hidden_term recording which term matched.
- Hidden means absent from every public surface — the
/teamsindex, 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_onlineand the existing guilds board — a character called "Admin" is already renderable.utils/reservedNames.jsis 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.
CREATE TABLE IF NOT EXISTS team_moderation_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
action ENUM('unhide','display_name_override','clear_display_name_override') NOT NULL,
payload JSON NULL, -- e.g. { "displayName": "…" }
reason VARCHAR(255) NULL,
requested_by INT NULL,
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
status ENUM('pending','approved','rejected','withdrawn') NOT NULL DEFAULT 'pending',
decided_by INT NULL,
decided_at DATETIME NULL,
decision_note VARCHAR(255) NULL,
CONSTRAINT fk_tmr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
INDEX idx_tmr_queue (status, requested_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Rows are kept after a decision — the record of "a moderator asked to publish this name and an admin
refused" is the part worth having. Every transition also writes activity_log (§2.7).
The gate is server-side and role-checked per request, not a UI affordance: core's admin routes are already re-validated against the database on every request (a demoted user loses access at once), so "is this caller an admin" is answered live rather than from a token claim.
Built generically enough to extend, not generalised speculatively. action + payload means a
fourth gated action is an enum value, not a schema change — but no other action is gated today, and
none should be added without the same question being asked: does this publish untrusted game data?
2.10 Account deletion
Settled here rather than inherited from whatever the foreign keys happen to say, because retrofitting it is painful and two of the obvious defaults are wrong:
| Table | On user delete | Why |
|---|---|---|
team_members.user_id |
SET NULL |
the member still exists in the game; only the site link goes |
team_forum_posts.author_user_id |
SET NULL |
a thread others replied to must not lose its posts |
team_forum_grants.user_id |
SET NULL + retained username snapshot |
CASCADE would destroy the audit trail of who granted whom — exactly what an audit exists to survive. A granted_username / revoked_username snapshot column keeps the record readable. |
team_forum_grants.granted_by / revoked_by |
SET NULL + snapshot |
same reason, for the actor |
team_activity.actor_user_id |
SET NULL |
the event happened; the feed keeps the module-supplied summary |
team_forum_uploads.user_id |
SET NULL, and mark deleted_at |
an orphaned file with no owner and no deletion trigger is the worst outcome; deleting the account removes the files |
team_moderation_requests.requested_by / decided_by |
SET NULL + snapshot |
as above |
team_notification_prefs |
CASCADE |
a preference with no user is meaningless |
The rule the table encodes: content and audit survive; preferences and links do not. The one place it bites is uploads, where "forget me" has to mean the bytes go too, not just the row.
2.11 API surface
GET /api/v1/public/teams list (active), paged, {stale,lastSyncAt}
GET /api/v1/public/teams/:slug overview + counts
GET /api/v1/public/teams/:slug/members roster, field-projected per audience rung (§3.3)
GET /api/v1/player/teams the caller's Teams (membership + grants), with the
reason for each: 'membership' | 'grant' | both
GET /api/v1/player/teams/:slug/access the caller's own resolved access on one Team
GET /api/v1/admin/teams incl. archived, sync state, per-Team integration state
POST /api/v1/admin/teams/resync manual reconciliation
POST /api/v1/admin/teams/:id/archive staff archive (archived_reason='staff')
GET /api/v1/admin/teams/:id/grants the full grant ledger, incl. revoked
POST /api/v1/admin/teams/:id/leader-override { memberKey, effect, reason }
DELETE /api/v1/admin/teams/:id/leader-override/:memberKey
GET /api/v1/admin/teams/review the reserved-name review queue (§2.8.3)
POST /api/v1/admin/teams/:id/unhide admin: applies · moderator: creates a pending request
POST /api/v1/admin/teams/:id/display-name same gate; { displayName, reason }
POST /api/v1/admin/teams/:id/hide staff hide — NOT gated (suppression is always safe)
GET /api/v1/admin/teams/requests the §2.9 approval queue
POST /api/v1/admin/teams/requests/:id/decide admin only; { status: 'approved'|'rejected', note? }
POST /api/v1/player/teams/:slug/grants leader-grantable forum access { userId | username, reason }
DELETE /api/v1/player/teams/:slug/grants/:userId leader revoke (not of a staff-issued grant)
Leader-exercised actions live under /player rather than /admin deliberately: a leader is a player,
the /admin tier gate is requireRole('admin','editor','moderator')
(MODULE_API.md §2.4), and putting a leader endpoint behind it would mean widening that gate. The
leader check is a per-route gate on top of the /player tier's requireAuth.
Process contract for every route above, per CLAUDE.md: #swagger.* annotations →
npm run swagger, npm run routes:manifest -- --check zero-line diff (the additions are core's, so
they land in core's committed manifest), and a matching BACKEND_DESIGN.md edit.
Part 3 — Team pages and roster
3.1 Routes and shell
Core client routes, not module ones:
| Path | Page |
|---|---|
/teams |
index — searchable list, name/abbr/member/online counts |
/teams/:slug |
overview — counts, leaders, recent activity (§4), forum entry point (§5) |
/teams/:slug/roster |
full roster |
/teams/:slug/forum/* |
§5 |
/player/teams |
the caller's Teams and what each grants them |
/admin/teams, /admin/teams/:id |
sync state, grants ledger, overrides, integrations |
Public pages use PublicLayout shell="wide" for the roster and "mid" elsewhere — the shell prop
added in MODULE_API_VERSION 1.5.0 exists precisely so a page renders inside the site's column, and
core's own pages are the ones that most often forget it.
3.2 Roster
Rows come from team_members (status='active'), sorted leaders first then by display_name. Each
row carries a link state — this is the surfaced divergence Part 2 asks for, and it is a first-class
column rather than an absence:
| State | Condition | Rendered |
|---|---|---|
linked |
user_id IS NOT NULL |
name links to the public profile |
unlinked |
user_id IS NULL |
name in muted text + an "not linked" chip |
granted |
not a member; active forum grant | listed separately, under "Forum guests", never counted as members |
The header states it plainly — "37 members · 21 linked · 4 forum guests" — so the 37/21 gap reads as
information rather than as a bug. linked_count is denormalised on teams so the index page does not
need a join per row.
3.3 Online status, without new transport
team_members.online is written by the sync from the module's online field, which module-uo
computes from shard_online — data it already holds. Refresh cadence is the reconcile interval, so
the stored value is coarse.
Live refinement is the module's, through the team.overview extension slot (§3.4): module-uo's client
chunk already has useShardFeed and a live presence.online view, and it can render a live "online
now" strip on the Team page without core acquiring an SSE stack for it (§0.3). Core's number is the
durable floor; the module's is live. If the module is absent or its slot throws, the page shows the
stored value and the error boundary contains the rest (MODULE_API.md §3.7).
Field projection: the roster response passes through the module's audience-rung projection before it
leaves the server. acct and webId-equivalents never reach a caller below the configured rung —
this is the same boundary shardBroadcast enforces on the live feed, applied to a core response, and
it is why the roster endpoint asks the module to project rather than serialising team_members
directly. Concretely: core hands the module the rows and the viewer, the module returns the rows it
permits. A module that declines (unavailable) yields the public projection, not the full one — fail
closed.
3.4 Extension slots
Two new core-declared slots (MODULE_API.md §3.7 — core declares, one module fills, errors contained,
named for a place and never for a meaning):
| Slot | Rendered in | Props |
|---|---|---|
team.overview |
the Team overview page, below the counts | { teamId, externalId, moduleId } |
team.member.row |
each roster row, trailing cell | { memberKey, userId, displayName } |
Both unfilled on bare core, which renders exactly the page core writes. Neither is typed by content —
team.overview is "the spot under the counts", not "where the game puts guild stats".
3.5 Nav
One coded public header entry, { label: 'Teams', to: '/teams' }, plus { label: 'My Teams', to: '/player/teams' } in the player portal and { label: 'Teams', to: '/admin/teams', group: 'Community' } in the admin sidebar. All three flow through the existing registered-defaults → admin
overrides → role/feature filtering pipeline unchanged (THEMING_AND_NAV.md §7); the override layer can
relabel, reorder, re-section and hide them and — structurally — cannot change their to or their
gates.
Gating on "is a Team provider registered". With no module installed there are no Teams, so the
entry would lead to a permanently empty page. Rather than a new mechanism, core registers a feature
flag (teams) against its own core feature provider — the seam useShardFlags already uses
(MODULE_API.md §3.3) — resolved from GET /api/v1/public/teams's enabled field. Per that section's
fail-open rule, an unknown answer shows the link; the page itself is the gate.
Part 4 — Team activity feed
4.1 The seam
Not the news hook — see §0.2, which is why. A new ctx member, modelled on ctx.push.publish:
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
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 oftype='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: thetypecolumn 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)
CREATE TABLE IF NOT EXISTS team_forum_threads (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
type ENUM('announcement','discussion') NOT NULL DEFAULT 'discussion',
title VARCHAR(200) NOT NULL,
created_by INT NULL, -- SET NULL on user delete; the post body survives
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_post_at DATETIME NULL,
post_count INT NOT NULL DEFAULT 0,
pinned TINYINT(1) NOT NULL DEFAULT 0,
locked TINYINT(1) NOT NULL DEFAULT 0,
status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible',
CONSTRAINT fk_tft_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tft_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tft_team_feed (team_id, status, pinned, last_post_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS team_forum_posts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
thread_id INT NOT NULL,
author_user_id INT NULL,
body_html MEDIUMTEXT NOT NULL, -- sanitised on write via utils/sanitizeHtml.js
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
edited_at DATETIME NULL,
edited_by INT NULL,
status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible',
CONSTRAINT fk_tfp_thread FOREIGN KEY (thread_id) REFERENCES team_forum_threads(id) ON DELETE CASCADE,
CONSTRAINT fk_tfp_user FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tfp_thread (thread_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Append-only. Never updated, never deleted.
CREATE TABLE IF NOT EXISTS team_forum_moderation (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
target_type ENUM('thread','post') NOT NULL,
target_id BIGINT NOT NULL,
action ENUM('pin','unpin','lock','unlock','hide','unhide','delete','restore') NOT NULL,
actor_user_id INT NULL,
actor_role ENUM('leader','staff') NOT NULL, -- WHICH authority was exercised
reason VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_tfm_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
INDEX idx_tfm_target (target_type, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Bodies are sanitised on write with core's existing utils/sanitizeHtml.js (already the wiki's and
the CMS's sanitiser), stored sanitised, and served without re-sanitising — the same contract those two
already follow. Writes go through ctx.middleware.rateLimit-equivalent core middleware.
ON DELETE SET NULL on authors rather than CASCADE: deleting a user must not silently blow holes in
a thread other people replied to. The post renders as "[deleted account]".
5.3 Moderation stays separate from the site's moderation system
Deliberately, and it is worth writing down because merging them looks tidy and is wrong.
The existing mod_actions / appeals pair is Discord-sanction-shaped: mod_actions is a
bot-owned table (bot/src/model/*, its own pool; the website never writes it), keyed on a Discord
user id, and appeals exists so a player can contest a staff sanction and have it reversed in
Discord (MODERATION_APPEALS.md §2, §6). Routing a guild leader locking a thread into that pipeline
would make a leader's ordinary housekeeping an appealable Discord sanction with a reversal path into
the bot. That is not what it is.
So: team_forum_moderation is its own ledger, and the two are cross-referenced, not merged. Every
staff-exercised forum moderation additionally writes an activity_log row (§2.7), so the site's
existing staff-accountability trail sees it. A leader-exercised one writes only the forum ledger,
visible to the Team and to staff on the admin Team page. If a forum post warrants a site-wide sanction,
that is a separate, existing action a staffer takes against the account.
5.4 API
GET /api/v1/player/teams/:slug/forum/threads
POST /api/v1/player/teams/:slug/forum/threads 5a: announcement (leader); 5b: discussion (any member)
GET /api/v1/player/teams/:slug/forum/threads/:id
POST /api/v1/player/teams/:slug/forum/threads/:id/posts 5b
PATCH /api/v1/player/teams/:slug/forum/posts/:id 5b — author, within an edit window; staff any time
POST /api/v1/player/teams/:slug/forum/threads/:id/moderate { action, reason } — leader or staff
GET /api/v1/admin/teams/:id/forum/moderation the ledger
POST /api/v1/player/teams/:slug/forum/uploads multipart; 404 unless teams_forum_images='uploads'
DELETE /api/v1/player/teams/:slug/forum/uploads/:id uploader (within the edit window) or staff
GET /api/v1/admin/teams/forum/uploads attribution view: who uploaded what, when, how much
The forum settings themselves ride the existing admin settings endpoint rather than getting one of
their own — they are ordinary settings keys. The only special handling is server-side validation of
the acknowledgement (§5.5.5) when teams_forum_images is set to uploads.
Every forum route above answers 404 while teams_forums_enabled is off, and the upload routes
answer 404 in any image mode but uploads — the same guard, applied at two levels, for the same
reason (§5.5.1).
Under /player for the same reason as §2.11: a forum participant may be a plain player, and the tier
gate is requireAuth. Every route resolves access through the §2.5 resolver — never by checking
membership directly, which is how paths 1 and 3 would drift back together.
5.5 Admin controls — the forum switch and the image policy
Both land in Phase 4 (5a), because a forum that ships without an off switch is one an operator cannot ship at all, and because announcements carry images from the first day the forum exists.
5.5.1 teams_forums_enabled — the operator's switch
A core settings key (VARCHAR(64) key, TEXT value, with updated_by / updated_at recorded by
the existing schema). Default '0' — off. Turned on from Admin → Settings.
Off means guarded, never destroyed. The same principle as the module disabled guard
(MODULE_API.md §4.5): routes stay mounted and answer 404, the forum panel is absent from the
Team page, and the nav entry is filtered by the teams.forums feature flag (§3.5). Not 403 — a 403
says "this exists and you may not have it", which advertises a feature the operator deliberately
turned off; 404 says "not a thing on this site", which is the true statement.
Three things a toggle-off must not do, each because the operator will toggle it back on:
- No data is deleted. Threads, posts, grants and the moderation ledger are untouched. Re-enabling restores the forum exactly as it was.
- No subscription is cleared. The forum notification streams are suppressed at the recipient-computation step (§6.2), not by unsubscribing users. An operator switching the forum off for a fortnight must not silently wipe everyone's notification preferences.
- No grant is revoked.
team_forum_grantsrows 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.
uploadsimplies 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:
- The policy is enforceable, because the only code that can emit an
<img>is core's. - Flipping the setting back to
disabledretroactively un-renders every image, on every existing post, with no data migration — the images were never in the stored HTML. - No attribute smuggling. There is no author-supplied
srcset,onerror,width=99999,style, or anything else; core emits a fixed attribute set. - 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.
- It matches how forums conventionally behave, which is what the ask described.
Concrete rules for what gets embedded:
https:only. CSP isimg-src 'self' data: https:(config/csp.js:52) — anhttp:image is blocked by the browser and renders as a broken picture, so anhttp:URL stays a plain link. This is a real mismatch with the shared sanitizer, which permitshttpforimg, 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"andloading="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-referrerlimits 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.mimetypeis the client'sContent-Typeheader. A player can sendimage/pngwith 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
rateLimitmiddleware), 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_uploadstable (§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
RunicGateway 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?
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:
- 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.
- 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.
- 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.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.
-- Deliberately generic. Team forum content is the first consumer; wiki pages,
-- news comments and profile fields can be added as target_type values with no
-- schema change.
CREATE TABLE IF NOT EXISTS content_reports (
id INT AUTO_INCREMENT PRIMARY KEY,
target_type VARCHAR(32) NOT NULL, -- 'team_forum_post' | 'team_forum_thread' | 'team_forum_upload'
target_id BIGINT NOT NULL,
team_id INT NULL, -- denormalised for the queue's filters
reporter_user_id INT NULL,
reason ENUM('spam','abuse','sexual','illegal','impersonation','other') NOT NULL,
detail VARCHAR(500) NULL,
status ENUM('open','reviewing','actioned','dismissed') NOT NULL DEFAULT 'open',
handled_by INT NULL,
handled_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_cr_reporter FOREIGN KEY (reporter_user_id) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_cr_one_open (target_type, target_id, reporter_user_id, status),
INDEX idx_cr_queue (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Four rules:
- Reports go to site staff, not to Team leaders. A leader may also see and act on reports for their own Team, but staff always receive them — the whole point is a path that routes around a Team's own leadership.
- Reporting is not a moderation action. A report changes nothing about the content; it opens a queue item. This keeps it clear of §5.3's leader/staff moderation ledger, which records things that actually happened.
- Rate-limited and deduplicated. One open report per (target, reporter) — the unique key — plus
core's
rateLimitmiddleware, so the queue cannot be used as a harassment tool. - Reports on uploads carry the
team_forum_uploadsrow, so a staffer sees uploader, size and sniffed type without hunting. This is why §5.5.4's attribution table is load-bearing rather than bookkeeping.
POST /api/v1/player/teams/:slug/forum/report { targetType, targetId, reason, detail? }
GET /api/v1/admin/moderation/reports the queue, alongside the existing appeals queue
POST /api/v1/admin/moderation/reports/:id/handle { status, note? }
Mounted under the existing admin moderation section rather than under Teams: a staffer working a queue should have one place to work, and a report about a forum post is the same job as a report about anything else.
5.2a Upload attribution table (lands with §5.2)
CREATE TABLE IF NOT EXISTS team_forum_uploads (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
post_id BIGINT NULL, -- NULL while a draft upload is unattached
user_id INT NULL, -- SET NULL on account delete; attribution survives in activity_log
filename VARCHAR(191) NOT NULL, -- the stored random name, not originalname
byte_size INT NOT NULL,
mime VARCHAR(64) NOT NULL, -- the SNIFFED type, not the declared one
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL, -- soft; the nightly sweep removes the file
CONSTRAINT fk_tfu_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tfu_post FOREIGN KEY (post_id) REFERENCES team_forum_posts(id) ON DELETE SET NULL,
CONSTRAINT fk_tfu_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_tfu_filename (filename),
INDEX idx_tfu_user (user_id, created_at),
INDEX idx_tfu_sweep (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Part 6 — Notifications
6.1 What the existing pipeline gives us, and the one thing it does not
Reusable unchanged: ntfy itself (a compose service, declarative config, no per-user accounts), the
device registry (push_devices), the opt-in model (notification_subscriptions), the content-free
tickle, the SSRF allow-set, and pushDispatch.publish.
The gap is §0.5: publish fans out to all subscribers of a stream or to one ownerUserId.
Team notifications need "these N users".
6.2 The design: fixed streams, computed recipients
Do not create a stream per Team. The catalog is a static registration validated at boot with a
namespaced-id pattern; it cannot express an unbounded, runtime-created set, and stream ids are stored
in notification_subscriptions rows that would then need garbage-collecting when a Team archives.
Instead: four fixed core streams, and Team scoping lives entirely in the recipient set.
// 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:
// 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:
CREATE TABLE IF NOT EXISTS team_notification_prefs (
user_id INT NOT NULL,
team_id INT NOT NULL,
muted TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, team_id),
CONSTRAINT fk_tnp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_tnp_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Applied as a subtraction from the computed recipient set. Surfaced as a mute toggle on the Team page
and as a list under the existing notification settings screen (GET|PUT /auth/me/notifications/teams), which the Android app can adopt without a new screen concept.
6.4 Email — the third sink, already built and unused
Push needs the Android app. The Discord bridge (§7.2) needs Discord. A web-only user on a deployment running neither currently gets no notification that someone replied to their own thread — which is most users on most deployments, and a forum where replies are invisible is a forum nobody returns to.
Core already has utils/mailer.js and an admin-configured email_config. The expensive part of
notifications is computing the recipient set, and §6.2 builds it; email is a third consumer of the
same event, not a fourth pipeline.
- Same recipient computation, same per-Team mute, same suppression while
teams_forums_enabledis off. - Unlike a push tickle, an email carries content — the same reasoning as the Discord bridge (§7.2): the recipient's mailbox is a destination they chose, not an untrusted relay reached by an unguessable topic. It carries the thread title, an excerpt and a link; never the full post.
- Digest, not per-event, by default. A busy Team forum sending one email per reply is how a
notification feature gets marked as spam. Default to a daily digest per Team with an immediate
option, stored in
team_notification_prefsas aemail_mode ENUM('off','digest','immediate')column. - Off unless email is configured. No
email_configrow means the sink is absent, not broken. - One-click unsubscribe link honouring the same per-Team mute, so an unsubscribe from the mail client writes the preference the site shows.
Folded into Phase 6 rather than getting a phase of its own: the recipient set is the work, and it is already being built there.
Android gotcha, carried forward. The existing PUT-the-whole-set endpoints require the array field even when empty (
docs/android/PLAN.md§11). Any new "replace the set" endpoint here must be specified the same way, and the app-side DTO field must have no default, or kotlinx drops it and clearing the last entry 400s.
Part 7 — Discord integration
7.1 Slash-command registration
Ownership, decided: the registrant owns the definition and the handler; the handler runs in
the website process and returns a response envelope; the bot owns every Discord-specific
concern — deferral, the 3-second ack, ephemerality, follow-ups, interaction tokens, embeds. This is
forced by §0.4 (the bot cannot load module code) and it is also the right boundary: a module writing
interaction.deferReply() would be a module holding a Discord handle.
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 }
},
}])
actoris resolved by core before the handler runs:{ platform: 'discord', platformUserId, guildId, userId | null, isLinked, isStaff }.userIdcomes fromuser_identities. A handler never parses a Discord payload and never learns anything platform-shaped beyondplatform.accessis 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.ignored today and stays so — it is not public API.
Lifecycle. The bot pulls /internal/commands on ready and merges the result with its own static
array before the single REST.put(applicationGuildCommands) it already does. Because that call is a
whole-set PUT, deregistration on module unload is free — a module that is gone is simply absent
from the next pull. Core exposes a version counter bumped on any module state change (the same trick
modules.version() already uses for the OpenAPI cache); the app pushes a re-register nudge through the
existing /internal/config path when it changes, and the bot re-pulls.
Timing. The bot defers immediately on receiving an interaction (well inside Discord's 3s ack
budget), then POSTs the dispatch with a 4s timeout — the same budget botInternalClient already uses —
and edits the deferred reply with the envelope. The website is never in the 3s critical path.
Failure isolation. The dispatcher try/catches per handler; a throw becomes
{ ok: false } and the bot posts an ephemeral "that command failed". A timeout is identical. A module
whose handler wedges costs its own command and nothing else — the bot process is never at risk because
the handler does not run in it. Cross-module isolation is not required (single-module deployment) and
none is built.
The example command. /team [name] → member/linked/online counts, leaders, and a deep link to
/teams/:slug. For a caller with no linked account it returns the public projection plus an
ephemeral "link your account for more" — see §9 answer 5.
7.2 Notifications bridge
The same Team events as §6, delivered to a second consumer. Core emits each Team notification to an internal fan-out with two subscribers: push (§6) and the integration bridge. Not a second pipeline — one event, two deliveries.
CREATE TABLE IF NOT EXISTS team_integration_config (
platform VARCHAR(32) NOT NULL, -- 'discord'
team_id INT NULL, -- NULL = the deployment-wide default
events JSON NOT NULL, -- ['team.announcement','team.forum.post']
channel_ref VARCHAR(64) NULL, -- destination on that platform
enabled TINYINT(1) NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (platform, team_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Admin-configurable per event type, globally and per Team (a per-Team row overrides the team_id IS NULL default). Delivered via POST /internal/team-notify on the bot, best-effort, never throwing —
identical to announce and mod-reverse.
A Discord message carries content; a push tickle does not. Stated explicitly because the two look
like the same event and are not: ntfy is an untrusted relay reached by an unguessable topic, so the
tickle is content-free by design; the Discord server is an operator-configured, trusted destination
where an empty "something happened, go look" message would be useless. What is shared is the
allowlist discipline — an event is bridged only if its visibility is public, or its destination
channel is configured for a members-only Team context.
7.3 One voice channel per Team
Shape. One voice channel per qualifying Team, under a single shared parent category
(Teams), created by the bot. No per-Team role by default, no auto-created category per Team.
Access, and why overwrites are enough — with a stated fallback. Access is @everyone deny +
VIEW_CHANNEL/CONNECT allow per linked Team member (path 4, §2.5) + the staff role. Discord's
practical per-channel overwrite budget is ~100. A Team of up to ~95 linked members fits with room for
@everyone and staff. Above a configurable threshold (voice_overwrite_max, default 90) the
integration escalates that one Team to a per-Team role — one overwrite for the role instead of N for
members — because roles are the scarcer guild-wide resource (250 cap) and should be spent only where
overwrites actually run out. So: overwrites by default, role on demand, and the escalation is recorded
in team_integrations.mode.
Provisioning gate. Admin opt-in per deployment, plus voice_min_linked_members (default 5).
Counted on linked members only, since an unlinked member cannot be granted anything on Discord
anyway.
Lifecycle: delete, but after a grace window. Justification, since the brief asks for one:
- A voice channel holds no message history, so deletion destroys nothing recoverable. The archive-don't-delete caution from the earlier full-category proposal was about text channels with history, and it does not transfer.
- What does transfer is churn. A Team hovering around the threshold — one member unlinks, one rejoins — would delete-and-recreate, changing the channel id, breaking every pinned link to it, and filling the audit log. That is a real harm with no content loss at all, which is exactly the case a grace window fixes and an archive does not.
So: drop below threshold → state='pending_removal', remove_after = now + voice_grace_days
(default 7). Recover above threshold inside the window → cancel, no Discord call made. Still below at
expiry → delete. A Team archived (disbanded or renamed) takes the same window, because "disbanded"
can be a missed event and 7 days is cheap insurance.
And never on stale data. If team_sync_state is stale for the module (§2.4), the integration
reconciler skips entirely — no creation, no deletion, no overwrite changes. A voice channel is never
destroyed because a sidecar was down.
CREATE TABLE IF NOT EXISTS team_integrations (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
platform VARCHAR(32) NOT NULL,
resource VARCHAR(32) NOT NULL, -- 'voice'
external_ref VARCHAR(64) NULL, -- the channel id
mode ENUM('overwrites','role') NOT NULL DEFAULT 'overwrites',
role_ref VARCHAR(64) NULL,
state ENUM('none','active','pending_removal','error') NOT NULL DEFAULT 'none',
remove_after DATETIME NULL,
last_error VARCHAR(500) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_team_integration (team_id, platform, resource),
CONSTRAINT fk_ti_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Sync rides the same reconciliation as membership: after a successful Team reconcile, the
integration reconciler diffs the desired access set (path 4) against what the bot reports and issues
the minimum set of calls. Every call is best-effort; a failure records state='error' with the message
and retries on the next pass. It never blocks the Team sync.
Part 8 — Keeping the integration layer platform-agnostic
8.1 What a Matrix integration would actually need
Researched rather than assumed, and the answer is that Matrix's model diverges from Discord's on every axis this design touches:
| Discord | Matrix |
|---|---|
| Guild → channels; per-channel permission overwrites (~100/channel) | Rooms, optionally grouped in a Space. Access is room membership (invite/knock/join rules) plus m.room.power_levels — an integer map, not per-resource ACLs. No overwrite concept. |
| Roles (250/guild), assignable, mentionable | No role object. The nearest analogue is "membership of a room/Space", or a power level. |
| Voice channel as a first-class persistent resource | No voice channel exists. Real-time voice is MatrixRTC — a session held inside an ordinary room, surfaced by Element Call. Self-hosting it needs infrastructure the homeserver does not ship: a LiveKit SFU plus lk-jwt-service. "Provision a voice channel" maps to "create a room and hope the operator deployed an SFU". |
| Slash commands: a registered application command, an interaction API, a 3s ack, ephemeral replies, follow-ups | None of it. A Matrix bot reads message bodies (or runs as an Application Service acting as virtual users). Commands are a convention in message text, with no registration, no ack deadline, no ephemerality. |
Identity: OAuth2 link → a stable user_identities row, already built |
@user:server MXIDs come from the homeserver. There is no equivalent OAuth link flow unless the homeserver speaks OIDC; otherwise binding is a challenge-DM. |
8.2 Conclusion: capability-based contract, not a shared interface
A shared ExternalChatIntegration would have to name roughly five methods — deliverNotification,
registerCommands, dispatchInteraction, provisionVoice, syncAccess. Matrix implements
deliverNotification cleanly, syncAccess differently (invite/kick rather than ACL edits), and
cannot honestly implement registerCommands, dispatchInteraction or provisionVoice. Three of
five would be no-ops or lies, and the two that survive would be pushed through argument shapes designed
for Discord's resources.
So: capabilities, declared per integration, and core's UI and reconciler read the declaration.
integrations.register('discord', {
capabilities: ['notify.deliver', 'command.dispatch', 'identity.link', 'voice.provision', 'access.sync'],
… })
| Capability | Core exposes | Discord | (hypothetical) Matrix |
|---|---|---|---|
notify.deliver |
the Team notification event + a rendered payload | ✅ channel message | ✅ room message |
identity.link |
user_identities lookup by provider |
✅ (built) | ✅ if the homeserver speaks OIDC |
access.sync |
the path-4 eligible set for a Team | ✅ overwrites/role | ✅ room invite/kick — different mechanism, same question |
command.dispatch |
command definitions + the actor-resolved dispatcher | ✅ | ❌ not declared |
voice.provision |
"this Team qualifies / no longer qualifies" | ✅ | ❌ not declared |
Core's surface is the capability, not the mechanism. For access.sync core says "these user ids
are eligible for Team 3" and never "set these overwrites" — Discord turns that into overwrites (or a
role), Matrix would turn it into invites. That is the difference between a capability contract and a
thin wrapper around Discord's API, and it is where the abstraction actually earns its keep.
What core does with an undeclared capability: nothing at all, silently, and the corresponding UI is
absent. Admin → Teams renders the voice panel only when the enabled integration declares
voice.provision. No stubs, no "not supported on this platform" placeholders for a platform nobody has
installed.
No Matrix implementation is built. §8 is research to shape the Discord contract, exactly as the brief asks.
Part 9 — The explicit answers
1. Is a full threaded forum one phase, or should Part 5 split?
Split — into two, by layer rather than by feature. 5a is the access-control model (grants, the
four-path resolver, audit, leader/staff UI) plus announcement threads; 5b is discussion threads,
replies, editing and the moderation ledger. The full schema — including threads.type and the
moderation table — lands in 5a, so 5b enables code paths and never migrates data. This split is
chosen over "announcements first, generalise later" because an announcement is a degenerate thread, and
building it as its own thing then replacing it wastes the work. See §5.1.
2. Does the notifications bridge need new ntfy topic/routing work?
No ntfy work at all; a small amount of website work. ntfy topics are per-device UnifiedPush
endpoints, not per-subject channels, and the payload is a content-free tickle — so Team scoping is a
recipient-set problem inside the website, not a topic problem in the relay. What is missing is a
"these N users" fan-out: today pushDispatch.publish does all-subscribers or one ownerUserId. The
gap closes with one new query (endpointsForUsersStream) and one new signature
(publishToUsers), four fixed stream ids in coreStreams.js, and one small opt-out table for per-Team
mute. No new topics, no ntfy ACLs, no per-user accounts, no second pipeline. See §6.
3. Voice channel: straight delete below threshold, or archive first?
Delete — but only after a 7-day grace window, and never while the sync is stale. A voice channel
holds no message history, so the archive-don't-delete caution from the earlier text-channel proposal
does not transfer: deletion destroys nothing recoverable. What does apply is churn — a Team
oscillating around the threshold would delete-and-recreate, changing the channel id, breaking pinned
links and spamming the audit log, all with zero content loss. A grace window fixes exactly that and an
archive would not. The stale-sync guard is separate and stricter: if team_sync_state is stale, the
reconciler makes no integration decisions at all, so a channel is never removed because a sidecar
was down. See §7.3.
4. Shared ExternalChatIntegration interface, or a capability contract?
Capability contract. Matrix has no channel-with-overwrites, no role object, no voice channel at
all (voice is a MatrixRTC session inside an ordinary room, needing a LiveKit SFU the homeserver does
not ship), and no slash-command registration — a Matrix bot parses message text with no interaction
API, no 3-second ack and no ephemeral replies. Of the five methods a shared interface would name, a
Matrix implementation could honestly provide two. Forcing the other three would mean no-ops on one side
and Discord-shaped arguments on both. Core therefore exposes capabilities — notify.deliver,
identity.link, access.sync, command.dispatch, voice.provision — stated as questions
("these users are eligible for Team 3") rather than mechanisms ("set these overwrites"); each platform
implements what it can and declares the rest absent, and core renders UI only for declared
capabilities. See §8.
5. What if none of a Team's leaders has a linked Discord account? Nothing breaks, because no integration action is leader-scoped. The two classes of leader action answer differently:
- Platform-side leader actions — grant/revoke forum access, post an announcement, pin or lock a thread — depend on path 2 (leadership, from the module) and path 3 (forum access, core's). Discord is irrelevant to all of them, and they keep working unchanged.
- Integration-side actions are performed by the bot with its own permissions, on a decision core made from the Team's linked-member count. No design here asks a leader to authorise, own or execute a Discord action, and none should be added — a leader-scoped Discord action would create exactly this single point of failure.
So a Team with zero Discord-linked leaders is not a degraded Team. Its leaders simply do not personally
get voice access, exactly like any other unlinked member (§2.6 hop 3). Three consequences worth naming:
/team run by an unlinked caller returns the public projection plus an ephemeral prompt to link;
staff can always act on any Team from the site, which is the standing escape hatch; and the
condition is surfaced, not silent — the Team's admin page shows "0 of 3 leaders have linked
Discord" as an informational row, because otherwise it looks like a bug the first time someone notices
it.
6. Why the admin-approval gate covers three actions and not every staff action. Asked and answered during review (2026-08-17): the gate exists because untrusted game-sourced data becomes a public page, not because staff actions are inherently risky. A guild name is written by a player, unreviewed, and this design turns it into a page, a URL, a Discord message and a voice-channel name — so the actions that publish such a string (clearing a reserved-name hide, setting a display name, un-hiding) need a second pair of eyes, and every other staff action does not.
The alternative — approval on all staff actions site-wide — was considered and rejected as a different
workstream: it would touch nearly every admin controller (moderation, appeals, roles, bans, wiki, news,
modules, settings) and deserves its own design doc rather than riding in on Teams. team_moderation_requests
carries an action enum and a payload, so extending it is adding a value, but no action should be
added without asking the same question: does this publish untrusted game data?
The gate is moderator-initiated only, with admins applying immediately. Four-eyes on admin actions
was rejected for a concrete reason: users.role defaults to admin, npm run seed creates exactly
one, and most deployments have precisely one admin — a second-admin requirement would wedge them
completely.
Part 10 — Ownership: what is contract, what is core-internal, and what is the module's
Read this part before building anything. Everything above describes a system that spans a versioned contract, a large body of core-internal code, and a module — and the three are easy to confuse because they all appear in the same paragraphs. This part draws the lines; §10.5 is the one to check a specific question against.
This document is not normative. MODULE_API.md 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 existingctx.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.settingsis 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:appandbotship 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 existingnews.postdoes 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 and
../link/v3.md, not by MODULE_API.md.
| Artifact | Repo | Note |
|---|---|---|
guild.roster, guild.leave event kinds |
servuo-plugins (emit), link (persist), module-uo (ingest) |
new kinds; guild.update unchanged |
PROTOCOL_VERSION 3 → 4 |
link/sidecar/src/main.rs:49 |
every response carries X-UOLink-Version; a mismatch is 409 |
protocol = 4 |
servuo-plugins/overlay.toml:27 |
same PR as the emitters — CI copies it into the release manifest and the installer refuses a mismatched pair |
members on the sidecar guilds board + GET /guilds |
link |
the snapshot rule, so a fresh website gets a roster without waiting for a change |
A module does not participate in class D at all. MODULE_API.md §2.7 forbids a module opening a
connection to a game server; the module reads the sidecar, which is the only thing that ever speaks
this protocol.
Part 11 — MODULE_API_VERSION bump proposal
1.6.0 — minor. Every change is an addition; no member is removed and no existing signature changes,
so MODULE_API.md §1.1's table gives minor, and module-uo's coreApi: "^1.3.0" still resolves.
| Addition | Half | Section |
|---|---|---|
ctx.teams.publish(event) — six Team events |
server | §2.3 |
ctx.teams.reconcile({ reason }) |
server | §2.4 |
ctx.teams.activity.push(items) |
server | §4.1 |
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) |
server | §2.3 |
api.registerSlashCommands([...]) |
server | §7.1 |
Core declares slots team.overview, team.member.row |
client | §3.4 |
capabilities may include teams (opaque, as always) |
manifest | — |
Two things this bump does not do, deliberately:
- It does not add a
getTeamRostertoctx. 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_grantsor 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/webIdare present only for members whose game account is linked.guild.leave— new kind, the missing counterpart to the existingguild.join, now computable from that same set diff.guild.update— unchanged, so nothing that reads it today has to change.- Sidecar:
membersJSON on the existingguildsboard table, served fromGET /guilds— the §12.2 snapshot rule, so a website that connects fresh gets a roster without waiting for the next membership change. - Multiple leaders on the wire come from
PlayerMobile.GuildRank.Rank >= 4(Scripts/Misc/Guild.cs:38), which ServUO already maintains per member.
Part 12 — Phased plan
Every phase is independently shippable and leaves the site working. Phases 1 and 2 are the only hard serial dependency in the list.
Phase 0 — a one-guild roster spike (servuo-plugins + link, throwaway)
Not a deliverable — insurance on the phase that gates everything else. servuo-plugins has no CI
build: the plugin compiles only inside ServUO, and the dynamic rebuild can silently reload a stale
Scripts.dll, so "it booted clean" is not evidence the new code is live. Phase 1 is a four-repo
protocol bump with that verification story, and discovering a problem at the end of it is expensive.
Emit guild.roster for a single guild against the local ServUO tree
(C:\Users\colby\Desktop\ServUO) and the real Rust sidecar — not a stub — confirm it lands in the
store, survives a sidecar restart, and comes back out of GET /guilds. Then build Phase 1. Days, not
weeks, and it retires the only unknown in the plan.
Phase 1 — the roster on the wire (servuo-plugins + link + module-uo + docs)
The prerequisite for everything. Nothing in Team core can be built against counts.
BridgeSocial.cs holds the member serial set rather than its sum and diffs it → guild.roster
and guild.leave; overlay.toml protocol → 4. Sidecar: members on the guilds board,
PROTOCOL_VERSION → 4, GET /guilds projection. module-uo: ingest both kinds, a
shard_guild_members table, the kind→feature map entry and field projection for the new fields.
docs/link/PROTOCOL_2.md + v3.md + INTEGRATION.md.
Ships: a richer public Guilds page (real rosters) on its own merit, with no Team code anywhere.
Verify: the five-rung shard visibility walk against a live ServUO + sidecar, confirming acct/webId
never reach a caller below their rung.
Stagger the reconnect baseline. BridgeLink.OnConnected clears the diff cache, so every guild
re-emits on reconnect. Checked rather than assumed: the outbound queue cap is 10,000 lines
(BridgeConfig.QueueCap) and 200 guilds is 200 lines, so there is no drop risk — but each line is
now fat (a 200-member roster ≈ 16 KB) and the sidecar's read_line has no length bound. Spread the
baseline re-emit across several ticks rather than firing it in one, and cap members per guild.roster
line with a continuation flag for the pathological guild.
Phase 2 — Team core (website + module-uo + docs)
teams, team_members, team_sync_state; registerTeamProvider + the three ctx.teams members
(MODULE_API_VERSION → 1.6.0); the reconciler with all four refusal gates; the four-path
resolver with its non-contamination tests; team_leader_overrides; the public/player/admin read API;
Admin → Teams (sync state, resync, archive, overrides). module-uo implements the provider over
Phase 1's data.
Plus the impersonation controls, which belong here because this is the phase where game-sourced names
first become platform entities: utils/reservedNames.js, auto-hide with the review queue (§2.8), the
hidden / display_name_override columns, team_moderation_requests and the admin-approval gate on
the three publishing actions (§2.9), and the §2.10 account-deletion FK decisions — settled now, while
the tables are being created, rather than migrated later.
Ships: Teams exist, are visible in the admin panel, stay correct across a sidecar outage, and a guild called "Admin" cannot put an official-looking page on the site.
Acceptance, three:
- Kill the sidecar mid-reconcile — zero rows change, state goes stale, the UI says so.
- 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.
- A moderator un-hiding it produces a
pendingrequest and no public change; an admin approving it publishes; an admin doing it directly publishes at once. All three land inactivity_log.
Phase 3 — Team pages, roster, nav, activity feed (website + module-uo)
/teams, /teams/:slug, /teams/:slug/roster, /player/teams; the linked/unlinked/guest surface;
team.overview + team.member.row slots; nav registration; team_activity +
ctx.teams.activity.push + core's own five activity kinds + the retention prune.
Ships: the whole public Team experience. Independently valuable with no forum and no Discord.
Phase 4 — Forum 5a: access model + announcements + admin controls (website)
team_forum_grants, the grant/revoke flow with audit into activity_log, leader vs staff authority,
the full forum schema, announcement threads, and the leader/staff grant UI.
Plus the operator's controls (§5.5), which land here because a forum without an off switch is one an
operator cannot ship: teams_forums_enabled and its 404 guard; teams_forum_images and the
renderer-owned image path (the forum's own sanitizer profile with img excluded in every mode,
URL detection, the https + extension rules); uploads mode with magic-byte sniffing, quotas, rate
limits, team_forum_uploads attribution and the deletion sweep; and the versioned, server-enforced,
activity_log-recorded upload acknowledgement.
Ships: the permission model everything downstream depends on, plus the switches an operator needs to run it, at low surface area.
Acceptance, four:
- A granted, unlinked user reads the forum, appears under "Forum guests", and is absent from every
membership count and from
externalEligible. teams_forums_enabled='0'→ every forum route 404s, and no thread, post, grant or subscription is touched; flipping it back restores the forum unchanged.- A post containing an image URL renders as a plain link under
disabled, as a link plus an embed underremote— with no change to the stored HTML between the two, which is the property the renderer-owned design exists to give. PUT teams_forum_images='uploads'without a matchingacknowledgeversion is rejected400server-side, with the admin UI checkbox bypassed.
Phase 5 — Forum 5b: discussion + moderation + reports (website)
Discussion threads, replies, the edit window, pin/lock/hide/delete, team_forum_moderation, the admin
ledger view, and abuse reporting (§5.6): content_reports, the report control, and the queue in
the existing admin moderation section.
Reports land here rather than in Phase 4 only because discussion is what generates them at volume — if
Phase 4 ships uploads mode enabled anywhere before Phase 5, pull reports forward into Phase 4.
An upload path with a liability acknowledgement and no way for a member to raise a problem is the one
combination this plan should not ship.
Phase 6 — Team notifications (website)
Four core streams, publishToUsers + endpointsForUsersStream, the recipient computation,
team_notification_prefs, its settings screen, and email as the third sink (§6.4) with digest
mode and one-click unsubscribe.
Android is deliberately not in this phase — see the deferred note in
../android/PLAN.md. The streams exist in the catalog and the app will show
them as toggles automatically, but nothing here builds a Team screen or a deep-link target for the
app, so a Team tickle on mobile opens the app and no more. That is a stated limitation, not an
oversight.
Phase 7 — Discord: slash commands (website + bot + docs)
api.registerSlashCommands, /internal/commands + /internal/commands/dispatch, the bot's
defer→dispatch→edit path, the actor resolver, the version-bump re-register, and /team as the first
command through it.
Ships: a working /team, and the seam a module needs for its own commands.
Phase 8 — Discord: notifications bridge (website + bot)
team_integration_config, the internal fan-out with push and bridge as two consumers,
POST /internal/team-notify, the admin per-event configuration.
Phase 9 — Discord: voice channels (website + bot)
team_integrations, the threshold gate, the shared category, overwrite management with role
escalation above voice_overwrite_max, the grace-window lifecycle, and the stale-sync suspension.
Phase 10 — the capability layer (website + docs)
Refactor Phases 7–9's Discord code behind the declared-capability registry (§8.2) and prove it by rendering the admin UI from the declaration rather than from a hardcoded "Discord" assumption. Last on purpose: extracting a capability surface from one working implementation is honest; designing it before one exists is speculation.
Cross-cutting, every phase that touches the server
npm run swagger regenerated and committed · npm run routes:manifest -- --check zero-line diff ·
BACKEND_DESIGN.md updated · forward-only idempotent schema fragments · scoped-router isolation ·
npm test green in website/server and website/client. Phase 1 additionally needs the installer
bundle to pair a protocol-4 sidecar with a protocol-4 overlay.