diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml
index 8c09cce..add43e5 100644
--- a/.gitea/workflows/pr-checks.yml
+++ b/.gitea/workflows/pr-checks.yml
@@ -86,9 +86,13 @@ jobs:
- name: Build client
run: npm run build --prefix client
- bot-install:
- # No tests/build to run; a clean install still catches a broken or
- # out-of-sync lockfile before it ships in the bot image.
+ bot-tests:
+ # The install still runs first and still catches a broken or out-of-sync
+ # lockfile before it ships in the bot image — that was this job's whole
+ # purpose until phase 7 (TEAMS.md §7.1) put real logic in the bot: it now
+ # pulls slash-command definitions from the app, merges them into the
+ # whole-set PUT, and runs the defer→dispatch→edit path. None of that is
+ # reachable from the server suite, and phases 8 and 9 add more of it.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -99,3 +103,7 @@ jobs:
cache-dependency-path: bot/package-lock.json
- name: Install bot deps
run: npm ci --prefix bot
+ - name: Run bot tests
+ # Node's built-in runner, no browser and no Discord connection — the
+ # interaction is a fake that records what was called on it.
+ run: npm test --prefix bot
diff --git a/bot/package.json b/bot/package.json
index 7e97402..0c4692a 100644
--- a/bot/package.json
+++ b/bot/package.json
@@ -6,6 +6,7 @@
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
+ "test": "node --test test/*.test.js",
"dev": "nodemon src/server.js"
},
"keywords": ["discord", "discord.js"],
diff --git a/bot/src/discord/discordManager.js b/bot/src/discord/discordManager.js
index 5edc42a..c1b0742 100644
--- a/bot/src/discord/discordManager.js
+++ b/bot/src/discord/discordManager.js
@@ -5,6 +5,7 @@ const { Client, GatewayIntentBits, REST, Routes } = require('discord.js')
const createLogger = require('../utils/logger')
const commands = require('./commands')
+const dynamicCommands = require('./dynamicCommands')
const messageFilter = require('./messageFilter')
const scheduler = require('../scheduler/scheduler')
const roleMenuHandler = require('./roleMenuHandler')
@@ -22,12 +23,46 @@ let status = 'disconnected' // disconnected | connecting | connected | error
let statusDetail = null
let lastConnectedAt = null
+// One whole-set PUT of the bot's own commands plus whatever the app has
+// registered (TEAMS.md §7.1). Because it replaces the set rather than adding to
+// it, DEREGISTRATION is free: a module that is gone is simply absent from the
+// next pull, and nobody has to remember to take its command back.
async function registerCommands(applicationId, targetGuildId) {
+ const dynamic = dynamicCommands.definitions()
const rest = new REST({ version: '10' }).setToken(client.token)
await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), {
- body: commands.all.map((c) => c.data),
+ body: [...commands.all.map((c) => c.data), ...dynamic],
})
- log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length })
+ log.info('registered guild slash commands', {
+ guildId: targetGuildId,
+ builtIn: commands.all.length,
+ fromApp: dynamic.length,
+ })
+}
+
+/**
+ * Re-pull the app's commands and re-register the set if it moved.
+ *
+ * Called on `ready` and again whenever the app nudges
+ * (`POST /internal/refresh-commands`). A no-op when nothing changed, so a nudge
+ * per module state change costs one cheap GET rather than a REST.put per
+ * install — and a disconnected bot does nothing at all, since there is no
+ * application to register against until it logs in.
+ */
+async function refreshCommands() {
+ const result = await dynamicCommands.pull()
+ if (!result.ok || !result.changed) return result
+ if (!client || !client.isReady()) return result
+ try {
+ await registerCommands(client.application.id, guildId)
+ } catch (err) {
+ // The PUT is all-or-nothing: a definition Discord rejects costs every
+ // command, the built-ins included. Loud, and never fatal to the process.
+ log.error('re-registering slash commands failed — the previous set is still live', {
+ message: err.message,
+ })
+ }
+ return result
}
async function stop() {
@@ -54,6 +89,11 @@ async function stop() {
// failure here leaves the client connected but flags an error status.
async function onReady() {
try {
+ // Pull BEFORE the single PUT, so the app's commands are in the very first
+ // registration rather than appearing a beat later. The pull never throws —
+ // an unreachable app costs the module commands and nothing else, and the
+ // bot's own set registers exactly as it always did.
+ await dynamicCommands.pull()
await registerCommands(client.application.id, guildId)
await scheduler.start(client)
tempRoleSweeper.start(client)
@@ -70,14 +110,19 @@ async function onReady() {
}
}
-// Route an interaction: role-menu handler first, then chat-input slash commands.
+// Route an interaction: role-menu handler first, then chat-input slash commands
+// — the bot's own, then the app's. Built-ins are consulted FIRST and the pull
+// already drops any module name that collides with one, so the two orderings
+// agree; checking here as well means a name that somehow reached Discord twice
+// still runs the bot's version rather than whichever registry answered first.
async function onInteractionCreate(interaction) {
if (await roleMenuHandler.handleInteraction(interaction)) return
if (!interaction.isChatInputCommand()) return
const command = commands.get(interaction.commandName)
- if (!command) return
+ if (!command && !dynamicCommands.has(interaction.commandName)) return
try {
- await command.execute(interaction)
+ if (command) await command.execute(interaction)
+ else await dynamicCommands.execute(interaction)
} catch (err) {
log.error('command execution failed', { command: interaction.commandName, message: err.message })
const payload = { content: 'Something went wrong running that command.', ephemeral: true }
@@ -146,4 +191,4 @@ function getConnection() {
return { client, guildId }
}
-module.exports = { start, stop, getStatus, getConnection }
+module.exports = { start, stop, getStatus, getConnection, refreshCommands }
diff --git a/bot/src/discord/dynamicCommands.js b/bot/src/discord/dynamicCommands.js
new file mode 100644
index 0000000..3632df1
--- /dev/null
+++ b/bot/src/discord/dynamicCommands.js
@@ -0,0 +1,242 @@
+// Slash commands whose DEFINITION and HANDLER live in the website process
+// (TEAMS.md §7.1). The bot pulls the definitions, registers them alongside its
+// own, and executes one by deferring, asking the app, and editing the reply in.
+//
+// Everything Discord-specific is here and nothing else is: the app's dispatcher
+// resolves the actor, enforces access and produces a platform-neutral envelope,
+// and this file turns that envelope into an interaction reply. A module never
+// touches an interaction, which is what makes the registration API something a
+// second platform could implement.
+const { PermissionFlagsBits } = require('discord.js')
+
+const appInternal = require('../site/appInternalClient')
+const staticCommands = require('./commands')
+const createLogger = require('../utils/logger')
+
+const log = createLogger('dynamic-commands')
+
+// §7.1.1's four types, and the only four. The app rejects anything else at
+// registration; this map is the second half of that agreement.
+const OPTION_TYPE = { string: 3, integer: 4, boolean: 5, user: 6 }
+
+// The pulled set, and the app's module-state counter it came from. `null`
+// version means "never successfully pulled", which is distinct from 0 ("pulled
+// while the app had no modules loaded") — the first should retry, the second is
+// a true answer.
+let pulled = []
+let version = null
+
+/**
+ * Ask the app for the current definitions.
+ *
+ * **A failed pull KEEPS the previous set.** The app being briefly unreachable is
+ * not the same as it having no commands, and treating it as such would
+ * deregister every module command from Discord on a restart blip — then
+ * re-register them a minute later, with members watching commands appear and
+ * disappear. Nothing changes until the app actually answers.
+ *
+ * @returns {Promise<{ok: boolean, changed: boolean, count: number}>}
+ */
+async function pull() {
+ const res = await appInternal.fetchCommands()
+ if (!res.ok) {
+ log.warn('command pull failed — keeping the set already registered', {
+ error: res.error,
+ holding: pulled.length,
+ })
+ return { ok: false, changed: false, count: pulled.length }
+ }
+
+ const { version: pulledVersion, commands } = res.data || {}
+ const next = Array.isArray(commands) ? commands.filter(usable) : []
+ const changed = version === null || pulledVersion !== version || next.length !== pulled.length
+ pulled = next
+ version = typeof pulledVersion === 'number' ? pulledVersion : 0
+ return { ok: true, changed, count: pulled.length }
+}
+
+/**
+ * Drop a pulled definition the bot cannot honour.
+ *
+ * **The name collision the app cannot see.** The app validates a command against
+ * everything IT has registered; it does not know the bot's own static array
+ * exists. A module registering `ping` would produce two `ping` entries in one
+ * `REST.put`, which Discord rejects as a batch — taking down every command
+ * including the bot's own. The bot's built-ins win, because they are the ones a
+ * module cannot be asked to change.
+ */
+function usable(definition) {
+ if (!definition || typeof definition.name !== 'string') return false
+ if (staticCommands.get(definition.name)) {
+ log.warn('module slash command collides with a built-in and is ignored', {
+ command: definition.name,
+ owner: definition.owner,
+ })
+ return false
+ }
+ return true
+}
+
+/**
+ * The pulled definitions as Discord command data, for the whole-set PUT.
+ *
+ * `access: 'staff'` becomes a Discord-side permission default; `linked` cannot
+ * be expressed in Discord's permission model at all — there is no "has a website
+ * account" predicate — so it is simply not advertised and the app's dispatcher
+ * refuses it. That asymmetry is the reason §7.1 says access is enforced twice
+ * and that only the server half is the gate.
+ */
+function definitions() {
+ return pulled.map((c) => {
+ const data = {
+ name: c.name,
+ description: c.description,
+ options: (c.options || []).map((o) => ({
+ name: o.name,
+ description: o.description,
+ type: OPTION_TYPE[o.type],
+ required: Boolean(o.required),
+ ...(o.choices ? { choices: o.choices } : {}),
+ })),
+ }
+ if (c.access === 'staff') data.default_member_permissions = PermissionFlagsBits.ModerateMembers.toString()
+ return data
+ })
+}
+
+/** Is this a command the app owns? Asked before the static registry is consulted. */
+const has = (name) => pulled.some((c) => c.name === name)
+
+// Read the options the member actually supplied, by the names the definition
+// declared. A `user` option is passed on as the Discord user id and nothing else
+// — a handler receives platform ids, never a platform object.
+function collectOptions(interaction, definition) {
+ const out = {}
+ for (const option of definition.options || []) {
+ const supplied = interaction.options.get(option.name)
+ if (supplied === null || supplied === undefined) continue
+ out[option.name] = option.type === 'user' ? String(supplied.value) : supplied.value
+ }
+ return out
+}
+
+// What the caller sees when the app declined. The COPY lives here rather than in
+// the app on purpose: the app answers with a machine reason, and how a refusal is
+// phrased to a member is the platform's own voice.
+function refusal({ reason, access }) {
+ if (reason === 'forbidden' && access === 'linked') {
+ return 'Link your Discord account on the site to use this command.'
+ }
+ if (reason === 'forbidden') return 'You do not have access to that command.'
+ if (reason === 'unknown') return 'That command is no longer available.'
+ return 'Something went wrong running that command.'
+}
+
+// Envelope → interaction payload. A response with fields or a title is an embed;
+// a bare `text` is plain content, which reads better for a one-line answer.
+function render(envelope) {
+ const { text, title, fields, url } = envelope
+ if (!title && !fields) return { content: text || '' }
+ const embed = {}
+ if (title) embed.title = title
+ if (text) embed.description = text
+ if (url) embed.url = url
+ if (fields) embed.fields = fields
+ return { embeds: [embed] }
+}
+
+/**
+ * Deliver the envelope at the privacy the HANDLER asked for, not the privacy the
+ * deferral guessed.
+ *
+ * When the two agree — the ordinary case — this is one `editReply`. When the
+ * handler wants a private answer to a publicly deferred command, the deferred
+ * reply is deleted and the answer arrives as an ephemeral follow-up: the
+ * interaction token stays valid, so this is a supported path rather than a
+ * trick, and the cost is a "thinking…" that appears and vanishes.
+ *
+ * There is no reverse case. A command deferred ephemerally is one whose answers
+ * are all about the caller's own account, and nothing it returns should become
+ * public because a handler forgot a flag.
+ */
+async function reply(interaction, envelope, deferredEphemeral) {
+ const payload = render(envelope)
+ if (!envelope.ephemeral || deferredEphemeral) {
+ await interaction.editReply(payload)
+ return
+ }
+ await interaction.deleteReply()
+ await interaction.followUp({ ...payload, ephemeral: true })
+}
+
+/**
+ * Defer, dispatch, edit.
+ *
+ * **The deferral comes first, always.** Discord gives three seconds to acknowledge
+ * an interaction; the app is given four to answer. Deferring before the dispatch
+ * is what keeps the website out of that critical path entirely — a wedged handler
+ * costs its own reply and never an "application did not respond".
+ *
+ * A failure at any point after the defer is an edit, not a reply: the interaction
+ * has already been acknowledged, and `reply()` on a deferred interaction throws.
+ */
+async function execute(interaction) {
+ const definition = pulled.find((c) => c.name === interaction.commandName)
+ if (!definition) return false
+
+ // **Ephemerality is fixed at the DEFERRAL, which happens before the answer
+ // exists.** That is Discord's rule, not a choice here, and it is the whole
+ // reason this needs care: the handler decides privacy per answer — a refusal
+ // is private, a guild summary is not — and by the time it says so the reply is
+ // already public or already not.
+ //
+ // So: defer for the common case (public, or private for a command that only
+ // ever speaks about the caller's own account), and if the envelope disagrees,
+ // reconcile below. Getting this wrong is not cosmetic — the live walk caught it
+ // posting "guild information is not shown to your account" into the channel,
+ // which announces a member's access level to everyone in it.
+ const ephemeral = definition.access === 'linked'
+ await interaction.deferReply({ ephemeral })
+
+ const res = await appInternal.dispatchCommand({
+ command: definition.name,
+ options: collectOptions(interaction, definition),
+ platformUserId: interaction.user.id,
+ guildId: interaction.guildId,
+ })
+
+ // A transport failure and a handler failure are the same sentence to the
+ // member and different lines in the log: one is the app being unreachable,
+ // the other is a module's code.
+ // A refusal is ALWAYS private, whatever the command's usual privacy: "you do
+ // not have access to that" is about one member and belongs to one member.
+ if (!res.ok) {
+ log.warn('command dispatch failed', { command: definition.name, error: res.error })
+ await reply(interaction, { text: refusal({ reason: 'error' }), ephemeral: true }, ephemeral)
+ return true
+ }
+ if (!res.data || !res.data.ok) {
+ await reply(interaction, { text: refusal(res.data || {}), ephemeral: true }, ephemeral)
+ return true
+ }
+
+ const envelope = res.data.response || {}
+ await reply(interaction, envelope, ephemeral)
+
+ // The private aside beside a public answer (§9 answer 5). Skipped when the
+ // reply was already private — the member would just be told the same thing
+ // twice, in the same place.
+ if (envelope.notice && !ephemeral && !envelope.ephemeral) {
+ await interaction.followUp({ content: envelope.notice, ephemeral: true })
+ }
+ return true
+}
+
+// Test-only: the pulled set is process-global, so a test that pulls has to be
+// able to hand the process back.
+function _reset() {
+ pulled = []
+ version = null
+}
+
+module.exports = { pull, definitions, has, execute, _reset }
diff --git a/bot/src/discord/teamNotify.js b/bot/src/discord/teamNotify.js
new file mode 100644
index 0000000..09ce99f
--- /dev/null
+++ b/bot/src/discord/teamNotify.js
@@ -0,0 +1,80 @@
+// Team notifications posted into an operator-configured channel (TEAMS.md §7.2).
+//
+// **The channel comes from the app, not from guild_config.** `newsAnnounce` looks
+// its channel up here because there is exactly one #news; a Team's destination is
+// per-Team configuration living in `team_integration_config`, and a bot that
+// resolved it would need a second copy of that table and a second place for it to
+// drift. The app sends the id it already decided on.
+//
+// **Everything this file knows about a Team it was told.** No lookups, no
+// membership checks, no access decisions: whether this content may reach this
+// channel was settled on the site, where the acknowledgement that gates it lives.
+// The bot is the transport, exactly as it is for slash commands.
+const { EmbedBuilder } = require('discord.js')
+
+const brand = require('../brand')
+const createLogger = require('../utils/logger')
+
+const log = createLogger('team-notify')
+
+// Discord's own limits. Truncating here rather than trusting the app is not
+// distrust — an embed that exceeds them is rejected wholesale, and a message
+// silently not appearing is the worst failure mode this path has.
+const TITLE_MAX = 256
+const DESCRIPTION_MAX = 4096
+
+const clamp = (value, max) => {
+ const text = String(value || '').trim()
+ if (!text) return null
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text
+}
+
+// What each stream is called in a channel. The app composes the BODY; this is
+// only the label above it, and it is here because it is Discord presentation —
+// the same reason the embed colour is.
+const HEADINGS = {
+ 'team.member.joined': 'New member',
+ 'team.leadership.changed': 'Leadership change',
+ 'team.forum.post': 'New forum post',
+ 'team.announcement': 'Announcement',
+}
+
+async function postTeamNotification(client, { channelId, stream, teamName, teamUrl, title, body, url }) {
+ if (!channelId) throw new Error('No channel id supplied.')
+
+ const channel = await client.channels.fetch(channelId).catch(() => null)
+ if (!channel || !channel.isTextBased()) {
+ throw new Error('Configured channel is missing, not text-based, or not visible to the bot.')
+ }
+
+ const heading = HEADINGS[stream] || 'Team update'
+ const name = clamp(teamName, 120) || 'A team'
+
+ const embed = new EmbedBuilder()
+ .setColor(brand.accentInt)
+ // The Team is the AUTHOR line and the event is the title, not the other way
+ // round: a channel carrying one Team's events would otherwise repeat its name
+ // as every heading, and a channel carrying several needs the name to be the
+ // thing the eye lands on first.
+ .setAuthor(teamUrl ? { name, url: teamUrl } : { name })
+ .setTitle(clamp(title, TITLE_MAX) || heading)
+
+ if (url) embed.setURL(url)
+
+ // Both a title and a body means a forum post: the heading has to go somewhere
+ // or "New forum post" and "Announcement" become indistinguishable once the
+ // thread title takes the title slot.
+ //
+ // **Clamped AFTER the heading is prepended, not before.** Clamping the body and
+ // then adding a prefix produces a description one heading longer than the limit,
+ // which discord.js rejects outright — so an over-long post would not arrive at
+ // all rather than arriving truncated. The prefix is part of what has to fit.
+ const composed = title && body ? `**${heading}**\n${String(body)}` : body
+ const description = clamp(composed, DESCRIPTION_MAX)
+ if (description) embed.setDescription(description)
+
+ await channel.send({ embeds: [embed] })
+ log.info('team notification posted', { stream, channelId, team: name })
+}
+
+module.exports = { postTeamNotification, HEADINGS, clamp, TITLE_MAX, DESCRIPTION_MAX }
diff --git a/bot/src/discord/teamVoice.js b/bot/src/discord/teamVoice.js
new file mode 100644
index 0000000..5f723db
--- /dev/null
+++ b/bot/src/discord/teamVoice.js
@@ -0,0 +1,315 @@
+// Per-Team voice channels (TEAMS.md §7.3, phase 9).
+//
+// **The site decides; this file compares and applies.** Every judgement — which
+// Teams qualify, who may enter, what the channel is called — was made on the site
+// and arrives in the request. What cannot be made there is the DIFF: which of
+// those people already hold the role, whether the channel still exists, whether
+// the category was deleted last week. That is live guild state, only this process
+// can see it, and shipping it to the site to be compared and shipped back would
+// be a copy of the guild in a database that cannot watch it change.
+//
+// So the contract is "make it look like this", not "do these calls".
+//
+// **Access is a per-Team ROLE.** §7.3 designed per-member permission overwrites
+// with a role only above ~90 members; the org lead settled on roles always
+// (2026-08-18). The channel therefore carries exactly three kinds of overwrite —
+// @everyone denied, the Team's role allowed, and each operator-designated staff
+// role allowed — and membership is the role's member list rather than a hundred
+// entries on the channel.
+const { ChannelType, PermissionFlagsBits } = require('discord.js')
+
+const createLogger = require('../utils/logger')
+
+const log = createLogger('team-voice')
+
+// The category every Team channel is created under. Created on the first pass
+// that needs one; the site stores the id and sends it back next time.
+const CATEGORY_NAME = 'Teams'
+
+// discord.js REST error codes for "the thing you are addressing is already gone".
+// A teardown that finds its target missing has SUCCEEDED — the desired end state
+// holds — and the same is true of a sync that finds a channel a human deleted,
+// which simply becomes a create.
+const UNKNOWN_CHANNEL = 10003
+const UNKNOWN_ROLE = 10011
+
+const isMissing = (err) => err && (err.code === UNKNOWN_CHANNEL || err.code === UNKNOWN_ROLE)
+
+// What a Team member may do in their channel, and what @everyone may not. Both
+// halves are needed: denying ViewChannel alone still leaves Connect resolvable
+// for anyone who has the id, and allowing ViewChannel alone shows a channel
+// nobody can enter.
+const ACCESS_BITS = [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect]
+
+/**
+ * Can this bot do §7.3's job in this guild?
+ *
+ * Asked before an operator may switch voice on, and again at the top of every
+ * pass. The site has no way to know: the operator invites the bot by hand, there
+ * is no invite URL with a permission integer anywhere in this project, and an
+ * unticked box means every call fails with nothing to point at.
+ *
+ * `bot_role_position` is reported because it is the second, quieter failure:
+ * ManageRoles lets the bot create a role, but it can only GRANT roles below its
+ * own highest one. A bot sitting at the bottom of the role list creates roles it
+ * then cannot hand to anybody — which looks exactly like a channel nobody can
+ * enter, with no error anywhere.
+ */
+async function preflight(client, guildId) {
+ const guild = await client.guilds.fetch(guildId)
+ const me = guild.members.me || (await guild.members.fetchMe())
+ return {
+ connected: true,
+ guild_id: guild.id,
+ can_manage_channels: me.permissions.has(PermissionFlagsBits.ManageChannels),
+ can_manage_roles: me.permissions.has(PermissionFlagsBits.ManageRoles),
+ // The guild's whole role list, not just the ones this feature made. The
+ // 250-role cap is guild-wide and shared with everything the operator created
+ // themselves, so counting ours would promise headroom that is not there.
+ role_count: guild.roles.cache.size,
+ bot_role_position: me.roles.highest.position,
+ }
+}
+
+/** The `Teams` category, reusing the one we were given when it is still there. */
+async function ensureCategory(guild, categoryId) {
+ if (categoryId) {
+ const existing = await guild.channels.fetch(categoryId).catch(() => null)
+ if (existing && existing.type === ChannelType.GuildCategory) return existing
+ log.warn('the configured Teams category is gone; making another', { categoryId })
+ }
+ const created = await guild.channels.create({
+ name: CATEGORY_NAME,
+ type: ChannelType.GuildCategory,
+ reason: 'Team voice channels',
+ })
+ log.info('created the Teams category', { categoryId: created.id })
+ return created
+}
+
+/**
+ * The Team's own role.
+ *
+ * A rename is applied but never allowed to fail the pass: a Team's name is the
+ * least important thing here and Discord rate-limits name edits hard, so losing
+ * one is worth strictly less than losing the access change in the same request.
+ */
+async function ensureRole(guild, roleId, name) {
+ let role = roleId ? await guild.roles.fetch(roleId).catch(() => null) : null
+ let created = false
+ if (!role) {
+ role = await guild.roles.create({
+ name,
+ // Not mentionable and not hoisted: this role exists to open a door, and a
+ // Team with two hundred members should not become a way to ping them all or
+ // a second copy of the member list down the sidebar.
+ mentionable: false,
+ hoist: false,
+ reason: 'Team voice access',
+ })
+ created = true
+ log.info('created a team role', { roleId: role.id, name })
+ } else if (role.name !== name) {
+ await role.setName(name, 'Team renamed').catch((err) => {
+ log.warn('could not rename the team role', { roleId: role.id, message: err.message })
+ })
+ }
+ return { role, created }
+}
+
+/** The overwrites a Team channel carries, in the order Discord takes them. */
+function overwritesFor(guild, role, staffRoleIds) {
+ const overwrites = [
+ { id: guild.roles.everyone.id, deny: ACCESS_BITS },
+ { id: role.id, allow: ACCESS_BITS },
+ ]
+ for (const staffId of staffRoleIds) {
+ // A staff role the operator has since deleted would make Discord reject the
+ // WHOLE set, taking the Team's own grant down with it. Filtered here rather
+ // than validated on the site, which cannot see the guild's role list.
+ if (!guild.roles.cache.has(staffId)) {
+ log.warn('a configured staff role is not in this guild; skipping it', { roleId: staffId })
+ continue
+ }
+ overwrites.push({ id: staffId, allow: ACCESS_BITS })
+ }
+ return overwrites
+}
+
+async function ensureChannel(guild, channelId, { name, category, role, staffRoleIds }) {
+ const overwrites = overwritesFor(guild, role, staffRoleIds)
+ let channel = channelId ? await guild.channels.fetch(channelId).catch(() => null) : null
+
+ if (channel && channel.type !== ChannelType.GuildVoice) {
+ // Somebody pointed us at, or converted this into, something that is not a
+ // voice channel. Not ours to repurpose — make the right one and leave theirs.
+ log.warn('the stored channel is not a voice channel; making a new one', { channelId })
+ channel = null
+ }
+
+ if (!channel) {
+ const created = await guild.channels.create({
+ name,
+ type: ChannelType.GuildVoice,
+ parent: category.id,
+ permissionOverwrites: overwrites,
+ reason: 'Team voice channel',
+ })
+ log.info('created a team voice channel', { channelId: created.id, name })
+ return { channel: created, created: true }
+ }
+
+ // Overwrites are re-set on every pass rather than diffed: the set is three or
+ // four entries, `set` is one API call, and re-asserting it is what repairs a
+ // channel somebody edited by hand.
+ await channel.permissionOverwrites.set(overwrites, 'Team voice access')
+ if (channel.parentId !== category.id) {
+ await channel.setParent(category.id, { lockPermissions: false, reason: 'Team voice channel' })
+ }
+ if (channel.name !== name) {
+ await channel.setName(name, 'Team renamed').catch((err) => {
+ log.warn('could not rename the team voice channel', { channelId: channel.id, message: err.message })
+ })
+ }
+ return { channel, created: false }
+}
+
+/**
+ * Bring the role's member list to the site's list, up to `maxOps` changes.
+ *
+ * **Bounded, and the remainder is reported rather than dropped.** Each grant is
+ * its own API call under its own rate limit, so an unbounded first pass on a
+ * large guild is a request that outlives its own timeout — and a timeout is the
+ * one outcome that leaves the site not knowing what was applied. The site asks
+ * again until `pending` reaches zero.
+ *
+ * **A member the site names who is not in this guild is skipped silently.** They
+ * linked their Discord account to the site and never joined the guild, which is
+ * an ordinary state (§2.6 hop 3 without hop 4) and not something an operator
+ * needs to see a hundred of.
+ */
+async function syncRoleMembers(guild, role, memberIds, maxOps) {
+ // One fetch of the whole member list, so `role.members` and the "are they even
+ // here" check both read from a cache that is actually populated. discord.js
+ // keeps it current from gateway events afterwards; without the fetch, a bot
+ // that has been up for five minutes knows only the members who spoke.
+ await guild.members.fetch()
+
+ const desired = new Set(memberIds.map(String))
+ const current = new Set(role.members.map((member) => member.id))
+
+ const toAdd = [...desired].filter((id) => !current.has(id) && guild.members.cache.has(id))
+ const toRemove = [...current].filter((id) => !desired.has(id))
+
+ let ops = 0
+ let added = 0
+ let removed = 0
+
+ for (const id of toAdd) {
+ if (ops >= maxOps) break
+ const member = guild.members.cache.get(id)
+ try {
+ // eslint-disable-next-line no-await-in-loop
+ await member.roles.add(role, 'Team member')
+ added += 1
+ } catch (err) {
+ // One member the bot cannot touch — almost always the role hierarchy, when
+ // the member outranks the bot — must not cost the other forty-nine.
+ log.warn('could not grant the team role', { userId: id, roleId: role.id, message: err.message })
+ }
+ ops += 1
+ }
+
+ for (const id of toRemove) {
+ if (ops >= maxOps) break
+ const member = guild.members.cache.get(id)
+ if (!member) continue
+ try {
+ // eslint-disable-next-line no-await-in-loop
+ await member.roles.remove(role, 'No longer a team member')
+ removed += 1
+ } catch (err) {
+ log.warn('could not revoke the team role', { userId: id, roleId: role.id, message: err.message })
+ }
+ ops += 1
+ }
+
+ return { added, removed, pending: Math.max(0, toAdd.length + toRemove.length - ops) }
+}
+
+/** One Team, reconciled. */
+async function syncTeamVoice(client, guildId, {
+ teamId, name, categoryId, channelId, roleId, staffRoleIds = [], memberIds = [], maxMemberOps = 50,
+}) {
+ const guild = await client.guilds.fetch(guildId)
+ const category = await ensureCategory(guild, categoryId)
+ const { role, created: roleCreated } = await ensureRole(guild, roleId, name)
+ const { channel, created: channelCreated } = await ensureChannel(guild, channelId, {
+ name, category, role, staffRoleIds,
+ })
+ const members = await syncRoleMembers(guild, role, memberIds, maxMemberOps)
+
+ log.info('team voice reconciled', {
+ teamId, name, channelId: channel.id, roleId: role.id, ...members,
+ })
+
+ return {
+ category_id: category.id,
+ channel_id: channel.id,
+ role_id: role.id,
+ created: { channel: channelCreated, role: roleCreated },
+ members,
+ }
+}
+
+/**
+ * Remove a Team's channel and role.
+ *
+ * Both, in one call, because they are one lifecycle: deleting the channel and
+ * leaving the role would leave every member wearing a badge for a place that no
+ * longer exists. Either being already gone is success.
+ */
+async function removeTeamVoice(client, guildId, { channelId, roleId }) {
+ const guild = await client.guilds.fetch(guildId)
+ const result = { channel_deleted: false, role_deleted: false }
+
+ if (channelId) {
+ const channel = await guild.channels.fetch(channelId).catch(() => null)
+ if (channel) {
+ try {
+ await channel.delete('Team no longer qualifies for a voice channel')
+ result.channel_deleted = true
+ } catch (err) {
+ if (!isMissing(err)) throw err
+ }
+ }
+ }
+
+ if (roleId) {
+ const role = await guild.roles.fetch(roleId).catch(() => null)
+ if (role) {
+ try {
+ await role.delete('Team no longer qualifies for a voice channel')
+ result.role_deleted = true
+ } catch (err) {
+ if (!isMissing(err)) throw err
+ }
+ }
+ }
+
+ log.info('team voice removed', { channelId, roleId, ...result })
+ return result
+}
+
+module.exports = {
+ CATEGORY_NAME,
+ ACCESS_BITS,
+ preflight,
+ ensureCategory,
+ ensureRole,
+ ensureChannel,
+ overwritesFor,
+ syncRoleMembers,
+ syncTeamVoice,
+ removeTeamVoice,
+}
diff --git a/bot/src/internal/internal.controller.js b/bot/src/internal/internal.controller.js
index 01fe2dd..595bf3c 100644
--- a/bot/src/internal/internal.controller.js
+++ b/bot/src/internal/internal.controller.js
@@ -1,5 +1,7 @@
const discordManager = require('../discord/discordManager')
const newsAnnounce = require('../discord/newsAnnounce')
+const teamNotify = require('../discord/teamNotify')
+const teamVoice = require('../discord/teamVoice')
const modLog = require('../discord/modLog')
const createLogger = require('../utils/logger')
@@ -99,4 +101,142 @@ async function reverseModAction(req, res) {
}
}
-module.exports = { setConfig, getStatus: getStatusHandler, announce, reverseModAction }
+// POST /internal/refresh-commands — the app's nudge that its registered
+// slash-command set has moved (TEAMS.md §7.1). No body: the bot re-pulls
+// `/internal/commands` and re-registers only if the set actually changed, so the
+// nudge stays a cheap thing the app can send on every module state change.
+//
+// Deliberately its OWN endpoint rather than riding on /internal/config, which
+// carries the decrypted bot token: saying "commands changed" should not require
+// the app to read a secret out of the database.
+//
+// Answers 200 even when disconnected — there is no application to register
+// against until the bot logs in, and `ready` pulls again anyway. A 5xx here
+// would make an ordinary module install look like a failure in the admin panel.
+async function refreshCommands(req, res) {
+ try {
+ const result = await discordManager.refreshCommands()
+ return res.json({ ok: true, ...result })
+ } catch (err) {
+ log.error('refresh-commands failed', { message: err.message })
+ return res.json({ ok: false, error: err.message })
+ }
+}
+
+// POST /internal/team-notify — a Team notification the site has already decided
+// belongs in a channel (TEAMS.md §7.2). Body: { channel_id, stream, team_name,
+// team_url, title, body, url }.
+//
+// **The site chose the channel and the site checked the access.** Whether
+// members-only forum text may reach this channel is an acknowledgement recorded
+// against team_integration_config, and re-deciding it here would mean the bot
+// holding a copy of a policy it cannot see the inputs to.
+//
+// 503 when disconnected and 400 for a channel the bot cannot post to, matching
+// /internal/announce — the caller is one-shot and best-effort and only logs the
+// difference, but an operator debugging a silent channel needs the two to read
+// differently in the bot's log.
+async function teamNotifyHandler(req, res) {
+ const connection = discordManager.getConnection()
+ if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
+
+ const { channel_id: channelId, stream, team_name: teamName, team_url: teamUrl, title, body, url } = req.body || {}
+ if (!channelId || !stream) {
+ return res.status(400).json({ message: 'channel_id and stream are required' })
+ }
+
+ try {
+ await teamNotify.postTeamNotification(connection.client, { channelId, stream, teamName, teamUrl, title, body, url })
+ return res.json({ posted: true })
+ } catch (err) {
+ log.warn('team-notify failed', { message: err.message, stream, channelId })
+ return res.status(400).json({ message: err.message })
+ }
+}
+
+// ── Voice channels (TEAMS.md §7.3, phase 9) ────────────────────────────────
+
+// GET /internal/team-voice/preflight — can this bot do the job at all?
+//
+// Its own endpoint, and the app asks it BEFORE letting an operator switch voice
+// on. §7.3 assumed the bot could manage channels and roles; nothing in this
+// project has ever checked, because the operator invites the bot by hand and
+// there is no invite URL with a permission integer anywhere in the tree. Without
+// this the first symptom of an unticked box is every Team recording its own
+// identical error, which reads like forty problems instead of one.
+async function voicePreflight(req, res) {
+ const connection = discordManager.getConnection()
+ if (!connection) return res.status(503).json({ connected: false, message: 'Bot is not connected' })
+ try {
+ return res.json(await teamVoice.preflight(connection.client, connection.guildId))
+ } catch (err) {
+ log.warn('voice preflight failed', { message: err.message })
+ return res.status(400).json({ connected: true, message: err.message })
+ }
+}
+
+// POST /internal/team-voice/sync — make one Team's channel, role and role
+// membership match what the site sent.
+//
+// The site sends DESIRED STATE and this works out the calls, which is the
+// opposite of the split every other endpoint here uses. The decisions are all
+// still the site's; what is here is the comparison against live guild state,
+// which only this process can see.
+async function voiceSync(req, res) {
+ const connection = discordManager.getConnection()
+ if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
+
+ const {
+ team_id: teamId, name, category_id: categoryId, channel_id: channelId, role_id: roleId,
+ staff_role_ids: staffRoleIds, member_ids: memberIds, max_member_ops: maxMemberOps,
+ } = req.body || {}
+
+ if (!name) return res.status(400).json({ message: 'name is required' })
+
+ try {
+ const result = await teamVoice.syncTeamVoice(connection.client, connection.guildId, {
+ teamId,
+ name,
+ categoryId: categoryId || null,
+ channelId: channelId || null,
+ roleId: roleId || null,
+ staffRoleIds: Array.isArray(staffRoleIds) ? staffRoleIds.map(String) : [],
+ memberIds: Array.isArray(memberIds) ? memberIds.map(String) : [],
+ maxMemberOps: Number(maxMemberOps) > 0 ? Number(maxMemberOps) : 50,
+ })
+ return res.json(result)
+ } catch (err) {
+ // 400 rather than 500, matching /internal/announce: from the app's side this
+ // is "Discord refused", which is a condition it records against the Team and
+ // retries next pass — not a bug in this process.
+ log.warn('voice sync failed', { message: err.message, teamId, name })
+ return res.status(400).json({ message: err.message })
+ }
+}
+
+// POST /internal/team-voice/remove — the grace window expired, or an admin said so.
+async function voiceRemove(req, res) {
+ const connection = discordManager.getConnection()
+ if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
+
+ const { channel_id: channelId, role_id: roleId } = req.body || {}
+ try {
+ const result = await teamVoice.removeTeamVoice(connection.client, connection.guildId, { channelId, roleId })
+ return res.json(result)
+ } catch (err) {
+ log.warn('voice remove failed', { message: err.message, channelId, roleId })
+ return res.status(400).json({ message: err.message })
+ }
+}
+
+module.exports = {
+ setConfig,
+ getStatus: getStatusHandler,
+ announce,
+ reverseModAction,
+ refreshCommands,
+ teamNotify: teamNotifyHandler,
+ voicePreflight,
+ voiceSync,
+ voiceRemove,
+}
diff --git a/bot/src/internal/internal.routes.js b/bot/src/internal/internal.routes.js
index 49891e4..78c78c5 100644
--- a/bot/src/internal/internal.routes.js
+++ b/bot/src/internal/internal.routes.js
@@ -11,5 +11,10 @@ router.post('/config', ctrl.setConfig)
router.get('/status', ctrl.getStatus)
router.post('/announce', ctrl.announce)
router.post('/mod-reverse', ctrl.reverseModAction)
+router.post('/refresh-commands', ctrl.refreshCommands)
+router.post('/team-notify', ctrl.teamNotify)
+router.get('/team-voice/preflight', ctrl.voicePreflight)
+router.post('/team-voice/sync', ctrl.voiceSync)
+router.post('/team-voice/remove', ctrl.voiceRemove)
module.exports = router
diff --git a/bot/src/site/appInternalClient.js b/bot/src/site/appInternalClient.js
new file mode 100644
index 0000000..3f3e07a
--- /dev/null
+++ b/bot/src/site/appInternalClient.js
@@ -0,0 +1,76 @@
+// Shared-secret client for the APP's internal listener (port 3001) — the
+// bot→app direction of the channel `botInternalClient.js` runs app→bot.
+//
+// Two callers, both slash-command plumbing (TEAMS.md §7.1): pull the registered
+// command definitions, and dispatch one that a member has just run. Distinct
+// from siteApiClient.js, which reads the site's PUBLIC API with no secret at all.
+//
+// **The base URL is derived from `SITE_INTERNAL_URL`'s origin, not configured
+// separately.** That variable already points at the app's internal listener —
+// `http://app:3001/internal/bot-config` — and adding a second variable naming the
+// same host would be one more thing an operator can get half-right. Deriving it
+// means every existing deployment gains these endpoints with no compose change.
+const createLogger = require('../utils/logger')
+
+const log = createLogger('app-internal')
+
+const KEY = process.env.BOT_INTERNAL_KEY || ''
+
+// §7.1's budget, and the same 4s `botInternalClient` uses in the other
+// direction. The app bounds its own handlers UNDER this (3s), so a timeout here
+// normally means the app itself is unreachable rather than a module being slow.
+const TIMEOUT_MS = 4000
+
+function baseUrl() {
+ const configured = process.env.SITE_INTERNAL_URL
+ if (!configured) return null
+ try {
+ return new URL(configured).origin
+ } catch {
+ log.error('SITE_INTERNAL_URL is not a URL — slash-command registration is off', { configured })
+ return null
+ }
+}
+
+async function call(path, { method = 'GET', body } = {}) {
+ const base = baseUrl()
+ if (!base || !KEY) return { ok: false, error: 'SITE_INTERNAL_URL or BOT_INTERNAL_KEY not set' }
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
+ try {
+ const res = await fetch(`${base}${path}`, {
+ method,
+ headers: { 'Content-Type': 'application/json', 'X-Internal-Key': KEY },
+ body: body ? JSON.stringify(body) : undefined,
+ signal: controller.signal,
+ })
+ if (!res.ok) return { ok: false, status: res.status, error: `app responded ${res.status}` }
+ return { ok: true, status: res.status, data: await res.json() }
+ } catch (err) {
+ log.warn('app internal call failed', { path, message: err.message })
+ return { ok: false, status: 0, error: err.message }
+ } finally {
+ clearTimeout(timeout)
+ }
+}
+
+/** The registered slash-command definitions, plus the version they belong to. */
+function fetchCommands() {
+ return call('/internal/commands')
+}
+
+/**
+ * Run one command in the app and get the response envelope back.
+ *
+ * The bot has already deferred by the time this is called, so the only deadline
+ * that matters is Discord's 15-minute follow-up window — TIMEOUT_MS is about not
+ * holding an interaction open on a wedged app, not about the 3-second ack.
+ */
+function dispatchCommand({ command, options, platformUserId, guildId }) {
+ return call('/internal/commands/dispatch', {
+ method: 'POST',
+ body: { command, options, platform: 'discord', platformUserId, guildId },
+ })
+}
+
+module.exports = { fetchCommands, dispatchCommand }
diff --git a/bot/test/appInternalClient.test.js b/bot/test/appInternalClient.test.js
new file mode 100644
index 0000000..9d3ae9b
--- /dev/null
+++ b/bot/test/appInternalClient.test.js
@@ -0,0 +1,77 @@
+// The bot→app internal client (TEAMS.md §7.1).
+//
+// One property carries this file: the base URL is DERIVED from
+// `SITE_INTERNAL_URL`, which already names the app's internal listener with a
+// path on the end. That derivation is the reason every existing deployment gains
+// slash commands with no compose change, and it is exactly the kind of string
+// handling that breaks silently — a wrong base means "the app is down" forever,
+// with nothing in the logs but a fetch error.
+
+const { test, beforeEach, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const env = { ...process.env }
+const realFetch = global.fetch
+
+beforeEach(() => {
+ process.env.SITE_INTERNAL_URL = 'http://app:3001/internal/bot-config'
+ process.env.BOT_INTERNAL_KEY = 'shh'
+ delete require.cache[require.resolve('../src/site/appInternalClient')]
+})
+
+afterEach(() => {
+ process.env = { ...env }
+ global.fetch = realFetch
+})
+
+/** Load the client fresh and record the single fetch it makes. */
+function withFetch(response) {
+ const seen = {}
+ global.fetch = async (url, init) => {
+ seen.url = url
+ seen.init = init
+ return response
+ }
+ // eslint-disable-next-line global-require
+ return { client: require('../src/site/appInternalClient'), seen }
+}
+
+const ok = (body) => ({ ok: true, status: 200, json: async () => body })
+
+test('the commands URL is the internal listener’s origin, not its bot-config path', async () => {
+ const { client, seen } = withFetch(ok({ version: 3, commands: [] }))
+ const res = await client.fetchCommands()
+ assert.equal(seen.url, 'http://app:3001/internal/commands')
+ assert.equal(seen.init.headers['X-Internal-Key'], 'shh')
+ assert.deepEqual(res.data, { version: 3, commands: [] })
+})
+
+test('a dispatch names the platform, so the app never has to guess', async () => {
+ const { client, seen } = withFetch(ok({ ok: true, response: {} }))
+ await client.dispatchCommand({ command: 'guild', options: { name: 'KOC' }, platformUserId: '5', guildId: '9' })
+ assert.equal(seen.url, 'http://app:3001/internal/commands/dispatch')
+ assert.deepEqual(JSON.parse(seen.init.body), {
+ command: 'guild', options: { name: 'KOC' }, platform: 'discord', platformUserId: '5', guildId: '9',
+ })
+})
+
+// A bot with no internal URL configured is an ordinary deployment state (the
+// warning already exists in bootstrap.js); it must not become an exception on
+// every `ready`.
+test('an unconfigured or unparseable SITE_INTERNAL_URL is a refusal, not a throw', async () => {
+ delete process.env.SITE_INTERNAL_URL
+ const { client } = withFetch(ok({}))
+ assert.equal((await client.fetchCommands()).ok, false)
+
+ delete require.cache[require.resolve('../src/site/appInternalClient')]
+ process.env.SITE_INTERNAL_URL = 'not a url'
+ // eslint-disable-next-line global-require
+ assert.equal((await require('../src/site/appInternalClient').fetchCommands()).ok, false)
+})
+
+test('a non-2xx carries its status so the caller can tell "down" from "rejected"', async () => {
+ const { client } = withFetch({ ok: false, status: 401, json: async () => ({}) })
+ const res = await client.fetchCommands()
+ assert.equal(res.ok, false)
+ assert.equal(res.status, 401)
+})
diff --git a/bot/test/dynamicCommands.test.js b/bot/test/dynamicCommands.test.js
new file mode 100644
index 0000000..a7882b4
--- /dev/null
+++ b/bot/test/dynamicCommands.test.js
@@ -0,0 +1,269 @@
+// ── The bot's half of module slash commands (TEAMS.md §7.1) ────────────────
+//
+// The first tests in this package, and they exist for a specific reason: phases
+// 8 and 9 put more of the Discord integration in this process, and the failure
+// modes here are ones no unit test in `server/` can see — a whole-set PUT that
+// one bad entry poisons, a deferral that has to happen before anything slow, and
+// a reply that must be EDITED rather than sent once the interaction is deferred.
+//
+// Nothing here talks to Discord. `interaction` is a fake that records what was
+// called on it, which is the whole of what this file is asserting about.
+
+const { test, beforeEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const dynamic = require('../src/discord/dynamicCommands')
+const appInternal = require('../src/site/appInternalClient')
+const staticCommands = require('../src/discord/commands')
+
+const originals = {
+ fetchCommands: appInternal.fetchCommands,
+ dispatchCommand: appInternal.dispatchCommand,
+ get: staticCommands.get,
+}
+
+beforeEach(() => {
+ dynamic._reset()
+ Object.assign(appInternal, originals)
+ staticCommands.get = originals.get
+})
+
+const definition = (over = {}) => ({
+ name: 'guild',
+ description: 'Show a guild',
+ owner: 'uo',
+ access: 'everyone',
+ options: [{ name: 'name', type: 'string', description: 'Guild name', required: false }],
+ ...over,
+})
+
+const answers = (commands, version = 1) => {
+ appInternal.fetchCommands = async () => ({ ok: true, data: { version, commands } })
+}
+
+function fakeInteraction({ commandName = 'guild', options = {}, userId = '555' } = {}) {
+ const calls = []
+ return {
+ calls,
+ commandName,
+ guildId: '999',
+ user: { id: userId },
+ options: {
+ get: (name) => (name in options ? { value: options[name] } : null),
+ },
+ deferReply: async (payload) => calls.push(['defer', payload]),
+ editReply: async (payload) => calls.push(['edit', payload]),
+ deleteReply: async () => calls.push(['delete']),
+ followUp: async (payload) => calls.push(['followUp', payload]),
+ }
+}
+
+// ── Pulling ────────────────────────────────────────────────────────────────
+
+test('a pull reports whether the set moved, so a nudge is cheap', async () => {
+ answers([definition()], 7)
+ assert.deepEqual(await dynamic.pull(), { ok: true, changed: true, count: 1 })
+ // Same version, same size: nothing to re-register, and re-registering anyway
+ // would mean a REST.put per module state change instead of per real change.
+ assert.deepEqual(await dynamic.pull(), { ok: true, changed: false, count: 1 })
+ answers([definition()], 8)
+ assert.equal((await dynamic.pull()).changed, true)
+})
+
+// Otherwise a restart blip would deregister every module command from Discord
+// and re-register it a minute later, with members watching it happen.
+test('a failed pull keeps the set already registered', async () => {
+ answers([definition()])
+ await dynamic.pull()
+ appInternal.fetchCommands = async () => ({ ok: false, error: 'ECONNREFUSED' })
+ assert.deepEqual(await dynamic.pull(), { ok: false, changed: false, count: 1 })
+ assert.equal(dynamic.definitions().length, 1)
+})
+
+// The collision the app cannot see: it validates against what IT registered and
+// does not know the bot's own array exists. Two entries of one name in a single
+// PUT is rejected as a batch, taking the built-ins down with it.
+test('a module command that collides with a built-in is dropped, not registered', async () => {
+ staticCommands.get = (name) => (name === 'ping' ? { data: { name: 'ping' } } : undefined)
+ answers([definition({ name: 'ping' }), definition()])
+ await dynamic.pull()
+ assert.deepEqual(dynamic.definitions().map((d) => d.name), ['guild'])
+ assert.equal(dynamic.has('ping'), false)
+})
+
+test('definitions carry Discord’s numeric option types, not the contract’s names', async () => {
+ answers([definition({
+ options: [
+ { name: 'who', type: 'user', description: 'A member', required: true },
+ { name: 'n', type: 'integer', description: 'How many', choices: [{ name: 'one', value: 1 }] },
+ ],
+ })])
+ await dynamic.pull()
+ const [data] = dynamic.definitions()
+ assert.deepEqual(data.options.map((o) => o.type), [6, 4])
+ assert.deepEqual(data.options[1].choices, [{ name: 'one', value: 1 }])
+ assert.equal(data.default_member_permissions, undefined)
+})
+
+// `linked` has no Discord equivalent — there is no "has a website account"
+// predicate — so only `staff` maps, and the app re-checks both regardless.
+test('only access: staff becomes a Discord permission default', async () => {
+ answers([definition({ access: 'staff' }), definition({ name: 'other', access: 'linked' })])
+ await dynamic.pull()
+ const [staff, linked] = dynamic.definitions()
+ assert.equal(typeof staff.default_member_permissions, 'string')
+ assert.equal(linked.default_member_permissions, undefined)
+})
+
+// ── Executing ──────────────────────────────────────────────────────────────
+
+test('the deferral happens before the dispatch, always', async () => {
+ answers([definition()])
+ await dynamic.pull()
+ let deferredFirst = false
+ const interaction = fakeInteraction()
+ appInternal.dispatchCommand = async () => {
+ deferredFirst = interaction.calls.length === 1 && interaction.calls[0][0] === 'defer'
+ return { ok: true, data: { ok: true, response: { text: 'hi' } } }
+ }
+ await dynamic.execute(interaction)
+ assert.ok(deferredFirst, 'the website is never in Discord’s 3-second ack path')
+ assert.deepEqual(interaction.calls.at(-1), ['edit', { content: 'hi' }])
+})
+
+test('the options the member supplied are passed by name, as plain values', async () => {
+ answers([definition({
+ options: [
+ { name: 'name', type: 'string', description: 'd' },
+ { name: 'who', type: 'user', description: 'd' },
+ { name: 'missing', type: 'string', description: 'd' },
+ ],
+ })])
+ await dynamic.pull()
+ let sent = null
+ appInternal.dispatchCommand = async (body) => {
+ sent = body
+ return { ok: true, data: { ok: true, response: {} } }
+ }
+ await dynamic.execute(fakeInteraction({ options: { name: 'KOC', who: '42' } }))
+ assert.deepEqual(sent.options, { name: 'KOC', who: '42' })
+ assert.equal(sent.platformUserId, '555')
+ assert.equal(sent.guildId, '999')
+})
+
+test('a title or fields render as an embed; a bare text does not', async () => {
+ answers([definition()])
+ await dynamic.pull()
+ appInternal.dispatchCommand = async () => ({
+ ok: true,
+ data: { ok: true, response: { title: 'Knights', text: 'Alliance: Accord', fields: [{ name: 'Members', value: '12' }], url: 'https://site.test/uo/guilds/7' } },
+ })
+ const interaction = fakeInteraction()
+ await dynamic.execute(interaction)
+ const [, payload] = interaction.calls.at(-1)
+ assert.equal(payload.embeds[0].title, 'Knights')
+ assert.equal(payload.embeds[0].description, 'Alliance: Accord')
+ assert.equal(payload.embeds[0].url, 'https://site.test/uo/guilds/7')
+})
+
+// §9 answer 5: the public projection, plus a private nudge to link. One reply
+// cannot be both, so the aside is a follow-up — which is the bot's decision to
+// make, not the handler's.
+test('a notice becomes an ephemeral follow-up beside a public answer', async () => {
+ answers([definition()])
+ await dynamic.pull()
+ appInternal.dispatchCommand = async () => ({
+ ok: true,
+ data: { ok: true, response: { text: 'public', notice: 'Link your account' } },
+ })
+ const interaction = fakeInteraction()
+ await dynamic.execute(interaction)
+ assert.deepEqual(interaction.calls.at(-1), ['followUp', { content: 'Link your account', ephemeral: true }])
+})
+
+test('a notice is not repeated when the answer was already private', async () => {
+ answers([definition({ access: 'linked' })])
+ await dynamic.pull()
+ appInternal.dispatchCommand = async () => ({
+ ok: true,
+ data: { ok: true, response: { text: 'private', notice: 'Link your account' } },
+ })
+ const interaction = fakeInteraction()
+ await dynamic.execute(interaction)
+ assert.deepEqual(interaction.calls[0], ['defer', { ephemeral: true }])
+ assert.equal(interaction.calls.some(([kind]) => kind === 'followUp'), false)
+})
+
+// Every failure path EDITS. Replying to a deferred interaction throws, so a
+// refusal that used reply() would turn a clean "no" into an unhandled error.
+// Ephemerality is fixed at the DEFERRAL, which happens before the handler has
+// said anything — so honouring a per-answer flag needs the deferred reply
+// withdrawn. The live walk caught the version that ignored it posting "guild
+// information is not shown to your account" into the channel, which announces a
+// member's access level to everyone in it.
+test('a handler asking for privacy gets it, even though the deferral was public', async () => {
+ answers([definition()])
+ await dynamic.pull()
+ appInternal.dispatchCommand = async () => ({
+ ok: true, data: { ok: true, response: { text: 'just for you', ephemeral: true } },
+ })
+ const interaction = fakeInteraction()
+ await dynamic.execute(interaction)
+ assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'delete', 'followUp'])
+ assert.deepEqual(interaction.calls.at(-1)[1], { content: 'just for you', ephemeral: true })
+})
+
+test('an already-private deferral just edits — no second message', async () => {
+ answers([definition({ access: 'linked' })])
+ await dynamic.pull()
+ appInternal.dispatchCommand = async () => ({
+ ok: true, data: { ok: true, response: { text: 'private', ephemeral: true } },
+ })
+ const interaction = fakeInteraction()
+ await dynamic.execute(interaction)
+ assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'edit'])
+})
+
+// "You do not have access to that" is about one member and belongs to one
+// member, whatever the command's usual privacy.
+test('a refusal is always private', async () => {
+ answers([definition()])
+ await dynamic.pull()
+ appInternal.dispatchCommand = async () => ({ ok: true, data: { ok: false, reason: 'forbidden' } })
+ const interaction = fakeInteraction()
+ await dynamic.execute(interaction)
+ assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'delete', 'followUp'])
+ assert.equal(interaction.calls.at(-1)[1].ephemeral, true)
+})
+
+test('a refusal is phrased by the bot and edited into the deferred reply', async () => {
+ answers([definition({ access: 'linked' })])
+ await dynamic.pull()
+ appInternal.dispatchCommand = async () => ({
+ ok: true, data: { ok: false, reason: 'forbidden', access: 'linked', isLinked: false },
+ })
+ const interaction = fakeInteraction()
+ await dynamic.execute(interaction)
+ assert.match(interaction.calls.at(-1)[1].content, /Link your Discord account/)
+ // Deferred ephemerally (access: 'linked'), so the refusal is one edit and no
+ // withdrawal — replying twice to a deferred interaction is what throws.
+ assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'edit'])
+})
+
+test('an unreachable app is the same sentence to the member and a different line in the log', async () => {
+ answers([definition()])
+ await dynamic.pull()
+ appInternal.dispatchCommand = async () => ({ ok: false, error: 'timeout' })
+ const interaction = fakeInteraction()
+ await dynamic.execute(interaction)
+ assert.match(interaction.calls.at(-1)[1].content, /Something went wrong/)
+ assert.equal(interaction.calls.at(-1)[1].ephemeral, true)
+})
+
+test('an interaction for a command the app no longer serves is left alone', async () => {
+ answers([definition()])
+ await dynamic.pull()
+ const interaction = fakeInteraction({ commandName: 'gone' })
+ assert.equal(await dynamic.execute(interaction), false)
+ assert.deepEqual(interaction.calls, [], 'nothing is deferred for a command that is not ours')
+})
diff --git a/bot/test/teamNotify.test.js b/bot/test/teamNotify.test.js
new file mode 100644
index 0000000..d14a152
--- /dev/null
+++ b/bot/test/teamNotify.test.js
@@ -0,0 +1,138 @@
+// ── The bot's half of the Team notifications bridge (TEAMS.md §7.2) ────────
+//
+// Nothing here talks to Discord. `channel` is a fake that records what was sent,
+// and the assertions are about the three things this side genuinely owns:
+//
+// 1. **the channel comes from the app and is never looked up.** `newsAnnounce`
+// reads guild_config because there is one #news; a Team's destination is
+// per-Team configuration, and a bot that resolved it would hold a second
+// copy of a table it cannot see the inputs to;
+// 2. **a channel the bot cannot post to fails loudly rather than silently.** A
+// caller that is one-shot and best-effort only logs the difference, but an
+// operator debugging a quiet channel needs the bot's log to distinguish
+// "not connected" from "that id is not a text channel";
+// 3. **Discord's own limits are enforced here.** An embed that exceeds them is
+// rejected WHOLESALE, so a long forum body must be truncated on this side
+// even though the app already excerpted it — the app's limit is a product
+// decision and this one is a protocol constraint.
+
+const { test } = require('node:test')
+const assert = require('node:assert/strict')
+
+const teamNotify = require('../src/discord/teamNotify')
+
+// A fake channel that records what it was sent. `isTextBased` is the one method
+// the code branches on, so it is the one worth making configurable.
+function fakeChannel({ textBased = true } = {}) {
+ const sends = []
+ return {
+ sends,
+ isTextBased: () => textBased,
+ send: async (payload) => { sends.push(payload); return { id: 'm1' } },
+ }
+}
+
+function fakeClient(channel, { throws = false } = {}) {
+ return {
+ channels: {
+ fetch: async (id) => {
+ if (throws) throw new Error('Unknown Channel')
+ return id === 'chan-1' ? channel : null
+ },
+ },
+ }
+}
+
+const post = (client, over = {}) => teamNotify.postTeamNotification(client, {
+ channelId: 'chan-1',
+ stream: 'team.forum.post',
+ teamName: 'Blackthorn’s Legion',
+ teamUrl: 'https://site/guilds/blackthorns-legion',
+ title: 'Siege tonight',
+ body: 'Meet at the moongate.',
+ url: 'https://site/guilds/blackthorns-legion?thread=41',
+ ...over,
+})
+
+// ── 1. The channel is the app's decision ───────────────────────────────────
+
+test('the message goes to the channel the app named', async () => {
+ const channel = fakeChannel()
+ await post(fakeClient(channel))
+ assert.equal(channel.sends.length, 1)
+ const [embed] = channel.sends[0].embeds
+ assert.equal(embed.data.title, 'Siege tonight')
+ assert.equal(embed.data.author.name, 'Blackthorn’s Legion')
+ assert.equal(embed.data.url, 'https://site/guilds/blackthorns-legion?thread=41')
+})
+
+test('no channel id at all is refused before anything is fetched', async () => {
+ await assert.rejects(() => post(fakeClient(fakeChannel()), { channelId: '' }), /No channel id/)
+})
+
+// ── 2. A channel the bot cannot use ────────────────────────────────────────
+
+test('a channel the bot cannot see is a clear error, not a silent no-op', async () => {
+ await assert.rejects(() => post(fakeClient(null)), /missing, not text-based, or not visible/)
+})
+
+test('a fetch that throws is reported the same way — the bot does not distinguish gone from hidden', async () => {
+ await assert.rejects(() => post(fakeClient(fakeChannel(), { throws: true })), /missing, not text-based/)
+})
+
+test('a voice channel is refused', async () => {
+ await assert.rejects(() => post(fakeClient(fakeChannel({ textBased: false }))), /not text-based/)
+})
+
+// ── 3. Discord's limits, and the heading ───────────────────────────────────
+
+test('an over-long title is truncated rather than rejected by Discord as a whole', async () => {
+ const channel = fakeChannel()
+ await post(fakeClient(channel), { title: 'y'.repeat(400) })
+ const [embed] = channel.sends[0].embeds
+ assert.equal(embed.data.title.length, teamNotify.TITLE_MAX)
+ assert.ok(embed.data.title.endsWith('…'))
+})
+
+test('an over-long body is truncated to the description limit', async () => {
+ const channel = fakeChannel()
+ await post(fakeClient(channel), { body: 'z'.repeat(9000) })
+ const [embed] = channel.sends[0].embeds
+ assert.ok(embed.data.description.length <= teamNotify.DESCRIPTION_MAX + 32)
+})
+
+test('a titled event keeps its heading, so a post and an announcement stay distinguishable', async () => {
+ const channel = fakeChannel()
+ await post(fakeClient(channel), { stream: 'team.announcement' })
+ const [embed] = channel.sends[0].embeds
+ assert.match(embed.data.description, /^\*\*Announcement\*\*/)
+ assert.match(embed.data.description, /Meet at the moongate\./)
+})
+
+test('a roster event has no title, so the heading becomes the title', async () => {
+ const channel = fakeChannel()
+ await post(fakeClient(channel), { stream: 'team.member.joined', title: null, body: '3 new members joined.' })
+ const [embed] = channel.sends[0].embeds
+ assert.equal(embed.data.title, 'New member')
+ assert.equal(embed.data.description, '3 new members joined.', 'no heading prefix when the title already is one')
+})
+
+test('an unknown stream still posts, under a neutral heading', async () => {
+ const channel = fakeChannel()
+ await post(fakeClient(channel), { stream: 'team.something.new', title: null })
+ const [embed] = channel.sends[0].embeds
+ assert.equal(embed.data.title, 'Team update')
+})
+
+test('a missing team name does not produce an embed with an empty author line', async () => {
+ const channel = fakeChannel()
+ await post(fakeClient(channel), { teamName: '', teamUrl: null })
+ const [embed] = channel.sends[0].embeds
+ assert.equal(embed.data.author.name, 'A team')
+ assert.equal(embed.data.author.url, undefined)
+})
+
+test('clamp treats whitespace-only as absent, which is what keeps an empty description off the embed', async () => {
+ assert.equal(teamNotify.clamp(' ', 100), null)
+ assert.equal(teamNotify.clamp('ok', 100), 'ok')
+})
diff --git a/bot/test/teamVoice.test.js b/bot/test/teamVoice.test.js
new file mode 100644
index 0000000..1ce95f5
--- /dev/null
+++ b/bot/test/teamVoice.test.js
@@ -0,0 +1,364 @@
+// ── The bot's half of Team voice channels (TEAMS.md §7.3, phase 9) ────────
+//
+// Nothing here talks to Discord. `fakeGuild` records the calls, and the
+// assertions are about the four things this side genuinely owns — the ones the
+// site cannot decide because it cannot see the guild:
+//
+// 1. **The overwrite set.** @everyone denied, the Team's role allowed, each
+// configured staff role allowed — and a staff role the operator has since
+// deleted is FILTERED, because Discord rejects the whole set for one bad id
+// and that would take the Team's own grant down with it.
+// 2. **The membership diff is bounded and the remainder is reported.** Each
+// grant is its own API call; an unbounded first pass on a large guild
+// outlives its own timeout, which is the one failure that leaves the site
+// not knowing what was applied.
+// 3. **A member who linked Discord but never joined the guild is skipped
+// silently.** That is §2.6 hop 3 without hop 4 — an ordinary state, not an
+// error, and certainly not a hundred log lines.
+// 4. **A missing target is success.** A teardown that finds its channel already
+// deleted has reached the desired end state; a sync that finds one deleted
+// simply creates it again.
+
+const { test } = require('node:test')
+const assert = require('node:assert/strict')
+
+const { ChannelType, PermissionFlagsBits } = require('discord.js')
+const teamVoice = require('../src/discord/teamVoice')
+
+const EVERYONE = 'guild-everyone'
+
+function fakeMember(id, { canGrant = true } = {}) {
+ const roles = new Set()
+ return {
+ id,
+ roles: {
+ cache: roles,
+ add: async (role) => {
+ if (!canGrant) throw new Error('Missing Permissions')
+ roles.add(role.id)
+ },
+ remove: async (role) => { roles.delete(role.id) },
+ },
+ }
+}
+
+function fakeGuild({
+ members = [],
+ roles = [],
+ channels = [],
+ botPermissions = [PermissionFlagsBits.ManageChannels, PermissionFlagsBits.ManageRoles],
+} = {}) {
+ const memberMap = new Map(members.map((m) => [m.id, m]))
+ const roleMap = new Map(roles.map((r) => [r.id, r]))
+ const channelMap = new Map(channels.map((c) => [c.id, c]))
+ const created = { roles: [], channels: [] }
+ let nextId = 1000
+
+ const guild = {
+ id: 'guild-1',
+ created,
+ roles: {
+ everyone: { id: EVERYONE },
+ cache: roleMap,
+ fetch: async (id) => roleMap.get(id) || null,
+ create: async (opts) => {
+ const role = {
+ id: String(nextId++),
+ name: opts.name,
+ members: [],
+ setName: async (name) => { role.name = name },
+ delete: async () => { roleMap.delete(role.id) },
+ }
+ roleMap.set(role.id, role)
+ created.roles.push(opts)
+ return role
+ },
+ },
+ channels: {
+ cache: channelMap,
+ fetch: async (id) => channelMap.get(id) || null,
+ create: async (opts) => {
+ const channel = {
+ id: String(nextId++),
+ name: opts.name,
+ type: opts.type,
+ parentId: opts.parent || null,
+ overwrites: opts.permissionOverwrites || [],
+ permissionOverwrites: {
+ set: async (list) => { channel.overwrites = list },
+ },
+ setParent: async (parentId) => { channel.parentId = parentId },
+ setName: async (name) => { channel.name = name },
+ delete: async () => { channelMap.delete(channel.id) },
+ }
+ channelMap.set(channel.id, channel)
+ created.channels.push(opts)
+ return channel
+ },
+ },
+ members: {
+ me: { permissions: { has: (bit) => botPermissions.includes(bit) }, roles: { highest: { position: 7 } } },
+ cache: memberMap,
+ fetch: async () => memberMap,
+ },
+ }
+ return guild
+}
+
+const fakeClient = (guild) => ({ guilds: { fetch: async () => guild } })
+
+const voiceChannel = (id, over = {}) => {
+ const channel = {
+ id,
+ name: 'The Silver Hand',
+ type: ChannelType.GuildVoice,
+ parentId: '500',
+ overwrites: [],
+ permissionOverwrites: { set: async (list) => { channel.overwrites = list } },
+ setParent: async (parentId) => { channel.parentId = parentId },
+ setName: async (name) => { channel.name = name },
+ delete: async () => {},
+ ...over,
+ }
+ return channel
+}
+
+const category = (id = '500') => ({ id, type: ChannelType.GuildCategory })
+
+const role = (id, name = 'The Silver Hand', members = []) => {
+ const r = {
+ id,
+ name,
+ members,
+ setName: async (next) => { r.name = next },
+ delete: async () => {},
+ }
+ return r
+}
+
+// ── Preflight ──────────────────────────────────────────────────────────────
+
+test('preflight reports both permissions and the guild-wide role count', async () => {
+ const guild = fakeGuild({ roles: [role('1'), role('2')] })
+ const result = await teamVoice.preflight(fakeClient(guild), 'guild-1')
+ assert.equal(result.can_manage_channels, true)
+ assert.equal(result.can_manage_roles, true)
+ // The GUILD's roles, not ours. The 250 cap is shared with everything the
+ // operator made themselves, so counting only ours would promise headroom that
+ // is not there.
+ assert.equal(result.role_count, 2)
+ assert.equal(result.bot_role_position, 7)
+})
+
+test('preflight reports a missing permission rather than throwing', async () => {
+ const guild = fakeGuild({ botPermissions: [PermissionFlagsBits.ManageChannels] })
+ const result = await teamVoice.preflight(fakeClient(guild), 'guild-1')
+ assert.equal(result.can_manage_channels, true)
+ assert.equal(result.can_manage_roles, false)
+})
+
+// ── Overwrites ─────────────────────────────────────────────────────────────
+
+test('the overwrite set denies @everyone and allows the Team role', () => {
+ const guild = fakeGuild()
+ const list = teamVoice.overwritesFor(guild, role('900'), [])
+ assert.equal(list.length, 2)
+ assert.equal(list[0].id, EVERYONE)
+ assert.deepEqual(list[0].deny, teamVoice.ACCESS_BITS)
+ assert.equal(list[1].id, '900')
+ assert.deepEqual(list[1].allow, teamVoice.ACCESS_BITS)
+})
+
+test('a configured staff role that still exists gets an allow', () => {
+ const staff = role('777', 'Moderators')
+ const guild = fakeGuild({ roles: [staff] })
+ const list = teamVoice.overwritesFor(guild, role('900'), ['777'])
+ assert.equal(list.length, 3)
+ assert.equal(list[2].id, '777')
+})
+
+test('a staff role deleted in Discord is skipped, not sent — it would void the whole set', () => {
+ const guild = fakeGuild({ roles: [] })
+ const list = teamVoice.overwritesFor(guild, role('900'), ['deleted-1'])
+ assert.equal(list.length, 2)
+ assert.ok(!list.some((o) => o.id === 'deleted-1'))
+})
+
+// ── Ensure ─────────────────────────────────────────────────────────────────
+
+test('a missing category is created; an existing one is reused', async () => {
+ const guild = fakeGuild()
+ const made = await teamVoice.ensureCategory(guild, null)
+ assert.equal(guild.created.channels.length, 1)
+ assert.equal(guild.created.channels[0].type, ChannelType.GuildCategory)
+
+ const again = await teamVoice.ensureCategory(guild, made.id)
+ assert.equal(again.id, made.id)
+ assert.equal(guild.created.channels.length, 1)
+})
+
+test('a category id pointing at something that is not a category makes a new one', async () => {
+ const guild = fakeGuild({ channels: [voiceChannel('700')] })
+ await teamVoice.ensureCategory(guild, '700')
+ assert.equal(guild.created.channels.length, 1)
+})
+
+test('the Team role is created not mentionable and not hoisted', async () => {
+ const guild = fakeGuild()
+ const { role: made, created } = await teamVoice.ensureRole(guild, null, 'The Silver Hand')
+ assert.equal(created, true)
+ assert.equal(made.name, 'The Silver Hand')
+ // A Team with two hundred members must not become a way to ping them all, or a
+ // second copy of the member list down the sidebar.
+ assert.equal(guild.created.roles[0].mentionable, false)
+ assert.equal(guild.created.roles[0].hoist, false)
+})
+
+test('a renamed Team renames its role rather than making a second', async () => {
+ const existing = role('900', 'Old Name')
+ const guild = fakeGuild({ roles: [existing] })
+ const { role: made, created } = await teamVoice.ensureRole(guild, '900', 'New Name')
+ assert.equal(created, false)
+ assert.equal(made.name, 'New Name')
+ assert.equal(guild.created.roles.length, 0)
+})
+
+test('a rename Discord refuses does not fail the pass — access matters more than a label', async () => {
+ const existing = role('900', 'Old Name')
+ existing.setName = async () => { throw new Error('rate limited') }
+ const guild = fakeGuild({ roles: [existing] })
+ const { role: made } = await teamVoice.ensureRole(guild, '900', 'New Name')
+ assert.equal(made.id, '900')
+})
+
+test('a channel a human deleted is simply created again', async () => {
+ const guild = fakeGuild()
+ const { channel, created } = await teamVoice.ensureChannel(guild, 'gone-1', {
+ name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
+ })
+ assert.equal(created, true)
+ assert.equal(channel.type, ChannelType.GuildVoice)
+ assert.equal(channel.parentId, '500')
+})
+
+test('an existing channel has its overwrites re-asserted every pass', async () => {
+ const existing = voiceChannel('600')
+ const guild = fakeGuild({ channels: [existing] })
+ const { created } = await teamVoice.ensureChannel(guild, '600', {
+ name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
+ })
+ assert.equal(created, false)
+ // Re-setting rather than diffing is what repairs a channel somebody edited by
+ // hand.
+ assert.equal(existing.overwrites.length, 2)
+})
+
+test('a channel that is no longer a voice channel is left alone and a new one made', async () => {
+ const text = voiceChannel('600', { type: ChannelType.GuildText })
+ const guild = fakeGuild({ channels: [text] })
+ const { channel, created } = await teamVoice.ensureChannel(guild, '600', {
+ name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
+ })
+ assert.equal(created, true)
+ assert.notEqual(channel.id, '600')
+})
+
+// ── Membership ─────────────────────────────────────────────────────────────
+
+test('the role is granted to the members the site named', async () => {
+ const alice = fakeMember('a')
+ const bob = fakeMember('b')
+ const guild = fakeGuild({ members: [alice, bob] })
+ const teamRole = role('900', 'The Silver Hand', [])
+
+ const result = await teamVoice.syncRoleMembers(guild, teamRole, ['a', 'b'], 50)
+ assert.equal(result.added, 2)
+ assert.equal(result.removed, 0)
+ assert.equal(result.pending, 0)
+})
+
+test('a member who left the Team has the role taken away', async () => {
+ const alice = fakeMember('a')
+ const bob = fakeMember('b')
+ const guild = fakeGuild({ members: [alice, bob] })
+ const teamRole = role('900', 'The Silver Hand', [alice, bob])
+
+ const result = await teamVoice.syncRoleMembers(guild, teamRole, ['a'], 50)
+ assert.equal(result.added, 0)
+ assert.equal(result.removed, 1)
+})
+
+test('a member who linked Discord but never joined the guild is skipped without an error', async () => {
+ const guild = fakeGuild({ members: [] })
+ const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), ['not-in-guild'], 50)
+ assert.equal(result.added, 0)
+ assert.equal(result.pending, 0)
+})
+
+test('the diff is bounded and the remainder is REPORTED, not dropped', async () => {
+ const members = Array.from({ length: 10 }, (_, i) => fakeMember(`m${i}`))
+ const guild = fakeGuild({ members })
+ const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), members.map((m) => m.id), 4)
+ assert.equal(result.added, 4)
+ assert.equal(result.pending, 6)
+})
+
+test('one member the bot cannot touch does not cost the other forty-nine', async () => {
+ const ok1 = fakeMember('a')
+ const nope = fakeMember('b', { canGrant: false })
+ const ok2 = fakeMember('c')
+ const guild = fakeGuild({ members: [ok1, nope, ok2] })
+
+ const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), ['a', 'b', 'c'], 50)
+ assert.equal(result.added, 2)
+})
+
+// ── Teardown ───────────────────────────────────────────────────────────────
+
+test('a teardown deletes the channel and the role together', async () => {
+ const channel = voiceChannel('600')
+ const teamRole = role('900')
+ let deletedChannel = false
+ let deletedRole = false
+ channel.delete = async () => { deletedChannel = true }
+ teamRole.delete = async () => { deletedRole = true }
+ const guild = fakeGuild({ channels: [channel], roles: [teamRole] })
+
+ const result = await teamVoice.removeTeamVoice(fakeClient(guild), 'guild-1', { channelId: '600', roleId: '900' })
+ assert.equal(deletedChannel, true)
+ assert.equal(deletedRole, true)
+ assert.equal(result.channel_deleted, true)
+ assert.equal(result.role_deleted, true)
+})
+
+test('a teardown whose target is already gone is success, not a failure to retry forever', async () => {
+ const guild = fakeGuild({ channels: [], roles: [] })
+ const result = await teamVoice.removeTeamVoice(fakeClient(guild), 'guild-1', { channelId: 'gone', roleId: 'gone' })
+ assert.equal(result.channel_deleted, false)
+ assert.equal(result.role_deleted, false)
+})
+
+// ── The whole thing ────────────────────────────────────────────────────────
+
+test('a first sync creates the category, the role and the channel, and grants the members', async () => {
+ const alice = fakeMember('a')
+ const guild = fakeGuild({ members: [alice] })
+
+ const result = await teamVoice.syncTeamVoice(fakeClient(guild), 'guild-1', {
+ teamId: 1,
+ name: 'The Silver Hand',
+ categoryId: null,
+ channelId: null,
+ roleId: null,
+ staffRoleIds: [],
+ memberIds: ['a'],
+ maxMemberOps: 50,
+ })
+
+ assert.equal(result.created.channel, true)
+ assert.equal(result.created.role, true)
+ assert.ok(result.category_id)
+ assert.ok(result.channel_id)
+ assert.ok(result.role_id)
+ assert.equal(result.members.added, 1)
+})
diff --git a/client/src/App.jsx b/client/src/App.jsx
index a627509..88ff3f7 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -42,10 +42,12 @@ import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import UserDetail from './routes/admin/views/UserDetail.jsx'
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
+import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
import Appeals from './routes/admin/views/Appeals.jsx'
+import ContentReports from './routes/admin/views/ContentReports.jsx'
// Player portal
import PlayerLogin from './routes/player/PlayerLogin.jsx'
@@ -55,6 +57,8 @@ import ResetPassword from './routes/player/ResetPassword.jsx'
import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx'
+import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
+import Unsubscribe from './routes/player/Unsubscribe.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
export default function App() {
@@ -162,6 +166,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
@@ -174,6 +179,10 @@ export default function App() {
the volume in the first place. Declared here with the rest of
core's routes, above the module-supplied ones below. */}
} />
+ {/* Staff-wide, like the moderation queues: the gate on the three
+ actions that publish a game-written name is applied per request
+ on the server, from the caller's live role (TEAMS.md 2.9). */}
+ } />
} />
{/* Installed modules' admin pages, at /admin//…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
@@ -197,6 +206,10 @@ export default function App() {
} />
} />
} />
+ {/* PUBLIC, and grouped with the other tokened landings above rather
+ than with the portal below: the person following an unsubscribe
+ link is reading their mail, not signed in (TEAMS.md §6.4). */}
+ } />
@@ -211,6 +224,7 @@ export default function App() {
} />
} />
} />
+ } />
{/* Installed modules' player-portal pages, at /player//…. This
group's own routes are absolute (its layout route has no path),
so the prefix is written here rather than inherited — the one
diff --git a/client/src/api/client.js b/client/src/api/client.js
index b21db5a..1da6829 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -133,6 +133,80 @@ export const api = {
return req(`/public/wiki${withQs(s)}`)
},
wikiCategories: () => req('/public/wiki/categories'),
+
+ // ----- Teams (TEAMS.md §2.11, §4.3) -----
+ //
+ // Only the two calls CORE's own client makes. Core renders no Team pages — the
+ // vocabulary belongs to whichever module owns the surface — so the index, the
+ // roster and the player list are not here; a module that renders those calls
+ // the same public API from its own client.
+ //
+ // The lookup exists because a module names a Team in its own terms and core
+ // keys the feed by slug. Resolving that is core's job precisely so a module
+ // never has to hold core's identifiers.
+ teamByExternalId: (moduleId, externalId) =>
+ req(`/public/teams/by-external/${encodeURIComponent(moduleId)}/${encodeURIComponent(externalId)}`),
+ teamActivity: (slug, opts = {}) => {
+ const qs = new URLSearchParams()
+ if (opts.limit != null) qs.set('limit', String(opts.limit))
+ if (opts.offset != null) qs.set('offset', String(opts.offset))
+ return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`)
+ },
+ // The Team FORUM, under /player because a participant may be a plain player and
+ // a leader is a player (TEAMS.md §2.11). Core's, for the same reason the feed is
+ // core's: only core resolves whether this viewer is inside the Team, and the
+ // member/guest split is a security boundary. The module renders the PLACE.
+ teamForumThreads: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`),
+ teamForumThread: (slug, id) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}`),
+ teamForumPost: (slug, body) =>
+ req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
+ teamForumModerate: (slug, id, body) =>
+ req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }),
+ // Phase 5 ("5b"). A reply, an edit and post-level moderation are separate
+ // routes from their thread-level cousins rather than the same route with a
+ // target kind, because they answer to different rules: a reply is refused by a
+ // lock, an edit by a clock, and `pin`/`lock` mean nothing to a post at all.
+ teamForumReply: (slug, threadId, body) =>
+ req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${threadId}/posts`, { method: 'POST', body }),
+ teamForumEditPost: (slug, postId, body) =>
+ req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}`, { method: 'PATCH', body }),
+ teamForumModeratePost: (slug, postId, body) =>
+ req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}/moderate`, { method: 'POST', body }),
+ // The report goes to SITE STAFF, never to the Team's leaders — the whole point
+ // of it is a path that routes around a Team's own leadership (TEAMS.md §5.6).
+ // There is no leader-facing counterpart to this call and there should not be.
+ teamForumReport: (slug, body) =>
+ req(`/player/teams/${encodeURIComponent(slug)}/forum/report`, { method: 'POST', body }),
+ teamForumUpload: (slug, file) => {
+ const fd = new FormData()
+ fd.append('image', file)
+ return req(`/player/teams/${encodeURIComponent(slug)}/forum/uploads`, { method: 'POST', body: fd, raw: true })
+ },
+ teamGrantList: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/grants`),
+ teamGrantAdd: (slug, body) =>
+ req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }),
+ teamGrantRevoke: (slug, userId) =>
+ req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }),
+
+ // ----- notifications (TEAMS.md Part 6) -----
+ //
+ // Under /auth/me rather than /player: these are role-agnostic self-service, the
+ // same rule that put the forum under /player rather than behind a staff gate.
+ // The streams catalog and the per-stream subscriptions were built for the app
+ // and had no web consumer at all until phase 6 gave them one.
+ notificationStreams: () => req('/auth/me/notifications/streams'),
+ notificationSubscriptions: () => req('/auth/me/notifications/subscriptions'),
+ // `streams` is always sent, empty array included — the endpoint requires the
+ // field, so clearing the last subscription must not become an absent key.
+ setNotificationSubscriptions: (streams) =>
+ req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }),
+ teamNotificationPrefs: () => req('/auth/me/notifications/teams'),
+ setTeamNotificationPrefs: (teams) =>
+ req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }),
+ // Unauthenticated, and the one write in the public tier: the caller is reading
+ // their mail, not signed in. Always resolves 200 whatever the token was.
+ unsubscribeTeam: (token) =>
+ req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link
@@ -247,8 +321,61 @@ export const api = {
setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
restartServer: () => req('/admin/modules/restart', { method: 'POST' }),
+ // Teams (docs/website/TEAMS.md §2.11). Three of these mean something
+ // different depending on who calls them: for a moderator, unhide and
+ // setTeamDisplayName file a request and the response says `pending: true`.
+ // The caller does not choose — the server decides from the live role — so
+ // there is deliberately no "asRequest" argument to get wrong.
+ listTeams: () => req('/admin/teams'),
+ getTeam: (id) => req(`/admin/teams/${id}`),
+ resyncTeams: () => req('/admin/teams/resync', { method: 'POST' }),
+ archiveTeam: (id, reason) => req(`/admin/teams/${id}/archive`, { method: 'POST', body: { reason } }),
+ teamGrants: (id) => req(`/admin/teams/${id}/grants`),
+ hideTeam: (id, reason) => req(`/admin/teams/${id}/hide`, { method: 'POST', body: { reason } }),
+ unhideTeam: (id, reason) => req(`/admin/teams/${id}/unhide`, { method: 'POST', body: { reason } }),
+ setTeamDisplayName: (id, displayName, reason) =>
+ req(`/admin/teams/${id}/display-name`, { method: 'POST', body: { displayName, reason } }),
+ setTeamLeaderOverride: (id, body) =>
+ req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }),
+ clearTeamLeaderOverride: (id, memberKey) =>
+ req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }),
+ teamForumSettings: () => req('/admin/teams/forum/settings'),
+ // The notification bridge (TEAMS.md §7.2). Admin-only server-side, so a
+ // moderator's admin panel never renders the panel that calls these.
+ teamIntegrations: () => req('/admin/teams/integrations'),
+ saveTeamIntegration: (body) => req('/admin/teams/integrations', { method: 'PUT', body }),
+ deleteTeamIntegration: (teamId) =>
+ req(`/admin/teams/integrations/${teamId === null ? 'default' : teamId}`, { method: 'DELETE' }),
+ // Voice channels (TEAMS.md §7.3). Admin-only server-side, like the bridge.
+ teamVoice: () => req('/admin/teams/voice'),
+ saveTeamVoice: (body) => req('/admin/teams/voice', { method: 'PUT', body }),
+ teamVoicePass: () => req('/admin/teams/voice/sync', { method: 'POST' }),
+ removeTeamVoice: (teamId) => req(`/admin/teams/voice/${teamId}`, { method: 'DELETE' }),
+ teamForumUploads: (opts = {}) => {
+ const qs = new URLSearchParams()
+ if (opts.deleted) qs.set('deleted', '1')
+ return req(`/admin/teams/forum/uploads${withQs(qs.toString())}`)
+ },
+ teamForumModeration: (id) => req(`/admin/teams/${id}/forum/moderation`),
+ teamReviewQueue: () => req('/admin/teams/review'),
+ teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`),
+ decideTeamRequest: (id, status, note) =>
+ req(`/admin/teams/requests/${id}/decide`, { method: 'POST', body: { status, note } }),
+
// ----- moderation dashboard (admin + moderator) -----
modSummary: () => req('/admin/moderation/stats/summary'),
+ // The content-report queue (TEAMS.md §5.6). Under moderation rather than
+ // under Teams because a staffer working a queue should have one place to
+ // work, and a report about a forum post is the same job as a report about
+ // anything else — which is also why `targetType` is open-ended.
+ contentReports: (opts = {}) => {
+ const qs = new URLSearchParams()
+ if (opts.status) qs.set('status', opts.status)
+ if (opts.teamId) qs.set('teamId', String(opts.teamId))
+ return req(`/admin/moderation/reports${withQs(qs.toString())}`)
+ },
+ handleContentReport: (id, body) =>
+ req(`/admin/moderation/reports/${id}/handle`, { method: 'POST', body }),
modRecent: (params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
diff --git a/client/src/lib/teamActivity.js b/client/src/lib/teamActivity.js
new file mode 100644
index 0000000..2d5c3b6
--- /dev/null
+++ b/client/src/lib/teamActivity.js
@@ -0,0 +1,100 @@
+// What core's Team activity feed SAYS, separated from how it renders
+// (docs/website/TEAMS.md §4.3).
+//
+// Core renders this feed into a slot a MODULE declares on its own page, because
+// Teams is a contract primitive and not a surface: core owns the feed, its
+// visibility rules and its wording; the module owns the page and the vocabulary
+// around it. So this file is deliberately narrow — the roster and index
+// presentation that once lived here went with the core Team pages, to whichever
+// module renders them.
+//
+// Plain JS with tests, following lib/teamAdmin.js. Worth splitting for the same
+// reason it was there: a feed that is filtered, or a projection that is stale,
+// has to say so in words, and getting that wording right is logic rather than
+// markup.
+
+const MINUTE = 60_000
+const HOUR = 60 * MINUTE
+const DAY = 24 * HOUR
+
+/** "just now" / "14 minutes ago" / "3 hours ago" / "2 days ago". */
+export function relativeTime(when, now = Date.now()) {
+ if (!when) return null
+ const ms = now - new Date(when).getTime()
+ if (!Number.isFinite(ms)) return null
+ if (ms < MINUTE) return 'just now'
+ if (ms < HOUR) {
+ const n = Math.floor(ms / MINUTE)
+ return `${n} ${n === 1 ? 'minute' : 'minutes'} ago`
+ }
+ if (ms < DAY) {
+ const n = Math.floor(ms / HOUR)
+ return `${n} ${n === 1 ? 'hour' : 'hours'} ago`
+ }
+ const n = Math.floor(ms / DAY)
+ return `${n} ${n === 1 ? 'day' : 'days'} ago`
+}
+
+/**
+ * How a public surface describes the projection's freshness (§2.4).
+ *
+ * Distinct from `teamAdmin.freshnessOf`, which is worded for an operator
+ * debugging a sync. A visitor needs one sentence about whether what they are
+ * looking at is current, and specifically must never be shown an unconfirmed
+ * empty projection as though it were a confirmed empty shard.
+ */
+export function freshnessNote(sync = {}, now = Date.now()) {
+ // Nothing supplies Teams here, so there is nothing to be stale ABOUT. A
+ // deployment with no game module is not a broken one.
+ if (!sync.configured) return null
+ if (!sync.lastSyncAt) return { tone: 'warn', text: 'Not yet confirmed against the game.' }
+ const ago = relativeTime(sync.lastSyncAt, now)
+ if (sync.stale) return { tone: 'warn', text: `Last confirmed ${ago} — the game may have moved on.` }
+ return { tone: 'idle', text: `Last confirmed ${ago}.` }
+}
+
+/**
+ * Group feed items into days, newest first, preserving order within a day (§4.3).
+ *
+ * Keyed by local calendar date rather than by a UTC slice: "yesterday" is a
+ * property of where the reader is sitting, and a shard's evening raid landing at
+ * 00:30 UTC belongs on the day the players experienced it.
+ */
+export function groupByDay(items = [], locale = undefined) {
+ const days = []
+ const byKey = new Map()
+ for (const item of items) {
+ const date = new Date(item.occurredAt)
+ if (Number.isNaN(date.getTime())) continue
+ const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
+ if (!byKey.has(key)) {
+ const day = {
+ key,
+ label: date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }),
+ items: [],
+ }
+ byKey.set(key, day)
+ days.push(day)
+ }
+ byKey.get(key).items.push(item)
+ }
+ return days
+}
+
+/**
+ * What to say under a feed that has been filtered.
+ *
+ * Only when there is something to say: a caller who saw everything is told
+ * nothing, and an anonymous caller is invited to sign in rather than simply
+ * informed that entries exist which they cannot have.
+ *
+ * The wording avoids core's own noun. The reader is looking at a page the module
+ * titled — a guild, a clan — and "this Team" would be core's vocabulary leaking
+ * onto a surface that deliberately does not use it.
+ */
+export function activityScopeNote(feed = {}, signedIn = false) {
+ if (feed.scope !== 'public') return null
+ return signedIn
+ ? 'Some entries are visible to members only.'
+ : 'Sign in as a member to see the members-only entries.'
+}
diff --git a/client/src/lib/teamAdmin.js b/client/src/lib/teamAdmin.js
new file mode 100644
index 0000000..7c3c34f
--- /dev/null
+++ b/client/src/lib/teamAdmin.js
@@ -0,0 +1,140 @@
+// What Admin → Teams SAYS, separated from how it renders (docs/website/TEAMS.md
+// §2.4, §2.8, §2.9).
+//
+// Plain JS with tests, following lib/moduleAdmin.js. The reason it is worth
+// splitting here specifically: this screen's job is to tell an operator the
+// difference between "the shard has no Teams" and "core has not been able to ask
+// for two hours", and those two produce almost the same page. Getting that
+// wording right is logic, not markup.
+
+/** Tones the screen uses. Names, not colours — the view maps them. */
+export const TONE = { ok: 'ok', warn: 'warn', bad: 'bad', idle: 'idle' }
+
+/**
+ * How to describe the projection's freshness.
+ *
+ * The four states are genuinely different and an operator needs to tell them
+ * apart:
+ *
+ * - no provider registered — nothing to sync, and not a fault;
+ * - never synced — core has an empty projection it has never confirmed, which
+ * must NOT read as "there are no Teams";
+ * - stale — the projection is real but old, and the reason is usually in
+ * `lastError`;
+ * - current.
+ */
+export function freshnessOf(sync = {}) {
+ if (!sync.configured) {
+ return { tone: TONE.idle, label: 'No Team provider', detail: 'No installed module supplies Teams.' }
+ }
+ if (!sync.lastSyncAt) {
+ return {
+ tone: TONE.bad,
+ label: 'Never synced',
+ detail: 'Core has never had an answer it could trust. What is shown below is not a confirmed empty shard.',
+ }
+ }
+ if (sync.stale) {
+ return {
+ tone: TONE.warn,
+ label: 'Stale',
+ detail: `Last confirmed ${ago(sync.lastSyncAt)}. Rosters below may be out of date.`,
+ }
+ }
+ return { tone: TONE.ok, label: 'Current', detail: `Last confirmed ${ago(sync.lastSyncAt)}.` }
+}
+
+/**
+ * A short, human age. Deliberately coarse: this exists so a sentence reads
+ * "confirmed 14 minutes ago", and second-level precision would be false comfort
+ * about a projection whose interval is fifteen minutes.
+ */
+export function ago(value) {
+ if (!value) return 'never'
+ const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000))
+ if (seconds < 90) return 'just now'
+ const minutes = Math.round(seconds / 60)
+ if (minutes < 60) return `${minutes} minutes ago`
+ const hours = Math.round(minutes / 60)
+ if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
+ return `${Math.round(hours / 24)} days ago`
+}
+
+/** The status pill for one Team row. */
+export function statusOf(team = {}) {
+ if (team.status === 'archived') {
+ return { tone: TONE.idle, label: team.archivedReason === 'renamed' ? 'Renamed' : 'Archived' }
+ }
+ if (team.hidden && team.hiddenReason === 'reserved_name') {
+ return { tone: TONE.bad, label: 'Hidden — reserved name' }
+ }
+ if (team.hidden) return { tone: TONE.warn, label: 'Hidden by staff' }
+ return { tone: TONE.ok, label: 'Public' }
+}
+
+/**
+ * What a staff member is told will happen when they press the button.
+ *
+ * The gate is decided server-side from the caller's live role, so this only
+ * describes it. Saying "Request" to a moderator and "Apply" to an admin is what
+ * stops the pending result being a surprise.
+ */
+export function gateLabelFor(role, verb) {
+ return role === 'admin' ? verb : `Request ${verb.toLowerCase()}`
+}
+
+/** The three gated actions, for the note under the buttons. */
+export const GATED_NOTE =
+ 'Publishing a game-written name needs an admin: a moderator’s un-hide or display-name change '
+ + 'is filed for approval. Hiding is not gated — suppression is always safe.'
+
+/** A one-line description of a queued request, for the approval queue. */
+export function describeRequest(request = {}) {
+ const payload = parsePayload(request.payload)
+ const who = request.requested_username || 'a deleted user'
+ switch (request.action) {
+ case 'unhide':
+ return `${who} asks to publish “${request.team_name}”`
+ case 'display_name_override':
+ return `${who} asks to display “${request.team_name}” as “${payload.displayName || ''}”`
+ case 'clear_display_name_override':
+ return `${who} asks to clear the display name on “${request.team_name}”`
+ default:
+ return `${who} asks for “${request.action}” on “${request.team_name}”`
+ }
+}
+
+/**
+ * The payload may arrive parsed or as a JSON string depending on the driver, so
+ * this normalises rather than assuming either. The server has the same note.
+ */
+export function parsePayload(payload) {
+ if (payload == null) return {}
+ if (typeof payload === 'object') return payload
+ try {
+ return JSON.parse(payload)
+ } catch {
+ return {}
+ }
+}
+
+/**
+ * How a member's leadership should read.
+ *
+ * An override is shown AS an override rather than folded into the answer: staff
+ * looking at a roster need to see that a decision was made, not a fact that looks
+ * like the game's.
+ */
+export function leadershipOf(member = {}) {
+ if (!member.leaderOverride) {
+ return { isLeader: Boolean(member.isLeader), overridden: false, note: null }
+ }
+ const granted = member.leaderOverride.effect === 'grant'
+ return {
+ isLeader: granted,
+ overridden: true,
+ note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}`
+ + `${member.leaderOverride.reason ? ` — ${member.leaderOverride.reason}` : ''}`
+ + ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`,
+ }
+}
diff --git a/client/src/lib/teamForum.js b/client/src/lib/teamForum.js
new file mode 100644
index 0000000..b52ae3a
--- /dev/null
+++ b/client/src/lib/teamForum.js
@@ -0,0 +1,85 @@
+// The Team forum's client-side judgements — the few there are (TEAMS.md Part 5).
+//
+// This file is small on purpose. **Almost nothing about the forum is the
+// client's to decide**: who may post, who may moderate, whether an image
+// renders, and whether a post may be edited are all answered by the server and
+// read from the payload. What is left here is the handful of pure functions that
+// turn those answers into what a reader sees, and they are extracted so they can
+// be tested without a browser.
+//
+// The one that deserves a second look is `editOfferOpen`. It can only ever take
+// an offer AWAY — the server grants the edit and re-derives the window from
+// `created_at` when the write arrives. A client that granted one would be
+// deciding a time-bounded permission against the clock of the party it bounds.
+
+export const REPORT_REASONS = [
+ ['abuse', 'Abusive or harassing'],
+ ['spam', 'Spam'],
+ ['sexual', 'Sexual content'],
+ ['illegal', 'Illegal content'],
+ ['impersonation', 'Impersonation'],
+ ['other', 'Something else'],
+]
+
+/**
+ * Should the Edit control still be offered for this post?
+ *
+ * Three states, and the middle one is the reason this exists:
+ * • the server said no → no offer, and nothing here can create one
+ * • the server said yes, no deadline (staff) → offer
+ * • the server said yes with a deadline that has since passed while the page
+ * sat open → withdraw the offer, rather than leave a button that fails
+ */
+export function editOfferOpen(post, now = Date.now()) {
+ if (!post || !post.canEdit) return false
+ if (!post.editableUntil) return true
+ const until = new Date(post.editableUntil).getTime()
+ return Number.isFinite(until) && until > now
+}
+
+/**
+ * Turn a rendered body back into something an author can edit.
+ *
+ * The server stores sanitised HTML and generates images at READ time from the
+ * URLs an author wrote (§5.5.3), so what comes back is not what was typed. The
+ * `` has to go — it is core's output, not the author's input, and leaving it
+ * in would let an author "edit" markup they never wrote and cannot control.
+ * The URL survives as the link text beside it, which is what re-renders.
+ */
+export function stripToText(html) {
+ return String(html || '')
+ .replace(/]*>/gi, '')
+ .replace(/<\/p>\s*
]*>/gi, '\n\n')
+ .replace(/ /gi, '\n')
+ .replace(/<[^>]*>/g, '')
+ // Entities last: unescaping before tag-stripping would let an escaped
+ // "<script>" become a real tag the next pass then removes, which is a
+ // different string from the one the author wrote.
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/ /g, ' ')
+ // `&` last of all, or "<" would decode two steps into "<".
+ .replace(/&/g, '&')
+ .trim()
+}
+
+/**
+ * The one-line summary under a thread's title in the list.
+ *
+ * `postCount` counts every post including the opening one, so a discussion's
+ * REPLY count is one less — and an announcement has no replies to count at all,
+ * which is why the count is omitted rather than shown as zero.
+ */
+export function threadSummary(thread) {
+ const parts = []
+ if (thread.type === 'announcement') parts.push('Announcement')
+ parts.push(thread.author)
+ if (thread.type === 'discussion' && thread.postCount > 1) {
+ const replies = thread.postCount - 1
+ parts.push(`${replies} ${replies === 1 ? 'reply' : 'replies'}`)
+ }
+ if (thread.status === 'hidden') parts.push('hidden')
+ return parts.join(' · ')
+}
diff --git a/client/src/lib/teamIntegrations.js b/client/src/lib/teamIntegrations.js
new file mode 100644
index 0000000..b4198df
--- /dev/null
+++ b/client/src/lib/teamIntegrations.js
@@ -0,0 +1,103 @@
+// What Admin → Teams → Notification bridge decides (TEAMS.md §7.2, phase 8).
+//
+// The view is a form; these are the rules it applies, extracted for the same
+// reason `teamAdmin.js` is: the interesting parts are decisions — when the
+// acknowledgement dialog opens, and when a standing acknowledgement stops being
+// valid — and a decision embedded in JSX is one nothing can assert on.
+//
+// **The rules here MIRROR the server's and do not replace them.** The server
+// refuses to enable a members-only bridge without the acknowledgement (422)
+// whether or not this file ever ran. What is here is so the screen agrees with
+// that answer before making the round trip, rather than showing an operator a
+// save that fails for a reason the form did not mention.
+
+// Wording an operator reads, per event id the server offers. Presentation, so it
+// lives on this side; the one bit that is policy — which events are members-only —
+// comes from the server with each event.
+export const EVENT_LABELS = {
+ 'team.member.joined': 'New members joined',
+ 'team.leadership.changed': 'Leadership changed',
+ 'team.forum.post': 'New forum post',
+ 'team.announcement': 'Announcement posted',
+}
+
+export const eventLabel = (id) => EVENT_LABELS[id] || id
+
+/** A row's identity in a list. `null` and `undefined` are both the default row. */
+export const rowKey = (row) =>
+ (row.team_id === null || row.team_id === undefined ? 'default' : String(row.team_id))
+
+export const isDefaultRow = (row) => row.team_id === null || row.team_id === undefined
+
+export const blankDraft = (teamId = null) => ({
+ teamId,
+ events: [],
+ channelRef: '',
+ enabled: false,
+ membersAck: false,
+})
+
+export const draftFrom = (row) => ({
+ teamId: row.team_id ?? null,
+ events: row.events || [],
+ channelRef: row.channel_ref || '',
+ enabled: !!row.enabled,
+ membersAck: !!row.members_ack,
+})
+
+export function appliesToLabel(row, fallback = 'All Teams') {
+ if (isDefaultRow(row)) return fallback
+ return row.display_name_override || row.team_name || `Team #${row.team_id}`
+}
+
+/** Toggle one event in a draft, preserving order of first selection. */
+export const toggleEvent = (draft, id) => ({
+ ...draft,
+ events: draft.events.includes(id) ? draft.events.filter((e) => e !== id) : [...draft.events, id],
+})
+
+/**
+ * Repointing the row drops a standing acknowledgement, in the SAME place the
+ * server does.
+ *
+ * Leaving the tick showing while the server has already decided to clear it is
+ * the one way this screen could actively mislead: an operator repoints a row at a
+ * public channel, sees "members-only destination confirmed" still ticked, and
+ * believes the confirmation they gave for a private channel covers the new one.
+ */
+export function setChannel(draft, channelRef) {
+ if (channelRef === draft.channelRef) return draft
+ return { ...draft, channelRef, membersAck: false }
+}
+
+/** Does this draft carry anything that would publish members-only text? */
+export const carriesMembersOnly = (draft, membersOnlyIds) =>
+ draft.events.some((id) => membersOnlyIds.includes(id))
+
+/**
+ * Should saving stop and ask first?
+ *
+ * Only when ENABLING. A draft that carries forum events but is switched off is a
+ * configuration being written, not a channel being published to — asking then
+ * would make an operator confirm something they have not decided to do yet, which
+ * is how a confirmation dialog becomes a thing people click through.
+ */
+export const needsAcknowledgement = (draft, membersOnlyIds) =>
+ !!draft.enabled && carriesMembersOnly(draft, membersOnlyIds) && !draft.membersAck
+
+/** The ids of every event the server flagged as members-only. */
+export const membersOnlyIdsOf = (events) => (events || []).filter((e) => e.membersOnly).map((e) => e.id)
+
+/**
+ * Which Teams may still be given an override, and whether the default is taken.
+ *
+ * Offering a Team that already has a row would only produce a save that silently
+ * overwrote it, since the unique key is (platform, team).
+ */
+export function availableTargets(rows, teams) {
+ const taken = new Set(rows.filter((r) => !isDefaultRow(r)).map((r) => r.team_id))
+ return {
+ hasDefault: rows.some(isDefaultRow),
+ teams: (teams || []).filter((t) => t.status === 'active' && !taken.has(t.id)),
+ }
+}
diff --git a/client/src/lib/teamVoice.js b/client/src/lib/teamVoice.js
new file mode 100644
index 0000000..77b75e3
--- /dev/null
+++ b/client/src/lib/teamVoice.js
@@ -0,0 +1,112 @@
+// What Admin → Teams → Voice channels decides (TEAMS.md §7.3, phase 9).
+//
+// Extracted for the reason `teamIntegrations.js` is: the interesting parts are
+// decisions — when the panel refuses to let voice be switched on, how close the
+// guild is to running out of roles, what a row's state actually means to the
+// person reading it — and a decision written inline in JSX is one nothing can
+// assert on.
+//
+// **These rules MIRROR the server's and do not replace them.** The server refuses
+// to enable voice while the bot cannot manage channels and roles (422) whether or
+// not this file ever ran, and the reconciler applies the threshold and the grace
+// window regardless of what the screen says. What is here is so the screen agrees
+// with those answers before making the round trip.
+
+/** Wording for each state the server can report on a row. */
+export const STATE_LABELS = {
+ none: 'Not provisioned',
+ active: 'Active',
+ pending_removal: 'Scheduled for removal',
+ error: 'Error',
+}
+
+export const stateLabel = (state) => STATE_LABELS[state] || state || 'Unknown'
+
+/**
+ * Is the panel allowed to offer the enable switch?
+ *
+ * The preflight answers three separate questions and they fail differently: the
+ * bot is not connected at all, it is connected but missing a permission, or it
+ * could not be reached. An operator can act on each of those and they need
+ * different actions, so the reason is passed through rather than flattened to a
+ * boolean.
+ */
+export function enableBlockedReason(preflight) {
+ if (!preflight) return 'The bot’s status is unknown.'
+ if (!preflight.connected) return preflight.reason || 'The Discord bot is not connected.'
+ if (preflight.missingPermissions && preflight.missingPermissions.length > 0) {
+ return `The bot is missing ${preflight.missingPermissions.join(' and ')} in this guild.`
+ }
+ if (!preflight.ready) return preflight.reason || 'The bot cannot manage channels and roles yet.'
+ return null
+}
+
+// Below this many free roles the panel starts saying so. Not a server rule and
+// deliberately not one: it is a warning, and the server's only hard behaviour is
+// to refuse the create that would exceed the cap.
+const HEADROOM_WARNING = 25
+
+/**
+ * How much room is left, and whether to say something about it.
+ *
+ * The 250-role cap is the ceiling this phase's shape brings with it. Access is a
+ * per-Team role, so it is not "how big can a Team be" — the old overwrite design's
+ * limit — but "how many Teams can have voice at all", and the difference matters
+ * to an operator with sixty guilds on their shard. It is guild-wide and shared
+ * with every role they created themselves, which is why the count comes from the
+ * bot rather than from core's own rows.
+ */
+export function roleHeadroom(preflight) {
+ if (!preflight || !preflight.roleCap) return null
+ const used = Number(preflight.roleCount) || 0
+ const cap = Number(preflight.roleCap)
+ const free = Math.max(0, cap - used)
+ return { used, cap, free, tight: free <= HEADROOM_WARNING, exhausted: free === 0 }
+}
+
+/** How a row's grace window reads while it is running. */
+export function removalCountdown(row, now = new Date()) {
+ if (!row || row.state !== 'pending_removal' || !row.removeAfter) return null
+ const ms = new Date(row.removeAfter).getTime() - now.getTime()
+ if (ms <= 0) return 'due for removal on the next pass'
+ const days = Math.floor(ms / 86400000)
+ if (days >= 1) return `in ${days} day${days === 1 ? '' : 's'}`
+ const hours = Math.max(1, Math.round(ms / 3600000))
+ return `in ${hours} hour${hours === 1 ? '' : 's'}`
+}
+
+/**
+ * Parse the staff-role field an operator types.
+ *
+ * Comma-separated ids, because that is what a person copying role ids out of
+ * Discord ends up with. Validated rather than filtered, mirroring the server: a
+ * quietly dropped id is a settings screen showing a save that did not happen.
+ */
+export function parseStaffRoles(text) {
+ const parts = String(text || '')
+ .split(',')
+ .map((part) => part.trim())
+ .filter(Boolean)
+ const bad = parts.filter((part) => !/^[0-9]{5,32}$/.test(part))
+ return { roles: parts, invalid: bad }
+}
+
+export const formatStaffRoles = (roles) => (roles || []).join(', ')
+
+/**
+ * The sentence under the enable switch, which changes meaning with the state.
+ *
+ * "Off" is not "nothing is provisioned": switching voice off suspends the
+ * reconciler in BOTH directions and leaves existing channels in place, which is
+ * deliberate — a checkbox must not delete structure in somebody's guild — but it
+ * is also surprising unless the screen says so.
+ */
+export function statusSummary(settings, rows) {
+ const provisioned = (rows || []).filter((row) => row.channelRef).length
+ if (!settings || !settings.enabled) {
+ return provisioned > 0
+ ? `Off. ${provisioned} channel${provisioned === 1 ? '' : 's'} remain in Discord and are no longer being kept in step — remove them below if they are not wanted.`
+ : 'Off. No channels are provisioned.'
+ }
+ return `On. Teams with at least ${settings.minMembers} member${settings.minMembers === 1 ? '' : 's'} get a voice channel and a role; ${provisioned} provisioned.`
+}
diff --git a/client/src/main.jsx b/client/src/main.jsx
index c2c3101..ca3dce6 100644
--- a/client/src/main.jsx
+++ b/client/src/main.jsx
@@ -3,7 +3,10 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js'
-import { declareSlot } from './modules/registry.js'
+import { declareSlot, applyCoreFills, offerCoreFill } from './modules/registry.js'
+import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
+import TeamForumPanel from './modules/TeamForumPanel.jsx'
+import TeamNotifyToggle from './modules/TeamNotifyToggle.jsx'
import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
@@ -18,8 +21,6 @@ publishSharedDependencies()
// and namespace `uo`, so that the seam was exercised by real content from the
// day it was built. That prediction paid out exactly as written: the extraction
// deleted the registration and the hook it named, and SiteHeader was not touched.
-// There is nothing for core to register now — no core nav row carries a
-// `feature` — and the filter is a correct no-op until a module supplies one.
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
//
@@ -56,6 +57,49 @@ declareSlot('player.invite.accepted')
// all three, and core's own fills had to go for it to be able to — the first
// fill wins, and core registered first (§3.7).
+// ── The inverted direction: core fills a MODULE's slot ─────────────────────
+//
+// Teams is a contract PRIMITIVE, not a surface (TEAMS.md Part 3). Core owns the
+// tables, the sync, the access rules and the activity feed; it does not own the
+// word for one — a UO shard says guild, and the module that comes after it will
+// say clan. So core publishes no Team page and no Team nav row, and the module
+// that owns the vocabulary owns the page.
+//
+// The activity feed is the one piece of that page core cannot hand over: only
+// core can resolve whether this viewer is inside the Team, and the public/members
+// split is a security boundary. So the module declares the place and core fills
+// it. Registered here, applied at mount — `applyCoreFills` runs after every
+// module chunk has evaluated, which is the only moment a module-declared slot
+// exists to be filled.
+//
+// **Core offers a CONTRIBUTION and never names a slot.** The module that owns the
+// page says where each of these goes, in its own vocabulary, by asking for one on
+// `declareModuleSlot`. Naming the slots here instead — which is how this was first
+// written — meant core's Team content reached exactly one module: any other game
+// declaring a place under its own id got an empty page and no error, because a
+// fill nobody asked for is deliberately not an error. It also put a module id
+// inside core, in string literals `scripts/checkModuleIdentifiers.js` masks by
+// construction and so could never have caught.
+//
+// Offering something nothing asks for is still not an error: a deployment with no
+// game module installed asks for none of these, which is the mirror of an
+// unfilled slot rendering nothing.
+offerCoreFill('team.activity', TeamActivityFeed)
+
+// The forum is core's for the same reason and goes wherever the module asked for
+// it — a SECOND place, in module-uo's case, rather than joining the feed in the
+// first: a slot takes one component (first fill wins), and stacking two unrelated
+// panels into one contribution would make the module unable to place them
+// separately on its own page. It also keeps the two independent — a deployment
+// with the forum switched off renders the feed exactly as before.
+offerCoreFill('team.forum', TeamForumPanel)
+
+// And the notification control. A third contribution rather than a corner of the
+// feed for the same reason there were two: this is an action on the page and the
+// other two are content in it, and only the module can say where each belongs on
+// a page it owns.
+offerCoreFill('team.notify', TeamNotifyToggle)
+
// Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes.
//
@@ -82,6 +126,10 @@ declareSlot('player.invite.accepted')
// static deferred script, so this branch is the genuine "the event has already
// been and gone" case and not a wrong guess about our own timing.
function mount() {
+ // Every module chunk has evaluated by now, so any slot a module declared is
+ // present and core's pending fills can land. Must happen before the first
+ // render: `extensionFor` is read during render and there is no subscription.
+ applyCoreFills()
createRoot(document.getElementById('root')).render(
diff --git a/client/src/modules/TeamActivityFeed.jsx b/client/src/modules/TeamActivityFeed.jsx
new file mode 100644
index 0000000..873d098
--- /dev/null
+++ b/client/src/modules/TeamActivityFeed.jsx
@@ -0,0 +1,96 @@
+import { useEffect, useState } from 'react'
+import { api } from '../api/client.js'
+import { useAuth } from '../contexts/AuthContext.jsx'
+import { activityScopeNote, freshnessNote, groupByDay } from '../lib/teamActivity.js'
+
+// Core's Team activity feed, rendered into a slot a MODULE declares
+// (TEAMS.md Part 4, §3.4 as amended).
+//
+// **This is the inverted slot direction, and this component is why it exists.**
+// The feed is core's: core owns `team_activity`, writes the membership and rename
+// items into it, enforces the public/members split, and is the only thing that
+// can resolve whether this viewer is inside the Team. None of that is a module's
+// to reimplement. But the PAGE is the module's, because Teams is a contract
+// primitive and core does not own the word for one — a UO shard says guild, the
+// next game will say something else. So the module declares the place and core
+// puts the feed in it.
+//
+// The module passes the Team in ITS OWN vocabulary — `externalId` plus its module
+// id — and core resolves the slug. A module never learns core's Team id and never
+// needs to: it names the thing the way it already names it.
+//
+// Everything here degrades to rendering nothing. A slot that throws is contained
+// by core's own boundary (Slot.jsx), but a slot that renders an error box would
+// still be core putting a defect on a page it does not own — so a failed fetch is
+// silence, not a message.
+
+export default function TeamActivityFeed({ externalId, moduleId, limit = 25 }) {
+ const { user } = useAuth()
+ const [state, setState] = useState({ loading: true, feed: null, team: null })
+
+ useEffect(() => {
+ let active = true
+ if (!externalId || !moduleId) {
+ setState({ loading: false, feed: null, team: null })
+ return undefined
+ }
+ // Two calls because the module names the Team its way and the feed is keyed
+ // by core's slug. The lookup is core's job precisely so the module does not
+ // have to hold core's identifiers.
+ api.teamByExternalId(moduleId, externalId)
+ .then(async (team) => {
+ const feed = await api.teamActivity(team.slug, { limit })
+ if (active) setState({ loading: false, feed, team })
+ })
+ .catch(() => { if (active) setState({ loading: false, feed: null, team: null }) })
+ return () => { active = false }
+ }, [externalId, moduleId, limit])
+
+ const { loading, feed, team } = state
+ if (loading || !feed) return null
+
+ const days = groupByDay(feed.items || [])
+ const note = team ? freshnessNote(team) : null
+ const scopeNote = activityScopeNote(feed, Boolean(user))
+
+ // Nothing has happened and nothing to explain: render nothing rather than an
+ // empty heading on someone else's page.
+ if (days.length === 0 && !scopeNote) return null
+
+ return (
+
+
+ Recent activity
+
+ {note && (
+
{note.text}
+ )}
+
+ {days.length === 0 && (
+
Nothing has happened here yet.
+ )}
+
+ {days.map((day) => (
+
+
+ {day.label}
+
+
+ {day.items.map((item) => (
+
+ {item.summary}
+
+ ))}
+
+
+ ))}
+
+ {scopeNote && (
+
{scopeNote}
+ )}
+
+ )
+}
diff --git a/client/src/modules/TeamForumPanel.jsx b/client/src/modules/TeamForumPanel.jsx
new file mode 100644
index 0000000..5b2ea9f
--- /dev/null
+++ b/client/src/modules/TeamForumPanel.jsx
@@ -0,0 +1,754 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useSearchParams } from 'react-router-dom'
+import DOMPurify from 'dompurify'
+import { api } from '../api/client.js'
+import { useAuth } from '../contexts/AuthContext.jsx'
+import { useSite } from '../contexts/SiteContext.jsx'
+import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../lib/teamForum.js'
+
+// Core's Team forum, rendered into a second slot a MODULE declares
+// (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
+//
+// **Why the forum is core's content on a module's page.** Everything that decides
+// who may read a thread is core's — the §2.5 resolver, the grants ledger, the
+// member/guest distinction — and none of it is a module's to reimplement. But
+// core does not own the word for a Team, so it publishes no Team page: the module
+// that says "guild" owns the page and declares a place on it, and core fills the
+// place. Same direction as the activity feed, same reason.
+//
+// **It is a whole forum inside one slot, and navigates by SEARCH PARAM.** A
+// thread needs to be linkable, and core cannot mount a route for it — the route
+// belongs to the module's page. `?thread=12` gives a shareable URL that works
+// under whatever path the module chose, with no route of core's anywhere in it,
+// and the browser's back button behaves. That is the whole reason this component
+// holds a list view and a detail view rather than being two components.
+//
+// **The image mode is published so this can draw the right composer — never to
+// decide what renders.** Post bodies arrive already rendered by the server under
+// the current policy (§5.5.3); the mode is read here only to show or hide an
+// upload control that would otherwise 404. If the two ever disagree, the server
+// is right.
+//
+// **Phase 5 added discussion, and with it three capabilities this file must not
+// invent for itself.** `canPost`, `canAnnounce` and each post's `canEdit` are
+// computed on the server and read here. In particular the edit window is a
+// server decision twice over — the read path stamps `canEdit`/`editableUntil` and
+// the write re-derives it — because a time-bounded permission must not take its
+// clock from the party it bounds. What this file does with `editableUntil` is
+// stop OFFERING an edit whose deadline has passed while the page sat open; it
+// never grants one.
+//
+// Like the feed, everything here degrades to rendering nothing. A 404 from the
+// thread list is the ordinary case — the forum is switched off, or this viewer
+// has no access — and putting an error box on a page core does not own would be
+// core reporting its own absence as a defect on someone else's surface.
+
+export default function TeamForumPanel({ externalId, moduleId }) {
+ const { user } = useAuth()
+ const { settings } = useSite()
+ const [params, setParams] = useSearchParams()
+ const [team, setTeam] = useState(null)
+ const [state, setState] = useState({ loading: true, forum: null })
+ const [thread, setThread] = useState(null)
+ const [composing, setComposing] = useState(null) // 'discussion' | 'announcement' | null
+
+ const openThreadId = params.get('thread')
+ const imageMode = settings?.teams_forum_images || 'disabled'
+ const forumsEnabled = String(settings?.teams_forums_enabled ?? '0') === '1'
+
+ const loadThreads = useCallback(async (slug) => {
+ try {
+ setState({ loading: false, forum: await api.teamForumThreads(slug) })
+ } catch {
+ setState({ loading: false, forum: null })
+ }
+ }, [])
+
+ const loadThread = useCallback(async (slug, id) => {
+ try {
+ setThread(await api.teamForumThread(slug, id))
+ } catch {
+ setThread(null)
+ }
+ }, [])
+
+ useEffect(() => {
+ let active = true
+ // An anonymous visitor has no forum by definition — every route is behind
+ // requireAuth — so skip the two calls rather than provoking a 401 per page.
+ if (!externalId || !moduleId || !user || !forumsEnabled) {
+ setState({ loading: false, forum: null })
+ return undefined
+ }
+ // The module names the Team its own way; core resolves that to a slug. Same
+ // two-call shape as the activity feed, and for the same reason: a module
+ // never has to hold core's identifiers.
+ api.teamByExternalId(moduleId, externalId)
+ .then(async (found) => {
+ if (!active) return
+ setTeam(found)
+ await loadThreads(found.slug)
+ })
+ .catch(() => { if (active) setState({ loading: false, forum: null }) })
+ return () => { active = false }
+ }, [externalId, moduleId, user, forumsEnabled, loadThreads])
+
+ useEffect(() => {
+ let active = true
+ if (!team || !openThreadId) {
+ setThread(null)
+ return undefined
+ }
+ api.teamForumThread(team.slug, openThreadId)
+ .then((t) => { if (active) setThread(t) })
+ .catch(() => { if (active) setThread(null) })
+ return () => { active = false }
+ }, [team, openThreadId])
+
+ const openThread = (id) => {
+ const next = new URLSearchParams(params)
+ if (id == null) next.delete('thread')
+ else next.set('thread', String(id))
+ setParams(next)
+ }
+
+ const { loading, forum } = state
+ if (loading || !forum) return null
+
+ if (openThreadId && thread) {
+ return (
+ openThread(null)}
+ onChanged={() => loadThread(team.slug, thread.id)}
+ onModerate={async (action) => {
+ await api.teamForumModerate(team.slug, thread.id, { action })
+ await loadThreads(team.slug)
+ openThread(null)
+ }}
+ />
+ )
+ }
+
+ return (
+
+
+
+ Forum
+
+ {!composing && (
+
+ {/*
+ Two buttons, because phase 5 split one capability in two. `canPost`
+ means "may open a discussion" and every participant may — including a
+ granted guest with no game character, which is path 3 doing its job.
+ `canAnnounce` is the leader-only half.
+ */}
+ {forum.canPost && (
+
+ )}
+ {forum.canAnnounce && (
+
+ )}
+
+
+ )
+}
+
+/**
+ * The leader's grant control — §2.5 path 3, exercised by a leader rather than by
+ * staff.
+ *
+ * Worth being explicit about what this admits someone to and what it does not: a
+ * grant may name ANY account, including one with no linked game character, and it
+ * writes nothing but the grants ledger. A guest here never appears on the roster,
+ * never counts towards the Team's membership, and never becomes eligible for a
+ * Discord role — an integration cannot verify that an unlinked account is a real
+ * game member, so it must not hand that account a privilege somewhere
+ * impersonation has consequences.
+ *
+ * A leader is capped; staff are not. The cap is shown rather than only enforced,
+ * because a leader who hits a limit they were never told about reads it as a bug.
+ */
+function GuestManager({ slug }) {
+ const [open, setOpen] = useState(false)
+ const [data, setData] = useState(null)
+ const [username, setUsername] = useState('')
+ const [error, setError] = useState(null)
+
+ const load = useCallback(async () => {
+ try {
+ setData(await api.teamGrantList(slug))
+ } catch {
+ setData(null)
+ }
+ }, [slug])
+
+ useEffect(() => { if (open) load() }, [open, load])
+
+ const add = async (event) => {
+ event.preventDefault()
+ setError(null)
+ try {
+ await api.teamGrantAdd(slug, { username })
+ setUsername('')
+ await load()
+ } catch (err) {
+ setError(err.message || 'Could not grant access')
+ }
+ }
+
+ const revoke = async (userId) => {
+ setError(null)
+ try {
+ await api.teamGrantRevoke(slug, userId)
+ await load()
+ } catch (err) {
+ setError(err.message || 'Could not revoke that')
+ }
+ }
+
+ if (!open) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
Forum guests
+
+
+
+ Guests read and post in this forum without being members of the Team. They do not appear on the
+ roster and are not counted as members.
+ {data?.cap ? ` Up to ${data.cap} at a time.` : ''}
+
+
+
+ {(data?.guests || []).map((g) => (
+
+ {g.username}
+
+
+ ))}
+ {data && data.guests.length === 0 && (
+
No guests yet.
+ )}
+
+
+
+ {error &&
{error}
}
+
+ )
+}
+
+function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onModerate }) {
+ // A clock that ticks, so an edit control whose deadline passed while the page
+ // sat open goes away instead of becoming a button that fails. It only ever
+ // REMOVES an offer — the server decides whether an edit happens, and re-derives
+ // the window from created_at when it does.
+ const [now, setNow] = useState(() => Date.now())
+ useEffect(() => {
+ const id = setInterval(() => setNow(Date.now()), 30_000)
+ return () => clearInterval(id)
+ }, [])
+
+ const [replying, setReplying] = useState(false)
+
+ return (
+
+
+
+
+ {thread.posts.map((post) => (
+
+ ))}
+
+ {/*
+ `canReply` is the server's answer to "does this thread take replies right
+ now", and it folds together the two reasons it might not: an announcement
+ takes none by TYPE, and a locked thread takes none by STATE. Both are
+ reported separately above so the reader can see which.
+ */}
+ {thread.canReply && !replying && (
+
+ )}
+ {thread.canReply && replying && (
+ setReplying(false)}
+ onPosted={async () => {
+ setReplying(false)
+ await onChanged()
+ }}
+ />
+ )}
+ {!thread.canReply && thread.locked && (
+
+ This thread is locked. Nobody can reply to it, including staff — a moderator who wants the
+ last word unlocks it first, which leaves a record.
+
+ )}
+
+
+
+ {canModerate && (
+ <>
+
+
+
+ >
+ )}
+
+
+ )
+}
+
+/**
+ * One post, with whatever this reader may do to it.
+ *
+ * Every capability shown here was decided by the server and is read, not
+ * computed: `canEdit` and `editableUntil` come stamped on the post, and
+ * `canModerate` on the thread. The one local judgement is whether an
+ * already-granted edit window has since elapsed, which can only take an offer
+ * away.
+ */
+function PostView({ slug, post, canModerate, now, onChanged }) {
+ const [editing, setEditing] = useState(false)
+ const [body, setBody] = useState('')
+ const [error, setError] = useState(null)
+ const [busy, setBusy] = useState(false)
+
+ const stillEditable = useMemo(() => editOfferOpen(post, now), [post, now])
+
+ const save = async (event) => {
+ event.preventDefault()
+ setBusy(true)
+ setError(null)
+ try {
+ await api.teamForumEditPost(slug, post.id, { body })
+ setEditing(false)
+ await onChanged()
+ } catch (err) {
+ setError(err.message || 'Could not save that')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const moderate = async (action) => {
+ setError(null)
+ try {
+ await api.teamForumModeratePost(slug, post.id, { action })
+ await onChanged()
+ } catch (err) {
+ setError(err.message || 'Could not do that')
+ }
+ }
+
+ return (
+
+
+
+ {editing ? (
+
+ ) : (
+ <>
+ {/*
+ Sanitised on write with the forum's own profile, rendered server-side
+ under the operator's image policy, and re-sanitised here — the same
+ defence-in-depth every other body-HTML surface on this site applies
+ (FiveOnFriday, NewsletterIssue, the rich-text block).
+
+ `ADD_ATTR: ['referrerpolicy']` is load-bearing and not a preference.
+ DOMPurify's default allowlist carries `loading` but NOT
+ `referrerpolicy`, so a plain sanitize() call silently strips the one
+ attribute that limits what a remote embed leaks to the host serving it
+ — the privacy property the admin help text promises an operator. The
+ itself is core's own output with a fixed attribute set, so
+ nothing here is widening what an author can write.
+ */}
+ {/* eslint-disable-next-line react/no-danger */}
+
+ >
+ )}
+
+ {error &&
{error}
}
+
+ {!editing && (
+
+ {stillEditable && (
+
+ )}
+ {/* Reporting your own post is pointless rather than harmful, but
+ offering it reads as an invitation to misunderstand the control. */}
+ {!post.mine && (
+
+ )}
+ {canModerate && (
+ <>
+
+
+ >
+ )}
+
+ )}
+
+ )
+}
+
+/**
+ * The report control — the first user-facing report flow this site has ever had.
+ *
+ * **It goes to site staff, and it says so.** The gap it closes is that leaders
+ * moderate their own Team's forum and a Team's leaders are exactly the people who
+ * will not report their own Team, so telling a member where the report lands is
+ * not reassurance copy — it is the whole reason the control is worth using in a
+ * Team whose leadership is the problem.
+ *
+ * A report changes nothing about the content, and the confirmation says that too,
+ * because a member who expects a post to vanish and watches it stay will report
+ * it again.
+ */
+function ReportControl({ slug, targetType, targetId, label }) {
+ const [open, setOpen] = useState(false)
+ const [reason, setReason] = useState('abuse')
+ const [detail, setDetail] = useState('')
+ const [done, setDone] = useState(false)
+ const [error, setError] = useState(null)
+ const [busy, setBusy] = useState(false)
+
+ const submit = async (event) => {
+ event.preventDefault()
+ setBusy(true)
+ setError(null)
+ try {
+ await api.teamForumReport(slug, { targetType, targetId, reason, detail: detail || undefined })
+ setDone(true)
+ setOpen(false)
+ } catch (err) {
+ setError(err.message || 'Could not send that')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ if (done) {
+ return (
+
+ Reported to site staff.
+
+ )
+ }
+
+ if (!open) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+/** A reply to an open discussion thread. */
+function ReplyBox({ slug, threadId, imageMode, onCancel, onPosted }) {
+ const [body, setBody] = useState('')
+ const [error, setError] = useState(null)
+ const [busy, setBusy] = useState(false)
+
+ const submit = async (event) => {
+ event.preventDefault()
+ setBusy(true)
+ setError(null)
+ try {
+ await api.teamForumReply(slug, threadId, { body })
+ await onPosted()
+ } catch (err) {
+ setError(err.message || 'Could not post that')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+/**
+ * The upload control, shared by both composers.
+ *
+ * The URL goes into the BODY as text, never as an `` tag. The author never
+ * writes markup here — core decides at render time whether a URL becomes a
+ * picture, which is what makes the operator's image policy enforceable rather
+ * than decorative.
+ */
+function ImageAttacher({ slug, onAttached, onError }) {
+ const attach = async (event) => {
+ const file = event.target.files?.[0]
+ if (!file) return
+ try {
+ const { url } = await api.teamForumUpload(slug, file)
+ onAttached(url)
+ } catch (err) {
+ onError(err.message || 'Could not upload that')
+ }
+ }
+
+ return (
+
+ )
+}
+
+function Composer({ slug, type, imageMode, onCancel, onPosted }) {
+ const [title, setTitle] = useState('')
+ const [body, setBody] = useState('')
+ const [error, setError] = useState(null)
+ const [busy, setBusy] = useState(false)
+
+ const isAnnouncement = type === 'announcement'
+
+ const submit = async (event) => {
+ event.preventDefault()
+ setBusy(true)
+ setError(null)
+ try {
+ // `type` is always sent explicitly. The server defaults an absent one to
+ // `announcement` so that a phase-4 client keeps meaning what it meant, and
+ // relying on that default here would make a discussion depend on a
+ // compatibility shim.
+ await api.teamForumPost(slug, { type, title, body })
+ await onPosted()
+ } catch (err) {
+ setError(err.message || 'Could not post that')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/client/src/modules/TeamNotifyToggle.jsx b/client/src/modules/TeamNotifyToggle.jsx
new file mode 100644
index 0000000..e48c8e6
--- /dev/null
+++ b/client/src/modules/TeamNotifyToggle.jsx
@@ -0,0 +1,102 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { api } from '../api/client.js'
+import { useAuth } from '../contexts/AuthContext.jsx'
+
+// Core's per-Team notification control, rendered into a THIRD slot a module
+// declares (TEAMS.md §6.3, phase 6).
+//
+// **Why this is a slot at all, and why it is the third one.** Teams have no core
+// page — the module that owns the vocabulary owns the page — so a control that
+// acts on one Team has nowhere of core's to live. The feed and the forum go below
+// the module's roster; this goes above it, because muting a guild is an action ON
+// the page rather than more content in it, and that is exactly the placement
+// decision a module cannot make if core stacks everything into one fill.
+//
+// **It renders nothing for a viewer who is not in the Team**, including anonymous
+// ones, and that is a privacy property rather than a tidiness one: whether a
+// notification preference EXISTS for a Team answers "is this person in it", and
+// the guild page is public. The server decides — the preference list only contains
+// Teams the caller may be notified about — and this file never infers membership
+// from anything it can see on the page.
+//
+// **Muting is per-Team and covers all four streams.** The per-stream on/off lives
+// on the account screen, where the catalog does; the thing that could not be
+// expressed before phase 6 is "I am in five Teams and want notifications from
+// one", and that is the only question this control asks.
+
+export default function TeamNotifyToggle({ externalId, moduleId }) {
+ const { user } = useAuth()
+ const [state, setState] = useState({ loading: true, team: null, pref: null })
+ const [busy, setBusy] = useState(false)
+
+ const load = useCallback(async () => {
+ // Anonymous viewers never fetch. The endpoint would 401 harmlessly, but a
+ // guild page rendering a public roster should not put an authenticated
+ // request on the wire for every visitor.
+ if (!user) return setState({ loading: false, team: null, pref: null })
+ try {
+ const team = await api.teamByExternalId(moduleId, externalId)
+ const { teams } = await api.teamNotificationPrefs()
+ const pref = (teams || []).find((t) => t.teamId === team.id) || null
+ setState({ loading: false, team, pref })
+ } catch {
+ // Same rule as the feed and the forum: this is core's content on a page
+ // core does not own, so a failure renders nothing rather than putting an
+ // error box on somebody else's surface.
+ setState({ loading: false, team: null, pref: null })
+ }
+ }, [externalId, moduleId, user])
+
+ useEffect(() => { load() }, [load])
+
+ const { loading, pref } = state
+ if (loading || !pref) return null
+
+ async function toggle() {
+ setBusy(true)
+ // Optimistic, and reconciled from the server's echo rather than assumed: a
+ // PUT that silently dropped the entry (a Team left in another tab) must not
+ // leave the control claiming a state the server does not hold.
+ const next = { ...pref, muted: !pref.muted }
+ setState((s) => ({ ...s, pref: next }))
+ try {
+ const { teams } = await api.setTeamNotificationPrefs([
+ { teamId: pref.teamId, muted: next.muted, emailMode: pref.emailMode },
+ ])
+ const echoed = (teams || []).find((t) => t.teamId === pref.teamId)
+ if (echoed) setState((s) => ({ ...s, pref: echoed }))
+ } catch {
+ setState((s) => ({ ...s, pref }))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+
+ {pref.muted
+ ? 'You get no notifications about this team.'
+ : 'You get notifications about this team.'}
+
+ {/* The one link off this control, because "mute" is a blunt answer to a
+ question the account screen asks properly — which streams, and whether
+ email is on at all. */}
+ All notification settings
+
+ )
+}
diff --git a/client/src/modules/registry.js b/client/src/modules/registry.js
index d6783f5..c5a89dc 100644
--- a/client/src/modules/registry.js
+++ b/client/src/modules/registry.js
@@ -135,6 +135,120 @@ export function declareSlot(name) {
slots.set(name, { Component: null, filledBy: null })
}
+/**
+ * The contributions core has for a module-declared slot.
+ *
+ * **Core offers a CONTRIBUTION, not a slot name, and that is the whole of why
+ * this list exists.** The first cut of the inverted direction had core fill three
+ * literal names — `uo.guild.detail` and its two siblings — which worked for
+ * exactly one module and silently did nothing for any other: a second game
+ * declaring `clan.detail` under its own id got an empty page and no error,
+ * because "a fill for a slot nobody declared is not an error" is the rule that
+ * makes an unknown name invisible. It also put a module identifier in core, in
+ * three string literals `scripts/checkModuleIdentifiers.js` cannot see, since it
+ * masks string bodies by construction.
+ *
+ * So the module says WHERE (its own slot, in its own vocabulary) and WHICH of
+ * core's contributions goes there. Core never names a module id.
+ *
+ * Adding a member here is a **minor** MODULE_API bump. Requesting one that is not
+ * here THROWS at the declaration, deliberately: unlike an unfilled slot, an
+ * unknown contribution is always a typo or a version skew — core's list is fixed
+ * at build time and a module's `coreApi` range has already been checked — and the
+ * failure it would otherwise produce is a page that renders empty forever.
+ */
+export const CORE_CONTRIBUTIONS = Object.freeze({
+ /** The Team activity feed. Core's because only core can resolve the public/members split on it. */
+ 'team.activity': true,
+ /** The Team forum panel. Core's because membership and manual grants are core's rules. */
+ 'team.forum': true,
+ /** The per-Team notification control. Core's because it resolves whether the viewer is in the Team. */
+ 'team.notify': true,
+})
+
+/**
+ * The INVERTED direction: a MODULE declares a slot and CORE fills it.
+ *
+ * Added for Teams (TEAMS.md Part 3). The original direction assumes core owns
+ * the page and a module contributes to it, which is right for the footer and the
+ * admin user detail. Teams is the other shape: **Teams is a contract primitive,
+ * not a surface.** Core owns the tables, the sync, the access rules and the
+ * activity feed; it does not own the vocabulary — a UO shard calls them guilds
+ * and the next game will call them something else — so the PAGE is the module's
+ * and the content core contributes to it is core's.
+ *
+ * Without this, core would have to publish a `/teams` page under a word it
+ * invented, next to the module's own Guilds page saying the same thing twice.
+ *
+ * A module namespaces its slot under its own id (`uo.guild.detail`), which is
+ * what stops two modules colliding and what makes the owner readable at the fill
+ * site. The namespace is enforced rather than conventional.
+ *
+ * **`options.core` names which of core's contributions belongs in that place.**
+ * It is optional — a module may declare a slot it fills itself, or one it keeps
+ * empty for now — and it is the only thing that gets core's content into the
+ * page. The place name stays the module's own word; the contribution is core's.
+ *
+ * **Ordering is why this is a separate call and not just `declareSlot` exposed
+ * to modules.** Core's bundle evaluates BEFORE any module chunk (module scripts
+ * are deferred and injected after core's), so at the moment core would like to
+ * fill one of these, it does not exist yet. Core therefore offers its
+ * contributions through `offerCoreFill` below, applied after every module chunk
+ * has evaluated — see main.jsx.
+ */
+export function declareModuleSlot(id, name, options = {}) {
+ if (!name.startsWith(`${id}.`)) {
+ throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
+ }
+ if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`)
+ const contribution = options.core ?? null
+ if (contribution !== null && !Object.hasOwn(CORE_CONTRIBUTIONS, contribution)) {
+ throw new Error(
+ `declareModuleSlot: "${name}" asks for core contribution "${contribution}", which core does not ` +
+ `offer. Known: ${Object.keys(CORE_CONTRIBUTIONS).join(', ')}.`,
+ )
+ }
+ slots.set(name, { Component: null, filledBy: null, declaredBy: id, wants: contribution })
+}
+
+// Core's pending contributions, applied once every module chunk has evaluated.
+// Kept as a list rather than applied eagerly because no module-declared slot
+// exists when core offers — see the ordering note above.
+const coreFills = []
+
+/**
+ * Core: "here is my , for whichever module asked for it."
+ *
+ * Deliberately not an error when nothing asked. A deployment with no game module
+ * installed asks for none of these, and core offering content for a page that
+ * does not exist is the ordinary case rather than a misconfiguration — the mirror
+ * of an unfilled slot rendering nothing.
+ *
+ * More than one slot may ask for the same contribution, and each gets it. Core
+ * has no reason to care how many places a module wants its feed in, and refusing
+ * the second would be core making a layout decision on a page it does not own.
+ */
+export function offerCoreFill(contribution, Component) {
+ if (!Object.hasOwn(CORE_CONTRIBUTIONS, contribution)) {
+ throw new Error(`offerCoreFill: "${contribution}" is not in CORE_CONTRIBUTIONS`)
+ }
+ if (typeof Component !== 'function') throw new Error(`offerCoreFill: ${contribution} is not a component`)
+ coreFills.push([contribution, Component])
+}
+
+/** Apply core's contributions. Called once from main.jsx, after module chunks have run. */
+export function applyCoreFills() {
+ for (const [contribution, Component] of coreFills) {
+ for (const entry of slots.values()) {
+ if (entry.wants !== contribution) continue
+ if (entry.filledBy) continue // a module already claimed it; first fill wins
+ entry.Component = Component
+ entry.filledBy = 'core'
+ }
+ }
+ coreFills.length = 0
+}
+
/**
* Fill a declared slot with a component.
*
@@ -207,6 +321,7 @@ export function _reset() {
nav[area].length = 0
}
providers.clear()
+ coreFills.length = 0
// Declarations go too, unlike the server's, where a slot is declared once at
// require time by the router that owns it. Core declares its slots in
// main.jsx — the one file no test loads — so on this side there is nothing
@@ -224,6 +339,8 @@ export const registry = {
registerNav,
registerFeatureProvider,
registerExtension,
+ // The inverted direction (TEAMS.md Part 3): the module declares, core fills.
+ declareModuleSlot,
routesFor,
navFor,
featureProviderFor,
diff --git a/client/src/modules/shared.js b/client/src/modules/shared.js
index 5deb940..4c78d3a 100644
--- a/client/src/modules/shared.js
+++ b/client/src/modules/shared.js
@@ -34,6 +34,7 @@ import { MODULE_API_VERSION } from './version.js'
import PublicLayout from '../components/PublicLayout.jsx'
import PageHeader from '../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
+import Slot from './Slot.jsx'
import { useAsync } from '../lib/useAsync.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
@@ -66,6 +67,13 @@ const ui = {
useAsync,
useAuth,
useSite,
+ // The ninth member, for the INVERTED slot direction (TEAMS.md Part 3). A
+ // module that declares a slot on its own page needs the same component core
+ // renders its own with — the error boundary in particular, since the thing
+ // being contained here is CORE's content failing inside the MODULE's page.
+ // Shared rather than reimplemented for the reason the whole kit exists: two
+ // boundaries with different behaviour would be two bugs.
+ Slot,
}
// The request PRIMITIVE, not the `api` object (§3.5): a module builds its own
diff --git a/client/src/modules/version.js b/client/src/modules/version.js
index 73934de..c0c833a 100644
--- a/client/src/modules/version.js
+++ b/client/src/modules/version.js
@@ -11,6 +11,13 @@
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
+// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Nothing on this half
+// changed yet: the two client additions the version covers are the `team.overview`
+// and `team.member.row` slots, and a slot can only be declared by the page that
+// hosts it, which lands with the Team pages in phase 3. This file bumps anyway,
+// for the reason at the top — the two halves state ONE version, and a module
+// declares one `coreApi` range against both.
+//
// 1.5.0 — `PublicLayout` takes an optional `shell` prop ('narrow' | 'mid' |
// 'wide') that renders the `shell-… page-body` wrapper core's own pages write by
// hand. Additive: omitting it is 1.4.0's behaviour, so §3.4's "changing a kit
@@ -38,4 +45,4 @@
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
-export const MODULE_API_VERSION = '1.5.0'
+export const MODULE_API_VERSION = '1.6.0'
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index c7b9136..db4def1 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -76,6 +76,17 @@ export const NAV = [
items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
+ // Member-raised reports (TEAMS.md §5.6). Here rather than under Teams
+ // because a staffer working a queue should have one place to work — and
+ // because the queue is deliberately generic, so the next thing that can
+ // be reported arrives as a row rather than as another nav entry.
+ { to: '/admin/moderation/reports', label: 'Reports', icon: IconShield, roles: ['admin', 'moderator'] },
+ // Moderation rather than System: the screen's daily job is the
+ // reserved-name review queue, which is moderator work. The three actions
+ // that publish a game-written name are gated to admins server-side, so a
+ // moderator reaching this screen is correct — what they do here is file a
+ // request (TEAMS.md §2.9).
+ { to: '/admin/teams', label: 'Teams', icon: IconUsers, roles: ['admin', 'moderator'] },
],
},
{
@@ -134,6 +145,7 @@ const TITLES = {
'/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation',
'/admin/moderation/appeals': 'Appeals',
+ '/admin/moderation/reports': 'Reports',
'/admin/settings': 'Site Settings',
'/admin/appearance': 'Appearance',
'/admin/navigation': 'Navigation',
diff --git a/client/src/routes/admin/views/ContentReports.jsx b/client/src/routes/admin/views/ContentReports.jsx
new file mode 100644
index 0000000..9f780a6
--- /dev/null
+++ b/client/src/routes/admin/views/ContentReports.jsx
@@ -0,0 +1,310 @@
+import { useCallback, useState } from 'react'
+import Modal from '../../../components/Modal.jsx'
+import { Loading, ErrorState } from '../../../components/PageState.jsx'
+import { useAsync } from '../../../lib/useAsync.js'
+import { ago, dateTime } from '../../../lib/format.js'
+import { api } from '../../../api/client.js'
+
+// The member-raised content-report queue (TEAMS.md §5.6).
+//
+// **This is the only view of this queue, and that is the design.** The gap §5.6
+// exists to close has a specific shape: leaders moderate their own Team's forum,
+// and a Team's leaders are exactly the people who will not report their own Team.
+// A leader-visible queue would route a complaint about a leader back to that
+// leader. Org lead, 2026-08-18: reports are **site administration only**. If a
+// leader-facing view is ever wanted it is a design decision, not a component.
+//
+// It sits beside Appeals rather than under Teams because a staffer working a
+// queue should have one place to work — and because `target_type` is deliberately
+// open-ended, so the next consumer (a wiki page, a news comment) arrives as a new
+// row here rather than as a new screen.
+//
+// **Handling a report is bookkeeping about the REPORT, not moderation of the
+// content.** Acting on the content itself is the ordinary forum moderation
+// control, or a site-wide sanction against the account. Keeping those separate is
+// what stops "report" from becoming a way for any member to hide anything, so
+// this screen deliberately offers no hide/delete button of its own.
+
+const STATUS_TABS = [
+ { key: 'open_work', label: 'Open work', param: undefined },
+ { key: 'open', label: 'Open', param: 'open' },
+ { key: 'reviewing', label: 'Reviewing', param: 'reviewing' },
+ { key: 'actioned', label: 'Actioned', param: 'actioned' },
+ { key: 'dismissed', label: 'Dismissed', param: 'dismissed' },
+ { key: 'all', label: 'All', param: 'all' },
+]
+
+const STATUS_STYLE = {
+ open: { color: '#e0b070', background: 'rgba(224,176,112,0.12)', border: '1px solid rgba(224,176,112,0.4)' },
+ reviewing: { color: '#7fa8d0', background: 'rgba(127,168,208,0.14)', border: '1px solid rgba(127,168,208,0.4)' },
+ actioned: { color: '#7fd0a4', background: 'rgba(95,185,138,0.16)', border: '1px solid rgba(95,185,138,0.4)' },
+ dismissed: { color: '#9fb0c6', background: 'rgba(127,153,189,0.14)', border: '1px solid var(--line)' },
+}
+const STATUS_LABEL = {
+ open: 'Open', reviewing: 'Reviewing', actioned: 'Actioned', dismissed: 'Dismissed',
+}
+
+const REASON_LABEL = {
+ spam: 'Spam',
+ abuse: 'Abuse',
+ sexual: 'Sexual',
+ illegal: 'Illegal',
+ impersonation: 'Impersonation',
+ other: 'Other',
+}
+
+const bytes = (n) => {
+ if (!n && n !== 0) return ''
+ if (n < 1024) return `${n} B`
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`
+}
+
+/**
+ * What was reported, rendered from the row the queue already resolved.
+ *
+ * Nothing here fetches: §5.6's fourth rule is that a staffer sees uploader, size
+ * and sniffed type without hunting, and the server attaches all of it in three
+ * batched reads. A `null` target is a target that has since been hard-deleted,
+ * and the row still shows — "somebody reported this and by the time we looked it
+ * was gone" is a fact worth seeing, and dropping it would hide the pattern of a
+ * member deleting their own content the moment it is reported.
+ */
+function TargetCell({ report }) {
+ const t = report.target
+ if (!t) {
+ return (
+
+ {report.targetType.replace('team_forum_', '')} #{report.targetId} — no longer exists
+
+ )
+ }
+ if (t.kind === 'upload') {
+ return (
+
+ {t.filename}
+
+ {t.uploader || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
+ {t.deleted && ' · removed'}
+
+
+ )
+ }
+ if (t.kind === 'thread') {
+ return (
+
+ {t.title}
+
+ {t.type} by {t.author || 'unknown'}
+ {t.status !== 'visible' && ` · ${t.status}`}
+
+
+ )
+ }
+ return (
+
+ {t.excerpt || (no text)}
+
+ {t.author || 'unknown'} in “{t.threadTitle}”
+ {t.status !== 'visible' && ` · ${t.status}`}
+
+
+ )
+}
+
+export default function ContentReports() {
+ const [tab, setTab] = useState('open_work')
+ const [tick, setTick] = useState(0)
+ const reload = useCallback(() => setTick((t) => t + 1), [])
+ const [handling, setHandling] = useState(null)
+ const [notice, setNotice] = useState(null)
+
+ const activeTab = STATUS_TABS.find((t) => t.key === tab) || STATUS_TABS[0]
+ const { loading, error, data } = useAsync(
+ () => api.admin.contentReports({ status: activeTab.param }),
+ [tab, tick],
+ )
+
+ if (loading) return
+ if (error) return
+
+ const rows = data?.reports || []
+
+ return (
+
+
+ Reports raised by members about Team forum content. They come to site staff and are not visible
+ to a Team’s own leaders — a leader moderates their own forum, so a report about a leader
+ has to reach someone above them. Handling a report records a decision about the report; hiding
+ or removing the content itself is done from the forum, or as a sanction against the account.
+ {typeof data?.openCount === 'number' && ` ${data.openCount} open.`}
+
+
+ {handling && (
+ setHandling(null)}
+ onDone={() => {
+ setHandling(null)
+ setNotice({ text: 'Report updated.', tone: 'ok' })
+ reload()
+ }}
+ onError={(message) => setNotice({ text: message, tone: 'error' })}
+ />
+ )}
+
+ )
+}
+
+/**
+ * Record a decision about a report.
+ *
+ * The note is optional and worth writing: every transition is audited, dismissals
+ * included, and the note is what the next staffer to see a repeat report about the
+ * same content reads to find out why the last one was closed.
+ */
+function HandleModal({ report, onCancel, onDone, onError }) {
+ const [status, setStatus] = useState(report.status === 'open' ? 'reviewing' : 'actioned')
+ const [note, setNote] = useState('')
+ const [busy, setBusy] = useState(false)
+
+ const submit = async () => {
+ setBusy(true)
+ try {
+ await api.admin.handleContentReport(report.id, { status, note: note || undefined })
+ onDone()
+ } catch (err) {
+ onError(err.message || 'Could not update that report.')
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+
+ >
+ )}
+ >
+
+
+ This records a decision about the report. It does not hide, delete or restore the content —
+ do that from the forum itself, or against the account.
+
+
+ )
+}
+
+const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
+const muted = { color: 'var(--muted)' }
diff --git a/client/src/routes/admin/views/SettingsAdmin.jsx b/client/src/routes/admin/views/SettingsAdmin.jsx
index 711d2b3..298a254 100644
--- a/client/src/routes/admin/views/SettingsAdmin.jsx
+++ b/client/src/routes/admin/views/SettingsAdmin.jsx
@@ -3,6 +3,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
import EmailDelivery from './EmailDelivery.jsx'
+import TeamForumSettings from './TeamForumSettings.jsx'
// Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor).
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
@@ -143,6 +144,8 @@ export default function SettingsAdmin() {
+
+
)
diff --git a/client/src/routes/admin/views/TeamForumSettings.jsx b/client/src/routes/admin/views/TeamForumSettings.jsx
new file mode 100644
index 0000000..9e19492
--- /dev/null
+++ b/client/src/routes/admin/views/TeamForumSettings.jsx
@@ -0,0 +1,276 @@
+import { useEffect, useState } from 'react'
+import { api } from '../../../api/client.js'
+import { useSite } from '../../../contexts/SiteContext.jsx'
+
+// The operator's Team-forum controls (TEAMS.md §5.5, plus phase 5's edit window),
+// and the acknowledgement.
+//
+// Its own panel rather than two more rows in SettingsAdmin's FIELDS table, for the
+// same reason EmailDelivery is its own: one of these settings has a server-side
+// PRECONDITION and a confirmation flow, and a control with a precondition inside a
+// generic list of key/value inputs is one whose behaviour nobody reading that list
+// would predict.
+//
+// **The checkbox below is not the gate.** The server rejects `teams_forum_images =
+// 'uploads'` with 400 unless the same request carries the acknowledgement version,
+// and it does so whether or not this dialog was ever rendered. What is here is how
+// the gate is PRESENTED — the wording an operator agrees to, and the recording of
+// which version they agreed to.
+
+// §5.5.5(a). Rendered beneath the selector at ALL times, in every mode: it
+// explains what the setting is, which is a different job from the confirmation.
+const HELP_TEXT = [
+ 'Image uploads are disabled by default.',
+ 'Enabling uploads allows users to store files on infrastructure that you control.',
+ 'By enabling this feature, you acknowledge that you are responsible for:',
+]
+const HELP_BULLETS = [
+ 'Moderating uploaded content',
+ 'Managing storage and backups',
+ 'Complying with applicable laws and regulations',
+ 'Establishing policies for your community',
+]
+const HELP_TAIL = [
+ 'Runic Gateway does not provide hosted storage or content moderation services. All uploaded content'
+ + ' is stored on your own infrastructure.',
+ // Addition 1 — the reassuring counterpart, and the reason the attribution table
+ // in §5.5.4 exists at all.
+ 'Uploads are attributed to the account that made them, and your staff can remove them at any time.',
+ // Addition 3 — the blast radius. "Users" is doing a lot of work: forum access is
+ // not the same as game membership, so this genuinely surprises.
+ 'Anyone with access to a team forum can upload, including members granted access manually who have'
+ + ' no linked game account.',
+]
+
+// §5.5.2's non-blocking advisory for `remote`. Not an acknowledgement — nothing is
+// stored in that mode — but the operator's server is still doing the displaying.
+const REMOTE_ADVISORY = 'Images hosted elsewhere are loaded by each visitor’s browser directly from the'
+ + ' site hosting them. That site can see your visitors’ IP addresses, and you do not control whether'
+ + ' the image changes or disappears.'
+
+// §5.5.5(b). Shown only when changing the mode TO uploads.
+const DIALOG_CHECKS = [
+ 'I understand that uploaded files will be stored on infrastructure that I control.',
+ 'I understand that I am responsible for community moderation policies on this installation.',
+]
+// Addition 2 — the expectation gap most likely to bite. An operator who turns
+// uploads off because of a problem will assume the problem goes with it.
+const DIALOG_TAIL = 'Disabling uploads later stops new files being accepted. It does not delete files'
+ + ' already uploaded — remove those from the forum moderation tools.'
+
+const MODES = [
+ { value: 'disabled', label: 'Disabled — image URLs stay plain links' },
+ { value: 'remote', label: 'Remote — images hosted elsewhere are shown' },
+ { value: 'uploads', label: 'Uploads — members may upload images to this server' },
+]
+
+export default function TeamForumSettings() {
+ const { refresh: refreshSite } = useSite()
+ const [state, setState] = useState(null)
+ const [enabled, setEnabled] = useState(false)
+ const [mode, setMode] = useState('disabled')
+ const [editWindow, setEditWindow] = useState('15')
+ const [dialog, setDialog] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState('')
+ const [saved, setSaved] = useState(false)
+
+ const load = async () => {
+ try {
+ const s = await api.admin.teamForumSettings()
+ setState(s)
+ setEnabled(s.enabled)
+ setMode(s.imageMode)
+ setEditWindow(String(s.editWindowMinutes ?? 15))
+ } catch {
+ setError('Could not load forum settings.')
+ }
+ }
+
+ useEffect(() => { load() }, [])
+
+ if (!state) return null
+
+ const stale = state.acknowledgement?.stale
+
+ async function persist(next, acknowledge) {
+ setBusy(true)
+ setError('')
+ try {
+ await api.admin.updateSettings({
+ teams_forums_enabled: next.enabled ? '1' : '0',
+ teams_forum_images: next.mode,
+ teams_forum_edit_window_minutes: String(next.editWindow),
+ ...(acknowledge ? { acknowledge } : {}),
+ })
+ setSaved(true)
+ await load()
+ await refreshSite()
+ } catch (err) {
+ setError(err.message || 'Could not save forum settings.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ // Moving TO uploads asks first; every other change saves directly. A stale
+ // acknowledgement also routes through the dialog, because re-acknowledging is
+ // the only thing that unfreezes these settings.
+ function save() {
+ setSaved(false)
+ if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) {
+ setDialog({ enabled, mode, editWindow })
+ return
+ }
+ if (stale) {
+ setDialog({ enabled, mode, editWindow })
+ return
+ }
+ persist({ enabled, mode, editWindow })
+ }
+
+ return (
+
+
Team forums
+
+ {stale && (
+
+ The image-upload notice has changed since it was accepted
+ {state.acknowledgement.acknowledgedBy ? ` by ${state.acknowledgement.acknowledgedBy}` : ''}.
+ Uploads keep working, but no forum setting can be saved until it is acknowledged again.
+
+ )}
+
+
+
+
+
+
+
+
+ {HELP_TEXT.map((line) =>
{line}
)}
+
+ {HELP_BULLETS.map((b) =>
{b}
)}
+
+ {HELP_TAIL.map((line) =>
{line}
)}
+ {mode !== 'disabled' && (
+
{REMOTE_ADVISORY}
+ )}
+
+
+
+
+ {saved && Saved.}
+ {error && {error}}
+
+
+ {dialog && (
+ {
+ setDialog(null)
+ setMode(state.imageMode)
+ setEnabled(state.enabled)
+ setEditWindow(String(state.editWindowMinutes ?? 15))
+ }}
+ onConfirm={async (version) => {
+ setDialog(null)
+ await persist(dialog, version)
+ }}
+ />
+ )}
+
+ )
+}
+
+/**
+ * Two checkboxes, one recorded acknowledgement.
+ *
+ * `Enable uploads` stays disabled until both are ticked, but the request carries a
+ * single version and the stored value is the text VERSION. Recording two booleans
+ * would add nothing — there is no reachable state where an operator consented to
+ * one clause and not the other and proceeded anyway — while the version answers
+ * the question that actually matters later: which text did they agree to?
+ */
+function UploadsDialog({ version, onCancel, onConfirm }) {
+ const [checks, setChecks] = useState(DIALOG_CHECKS.map(() => false))
+ const all = checks.every(Boolean)
+
+ return (
+
+
+ ⚠ Image uploads are currently disabled.
+
+
+ Enabling uploads will allow users to store files on your server.
+
+ {DIALOG_CHECKS.map((text, i) => (
+
+ ))}
+
{DIALOG_TAIL}
+
+
+
+
+
+ )
+}
diff --git a/client/src/routes/admin/views/TeamIntegrations.jsx b/client/src/routes/admin/views/TeamIntegrations.jsx
new file mode 100644
index 0000000..5f89e69
--- /dev/null
+++ b/client/src/routes/admin/views/TeamIntegrations.jsx
@@ -0,0 +1,281 @@
+import { useCallback, useEffect, useState } from 'react'
+import { api } from '../../../api/client.js'
+import {
+ eventLabel, rowKey, isDefaultRow, blankDraft, draftFrom, appliesToLabel, toggleEvent,
+ setChannel, needsAcknowledgement, membersOnlyIdsOf, availableTargets,
+} from '../../../lib/teamIntegrations.js'
+
+// The Team notification bridge (TEAMS.md §7.2, phase 8).
+//
+// Named for the TEAM concern rather than for Discord, and placed under Teams
+// rather than in the Discord Bot panel, because phase 10 replaces "Discord" here
+// with whatever the capability registry declares. What changes then should be
+// what fills this panel, not where an operator goes to find it. Nothing below
+// hardcodes the word except the heading the server sends as `platform`.
+//
+// **The checkbox in the dialog is not the gate.** The server refuses to enable a
+// row carrying `team.forum.post` or `team.announcement` without the
+// acknowledgement, 422, whether or not this dialog was ever rendered — the same
+// division TeamForumSettings draws for image uploads. What is here is how the
+// gate is PRESENTED: the sentence an operator agrees to, and the fact that
+// agreeing is a deliberate act rather than a checkbox they tab past.
+
+const ACK_TEXT = [
+ 'Forum posts and announcements are visible only to a Team’s members. This site cannot see who can'
+ + ' read a channel on another platform, so it cannot check that for you.',
+ 'By enabling these events you confirm that the destination channel is restricted to the members of'
+ + ' the Team whose posts it will carry.',
+]
+
+export default function TeamIntegrations() {
+ const [config, setConfig] = useState(null)
+ const [teams, setTeams] = useState([])
+ const [draft, setDraft] = useState(null)
+ const [dialog, setDialog] = useState(null)
+ const [error, setError] = useState('')
+ const [notice, setNotice] = useState('')
+ const [busy, setBusy] = useState(false)
+
+ const load = useCallback(async () => {
+ setError('')
+ try {
+ const [cfg, teamList] = await Promise.all([api.admin.teamIntegrations(), api.admin.listTeams()])
+ setConfig(cfg)
+ setTeams((teamList.teams || []).filter((t) => t.status === 'active'))
+ } catch (err) {
+ // A moderator never reaches this panel — the admin nav does not render it —
+ // so a 403 here means the role changed underneath an open tab rather than a
+ // routing mistake, and saying so beats "could not load".
+ setError(err.status === 403 ? 'Only an admin can configure the notification bridge.' : (err.message || 'Could not load the bridge configuration.'))
+ }
+ }, [])
+
+ useEffect(() => { load() }, [load])
+
+ if (!config) {
+ return (
+
+
+ Send Team notifications to a {config.platform} channel. Set a default that every Team uses, and
+ override it for individual Teams. A message is sent once and not retried — the bridge is a
+ courtesy, and nothing on the site depends on it arriving.
+
+
+ {error &&
{error}
}
+ {notice &&
{notice}
}
+
+ {config.rows.length === 0 && !draft && (
+
Nothing configured — no Team events leave the site.
+ You have confirmed this channel is restricted to the Team’s members.{' '}
+
+
+ )}
+
+
+
+
+
+
+ )}
+
+ {dialog && (
+
+
Confirm the destination’s audience
+ {ACK_TEXT.map((line) => (
+
{line}
+ ))}
+
+
+
+ )}
+
+ )
+}
diff --git a/client/src/routes/admin/views/TeamVoice.jsx b/client/src/routes/admin/views/TeamVoice.jsx
new file mode 100644
index 0000000..a88e765
--- /dev/null
+++ b/client/src/routes/admin/views/TeamVoice.jsx
@@ -0,0 +1,256 @@
+import { useCallback, useEffect, useState } from 'react'
+import { api } from '../../../api/client.js'
+import {
+ stateLabel, enableBlockedReason, roleHeadroom, removalCountdown,
+ parseStaffRoles, formatStaffRoles, statusSummary,
+} from '../../../lib/teamVoice.js'
+
+// Team voice channels (TEAMS.md §7.3, phase 9).
+//
+// Named for the Team concern and placed under Teams beside the notification
+// bridge, for the reason that panel gives: phase 10 replaces "Discord" with
+// whatever the capability registry declares, and what should change then is what
+// fills this panel rather than where an operator goes to find it.
+//
+// **The preflight is the first thing on the page, not a diagnostic.** §7.3
+// assumed the bot could manage channels and roles; nothing in this project has
+// ever checked, because the operator invites the bot by hand and no invite URL
+// with a permission integer exists anywhere in the tree. An operator whose bot
+// lacks Manage Roles otherwise has a screen full of controls that cannot work,
+// and finds out one Team at a time from a column of identical errors.
+
+export default function TeamVoice() {
+ const [config, setConfig] = useState(null)
+ const [draft, setDraft] = useState(null)
+ const [error, setError] = useState('')
+ const [notice, setNotice] = useState('')
+ const [busy, setBusy] = useState(false)
+
+ const load = useCallback(async () => {
+ setError('')
+ try {
+ const cfg = await api.admin.teamVoice()
+ setConfig(cfg)
+ setDraft({
+ enabled: cfg.settings.enabled,
+ minMembers: cfg.settings.minMembers,
+ graceDays: cfg.settings.graceDays,
+ staffRoles: formatStaffRoles(cfg.settings.staffRoles),
+ })
+ } catch (err) {
+ // A moderator never reaches this panel — the admin nav does not render it —
+ // so a 403 means the role changed underneath an open tab.
+ setError(err.status === 403
+ ? 'Only an admin can configure Team voice channels.'
+ : (err.message || 'Could not load the voice configuration.'))
+ }
+ }, [])
+
+ useEffect(() => { load() }, [load])
+
+ if (!config || !draft) {
+ return (
+
+
Voice channels
+ {error &&
{error}
}
+
+ )
+ }
+
+ const blocked = enableBlockedReason(config.preflight)
+ const headroom = roleHeadroom(config.preflight)
+
+ async function save() {
+ const { roles, invalid } = parseStaffRoles(draft.staffRoles)
+ if (invalid.length > 0) {
+ setError(`Not a role id: ${invalid.join(', ')}. Copy role ids from Discord with Developer Mode on.`)
+ return
+ }
+ setBusy(true)
+ setError('')
+ setNotice('')
+ try {
+ await api.admin.saveTeamVoice({
+ enabled: draft.enabled,
+ minMembers: Number(draft.minMembers),
+ graceDays: Number(draft.graceDays),
+ staffRoles: roles,
+ })
+ setNotice('Saved.')
+ await load()
+ } catch (err) {
+ setError(err.message || 'Could not save.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ async function runPass() {
+ setBusy(true)
+ setError('')
+ setNotice('')
+ try {
+ const result = await api.admin.teamVoicePass()
+ // A pass that refused says why, and that is the useful answer far more often
+ // than a count is — "stale projection" and "synced 0" look identical in a
+ // summary and mean completely different things.
+ setNotice(result.ran
+ ? `Synced ${result.synced}, created ${result.created}, scheduled ${result.scheduled}, removed ${result.removed}, failed ${result.failed}.`
+ : `Nothing was done: ${result.reason}`)
+ await load()
+ } catch (err) {
+ setError(err.message || 'Could not run a pass.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ async function remove(row) {
+ setBusy(true)
+ setError('')
+ try {
+ await api.admin.removeTeamVoice(row.teamId)
+ setNotice('Removed.')
+ await load()
+ } catch (err) {
+ setError(err.message || 'Could not remove.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
Voice channels
+
+ Give each Team a {config.platform} voice channel of its own. Access is granted with a role per
+ Team, so members of a Team can see and join their channel and nobody else can. Members need a
+ linked {config.platform} account and must be in the guild.
+
+
+ {blocked && (
+
+ {blocked} Voice channels cannot be switched on until that is fixed.
+
+ )}
+
+ {headroom && (
+
+ {headroom.used} of {headroom.cap} {config.platform} roles used in this guild
+ {headroom.exhausted
+ ? ' — no room for another Team.'
+ : headroom.tight
+ ? ` — room for about ${headroom.free} more Teams.`
+ : '.'}
+
+ Last pass {new Date(config.lastPass.at).toLocaleString()}
+ {config.lastPass.ran ? '' : ` — nothing was done: ${config.lastPass.reason}`}
+
+ )}
+
+ )
+}
diff --git a/client/src/routes/admin/views/TeamsAdmin.jsx b/client/src/routes/admin/views/TeamsAdmin.jsx
new file mode 100644
index 0000000..85f8f9a
--- /dev/null
+++ b/client/src/routes/admin/views/TeamsAdmin.jsx
@@ -0,0 +1,392 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Loading, ErrorState } from '../../../components/PageState.jsx'
+import { dateTime } from '../../../lib/format.js'
+import {
+ freshnessOf, statusOf, gateLabelFor, describeRequest, leadershipOf, GATED_NOTE,
+} from '../../../lib/teamAdmin.js'
+import { useAuth } from '../../../contexts/AuthContext.jsx'
+import { api } from '../../../api/client.js'
+import TeamIntegrations from './TeamIntegrations.jsx'
+import TeamVoice from './TeamVoice.jsx'
+
+// Admin → Teams (docs/website/TEAMS.md §2.4, §2.8, §2.9).
+//
+// Three panels, in the order an operator needs them:
+//
+// 1. **Sync state**, verbatim, including the last error. The screen's first job
+// is to make "the shard has no Teams" and "core has not been able to ask for
+// two hours" impossible to confuse — they render almost identically
+// otherwise, and one is fine while the other is an outage.
+// 2. **The review queue** — Teams auto-hidden because their name matched the
+// impersonation list, each showing which term matched.
+// 3. **The approval queue** — what moderators have asked to publish.
+//
+// Everything that decides what a row SAYS lives in lib/teamAdmin.js, which is
+// plain JS and has tests; this file renders it.
+
+const TONE_COLOR = { ok: '#7fd0a4', warn: 'var(--accent)', bad: '#d98b84', idle: 'var(--muted)' }
+
+function Pill({ tone, children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+// ── Sync state ─────────────────────────────────────────────────────────────
+
+function SyncPanel({ sync, syncState, onResync, busy }) {
+ const freshness = freshnessOf(sync)
+ return (
+
+
+
Sync
+ {freshness.label}
+
+
+
{freshness.detail}
+
+ {syncState && (
+
+
Module
{syncState.moduleId}
+
Last attempt
{dateTime(syncState.lastAttemptAt) || 'never'}
+
Last success
{dateTime(syncState.lastSuccessAt) || 'never'}
+
Consecutive failures
{syncState.consecutiveFailures}
+ {syncState.lastError && (
+ <>
+ {/* Verbatim. An operator debugging a stale projection needs what the
+ provider actually said, not a friendlier paraphrase of it. */}
+
+ These Teams are hidden from every public surface because their name matched a reserved term.
+ They work normally for their own members. {GATED_NOTE}
+
+ {canDecide
+ ? 'Approving publishes the name; rejecting keeps the record and changes nothing.'
+ : 'Only an admin can decide these. Your own requests stay here until one does.'}
+
+ )
+}
+
+/**
+ * One Team's forum moderation ledger (TEAMS.md §5.3).
+ *
+ * The route and the API method have existed since phase 4 and nothing rendered
+ * them, which made the ledger a table only a DB client could read. The column
+ * that earns the screen is `actorRole`: it records WHICH authority was exercised,
+ * so a leader's ordinary housekeeping stays distinguishable from a staff
+ * intervention after the fact.
+ *
+ * **This is deliberately not merged with the site's mod_actions/appeals pair.**
+ * That one is Discord-sanction-shaped and bot-owned; routing a guild leader
+ * locking a thread through it would make ordinary housekeeping an appealable
+ * sanction with a reversal path into the bot. Every STAFF-exercised action here
+ * additionally writes activity_log, so the site's accountability trail sees it —
+ * the two are cross-referenced, not merged.
+ */
+function ForumLedger({ team, onClose }) {
+ const [rows, setRows] = useState(null)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ let active = true
+ api.admin.teamForumModeration(team.id)
+ // `{ entries }`, and the rows are the ledger table's own snake_case
+ // columns — this endpoint serves them unmapped, unlike the Team payloads
+ // above it. Reading them as they are, rather than accepting three possible
+ // shapes, is what makes a change to that endpoint fail here instead of
+ // rendering an empty table.
+ .then((res) => { if (active) setRows(res.entries) })
+ .catch((err) => { if (active) setError(err.message || 'Could not load the forum log.') })
+ return () => { active = false }
+ }, [team.id])
+
+ return (
+
+
+
+ {/* The distinction the whole ledger exists to preserve. */}
+ {r.actor_role}
+
+
{r.reason || '—'}
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+// ── The screen ─────────────────────────────────────────────────────────────
+
+export default function TeamsAdmin() {
+ const { user } = useAuth()
+ const role = user ? user.role : null
+
+ const [data, setData] = useState(null)
+ const [review, setReview] = useState([])
+ const [requests, setRequests] = useState([])
+ const [error, setError] = useState('')
+ const [notice, setNotice] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [ledgerTeam, setLedgerTeam] = useState(null)
+
+ const load = useCallback(async () => {
+ setError('')
+ try {
+ const [teams, reviewQueue, requestQueue] = await Promise.all([
+ api.admin.listTeams(),
+ api.admin.teamReviewQueue(),
+ api.admin.teamRequests('pending'),
+ ])
+ setData(teams)
+ setReview(reviewQueue.teams || [])
+ setRequests(requestQueue.requests || [])
+ } catch (err) {
+ setError(err.message || 'Could not load Teams.')
+ }
+ }, [])
+
+ useEffect(() => { load() }, [load])
+
+ async function run(fn, pendingMessage) {
+ setBusy(true)
+ setNotice('')
+ setError('')
+ try {
+ const result = await fn()
+ // The server decides whether an action applied or was filed, from the
+ // caller's live role. Saying so plainly is what stops a moderator thinking
+ // nothing happened.
+ if (result && result.pending) setNotice(pendingMessage)
+ await load()
+ } catch (err) {
+ setError(err.message || 'That did not work.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const act = (id, action) => run(
+ () => (action === 'hide' ? api.admin.hideTeam(id) : api.admin.unhideTeam(id)),
+ 'Filed for approval. Nothing has changed publicly until an admin approves it.',
+ )
+
+ const decide = (id, status) => run(
+ () => api.admin.decideTeamRequest(id, status),
+ '',
+ )
+
+ const resync = () => run(async () => {
+ const result = await api.admin.resyncTeams()
+ // A refusal is the normal, designed outcome when the provider cannot answer,
+ // so it is reported as a result rather than thrown as an error.
+ if (!result.ok) setError(`Resync refused: ${result.reason}. Nothing was changed.`)
+ else if (result.quarantined) {
+ setNotice('The provider answered with an empty list. It is being held for confirmation, not applied.')
+ }
+ return null
+ }, '')
+
+ if (error && !data) return
+ if (!data) return
+
+ return (
+
+
Teams
+ {error && }
+ {notice &&
{notice}
}
+
+ {ledgerTeam && setLedgerTeam(null)} />}
+
+ {/* Admin-only, matching the server (§7.2). Rendered for a moderator it would
+ be a panel every action in fails 403 — the role gate is the server's, and
+ this is only how the screen agrees with it. */}
+ {role === 'admin' && }
+ {role === 'admin' && }
+
+
+
+
+
+
+
All Teams
+ {!data.teams.length && (
+
+ {data.configured
+ ? 'No Teams in the projection yet.'
+ : 'No installed module supplies Teams, so there is nothing to show.'}
+
+ )}
+ {data.teams.length > 0 && (
+
+
+
+
Name
Status
Members
Linked
Online
+
Roster confirmed
+
+
+
+ {data.teams.map((team) => (
+
+ ))}
+
+
+ )}
+
+
+ )
+}
+
+export { leadershipOf }
diff --git a/client/src/routes/player/PlayerNotifications.jsx b/client/src/routes/player/PlayerNotifications.jsx
new file mode 100644
index 0000000..ad9ac9b
--- /dev/null
+++ b/client/src/routes/player/PlayerNotifications.jsx
@@ -0,0 +1,268 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Loading, ErrorState } from '../../components/PageState.jsx'
+import { api } from '../../api/client.js'
+
+// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6).
+//
+// **This screen did not exist before phase 6, and that was the phase's first
+// finding.** §6.3 says the per-Team mute list is "surfaced under the existing
+// notification settings screen" — there was no such screen on the web. The stream
+// catalog and the per-stream subscriptions have been built and shipped since M7,
+// with the Android app as their only consumer; a browser could not see them at
+// all. That is tolerable for push, which needs the app anyway. It is not tolerable
+// for email, whose whole reason for existing (§6.4) is the web-only user who runs
+// neither the app nor Discord — so the sink and the screen to configure it had to
+// arrive together.
+//
+// Three blocks, in the order a user actually reasons about them: what kinds of
+// thing to be told about, then which Teams, then whether any of it should reach a
+// mailbox.
+
+const EMAIL_MODES = [
+ { value: 'off', label: 'No email' },
+ { value: 'digest', label: 'Daily digest' },
+ { value: 'immediate', label: 'Every post' },
+]
+
+// Streams whose scoping lives in this page's second block rather than in the
+// first. Shown as a group so a user does not toggle `team.forum.post` off site-
+// wide when what they meant was "not this one guild".
+const isTeamStream = (id) => String(id).startsWith('team.')
+
+function Section({ title, hint, children }) {
+ return (
+
+
+ You are not in a team, and nobody has given you access to a team forum. There is nothing to
+ configure here yet.
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
Team
+
Notifications
+
Email
+
+
+
+ {rows.map((t) => (
+
+
+ {t.name}
+ {/* An archived Team is still listed when a preference exists for
+ it, so a mute does not silently vanish when a guild disbands
+ and reappear if it re-forms under the same name. */}
+ {t.archived && · archived}
+
+ Choose what you are told about, and how. Nothing here is on by default except team
+ notifications to the app, which you can mute per team below.
+
+
+
+
+ )
+}
diff --git a/client/src/routes/player/PlayerPortalLayout.jsx b/client/src/routes/player/PlayerPortalLayout.jsx
index 12e9153..35c3312 100644
--- a/client/src/routes/player/PlayerPortalLayout.jsx
+++ b/client/src/routes/player/PlayerPortalLayout.jsx
@@ -35,6 +35,7 @@ function Icon({ children, size = 16 }) {
}
const IconGear = () =>
const IconShield = () =>
+const IconBell = () =>
// Exported because Admin -> Navigation edits this list. It stays declared here;
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
@@ -47,6 +48,7 @@ const IconShield = () =>
+
+ {state === 'working' &&
One moment…
}
+ {state === 'done' && (
+ <>
+
+ You will not receive further notification emails about this team.
+
+
+ This muted the team rather than switching off your account’s email, so your other
+ teams are unaffected. You can turn it back on any time under{' '}
+ notification settings.
+
+ >
+ )}
+ {state === 'failed' && (
+
+ We could not reach the site to record that. Please try the link again, or change the
+ setting yourself under notification settings.
+
+ )}
+
+ )
+}
diff --git a/client/src/styles/theme.css b/client/src/styles/theme.css
index dd24ae3..62c4cf3 100644
--- a/client/src/styles/theme.css
+++ b/client/src/styles/theme.css
@@ -298,6 +298,18 @@ button[disabled] {
}
/* ===== Rich prose (wiki / newsletter body) ===== */
+.forum-embed {
+ /* The image a Team-forum post's URL renders as, in `remote`/`uploads` mode.
+ Emitted by the server (utils/forumHtml.js), never by an author — which is
+ what makes the operator's image policy enforceable. Block, so it sits
+ beneath its link rather than beside it; capped, because a remote image is
+ whatever size its host decided and one post must not blow out the column. */
+ display: block;
+ margin-top: 8px;
+ max-width: 100%;
+ height: auto;
+ border-radius: var(--radius-input);
+}
.prose {
color: var(--text);
font-size: 1.06rem;
diff --git a/client/test/apiClient.test.js b/client/test/apiClient.test.js
index fbf606f..4942133 100644
--- a/client/test/apiClient.test.js
+++ b/client/test/apiClient.test.js
@@ -185,3 +185,70 @@ test('a module id is URL-encoded on the way into the path', async () => {
await api.admin.disableModule('a b/c')
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable')
})
+
+// ── Team forum, phase 5 ("5b") ──────────────────────────────────────────
+//
+// The URL shapes matter more here than they look. Replies hang off a THREAD;
+// edits and post moderation hang off a POST; and the report route hangs off the
+// forum rather than off either, because a report can name a thread, a post or an
+// upload and is not moderation of any of them.
+
+test('a reply hangs off its thread and an edit hangs off its post', async () => {
+ willReply({ body: { ok: true } })
+ await api.teamForumReply('ossuary', 5, { body: 'hi' })
+ assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/posts')
+ assert.equal(calls[0].opts.method, 'POST')
+
+ calls = []
+ willReply({ body: { ok: true } })
+ await api.teamForumEditPost('ossuary', 80, { body: 'fixed' })
+ assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80')
+ // PATCH, not POST: an edit replaces part of a post that already exists, and the
+ // server's route is mounted on the verb.
+ assert.equal(calls[0].opts.method, 'PATCH')
+})
+
+test('post moderation is a different route from thread moderation', async () => {
+ // Not the same route with a target kind, because the two answer to different
+ // rules — `pin` and `lock` mean nothing to a post at all.
+ willReply({ body: { ok: true } })
+ await api.teamForumModeratePost('ossuary', 80, { action: 'hide' })
+ assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80/moderate')
+
+ calls = []
+ willReply({ body: { ok: true } })
+ await api.teamForumModerate('ossuary', 5, { action: 'pin' })
+ assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/moderate')
+})
+
+test('a report goes to the forum, and its queue is under admin moderation', async () => {
+ willReply({ body: { ok: true } })
+ await api.teamForumReport('ossuary', { targetType: 'team_forum_post', targetId: 80, reason: 'abuse' })
+ assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/report')
+ assert.deepEqual(JSON.parse(calls[0].opts.body), {
+ targetType: 'team_forum_post', targetId: 80, reason: 'abuse',
+ })
+
+ // Under /admin/moderation and NOT under /admin/teams: a staffer working a queue
+ // should have one place to work, and there is deliberately no leader-facing
+ // counterpart to this call anywhere in the client (TEAMS.md §5.6).
+ calls = []
+ willReply({ body: { reports: [] } })
+ await api.admin.contentReports({ status: 'open' })
+ assert.equal(calls[0].url, '/api/v1/admin/moderation/reports?status=open')
+})
+
+test('the report queue defaults to the open work rather than to everything', async () => {
+ willReply({ body: { reports: [] } })
+ await api.admin.contentReports()
+ // No query string at all — the server's default is open + reviewing, and a
+ // client that pinned `status=all` here would put the archive in front of a
+ // staffer every time they opened the screen.
+ assert.equal(calls[0].url, '/api/v1/admin/moderation/reports')
+})
+
+test('a Team slug is URL-encoded on every forum path', async () => {
+ willReply({ body: { ok: true } })
+ await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
+ assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
+})
diff --git a/client/test/moduleRegistry.test.js b/client/test/moduleRegistry.test.js
index ffd78b1..9981675 100644
--- a/client/test/moduleRegistry.test.js
+++ b/client/test/moduleRegistry.test.js
@@ -160,6 +160,9 @@ test('the registry object handed to modules exposes the whole surface', () => {
// window.__rg.registry is the ONLY way a module reaches any of this, so a
// member missing from the object is a member that does not exist.
assert.deepEqual(Object.keys(registry).sort(), [
+ // `declareModuleSlot` is the INVERTED direction added in 1.6.0: the module
+ // declares a place on its own page and core fills it (TEAMS.md Part 3).
+ 'declareModuleSlot',
'featureProviderFor',
'navFor',
'registerExtension',
diff --git a/client/test/moduleSlots.test.js b/client/test/moduleSlots.test.js
index d0540e4..8361914 100644
--- a/client/test/moduleSlots.test.js
+++ b/client/test/moduleSlots.test.js
@@ -4,6 +4,10 @@ import assert from 'node:assert/strict'
import {
registry,
declareSlot,
+ declareModuleSlot,
+ offerCoreFill,
+ CORE_CONTRIBUTIONS,
+ applyCoreFills,
registerExtension,
extensionFor,
registeredIds,
@@ -92,3 +96,114 @@ test('declareSlot and extensionFor are not on the module-facing registry', () =>
assert.equal(registry.extensionFor, undefined)
assert.equal(typeof registry.registerExtension, 'function')
})
+
+// ── The INVERTED direction: the module declares, core fills ────────────────
+//
+// Added in 1.6.0 for Teams (TEAMS.md Part 3). Teams are a core primitive with no
+// core surface — core owns the tables and the activity feed, the module owns the
+// page and the word "guild" — so the content flows the other way for the first
+// time. The rules below are the ones that direction gets wrong.
+
+const Feed = () => null
+
+test('a module-declared slot must be namespaced under the declaring module', () => {
+ // Enforced rather than conventional: this is the only thing keeping two
+ // modules from claiming the same slot name.
+ assert.throws(() => declareModuleSlot('uo', 'guild.detail'), /must be namespaced/)
+ assert.doesNotThrow(() => declareModuleSlot('uo', 'uo.guild.detail'))
+})
+
+test('core offers a contribution and the module says where it goes', () => {
+ // The ordering that makes this two calls: core's bundle evaluates BEFORE any
+ // module chunk, so at the moment core offers, no module-declared slot exists.
+ offerCoreFill('team.activity', Feed)
+ declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
+ assert.equal(extensionFor('uo.guild.detail'), null, 'not before the fills are applied')
+
+ applyCoreFills()
+ assert.equal(extensionFor('uo.guild.detail'), Feed)
+})
+
+test('core names no slot, so a second game gets the same content in its own words', () => {
+ // The defect this replaced: core used to fill three literal `uo.guild.*` names,
+ // which reached exactly one module. Every other game declared a place under its
+ // own id and got an empty page with no error, because a fill nobody declared is
+ // deliberately not an error — the rule that makes an unknown name invisible.
+ offerCoreFill('team.activity', Feed)
+ declareModuleSlot('examplegame', 'examplegame.clan.detail', { core: 'team.activity' })
+ applyCoreFills()
+ assert.equal(extensionFor('examplegame.clan.detail'), Feed)
+})
+
+test('two modules can ask for the same contribution, and both get it', () => {
+ // Core has no reason to care how many places want its feed, and refusing the
+ // second would be core making a layout decision on a page it does not own.
+ offerCoreFill('team.activity', Feed)
+ declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
+ declareModuleSlot('uo', 'uo.guild.summary', { core: 'team.activity' })
+ applyCoreFills()
+ assert.equal(extensionFor('uo.guild.detail'), Feed)
+ assert.equal(extensionFor('uo.guild.summary'), Feed)
+})
+
+test('a slot that asks for nothing stays empty', () => {
+ // Optional on purpose: a module may declare a place it fills itself, or one it
+ // is keeping for later. Neither is core's business.
+ offerCoreFill('team.activity', Feed)
+ declareModuleSlot('uo', 'uo.guild.detail')
+ applyCoreFills()
+ assert.equal(extensionFor('uo.guild.detail'), null)
+})
+
+test('asking for a contribution core does not offer THROWS', () => {
+ // The asymmetry with an unfilled slot, and it is deliberate. An unknown
+ // contribution is always a typo or a version skew — core's list is fixed at
+ // build time and the module's coreApi range has already been checked — and the
+ // alternative failure is a page that renders empty forever with nothing logged.
+ assert.throws(
+ () => declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activityfeed' }),
+ /does not offer/,
+ )
+ assert.ok(CORE_CONTRIBUTIONS['team.activity'], 'the catalogue is exported so a test can name it')
+})
+
+test('a contribution nothing asks for is not an error', () => {
+ // No game module installed. Core offering content for a page that does not
+ // exist is the ordinary case on any deployment, not a misconfiguration.
+ offerCoreFill('team.forum', Feed)
+ assert.doesNotThrow(() => applyCoreFills())
+})
+
+test('a module that fills its own slot first keeps it', () => {
+ const Own = () => null
+ declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
+ registerExtension('uo', 'uo.guild.detail', Own)
+ offerCoreFill('team.activity', Feed)
+ applyCoreFills()
+ assert.equal(extensionFor('uo.guild.detail'), Own, 'first fill wins, as everywhere else')
+})
+
+test('a module-declared slot cannot be declared twice', () => {
+ declareModuleSlot('uo', 'uo.guild.detail')
+ assert.throws(() => declareModuleSlot('uo', 'uo.guild.detail'), /already declared/)
+})
+
+test('applying the fills twice does not re-fill or throw', () => {
+ declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
+ offerCoreFill('team.activity', Feed)
+ applyCoreFills()
+ assert.doesNotThrow(() => applyCoreFills())
+ assert.equal(extensionFor('uo.guild.detail'), Feed)
+})
+
+test('a non-component contribution is refused at the call site, not at render', () => {
+ assert.throws(() => offerCoreFill('team.activity', 'nope'), /is not a component/)
+})
+
+test('_reset clears pending fills, so one test cannot leak into the next', () => {
+ offerCoreFill('team.activity', Feed)
+ _reset()
+ declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
+ applyCoreFills()
+ assert.equal(extensionFor('uo.guild.detail'), null)
+})
diff --git a/client/test/teamActivity.test.js b/client/test/teamActivity.test.js
new file mode 100644
index 0000000..d58cecb
--- /dev/null
+++ b/client/test/teamActivity.test.js
@@ -0,0 +1,78 @@
+// What core's Team activity feed says (client/src/lib/teamActivity.js).
+//
+// The test that earns this file: a projection nobody can tell is stale, and a
+// feed nobody can tell is filtered, both look like complete information. Every
+// case below is about saying which one the reader is looking at.
+//
+// Note the wording assertions avoid core's own noun. The feed renders inside a
+// page a MODULE titled — Guilds today, Clans next — so "this Team" would be
+// core's vocabulary leaking onto a surface that deliberately does not use it.
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+
+import { activityScopeNote, freshnessNote, groupByDay, relativeTime } from '../src/lib/teamActivity.js'
+
+const NOW = new Date('2026-08-17T12:00:00Z').getTime()
+const ago = (ms) => new Date(NOW - ms).toISOString()
+
+test('a deployment with no provider is not stale, it is uninvolved', () => {
+ assert.equal(freshnessNote({ configured: false }, NOW), null)
+})
+
+test('never synced is a warning, and never reads as a confirmed empty shard', () => {
+ const note = freshnessNote({ configured: true, lastSyncAt: null }, NOW)
+ assert.equal(note.tone, 'warn')
+ assert.match(note.text, /Not yet confirmed/)
+})
+
+test('a stale projection says how old it is and that the game may have moved on', () => {
+ const note = freshnessNote({ configured: true, lastSyncAt: ago(14 * 60_000), stale: true }, NOW)
+ assert.equal(note.tone, 'warn')
+ assert.equal(note.text, 'Last confirmed 14 minutes ago — the game may have moved on.')
+})
+
+test('a current projection is stated quietly', () => {
+ const note = freshnessNote({ configured: true, lastSyncAt: ago(90_000), stale: false }, NOW)
+ assert.equal(note.tone, 'idle')
+ assert.equal(note.text, 'Last confirmed 1 minute ago.')
+})
+
+test('relative time singularises and steps through the units', () => {
+ assert.equal(relativeTime(ago(5_000), NOW), 'just now')
+ assert.equal(relativeTime(ago(60_000), NOW), '1 minute ago')
+ assert.equal(relativeTime(ago(3 * 3_600_000), NOW), '3 hours ago')
+ assert.equal(relativeTime(ago(2 * 86_400_000), NOW), '2 days ago')
+ assert.equal(relativeTime(null, NOW), null)
+ assert.equal(relativeTime('not a date', NOW), null)
+})
+
+test('items group into days, newest day first, order kept within a day', () => {
+ const days = groupByDay([
+ { id: 3, occurredAt: '2026-08-17T09:00:00' },
+ { id: 2, occurredAt: '2026-08-17T08:00:00' },
+ { id: 1, occurredAt: '2026-08-16T22:00:00' },
+ ], 'en-US')
+ assert.equal(days.length, 2)
+ assert.deepEqual(days[0].items.map((i) => i.id), [3, 2])
+ assert.deepEqual(days[1].items.map((i) => i.id), [1])
+})
+
+test('an unparseable timestamp is skipped rather than making a day called Invalid Date', () => {
+ assert.deepEqual(groupByDay([{ id: 1, occurredAt: 'nonsense' }], 'en-US'), [])
+})
+
+test('a caller who saw everything is told nothing', () => {
+ assert.equal(activityScopeNote({ scope: 'members' }, true), null)
+})
+
+test('a filtered feed says so, and invites an anonymous caller to sign in', () => {
+ assert.match(activityScopeNote({ scope: 'public' }, false), /Sign in/)
+ assert.match(activityScopeNote({ scope: 'public' }, true), /members only/)
+})
+
+test('the wording never says "Team" — that is core\'s noun, not the page\'s', () => {
+ for (const signedIn of [true, false]) {
+ assert.doesNotMatch(activityScopeNote({ scope: 'public' }, signedIn), /Team/)
+ }
+ assert.doesNotMatch(freshnessNote({ configured: true, lastSyncAt: null }, NOW).text, /Team/)
+})
diff --git a/client/test/teamAdmin.test.js b/client/test/teamAdmin.test.js
new file mode 100644
index 0000000..b0e889a
--- /dev/null
+++ b/client/test/teamAdmin.test.js
@@ -0,0 +1,140 @@
+// What Admin → Teams says (client/src/lib/teamAdmin.js).
+//
+// The test that earns this file: "no Teams" and "core has not been able to ask"
+// must never read the same. They produce almost identical screens — an empty
+// table — and one is fine while the other is an outage an operator needs to act
+// on. Everything else here is in service of that distinction.
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+ freshnessOf, ago, statusOf, gateLabelFor, describeRequest, parsePayload, leadershipOf, TONE,
+} from '../src/lib/teamAdmin.js'
+
+const minutesAgo = (n) => new Date(Date.now() - n * 60_000).toISOString()
+
+// ── Freshness: four states that must not be confused ───────────────────────
+
+test('no provider is idle, not a fault', () => {
+ const f = freshnessOf({ configured: false })
+ assert.equal(f.tone, TONE.idle)
+ assert.match(f.label, /No Team provider/)
+})
+
+test('never synced is reported as never synced, not as an empty shard', () => {
+ // The failure this prevents: an empty projection core has never confirmed,
+ // rendered as though the game genuinely has no Teams.
+ const f = freshnessOf({ configured: true, lastSyncAt: null })
+ assert.equal(f.tone, TONE.bad)
+ assert.equal(f.label, 'Never synced')
+ assert.match(f.detail, /not a confirmed empty shard/)
+})
+
+test('stale says how old it is', () => {
+ const f = freshnessOf({ configured: true, stale: true, lastSyncAt: minutesAgo(14) })
+ assert.equal(f.tone, TONE.warn)
+ assert.equal(f.label, 'Stale')
+ assert.match(f.detail, /14 minutes ago/)
+})
+
+test('current says so plainly', () => {
+ const f = freshnessOf({ configured: true, stale: false, lastSyncAt: minutesAgo(2) })
+ assert.equal(f.tone, TONE.ok)
+ assert.equal(f.label, 'Current')
+})
+
+test('ago is deliberately coarse', () => {
+ // Second-level precision would be false comfort about a projection whose poll
+ // interval is fifteen minutes.
+ assert.equal(ago(null), 'never')
+ assert.equal(ago(new Date().toISOString()), 'just now')
+ assert.equal(ago(minutesAgo(14)), '14 minutes ago')
+ assert.equal(ago(minutesAgo(60)), '1 hour ago')
+ assert.equal(ago(minutesAgo(180)), '3 hours ago')
+ assert.equal(ago(minutesAgo(60 * 72)), '3 days ago')
+})
+
+// ── Status ─────────────────────────────────────────────────────────────────
+
+test('the four Team statuses are distinguishable', () => {
+ assert.equal(statusOf({ status: 'active' }).label, 'Public')
+ assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'reserved_name' }).label, 'Hidden — reserved name')
+ assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'staff' }).label, 'Hidden by staff')
+ assert.equal(statusOf({ status: 'archived', archivedReason: 'disbanded' }).label, 'Archived')
+ assert.equal(statusOf({ status: 'archived', archivedReason: 'renamed' }).label, 'Renamed')
+})
+
+test('a reserved-name hide is the loudest tone', () => {
+ assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'reserved_name' }).tone, TONE.bad)
+ assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'staff' }).tone, TONE.warn)
+})
+
+// ── The gate, described honestly ───────────────────────────────────────────
+
+test('the button says what will actually happen for this role', () => {
+ // The server decides from the live role; this only describes it. Saying
+ // "Publish" to a moderator would make the pending result a surprise.
+ assert.equal(gateLabelFor('admin', 'Publish'), 'Publish')
+ assert.equal(gateLabelFor('moderator', 'Publish'), 'Request publish')
+})
+
+// ── The approval queue ─────────────────────────────────────────────────────
+
+test('a request describes itself, including the name being published', () => {
+ assert.equal(
+ describeRequest({ action: 'unhide', requested_username: 'mod1', team_name: 'Admin' }),
+ 'mod1 asks to publish “Admin”',
+ )
+ assert.equal(
+ describeRequest({
+ action: 'display_name_override', requested_username: 'mod1', team_name: 'Admin',
+ payload: { displayName: 'The Old Guard' },
+ }),
+ 'mod1 asks to display “Admin” as “The Old Guard”',
+ )
+ assert.equal(
+ describeRequest({ action: 'clear_display_name_override', requested_username: 'mod1', team_name: 'X' }),
+ 'mod1 asks to clear the display name on “X”',
+ )
+})
+
+test('a deleted requester still reads as a sentence', () => {
+ // §2.10 sets requested_by to NULL and keeps the username snapshot; when even
+ // that is gone the queue must not render "null asks to publish".
+ assert.match(describeRequest({ action: 'unhide', team_name: 'Admin' }), /^a deleted user asks/)
+})
+
+test('a payload arrives parsed or as a string, and both work', () => {
+ assert.deepEqual(parsePayload({ displayName: 'X' }), { displayName: 'X' })
+ assert.deepEqual(parsePayload('{"displayName":"X"}'), { displayName: 'X' })
+ assert.deepEqual(parsePayload(null), {})
+ assert.deepEqual(parsePayload('not json'), {})
+})
+
+// ── Leadership shows the decision, not just the answer ─────────────────────
+
+test('an unoverridden member reads straight from the projection', () => {
+ const l = leadershipOf({ isLeader: true, isLeaderSynced: true })
+ assert.equal(l.isLeader, true)
+ assert.equal(l.overridden, false)
+ assert.equal(l.note, null)
+})
+
+test('an override is shown AS an override, with what the game says', () => {
+ // Staff looking at a roster need to see that a decision was made, not a fact
+ // that looks like the game's.
+ const l = leadershipOf({
+ isLeaderSynced: true,
+ leaderOverride: { effect: 'deny', by: 'mod1', reason: 'harassment' },
+ })
+ assert.equal(l.isLeader, false)
+ assert.equal(l.overridden, true)
+ assert.match(l.note, /Denied by mod1 — harassment/)
+ assert.match(l.note, /the game says leader/)
+})
+
+test('a grant override says the game disagrees', () => {
+ const l = leadershipOf({ isLeaderSynced: false, leaderOverride: { effect: 'grant', by: 'root' } })
+ assert.equal(l.isLeader, true)
+ assert.match(l.note, /the game says not a leader/)
+})
diff --git a/client/test/teamForum.test.js b/client/test/teamForum.test.js
new file mode 100644
index 0000000..6bae191
--- /dev/null
+++ b/client/test/teamForum.test.js
@@ -0,0 +1,120 @@
+// What the Team forum's client half decides for itself (client/src/lib/teamForum.js).
+//
+// The point of this file is how LITTLE that is. Who may post, who may moderate,
+// whether an image renders and whether a post may be edited are all server
+// answers the panel reads. What is tested here is the three places the client
+// turns those answers into what a reader sees — and one property that is easy to
+// break by accident: the edit offer can only ever be withdrawn here, never
+// granted.
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+
+import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../src/lib/teamForum.js'
+
+const NOW = new Date('2026-08-18T12:00:00Z').getTime()
+const inMinutes = (n) => new Date(NOW + n * 60_000).toISOString()
+
+// ── the edit offer ─────────────────────────────────────────────────────────
+
+test('the client can withdraw an edit offer and can never create one', () => {
+ // The server said no. Nothing about a deadline changes that — a future
+ // `editableUntil` on a post the server refused must not become an offer, or
+ // the client would be granting a permission.
+ assert.equal(editOfferOpen({ canEdit: false, editableUntil: inMinutes(10) }, NOW), false)
+ assert.equal(editOfferOpen({ canEdit: false, editableUntil: null }, NOW), false)
+})
+
+test('a deadline that has passed while the page sat open withdraws the offer', () => {
+ assert.equal(editOfferOpen({ canEdit: true, editableUntil: inMinutes(5) }, NOW), true)
+ // Same post, fifteen minutes of the reader staring at it later.
+ assert.equal(editOfferOpen({ canEdit: true, editableUntil: inMinutes(5) }, NOW + 15 * 60_000), false)
+})
+
+test('no deadline means no deadline, not no permission', () => {
+ // Staff are not time-bounded, and `editableUntil: null` is how the server says
+ // so. Reading it as "expired" would take the edit control away from exactly the
+ // people whose authority does not expire.
+ assert.equal(editOfferOpen({ canEdit: true, editableUntil: null }, NOW), true)
+})
+
+test('an unparseable deadline closes the offer rather than opening it', () => {
+ assert.equal(editOfferOpen({ canEdit: true, editableUntil: 'not a date' }, NOW), false)
+ assert.equal(editOfferOpen(null, NOW), false)
+ assert.equal(editOfferOpen(undefined, NOW), false)
+})
+
+// ── round-tripping a body back into the composer ───────────────────────────
+
+test('the image core generated is stripped, and the URL that made it survives', () => {
+ // §5.5.3: the author wrote a URL, core emitted the at read time. Handing
+ // the back would let an author edit markup they never wrote — and the
+ // URL is what re-renders it, so nothing is lost by removing it.
+ const rendered = '