feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161

Merged
whitlocktech merged 45 commits from edge into main 2026-08-19 08:57:13 +00:00
141 changed files with 32320 additions and 32 deletions

View File

@@ -86,9 +86,13 @@ jobs:
- name: Build client - name: Build client
run: npm run build --prefix client run: npm run build --prefix client
bot-install: bot-tests:
# No tests/build to run; a clean install still catches a broken or # The install still runs first and still catches a broken or out-of-sync
# out-of-sync lockfile before it ships in the bot image. # 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 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -99,3 +103,7 @@ jobs:
cache-dependency-path: bot/package-lock.json cache-dependency-path: bot/package-lock.json
- name: Install bot deps - name: Install bot deps
run: npm ci --prefix bot 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

View File

@@ -6,6 +6,7 @@
"main": "src/server.js", "main": "src/server.js",
"scripts": { "scripts": {
"start": "node src/server.js", "start": "node src/server.js",
"test": "node --test test/*.test.js",
"dev": "nodemon src/server.js" "dev": "nodemon src/server.js"
}, },
"keywords": ["discord", "discord.js"], "keywords": ["discord", "discord.js"],

View File

@@ -5,6 +5,7 @@ const { Client, GatewayIntentBits, REST, Routes } = require('discord.js')
const createLogger = require('../utils/logger') const createLogger = require('../utils/logger')
const commands = require('./commands') const commands = require('./commands')
const dynamicCommands = require('./dynamicCommands')
const messageFilter = require('./messageFilter') const messageFilter = require('./messageFilter')
const scheduler = require('../scheduler/scheduler') const scheduler = require('../scheduler/scheduler')
const roleMenuHandler = require('./roleMenuHandler') const roleMenuHandler = require('./roleMenuHandler')
@@ -22,12 +23,46 @@ let status = 'disconnected' // disconnected | connecting | connected | error
let statusDetail = null let statusDetail = null
let lastConnectedAt = 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) { async function registerCommands(applicationId, targetGuildId) {
const dynamic = dynamicCommands.definitions()
const rest = new REST({ version: '10' }).setToken(client.token) const rest = new REST({ version: '10' }).setToken(client.token)
await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), { 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() { async function stop() {
@@ -54,6 +89,11 @@ async function stop() {
// failure here leaves the client connected but flags an error status. // failure here leaves the client connected but flags an error status.
async function onReady() { async function onReady() {
try { 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 registerCommands(client.application.id, guildId)
await scheduler.start(client) await scheduler.start(client)
tempRoleSweeper.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) { async function onInteractionCreate(interaction) {
if (await roleMenuHandler.handleInteraction(interaction)) return if (await roleMenuHandler.handleInteraction(interaction)) return
if (!interaction.isChatInputCommand()) return if (!interaction.isChatInputCommand()) return
const command = commands.get(interaction.commandName) const command = commands.get(interaction.commandName)
if (!command) return if (!command && !dynamicCommands.has(interaction.commandName)) return
try { try {
await command.execute(interaction) if (command) await command.execute(interaction)
else await dynamicCommands.execute(interaction)
} catch (err) { } catch (err) {
log.error('command execution failed', { command: interaction.commandName, message: err.message }) log.error('command execution failed', { command: interaction.commandName, message: err.message })
const payload = { content: 'Something went wrong running that command.', ephemeral: true } const payload = { content: 'Something went wrong running that command.', ephemeral: true }
@@ -146,4 +191,4 @@ function getConnection() {
return { client, guildId } return { client, guildId }
} }
module.exports = { start, stop, getStatus, getConnection } module.exports = { start, stop, getStatus, getConnection, refreshCommands }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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,
}

View File

@@ -1,5 +1,7 @@
const discordManager = require('../discord/discordManager') const discordManager = require('../discord/discordManager')
const newsAnnounce = require('../discord/newsAnnounce') const newsAnnounce = require('../discord/newsAnnounce')
const teamNotify = require('../discord/teamNotify')
const teamVoice = require('../discord/teamVoice')
const modLog = require('../discord/modLog') const modLog = require('../discord/modLog')
const createLogger = require('../utils/logger') 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,
}

View File

@@ -11,5 +11,10 @@ router.post('/config', ctrl.setConfig)
router.get('/status', ctrl.getStatus) router.get('/status', ctrl.getStatus)
router.post('/announce', ctrl.announce) router.post('/announce', ctrl.announce)
router.post('/mod-reverse', ctrl.reverseModAction) 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 module.exports = router

View File

@@ -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 }

View File

@@ -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 listeners 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)
})

View File

@@ -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 Discords numeric option types, not the contracts 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 Discords 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')
})

138
bot/test/teamNotify.test.js Normal file
View File

@@ -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: 'Blackthorns 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, 'Blackthorns 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')
})

364
bot/test/teamVoice.test.js Normal file
View File

@@ -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)
})

View File

@@ -42,10 +42,12 @@ import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import UserDetail from './routes/admin/views/UserDetail.jsx' import UserDetail from './routes/admin/views/UserDetail.jsx'
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx' import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
import ModulesAdmin from './routes/admin/views/ModulesAdmin.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 AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx' import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx' import ModerationUser from './routes/admin/views/ModerationUser.jsx'
import Appeals from './routes/admin/views/Appeals.jsx' import Appeals from './routes/admin/views/Appeals.jsx'
import ContentReports from './routes/admin/views/ContentReports.jsx'
// Player portal // Player portal
import PlayerLogin from './routes/player/PlayerLogin.jsx' 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 AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx' import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
import PlayerAccount from './routes/player/PlayerAccount.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' import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
export default function App() { export default function App() {
@@ -162,6 +166,7 @@ export default function App() {
<Route index element={<Moderation />} /> <Route index element={<Moderation />} />
<Route path="user/:discordId" element={<ModerationUser />} /> <Route path="user/:discordId" element={<ModerationUser />} />
<Route path="appeals" element={<Appeals />} /> <Route path="appeals" element={<Appeals />} />
<Route path="reports" element={<ContentReports />} />
</Route> </Route>
<Route path="activity" element={<ActivityAdmin />} /> <Route path="activity" element={<ActivityAdmin />} />
<Route path="bot-activity" element={<BotActivityAdmin />} /> <Route path="bot-activity" element={<BotActivityAdmin />} />
@@ -174,6 +179,10 @@ export default function App() {
the volume in the first place. Declared here with the rest of the volume in the first place. Declared here with the rest of
core's routes, above the module-supplied ones below. */} core's routes, above the module-supplied ones below. */}
<Route path="modules" element={<ModulesAdmin />} /> <Route path="modules" element={<ModulesAdmin />} />
{/* 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). */}
<Route path="teams" element={<TeamsAdmin />} />
<Route path="account" element={<AccountAdmin />} /> <Route path="account" element={<AccountAdmin />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside {/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth RequireAuth + AdminLayout. A module cannot supply its own auth
@@ -197,6 +206,10 @@ export default function App() {
<Route path="/account/forgot" element={<ForgotPassword />} /> <Route path="/account/forgot" element={<ForgotPassword />} />
<Route path="/account/reset/:token" element={<ResetPassword />} /> <Route path="/account/reset/:token" element={<ResetPassword />} />
<Route path="/invite/:token" element={<AcceptInvite />} /> <Route path="/invite/:token" element={<AcceptInvite />} />
{/* 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). */}
<Route path="/unsubscribe/:token" element={<Unsubscribe />} />
<Route <Route
element={ element={
<RequirePlayer> <RequirePlayer>
@@ -211,6 +224,7 @@ export default function App() {
<Route path="/player" element={<PlayerIndex />} /> <Route path="/player" element={<PlayerIndex />} />
<Route path="/account" element={<PlayerAccount />} /> <Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} /> <Route path="/account/appeals" element={<PlayerAppeals />} />
<Route path="/account/notifications" element={<PlayerNotifications />} />
{/* Installed modules' player-portal pages, at /player/<id>/…. This {/* Installed modules' player-portal pages, at /player/<id>/…. This
group's own routes are absolute (its layout route has no path), group's own routes are absolute (its layout route has no path),
so the prefix is written here rather than inherited — the one so the prefix is written here rather than inherited — the one

View File

@@ -133,6 +133,80 @@ export const api = {
return req(`/public/wiki${withQs(s)}`) return req(`/public/wiki${withQs(s)}`)
}, },
wikiCategories: () => req('/public/wiki/categories'), 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'), wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`), wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link // 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 } }), setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
restartServer: () => req('/admin/modules/restart', { method: 'POST' }), 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) ----- // ----- moderation dashboard (admin + moderator) -----
modSummary: () => req('/admin/moderation/stats/summary'), 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 = {}) => { modRecent: (params = {}) => {
const qs = new URLSearchParams() const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type) if (params.type) qs.set('type', params.type)

View File

@@ -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.'
}

140
client/src/lib/teamAdmin.js Normal file
View File

@@ -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 moderators 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'})`,
}
}

View File

@@ -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
* `<img>` 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(/<img[^>]*>/gi, '')
.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]*>/g, '')
// Entities last: unescaping before tag-stripping would let an escaped
// "&lt;script&gt;" become a real tag the next pass then removes, which is a
// different string from the one the author wrote.
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
// `&amp;` last of all, or "&amp;lt;" would decode two steps into "<".
.replace(/&amp;/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(' · ')
}

View File

@@ -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)),
}
}

112
client/src/lib/teamVoice.js Normal file
View File

@@ -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 bots 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.`
}

View File

@@ -3,7 +3,10 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx' import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js' 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' import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates. // 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 // 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 // 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. // 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) ─────────────────────────────────── // ── 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 // 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). // 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 // Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes. // 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 // 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. // been and gone" case and not a wrong guess about our own timing.
function mount() { 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( createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>

View File

@@ -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 (
<section style={{ marginTop: 26 }}>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', marginBottom: 4 }}>
Recent activity
</h2>
{note && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 12px' }}>{note.text}</p>
)}
{days.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.9rem' }}>Nothing has happened here yet.</p>
)}
{days.map((day) => (
<div key={day.key} style={{ marginBottom: 16 }}>
<h3
className="sans dim"
style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 6 }}
>
{day.label}
</h3>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: 6 }}>
{day.items.map((item) => (
<li key={item.id} className="sans" style={{ fontSize: '0.92rem', color: 'var(--ink)' }}>
{item.summary}
</li>
))}
</ul>
</div>
))}
{scopeNote && (
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>{scopeNote}</p>
)}
</section>
)
}

View File

@@ -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 (
<ThreadView
slug={team.slug}
thread={thread}
canModerate={forum.canModerate}
imageMode={imageMode}
onBack={() => 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 (
<section style={{ marginTop: 26 }}>
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: 0 }}>
Forum
</h2>
{!composing && (
<div style={{ display: 'flex', gap: 8 }}>
{/*
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 && (
<button type="button" className="pill" onClick={() => setComposing('discussion')}>
Start a discussion
</button>
)}
{forum.canAnnounce && (
<button type="button" className="pill" onClick={() => setComposing('announcement')}>
Post an announcement
</button>
)}
</div>
)}
</header>
{composing && (
<Composer
slug={team.slug}
type={composing}
imageMode={imageMode}
onCancel={() => setComposing(null)}
onPosted={async () => {
setComposing(null)
await loadThreads(team.slug)
}}
/>
)}
{forum.threads.length === 0 && !composing && (
<p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}>
Nothing has been posted here yet.
</p>
)}
{forum.canModerate && <GuestManager slug={team.slug} />}
<ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0', display: 'grid', gap: 8 }}>
{forum.threads.map((t) => (
<li key={t.id}>
<button
type="button"
className="sans"
onClick={() => openThread(t.id)}
style={{
background: 'none', border: 0, padding: 0, cursor: 'pointer',
textAlign: 'left', color: 'var(--ink)', font: 'inherit',
}}
>
{t.pinned && <span className="dim" style={{ marginRight: 6 }} title="Pinned">📌</span>}
{t.locked && <span className="dim" style={{ marginRight: 6 }} title="Locked">🔒</span>}
<strong>{t.title}</strong>
<span className="dim" style={{ marginLeft: 8, fontSize: '0.82rem' }}>
{threadSummary(t)}
</span>
</button>
</li>
))}
</ul>
</section>
)
}
/**
* 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 (
<button type="button" className="pill" onClick={() => setOpen(true)} style={{ marginTop: 10 }}>
Forum guests
</button>
)
}
return (
<section style={{ marginTop: 12, padding: 12, border: '1px solid var(--rule, #ccc)', borderRadius: 6 }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<h3 className="sans" style={{ margin: 0, fontSize: '0.95rem' }}>Forum guests</h3>
<button type="button" className="pill" onClick={() => setOpen(false)}>Close</button>
</header>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '6px 0 10px' }}>
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.` : ''}
</p>
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 10px', display: 'grid', gap: 6 }}>
{(data?.guests || []).map((g) => (
<li key={g.userId} className="sans" style={{ fontSize: '0.88rem', display: 'flex', gap: 8 }}>
<span>{g.username}</span>
<button type="button" className="pill" onClick={() => revoke(g.userId)}>Remove</button>
</li>
))}
{data && data.guests.length === 0 && (
<li className="sans dim" style={{ fontSize: '0.85rem' }}>No guests yet.</li>
)}
</ul>
<form onSubmit={add} style={{ display: 'flex', gap: 8 }}>
<input
className="input"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Account name"
maxLength={32}
required
/>
<button type="submit" className="btn btn-primary btn-sq">Add</button>
</form>
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
</section>
)
}
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 (
<section style={{ marginTop: 26 }}>
<button type="button" className="pill" onClick={onBack} style={{ marginBottom: 10 }}>
All threads
</button>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}>
{thread.title}
</h2>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
{thread.type === 'announcement' ? 'Announcement · ' : ''}
{thread.author}
{thread.authorDeleted && ' (account removed)'}
{thread.locked && ' · locked'}
</p>
{thread.posts.map((post) => (
<PostView
key={post.id}
slug={slug}
post={post}
canModerate={canModerate}
now={now}
onChanged={onChanged}
/>
))}
{/*
`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 && (
<button type="button" className="pill" onClick={() => setReplying(true)} style={{ marginTop: 4 }}>
Reply
</button>
)}
{thread.canReply && replying && (
<ReplyBox
slug={slug}
threadId={thread.id}
imageMode={imageMode}
onCancel={() => setReplying(false)}
onPosted={async () => {
setReplying(false)
await onChanged()
}}
/>
)}
{!thread.canReply && thread.locked && (
<p className="sans dim" style={{ fontSize: '0.85rem', marginTop: 10 }}>
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.
</p>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' }}>
<ReportControl
slug={slug}
targetType="team_forum_thread"
targetId={thread.id}
label="Report this thread"
/>
{canModerate && (
<>
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
{thread.pinned ? 'Unpin' : 'Pin'}
</button>
<button type="button" className="pill" onClick={() => onModerate(thread.locked ? 'unlock' : 'lock')}>
{thread.locked ? 'Unlock' : 'Lock'}
</button>
<button type="button" className="pill" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
</>
)}
</div>
</section>
)
}
/**
* 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 (
<article style={{ marginBottom: 16 }}>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 2px' }}>
{post.author}
{post.authorDeleted && ' (account removed)'}
{post.editedAt && ' · edited'}
{post.status === 'hidden' && ' · hidden'}
</p>
{editing ? (
<form onSubmit={save} style={{ display: 'grid', gap: 8 }}>
<textarea
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={6}
required
/>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Save</button>
<button type="button" className="pill" onClick={() => setEditing(false)}>Cancel</button>
</div>
</form>
) : (
<>
{/*
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
<img> 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 */}
<div
className="prose"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body || '', { ADD_ATTR: ['referrerpolicy'] }) }}
/>
</>
)}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
{!editing && (
<div style={{ display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{stillEditable && (
<button
type="button"
className="pill"
onClick={() => { setBody(stripToText(post.body)); setEditing(true) }}
>
Edit
</button>
)}
{/* Reporting your own post is pointless rather than harmful, but
offering it reads as an invitation to misunderstand the control. */}
{!post.mine && (
<ReportControl
slug={slug}
targetType="team_forum_post"
targetId={post.id}
label="Report"
/>
)}
{canModerate && (
<>
<button type="button" className="pill" onClick={() => moderate(post.status === 'hidden' ? 'unhide' : 'hide')}>
{post.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
<button type="button" className="pill" onClick={() => moderate('delete')}>Delete</button>
</>
)}
</div>
)}
</article>
)
}
/**
* 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 (
<span className="sans dim" style={{ fontSize: '0.8rem' }}>
Reported to site staff.
</span>
)
}
if (!open) {
return (
<button type="button" className="pill" onClick={() => setOpen(true)}>
{label}
</button>
)
}
return (
<form
onSubmit={submit}
style={{
display: 'grid', gap: 8, marginTop: 8, padding: 12, width: '100%',
border: '1px solid var(--rule, #ccc)', borderRadius: 6,
}}
>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
This goes to <strong>site staff</strong>, not to this Team&rsquo;s leaders. Reporting does not
hide or change anything it asks a staffer to look.
</p>
<label className="sans" style={{ fontSize: '0.85rem' }}>
Reason
{' '}
<select className="input" value={reason} onChange={(e) => setReason(e.target.value)}>
{REPORT_REASONS.map(([value, text]) => (
<option key={value} value={value}>{text}</option>
))}
</select>
</label>
<textarea
className="textarea"
value={detail}
onChange={(e) => setDetail(e.target.value)}
placeholder="Anything a staffer should know (optional)"
maxLength={500}
rows={3}
/>
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Send report</button>
<button type="button" className="pill" onClick={() => setOpen(false)}>Cancel</button>
</div>
</form>
)
}
/** 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 (
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 10 }}>
<textarea
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write a reply. Paste an image URL on its own line to share a picture."
rows={5}
required
/>
{imageMode === 'uploads' && (
<ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
)}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Post reply</button>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}
/**
* The upload control, shared by both composers.
*
* The URL goes into the BODY as text, never as an `<img>` 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 (
<label className="sans dim" style={{ fontSize: '0.85rem' }}>
Attach an image: <input type="file" accept="image/*" onChange={attach} />
</label>
)
}
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 (
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 12 }}>
<input
className="input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
maxLength={200}
required
/>
<textarea
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={isAnnouncement
? 'Write your announcement. Paste an image URL on its own line to share a picture.'
: 'Start the discussion. Paste an image URL on its own line to share a picture.'}
rows={6}
required
/>
{isAnnouncement && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
Announcements cannot be replied to.
</p>
)}
{imageMode === 'uploads' && (
<ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
)}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
{isAnnouncement ? 'Post announcement' : 'Start discussion'}
</button>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}

View File

@@ -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 (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
flexWrap: 'wrap',
margin: '10px 0 0',
fontSize: '0.84rem',
}}
>
<button type="button" onClick={toggle} disabled={busy} className="btn btn-sq">
{pref.muted ? 'Unmute notifications' : 'Mute notifications'}
</button>
<span className="dim">
{pref.muted
? 'You get no notifications about this team.'
: 'You get notifications about this team.'}
</span>
{/* 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. */}
<Link to="/account/notifications" className="dim">All notification settings</Link>
</div>
)
}

View File

@@ -135,6 +135,120 @@ export function declareSlot(name) {
slots.set(name, { Component: null, filledBy: null }) 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 <contribution>, 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. * Fill a declared slot with a component.
* *
@@ -207,6 +321,7 @@ export function _reset() {
nav[area].length = 0 nav[area].length = 0
} }
providers.clear() providers.clear()
coreFills.length = 0
// Declarations go too, unlike the server's, where a slot is declared once at // 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 // 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 // main.jsx — the one file no test loads — so on this side there is nothing
@@ -224,6 +339,8 @@ export const registry = {
registerNav, registerNav,
registerFeatureProvider, registerFeatureProvider,
registerExtension, registerExtension,
// The inverted direction (TEAMS.md Part 3): the module declares, core fills.
declareModuleSlot,
routesFor, routesFor,
navFor, navFor,
featureProviderFor, featureProviderFor,

View File

@@ -34,6 +34,7 @@ import { MODULE_API_VERSION } from './version.js'
import PublicLayout from '../components/PublicLayout.jsx' import PublicLayout from '../components/PublicLayout.jsx'
import PageHeader from '../components/PageHeader.jsx' import PageHeader from '../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx' import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
import Slot from './Slot.jsx'
import { useAsync } from '../lib/useAsync.js' import { useAsync } from '../lib/useAsync.js'
import { useAuth } from '../contexts/AuthContext.jsx' import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx' import { useSite } from '../contexts/SiteContext.jsx'
@@ -66,6 +67,13 @@ const ui = {
useAsync, useAsync,
useAuth, useAuth,
useSite, 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 // The request PRIMITIVE, not the `api` object (§3.5): a module builds its own

View File

@@ -11,6 +11,13 @@
// that the two files can drift, so a test asserts they agree // that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both. // 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' | // 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 // '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 // 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 // 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 // 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. // 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'

View File

@@ -76,6 +76,17 @@ export const NAV = [
items: [ items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] }, { to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', 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/hero': 'Hero Editor',
'/admin/moderation': 'Moderation', '/admin/moderation': 'Moderation',
'/admin/moderation/appeals': 'Appeals', '/admin/moderation/appeals': 'Appeals',
'/admin/moderation/reports': 'Reports',
'/admin/settings': 'Site Settings', '/admin/settings': 'Site Settings',
'/admin/appearance': 'Appearance', '/admin/appearance': 'Appearance',
'/admin/navigation': 'Navigation', '/admin/navigation': 'Navigation',

View File

@@ -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 (
<span style={{ color: 'var(--muted)' }}>
{report.targetType.replace('team_forum_', '')} #{report.targetId} no longer exists
</span>
)
}
if (t.kind === 'upload') {
return (
<span>
<a href={t.url} target="_blank" rel="noopener noreferrer" className="link-accent">{t.filename}</a>
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.uploader || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
{t.deleted && ' · removed'}
</span>
</span>
)
}
if (t.kind === 'thread') {
return (
<span>
<strong>{t.title}</strong>
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.type} by {t.author || 'unknown'}
{t.status !== 'visible' && ` · ${t.status}`}
</span>
</span>
)
}
return (
<span>
{t.excerpt || <em className="dim">(no text)</em>}
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.author || 'unknown'} in {t.threadTitle}
{t.status !== 'visible' && ` · ${t.status}`}
</span>
</span>
)
}
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 <Loading />
if (error) return <ErrorState message="Could not load reports." />
const rows = data?.reports || []
return (
<section>
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: 720 }}>
Reports raised by members about Team forum content. They come to site staff and are not visible
to a Team&rsquo;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.`}
</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 16 }}>
{STATUS_TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className="pill"
style={tab === t.key ? activePill : undefined}
>
{t.label}
</button>
))}
</div>
{notice && (
<p
className="sans"
style={{ margin: '0 0 14px', color: notice.tone === 'error' ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}
>
{notice.text}
</p>
)}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Reported content</th>
<th className="adm-th">Reason</th>
<th className="adm-th">Detail</th>
<th className="adm-th">Reporter</th>
<th className="adm-th">Age</th>
<th className="adm-th">Status</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td className="adm-td" colSpan={7} style={muted}>
No reports match this filter.
</td>
</tr>
)}
{rows.map((r) => (
<tr key={r.id}>
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 340 }}>
<TargetCell report={r} />
</td>
<td className="adm-td">
<span className="badge">{REASON_LABEL[r.reason] || r.reason}</span>
</td>
<td className="adm-td dim" style={{ maxWidth: 260 }}>{r.detail || '—'}</td>
<td className="adm-td dim">{r.reporter}</td>
<td className="adm-td dim" title={dateTime(r.createdAt)}>{ago(r.createdAt)}</td>
<td className="adm-td">
<span className="badge" style={STATUS_STYLE[r.status]}>{STATUS_LABEL[r.status] || r.status}</span>
{r.handledBy && (
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>
{r.handledBy}
{r.handledNote ? `${r.handledNote}` : ''}
</span>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button
onClick={() => setHandling(r)}
className="btn btn-primary btn-sq"
style={{ padding: '5px 12px', fontSize: '0.82rem' }}
>
Handle
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{handling && (
<HandleModal
report={handling}
onCancel={() => setHandling(null)}
onDone={() => {
setHandling(null)
setNotice({ text: 'Report updated.', tone: 'ok' })
reload()
}}
onError={(message) => setNotice({ text: message, tone: 'error' })}
/>
)}
</section>
)
}
/**
* 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 (
<Modal
title={`Report #${report.id}`}
onClose={onCancel}
footer={(
<>
<button className="pill" onClick={onCancel}>Cancel</button>
<button className="btn btn-primary btn-sq" onClick={submit} disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</button>
</>
)}
>
<div style={{ display: 'grid', gap: 12 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
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.
</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{['reviewing', 'actioned', 'dismissed', 'open'].map((value) => (
<button
key={value}
onClick={() => setStatus(value)}
className="pill"
style={status === value ? activePill : undefined}
>
{STATUS_LABEL[value]}
</button>
))}
</div>
<label>
<span className="field-label">Note (optional)</span>
<textarea
className="textarea"
placeholder="Why this was actioned or dismissed — the next staffer to see a repeat report reads this."
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={500}
rows={4}
style={{ width: '100%' }}
/>
</label>
</div>
</Modal>
)
}
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
const muted = { color: 'var(--muted)' }

View File

@@ -3,6 +3,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx' import { useSite } from '../../../contexts/SiteContext.jsx'
import EmailDelivery from './EmailDelivery.jsx' import EmailDelivery from './EmailDelivery.jsx'
import TeamForumSettings from './TeamForumSettings.jsx'
// Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor). // Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor).
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx')) const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
@@ -143,6 +144,8 @@ export default function SettingsAdmin() {
</div> </div>
</div> </div>
<TeamForumSettings />
<EmailDelivery /> <EmailDelivery />
</section> </section>
) )

View File

@@ -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 visitors 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 (
<section style={{ marginTop: 34, maxWidth: 620 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Team forums</h2>
{stale && (
<p className="sans" style={{ fontSize: '0.82rem', color: '#e0b877', margin: '0 0 12px' }}>
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.
</p>
)}
<label style={{ display: 'block', marginBottom: 14 }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => { setEnabled(e.target.checked); setSaved(false) }}
style={{ marginRight: 8 }}
/>
<span className="field-label" style={{ display: 'inline' }}>Enable Team forums</span>
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
Off by default. Switching forums off hides them completely every forum route answers not
found but deletes nothing: threads, posts, access grants and notification preferences all
survive and come back exactly as they were.
</span>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Images in forum posts</span>
<select value={mode} onChange={(e) => { setMode(e.target.value); setSaved(false) }} className="select">
{MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</label>
<label style={{ display: 'block', marginTop: 14 }}>
<span className="field-label">Post edit window (minutes)</span>
<input
type="number"
className="input"
min={0}
max={state.editWindowMax ?? 1440}
value={editWindow}
onChange={(e) => { setEditWindow(e.target.value); setSaved(false) }}
style={{ maxWidth: 120 }}
/>
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
How long an author may edit their own post after writing it. Staff are not bound by it and
may edit at any time. Set it to 0 to make posts permanent once written a bound of some
kind is what stops a post being rewritten out from under someone quoting it, or under a
moderator about to act on a report.
</span>
</label>
<div className="sans dim" style={{ marginTop: 8, fontSize: '0.76rem', lineHeight: 1.55 }}>
{HELP_TEXT.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
<ul style={{ margin: '0 0 6px 18px' }}>
{HELP_BULLETS.map((b) => <li key={b}>{b}</li>)}
</ul>
{HELP_TAIL.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
{mode !== 'disabled' && (
<p style={{ margin: '0 0 6px', color: '#e0b877' }}>{REMOTE_ADVISORY}</p>
)}
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 12, alignItems: 'center' }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save forum settings'}
</button>
{saved && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
{dialog && (
<UploadsDialog
version={state.acknowledgement.version}
onCancel={() => {
setDialog(null)
setMode(state.imageMode)
setEnabled(state.enabled)
setEditWindow(String(state.editWindowMinutes ?? 15))
}}
onConfirm={async (version) => {
setDialog(null)
await persist(dialog, version)
}}
/>
)}
</section>
)
}
/**
* 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 (
<div
role="dialog"
aria-modal="true"
aria-label="Enable image uploads"
style={{
marginTop: 14, padding: 14, border: '1px solid #e0b877', borderRadius: 6,
}}
>
<p className="sans" style={{ margin: '0 0 8px', fontWeight: 600 }}>
Image uploads are currently disabled.
</p>
<p className="sans" style={{ margin: '0 0 10px', fontSize: '0.88rem' }}>
Enabling uploads will allow users to store files on your server.
</p>
{DIALOG_CHECKS.map((text, i) => (
<label key={text} className="sans" style={{ display: 'block', fontSize: '0.85rem', marginBottom: 6 }}>
<input
type="checkbox"
checked={checks[i]}
onChange={(e) => setChecks((c) => c.map((v, j) => (j === i ? e.target.checked : v)))}
style={{ marginRight: 8 }}
/>
{text}
</label>
))}
<p className="sans dim" style={{ margin: '10px 0', fontSize: '0.8rem' }}>{DIALOG_TAIL}</p>
<div style={{ display: 'flex', gap: 10 }}>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={!all}
onClick={() => onConfirm(version)}
>
Enable uploads
</button>
</div>
</div>
)
}

View File

@@ -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 Teams 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 (
<section style={{ marginTop: 34, maxWidth: 760 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Notification bridge</h2>
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
</section>
)
}
const membersOnlyIds = membersOnlyIdsOf(config.events)
const { hasDefault, teams: available } = availableTargets(config.rows, teams)
async function persist(next) {
setBusy(true)
setError('')
setNotice('')
try {
await api.admin.saveTeamIntegration({
teamId: next.teamId,
events: next.events,
channelRef: next.channelRef.trim() || null,
enabled: next.enabled,
membersAck: next.membersAck,
})
setDraft(null)
setDialog(null)
setNotice('Saved.')
await load()
} catch (err) {
setError(err.message || 'Could not save.')
setDialog(null)
} finally {
setBusy(false)
}
}
// Enabling members-only events without a standing acknowledgement asks first.
// Everything else — disabling, editing a channel, adding a roster event — saves
// straight through.
function save() {
if (!draft) return
if (needsAcknowledgement(draft, membersOnlyIds)) {
setDialog(draft)
return
}
persist(draft)
}
async function remove(row) {
setBusy(true)
setError('')
try {
await api.admin.deleteTeamIntegration(row.team_id ?? null)
setNotice('Removed.')
await load()
} catch (err) {
setError(err.message || 'Could not remove.')
} finally {
setBusy(false)
}
}
return (
<section style={{ marginTop: 34, maxWidth: 760 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Notification bridge</h2>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
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.
</p>
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
{notice && <p className="sans" style={{ color: '#8fbf7a', fontSize: '0.82rem' }}>{notice}</p>}
{config.rows.length === 0 && !draft && (
<p className="sans dim" style={{ fontSize: '0.8rem' }}>Nothing configured no Team events leave the site.</p>
)}
{config.rows.length > 0 && (
<table className="table">
<thead>
<tr><th>Applies to</th><th>Events</th><th>Channel</th><th>State</th><th /></tr>
</thead>
<tbody>
{config.rows.map((row) => (
<tr key={rowKey(row)}>
<td>
{appliesToLabel(row)}
{isDefaultRow(row) && <span className="dim"> (default)</span>}
</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>
{row.events.length === 0
? <span className="dim">none</span>
: row.events.map(eventLabel).join(', ')}
</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>{row.channel_ref || <span className="dim">unset</span>}</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>
{row.enabled ? 'Enabled' : 'Disabled'}
{row.members_ack && (
<span className="dim" style={{ display: 'block' }}>
members-only destination confirmed
{row.members_ack_username ? ` by ${row.members_ack_username}` : ''}
</span>
)}
</td>
<td>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => setDraft(draftFrom(row))}>Edit</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => remove(row)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{!draft && (
<div style={{ marginTop: 12 }}>
{!hasDefault && (
<button type="button" className="btn-ghost" onClick={() => setDraft(blankDraft(null))}>
Set a default for all Teams
</button>
)}
{available.length > 0 && (
<button type="button" className="btn-ghost" onClick={() => setDraft(blankDraft(available[0].id))}>
Add a per-Team override
</button>
)}
</div>
)}
{draft && (
<div style={{ marginTop: 18, borderTop: '1px solid rgba(255,255,255,0.12)', paddingTop: 16 }}>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Applies to</span>
<select
className="select"
value={draft.teamId === null ? 'default' : String(draft.teamId)}
onChange={(e) => setDraft({ ...draft, teamId: e.target.value === 'default' ? null : Number(e.target.value) })}
>
<option value="default">All Teams (default)</option>
{teams.map((t) => (
<option key={t.id} value={t.id}>{t.display_name_override || t.name}</option>
))}
</select>
</label>
<span className="field-label">Events to send</span>
{config.events.map((event) => (
<label key={event.id} style={{ display: 'block', marginTop: 6 }}>
<input
type="checkbox"
checked={draft.events.includes(event.id)}
onChange={() => setDraft((d) => toggleEvent(d, event.id))}
style={{ marginRight: 8 }}
/>
<span className="sans" style={{ fontSize: '0.82rem' }}>{eventLabel(event.id)}</span>
{event.membersOnly && (
<span className="dim sans" style={{ fontSize: '0.72rem', marginLeft: 8 }}>members-only content</span>
)}
</label>
))}
<label style={{ display: 'block', marginTop: 14 }}>
<span className="field-label">Channel id</span>
<input
className="input"
value={draft.channelRef}
// Changing the channel drops a standing acknowledgement in the SAME
// place the server does. Leaving the tick showing while the server
// has already decided to clear it would let an operator repoint a row
// at a public channel and believe the confirmation still covered it.
onChange={(e) => setDraft((d) => setChannel(d, e.target.value))}
placeholder="1024839201048392010"
style={{ maxWidth: 280 }}
/>
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
Right-click a channel in {config.platform} and copy its id. Changing it asks you to confirm
the new channels audience again.
</span>
</label>
<label style={{ display: 'block', marginTop: 14 }}>
<input
type="checkbox"
checked={draft.enabled}
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
style={{ marginRight: 8 }}
/>
<span className="field-label" style={{ display: 'inline' }}>Enabled</span>
</label>
{draft.membersAck && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 10 }}>
You have confirmed this channel is restricted to the Teams members.{' '}
<button type="button" className="btn-ghost" onClick={() => setDraft({ ...draft, membersAck: false })}>
Withdraw
</button>
</p>
)}
<div style={{ marginTop: 16 }}>
<button type="button" className="btn" disabled={busy} onClick={save}>Save</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => { setDraft(null); setError('') }}>Cancel</button>
</div>
</div>
)}
{dialog && (
<div style={{ marginTop: 18, border: '1px solid #e0b877', padding: 14, borderRadius: 4 }}>
<h3 className="display" style={{ fontSize: '0.95rem', marginTop: 0 }}>Confirm the destinations audience</h3>
{ACK_TEXT.map((line) => (
<p key={line} className="sans" style={{ fontSize: '0.8rem' }}>{line}</p>
))}
<button
type="button"
className="btn"
disabled={busy}
onClick={() => persist({ ...dialog, membersAck: true })}
>
I confirm the channel is members-only
</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => setDialog(null)}>Cancel</button>
</div>
)}
</section>
)
}

View File

@@ -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 (
<section style={{ marginTop: 34, maxWidth: 760 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Voice channels</h2>
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
</section>
)
}
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 (
<section style={{ marginTop: 34, maxWidth: 760 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Voice channels</h2>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
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.
</p>
{blocked && (
<p className="sans" style={{ color: '#e0b877', fontSize: '0.82rem' }}>
{blocked} Voice channels cannot be switched on until that is fixed.
</p>
)}
{headroom && (
<p className="sans dim" style={{ fontSize: '0.78rem' }}>
{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.`
: '.'}
</p>
)}
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
{notice && <p className="sans" style={{ color: '#8fbf7a', fontSize: '0.82rem' }}>{notice}</p>}
<p className="sans" style={{ fontSize: '0.8rem' }}>{statusSummary(config.settings, config.rows)}</p>
<div style={{ marginTop: 14, borderTop: '1px solid rgba(255,255,255,0.12)', paddingTop: 16 }}>
<label className="sans" style={{ display: 'block', marginBottom: 12, fontSize: '0.82rem' }}>
<input
type="checkbox"
checked={draft.enabled}
disabled={busy || (!!blocked && !draft.enabled)}
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
/>
{' '}Provision voice channels for Teams
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Minimum members</span>
<input
className="input"
type="number"
min="1"
max="10000"
value={draft.minMembers}
disabled={busy}
onChange={(e) => setDraft({ ...draft, minMembers: e.target.value })}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
Every active member counts, whether or not they have linked an account.
</span>
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Grace window (days)</span>
<input
className="input"
type="number"
min="0"
max="90"
value={draft.graceDays}
disabled={busy}
onChange={(e) => setDraft({ ...draft, graceDays: e.target.value })}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the
window keeps the same channel; zero removes it on the next pass.
</span>
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Staff roles</span>
<input
className="input"
type="text"
value={draft.staffRoles}
disabled={busy}
placeholder="role id, role id"
onChange={(e) => setDraft({ ...draft, staffRoles: e.target.value })}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
Roles that can see and join every Teams channel. Guild administrators already can, so this
is for staff who are not administrators. Leave empty if there are none.
</span>
</label>
<button type="button" className="btn" disabled={busy} onClick={save}>Save</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={runPass}>Sync now</button>
</div>
{config.rows.length > 0 && (
<table className="table" style={{ marginTop: 18 }}>
<thead>
<tr><th>Team</th><th>Members</th><th>Channel</th><th>State</th><th /></tr>
</thead>
<tbody>
{config.rows.map((row) => (
<tr key={row.teamId}>
<td>{row.teamName}</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>{row.memberCount}</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>
{row.channelRef || <span className="dim">none</span>}
</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>
{stateLabel(row.state)}
{removalCountdown(row) && (
<span className="dim" style={{ display: 'block' }}>{removalCountdown(row)}</span>
)}
{row.lastError && (
<span style={{ display: 'block', color: '#e08b77' }}>{row.lastError}</span>
)}
</td>
<td>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => remove(row)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{config.lastPass && config.lastPass.at && (
<p className="sans dim" style={{ fontSize: '0.74rem', marginTop: 10 }}>
Last pass {new Date(config.lastPass.at).toLocaleString()}
{config.lastPass.ran ? '' : ` — nothing was done: ${config.lastPass.reason}`}
</p>
)}
</section>
)
}

View File

@@ -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 (
<span
className="badge"
style={{ color: TONE_COLOR[tone] || 'var(--muted)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
>
{children}
</span>
)
}
// ── Sync state ─────────────────────────────────────────────────────────────
function SyncPanel({ sync, syncState, onResync, busy }) {
const freshness = freshnessOf(sync)
return (
<section className="panel" style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '.75rem', flexWrap: 'wrap' }}>
<h2 style={{ margin: 0 }}>Sync</h2>
<Pill tone={freshness.tone}>{freshness.label}</Pill>
<button type="button" className="btn" onClick={onResync} disabled={busy || !sync.configured}>
{busy ? 'Resyncing…' : 'Resync now'}
</button>
</div>
<p className="muted" style={{ marginTop: '.5rem' }}>{freshness.detail}</p>
{syncState && (
<dl className="kv" style={{ marginTop: '.75rem' }}>
<dt>Module</dt><dd>{syncState.moduleId}</dd>
<dt>Last attempt</dt><dd>{dateTime(syncState.lastAttemptAt) || 'never'}</dd>
<dt>Last success</dt><dd>{dateTime(syncState.lastSuccessAt) || 'never'}</dd>
<dt>Consecutive failures</dt><dd>{syncState.consecutiveFailures}</dd>
{syncState.lastError && (
<>
{/* Verbatim. An operator debugging a stale projection needs what the
provider actually said, not a friendlier paraphrase of it. */}
<dt>Last error</dt>
<dd style={{ color: TONE_COLOR.bad }}>{syncState.lastError}</dd>
</>
)}
{syncState.pendingEmptySince && (
<>
<dt>Empty answer held</dt>
<dd>
since {dateTime(syncState.pendingEmptySince)} an authoritative but empty list is
applied only if the next answer agrees.
</dd>
</>
)}
</dl>
)}
</section>
)
}
// ── The reserved-name review queue ─────────────────────────────────────────
function ReviewQueue({ rows, role, onAct, busy }) {
if (!rows.length) return null
return (
<section className="panel" style={{ marginBottom: '1.5rem' }}>
<h2>Names to review</h2>
<p className="muted">
These Teams are hidden from every public surface because their name matched a reserved term.
They work normally for their own members. {GATED_NOTE}
</p>
<table className="table">
<thead>
<tr><th>Name</th><th>Matched</th><th>Members</th><th>Created</th><th /></tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>{row.name}</td>
<td><Pill tone="bad">{row.hidden_term}</Pill></td>
<td>{row.member_count}</td>
<td>{dateTime(row.created_at)}</td>
<td>
<button type="button" className="btn" disabled={busy} onClick={() => onAct(row.id, 'unhide')}>
{gateLabelFor(role, 'Publish')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</section>
)
}
// ── The approval queue ─────────────────────────────────────────────────────
function RequestQueue({ rows, role, onDecide, busy }) {
if (!rows.length) return null
const canDecide = role === 'admin'
return (
<section className="panel" style={{ marginBottom: '1.5rem' }}>
<h2>Awaiting approval</h2>
<p className="muted">
{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.'}
</p>
<ul className="list">
{rows.map((row) => (
<li key={row.id} style={{ display: 'flex', gap: '.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span>{describeRequest(row)}</span>
<span className="muted">{dateTime(row.requested_at)}</span>
{row.reason && <span className="muted">{row.reason}</span>}
{canDecide && (
<>
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'approved')}>
Approve
</button>
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'rejected')}>
Reject
</button>
</>
)}
</li>
))}
</ul>
</section>
)
}
// ── One Team ───────────────────────────────────────────────────────────────
function TeamRow({ team, role, onAct, busy, onLedger }) {
const status = statusOf(team)
return (
<tr>
<td>
{team.displayName}
{team.displayNameOverride && (
<div className="muted" style={{ fontSize: '.85em' }}>
shown instead of {team.name}
</div>
)}
</td>
<td><Pill tone={status.tone}>{status.label}</Pill></td>
<td>{team.memberCount}</td>
<td>{team.linkedCount}</td>
<td>{team.onlineCount}</td>
<td className="muted">{dateTime(team.rosterSyncedAt) || 'never'}</td>
<td>
{team.status === 'active' && (team.hidden
? (
<button type="button" className="btn" disabled={busy} onClick={() => onAct(team.id, 'unhide')}>
{gateLabelFor(role, 'Publish')}
</button>
)
: (
<button type="button" className="btn" disabled={busy} onClick={() => onAct(team.id, 'hide')}>
Hide
</button>
))}
<button type="button" className="btn" onClick={() => onLedger(team)} style={{ marginLeft: 6 }}>
Forum log
</button>
</td>
</tr>
)
}
/**
* 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 (
<section className="panel">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<h2>Forum log {team.displayName}</h2>
<button type="button" className="btn" onClick={onClose}>Close</button>
</header>
{error && <ErrorState message={error} />}
{!rows && !error && <Loading />}
{rows && rows.length === 0 && <p className="muted">Nothing has been moderated in this forum.</p>}
{rows && rows.length > 0 && (
<table className="table">
<thead>
<tr>
<th>When</th><th>Action</th><th>Target</th><th>By</th><th>As</th><th>Reason</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="muted">{dateTime(r.created_at)}</td>
<td>{r.action}</td>
<td className="muted">{r.target_type} #{r.target_id}</td>
<td>{r.actor_username || '—'}</td>
<td>
{/* The distinction the whole ledger exists to preserve. */}
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>
</td>
<td className="muted">{r.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
)
}
// ── 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 <ErrorState message={error} />
if (!data) return <Loading />
return (
<div>
<h1>Teams</h1>
{error && <ErrorState message={error} />}
{notice && <p className="notice">{notice}</p>}
{ledgerTeam && <ForumLedger team={ledgerTeam} onClose={() => 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' && <TeamIntegrations />}
{role === 'admin' && <TeamVoice />}
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
<section className="panel">
<h2>All Teams</h2>
{!data.teams.length && (
<p className="muted">
{data.configured
? 'No Teams in the projection yet.'
: 'No installed module supplies Teams, so there is nothing to show.'}
</p>
)}
{data.teams.length > 0 && (
<table className="table">
<thead>
<tr>
<th>Name</th><th>Status</th><th>Members</th><th>Linked</th><th>Online</th>
<th>Roster confirmed</th><th />
</tr>
</thead>
<tbody>
{data.teams.map((team) => (
<TeamRow
key={team.id}
team={team}
role={role}
onAct={act}
busy={busy}
onLedger={setLedgerTeam}
/>
))}
</tbody>
</table>
)}
</section>
</div>
)
}
export { leadershipOf }

View File

@@ -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 (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 26, marginTop: 26 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: 'var(--head)' }}>{title}</h2>
{hint && <p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.86rem' }}>{hint}</p>}
{children}
</section>
)
}
function Note({ msg, error }) {
if (!msg && !error) return null
return (
<p className="sans" style={{ margin: '10px 0 0', color: error ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}>
{error || msg}
</p>
)
}
// ── What to be told about ──────────────────────────────────────────────────
function Streams({ streams, subscribed, onSave, busy, msg, error }) {
const [set, setSet] = useState(() => new Set(subscribed))
useEffect(() => { setSet(new Set(subscribed)) }, [subscribed])
const toggle = (id) => {
const next = new Set(set)
if (next.has(id)) next.delete(id)
else next.add(id)
setSet(next)
}
const team = streams.filter((s) => isTeamStream(s.id))
const rest = streams.filter((s) => !isTeamStream(s.id))
const row = (s) => (
<label key={s.id} className="sans" style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: '0.92rem' }}>
<input type="checkbox" checked={set.has(s.id)} onChange={() => toggle(s.id)} style={{ marginTop: 3 }} />
<span>
<span style={{ color: 'var(--ink)' }}>{s.label}</span>
{s.description && <span className="dim" style={{ display: 'block', fontSize: '0.82rem' }}>{s.description}</span>}
</span>
</label>
)
return (
<Section
title="What to notify me about"
hint="Applies to every device you have signed in on. Notifications are delivered to the app; the website itself does not pop anything up."
>
<div style={{ display: 'grid', gap: 12 }}>{rest.map(row)}</div>
{team.length > 0 && (
<>
<h3 className="sans dim" style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '20px 0 10px' }}>
Teams
</h3>
<div style={{ display: 'grid', gap: 12 }}>{team.map(row)}</div>
</>
)}
<div style={{ marginTop: 18 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave([...set])}>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
<Note msg={msg} error={error} />
</Section>
)
}
// ── Which Teams, and whether by email ──────────────────────────────────────
function Teams({ teams, onSave, busy, msg, error }) {
const [rows, setRows] = useState(teams)
useEffect(() => { setRows(teams) }, [teams])
const patch = (teamId, change) =>
setRows((rs) => rs.map((r) => (r.teamId === teamId ? { ...r, ...change } : r)))
if (rows.length === 0) {
return (
<Section title="Teams">
<p className="sans dim" style={{ fontSize: '0.9rem', margin: 0 }}>
You are not in a team, and nobody has given you access to a team forum. There is nothing to
configure here yet.
</p>
</Section>
)
}
return (
<Section
title="Teams"
hint="Muting a team silences all four team notifications for it, without changing anything for your other teams. Email is off until you turn it on."
>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
<th style={{ padding: '8px 10px' }}>Team</th>
<th style={{ padding: '8px 10px' }}>Notifications</th>
<th style={{ padding: '8px 10px' }}>Email</th>
</tr>
</thead>
<tbody>
{rows.map((t) => (
<tr key={t.teamId} style={{ borderTop: '1px solid var(--line-soft)' }}>
<td className="sans" style={{ padding: '10px', color: 'var(--ink)' }}>
{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 && <span className="dim" style={{ fontSize: '0.78rem' }}> · archived</span>}
</td>
<td style={{ padding: '10px' }}>
<label className="sans" style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: '0.88rem' }}>
<input type="checkbox" checked={!t.muted} onChange={() => patch(t.teamId, { muted: !t.muted })} />
<span className="dim">{t.muted ? 'Muted' : 'On'}</span>
</label>
</td>
<td style={{ padding: '10px' }}>
<select
className="input"
value={t.emailMode}
onChange={(e) => patch(t.teamId, { emailMode: e.target.value })}
style={{ fontSize: '0.88rem' }}
>
{EMAIL_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ marginTop: 18 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave(rows)}>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
<Note msg={msg} error={error} />
</Section>
)
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function PlayerNotifications() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [streams, setStreams] = useState([])
const [subscribed, setSubscribed] = useState([])
const [teams, setTeams] = useState([])
const [saving, setSaving] = useState({ streams: false, teams: false })
const [notes, setNotes] = useState({ streams: '', teams: '', streamsError: '', teamsError: '' })
const load = useCallback(async () => {
setLoading(true)
try {
// Three reads in parallel: the catalog is boot-fixed, the subscriptions and
// the Team list are this user's. None depends on another.
const [cat, subs, prefs] = await Promise.all([
api.notificationStreams(),
api.notificationSubscriptions(),
api.teamNotificationPrefs(),
])
setStreams(cat.streams || [])
setSubscribed(subs.streams || [])
setTeams(prefs.teams || [])
setError('')
} catch {
setError('Could not load your notification settings.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const saveStreams = useCallback(async (ids) => {
setSaving((s) => ({ ...s, streams: true }))
setNotes((n) => ({ ...n, streams: '', streamsError: '' }))
try {
const { streams: stored } = await api.setNotificationSubscriptions(ids)
setSubscribed(stored || [])
setNotes((n) => ({ ...n, streams: 'Saved.' }))
} catch {
setNotes((n) => ({ ...n, streamsError: 'Could not save that.' }))
} finally {
setSaving((s) => ({ ...s, streams: false }))
}
}, [])
const saveTeams = useCallback(async (rows) => {
setSaving((s) => ({ ...s, teams: true }))
setNotes((n) => ({ ...n, teams: '', teamsError: '' }))
try {
// The whole set, every time, and the array is sent even when empty — the
// endpoint requires the field (docs/android/PLAN.md §11).
const { teams: stored } = await api.setTeamNotificationPrefs(
rows.map((t) => ({ teamId: t.teamId, muted: t.muted, emailMode: t.emailMode })),
)
setTeams(stored || [])
setNotes((n) => ({ ...n, teams: 'Saved.' }))
} catch {
setNotes((n) => ({ ...n, teamsError: 'Could not save that.' }))
} finally {
setSaving((s) => ({ ...s, teams: false }))
}
}, [])
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
return (
<div>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
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.
</p>
<Streams
streams={streams}
subscribed={subscribed}
onSave={saveStreams}
busy={saving.streams}
msg={notes.streams}
error={notes.streamsError}
/>
<Teams
teams={teams}
onSave={saveTeams}
busy={saving.teams}
msg={notes.teams}
error={notes.teamsError}
/>
</div>
)
}

View File

@@ -35,6 +35,7 @@ function Icon({ children, size = 16 }) {
} }
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon> const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon> const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
// Exported because Admin -> Navigation edits this list. It stays declared here; // 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 // the editor may only relabel, reorder and hide what it finds (§7). No CORE row
@@ -47,6 +48,7 @@ const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-1
// with `order: 0`. // with `order: 0`.
export const NAV = [ export const NAV = [
{ to: '/account/appeals', label: 'Appeals', icon: IconShield }, { to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account/notifications', label: 'Notifications', icon: IconBell },
{ to: '/account', label: 'Account', end: true, icon: IconGear }, { to: '/account', label: 'Account', end: true, icon: IconGear },
] ]
@@ -56,6 +58,7 @@ export const NAV = [
const TITLES = { const TITLES = {
'/account': 'Account', '/account': 'Account',
'/account/appeals': 'Appeals', '/account/appeals': 'Appeals',
'/account/notifications': 'Notifications',
} }
function moduleTitle(baseNav, pathname) { function moduleTitle(baseNav, pathname) {

View File

@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { api } from '../../api/client.js'
// The landing page for the unsubscribe link in a Team notification email
// (TEAMS.md §6.4).
//
// **Public, and it must be**: the person reading it is in their mail client, not
// signed in, and an unsubscribe that first demands a login is one most people do
// not complete. The token in the path is what stands in for the session.
//
// **The page POSTs; the link the user clicked was a GET.** A GET must not mutate —
// mail clients and security scanners follow links in messages, and one that did
// would silently mute Teams nobody asked to leave. So the link lands here, this
// runs one POST, and the API route that shares the path answers GET with a
// redirect to exactly this page.
//
// **It says the same thing whatever the token was.** A page that distinguished a
// valid token from a forged one would be an oracle for which (user, Team) pairs
// exist, on a surface with no session behind it. The server always answers 200 and
// this always says the same sentence.
export default function Unsubscribe() {
const { token } = useParams()
const [state, setState] = useState('working')
// React 18 StrictMode mounts an effect twice in development. The POST is
// idempotent (it sets a boolean), so a second call is harmless — but it is
// still a second request for no reason, and the guard keeps the network panel
// honest for anyone debugging this page.
const fired = useRef(false)
useEffect(() => {
if (fired.current) return
fired.current = true
api.unsubscribeTeam(token)
.then(() => setState('done'))
// A network failure is the ONE case worth distinguishing, because it is the
// one where trying again helps. A rejected token is not: the server does not
// tell us, deliberately.
.catch(() => setState('failed'))
}, [token])
return (
<PublicLayout section="website" shell="narrow">
<PageHeader eyebrow="Notifications" title="Unsubscribe" />
{state === 'working' && <p className="sans dim">One moment</p>}
{state === 'done' && (
<>
<p className="sans" style={{ color: 'var(--ink)' }}>
You will not receive further notification emails about this team.
</p>
<p className="sans dim" style={{ fontSize: '0.9rem' }}>
This muted the team rather than switching off your account&rsquo;s email, so your other
teams are unaffected. You can turn it back on any time under{' '}
<Link to="/account/notifications">notification settings</Link>.
</p>
</>
)}
{state === 'failed' && (
<p className="sans" style={{ color: 'var(--ink)' }}>
We could not reach the site to record that. Please try the link again, or change the
setting yourself under <Link to="/account/notifications">notification settings</Link>.
</p>
)}
</PublicLayout>
)
}

View File

@@ -298,6 +298,18 @@ button[disabled] {
} }
/* ===== Rich prose (wiki / newsletter body) ===== */ /* ===== 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 { .prose {
color: var(--text); color: var(--text);
font-size: 1.06rem; font-size: 1.06rem;

View File

@@ -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') await api.admin.disableModule('a b/c')
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable') 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')
})

View File

@@ -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 // 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. // member missing from the object is a member that does not exist.
assert.deepEqual(Object.keys(registry).sort(), [ 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', 'featureProviderFor',
'navFor', 'navFor',
'registerExtension', 'registerExtension',

View File

@@ -4,6 +4,10 @@ import assert from 'node:assert/strict'
import { import {
registry, registry,
declareSlot, declareSlot,
declareModuleSlot,
offerCoreFill,
CORE_CONTRIBUTIONS,
applyCoreFills,
registerExtension, registerExtension,
extensionFor, extensionFor,
registeredIds, registeredIds,
@@ -92,3 +96,114 @@ test('declareSlot and extensionFor are not on the module-facing registry', () =>
assert.equal(registry.extensionFor, undefined) assert.equal(registry.extensionFor, undefined)
assert.equal(typeof registry.registerExtension, 'function') 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)
})

View File

@@ -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/)
})

View File

@@ -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/)
})

View File

@@ -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 <img> at read time. Handing
// the <img> 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 = '<p><a href="https://x/a.png" rel="noopener noreferrer">https://x/a.png</a>'
+ '<img src="https://x/a.png" class="forum-embed" referrerpolicy="no-referrer" /></p>'
const text = stripToText(rendered)
assert.ok(!text.includes('<img'))
assert.ok(text.includes('https://x/a.png'))
})
test('paragraphs become blank lines and breaks become newlines', () => {
assert.equal(stripToText('<p>One</p><p>Two</p>'), 'One\n\nTwo')
assert.equal(stripToText('<p>One<br>Two</p>'), 'One\nTwo')
// A paragraph carrying attributes is still a paragraph.
assert.equal(stripToText('<p>One</p>\n<p class="x">Two</p>'), 'One\n\nTwo')
})
test('entities decode to what the author typed, and only once', () => {
assert.equal(stripToText('<p>Tom &amp; Jerry</p>'), 'Tom & Jerry')
assert.equal(stripToText('<p>&quot;quoted&quot;</p>'), '"quoted"')
// The one that bites: an author who typed a literal "<script>" has it stored
// escaped. Decoding entities BEFORE stripping tags would turn it into a real
// tag that the strip pass then deletes — silently losing text the author wrote
// and which was never dangerous.
assert.equal(stripToText('<p>&lt;script&gt;</p>'), '<script>')
// And decoding &amp; first would turn "&amp;lt;" into "<" in two steps.
assert.equal(stripToText('<p>&amp;lt;</p>'), '&lt;')
})
test('an empty or absent body is an empty string, never a crash', () => {
assert.equal(stripToText(''), '')
assert.equal(stripToText(null), '')
assert.equal(stripToText(undefined), '')
assert.equal(stripToText('<p></p>'), '')
})
// ── the thread list line ───────────────────────────────────────────────────
test('a discussion counts REPLIES, which is one fewer than its posts', () => {
// postCount includes the opening post. Showing it raw would tell a reader a
// brand-new thread already has one reply.
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 1 }), 'ada')
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 2 }), 'ada · 1 reply')
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 4 }), 'ada · 3 replies')
})
test('an announcement says so and never counts replies, because it takes none', () => {
const line = threadSummary({ type: 'announcement', author: 'aldric', postCount: 1 })
assert.equal(line, 'Announcement · aldric')
assert.ok(!line.includes('repl'))
})
test('hidden is said out loud — it is only shown to whoever can unhide it', () => {
assert.equal(
threadSummary({ type: 'discussion', author: 'ada', postCount: 1, status: 'hidden' }),
'ada · hidden',
)
})
// ── the report control ─────────────────────────────────────────────────────
test('every reason the server accepts is offered, and no others', () => {
// The server validates against its own list; a client offering a reason the
// server rejects produces a 400 the reporter cannot act on, and one MISSING a
// reason quietly funnels those reports into "other".
assert.deepEqual(
REPORT_REASONS.map(([value]) => value).sort(),
['abuse', 'illegal', 'impersonation', 'other', 'sexual', 'spam'],
)
assert.ok(REPORT_REASONS.every(([, label]) => typeof label === 'string' && label.length > 0))
})

View File

@@ -0,0 +1,129 @@
// What Admin → Teams → Notification bridge decides (client/src/lib/teamIntegrations.js).
//
// The test that earns this file: **repointing a row must not carry its
// acknowledgement across.** That is the one way this screen could actively
// mislead — an operator confirms a private channel, changes the id to a public
// one, and the form still shows the confirmation as standing. The server clears
// it either way, so the failure would be a screen that disagrees with the answer
// it is about to get, which is worse than one that simply refuses.
//
// The rest is the boundary of the confirmation dialog: it must open when it
// matters and stay shut when it does not, because a dialog that appears on saves
// that did not need it is one people learn to click through.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
eventLabel, rowKey, isDefaultRow, blankDraft, draftFrom, appliesToLabel, toggleEvent,
setChannel, carriesMembersOnly, needsAcknowledgement, membersOnlyIdsOf, availableTargets,
} from '../src/lib/teamIntegrations.js'
const MEMBERS_ONLY = ['team.forum.post', 'team.announcement']
const ROSTER = 'team.member.joined'
const FORUM = 'team.forum.post'
const draft = (over = {}) => ({ ...blankDraft(null), ...over })
// ── The acknowledgement dies with its channel ──────────────────────────────
test('changing the channel drops a standing acknowledgement', () => {
const before = draft({ channelRef: '111', membersAck: true, events: [FORUM], enabled: true })
const after = setChannel(before, '222')
assert.equal(after.membersAck, false)
assert.equal(after.channelRef, '222')
})
test('setting the SAME channel does not clear it — an unrelated re-render is not a repoint', () => {
const before = draft({ channelRef: '111', membersAck: true })
const after = setChannel(before, '111')
assert.equal(after.membersAck, true)
assert.equal(after, before, 'and the object is returned unchanged, so nothing re-renders')
})
test('a repointed row needs the dialog again, which is the whole point of clearing it', () => {
const before = draft({ channelRef: '111', membersAck: true, events: [FORUM], enabled: true })
assert.equal(needsAcknowledgement(before, MEMBERS_ONLY), false)
assert.equal(needsAcknowledgement(setChannel(before, '222'), MEMBERS_ONLY), true)
})
// ── When the dialog opens ──────────────────────────────────────────────────
test('enabling a forum event without the tick asks first', () => {
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: true }), MEMBERS_ONLY), true)
})
test('a DISABLED draft carrying forum events does not ask — nothing is being published yet', () => {
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: false }), MEMBERS_ONLY), false)
})
test('a roster-only bridge never asks, however it is configured', () => {
assert.equal(needsAcknowledgement(draft({ events: [ROSTER], enabled: true }), MEMBERS_ONLY), false)
assert.equal(carriesMembersOnly(draft({ events: [ROSTER] }), MEMBERS_ONLY), false)
})
test('an acknowledgement already given means no second dialog for an unrelated edit', () => {
const d = draft({ events: [FORUM], enabled: true, membersAck: true, channelRef: '111' })
const withRoster = toggleEvent(d, ROSTER)
assert.equal(needsAcknowledgement(withRoster, MEMBERS_ONLY), false)
})
test('the members-only set comes from the server, not from a list held here', () => {
// The client must not decide what is members-only: a future stream added
// server-side would silently escape a hardcoded client list.
assert.deepEqual(
membersOnlyIdsOf([{ id: ROSTER, membersOnly: false }, { id: FORUM, membersOnly: true }]),
[FORUM],
)
// Told nothing is members-only, the dialog never opens — the server is the one
// that would then refuse, which is the correct division.
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: true }), []), false)
})
// ── Events, rows and targets ───────────────────────────────────────────────
test('toggling adds then removes, and preserves selection order', () => {
let d = draft()
d = toggleEvent(d, FORUM)
d = toggleEvent(d, ROSTER)
assert.deepEqual(d.events, [FORUM, ROSTER])
d = toggleEvent(d, FORUM)
assert.deepEqual(d.events, [ROSTER])
})
test('the default row is identified by a NULL team, and an undefined one counts too', () => {
assert.equal(isDefaultRow({ team_id: null }), true)
assert.equal(isDefaultRow({}), true)
assert.equal(isDefaultRow({ team_id: 4 }), false)
assert.equal(rowKey({ team_id: null }), 'default')
assert.equal(rowKey({ team_id: 4 }), '4')
})
test('a row is labelled by the staff override first, then the name, then its id', () => {
assert.equal(appliesToLabel({ team_id: null }), 'All Teams')
assert.equal(appliesToLabel({ team_id: 4, team_name: 'Real', display_name_override: 'Shown' }), 'Shown')
assert.equal(appliesToLabel({ team_id: 4, team_name: 'Real' }), 'Real')
assert.equal(appliesToLabel({ team_id: 4 }), 'Team #4')
})
test('a Team that already has an override is not offered a second one', () => {
const rows = [{ team_id: null }, { team_id: 2 }]
const teams = [{ id: 1, status: 'active' }, { id: 2, status: 'active' }, { id: 3, status: 'archived' }]
const { hasDefault, teams: available } = availableTargets(rows, teams)
assert.equal(hasDefault, true)
assert.deepEqual(available.map((t) => t.id), [1], 'the taken one and the archived one are both out')
})
test('with no default configured, the default is still offered', () => {
const { hasDefault } = availableTargets([{ team_id: 2 }], [])
assert.equal(hasDefault, false)
})
test('a row round-trips through the draft without changing what it means', () => {
const row = { team_id: 4, events: [FORUM], channel_ref: '111', enabled: 1, members_ack: 1 }
assert.deepEqual(draftFrom(row), { teamId: 4, events: [FORUM], channelRef: '111', enabled: true, membersAck: true })
})
test('an unknown event id renders as itself rather than as blank', () => {
assert.equal(eventLabel(FORUM), 'New forum post')
assert.equal(eventLabel('team.something.new'), 'team.something.new')
})

View File

@@ -0,0 +1,87 @@
import { test, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { api } from '../src/api/client.js'
// The client half of Team notifications (docs/website/TEAMS.md Part 6, phase 6).
//
// There is no DOM in this runner, so what is asserted here is the WIRE — which is
// where this feature's client-side mistakes actually live. Two of them have
// already been made once in this repo and are recorded rather than re-derived:
//
// 1. **A PUT-the-whole-set body must always carry its array**, empty included.
// `docs/android/PLAN.md` §11: a DTO field with a default is dropped by
// kotlinx when it equals that default, so "clear the last entry" arrives as a
// body with no array at all and 400s. The web client has no such
// serialisation quirk, but it shares the endpoint's contract, and a test that
// pins the shape here is what keeps the two clients honest about the same
// rule.
// 2. **The unsubscribe call is a POST**, not the GET the link in the mail was.
// A GET that mutated would be triggered by every mail-client link scanner.
let calls
const realFetch = global.fetch
function reply(body = {}) {
return {
ok: true,
status: 200,
statusText: 'OK',
text: async () => JSON.stringify(body),
}
}
beforeEach(() => {
calls = []
global.fetch = async (url, opts = {}) => {
calls.push({ url, opts })
return reply({ teams: [], streams: [], ok: true })
}
})
afterEach(() => { global.fetch = realFetch })
const body = (i = 0) => JSON.parse(calls[i].opts.body)
test('the per-Team preference endpoints sit under /auth/me, not /player', async () => {
await api.teamNotificationPrefs()
// Role-agnostic self-service, the same rule that put the Team forum under
// /player rather than behind a staff gate: staff are a superset of players and
// manage their own notifications like anyone else.
assert.match(calls[0].url, /\/auth\/me\/notifications\/teams$/)
assert.equal(calls[0].opts.method ?? 'GET', 'GET')
})
test('saving preferences PUTs the whole set under a `teams` key', async () => {
await api.setTeamNotificationPrefs([{ teamId: 3, muted: true, emailMode: 'digest' }])
assert.equal(calls[0].opts.method, 'PUT')
assert.deepEqual(body(), { teams: [{ teamId: 3, muted: true, emailMode: 'digest' }] })
})
test('clearing every preference still sends the array, never an absent key', async () => {
await api.setTeamNotificationPrefs([])
assert.deepEqual(body(), { teams: [] })
assert.equal('teams' in body(), true)
})
test('the same rule holds for the stream subscriptions beside them', async () => {
await api.setNotificationSubscriptions([])
assert.deepEqual(body(), { streams: [] })
})
test('unsubscribe is a POST to the public tier, with the token encoded into the path', async () => {
await api.unsubscribeTeam('1.7.3.abcDEF')
assert.equal(calls[0].opts.method, 'POST')
assert.match(calls[0].url, /\/public\/teams\/unsubscribe\/1\.7\.3\.abcDEF$/)
})
test('a token with url-unsafe characters is encoded rather than pasted in', async () => {
await api.unsubscribeTeam('a/b c')
assert.match(calls[0].url, /unsubscribe\/a%2Fb%20c$/)
})
test('the streams catalog and subscriptions are separate reads', async () => {
await api.notificationStreams()
await api.notificationSubscriptions()
assert.match(calls[0].url, /\/notifications\/streams$/)
assert.match(calls[1].url, /\/notifications\/subscriptions$/)
})

View File

@@ -0,0 +1,152 @@
// Admin → Teams → Voice channels, the decisions (TEAMS.md §7.3, phase 9).
//
// These mirror server rules and do not replace them: the server refuses to enable
// voice while the bot cannot act, and the reconciler applies the threshold and the
// grace window, whether or not this file ever ran. What is asserted here is that
// the SCREEN agrees with those answers instead of offering a control that will
// fail, or describing a state the deployment is not in.
//
// The one that matters most is `statusSummary`'s "off" branch. Switching voice off
// suspends the reconciler in both directions and deliberately leaves existing
// channels standing — a checkbox must not delete structure in somebody's guild —
// and an operator who reads "off" as "nothing is provisioned" would never go
// looking for the channels that are still there.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
stateLabel, enableBlockedReason, roleHeadroom, removalCountdown,
parseStaffRoles, formatStaffRoles, statusSummary,
} from '../src/lib/teamVoice.js'
test('every state the server can report has wording', () => {
for (const state of ['none', 'active', 'pending_removal', 'error']) {
assert.notEqual(stateLabel(state), state)
}
})
test('an unknown state falls back to itself rather than rendering blank', () => {
assert.equal(stateLabel('something-new'), 'something-new')
})
// ── The enable gate ────────────────────────────────────────────────────────
test('a ready bot blocks nothing', () => {
assert.equal(enableBlockedReason({ ready: true, connected: true, missingPermissions: [] }), null)
})
test('a disconnected bot and a bot missing a permission read differently', () => {
const disconnected = enableBlockedReason({ ready: false, connected: false, reason: 'the bot is not connected to Discord' })
const missing = enableBlockedReason({ ready: false, connected: true, missingPermissions: ['Manage Roles'] })
assert.match(disconnected, /not connected/)
assert.match(missing, /Manage Roles/)
// An operator fixes these in two completely different places, so collapsing
// them into one message would send half of them to the wrong one.
assert.notEqual(disconnected, missing)
})
test('an absent preflight blocks rather than silently allowing', () => {
assert.ok(enableBlockedReason(null))
assert.ok(enableBlockedReason(undefined))
})
// ── The role ceiling ───────────────────────────────────────────────────────
test('headroom is counted against the guild-wide cap', () => {
const h = roleHeadroom({ roleCount: 200, roleCap: 250 })
assert.equal(h.free, 50)
assert.equal(h.tight, false)
assert.equal(h.exhausted, false)
})
test('a nearly full guild is flagged before the create fails, not after', () => {
// The whole reason this is in the panel: access is a per-Team role, so the cap
// limits how many TEAMS can have voice, and an operator with sixty guilds needs
// to know that before the sixtieth silently errors.
const h = roleHeadroom({ roleCount: 240, roleCap: 250 })
assert.equal(h.tight, true)
assert.equal(h.exhausted, false)
})
test('a full guild is exhausted, and never reports negative headroom', () => {
const h = roleHeadroom({ roleCount: 260, roleCap: 250 })
assert.equal(h.free, 0)
assert.equal(h.exhausted, true)
})
test('no preflight means no claim about headroom', () => {
assert.equal(roleHeadroom(null), null)
assert.equal(roleHeadroom({}), null)
})
// ── The grace window ───────────────────────────────────────────────────────
test('a row that is not scheduled has no countdown', () => {
assert.equal(removalCountdown({ state: 'active', removeAfter: null }), null)
})
test('a running window reads in days', () => {
const now = new Date('2026-08-19T00:00:00Z')
const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-24T00:00:00Z' }, now)
assert.equal(text, 'in 5 days')
})
test('under a day reads in hours rather than rounding to zero days', () => {
const now = new Date('2026-08-19T00:00:00Z')
const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-19T06:00:00Z' }, now)
assert.equal(text, 'in 6 hours')
})
test('an expired window says the next pass will act, not "in 0 days"', () => {
const now = new Date('2026-08-19T00:00:00Z')
const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-18T00:00:00Z' }, now)
assert.match(text, /next pass/)
})
// ── Staff roles ────────────────────────────────────────────────────────────
test('staff roles parse from the comma-separated ids a person actually pastes', () => {
const { roles, invalid } = parseStaffRoles(' 123456789012345678 , 987654321098765432 ')
assert.deepEqual(roles, ['123456789012345678', '987654321098765432'])
assert.deepEqual(invalid, [])
})
test('a typo is REPORTED, never quietly dropped', () => {
const { invalid } = parseStaffRoles('123456789012345678, @Moderators')
assert.deepEqual(invalid, ['@Moderators'])
})
test('an empty field is a legitimate answer and not an error', () => {
const { roles, invalid } = parseStaffRoles('')
assert.deepEqual(roles, [])
assert.deepEqual(invalid, [])
})
test('roles round-trip through the field', () => {
const { roles } = parseStaffRoles(formatStaffRoles(['111111111111111111', '222222222222222222']))
assert.deepEqual(roles, ['111111111111111111', '222222222222222222'])
})
// ── The status line ────────────────────────────────────────────────────────
test('off with channels still standing says so — the surprising case', () => {
const text = statusSummary({ enabled: false }, [{ channelRef: '900' }, { channelRef: '901' }])
assert.match(text, /^Off\./)
assert.match(text, /2 channels remain/)
})
test('off with nothing provisioned does not invent a warning', () => {
const text = statusSummary({ enabled: false }, [])
assert.match(text, /No channels are provisioned/)
})
test('on states the threshold in the words the setting uses', () => {
const text = statusSummary({ enabled: true, minMembers: 5 }, [{ channelRef: '900' }])
assert.match(text, /at least 5 members/)
assert.match(text, /1 provisioned/)
})
test('a threshold of one is not pluralised', () => {
assert.match(statusSummary({ enabled: true, minMembers: 1 }, []), /at least 1 member get/)
})

View File

@@ -845,6 +845,561 @@ CREATE TABLE IF NOT EXISTS installed_modules (
INDEX idx_installed_modules_state (state) INDEX idx_installed_modules_state (state)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Teams (docs/website/TEAMS.md Part 2, phase 2) ─────────────────────────────
--
-- A Team is a core platform entity POPULATED by a module and owned by core. The
-- module answers "what teams exist and who is in them" through the team provider
-- (MODULE_API.md — registerTeamProvider); core stores the answer, gates it and
-- displays it. Every table below is core-internal (TEAMS.md §10.3): a module must
-- never read or write one, even though a module is what fills them.
--
-- Note the tables carry no `<moduleId>_` prefix, correctly — MODULE_API.md §2.6's
-- prefix rule binds modules, and these are core's.
-- The Team itself. `external_id` is the module's own stable identity for it
-- (module-uo sends the persistent ServUO Guild.Id) and is opaque to core.
--
-- `name` is IMMUTABLE for the life of the row (§2.2): a rename archives this row
-- with archived_reason='renamed' and creates a new one, so the old Team keeps its
-- activity, its grants and its forum as a read-only record. What staff can change
-- is display_name_override, which changes what is RENDERED and never what the row
-- IS — identity and display are different things and only identity is frozen.
CREATE TABLE IF NOT EXISTS teams (
id INT AUTO_INCREMENT PRIMARY KEY,
module_id VARCHAR(32) NOT NULL, -- which module is authoritative
external_id VARCHAR(191) NOT NULL, -- opaque to core
name VARCHAR(160) NOT NULL,
abbr VARCHAR(32) NULL,
slug VARCHAR(191) NOT NULL, -- derived from name, unique among ACTIVE teams
status ENUM('active','archived') NOT NULL DEFAULT 'active',
meta JSON NULL, -- module-supplied, opaque (alliance, crest, …)
member_count INT NOT NULL DEFAULT 0, -- denormalised from team_members
linked_count INT NOT NULL DEFAULT 0, -- members whose user_id is not null
online_count INT NOT NULL DEFAULT 0, -- last known; refreshed by sync
-- Public suppression, independent of status. A hidden Team still works
-- completely for its own members; it is absent from public surfaces (§2.8).
hidden TINYINT(1) NOT NULL DEFAULT 0,
hidden_reason ENUM('reserved_name','staff') NULL,
hidden_term VARCHAR(64) NULL, -- which reserved term matched, for the review queue
-- Set once staff have made an explicit decision about the name. Re-screening
-- runs on every sync, and this is what stops it re-hiding a Team a human has
-- already allowed — without it the override would be undone every 15 minutes.
name_reviewed_at DATETIME NULL,
-- PER-TEAM freshness, which team_sync_state cannot express: it holds one row per
-- MODULE, and §2.4 gate 3 leaves one Team's roster untouched while the others
-- sync normally. Without a per-Team stamp that Team's page would claim the
-- module's last success as its own, which is precisely the staleness the rule
-- exists to surface. Bumped only when a roster is actually applied.
roster_synced_at DATETIME NULL,
-- §2.4 gate 4's per-Team quarantine, the twin of team_sync_state.pending_empty_
-- since: an authoritative-but-empty ROSTER for a Team that currently has members
-- is remembered here and applied only if the next answer agrees.
members_empty_since DATETIME NULL,
-- Staff may change what is DISPLAYED without touching identity (§2.8.3).
display_name_override VARCHAR(160) NULL,
-- The successor row written at archive time when this Team was renamed, so the
-- old slug can still resolve and explain itself rather than 404 (§2.2).
succeeded_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
archived_at DATETIME NULL,
archived_reason VARCHAR(64) NULL, -- 'disbanded' | 'renamed' | 'staff'
-- A generated column is how "unique among ACTIVE rows only" is expressed without
-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key, so
-- any number of archived rows may share an external_id.
active_key VARCHAR(191) AS (IF(status='active', external_id, NULL)) STORED,
active_slug VARCHAR(191) AS (IF(status='active', slug, NULL)) STORED,
UNIQUE KEY uq_teams_active (module_id, active_key),
UNIQUE KEY uq_teams_active_slug (active_slug),
INDEX idx_teams_status (status),
INDEX idx_teams_slug (slug),
INDEX idx_teams_review (hidden, hidden_reason),
-- Self-referential and deliberately SET NULL: a successor may itself be archived
-- and eventually pruned, and losing the pointer must not take the old row with it.
CONSTRAINT fk_teams_succeeded_by FOREIGN KEY (succeeded_by) REFERENCES teams(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The membership PROJECTION. Module-authoritative; core only mirrors it, and the
-- sync is the ONLY writer (§2.5 path 1). Rows are soft-departed rather than
-- deleted so history and rejoin detection survive, and so the activity feed can
-- still name a departed member.
--
-- user_id is resolved BY THE MODULE (it owns the game↔site link table); core never
-- resolves it, because doing so would be core reading a module's table by name.
CREATE TABLE IF NOT EXISTS team_members (
team_id INT NOT NULL,
member_key VARCHAR(191) NOT NULL, -- module's stable member id (UO: character serial)
display_name VARCHAR(160) NULL, -- in-game name
user_id INT NULL, -- resolved by the MODULE; NULL = unlinked
is_leader TINYINT(1) NOT NULL DEFAULT 0,
rank_label VARCHAR(48) NULL, -- module vocabulary, opaque to core
online TINYINT(1) NOT NULL DEFAULT 0,
status ENUM('active','departed') NOT NULL DEFAULT 'active',
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
departed_at DATETIME NULL,
PRIMARY KEY (team_id, member_key),
-- SET NULL, not CASCADE (§2.10): deleting a site account does not remove the
-- character from the guild — only the link to the site goes.
CONSTRAINT fk_team_members_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_team_members_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_team_members_user (user_id),
INDEX idx_team_members_status (team_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Freshness of the module's answer. One row per module. THE table invariant 1
-- ("module unavailability is staleness, never emptiness") is enforced against.
CREATE TABLE IF NOT EXISTS team_sync_state (
module_id VARCHAR(32) NOT NULL PRIMARY KEY,
last_attempt_at DATETIME NULL,
last_success_at DATETIME NULL,
consecutive_failures INT NOT NULL DEFAULT 0,
last_error VARCHAR(500) NULL,
-- The quarantine for §2.4's mass-deletion guard: an authoritative-but-empty
-- answer is remembered here and applied only if the NEXT one agrees.
pending_empty_since DATETIME NULL,
INDEX idx_team_sync_success (last_success_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Staff leadership overrides (§2.5.1), applied ON TOP of the synced value at read
-- time. The projection is never mutated: the sync keeps writing what the game
-- says and this keeps saying what staff decided, which is the whole point — an
-- override the sync clobbered every 15 minutes would be useless.
CREATE TABLE IF NOT EXISTS team_leader_overrides (
team_id INT NOT NULL,
member_key VARCHAR(191) NOT NULL,
effect ENUM('grant','deny') NOT NULL,
actor_user_id INT NULL,
actor_username VARCHAR(32) NULL, -- snapshot, so the record survives the account
reason VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (team_id, member_key),
CONSTRAINT fk_tlo_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tlo_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Forum access grants (§2.5 path 3) — an append-only grant/revoke ledger that is
-- ALSO the current state. An active grant is one with revoked_at IS NULL, and a
-- generated column is how "one active grant per (team,user)" is expressed without
-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key.
--
-- The table lands here, in the phase that builds the resolver, so forumAccess() is
-- written once and its non-contamination tests are real. The grant/revoke FLOW,
-- the per-Team cap and the leader UI are phase 4's; nothing writes this table yet.
--
-- user_id is NULLABLE and SET NULL, which contradicts the sketch in TEAMS.md §2.5
-- and follows §2.10, which settled it deliberately: CASCADE would delete the audit
-- trail of who granted whom, which is exactly what an audit exists to survive. The
-- username snapshots keep the record readable after the account is gone.
--
-- THE TWO CANNOT BOTH BE HAD AS §2.5 WROTE THEM, and this is why the marker below
-- is a bare flag rather than §2.5's `active_user AS (IF(revoked_at IS NULL,
-- user_id, NULL))`. MariaDB refuses `ON DELETE SET NULL` on a foreign key whose
-- column is a base column of a STORED generated column (ER_GENERATED_COLUMN_
-- FUNCTION_IS_NOT_ALLOWED, 1901) — so §2.5's generated column forces §2.10's
-- CASCADE, and the audit trail with it. Deriving the marker from `revoked_at`
-- ALONE and putting user_id in the KEY instead gives identical semantics: at most
-- one active row per (team_id, user_id), unlimited revoked rows, and user_id free
-- to be a SET NULL foreign key. Verified against MariaDB 11 both ways.
CREATE TABLE IF NOT EXISTS team_forum_grants (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
user_id INT NULL,
username VARCHAR(32) NULL, -- snapshot of the grantee at grant time
granted_by INT NULL, -- NULL for a system grant, or a deleted actor
granted_username VARCHAR(32) NULL, -- snapshot of the actor
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
reason VARCHAR(255) NULL,
revoked_by INT NULL,
revoked_username VARCHAR(32) NULL,
revoked_at DATETIME NULL,
revoke_reason VARCHAR(255) NULL,
active_marker TINYINT(1) AS (IF(revoked_at IS NULL, 1, NULL)) STORED,
UNIQUE KEY uq_team_forum_grant_active (team_id, user_id, active_marker),
CONSTRAINT fk_tfg_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tfg_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_tfg_granted_by FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_tfg_revoked_by FOREIGN KEY (revoked_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tfg_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Team forums (TEAMS.md Part 5, phase 4 "5a") ────────────────────────────
--
-- The WHOLE forum schema lands here, in 5a, including the columns only 5b uses.
-- That is §5.1's split-by-layer: 5a ships the access model and announcements, 5b
-- enables discussion by opening paths rather than by migrating data. `type`,
-- `locked`, `pinned` and the whole post table exist from day one so that the
-- second half adds no ALTER.
--
-- Every table here is guarded by `teams_forums_enabled` at the ROUTE level and
-- never at the data level (§5.5.1). Switching the forum off must not delete a
-- thread, revoke a grant or clear a subscription, because the operator will
-- switch it back on and expects what they had.
CREATE TABLE IF NOT EXISTS team_forum_threads (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
type ENUM('announcement','discussion') NOT NULL DEFAULT 'discussion',
title VARCHAR(200) NOT NULL,
created_by INT NULL, -- SET NULL: the body survives the account (§2.10)
created_username VARCHAR(32) NULL, -- snapshot, so a deleted author still reads
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_post_at DATETIME NULL,
post_count INT NOT NULL DEFAULT 0,
pinned TINYINT(1) NOT NULL DEFAULT 0,
locked TINYINT(1) NOT NULL DEFAULT 0,
status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible',
CONSTRAINT fk_tft_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tft_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tft_team_feed (team_id, status, pinned, last_post_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- `body_html` is sanitised ON WRITE and served without re-sanitising, the same
-- contract the wiki and the CMS already follow — but through the FORUM's own
-- profile (utils/forumHtml.js), not the shared one. The shared profile allows
-- `<img>` from any host, which would make `teams_forum_images` unenforceable:
-- every post could hotlink in every mode and the setting would be decoration.
-- No stored body ever contains an `<img>`; core's renderer emits those at read
-- time from the URLs the author wrote (§5.5.3), which is why flipping the policy
-- back to `disabled` un-renders every image on every existing post with no
-- migration at all.
CREATE TABLE IF NOT EXISTS team_forum_posts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
thread_id INT NOT NULL,
author_user_id INT NULL,
author_username VARCHAR(32) NULL, -- snapshot; renders as "[deleted account]" when both are gone
body_html MEDIUMTEXT NOT NULL, -- sanitised on write via utils/forumHtml.js
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
edited_at DATETIME NULL,
edited_by INT NULL,
status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible',
CONSTRAINT fk_tfp_thread FOREIGN KEY (thread_id) REFERENCES team_forum_threads(id) ON DELETE CASCADE,
CONSTRAINT fk_tfp_user FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_tfp_editor FOREIGN KEY (edited_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tfp_thread (thread_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Append-only. Never updated, never deleted.
--
-- Deliberately NOT merged into the site's mod_actions/appeals pair (§5.3), which
-- 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. The two are cross-referenced instead — every
-- STAFF-exercised action here additionally writes an activity_log row, so the
-- site's staff-accountability trail sees it; a LEADER-exercised one writes only
-- this ledger. `actor_role` records WHICH authority was exercised, which is the
-- column that makes that distinction auditable after the fact.
CREATE TABLE IF NOT EXISTS team_forum_moderation (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
target_type ENUM('thread','post') NOT NULL,
target_id BIGINT NOT NULL,
action ENUM('pin','unpin','lock','unlock','hide','unhide','delete','restore') NOT NULL,
actor_user_id INT NULL,
actor_username VARCHAR(32) NULL, -- snapshot (§2.10)
actor_role ENUM('leader','staff') NOT NULL,
reason VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_tfm_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tfm_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tfm_target (target_type, target_id),
INDEX idx_tfm_team (team_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Upload attribution (§5.2a, §5.5.4). Not bookkeeping: the acknowledgement an
-- operator gives before enabling uploads is meaningless if "who uploaded this"
-- cannot be answered afterwards, and the deletion sweep needs a row to sweep.
--
-- `post_id` is NULL between the upload and the post that embeds it — the composer
-- uploads first and references the URL in the body — and that is exactly the state
-- the orphan sweep looks for. `deleted_at` is a soft delete: the file survives a
-- retention window so a mis-click is recoverable, then the nightly sweep removes
-- the bytes.
CREATE TABLE IF NOT EXISTS team_forum_uploads (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
post_id BIGINT NULL,
uploader_user_id INT NULL,
uploader_username VARCHAR(32) NULL, -- snapshot: attribution must survive the account
filename VARCHAR(255) NOT NULL, -- the STORED name, never originalname
mimetype VARCHAR(64) NOT NULL, -- the SNIFFED type, never the client's header
byte_size INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
deleted_by INT NULL,
CONSTRAINT fk_tfu_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tfu_post FOREIGN KEY (post_id) REFERENCES team_forum_posts(id) ON DELETE SET NULL,
CONSTRAINT fk_tfu_user FOREIGN KEY (uploader_user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_tfu_deleter FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_tfu_filename (filename),
INDEX idx_tfu_uploader (uploader_user_id, created_at),
INDEX idx_tfu_sweep (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Member-raised abuse reports (§5.6). **Core had no user-facing report flow of
-- any kind before this**: `moderation`, `mod_notes` and `appeals` are all either
-- staff-initiated or Discord-sanction-shaped, and nothing anywhere let a MEMBER
-- say "this is a problem". That was survivable while every piece of content on
-- the site came from staff. It stops being survivable the moment a Team forum
-- lets players write to each other, and stops twice over when `uploads` mode lets
-- them put files on the operator's disk under a signed liability acknowledgement.
--
-- The gap has a specific shape worth naming: leaders moderate their own Team's
-- forum, and a Team's leaders are exactly the people who will not report their own
-- Team. So this table's whole point is a path that routes AROUND a Team's own
-- leadership — **reports go to site staff and to nobody else.** There is
-- deliberately no leader-facing view of this queue (org lead, 2026-08-18); a
-- leader-visible report about a leader is not a report.
--
-- Not a `team_*` table, and not named for the forum: `target_type` is a plain
-- VARCHAR so wiki pages, news comments and profile fields become new values
-- rather than new tables. Team forum content is only the first consumer.
--
-- **The unique key is on an `open_marker`, not on `status`.** §5.6 writes the key
-- as (target_type, target_id, reporter_user_id, status), and that spelling has a
-- defect worth recording rather than quietly fixing: it makes CLOSED rows collide
-- with each other too. A reporter reports a post, staff dismiss it, the behaviour
-- recurs, they report it again — and the second dismissal is an UPDATE into a
-- (…, 'dismissed') tuple that already exists, so working the queue would start
-- throwing duplicate-key errors after the first repeat reporter.
--
-- The generated marker is the same trick `team_forum_grants.active_marker` uses:
-- it is 1 while the report is OPEN and NULL once it is closed, and MySQL treats
-- NULLs as distinct, so any number of closed reports coexist while at most one
-- open one can. That is what §5.6's prose actually asks for — "one open report per
-- (target, reporter)".
--
-- NULL reporters (deleted accounts) are distinct for the same reason, which is
-- also wanted: nothing should collapse two dead accounts' reports into one.
--
-- `handled_note` is not in the design doc and earns its place: a queue whose
-- resolution reason lives only in an activity_log line is one where the next
-- staffer to see a repeat report cannot find out why the last one was dismissed.
CREATE TABLE IF NOT EXISTS content_reports (
id INT AUTO_INCREMENT PRIMARY KEY,
target_type VARCHAR(32) NOT NULL, -- 'team_forum_post' | 'team_forum_thread' | 'team_forum_upload'
target_id BIGINT NOT NULL,
team_id INT NULL, -- denormalised for the queue's filters
reporter_user_id INT NULL,
reporter_username VARCHAR(32) NULL, -- snapshot (§2.10): who raised it survives the account
reason ENUM('spam','abuse','sexual','illegal','impersonation','other') NOT NULL,
detail VARCHAR(500) NULL,
status ENUM('open','reviewing','actioned','dismissed') NOT NULL DEFAULT 'open',
handled_by INT NULL,
handled_username VARCHAR(32) NULL, -- snapshot, same reason
handled_note VARCHAR(500) NULL,
handled_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
open_marker TINYINT(1) AS (IF(status IN ('open','reviewing'), 1, NULL)) STORED,
CONSTRAINT fk_cr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_cr_reporter FOREIGN KEY (reporter_user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_cr_handler FOREIGN KEY (handled_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_cr_one_open (target_type, target_id, reporter_user_id, open_marker),
INDEX idx_cr_queue (status, created_at),
INDEX idx_cr_team (team_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The §2.9 approval queue. A MODERATOR performing one of the three actions that
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
-- performing one applies it immediately. Rows are kept after a decision — "a
-- moderator asked to publish this name and an admin refused" is the record worth
-- having.
--
-- `action` + `payload` means a fourth gated action is an enum value rather than a
-- schema change. That is room to extend, not an invitation: nothing else is gated
-- today, and nothing should be without asking §2.9's question first.
CREATE TABLE IF NOT EXISTS team_moderation_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
action ENUM('unhide','display_name_override','clear_display_name_override') NOT NULL,
payload JSON NULL, -- e.g. { "displayName": "…" }
reason VARCHAR(255) NULL,
requested_by INT NULL,
requested_username VARCHAR(32) NULL, -- snapshot (§2.10)
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
status ENUM('pending','approved','rejected','withdrawn') NOT NULL DEFAULT 'pending',
decided_by INT NULL,
decided_username VARCHAR(32) NULL,
decided_at DATETIME NULL,
decision_note VARCHAR(255) NULL,
CONSTRAINT fk_tmr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tmr_requested_by FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_tmr_decided_by FOREIGN KEY (decided_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tmr_queue (status, requested_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The per-Team activity feed (TEAMS.md §4.2, phase 3). Two writers, one table:
-- core writes its own membership and rename items with source='core', and a module
-- pushes game items through ctx.teams.activity.push with source=<moduleId>. That
-- core writes here too is deliberate — the rendering path is exercised by core's
-- own content from day one, so the feed is never empty on a deployment whose
-- module pushes nothing.
--
-- `summary` is ALREADY-RENDERED text and core never composes one (§4.1). Core
-- cannot phrase "gained 15,000 gold" for a game whose vocabulary it does not know,
-- and a core that templated it would have re-acquired exactly the game semantics
-- the module system exists to remove. `kind` and `payload` are likewise opaque:
-- core stores and filters them, and only the module's `team.overview` slot renders
-- anything richer than the text.
CREATE TABLE IF NOT EXISTS team_activity (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
source VARCHAR(32) NOT NULL, -- 'core' or a module id
kind VARCHAR(64) NOT NULL, -- namespaced <source>.<name>, opaque to core
summary VARCHAR(255) NOT NULL, -- module-rendered; core never composes one
-- Defaults to 'members' — fail closed. The module CHOOSES visibility per item;
-- core ENFORCES it on the read path. Same shape as a module owning the
-- public-safety filter for its push streams (MODULE_API.md §2.4).
visibility ENUM('public','members') NOT NULL DEFAULT 'members',
actor_member_key VARCHAR(191) NULL,
actor_user_id INT NULL,
payload JSON NULL, -- opaque; rendered only by the module's slot
occurred_at DATETIME NOT NULL, -- when it happened in the game, not when it arrived
-- Optional idempotence key. INSERT IGNORE against this unique index is the same
-- trick shard_events already uses, and it is what makes a sidecar reconnect
-- backfill safe: replaying a window of events re-posts nothing.
dedupe_key CHAR(40) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_team_activity_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
-- Actor is SET NULL, not CASCADE (§2.10): deleting an account must not delete the
-- Team's history of what happened, only the attribution.
CONSTRAINT fk_team_activity_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_team_activity_dedupe (team_id, dedupe_key),
INDEX idx_team_activity_feed (team_id, occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Per-Team notification preference (TEAMS.md §6.3/§6.4, phase 6). OPT-OUT, not
-- opt-in: a user in a single Team must never have to configure anything, so the
-- absence of a row is the default and every column here is a deviation from it.
--
-- Team scoping lives HERE and in the recipient computation, never in a stream id.
-- The push catalog is a static registration validated at boot against a namespaced
-- pattern; it cannot express one stream per Team, and stream ids are stored in
-- notification_subscriptions rows that would then need garbage-collecting every
-- time a Team archived. Four fixed streams plus this table is the same feature
-- with nothing to collect.
--
-- `last_digest_at` is the digest's ONLY state. There is no queue of pending items:
-- the worker asks what arrived after this timestamp and re-runs the access
-- resolver, so a deployment that was down for a day sends one correct digest
-- rather than replaying a backlog, and a user who lost forum access between the
-- post and the send is not emailed content they can no longer read.
CREATE TABLE IF NOT EXISTS team_notification_prefs (
user_id INT NOT NULL,
team_id INT NOT NULL,
muted TINYINT(1) NOT NULL DEFAULT 0,
-- 'off', and NOT the design-of-record's 'digest'. Digest-by-default would mean
-- every member of every Team starts receiving daily mail the moment an operator
-- connects Gmail, which is a decision about other people's inboxes made on their
-- behalf. Email is therefore the one sink here that is opt-IN; the mute is still
-- opt-out, because a mute silences something the user already asked for.
--
-- It also keeps this column honest as a deviation-from-default: a row written to
-- set `muted` alone leaves email exactly where it was.
email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off',
last_digest_at DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, team_id),
CONSTRAINT fk_tnp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_tnp_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
-- The digest worker's driving query is "rows in digest mode, oldest send first",
-- which is a scan of this index rather than of every preference ever written.
INDEX idx_tnp_digest (email_mode, last_digest_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── The integration bridge's configuration (TEAMS.md §7.2, phase 8) ─────────
--
-- The SAME events as §6, delivered to a second consumer. Not a second pipeline:
-- `utils/teamNotify.js` computes the recipient set once and hands the event to
-- push, to email and now to this bridge.
--
-- `team_id NULL` is the deployment-wide default and a per-Team row overrides it,
-- which is what §7.2 asks for — but its `PRIMARY KEY (platform, team_id)` cannot
-- express it: MariaDB coerces every PRIMARY KEY column to NOT NULL, so the
-- default row is unrepresentable and the whole override mechanism has no base
-- case. Hence the surrogate key plus a generated `team_key`, the same trick
-- `teams.active_key` and `content_reports.open_marker` use: IFNULL folds the
-- default row onto 0, which no `teams.id` can be, so one default and one row per
-- Team coexist under a single UNIQUE key. It also buys the foreign key the
-- original DDL had no room for — without it, deleting a Team leaves its bridge
-- config behind to be inherited by the next Team that lands on the id.
--
-- **`members_ack` is a precondition, not a preference.** Forum posts and
-- announcements are members-only ALWAYS — there is no public forum thread, and
-- §7.2's gate ("visibility is public, or the channel is configured for a
-- members-only context") has no data source on either side: the streams carry no
-- visibility and core cannot see a Discord channel's permissions. Only the
-- operator can. So enabling a members-only event requires an explicit, attributed
-- acknowledgement that the destination is restricted to that Team, recorded the
-- way `teams_forum_uploads_ack` records the image-policy one. Changing the channel
-- CLEARS it (see the model): an acknowledgement is about a destination, and it
-- cannot survive the destination changing underneath it.
CREATE TABLE IF NOT EXISTS team_integration_config (
id INT AUTO_INCREMENT PRIMARY KEY,
platform VARCHAR(32) NOT NULL, -- 'discord'; opaque here, phase 10 makes it a registry key
team_id INT NULL, -- NULL = the deployment-wide default
events JSON NOT NULL, -- ['team.announcement','team.forum.post']
channel_ref VARCHAR(64) NULL, -- destination on that platform, opaque to core
enabled TINYINT(1) NOT NULL DEFAULT 0,
-- The §7.2 gate, as an operator assertion with a name against it.
members_ack TINYINT(1) NOT NULL DEFAULT 0,
members_ack_by INT NULL,
members_ack_at DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
team_key INT AS (IFNULL(team_id, 0)) STORED,
UNIQUE KEY uq_tic_platform_team (platform, team_key),
CONSTRAINT fk_tic_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
-- SET NULL rather than CASCADE, for the same reason every other snapshot in
-- this file is: deleting the admin's account must not silently un-acknowledge a
-- policy and start withholding messages the deployment is configured to send.
CONSTRAINT fk_tic_ack_by FOREIGN KEY (members_ack_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Per-Team external resources: the voice channel (TEAMS.md §7.3, phase 9) ─
--
-- One row per (Team, platform, resource). Today the only resource is 'voice',
-- and the column exists because the NEXT one — a text channel, a Matrix room —
-- is the same lifecycle with a different noun, and phase 10's capability
-- registry needs somewhere to say which resources a platform declares.
--
-- **Access is a per-Team ROLE, not per-member overwrites.** §7.3 designed
-- overwrites-by-default with escalation to a role above ~90 members; the org lead
-- settled on roles always (2026-08-18). That deletes `voice_overwrite_max` and the
-- mode transition, and it moves the ceiling: the binding limit is no longer ~100
-- overwrites on one channel but Discord's guild-wide cap of 250 roles, which the
-- admin panel surfaces rather than letting a create fail into `state='error'`.
-- `role_ref` is therefore NOT the escalation artefact it was in §7.3 — it is the
-- grant itself, and a row with a channel and no role is a broken row.
--
-- **Two external refs, two lifetimes, and the pair is why this is a table rather
-- than two columns on `teams`.** A channel can be deleted in Discord while the
-- role survives, and vice versa; the reconciler has to be able to say "the role is
-- there, the channel is not" and repair one without touching the other.
--
-- `state` is core's belief about Discord, never Discord's own answer: the
-- reconciler writes what it just did, and the next pass re-derives the truth. A
-- Team dropping below the threshold goes to 'pending_removal' with `remove_after`
-- set rather than being deleted at once (§7.3's grace window) — a Team hovering
-- around the threshold would otherwise delete-and-recreate, changing the channel
-- id and breaking every pinned link to it, and a voice channel holds no message
-- history, so the window costs nothing to keep.
CREATE TABLE IF NOT EXISTS team_integrations (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
platform VARCHAR(32) NOT NULL, -- 'discord'; opaque here, a registry key in phase 10
resource VARCHAR(32) NOT NULL, -- 'voice'
external_ref VARCHAR(64) NULL, -- the channel id
role_ref VARCHAR(64) NULL, -- the Team's own role; the grant itself, not an escalation
state ENUM('none','active','pending_removal','error') NOT NULL DEFAULT 'none',
remove_after DATETIME NULL, -- set with 'pending_removal'; the grace window's expiry
last_error VARCHAR(500) NULL,
synced_at DATETIME NULL, -- last pass that reached Discord and was believed
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_team_integration (team_id, platform, resource),
-- Expiry is swept across every Team, so the index is on the pair the sweep
-- filters by rather than on the Team the unique key already covers.
INDEX idx_ti_pending (state, remove_after),
CONSTRAINT fk_ti_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses -- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get -- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here. -- these columns from the CREATE TABLE above; existing installs get them here.
@@ -875,6 +1430,12 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL;
-- so the system behaves exactly as today until an admin opts in. -- so the system behaves exactly as today until an admin opts in.
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled'); INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
-- Team forum post edit window, in minutes (TEAMS.md §5.4, phase 5). Seeded rather
-- than left absent so the value an operator sees on the settings screen is the
-- value in force — an empty field that silently behaves as 15 is a field nobody
-- trusts. INSERT IGNORE, so an operator who has already changed it keeps theirs.
INSERT IGNORE INTO settings (`key`, value) VALUES ('teams_forum_edit_window_minutes', '15');
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;

View File

@@ -345,6 +345,26 @@
"requireAuth" "requireAuth"
] ]
}, },
{
"method": "GET",
"path": "/api/v1/admin/moderation/reports",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/reports/:id/handle",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/admin/moderation/search", "path": "/api/v1/admin/moderation/search",
@@ -730,6 +750,247 @@
"validate" "validate"
] ]
}, },
{
"method": "GET",
"path": "/api/v1/admin/teams",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/archive",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/display-name",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id/forum/moderation",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id/grants",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/hide",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/leader-override",
"handlers": 6,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/:id/leader-override/:memberKey",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/unhide",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/forum/settings",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/forum/uploads",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/integrations",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/teams/integrations",
"handlers": 8,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/integrations/:teamId",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/requests",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/requests/:id/decide",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/resync",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/review",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/voice",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/teams/voice",
"handlers": 7,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/voice/:teamId",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/voice/sync",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{ {
"method": "POST", "method": "POST",
"path": "/api/v1/admin/uploads", "path": "/api/v1/admin/uploads",
@@ -1197,6 +1458,26 @@
"validate" "validate"
] ]
}, },
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/teams",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/auth/me/notifications/teams",
"handlers": 6,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/auth/me/sessions", "path": "/api/v1/auth/me/sessions",
@@ -1499,6 +1780,162 @@
"requireAuth" "requireAuth"
] ]
}, },
{
"method": "GET",
"path": "/api/v1/player/teams",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/access",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PATCH",
"path": "/api/v1/player/teams/:slug/forum/posts/:id",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/posts/:id/moderate",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/report",
"handlers": 7,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/forum/threads",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads",
"handlers": 7,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/forum/threads/:id",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads/:id/posts",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/uploads",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"multerMiddleware"
]
},
{
"method": "DELETE",
"path": "/api/v1/player/teams/:slug/forum/uploads/:id",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/grants",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/grants",
"handlers": 6,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/player/teams/:slug/grants/:userId",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{ {
"method": "POST", "method": "POST",
"path": "/api/v1/public/contact", "path": "/api/v1/public/contact",
@@ -1556,6 +1993,60 @@
"handlers": 1, "handlers": 1,
"gates": [] "gates": []
}, },
{
"method": "GET",
"path": "/api/v1/public/teams",
"handlers": 2,
"gates": [
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug",
"handlers": 2,
"gates": [
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/activity",
"handlers": 3,
"gates": [
"siteMode",
"optionalAuth"
]
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/members",
"handlers": 3,
"gates": [
"siteMode",
"optionalAuth"
]
},
{
"method": "GET",
"path": "/api/v1/public/teams/by-external/:moduleId/:externalId",
"handlers": 2,
"gates": [
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/public/teams/unsubscribe/:token",
"handlers": 1,
"gates": []
},
{
"method": "POST",
"path": "/api/v1/public/teams/unsubscribe/:token",
"handlers": 1,
"gates": []
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/public/version", "path": "/api/v1/public/version",
@@ -1627,6 +2118,22 @@
"gates": [ "gates": [
"requireInternalKey" "requireInternalKey"
] ]
},
{
"method": "GET",
"path": "/internal/commands",
"handlers": 1,
"gates": [
"requireInternalKey"
]
},
{
"method": "POST",
"path": "/internal/commands/dispatch",
"handlers": 1,
"gates": [
"requireInternalKey"
]
} }
] ]
} }

View File

@@ -145,6 +145,14 @@
"method": "GET", "method": "GET",
"path": "/api/v1/admin/moderation/recent" "path": "/api/v1/admin/moderation/recent"
}, },
{
"method": "GET",
"path": "/api/v1/admin/moderation/reports"
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/reports/:id/handle"
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/admin/moderation/search" "path": "/api/v1/admin/moderation/search"
@@ -293,6 +301,98 @@
"method": "PUT", "method": "PUT",
"path": "/api/v1/admin/site-mode" "path": "/api/v1/admin/site-mode"
}, },
{
"method": "GET",
"path": "/api/v1/admin/teams"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/archive"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/display-name"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id/forum/moderation"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id/grants"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/hide"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/leader-override"
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/:id/leader-override/:memberKey"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/unhide"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/forum/settings"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/forum/uploads"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/integrations"
},
{
"method": "PUT",
"path": "/api/v1/admin/teams/integrations"
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/integrations/:teamId"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/requests"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/requests/:id/decide"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/resync"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/review"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/voice"
},
{
"method": "PUT",
"path": "/api/v1/admin/teams/voice"
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/voice/:teamId"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/voice/sync"
},
{ {
"method": "POST", "method": "POST",
"path": "/api/v1/admin/uploads" "path": "/api/v1/admin/uploads"
@@ -477,6 +577,14 @@
"method": "PUT", "method": "PUT",
"path": "/api/v1/auth/me/notifications/subscriptions" "path": "/api/v1/auth/me/notifications/subscriptions"
}, },
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/teams"
},
{
"method": "PUT",
"path": "/api/v1/auth/me/notifications/teams"
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/auth/me/sessions" "path": "/api/v1/auth/me/sessions"
@@ -605,6 +713,66 @@
"method": "GET", "method": "GET",
"path": "/api/v1/player/appeals/eligible" "path": "/api/v1/player/appeals/eligible"
}, },
{
"method": "GET",
"path": "/api/v1/player/teams"
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/access"
},
{
"method": "PATCH",
"path": "/api/v1/player/teams/:slug/forum/posts/:id"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/posts/:id/moderate"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/report"
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/forum/threads"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads"
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/forum/threads/:id"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads/:id/posts"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/uploads"
},
{
"method": "DELETE",
"path": "/api/v1/player/teams/:slug/forum/uploads/:id"
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/grants"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/grants"
},
{
"method": "DELETE",
"path": "/api/v1/player/teams/:slug/grants/:userId"
},
{ {
"method": "POST", "method": "POST",
"path": "/api/v1/public/contact" "path": "/api/v1/public/contact"
@@ -637,6 +805,34 @@
"method": "GET", "method": "GET",
"path": "/api/v1/public/status" "path": "/api/v1/public/status"
}, },
{
"method": "GET",
"path": "/api/v1/public/teams"
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug"
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/activity"
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/members"
},
{
"method": "GET",
"path": "/api/v1/public/teams/by-external/:moduleId/:externalId"
},
{
"method": "GET",
"path": "/api/v1/public/teams/unsubscribe/:token"
},
{
"method": "POST",
"path": "/api/v1/public/teams/unsubscribe/:token"
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/public/version" "path": "/api/v1/public/version"
@@ -674,6 +870,14 @@
{ {
"method": "GET", "method": "GET",
"path": "/internal/bot-config" "path": "/internal/bot-config"
},
{
"method": "GET",
"path": "/internal/commands"
},
{
"method": "POST",
"path": "/internal/commands/dispatch"
} }
] ]
} }

View File

@@ -79,6 +79,44 @@ async function requireAuth(req, res, next) {
} }
} }
// Best-effort AUTHENTICATION, as opposed to attachSession's best-effort decode.
//
// For a PUBLIC route whose content — not merely its presentation — depends on who
// is asking. The Team activity feed is the first: `public` items go to everyone
// and `members` items only to members and forum-granted users (TEAMS.md §4.3), so
// an anonymous caller must be served, not rejected, and an authenticated one must
// be identified properly.
//
// "Properly" is why this is not attachSession. That one decodes the token and
// stops, which is right for reading back your own session but wrong here: a
// banned account, a password change, or a logout would all keep working against
// the private half of the feed until the JWT expired. This runs the same
// database re-validation requireAuth does — status, cutoff, revocation — and on
// any failure continues ANONYMOUSLY rather than 401ing. A caller whose session is
// no longer good sees the public feed, which is exactly what they are entitled to.
//
// A database error also degrades to anonymous. On a public route the safe
// direction is to serve less, and 500ing a page because a session lookup failed
// would take the whole Team page down for callers who never sent a token.
async function optionalAuth(req, res, next) {
const session = sessionService.validateSession(req)
if (!session) return next()
try {
const user = await users.getById(session.userId)
if (!user) return next()
if (user.status && user.status !== 'active') return next()
if (isBeforeCutoff(session, user.tokens_valid_after)) return next()
if (await sessionService.isSessionRevoked(session.sessionId)) return next()
req.user = user
req.session = session
req.authMethod = session.authMethod
} catch (err) {
log.warn('optionalAuth: continuing anonymously', { message: err.message })
}
return next()
}
// Gate middleware factory: allow only the listed roles. Assumes requireAuth ran // Gate middleware factory: allow only the listed roles. Assumes requireAuth ran
// first so req.user is populated. Use for admin-only endpoints (users, site // first so req.user is populated. Use for admin-only endpoints (users, site
// mode, settings) so a lower-privilege editor cannot reach them. // mode, settings) so a lower-privilege editor cannot reach them.
@@ -91,6 +129,7 @@ function requireRole(...roles) {
module.exports = { module.exports = {
attachSession, attachSession,
optionalAuth,
requireAuth, requireAuth,
requireRole, requireRole,
} }

View File

@@ -2,13 +2,18 @@
// //
// What is left of config/notificationStreams.js once the shard-derived catalog // What is left of config/notificationStreams.js once the shard-derived catalog
// moved to config/shardStreams.js (MODULE_SYSTEM.md §1.8: push INFRASTRUCTURE is // moved to config/shardStreams.js (MODULE_SYSTEM.md §1.8: push INFRASTRUCTURE is
// core, the CATALOG is content). Exactly one stream is core's: `news.post` is // core, the CATALOG is content). `news.post` is produced by the website's own
// produced by the website's own posts path, not by any game feed. // posts path, not by any game feed, and the four `team.*` streams by core's own
// Team sync and forum.
// //
// Registered through modules/registries.js like any module's, and read back // Registered through modules/registries.js like any module's, and read back
// through it — nothing imports this file to get "the catalog", because the // through it — nothing imports this file to get "the catalog", because the
// catalog is core's plus every module's. // catalog is core's plus every module's.
// //
// Phase 6 added the four Team streams below. They are core's for the same reason
// the Team tables are: a module supplies who is in a Team, but who may be told
// about it is the access resolver's answer, and that is core's (TEAMS.md Part 6).
//
// The payload that ever leaves the server is a CONTENT-FREE tickle // The payload that ever leaves the server is a CONTENT-FREE tickle
// ({ stream, ref }); the app wakes and PULLS the real, ownership-checked content // ({ stream, ref }); the app wakes and PULLS the real, ownership-checked content
// over the authenticated API (docs/android/PLAN.md §11). // over the authenticated API (docs/android/PLAN.md §11).
@@ -21,6 +26,55 @@ const STREAMS = [
personal: false, personal: false,
requiresLinkedAccount: false, requiresLinkedAccount: false,
}, },
// ── Teams (TEAMS.md §6.2, phase 6) ───────────────────────────────────────
//
// FOUR streams, and not one per Team. The catalog is a static registration
// validated at boot; it has no way to express an unbounded runtime-created set,
// and a stream id per Team would leave rows in notification_subscriptions to
// collect every time a Team archived. Which Team an event came from lives in
// the RECIPIENT SET (utils/teamNotify.js) and in the `ref`, never in the id.
//
// `requiresLinkedAccount: false` on all four is deliberate and reads oddly.
// These are game-sourced events, so the instinct is to demand a linked game
// account — but a forum-granted user with no game identity at all is exactly
// the population §2.5 path 3 exists for, and they are a legitimate recipient of
// `team.forum.post`. The flag would refuse them a toggle they have every right
// to. What enforces who gets what is the recipient computation, which asks the
// access resolver; the stream flag is not a second, weaker copy of that rule.
//
// `personal: false` for the same reason it is false on news.post: these are not
// owner-keyed events about one account's own property. `publishToUsers` is a
// third fan-out shape alongside "everyone subscribed" and "this one owner", and
// the catalog has no flag for it because the flag would say nothing a caller
// does not already know by choosing the function.
{
id: 'team.member.joined',
label: 'Team — new member',
description: 'Someone joined a Team you belong to.',
personal: false,
requiresLinkedAccount: false,
},
{
id: 'team.leadership.changed',
label: 'Team — leadership change',
description: 'Leadership changed in a Team you belong to.',
personal: false,
requiresLinkedAccount: false,
},
{
id: 'team.forum.post',
label: 'Team — new forum post',
description: 'A new thread or reply in a Team forum you can read.',
personal: false,
requiresLinkedAccount: false,
},
{
id: 'team.announcement',
label: 'Team — announcements',
description: 'A leader posted an announcement in a Team you can read.',
personal: false,
requiresLinkedAccount: false,
},
] ]
module.exports = { STREAMS } module.exports = { STREAMS }

View File

@@ -48,4 +48,44 @@ const endpointsForUserStream = (userId, streamId) =>
[userId, streamId], [userId, streamId],
) )
module.exports = { upsert, getByUserEndpoint, listByUser, remove, endpointsForStream, endpointsForUserStream } // `Number.isInteger` alone is not enough: `Number(null)` is 0 and 0 is an
// integer, so a null slipping into a caller's list would become user id 0 and
// ride into an IN clause. No row has id 0, so it is harmless today — which is
// exactly why it would never be noticed.
const isUserId = (n) => Number.isInteger(n) && n > 0
// Endpoints of a COMPUTED SET of users' devices, each still gated on that user's
// own subscription (TEAMS.md §6.2's third fan-out shape).
//
// The set is the whole Team-scoping mechanism: the four `team.*` streams are
// global, and which Team an event belongs to is expressed by who is in `userIds`
// rather than by a stream id per Team. The caller has already resolved access and
// subtracted mutes; this function's only remaining job is to honour each
// recipient's own opt-in, which is why the JOIN is here and not left to the
// caller — a fan-out that skipped it would deliver to a user who had turned the
// stream off.
//
// Returns [] for an empty set rather than building `IN ()`, which is a syntax
// error in MariaDB. That case is common, not exceptional: most Team events have
// no subscribed recipients on a deployment with no app installed at all.
async function endpointsForUsersStream(userIds, streamId) {
const ids = [...new Set((userIds || []).map(Number).filter(isUserId))]
if (ids.length === 0) return []
return query(
`SELECT d.endpoint, d.transport
FROM push_devices d
JOIN notification_subscriptions s ON s.user_id = d.user_id
WHERE s.stream_id = ? AND d.user_id IN (${ids.map(() => '?').join(',')})`,
[streamId, ...ids],
)
}
module.exports = {
upsert,
getByUserEndpoint,
listByUser,
remove,
endpointsForStream,
endpointsForUserStream,
endpointsForUsersStream,
}

View File

@@ -28,5 +28,13 @@ const remove = async (id, userId) => (await db.remove(id, userId)) > 0
// Fan-out helpers: raw { endpoint, transport } rows (not toSafe-shaped). // Fan-out helpers: raw { endpoint, transport } rows (not toSafe-shaped).
const endpointsForStream = (streamId) => db.endpointsForStream(streamId) const endpointsForStream = (streamId) => db.endpointsForStream(streamId)
const endpointsForUserStream = (userId, streamId) => db.endpointsForUserStream(userId, streamId) const endpointsForUserStream = (userId, streamId) => db.endpointsForUserStream(userId, streamId)
const endpointsForUsersStream = (userIds, streamId) => db.endpointsForUsersStream(userIds, streamId)
module.exports = { register, listForUser, remove, endpointsForStream, endpointsForUserStream } module.exports = {
register,
listForUser,
remove,
endpointsForStream,
endpointsForUserStream,
endpointsForUsersStream,
}

View File

@@ -0,0 +1,154 @@
// SQL for `content_reports` (TEAMS.md §5.6).
//
// Not under model/teams/ even though Team forum content is its only consumer
// today: the table is deliberately generic — `target_type` is a VARCHAR so that a
// wiki page or a news comment becomes a new value rather than a new table — and
// filing it under a feature it will outgrow is how the next consumer ends up
// building its own.
//
// Nothing here decides who may read a report. That is the route's job, and there
// is exactly one answer: site staff (§5.6, and the org lead's 2026-08-18 ruling
// that reports are site administration only).
const { query } = require('../../utils/db')
const COLUMNS = `
id, target_type, target_id, team_id, reporter_user_id, reporter_username,
reason, detail, status, handled_by, handled_username, handled_note, handled_at,
created_at`
const OPEN_STATUSES = ['open', 'reviewing']
/**
* File a report.
*
* The duplicate is caught by the unique key rather than by a SELECT first, which
* is the difference between "usually not a duplicate" and "never a duplicate":
* two taps of a report button race, and only the index settles it. ER_DUP_ENTRY
* comes back as a clean `null` so the caller can answer 409 without knowing what
* a MySQL error code looks like.
*/
async function insert({ targetType, targetId, teamId, reporterUserId, reporterUsername, reason, detail }) {
try {
const res = await query(
`INSERT INTO content_reports
(target_type, target_id, team_id, reporter_user_id, reporter_username, reason, detail)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[targetType, targetId, teamId ?? null, reporterUserId, reporterUsername, reason, detail ?? null],
)
return res.insertId
} catch (err) {
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) return null
throw err
}
}
async function byId(id) {
const rows = await query(`SELECT ${COLUMNS} FROM content_reports WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
/**
* The queue.
*
* `status` defaults to the two OPEN statuses rather than to everything: a staffer
* opening the queue wants the work, not the archive. 'all' is the explicit escape
* hatch and every single status is selectable, so nothing is unreachable.
*/
async function list({ status, teamId, limit = 100, offset = 0 } = {}) {
const where = []
const args = []
if (status && status !== 'all') {
where.push('status = ?')
args.push(status)
} else if (!status) {
where.push(`status IN (${OPEN_STATUSES.map(() => '?').join(',')})`)
args.push(...OPEN_STATUSES)
}
if (teamId) {
where.push('team_id = ?')
args.push(teamId)
}
args.push(limit, offset)
return query(
`SELECT ${COLUMNS} FROM content_reports
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
args,
)
}
/** How many are waiting, for the dashboard badge. */
async function openCount() {
const rows = await query(
`SELECT COUNT(*) AS n FROM content_reports WHERE status IN (${OPEN_STATUSES.map(() => '?').join(',')})`,
OPEN_STATUSES,
)
return Number(rows[0]?.n || 0)
}
/**
* Record a staffer's decision.
*
* `handled_*` is stamped for every status including `reviewing`, so "who has this"
* is answerable while it is in progress and not only after it is closed — that is
* what stops two staffers working the same report.
*/
async function handle(id, { status, handledBy, handledUsername, note }) {
const res = await query(
`UPDATE content_reports
SET status = ?, handled_by = ?, handled_username = ?, handled_note = ?, handled_at = NOW()
WHERE id = ?`,
[status, handledBy, handledUsername, note ?? null, id],
)
return res.affectedRows > 0
}
// ── target enrichment ──────────────────────────────────────────────────────
//
// Three batched reads rather than one per row. §5.6's fourth rule — "reports on
// uploads carry the team_forum_uploads row, so a staffer sees uploader, size and
// sniffed type without hunting" — is the reason the queue enriches at all, and a
// queue that N+1s to do it would be the version that gets turned off.
async function threadsByIds(ids) {
if (!ids.length) return []
return query(
`SELECT id, team_id, title, type, status, created_username FROM team_forum_threads
WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)
}
async function postsByIds(ids) {
if (!ids.length) return []
return query(
`SELECT p.id, p.thread_id, p.author_user_id, p.author_username, p.body_html, p.status,
p.created_at, t.team_id, t.title AS thread_title
FROM team_forum_posts p JOIN team_forum_threads t ON t.id = p.thread_id
WHERE p.id IN (${ids.map(() => '?').join(',')})`,
ids,
)
}
async function uploadsByIds(ids) {
if (!ids.length) return []
return query(
`SELECT id, team_id, post_id, uploader_user_id, uploader_username, filename,
mimetype, byte_size, created_at, deleted_at
FROM team_forum_uploads WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)
}
module.exports = {
OPEN_STATUSES,
insert,
byId,
list,
openCount,
handle,
threadsByIds,
postsByIds,
uploadsByIds,
}

View File

@@ -0,0 +1,243 @@
// ── Abuse reports: the missing half of moderation (TEAMS.md §5.6) ──────────
//
// Two rules shape everything in this file, and both are easier to break than to
// notice broken:
//
// 1. **A report is not a moderation action.** Filing one changes nothing about
// the content — it opens a queue item. That keeps it clear of §5.3's
// leader/staff moderation ledger, which records things that actually
// happened. If reporting hid a post, reporting would BE moderation, and the
// first person to work that out would have found a way to hide anything.
//
// 2. **Reports go to site staff and to nobody else.** 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. The org lead settled this on 2026-08-18 — reports are **site
// administration only**, with no leader-facing view at all, not even a
// read-only one scoped to their own Team.
//
// The reporter's ACCESS is the caller's business, not this file's: the player
// route resolves the forum first, so anyone reaching `file()` is someone who can
// already see the thing they are reporting. What this file does check is that the
// target is really in the Team the caller reached it through — otherwise a
// participant in one Team could file reports carrying another Team's id, and the
// queue's per-Team filter would quietly be lying.
const reportsDb = require('./contentReports.db')
const forumDb = require('../teams/teamForum.db')
const TARGET_TYPES = ['team_forum_thread', 'team_forum_post', 'team_forum_upload']
const REASONS = ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other']
const STATUSES = ['open', 'reviewing', 'actioned', 'dismissed']
// A body excerpt for the queue, not a rendered post. Staff triage on what was
// written, and `body_html` is stored already sanitised — but the queue is a list,
// so it gets text and a length cap rather than markup.
const EXCERPT_CHARS = 300
const excerpt = (html) => String(html || '')
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, EXCERPT_CHARS)
/**
* Does this target exist, and is it in this Team?
*
* Returns the team id the target really belongs to, or null. The caller compares
* it with the Team the request came through — a mismatch is a 404 for the same
* §5.5.1 reason a foreign thread id is: confirming a target exists somewhere else
* on the site is itself a disclosure.
*/
async function targetTeamId(targetType, targetId) {
if (targetType === 'team_forum_thread') {
const thread = await forumDb.threadById(targetId)
return thread ? thread.team_id : null
}
if (targetType === 'team_forum_post') {
const post = await forumDb.postById(targetId)
if (!post) return null
const thread = await forumDb.threadById(post.thread_id)
return thread ? thread.team_id : null
}
if (targetType === 'team_forum_upload') {
const upload = await forumDb.uploadById(targetId)
return upload ? upload.team_id : null
}
return null
}
/**
* File a report.
*
* A duplicate answers 409 rather than pretending to succeed. Silently accepting
* it would be friendlier for one tap and dishonest for the second: a member who
* reports twice because nothing seemed to happen deserves to be told the first
* one is already in the queue.
*/
async function file({ team, actor, targetType, targetId, reason, detail }) {
if (!TARGET_TYPES.includes(targetType)) {
return { ok: false, status: 400, error: 'Unknown report target' }
}
if (!REASONS.includes(reason)) {
return { ok: false, status: 400, error: 'Unknown report reason' }
}
const owner = await targetTeamId(targetType, targetId)
if (owner == null || owner !== team.id) {
return { ok: false, status: 404, error: 'Not found' }
}
const id = await reportsDb.insert({
targetType,
targetId,
teamId: team.id,
reporterUserId: actor.id,
reporterUsername: actor.username,
reason,
detail,
})
if (id == null) {
return { ok: false, status: 409, error: 'You have already reported this. Staff are looking at it.' }
}
return { ok: true, reportId: id }
}
/**
* The staff queue, with each row's target attached.
*
* Enrichment is three batched reads keyed by target type, not one read per row.
* The alternative N+1s a page of a hundred into three hundred queries, which is
* how a queue becomes a thing staff avoid opening.
*
* A target that has since been hard-deleted comes back as `null`, and the report
* still lists. That is deliberate: "somebody reported this and by the time we
* looked it was gone" is a fact a moderator needs, and dropping the row would
* hide the pattern of a member deleting their own content the moment it is
* reported.
*/
async function queue({ status, teamId, limit, offset } = {}) {
const rows = await reportsDb.list({ status, teamId, limit, offset })
if (!rows.length) return []
const idsOf = (type) => rows.filter((r) => r.target_type === type).map((r) => Number(r.target_id))
const [threads, posts, uploads] = await Promise.all([
reportsDb.threadsByIds([...new Set(idsOf('team_forum_thread'))]),
reportsDb.postsByIds([...new Set(idsOf('team_forum_post'))]),
reportsDb.uploadsByIds([...new Set(idsOf('team_forum_upload'))]),
])
const byId = (list) => new Map(list.map((row) => [Number(row.id), row]))
const threadMap = byId(threads)
const postMap = byId(posts)
const uploadMap = byId(uploads)
return rows.map((r) => ({ ...publicReport(r), target: describeTarget(r, { threadMap, postMap, uploadMap }) }))
}
/**
* The reported content, resolved.
*
* **Every miss returns `null`, never `undefined`.** They look interchangeable in
* JavaScript and are not in JSON: `undefined` is dropped by `JSON.stringify`, so
* a hard-deleted target would reach the client as an ABSENT `target` key rather
* than as an explicit null, and the queue's own contract says nullable. A client
* distinguishing "gone" from "not resolved yet" would get it wrong.
*/
function describeTarget(report, { threadMap, postMap, uploadMap }) {
const id = Number(report.target_id)
if (report.target_type === 'team_forum_thread') {
const t = threadMap.get(id)
if (!t) return null
return {
kind: 'thread',
threadId: t.id,
title: t.title,
type: t.type,
status: t.status,
author: t.created_username,
}
}
if (report.target_type === 'team_forum_post') {
const p = postMap.get(id)
if (!p) return null
return {
kind: 'post',
postId: p.id,
threadId: p.thread_id,
threadTitle: p.thread_title,
author: p.author_username,
status: p.status,
excerpt: excerpt(p.body_html),
createdAt: p.created_at,
}
}
if (report.target_type === 'team_forum_upload') {
const u = uploadMap.get(id)
if (!u) return null
// §5.6's fourth rule: uploader, size and the SNIFFED type, without hunting.
// This is the payoff for §5.5.4's attribution table being load-bearing rather
// than bookkeeping.
return {
kind: 'upload',
uploadId: u.id,
postId: u.post_id,
uploader: u.uploader_username,
filename: u.filename,
url: `/uploads/${u.filename}`,
mimetype: u.mimetype,
byteSize: u.byte_size,
createdAt: u.created_at,
deleted: u.deleted_at != null,
}
}
return null
}
function publicReport(row) {
return {
id: row.id,
targetType: row.target_type,
targetId: Number(row.target_id),
teamId: row.team_id,
reporter: row.reporter_username || '[deleted account]',
reporterDeleted: row.reporter_user_id == null,
reason: row.reason,
detail: row.detail,
status: row.status,
handledBy: row.handled_username,
handledNote: row.handled_note,
handledAt: row.handled_at,
createdAt: row.created_at,
}
}
/** Move a report along the queue. Staff-only by its route. */
async function handle({ id, actor, status, note }) {
if (!STATUSES.includes(status)) {
return { ok: false, status: 400, error: 'Unknown report status' }
}
const report = await reportsDb.byId(id)
if (!report) return { ok: false, status: 404, error: 'Report not found' }
await reportsDb.handle(id, {
status,
handledBy: actor.id,
handledUsername: actor.username,
note,
})
return { ok: true, report: publicReport(await reportsDb.byId(id)) }
}
module.exports = {
TARGET_TYPES,
REASONS,
STATUSES,
EXCERPT_CHARS,
file,
queue,
handle,
openCount: reportsDb.openCount,
publicReport,
targetTeamId,
}

View File

@@ -17,6 +17,20 @@ async function set(key, value, updatedBy = null) {
) )
} }
// One row WITH its provenance. `updated_by`/`updated_at` are already stored for
// every key; this is the only reader that needs them, because TEAMS.md §5.5.5
// makes the uploads acknowledgement a RECORDED consent rather than a displayed
// one, and "which admin accepted it, and when" is the question that has to be
// answerable afterwards.
async function getRow(key) {
const rows = await query(
'SELECT s.`key`, s.value, s.updated_by, s.updated_at, u.username AS updated_by_username '
+ 'FROM settings s LEFT JOIN users u ON u.id = s.updated_by WHERE s.`key` = ? LIMIT 1',
[key],
)
return rows[0] || null
}
// Insert a default only if the key does not already exist. // Insert a default only if the key does not already exist.
async function seedDefault(key, value) { async function seedDefault(key, value) {
await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value]) await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
@@ -30,4 +44,4 @@ async function remove(key) {
await query('DELETE FROM settings WHERE `key` = ?', [key]) await query('DELETE FROM settings WHERE `key` = ?', [key])
} }
module.exports = { getAll, get, set, seedDefault, remove } module.exports = { getAll, get, getRow, set, seedDefault, remove }

View File

@@ -16,6 +16,18 @@ const PUBLIC_KEYS = [
'theme_visual', // preset/custom colors, fonts, radii (JSON). See THEMING_AND_NAV.md §6.1. 'theme_visual', // preset/custom colors, fonts, radii (JSON). See THEMING_AND_NAV.md §6.1.
'brand_assets', // uploaded logo/hero/favicon overrides (JSON). §6.3. 'brand_assets', // uploaded logo/hero/favicon overrides (JSON). §6.3.
'nav_public', // public site nav overrides (JSON). §6.4. 'nav_public', // public site nav overrides (JSON). §6.4.
// The two Team-forum controls (TEAMS.md §5.5.6). The client needs the first to
// know whether to render the forum panel at all, and the second to decide which
// composer to show — an upload control that 404s is worse than no control.
// Neither is sensitive.
//
// `teams_forum_uploads_ack` is deliberately NOT here: who accepted a liability
// notice is operator detail, exactly as `failure_reason` is in MODULE_API.md
// §2.9. And publishing the mode does not move the DECISION client-side — the
// server still resolves what renders (§5.5.3); the client is only told which
// composer to draw.
'teams_forums_enabled',
'teams_forum_images',
] ]
// Admin-configurable theming & navigation (docs/website/THEMING_AND_NAV.md). // Admin-configurable theming & navigation (docs/website/THEMING_AND_NAV.md).

View File

@@ -0,0 +1,140 @@
// SQL for the two tables the access resolver reads: forum grants (path 3) and
// staff leadership overrides (§2.5.1).
//
// Kept separate from teams.db.js on purpose. The four authority paths are four
// tables answering four questions, and the single most important structural rule
// in TEAMS.md is that no resolver reads another path's table — a file boundary is
// a cheap way to make crossing one visible in a diff.
const { query } = require('../../utils/db')
// ── team_forum_grants (path 3) ─────────────────────────────────────────────
const GRANT_COLUMNS = `
id, team_id, user_id, username, granted_by, granted_username, granted_at, reason,
revoked_by, revoked_username, revoked_at, revoke_reason`
/** The caller's ACTIVE grant on a team, or undefined. At most one, by the unique key. */
async function activeGrant(teamId, userId) {
const rows = await query(
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants
WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`,
[teamId, userId],
)
return rows[0]
}
/** The whole ledger for a team, revoked rows included — the admin grant view. */
async function grantLedger(teamId) {
return query(
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? ORDER BY granted_at DESC, id DESC`,
[teamId],
)
}
/** Active grants only, for the "Forum guests" list and the per-team cap. */
async function activeGrants(teamId) {
return query(
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL
ORDER BY granted_at`,
[teamId],
)
}
/** How many active grants a team currently holds — the §2.5 per-Team cap reads this. */
async function activeGrantCount(teamId) {
const rows = await query(
'SELECT COUNT(*) AS n FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL',
[teamId],
)
return Number(rows[0]?.n || 0)
}
/**
* Issue a grant.
*
* Writes nothing but this table — that is the non-contamination invariant, and it
* is a property of this function being the ONLY writer on the grant path rather
* than of anyone remembering it at the call site. The username snapshots are
* taken here so the ledger still reads after either account is deleted (§2.10).
*/
async function insertGrant({ teamId, userId, username, grantedBy, grantedUsername, reason }) {
const res = await query(
`INSERT INTO team_forum_grants (team_id, user_id, username, granted_by, granted_username, reason)
VALUES (?, ?, ?, ?, ?, ?)`,
[teamId, userId, username, grantedBy, grantedUsername, reason ?? null],
)
return res.insertId
}
/**
* Revoke the active grant, if there is one.
*
* An UPDATE of the existing row rather than a delete: the table is a ledger as
* well as the current state, and `revoked_at` is what moves a row out of the
* unique key (the generated `active_marker` goes NULL) while keeping the history.
*/
async function revokeGrant({ teamId, userId, revokedBy, revokedUsername, reason }) {
const res = await query(
`UPDATE team_forum_grants
SET revoked_at = NOW(), revoked_by = ?, revoked_username = ?, revoke_reason = ?
WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`,
[revokedBy, revokedUsername, reason ?? null, teamId, userId],
)
return res.affectedRows > 0
}
// ── team_leader_overrides (§2.5.1) ─────────────────────────────────────────
const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at'
async function overridesForTeam(teamId) {
return query(`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? ORDER BY member_key`,
[teamId])
}
async function overrideFor(teamId, memberKey) {
const rows = await query(
`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? AND member_key = ?`,
[teamId, memberKey],
)
return rows[0]
}
/**
* Set or replace one override.
*
* The projection is never touched by this — `team_members.is_leader` keeps saying
* what the game says and this keeps saying what staff decided, which is the entire
* point (§2.5.1). An override applied INTO the projection would be clobbered by
* the next sync, fifteen minutes later.
*/
async function setOverride({ teamId, memberKey, effect, actorUserId, actorUsername, reason }) {
await query(
`INSERT INTO team_leader_overrides (team_id, member_key, effect, actor_user_id, actor_username, reason)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
effect = VALUES(effect), actor_user_id = VALUES(actor_user_id),
actor_username = VALUES(actor_username), reason = VALUES(reason), created_at = NOW()`,
[teamId, memberKey, effect, actorUserId, actorUsername, reason],
)
}
async function clearOverride(teamId, memberKey) {
const res = await query('DELETE FROM team_leader_overrides WHERE team_id = ? AND member_key = ?',
[teamId, memberKey])
return res.affectedRows > 0
}
module.exports = {
activeGrant,
grantLedger,
activeGrants,
activeGrantCount,
insertGrant,
revokeGrant,
overridesForTeam,
overrideFor,
setOverride,
clearOverride,
}

View File

@@ -0,0 +1,131 @@
// ── The four authority paths ───────────────────────────────────────────────
//
// The single most important structural rule in TEAMS.md (§2.5): these are four
// tables answering four questions, and **no resolver reads another path's table.**
//
// 1. Is this account a member? module team_members
// 2. Does this account lead the Team? module team_members.is_leader,
// plus a staff override
// 3. May it use the Team forum? CORE team_forum_grants OR path 1
// 4. May it get external-platform CORE, nothing of its own
// access? derived
//
// The temptation this file exists to resist is collapsing 1 and 3 into one
// boolean. They answer different questions about different populations: a forum
// grant may name any Runic Gateway account, including one with no game identity
// at all — that is the point of it, since letting an unlinked guildmate into the
// forum must not require a staff ticket. Treating "has forum access" as "is a
// member" would put that person on the roster, in the member count, and into the
// external-platform grant, which is where it stops being a modelling preference
// and becomes an impersonation risk (path 4 below).
//
// Non-contamination is the invariant: a manual grant never writes the membership
// projection, in either direction, ever. Both facts coexist and neither migrates
// into the other.
const accessDb = require('./teamAccess.db')
const teamsDb = require('./teams.db')
const identities = require('../userIdentities/userIdentities.model')
/**
* Path 3 — forum access. Two reads, OR'd, and nothing else.
*
* `viaGrant` is reported even when membership also holds, deliberately: both
* facts are true, the UI presents membership as the current reason, and the grant
* survives as audit history. Collapsing them into one boolean is what loses the
* record of who let this person in and why.
*/
async function forumAccess(teamId, userId) {
if (!userId) return { allowed: false, viaMembership: false, viaGrant: false, isLeader: false }
const [grant, member] = await Promise.all([
accessDb.activeGrant(teamId, userId), // path 3's own table
teamsDb.activeByUser(teamId, userId), // path 1
])
return {
allowed: Boolean(grant) || Boolean(member),
viaMembership: Boolean(member),
viaGrant: Boolean(grant),
isLeader: member ? await isLeader(teamId, member) : false,
}
}
/**
* Path 2 — leadership, with the staff override applied ON TOP of the synced value
* at read time (§2.5.1).
*
* Applied at read rather than written into the projection because the sync owns
* that column and rewrites it every interval. An override that lived in
* `team_members` would be undone fifteen minutes after staff set it, which is the
* whole reason this is a separate table read here.
*/
async function isLeader(teamId, member) {
if (!member) return false
const override = await accessDb.overrideFor(teamId, member.member_key)
if (override) return override.effect === 'grant'
return Boolean(member.is_leader)
}
/** Leadership for a caller identified by user id rather than by a member row. */
async function isLeaderByUser(teamId, userId) {
if (!userId) return false
const member = await teamsDb.activeByUser(teamId, userId)
return isLeader(teamId, member)
}
/**
* Path 4 — external-platform eligibility. Computed, no table of its own, and
* deliberately blind to path 3.
*
* The reason, stated so nobody "fixes" it later: an integration cannot verify
* that an unlinked, forum-granted account corresponds to a real game member, so
* it must not hand that account a privilege on a platform where impersonation has
* consequences. A forum is a room on the operator's own site with a known
* moderator; a Discord role is an identity claim in someone else's space.
*/
async function externalEligible(teamId, userId, platform) {
if (!userId || !platform) return false
const member = await teamsDb.activeByUser(teamId, userId) // path 1 ONLY
if (!member || member.user_id == null) return false // must be a LINKED game member
const linked = await identities.listForUser(userId)
return linked.some((i) => i.provider === platform)
}
/**
* A team's roster with overrides folded in, for the admin view and the Team page.
*
* The rows returned carry `is_leader` as RESOLVED — synced value plus override —
* and `is_leader_synced` as what the game actually said, so the admin surface can
* show that a decision was made rather than silently presenting it as fact.
*/
async function rosterWithOverrides(teamId, { includeDeparted = false } = {}) {
const [members, overrides] = await Promise.all([
teamsDb.membersByTeam(teamId, { includeDeparted }),
accessDb.overridesForTeam(teamId),
])
const byKey = new Map(overrides.map((o) => [o.member_key, o]))
return members.map((m) => {
const override = byKey.get(m.member_key)
return {
...m,
is_leader_synced: Boolean(m.is_leader),
is_leader: override ? override.effect === 'grant' : Boolean(m.is_leader),
leader_override: override
? { effect: override.effect, reason: override.reason, by: override.actor_username, at: override.created_at }
: null,
}
})
}
module.exports = {
forumAccess,
isLeader,
isLeaderByUser,
externalEligible,
rosterWithOverrides,
setLeaderOverride: accessDb.setOverride,
clearLeaderOverride: accessDb.clearOverride,
grantLedger: accessDb.grantLedger,
activeGrants: accessDb.activeGrants,
}

View File

@@ -0,0 +1,133 @@
// SQL for the per-Team activity feed (TEAMS.md §4.2). Statements only; every
// decision about what a caller may SEE lives in teamActivity.model.js.
const { query } = require('../../utils/db')
const ACTIVITY_COLUMNS = `
id, team_id, source, kind, summary, visibility,
actor_member_key, actor_user_id, payload, occurred_at, created_at`
/**
* Insert one item, idempotently when it carries a dedupe key.
*
* INSERT IGNORE against uq_team_activity_dedupe is what makes replay safe: a
* sidecar reconnect backfills a window of events it already delivered, and
* without this every reconnect would double-post the feed. The same trick
* `shard_events` uses, for the same reason.
*
* The unique key is (team_id, dedupe_key) and MariaDB treats NULL as distinct in
* a unique index, so items WITHOUT a key never collide with each other — an
* un-keyed push is always an insert, which is the documented contract (§4.1:
* `dedupeKey` is optional and "makes replay idempotent", so omitting it opts out).
*
* IGNORE would also swallow a genuine error — a bad FK, an over-long summary. The
* model validates and truncates before calling, so what reaches here can only fail
* on the dedupe key, and `affectedRows` reports which happened.
*/
async function insert(item) {
const res = await query(
`INSERT IGNORE INTO team_activity
(team_id, source, kind, summary, visibility, actor_member_key, actor_user_id, payload, occurred_at, dedupe_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
item.teamId,
item.source,
item.kind,
item.summary,
item.visibility,
item.actorMemberKey,
item.actorUserId,
item.payload === null ? null : JSON.stringify(item.payload),
new Date(item.occurredAt),
item.dedupeKey,
],
)
return Number(res.affectedRows) > 0
}
/**
* One page of a Team's feed, already narrowed to the visibilities the caller may
* see.
*
* `visibilities` is always supplied by the model and never by a request
* parameter — a caller naming its own visibility filter is the whole bug this
* table's ENUM exists to prevent. Ordered newest first by `occurred_at`, the
* game's clock, not `created_at`: a backfill that arrives late still sorts where
* it happened.
*/
async function page(teamId, visibilities, { limit, offset }) {
const slots = visibilities.map(() => '?').join(', ')
return query(
`SELECT ${ACTIVITY_COLUMNS} FROM team_activity
WHERE team_id = ? AND visibility IN (${slots})
ORDER BY occurred_at DESC, id DESC
LIMIT ? OFFSET ?`,
[teamId, ...visibilities, limit, offset],
)
}
/** Total matching rows, for the same filter — so a client can page honestly. */
async function count(teamId, visibilities) {
const slots = visibilities.map(() => '?').join(', ')
const rows = await query(
`SELECT COUNT(*) AS n FROM team_activity WHERE team_id = ? AND visibility IN (${slots})`,
[teamId, ...visibilities],
)
return Number(rows[0] ? rows[0].n : 0)
}
/** Everything older than the retention horizon, across every Team. */
async function deleteOlderThan(days) {
const res = await query(
'DELETE FROM team_activity WHERE occurred_at < (NOW() - INTERVAL ? DAY)',
[days],
)
return Number(res.affectedRows) || 0
}
/**
* Which Teams currently exceed the per-Team row cap, and by how much.
*
* Asked first so the trim only runs for Teams that need it. A feed fed by a game
* loop is the obvious unbounded-growth failure (§4.2), and on a shard with one
* busy guild and fifty quiet ones this keeps the nightly job proportional to the
* problem rather than to the number of Teams.
*/
async function overCap(cap) {
return query(
`SELECT team_id, COUNT(*) AS n FROM team_activity
GROUP BY team_id HAVING n > ?`,
[cap],
)
}
/**
* Trim one Team back to the newest `cap` rows.
*
* Expressed as "delete everything at or below the id of the cap-th newest row"
* rather than as a correlated subquery on the same table, which MariaDB refuses
* inside a DELETE (error 1093). The derived table is what makes it legal — the
* subquery is materialised before the delete runs.
*/
async function trimToCap(teamId, cap) {
const rows = await query(
`SELECT id FROM team_activity
WHERE team_id = ? ORDER BY occurred_at DESC, id DESC LIMIT 1 OFFSET ?`,
[teamId, cap],
)
if (!rows[0]) return 0
const res = await query(
'DELETE FROM team_activity WHERE team_id = ? AND id <= ?',
[teamId, rows[0].id],
)
return Number(res.affectedRows) || 0
}
module.exports = {
insert,
page,
count,
deleteOlderThan,
overCap,
trimToCap,
}

View File

@@ -0,0 +1,312 @@
// ── The per-Team activity feed (TEAMS.md Part 4) ───────────────────────────
//
// Two writers, one table. A module pushes game items through
// `ctx.teams.activity.push` (§4.1); core writes its own membership and rename
// items directly (§4.2). Both land in `team_activity` with a `source`, and the
// read path treats them identically — which is the point of core writing here at
// all, since it means the rendering path is exercised from day one on a
// deployment whose module pushes nothing.
//
// **Three rules shape this file.**
//
// 1. *Core never composes a summary.* `summary` arrives already rendered and is
// stored verbatim (§4.1). Core cannot phrase "gained 15,000 gold" for a game
// whose vocabulary it does not know, and a core that templated it would have
// re-acquired the game semantics the module system exists to remove. Core's OWN
// five kinds are the sole exception, and they are about membership and renames
// — platform facts, not game ones.
//
// 2. *Visibility fails closed.* An item with no stated visibility is `members`,
// and the read path resolves what a caller may see from their access rather
// than from anything they send.
//
// 3. *A push never throws at its call site.* `ctx.teams.activity.push` is awaited
// by a module inside a game-event handler. A bad item is dropped and logged;
// an unknown Team is dropped and logged. The alternative — rejecting the batch
// — makes core's storage problem into the module's control flow, and the
// contract (MODULE_API.md §2.3) is that ctx pushes are fire-and-forget.
const activityDb = require('./teamActivity.db')
const teamsDb = require('./teams.db')
const access = require('./teamAccess.model')
const settings = require('../settings/settings.model')
const log = require('../../utils/logger')('teams')
// Column widths from schema.sql. Truncating rather than refusing: an over-long
// summary is a module being verbose, not a module being wrong, and dropping the
// item would lose a real event over a display detail.
const MAX_SUMMARY = 255
const MAX_KIND = 64
const MAX_MEMBER_KEY = 191
// CHAR(40) — a sha1 hex is the natural fit and what §4.1's example looks like,
// but the column is opaque and any stable string within the width works.
const MAX_DEDUPE = 40
const VISIBILITIES = ['public', 'members']
// Retention (§4.2). Both are settings so an operator can tighten a busy shard
// without a deploy; the defaults are the doc's.
const DEFAULT_RETAIN_DAYS = 90
const DEFAULT_ROW_CAP = 2000
/**
* Core's own kinds (§4.2).
*
* `core.forum.thread` is named in the doc and lands with the forum in phase 4 —
* there is nothing to emit it from yet. The four here are all core knows how to
* say without asking a game anything.
*/
const CORE_KINDS = {
MEMBER_JOINED: 'core.member.joined',
MEMBER_LEFT: 'core.member.left',
LEADER_CHANGED: 'core.leader.changed',
TEAM_RENAMED: 'core.team.renamed',
}
const clamp = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null)
/**
* Normalise one pushed item, or return null to drop it.
*
* `teamId` is resolved by the caller, not carried on the item: a module names its
* own `externalId` and core maps it (§4.1), so a module can never write into
* another module's Team by guessing an integer.
*/
function normalise(item, source, teamId) {
if (!item || typeof item !== 'object') return null
const kind = clamp(item.kind, MAX_KIND)
const summary = clamp(item.summary, MAX_SUMMARY)
// Both are load-bearing and neither has a safe default: an item with no kind
// cannot be filtered or rendered by a slot, and one with no summary is a blank
// row on a public page.
if (!kind || !summary) return null
// `occurredAt` is the game's clock and the feed's sort key. A missing or
// unparseable one becomes now — the item is real even when its timestamp is
// not, and dropping it would lose an event over metadata.
const occurredAt = Number.isFinite(item.occurredAt) ? Number(item.occurredAt) : Date.now()
return {
teamId,
source,
kind,
summary,
visibility: VISIBILITIES.includes(item.visibility) ? item.visibility : 'members',
actorMemberKey: clamp(item.actorMemberKey, MAX_MEMBER_KEY),
// Resolved BY THE MODULE, like every other user id crossing this boundary
// (§2.3) — core takes the number and never looks it up.
actorUserId: Number.isInteger(item.actorUserId) && item.actorUserId > 0 ? item.actorUserId : null,
payload: item.payload && typeof item.payload === 'object' ? item.payload : null,
occurredAt,
dedupeKey: clamp(item.dedupeKey, MAX_DEDUPE),
}
}
/**
* `ctx.teams.activity.push` — a module's whole write access to the feed.
*
* Items name their Team by the module's own `externalId`, and only ACTIVE Teams
* owned by THAT module resolve. An archived Team is deliberately not writable: its
* feed is a read-only record of what happened before the rename or the disband
* (§2.2), and letting a late-arriving event append to it would make a closed
* record grow.
*
* Returns the number of items actually stored. Dropped items are logged with the
* reason and never raised — see rule 3 above.
*/
async function push(source, items) {
if (!Array.isArray(items)) {
log.warn('teams activity push: not an array', { source })
return 0
}
if (!items.length) return 0
// One lookup per distinct externalId, not one per item: a champion spawn
// completing pushes a batch for a single Team, and re-resolving it per item
// would be a query per row.
const teamIds = new Map()
let stored = 0
let dropped = 0
for (const item of items) {
const externalId = item && typeof item.externalId === 'string' ? item.externalId.trim() : ''
if (!externalId) { dropped += 1; continue }
if (!teamIds.has(externalId)) {
// eslint-disable-next-line no-await-in-loop
const row = await teamsDb.findActive(source, externalId)
teamIds.set(externalId, row ? row.id : null)
}
const teamId = teamIds.get(externalId)
if (!teamId) { dropped += 1; continue }
const normalised = normalise(item, source, teamId)
if (!normalised) { dropped += 1; continue }
// eslint-disable-next-line no-await-in-loop
const inserted = await activityDb.insert(normalised)
// A dedupe collision is a SUCCESSFUL no-op, not a drop — it is the mechanism
// working. Counted as stored so a module replaying a backfill does not read
// its own idempotence as data loss.
if (inserted) stored += 1
}
if (dropped) {
log.warn('teams activity push: dropped items', { source, dropped, offered: items.length })
}
return stored
}
/**
* Core's own write path (§4.2), used by the reconciler and the rename rule.
*
* Separate from `push` because core names a Team by its own primary key — it is
* already holding the row — and because core's items are always `public`: a
* member joining or a Team being renamed is exactly what a public Team page is
* for. Nothing here is game vocabulary.
*/
async function logCore({ teamId, kind, summary, actorMemberKey = null, actorUserId = null, occurredAt = Date.now(), dedupeKey = null }) {
if (!teamId || !kind || !summary) return false
return activityDb.insert({
teamId,
source: 'core',
kind: clamp(kind, MAX_KIND),
summary: clamp(summary, MAX_SUMMARY),
visibility: 'public',
actorMemberKey: clamp(actorMemberKey, MAX_MEMBER_KEY),
actorUserId: Number.isInteger(actorUserId) && actorUserId > 0 ? actorUserId : null,
payload: null,
occurredAt,
dedupeKey: clamp(dedupeKey, MAX_DEDUPE),
})
}
/**
* Which visibilities a caller may see (§4.3).
*
* `members` items go to members and to forum-granted users — the same two
* authority paths `forumAccess` already resolves, reused rather than re-derived
* so the feed can never disagree with the forum about who is inside a Team.
* Anyone else, including every anonymous caller, sees `public` only.
*/
async function visibilitiesFor(teamId, userId) {
if (!userId) return ['public']
const resolved = await access.forumAccess(teamId, userId)
return resolved.allowed ? ['public', 'members'] : ['public']
}
/** The rendered shape. `payload` rides along for the module's slot (§4.3). */
function publicItem(row) {
return {
id: Number(row.id),
source: row.source,
kind: row.kind,
summary: row.summary,
visibility: row.visibility,
occurredAt: row.occurred_at,
payload: row.payload ?? null,
}
}
/**
* One page of a Team's feed for one viewer.
*
* A HIDDEN Team's feed is not served publicly, for the same reason its roster is
* not (§2.8.3): hidden means absent from every public surface, and a feed that
* answered while the page 404s would republish the suppressed name in every
* `core.team.renamed` summary.
*/
async function feedFor(slug, userId, { limit = 50, offset = 0 } = {}) {
const row = await teamsDb.findBySlug(slug)
if (!row) return null
const visibilities = await visibilitiesFor(row.id, userId)
// A member of a hidden Team still sees its feed — suppression is a
// public-surface rule, and a member is not a member of the public (§2.11).
if (row.hidden && visibilities.length === 1) return null
const [rows, total] = await Promise.all([
activityDb.page(row.id, visibilities, { limit, offset }),
activityDb.count(row.id, visibilities),
])
return {
items: rows.map(publicItem),
total,
limit,
offset,
// So a client can render "members-only items are hidden" rather than
// presenting a filtered feed as the whole one.
scope: visibilities.includes('members') ? 'members' : 'public',
}
}
// ── Retention (§4.2) ───────────────────────────────────────────────────────
const RETAIN_KEY = 'team_activity_retain_days'
const CAP_KEY = 'team_activity_row_cap'
/**
* Read both limits, falling back to the defaults on anything unreadable.
*
* Wrapped in a try like `teamSync.intervalSeconds`, and for the same reason: this
* runs on a timer with nobody watching, and a settings table that is briefly
* unavailable must yield the default rather than an exception that kills the
* nightly job. A misconfigured value fails the same way — a zero or a negative
* retention would delete the whole feed, so it is rejected rather than honoured.
*/
async function retentionConfig() {
let rawDays
let rawCap
try {
;[rawDays, rawCap] = await Promise.all([settings.get(RETAIN_KEY), settings.get(CAP_KEY)])
} catch {
return { days: DEFAULT_RETAIN_DAYS, cap: DEFAULT_ROW_CAP }
}
const days = Number.parseInt(rawDays, 10)
const cap = Number.parseInt(rawCap, 10)
return {
days: Number.isFinite(days) && days > 0 ? days : DEFAULT_RETAIN_DAYS,
cap: Number.isFinite(cap) && cap > 0 ? cap : DEFAULT_ROW_CAP,
}
}
/**
* The nightly prune: an age horizon AND a per-Team row cap.
*
* Both, because either alone has a hole. Age alone lets one busy guild write a
* million rows inside the window; a cap alone keeps a dead Team's feed forever.
* Unbounded growth on a per-Team feed fed by a game loop is the obvious failure
* here and it is cheaper to bound it now than to discover it at cutover.
*/
async function prune() {
const { days, cap } = await retentionConfig()
const byAge = await activityDb.deleteOlderThan(days)
let byCap = 0
const over = await activityDb.overCap(cap)
for (const row of over) {
// eslint-disable-next-line no-await-in-loop
byCap += await activityDb.trimToCap(row.team_id, cap)
}
if (byAge || byCap) log.info('teams activity prune', { byAge, byCap, days, cap })
return { byAge, byCap, days, cap }
}
module.exports = {
push,
logCore,
feedFor,
visibilitiesFor,
publicItem,
prune,
retentionConfig,
RETAIN_KEY,
CAP_KEY,
CORE_KINDS,
VISIBILITIES,
DEFAULT_RETAIN_DAYS,
DEFAULT_ROW_CAP,
}

View File

@@ -0,0 +1,294 @@
// SQL for the four forum tables (TEAMS.md §5.2, §5.2a).
//
// Kept apart from teamAccess.db.js for the same reason that file is kept apart
// from teams.db.js: forum CONTENT and forum ACCESS are different questions, and a
// query here that read `team_members` to decide who may see a thread would be the
// exact collapse §2.5 forbids. Nothing in this file resolves access; callers hand
// it a decision the resolver already made.
const { query } = require('../../utils/db')
const THREAD_COLUMNS = `
id, team_id, type, title, created_by, created_username, created_at,
last_post_at, post_count, pinned, locked, status`
const POST_COLUMNS = `
id, thread_id, author_user_id, author_username, body_html, created_at,
edited_at, edited_by, status`
// ── threads ────────────────────────────────────────────────────────────────
/**
* A Team's threads, newest activity first with pinned rows on top.
*
* `includeHidden` is the staff/leader view. Hidden is not deleted: a hidden
* thread stays in the ledger and comes back with `unhide`, which is why the
* status filter is a parameter rather than a WHERE clause everyone remembers.
*/
async function threadsByTeam(teamId, { includeHidden = false, limit = 50, offset = 0 } = {}) {
const statuses = includeHidden ? "('visible','hidden')" : "('visible')"
return query(
`SELECT ${THREAD_COLUMNS} FROM team_forum_threads
WHERE team_id = ? AND status IN ${statuses}
ORDER BY pinned DESC, COALESCE(last_post_at, created_at) DESC, id DESC
LIMIT ? OFFSET ?`,
[teamId, limit, offset],
)
}
async function threadById(id) {
const rows = await query(`SELECT ${THREAD_COLUMNS} FROM team_forum_threads WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
async function insertThread({ teamId, type, title, createdBy, createdUsername }) {
const res = await query(
`INSERT INTO team_forum_threads (team_id, type, title, created_by, created_username, last_post_at, post_count)
VALUES (?, ?, ?, ?, ?, NOW(), 0)`,
[teamId, type, title, createdBy, createdUsername],
)
return res.insertId
}
/** Apply one moderation action's effect. The LEDGER row is written separately. */
async function setThreadFlags(id, { pinned, locked, status }) {
const sets = []
const args = []
if (pinned !== undefined) { sets.push('pinned = ?'); args.push(pinned ? 1 : 0) }
if (locked !== undefined) { sets.push('locked = ?'); args.push(locked ? 1 : 0) }
if (status !== undefined) { sets.push('status = ?'); args.push(status) }
if (!sets.length) return false
args.push(id)
const res = await query(`UPDATE team_forum_threads SET ${sets.join(', ')} WHERE id = ?`, args)
return res.affectedRows > 0
}
// ── posts ──────────────────────────────────────────────────────────────────
async function postsByThread(threadId, { includeHidden = false } = {}) {
const statuses = includeHidden ? "('visible','hidden')" : "('visible')"
return query(
`SELECT ${POST_COLUMNS} FROM team_forum_posts
WHERE thread_id = ? AND status IN ${statuses} ORDER BY created_at, id`,
[threadId],
)
}
async function postById(id) {
const rows = await query(`SELECT ${POST_COLUMNS} FROM team_forum_posts WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
/**
* Append a post and move the thread's counters in the same breath.
*
* Two statements rather than a trigger: the counters are a denormalisation for
* the thread list, and a trigger would put half the write in the schema where
* nobody reading this file would find it.
*/
async function insertPost({ threadId, authorUserId, authorUsername, bodyHtml }) {
const res = await query(
`INSERT INTO team_forum_posts (thread_id, author_user_id, author_username, body_html)
VALUES (?, ?, ?, ?)`,
[threadId, authorUserId, authorUsername, bodyHtml],
)
await query(
'UPDATE team_forum_threads SET post_count = post_count + 1, last_post_at = NOW() WHERE id = ?',
[threadId],
)
return res.insertId
}
async function setPostStatus(id, status) {
const res = await query('UPDATE team_forum_posts SET status = ? WHERE id = ?', [status, id])
return res.affectedRows > 0
}
/**
* Rewrite a post's body, stamping who edited it and when.
*
* `edited_at` is set unconditionally, including when a staffer edits — the column
* answers "has this been changed since it was written", which a reader needs to
* know regardless of whose hand did it. `edited_by` is the second half of that
* answer and is why the two are separate columns rather than a boolean.
*/
async function updatePostBody(id, bodyHtml, editedBy) {
const res = await query(
'UPDATE team_forum_posts SET body_html = ?, edited_at = NOW(), edited_by = ? WHERE id = ?',
[bodyHtml, editedBy, id],
)
return res.affectedRows > 0
}
/**
* Recompute a thread's denormalised counters from the posts that are actually
* visible.
*
* Called after every post moderation rather than incrementing and decrementing,
* because hide → unhide → delete → restore is a sequence in which a counter kept
* by deltas drifts the first time any step is retried or raced. The read is one
* indexed aggregate over one thread; correctness is worth more than the write it
* saves. `last_post_at` falls back to NULL for an emptied thread, which is what
* `threadsByTeam`'s COALESCE onto `created_at` already expects.
*/
async function recountThread(threadId) {
await query(
`UPDATE team_forum_threads t
SET t.post_count = (SELECT COUNT(*) FROM team_forum_posts p
WHERE p.thread_id = t.id AND p.status = 'visible'),
t.last_post_at = (SELECT MAX(p.created_at) FROM team_forum_posts p
WHERE p.thread_id = t.id AND p.status = 'visible')
WHERE t.id = ?`,
[threadId],
)
}
// ── the moderation ledger (append-only) ────────────────────────────────────
async function insertModeration({ teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason }) {
await query(
`INSERT INTO team_forum_moderation
(team_id, target_type, target_id, action, actor_user_id, actor_username, actor_role, reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason ?? null],
)
}
async function moderationForTeam(teamId, { limit = 100, offset = 0 } = {}) {
return query(
`SELECT id, team_id, target_type, target_id, action, actor_user_id, actor_username,
actor_role, reason, created_at
FROM team_forum_moderation WHERE team_id = ?
ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
[teamId, limit, offset],
)
}
// ── uploads (§5.2a) ────────────────────────────────────────────────────────
const UPLOAD_COLUMNS = `
id, team_id, post_id, uploader_user_id, uploader_username, filename, mimetype,
byte_size, created_at, deleted_at, deleted_by`
async function insertUpload({ teamId, postId, uploaderUserId, uploaderUsername, filename, mimetype, byteSize }) {
const res = await query(
`INSERT INTO team_forum_uploads
(team_id, post_id, uploader_user_id, uploader_username, filename, mimetype, byte_size)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[teamId, postId ?? null, uploaderUserId, uploaderUsername, filename, mimetype, byteSize],
)
return res.insertId
}
async function uploadById(id) {
const rows = await query(`SELECT ${UPLOAD_COLUMNS} FROM team_forum_uploads WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
/** Bytes this account has uploaded in the trailing window — the §5.5.4 daily quota. */
async function bytesUploadedSince(userId, sinceHours) {
const rows = await query(
`SELECT COALESCE(SUM(byte_size), 0) AS bytes FROM team_forum_uploads
WHERE uploader_user_id = ? AND created_at > (NOW() - INTERVAL ? HOUR)`,
[userId, sinceHours],
)
return Number(rows[0]?.bytes || 0)
}
/** The admin attribution view: who uploaded what, when, how much, and where. */
async function listUploads({ limit = 100, offset = 0, includeDeleted = false } = {}) {
return query(
`SELECT u.id, u.team_id, u.post_id, u.uploader_user_id, u.uploader_username,
u.filename, u.mimetype, u.byte_size, u.created_at, u.deleted_at, u.deleted_by,
t.name AS team_name, t.slug AS team_slug
FROM team_forum_uploads u JOIN teams t ON t.id = u.team_id
${includeDeleted ? '' : 'WHERE u.deleted_at IS NULL'}
ORDER BY u.created_at DESC, u.id DESC LIMIT ? OFFSET ?`,
[limit, offset],
)
}
async function softDeleteUpload(id, deletedBy) {
const res = await query(
'UPDATE team_forum_uploads SET deleted_at = NOW(), deleted_by = ? WHERE id = ? AND deleted_at IS NULL',
[deletedBy, id],
)
return res.affectedRows > 0
}
/** Soft-delete every upload attached to a post — the lifecycle half of §5.5.4. */
async function softDeleteUploadsForPost(postId, deletedBy) {
await query(
'UPDATE team_forum_uploads SET deleted_at = NOW(), deleted_by = ? WHERE post_id = ? AND deleted_at IS NULL',
[deletedBy, postId],
)
}
/**
* The other half of the pair: a restored post gets its images back.
*
* Without this, `delete` then `restore` returns the words and loses the pictures —
* and loses them SILENTLY, because the soft-deleted rows survive the retention
* window before the sweep takes the bytes, so the post looks fine until the night
* it does not. Beyond that window the row itself is gone and this is a no-op;
* nothing can be done about that and nothing should pretend otherwise.
*/
async function restoreUploadsForPost(postId) {
await query(
'UPDATE team_forum_uploads SET deleted_at = NULL, deleted_by = NULL WHERE post_id = ? AND deleted_at IS NOT NULL',
[postId],
)
}
/** Rows soft-deleted longer ago than the retention window — the sweep's worklist. */
async function sweepableUploads(retentionDays) {
return query(
`SELECT id, filename FROM team_forum_uploads
WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)`,
[retentionDays],
)
}
/** Never-referenced uploads older than the grace period — a composer opened and abandoned. */
async function orphanedUploads(graceHours) {
return query(
`SELECT id, filename FROM team_forum_uploads
WHERE post_id IS NULL AND deleted_at IS NULL AND created_at < (NOW() - INTERVAL ? HOUR)`,
[graceHours],
)
}
async function deleteUploadRows(ids) {
if (!ids.length) return 0
const res = await query(
`DELETE FROM team_forum_uploads WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)
return res.affectedRows
}
module.exports = {
threadsByTeam,
threadById,
insertThread,
setThreadFlags,
postsByThread,
postById,
insertPost,
setPostStatus,
updatePostBody,
recountThread,
insertModeration,
moderationForTeam,
insertUpload,
uploadById,
bytesUploadedSince,
listUploads,
softDeleteUpload,
softDeleteUploadsForPost,
restoreUploadsForPost,
sweepableUploads,
orphanedUploads,
deleteUploadRows,
}

View File

@@ -0,0 +1,437 @@
// ── The forum: access + announcements (5a), discussion + moderation (5b) ───
//
// TEAMS.md §5.1's split is BY LAYER, not by feature: 5a shipped the whole access
// model and a single announcements stream per Team; 5b (phase 5) opens discussion
// threads, replies, editing and post-level moderation. The schema for all of it
// landed together, so this phase added no ALTER — every column it needed
// (`type`, `locked`, `edited_at`, `edited_by`, the post table's `status`, the
// ledger's `target_type='post'`) was already there waiting.
//
// **Every function here takes an already-resolved access decision.** Nothing in
// this file reads `team_members` or `team_forum_grants`; the caller asks
// teamAccess.forumAccess() once and hands the answer down. That is §5.4's "never
// by checking membership directly, which is how paths 1 and 3 would drift back
// together", made structural.
//
// **The read path is where the image policy is applied**, once, in `renderPost`.
// Not in the controller and never in the client: the client is TOLD the mode so it
// can draw the right composer, and is never the thing that decides whether an
// image appears (§5.5.6).
const forumDb = require('./teamForum.db')
const forumSettings = require('./teamForumSettings.model')
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
// Announcements are leader-authored and take no replies; discussion threads are
// member-authored and do. Both have been in the enum since 5a — what phase 5
// changed is that both are now CREATABLE, and by different people.
//
// **The authority split lives in the controller, not here.** This list says what
// kinds of thread exist; who may make one is a question about the caller, which
// this file deliberately never asks (see the header on access decisions).
const CREATABLE_TYPES = ['announcement', 'discussion']
// Kept as an export because it names a real fact — the one type 5a could create —
// and because removing a name from a module's surface to save a line is how a
// consumer outside this repo breaks. It is not used to decide anything.
const CREATABLE_TYPES_5A = ['announcement']
// Which thread types accept replies. An announcement's `locked` stays false even
// though nothing may reply to it: replies are refused because the TYPE takes none,
// not because the thread was closed, and conflating the two would make "unlock"
// look like it would open replies on an announcement.
const REPLYABLE_TYPES = ['discussion']
const DELETED_AUTHOR = '[deleted account]'
/**
* Moderation actions, and what each one does to the row.
*
* A table rather than a switch because the ledger and the effect have to stay in
* step: every entry here writes one row of `team_forum_moderation` naming the
* authority that was exercised, and an action with an effect but no ledger entry
* would be a moderation nobody can audit.
*/
const THREAD_ACTIONS = {
pin: { pinned: true },
unpin: { pinned: false },
lock: { locked: true },
unlock: { locked: false },
hide: { status: 'hidden' },
unhide: { status: 'visible' },
delete: { status: 'deleted' },
restore: { status: 'visible' },
}
// Post-level moderation. A strict subset of THREAD_ACTIONS: `pin` and `lock`
// describe a thread's place in a list and its openness to replies, neither of
// which a post has. Naming them here as "not applicable" rather than as "unknown"
// is what lets `moderatePost` tell a caller which mistake they made.
const POST_ACTIONS = {
hide: { status: 'hidden' },
unhide: { status: 'visible' },
delete: { status: 'deleted' },
restore: { status: 'visible' },
}
function publicThread(row) {
return {
id: row.id,
type: row.type,
title: row.title,
author: row.created_username || DELETED_AUTHOR,
authorDeleted: row.created_by == null,
createdAt: row.created_at,
lastPostAt: row.last_post_at,
postCount: row.post_count,
pinned: Boolean(row.pinned),
locked: Boolean(row.locked),
status: row.status,
}
}
/**
* May this viewer edit this post, and until when?
*
* **Computed on the server and handed to the client, never the other way round** —
* the same rule §5.5.3 applies to the image policy, for the same reason. A client
* that decided this would be deciding it against its own clock, and a clock is the
* one input a time-bounded permission must not take from the party it bounds.
*
* Staff get `editableUntil: null`, which reads as "no deadline" rather than as "no
* permission" — `canEdit` is the permission and this is only its expiry. An author
* past their window keeps a past `editableUntil`, so the UI can say *why* the
* control is gone instead of silently dropping it.
*/
function editability(row, { userId = null, isStaff = false, windowMinutes = 0, now = Date.now() } = {}) {
// A hidden or deleted post is not editable by anybody, staff included. Restoring
// it is a moderation action with a ledger row; quietly rewriting it while it is
// out of sight is the same act with no record.
if (row.status !== 'visible') return { canEdit: false, editableUntil: null }
if (isStaff) return { canEdit: true, editableUntil: null }
if (!userId || row.author_user_id == null || row.author_user_id !== userId) {
return { canEdit: false, editableUntil: null }
}
const until = new Date(row.created_at).getTime() + windowMinutes * 60_000
return { canEdit: until > now, editableUntil: new Date(until).toISOString() }
}
/**
* One post, rendered for one image policy and one viewer.
*
* `body` is what the reader gets and `mode` decides whether it carries images.
* The STORED html is never modified — flipping the policy changes this function's
* output and nothing on disk, which is the property §5.5.3 exists to give and the
* one acceptance criterion 3 measures.
*
* `viewer` is optional so that every 5a caller keeps working unchanged; omitting
* it yields `canEdit: false`, which is the right answer for a caller that has not
* said who is reading.
*/
function renderPost(row, mode, viewer) {
return {
id: row.id,
author: row.author_username || DELETED_AUTHOR,
authorDeleted: row.author_user_id == null,
body: renderForumBody(row.body_html, mode),
createdAt: row.created_at,
editedAt: row.edited_at,
status: row.status,
mine: Boolean(viewer?.userId) && row.author_user_id === viewer.userId,
...editability(row, viewer),
}
}
/**
* The thread list for one viewer.
*
* `canModerate` widens what is returned, not just what is offered: a hidden
* thread is visible to the people who can unhide it and to nobody else, so the
* same call answers both audiences without a second endpoint that could disagree
* with this one.
*/
async function listThreads(teamId, { canModerate = false, limit = 50, offset = 0 } = {}) {
const rows = await forumDb.threadsByTeam(teamId, { includeHidden: canModerate, limit, offset })
return rows.map(publicThread)
}
/**
* One thread with its posts, rendered under the current image policy and for one
* viewer.
*
* `viewer` carries who is reading and what the edit window is, so every post comes
* back already knowing whether this caller may edit it. The alternative — shipping
* the window to the client and letting it compare timestamps — is the thing
* `editability` exists not to do.
*/
async function getThread(teamId, threadId, { canModerate = false, viewer } = {}) {
const thread = await forumDb.threadById(threadId)
// The team check is here rather than in the SQL so a thread id from another
// Team reads as "not found" and not as "found, but not yours" — a forum is a
// private room and the existence of a thread in it is itself private.
if (!thread || thread.team_id !== teamId) return null
if (thread.status === 'deleted' && !canModerate) return null
if (thread.status === 'hidden' && !canModerate) return null
const mode = await forumSettings.imageMode()
const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate })
return {
...publicThread(thread),
// A reply control is offered when the TYPE takes replies and the thread is
// open. Both halves are reported separately (`type`, `locked`) so the UI can
// say which one is why, but the decision itself is made here — a client that
// recomputed it would be a second place for the rule to live.
canReply: REPLYABLE_TYPES.includes(thread.type) && !thread.locked && thread.status === 'visible',
posts: posts.map((p) => renderPost(p, mode, viewer)),
}
}
/**
* Open a thread: the thread and its first post, in one call.
*
* An announcement is a degenerate thread rather than its own thing (§5.1), which
* is why phase 5 added no migration — a discussion thread is the same two writes
* with a different `type`. The FIRST post is an ordinary post and is moderated,
* edited and reported like any other; nothing here marks it as special, because a
* thread whose opening post could not be moderated would be a hole shaped exactly
* like the one moderation exists to close.
*/
async function createThread({ team, actor, type, title, body }) {
if (!CREATABLE_TYPES.includes(type)) {
return { ok: false, status: 400, error: 'Unknown thread type' }
}
const cleaned = cleanForumBody(body)
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
return { ok: false, status: 400, error: 'A post needs a body' }
}
const threadId = await forumDb.insertThread({
teamId: team.id,
type,
title,
createdBy: actor.id,
createdUsername: actor.username,
})
const postId = await forumDb.insertPost({
threadId,
authorUserId: actor.id,
authorUsername: actor.username,
bodyHtml: cleaned,
})
// `notify` is what the CONTROLLER needs to fan a notification out, and it is a
// separate key rather than more fields on the result because the controller
// spreads the result straight into the response body — a notification's excerpt
// is not part of the API's answer to "did my post save".
//
// The notification itself is fired from the controller and not from here, on
// this file's own rule (see the header): everything in it takes an
// already-resolved access decision and reads no membership table. The fan-out
// reads both, so importing it here would make the forum model transitively
// depend on exactly what it exists not to touch.
return { ok: true, threadId, postId, notify: { threadId, title, type, bodyHtml: cleaned } }
}
/**
* Reply to a discussion thread.
*
* Three refusals, and the status codes are chosen to be distinguishable rather
* than uniform. A thread that is not there, or is hidden from this caller, is 404
* for the §5.5.1 reason. An announcement is 400 — the request is malformed for
* this thread, and no amount of retrying fixes it. A locked thread is **409**: the
* request is fine and the resource's state is what refuses, which is exactly the
* distinction a client needs to tell "you cannot" from "not right now".
*
* **Locked refuses staff too.** They hold `unlock`, so nothing is lost — and what
* is gained is that `locked` means the same thing to every reader. A moderator's
* reply appearing in a thread nobody else may answer is the last word by fiat;
* unlock, post, relock is the same outcome with three ledger rows saying so.
*/
async function createPost({ team, threadId, actor, body }) {
const thread = await forumDb.threadById(threadId)
if (!thread || thread.team_id !== team.id || thread.status !== 'visible') {
return { ok: false, status: 404, error: 'Thread not found' }
}
if (!REPLYABLE_TYPES.includes(thread.type)) {
return { ok: false, status: 400, error: 'Announcements do not take replies' }
}
if (thread.locked) {
return { ok: false, status: 409, error: 'This thread is locked' }
}
const cleaned = cleanForumBody(body)
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
return { ok: false, status: 400, error: 'A reply needs a body' }
}
const postId = await forumDb.insertPost({
threadId,
authorUserId: actor.id,
authorUsername: actor.username,
bodyHtml: cleaned,
})
// The thread's OWN title and type, not the reply's — a reply has neither, and
// what a recipient needs to know is which conversation moved. `type` is always
// 'discussion' here (an announcement takes no replies) and is carried anyway so
// the controller has one shape to hand the fan-out from both routes.
return { ok: true, threadId, postId, notify: { threadId, title: thread.title, type: thread.type, bodyHtml: cleaned } }
}
/**
* Edit a post: the author inside the window, staff at any time (§5.4).
*
* The window is re-derived HERE from `created_at` and never trusted from the
* request, which is also why `editability` runs on the read path — the read tells
* the client whether to draw the control, and this decides whether the edit
* happens. Two evaluations of one rule, deliberately: the read one is advice and
* this one is enforcement.
*
* A staffer editing someone else's post is reported back as `staffEdit` so the
* controller can write the §5.3 accountability row. A staffer editing their OWN
* post is an ordinary edit and is not: the trail records interventions, and
* everything a staffer ever typed is not an intervention.
*/
async function editPost({ team, postId, actor, isStaff = false, windowMinutes = 0, body }) {
const post = await forumDb.postById(postId)
if (!post) return { ok: false, status: 404, error: 'Post not found' }
const thread = await forumDb.threadById(post.thread_id)
if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Post not found' }
if (post.status !== 'visible' || thread.status !== 'visible') {
return { ok: false, status: 404, error: 'Post not found' }
}
const isAuthor = post.author_user_id != null && post.author_user_id === actor.id
if (!isAuthor && !isStaff) {
return { ok: false, status: 403, error: 'You may only edit your own posts' }
}
if (!isStaff) {
if (thread.locked) return { ok: false, status: 409, error: 'This thread is locked' }
const { canEdit } = editability(post, { userId: actor.id, windowMinutes })
if (!canEdit) {
return {
ok: false,
status: 403,
error: windowMinutes > 0
? `The ${windowMinutes}-minute edit window for this post has closed`
: 'Posts cannot be edited on this site',
}
}
}
const cleaned = cleanForumBody(body)
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
return { ok: false, status: 400, error: 'A post needs a body' }
}
await forumDb.updatePostBody(postId, cleaned, actor.id)
return { ok: true, postId, threadId: post.thread_id, staffEdit: isStaff && !isAuthor }
}
/**
* Apply a moderation action to a thread, and record WHICH authority did it.
*
* `actorRole` is 'leader' or 'staff' — the column that makes a leader's ordinary
* housekeeping distinguishable from a staff intervention after the fact (§5.3).
* The caller resolves it; this function records it and never infers it, because
* an actor who is both would otherwise be recorded as whichever the code checked
* first.
*/
async function moderateThread({ team, threadId, action, actor, actorRole, reason }) {
const effect = THREAD_ACTIONS[action]
if (!effect) return { ok: false, status: 400, error: 'Unknown moderation action' }
const thread = await forumDb.threadById(threadId)
if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Thread not found' }
await forumDb.setThreadFlags(threadId, effect)
await forumDb.insertModeration({
teamId: team.id,
targetType: 'thread',
targetId: threadId,
action,
actorUserId: actor.id,
actorUsername: actor.username,
actorRole,
reason,
})
return { ok: true, action, threadId }
}
/**
* Apply a moderation action to a POST, and record which authority did it.
*
* The same ledger as `moderateThread`, with `target_type='post'` — one table, two
* target kinds, because "show me everything that was moderated in this Team" is
* the question the admin view asks and two tables would make it a union.
*
* `pin` and `unpin`, `lock` and `unlock` are refused with a message that names the
* mistake rather than a bare "unknown action": they are real actions applied to
* the wrong kind of object, and a caller who sent one has a bug worth telling
* them about precisely.
*
* **The opening post of a thread is moderatable like any other.** Hiding it leaves
* a thread with a title and its replies and no body, which looks odd and is
* correct — an abusive opener does not have to take a good discussion with it, and
* a moderator who wants the whole thing gone has `hide` on the thread.
*/
async function moderatePost({ team, postId, action, actor, actorRole, reason }) {
const effect = POST_ACTIONS[action]
if (!effect) {
return {
ok: false,
status: 400,
error: THREAD_ACTIONS[action]
? `"${action}" applies to a thread, not to a post`
: 'Unknown moderation action',
}
}
const post = await forumDb.postById(postId)
if (!post) return { ok: false, status: 404, error: 'Post not found' }
const thread = await forumDb.threadById(post.thread_id)
if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Post not found' }
await forumDb.setPostStatus(postId, effect.status)
// The counters are recomputed rather than nudged, because these four actions
// form cycles (hide → unhide → hide) that a delta gets wrong the first time one
// is retried.
await forumDb.recountThread(post.thread_id)
// Images follow their post. Soft on the way out and reversible on the way back
// in, so `delete` → `restore` inside the retention window returns the post
// whole; past it, the sweep has taken the bytes and nothing can.
if (action === 'delete') await forumDb.softDeleteUploadsForPost(postId, actor.id)
if (action === 'restore') await forumDb.restoreUploadsForPost(postId)
await forumDb.insertModeration({
teamId: team.id,
targetType: 'post',
targetId: postId,
action,
actorUserId: actor.id,
actorUsername: actor.username,
actorRole,
reason,
})
return { ok: true, action, postId, threadId: post.thread_id }
}
/** The ledger for the admin Team page. Staff-only by its route, not by this function. */
async function moderationLedger(teamId, opts) {
return forumDb.moderationForTeam(teamId, opts)
}
module.exports = {
CREATABLE_TYPES,
CREATABLE_TYPES_5A,
REPLYABLE_TYPES,
THREAD_ACTIONS,
POST_ACTIONS,
listThreads,
getThread,
createThread,
createPost,
editPost,
moderateThread,
moderatePost,
moderationLedger,
publicThread,
renderPost,
editability,
}

View File

@@ -0,0 +1,197 @@
// ── The operator's forum controls, and the acknowledgement gate ────────────
//
// TEAMS.md §5.5, plus phase 5's edit window. Four `settings` keys, and the reason
// they live in their own file rather than in settings.model.js is that only two
// of them are ordinary keys: `teams_forum_images` has a server-side precondition,
// and a precondition buried in the generic setMany() loop is one nobody reading
// that loop would know about.
//
// teams_forums_enabled '0' | '1' default '0' — off
// teams_forum_images 'disabled' | 'remote' | 'uploads' default 'disabled'
// teams_forum_uploads_ack the acknowledged TEXT VERSION absent until given
// teams_forum_edit_window_minutes 0 … 1440 default 15 (phase 5)
//
// **Every read fails closed.** A DB fault reports the forum off, images disabled
// and the edit window shut, because the alternative is a transient error opening a
// feature the operator turned off, or rendering third-party images on a site whose
// operator chose not to. The cost of failing closed here is a forum that 404s for a
// minute; the cost of failing open is a policy that is not a policy.
const settingsDb = require('../settings/settings.db')
const ENABLED_KEY = 'teams_forums_enabled'
const IMAGES_KEY = 'teams_forum_images'
const ACK_KEY = 'teams_forum_uploads_ack'
const EDIT_WINDOW_KEY = 'teams_forum_edit_window_minutes'
const IMAGE_MODES = ['disabled', 'remote', 'uploads']
// How long an author may edit their own post. Staff are not bound by it (§5.4).
const EDIT_WINDOW_DEFAULT = 15
const EDIT_WINDOW_MAX = 1440 // a day; beyond that "window" stops meaning anything
// The version of the §5.5.5 warning text currently in force. Bumping this is what
// makes every stored acknowledgement stale — see `ackState` below for what that
// then does, which is deliberately NOT "turn uploads off".
const ACK_VERSION = '1'
/** Is the forum switched on? Fail closed. */
async function forumsEnabled() {
try {
return String(await settingsDb.get(ENABLED_KEY)) === '1'
} catch {
return false
}
}
/**
* The image policy. Fail closed, and coerce any unexpected stored value back to
* 'disabled' — a hand-edited row must not be able to widen the policy by being
* unreadable.
*/
async function imageMode() {
try {
const value = await settingsDb.get(IMAGES_KEY)
return IMAGE_MODES.includes(value) ? value : 'disabled'
} catch {
return 'disabled'
}
}
/**
* How many minutes an author has to edit their own post.
*
* Fails closed to ZERO rather than to the default, and that is the opposite of
* what it looks like it should do. The risk an edit window bounds is an author
* rewriting a post out from under a reader who is quoting it or a moderator who
* is about to act on a report — so the safe answer during a DB fault is "nobody
* may edit for the next minute", not "everyone may edit for fifteen". Staff are
* unaffected either way, because their authority is not time-bounded.
*
* `0` is also a legitimate STORED value, meaning an operator who wants posts
* immutable once written. There is deliberately no distinction between "off" and
* "unreadable" here: both deny, and inventing a third state would only give the
* caller a decision to get wrong.
*/
async function editWindowMinutes() {
try {
const raw = await settingsDb.get(EDIT_WINDOW_KEY)
if (raw == null || raw === '') return EDIT_WINDOW_DEFAULT
const n = Number(raw)
if (!Number.isFinite(n) || n < 0 || n > EDIT_WINDOW_MAX) return EDIT_WINDOW_DEFAULT
return Math.floor(n)
} catch {
return 0
}
}
/** Are uploads accepted? The one mode where files come to rest on the operator's disk. */
async function uploadsEnabled() {
return (await imageMode()) === 'uploads'
}
/**
* The acknowledgement's state, for the admin surface.
*
* `stale` is the case §5.5.5 spends its longest paragraph on: the text was
* reworded after an operator accepted it. Neither obvious answer is right —
* silently downgrading a live feature because a legal text changed strands users
* mid-conversation, and honouring an old acceptance forever defeats versioning.
* So uploads keep working, `stale` drives a persistent banner, and
* `assertSettingsWritable` below refuses every other forum setting until it is
* re-given. Non-destructive, and impossible to ignore.
*/
async function ackState() {
const stored = await settingsDb.get(ACK_KEY)
const row = await settingsDb.getRow(ACK_KEY)
return {
version: ACK_VERSION,
acknowledgedVersion: stored ?? null,
given: stored != null,
stale: stored != null && String(stored) !== ACK_VERSION,
...(row ? { acknowledgedBy: row.updated_by_username ?? null, acknowledgedAt: row.updated_at } : {}),
}
}
/**
* The gate. `PUT teams_forum_images = 'uploads'` is rejected 400 unless the SAME
* request carries `acknowledge: <currentVersion>`.
*
* The checkbox in the admin UI is not the gate — it is how the gate is presented.
* That distinction is the whole reason this function exists on the server: an
* acknowledgement a client could skip is not an acknowledgement.
*
* Returns `{ ok }` or `{ ok: false, error, status }`, matching the model result
* shape the Teams controllers already translate.
*/
async function assertAcknowledged(nextMode, acknowledge) {
if (nextMode !== 'uploads') return { ok: true }
if (String(acknowledge ?? '') === ACK_VERSION) return { ok: true }
// **The gate is on SELECTING uploads, not on the value being present.**
//
// A settings form sends every field it owns, so once uploads is on, every later
// save re-sends `uploads` — turning the forum off, switching back to `remote`,
// any of it. Demanding a fresh acknowledgement for those would make the mode a
// one-way door: the operator could never change a forum setting again, and the
// one thing they would most want to do in a hurry (switch the forum off) would
// be the thing refused. Found on the live rig, where unticking "Enable Team
// forums" came back 400.
//
// So an acknowledgement already ON RECORD, for the version in force, while
// uploads is ALREADY the stored mode, is what this request needs — there is no
// new consent to take. A transition INTO uploads still needs the checkbox, and
// a stale acknowledgement is caught by assertSettingsWritable, which is the
// separate rule for a reworded notice.
const [state, current] = await Promise.all([ackState(), imageMode()])
if (current === 'uploads' && state.given && !state.stale) return { ok: true }
return {
ok: false,
status: 400,
error: `Enabling uploads requires acknowledging the current notice (version ${ACK_VERSION}).`,
}
}
/**
* The stale-acknowledgement lock: while an acknowledgement is stale, NO forum
* setting may be saved until it is re-given. Not "uploads are disabled" — see
* `ackState`. The re-acknowledgement itself is exempt, or the lock would have no
* key.
*/
async function assertSettingsWritable(keys, acknowledge) {
const touchesForum = keys.some((k) => k === ENABLED_KEY || k === IMAGES_KEY || k === EDIT_WINDOW_KEY)
if (!touchesForum) return { ok: true }
const state = await ackState()
if (!state.stale) return { ok: true }
if (String(acknowledge ?? '') === ACK_VERSION) return { ok: true }
return {
ok: false,
status: 400,
error: 'The image-upload notice has changed. Re-acknowledge it before saving forum settings.',
}
}
/** Record the acknowledgement. `updated_by`/`updated_at` come free from the settings schema. */
async function recordAck(adminUserId) {
await settingsDb.set(ACK_KEY, ACK_VERSION, adminUserId)
}
module.exports = {
ENABLED_KEY,
IMAGES_KEY,
ACK_KEY,
EDIT_WINDOW_KEY,
IMAGE_MODES,
ACK_VERSION,
EDIT_WINDOW_DEFAULT,
EDIT_WINDOW_MAX,
forumsEnabled,
imageMode,
editWindowMinutes,
uploadsEnabled,
ackState,
assertAcknowledged,
assertSettingsWritable,
recordAck,
}

View File

@@ -0,0 +1,177 @@
// ── `uploads` mode, and what had to harden first (TEAMS.md §5.5.4) ─────────
//
// The existing admin upload path (router/v1/admin/imageUpload.js) is already good
// for an admin: an 8 MB cap, a mimetype allowlist, a random filename, an extension
// derived from the MIMETYPE MAP and never from `originalname`, and
// `X-Content-Type-Options: nosniff` forced on serve. All of that is kept and this
// file adds the four things that path never needed, because until now it has never
// had a hostile uploader.
//
// 1. MAGIC-BYTE SNIFFING. `file.mimetype` is the client's own Content-Type
// header. A player can send `image/png` with arbitrary bytes and land
// arbitrary content under a `.png`. Trusted from an admin, not from a player.
// 2. QUOTAS. A per-post attachment cap and a per-account daily byte quota.
// Community uploads with no ceiling is disk exhaustion on the operator's own
// host. (The per-request RATE limit is core's rateLimit middleware, applied
// at the route.)
// 3. ATTRIBUTION. Every accepted file gets a `team_forum_uploads` row. Not
// bookkeeping: the acknowledgement in §5.5.5 is meaningless if "who uploaded
// this" cannot be answered afterwards.
// 4. LIFECYCLE. Deleting a post soft-deletes its uploads; the sweep removes the
// bytes after a retention window, and files with no row at all. The admin
// upload path never deletes anything, which is fine at admin volume and is
// not fine here.
const fs = require('fs/promises')
const path = require('path')
const forumDb = require('./teamForum.db')
const { UPLOAD_DIR } = require('../../router/v1/admin/imageUpload')
// Leading bytes → the type they actually are. Deliberately not a library: five
// signatures, checked exactly, is less surface than a dependency that accepts
// hundreds of formats when the allowlist only wants these.
//
// WebP and AVIF are container formats, so both need a second check past the first
// four bytes — RIFF alone is also .wav, and the `ftyp` box also fronts .mp4.
const SIGNATURES = [
{ mime: 'image/png', test: (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) },
{ mime: 'image/jpeg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
{ mime: 'image/gif', test: (b) => b.subarray(0, 6).toString('latin1').match(/^GIF8[79]a$/) != null },
{
mime: 'image/webp',
test: (b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP',
},
{
mime: 'image/avif',
test: (b) => b.subarray(4, 8).toString('latin1') === 'ftyp'
&& ['avif', 'avis'].includes(b.subarray(8, 12).toString('latin1')),
},
]
// Per-post attachment cap and per-account rolling byte quota.
const MAX_ATTACHMENTS_PER_POST = 6
const DAILY_QUOTA_BYTES = 25 * 1024 * 1024
const QUOTA_WINDOW_HOURS = 24
// Lifecycle windows. A soft-deleted file survives long enough for a mis-click to
// be recoverable; an orphan is one uploaded into a composer that was never
// submitted, which is a normal thing to do and so gets a generous grace.
const RETENTION_DAYS = 30
const ORPHAN_GRACE_HOURS = 48
/**
* What do these bytes actually claim to be?
*
* Returns the sniffed mimetype, or null when nothing matches. Null is a rejection
* and never a "trust the header instead" — an unrecognised file is exactly the
* case this check exists for.
*/
function sniff(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null
return SIGNATURES.find((s) => s.test(buffer))?.mime || null
}
/**
* Accept a file multer has already written to disk.
*
* The file is on disk before it can be sniffed — multer streams it there — so the
* rejection path has to REMOVE it. A rejected upload that stays on disk is exactly
* the disk-exhaustion vector the quota exists to close, reached by a different
* route.
*/
async function accept({ team, actor, file }) {
const stored = path.join(UPLOAD_DIR, file.filename)
const discard = async () => { await fs.rm(stored, { force: true }) }
let head
try {
const handle = await fs.open(stored, 'r')
try {
head = Buffer.alloc(16)
await handle.read(head, 0, 16, 0)
} finally {
await handle.close()
}
} catch {
await discard()
return { ok: false, status: 400, error: 'Could not read the uploaded file' }
}
const sniffed = sniff(head)
if (!sniffed || sniffed !== file.mimetype) {
await discard()
return { ok: false, status: 400, error: 'That file is not the image type it claims to be' }
}
const used = await forumDb.bytesUploadedSince(actor.id, QUOTA_WINDOW_HOURS)
if (used + file.size > DAILY_QUOTA_BYTES) {
await discard()
return { ok: false, status: 429, error: 'Daily upload limit reached. Try again tomorrow.' }
}
const id = await forumDb.insertUpload({
teamId: team.id,
postId: null, // attached when the post that embeds it is written
uploaderUserId: actor.id,
uploaderUsername: actor.username,
filename: file.filename,
mimetype: sniffed, // the SNIFFED type, never the client's header
byteSize: file.size,
})
return { ok: true, id, url: `/uploads/${file.filename}`, bytes: file.size }
}
/**
* Remove an upload. The uploader may, within the edit window; staff may at any
* time. Soft — the bytes go with the sweep, not with the button.
*/
async function remove({ id, actor, isStaff }) {
const row = await forumDb.uploadById(id)
if (!row || row.deleted_at) return { ok: false, status: 404, error: 'No such upload' }
if (!isStaff && row.uploader_user_id !== actor.id) {
return { ok: false, status: 403, error: 'Not your upload' }
}
await forumDb.softDeleteUpload(id, actor.id)
return { ok: true }
}
/**
* The nightly sweep: bytes for soft-deleted rows past retention, plus files on
* disk with no row at all.
*
* The orphan half deliberately only considers files whose names match the upload
* naming scheme AND appear in no row. UPLOAD_DIR is shared with the admin upload
* path, whose files have no row here and must never be swept — so the sweep works
* from the FORUM's own rows outward and never from the directory listing inward.
*/
async function sweep({ retentionDays = RETENTION_DAYS, orphanGraceHours = ORPHAN_GRACE_HOURS } = {}) {
const expired = await forumDb.sweepableUploads(retentionDays)
const orphans = await forumDb.orphanedUploads(orphanGraceHours)
const doomed = [...expired, ...orphans]
const cleared = []
for (const row of doomed) {
try {
await fs.rm(path.join(UPLOAD_DIR, row.filename), { force: true })
cleared.push(row.id)
} catch {
// Leave the ROW as well as the file. A file we could not delete is one the
// next run should try again, and dropping its row would lose the only
// record that the bytes are still there.
}
}
await forumDb.deleteUploadRows(cleared)
return { swept: doomed.length, filesRemoved: cleared.length }
}
module.exports = {
MAX_ATTACHMENTS_PER_POST,
DAILY_QUOTA_BYTES,
QUOTA_WINDOW_HOURS,
RETENTION_DAYS,
ORPHAN_GRACE_HOURS,
sniff,
accept,
remove,
sweep,
}

View File

@@ -0,0 +1,169 @@
// ── The grant/revoke flow (TEAMS.md §2.5 path 3) ───────────────────────────
//
// The RESOLVER lives in teamAccess.model.js and answers "may this account use the
// forum". This file is the WRITE half: who may hand that access out, to whom, and
// what stops a leader turning a Team forum into open hosting on the operator's
// site.
//
// **Two authorities, and they are not the same authority with different reach.**
//
// staff (admin | moderator) — any Team, no cap, may revoke anything
// leader (path 2, on THIS Team) — own Team, capped, may not revoke a staff grant
//
// The last clause is the one worth stating: a leader who could revoke a
// staff-issued grant could undo a moderation decision, which is the whole reason
// `granted_by` is retained rather than collapsed into a boolean.
//
// **Nothing here writes `team_members`, in either direction, ever.** A grant is
// not a membership: it may name any Runic Gateway account, including one with no
// linked game identity at all — that is the point of it, since letting an unlinked
// guildmate into the forum must not be a staff ticket. `teams.model.js` keeps such
// an account off the roster and out of every membership count, and path 4 keeps it
// off external platforms.
const accessDb = require('./teamAccess.db')
const teamsDb = require('./teams.db')
const access = require('./teamAccess.model')
const usersDb = require('../users/users.db')
const settingsDb = require('../settings/settings.db')
// The per-Team ceiling on ACTIVE leader-issued grants. A leader admitting
// unlimited arbitrary accounts to a private space on the operator's host is a
// quiet way to turn a Team forum into free hosting; the cap is what makes it a
// decision the operator made rather than one a leader made for them.
const CAP_KEY = 'teams_max_grants_per_team'
const DEFAULT_CAP = 50
const STAFF_ROLES = ['admin', 'moderator']
async function grantCap() {
const raw = await settingsDb.get(CAP_KEY)
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) && n > 0 ? n : DEFAULT_CAP
}
const isStaff = (actor) => STAFF_ROLES.includes(actor?.role)
/**
* What may this actor do with grants on this Team?
*
* Resolved once and returned whole, so the controller asks a question rather than
* assembling the answer from three booleans — the shape that lets a leader check
* and a staff check drift apart.
*/
async function authorityFor(teamId, actor) {
if (isStaff(actor)) return { may: true, as: 'staff' }
const leads = await access.isLeaderByUser(teamId, actor?.id)
return { may: leads, as: leads ? 'leader' : null }
}
/**
* Issue a grant. Returns the model result shape the Teams controllers translate:
* `{ ok }` or `{ ok: false, status, error }`.
*
* `warning` on a staff grant past the cap is deliberate and is not an error:
* staff are exempt, and silently exceeding a ceiling the operator configured is
* worth saying out loud on the way past.
*/
async function grant({ team, actor, userId, username, reason }) {
const authority = await authorityFor(team.id, actor)
if (!authority.may) return { ok: false, status: 403, error: 'Not a leader of this Team' }
const target = userId
? await usersDb.findById(userId)
: await usersDb.findByUsername(username)
if (!target) return { ok: false, status: 404, error: 'No such account' }
const existing = await accessDb.activeGrant(team.id, target.id)
if (existing) return { ok: false, status: 409, error: 'That account already has an active grant' }
const cap = await grantCap()
const count = await accessDb.activeGrantCount(team.id)
let warning = null
if (count >= cap) {
if (authority.as === 'leader') {
return { ok: false, status: 409, error: `This Team has reached its limit of ${cap} forum guests` }
}
warning = `This Team is past the configured limit of ${cap} forum guests`
}
await accessDb.insertGrant({
teamId: team.id,
userId: target.id,
username: target.username,
grantedBy: actor.id,
grantedUsername: actor.username,
reason,
})
return { ok: true, as: authority.as, grantee: target.username, ...(warning ? { warning } : {}) }
}
/**
* Revoke a grant.
*
* The one asymmetry with `grant`: a leader may not revoke what staff issued.
* Checked against `granted_by`'s role AT REVOKE TIME rather than against a stored
* flag, so an account that has since lost its staff role stops protecting the
* grants it made — which is the behaviour an operator demoting someone expects.
*/
async function revoke({ team, actor, userId, reason }) {
const authority = await authorityFor(team.id, actor)
if (!authority.may) return { ok: false, status: 403, error: 'Not a leader of this Team' }
const existing = await accessDb.activeGrant(team.id, userId)
if (!existing) return { ok: false, status: 404, error: 'No active grant for that account' }
if (authority.as === 'leader' && existing.granted_by) {
const issuer = await usersDb.findById(existing.granted_by)
if (isStaff(issuer)) {
return { ok: false, status: 403, error: 'That access was granted by staff and only staff may revoke it' }
}
}
await accessDb.revokeGrant({
teamId: team.id,
userId,
revokedBy: actor.id,
revokedUsername: actor.username,
reason,
})
return { ok: true, as: authority.as, grantee: existing.username }
}
/**
* The Team's forum guests — active grants for accounts that are NOT members.
*
* The subtraction is the §3.2 "Forum guests" list: someone who is both a member
* and a grantee is a member, listed on the roster, and appears here not at all.
* Both facts stay true in the ledger; only the presentation picks one.
*/
async function forumGuests(teamId) {
const [grants, members] = await Promise.all([
accessDb.activeGrants(teamId),
teamsDb.membersByTeam(teamId, { includeDeparted: false }),
])
const memberUserIds = new Set(members.map((m) => m.user_id).filter((id) => id != null))
return grants
.filter((g) => g.user_id == null || !memberUserIds.has(g.user_id))
.map((g) => ({
userId: g.user_id,
username: g.username,
grantedBy: g.granted_username,
grantedAt: g.granted_at,
reason: g.reason,
}))
}
module.exports = {
CAP_KEY,
DEFAULT_CAP,
// Exported since phase 7: the Discord dispatcher's `access: 'staff'` has to
// mean the same two roles every other Team surface means by it, and a second
// copy of the list is a copy that drifts.
STAFF_ROLES,
grantCap,
authorityFor,
grant,
revoke,
forumGuests,
}

View File

@@ -0,0 +1,122 @@
// SQL for the integration bridge's configuration (TEAMS.md §7.2, phase 8).
//
// One table, and almost all of its subtlety is in the schema comment rather than
// here: `team_key` is a generated `IFNULL(team_id, 0)`, so the deployment-wide
// default and the per-Team overrides live under one UNIQUE key without the
// default row needing a NULL in a primary key it cannot have.
//
// **Reads join `teams` and callers get the Team's name.** Not for display alone:
// the resolver's answer is the input to a message that names a Team, and a second
// round trip per notification to fetch a name the first query already walked past
// is the kind of thing that only shows up under a busy forum.
const { query } = require('../../utils/db')
const COLUMNS = `
c.id, c.platform, c.team_id, c.events, c.channel_ref, c.enabled,
c.members_ack, c.members_ack_by, c.members_ack_at, c.updated_at`
/**
* Every row for a platform — the default first, then the overrides by Team name.
*
* The admin panel's whole listing, in one query. `team_name` is NULL on exactly
* one row (the default), which is also how the client tells them apart without
* needing to reason about `team_id`.
*/
async function listForPlatform(platform) {
return query(
`SELECT ${COLUMNS}, t.name AS team_name, t.slug AS team_slug, t.display_name_override,
u.username AS members_ack_username
FROM team_integration_config c
LEFT JOIN teams t ON t.id = c.team_id
LEFT JOIN users u ON u.id = c.members_ack_by
WHERE c.platform = ?
ORDER BY c.team_id IS NOT NULL, COALESCE(t.name, '')`,
[platform],
)
}
/**
* The row that governs `teamId`, or null.
*
* `team_key` is what makes this one query rather than two: asking for the pair
* (0, teamId) returns the default and the override together, and `ORDER BY
* team_key DESC LIMIT 1` puts the override first when it exists. A caller that
* fetched the default and then looked for an override would do two round trips
* per notification for an answer the index already holds.
*/
async function resolveFor(platform, teamId) {
const rows = await query(
`SELECT ${COLUMNS}, t.name AS team_name, t.display_name_override
FROM team_integration_config c
LEFT JOIN teams t ON t.id = c.team_id
WHERE c.platform = ? AND c.team_key IN (0, ?)
ORDER BY c.team_key DESC
LIMIT 1`,
[platform, Number(teamId)],
)
return rows[0] || null
}
async function getById(id) {
const rows = await query(
`SELECT ${COLUMNS}, t.name AS team_name FROM team_integration_config c
LEFT JOIN teams t ON t.id = c.team_id
WHERE c.id = ? LIMIT 1`,
[Number(id)],
)
return rows[0] || null
}
async function getForTeam(platform, teamId) {
const rows = await query(
`SELECT ${COLUMNS} FROM team_integration_config c
WHERE c.platform = ? AND c.team_key = ? LIMIT 1`,
[platform, teamId === null || teamId === undefined ? 0 : Number(teamId)],
)
return rows[0] || null
}
/**
* Create or replace the row for (platform, team).
*
* A full replace rather than a patch, and the acknowledgement columns are part of
* what is replaced — the model decides what they should be, because "did the
* channel change" is a comparison against the row that is about to be overwritten
* and only the model has both halves.
*/
async function upsert({ platform, teamId, events, channelRef, enabled, membersAck, membersAckBy, membersAckAt }) {
await query(
`INSERT INTO team_integration_config
(platform, team_id, events, channel_ref, enabled, members_ack, members_ack_by, members_ack_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
events = VALUES(events),
channel_ref = VALUES(channel_ref),
enabled = VALUES(enabled),
members_ack = VALUES(members_ack),
members_ack_by = VALUES(members_ack_by),
members_ack_at = VALUES(members_ack_at)`,
[
platform,
teamId === null || teamId === undefined ? null : Number(teamId),
JSON.stringify(events || []),
channelRef || null,
enabled ? 1 : 0,
membersAck ? 1 : 0,
membersAckBy || null,
membersAckAt || null,
],
)
return getForTeam(platform, teamId)
}
async function remove(platform, teamId) {
const res = await query('DELETE FROM team_integration_config WHERE platform = ? AND team_key = ?', [
platform,
teamId === null || teamId === undefined ? 0 : Number(teamId),
])
return Number(res && res.affectedRows) || 0
}
module.exports = { listForPlatform, resolveFor, getById, getForTeam, upsert, remove }

View File

@@ -0,0 +1,272 @@
// ── The integration bridge's configuration and its one precondition ────────
//
// TEAMS.md §7.2, phase 8. An operator says "send these Team events to this
// Discord channel", globally or for one Team, and this file is where that
// sentence is validated, stored and resolved.
//
// **Two of the four streams can never be public, and that is the whole reason
// this file is more than a settings row.** §7.2 gates bridging on "the event's
// visibility is public, or the destination channel is configured for a
// members-only Team context". Neither half exists in the tree and neither can:
// the four `team.*` streams carry no visibility (only `team_activity` rows do,
// and a notification is not an activity row), forum threads have no public/
// members column because a forum is members-only by construction — everything in
// it sits behind `team_forum_grants` — and core cannot see a Discord channel's
// permissions to know what it is.
//
// Only the operator can see that. So the gate becomes an ATTRIBUTED
// ACKNOWLEDGEMENT: enabling a members-only event requires an explicit tick that
// the destination is restricted to that Team's members, recorded with who gave it
// and when, in the same shape `teams_forum_uploads_ack` records the image-policy
// one. It is a precondition, not a preference — `assertEnableable` refuses the
// save rather than quietly dropping the event at delivery time, because a config
// that silently does less than it says is worse than one that will not save.
//
// **Changing the channel clears the acknowledgement.** An acknowledgement is
// about a destination; it cannot survive the destination changing underneath it,
// or an operator would tick "this channel is private", then repoint the row at a
// public one and keep the permission they were granted for a different place.
//
// **Every read fails closed**, like `teamForumSettings`: a DB fault reports no
// bridge configured, because the cost of failing closed is a Discord channel that
// stays quiet for a minute and the cost of failing open is members-only text in a
// room the operator never approved.
const db = require('./teamIntegration.db')
const log = require('../../utils/logger')('team-integration')
// The only platform phase 8 knows. Deliberately a value rather than a hardcoded
// literal at every call site: phase 10 turns this into a lookup against the
// declared-capability registry, and the fewer places that spell 'discord' the
// smaller that change is.
const DISCORD = 'discord'
const PLATFORMS = [DISCORD]
// The four §6.2 streams, and which of them can reach a channel core cannot vet.
//
// A stream is members-only if the CONTENT behind it is: `team.forum.post` and
// `team.announcement` both name a thread nobody outside the Team may read. The
// roster pair is public — Team pages and rosters are public by §1's projection
// rules — so bridging those asserts nothing and needs no tick.
const BRIDGEABLE = [
'team.member.joined',
'team.leadership.changed',
'team.forum.post',
'team.announcement',
]
const MEMBERS_ONLY = new Set(['team.forum.post', 'team.announcement'])
// Discord snowflakes are 17-20 digits today and the format is not promised. The
// check is only that a channel ref is plausibly one and cannot smuggle anything —
// core treats it as opaque and the bot is what resolves it.
const CHANNEL_RE = /^[0-9]{5,32}$/
const isMembersOnly = (streamId) => MEMBERS_ONLY.has(streamId)
/** Does this event list contain anything that would publish members-only text? */
const needsAck = (events) => (events || []).some(isMembersOnly)
/**
* Normalise an operator-supplied event list.
*
* Unknown ids are REJECTED rather than dropped. A silently-dropped event is a
* config screen that shows you saved something you did not, and the set is small
* and fixed enough that a typo is a mistake worth reporting.
*/
function normaliseEvents(events) {
if (!Array.isArray(events)) {
const err = new Error('events must be an array')
err.status = 400
throw err
}
const seen = []
for (const raw of events) {
const id = String(raw || '').trim()
if (!BRIDGEABLE.includes(id)) {
const err = new Error(`unknown event: ${id}`)
err.status = 400
throw err
}
if (!seen.includes(id)) seen.push(id)
}
return seen
}
function normaliseChannel(channelRef) {
const value = String(channelRef || '').trim()
if (!value) return null
if (!CHANNEL_RE.test(value)) {
const err = new Error('channel must be a numeric channel id')
err.status = 400
throw err
}
return value
}
/**
* The gate, as a throw.
*
* Order matters to the message an operator reads: an enabled row with no channel
* is a different mistake from one with an unacknowledged channel, and reporting
* the second when the first is true would send them to tick a box that would not
* have helped.
*/
function assertEnableable({ enabled, events, channelRef, membersAck }) {
if (!enabled) return
if (!channelRef) {
const err = new Error('a destination channel is required to enable this bridge')
err.status = 422
throw err
}
if (events.length === 0) {
const err = new Error('at least one event is required to enable this bridge')
err.status = 422
throw err
}
if (needsAck(events) && !membersAck) {
const err = new Error(
'forum posts and announcements are visible only to a Teams members — confirm the destination channel is restricted to them before enabling',
)
err.status = 422
err.code = 'members_ack_required'
throw err
}
}
/** Rows for the admin panel, `events` already parsed. */
async function list(platform = DISCORD) {
const rows = await db.listForPlatform(platform)
return rows.map(shape)
}
/**
* Parse the stored JSON once, here.
*
* `mariadb` hands a JSON column back as a string on some server versions and as a
* parsed value on others, which is a difference nobody wants to rediscover in a
* controller. Anything unreadable becomes an empty list rather than a throw: a
* row with a corrupt event list should render as a row that bridges nothing, not
* take the whole admin page down.
*/
function shape(row) {
if (!row) return null
let events = row.events
if (typeof events === 'string') {
try {
events = JSON.parse(events)
} catch {
events = []
}
}
return { ...row, events: Array.isArray(events) ? events : [], enabled: !!row.enabled, members_ack: !!row.members_ack }
}
/**
* The row that governs `teamId` — the override if there is one, otherwise the
* deployment default — filtered down to what may actually be delivered.
*
* **The acknowledgement is checked HERE as well as at the save.** A row saved
* with the tick can lose it later: an admin repoints the channel, or a future
* change to what counts as members-only reclassifies a stream a row already
* carries. Re-asking at delivery is what makes the tick a live property of the
* row rather than a note about a save that happened once.
*/
async function resolve(teamId, platform = DISCORD) {
try {
const row = shape(await db.resolveFor(platform, teamId))
if (!row || !row.enabled || !row.channel_ref) return null
const events = row.events.filter((id) => (isMembersOnly(id) ? row.members_ack : true))
if (events.length === 0) return null
return { ...row, events }
} catch (err) {
log.warn('bridge config lookup failed — treating as unconfigured', {
teamId,
platform,
message: err.message,
})
return null
}
}
/** Is `streamId` bridged for this Team? The delivery path's whole question. */
async function destinationFor(teamId, streamId, platform = DISCORD) {
const row = await resolve(teamId, platform)
if (!row || !row.events.includes(streamId)) return null
return { channelRef: row.channel_ref, membersOnly: isMembersOnly(streamId), platform }
}
/**
* Create or replace the row for (platform, team).
*
* `actorId` is the admin doing the saving, and it is what lands in
* `members_ack_by` — the acknowledgement names a person, so it cannot be written
* by a path that does not know who they are.
*/
async function save({ platform = DISCORD, teamId = null, events, channelRef, enabled, membersAck }, actorId) {
if (!PLATFORMS.includes(platform)) {
const err = new Error(`unknown platform: ${platform}`)
err.status = 400
throw err
}
const nextEvents = normaliseEvents(events)
const nextChannel = normaliseChannel(channelRef)
const existing = shape(await db.getForTeam(platform, teamId))
// An acknowledgement survives an ordinary edit and dies with the channel it was
// given for. `membersAck === false` from the client is an explicit withdrawal
// and is honoured; `undefined` means "leave it", which is what a save that only
// toggled an event should do.
const channelChanged = !!existing && existing.channel_ref !== nextChannel
let ack = existing ? existing.members_ack : false
if (membersAck === false) ack = false
else if (membersAck === true) ack = true
if (channelChanged) ack = membersAck === true
const nextEnabled = !!enabled
assertEnableable({ enabled: nextEnabled, events: nextEvents, channelRef: nextChannel, membersAck: ack })
// Re-stamp only when the acknowledgement is newly given, so an unrelated save
// does not rewrite the date on a decision nobody revisited.
//
// `channelChanged` belongs in this condition and it is easy to leave out: an
// acknowledgement given alongside a NEW channel is a new acknowledgement even
// though the column was already 1, and without it the row keeps naming whoever
// vetted the PREVIOUS destination. That attribution is the whole audit value of
// the column — it has to name the person who looked at the channel the row now
// points at.
const freshlyAcked = ack && (channelChanged || !(existing && existing.members_ack))
const row = await db.upsert({
platform,
teamId,
events: nextEvents,
channelRef: nextChannel,
enabled: nextEnabled,
membersAck: ack,
membersAckBy: ack ? (freshlyAcked ? actorId : existing.members_ack_by) : null,
membersAckAt: ack ? (freshlyAcked ? new Date() : existing.members_ack_at) : null,
})
return shape(row)
}
async function remove(platform, teamId) {
return db.remove(platform, teamId)
}
module.exports = {
DISCORD,
PLATFORMS,
BRIDGEABLE,
MEMBERS_ONLY,
isMembersOnly,
needsAck,
normaliseEvents,
normaliseChannel,
assertEnableable,
list,
resolve,
destinationFor,
save,
remove,
}

View File

@@ -0,0 +1,123 @@
// SQL for the reserved-name review queue and the §2.9 approval queue.
const { query } = require('../../utils/db')
// ── The hide/display state on `teams` ──────────────────────────────────────
async function setHidden(teamId, { hidden, reason, term }) {
await query(
'UPDATE teams SET hidden = ?, hidden_reason = ?, hidden_term = ? WHERE id = ?',
[hidden ? 1 : 0, hidden ? reason : null, hidden ? term || null : null, teamId],
)
}
/**
* Record that a human has decided about this name.
*
* What makes a staff decision STICKY (§2.8.3). Re-screening runs on every sync,
* and without this stamp an operator adding a reserved term — or simply renaming
* the deployment — would re-hide a Team staff had already allowed, every fifteen
* minutes, forever.
*/
async function markNameReviewed(teamId) {
await query('UPDATE teams SET name_reviewed_at = NOW() WHERE id = ?', [teamId])
}
async function setDisplayNameOverride(teamId, displayName) {
await query('UPDATE teams SET display_name_override = ? WHERE id = ?', [displayName, teamId])
}
/** Active teams whose name has never been screened by a human. */
async function unreviewedActive(moduleId) {
return query(
`SELECT id, name, hidden, hidden_reason FROM teams
WHERE module_id = ? AND status = 'active' AND name_reviewed_at IS NULL`,
[moduleId],
)
}
/** The reserved-name review queue (§2.8.3). */
async function reviewQueue() {
return query(
`SELECT id, name, slug, hidden_term, display_name_override, member_count, created_at
FROM teams
WHERE status = 'active' AND hidden = 1 AND hidden_reason = 'reserved_name' AND name_reviewed_at IS NULL
ORDER BY created_at DESC`,
)
}
// ── team_moderation_requests (§2.9) ────────────────────────────────────────
const REQUEST_COLUMNS = `
id, team_id, action, payload, reason, requested_by, requested_username, requested_at,
status, decided_by, decided_username, decided_at, decision_note`
async function insertRequest({ teamId, action, payload, reason, requestedBy, requestedUsername }) {
const res = await query(
`INSERT INTO team_moderation_requests
(team_id, action, payload, reason, requested_by, requested_username)
VALUES (?, ?, ?, ?, ?, ?)`,
[teamId, action, payload == null ? null : JSON.stringify(payload), reason, requestedBy, requestedUsername],
)
return res.insertId
}
async function findRequest(id) {
const rows = await query(`SELECT ${REQUEST_COLUMNS} FROM team_moderation_requests WHERE id = ?`, [id])
return rows[0]
}
/** The approval queue. Decided rows are kept — see §2.9 — so `status` is a filter. */
async function listRequests({ status = 'pending', limit = 100 } = {}) {
const params = []
let sql = `SELECT r.${REQUEST_COLUMNS.trim().split(/,\s*/).join(', r.')},
t.name AS team_name, t.slug AS team_slug
FROM team_moderation_requests r JOIN teams t ON t.id = r.team_id`
if (status !== 'all') {
sql += ' WHERE r.status = ?'
params.push(status)
}
sql += ' ORDER BY r.requested_at DESC, r.id DESC LIMIT ?'
params.push(limit)
return query(sql, params)
}
/**
* Decide a request, but only if it is still pending.
*
* The `status = 'pending'` guard is the concurrency control: two admins opening
* the same queue and both clicking approve would otherwise each apply the action,
* and the second would overwrite the first's record of who decided it. The caller
* applies the effect only when this reports a row was actually moved.
*/
async function decideRequest(id, { status, decidedBy, decidedUsername, note }) {
const res = await query(
`UPDATE team_moderation_requests
SET status = ?, decided_by = ?, decided_username = ?, decided_at = NOW(), decision_note = ?
WHERE id = ? AND status = 'pending'`,
[status, decidedBy, decidedUsername, note, id],
)
return res.affectedRows > 0
}
/** Pending requests for one team — shown on its admin page so a second is not filed. */
async function pendingForTeam(teamId) {
return query(
`SELECT ${REQUEST_COLUMNS} FROM team_moderation_requests
WHERE team_id = ? AND status = 'pending' ORDER BY requested_at`,
[teamId],
)
}
module.exports = {
setHidden,
markNameReviewed,
setDisplayNameOverride,
unreviewedActive,
reviewQueue,
insertRequest,
findRequest,
listRequests,
decideRequest,
pendingForTeam,
}

View File

@@ -0,0 +1,239 @@
// ── Impersonation controls, and the approval gate on them ──────────────────
//
// TEAMS.md §2.8§2.9. Two things live here:
//
// 1. **Auto-hide**, which turns a reserved-name match into a suppressed Team
// and a review queue entry rather than into a refusal. Core cannot refuse a
// name — the guild exists in the game and core is a mirror of it.
//
// 2. **The approval gate**, which is scoped to the three actions that RELEASE
// untrusted game-sourced strings onto public surfaces, and to nothing else.
//
// **The gate's scope is the part most likely to be misread.** It is not a general
// staff-approval workflow. Ordinary forum grants, leadership overrides, archives
// and forum moderation all still apply immediately and are audited, exactly as
// before. Three actions are gated, and the question that admits a fourth is
// always the same one: *does this publish untrusted game data?*
//
// - clearing a reserved_name hide — publishes a name that tripped the list
// - setting a display_name_override — substitutes free text into the same
// public surfaces
// - un-hiding a staff-hidden Team — reverses a deliberate suppression
//
// **Moderator-initiated, admin-approved — never four-eyes on admins.** `users.role`
// defaults to admin and `npm run seed` creates exactly one, so most deployments
// have precisely one admin. A rule requiring a second would wedge them with no
// way out, which is a worse failure than the one it guards against.
const moderationDb = require('./teamModeration.db')
const teamsDb = require('./teams.db')
const reservedNames = require('../../utils/reservedNames')
const activity = require('../activity/activity.model')
const log = require('../../utils/logger')('teams')
const GATED_ACTIONS = ['unhide', 'display_name_override', 'clear_display_name_override']
const isAdmin = (actor) => Boolean(actor) && actor.role === 'admin'
/**
* Screen a name and return the columns a create should carry.
*
* Never throws: screening reads settings, and a database hiccup during a
* reconcile must not stop a Team being created. It fails OPEN on the create — the
* Team appears — because the re-screen on the next sync will catch it, and a
* reconcile that aborts halfway is worse than a name that is public for one
* interval. That is a deliberate trade and it is the reason re-screening exists
* at all rather than being a create-time-only check.
*/
async function screenForCreate(name) {
try {
const { reserved, term } = await reservedNames.screen(name)
if (!reserved) return { hidden: false }
log.warn('team auto-hidden: its name matched a reserved term', { name, term })
return { hidden: true, hiddenReason: 'reserved_name', hiddenTerm: term }
} catch (err) {
log.error('reserved-name screening failed; the team is created unscreened', {
name, message: err.message,
})
return { hidden: false }
}
}
/**
* Re-screen every active Team whose name no human has ruled on.
*
* Names are immutable per row, so this only ever changes an outcome when the TERM
* LIST changed — an operator adding a term, or the deployment being renamed. That
* is precisely the case a create-time-only check would miss forever.
*
* A Team staff have already decided about is skipped, and that stickiness is the
* point: without it, an override would be undone on the next sweep.
*/
async function rescreen(moduleId) {
let hidden = 0
try {
const rows = await moderationDb.unreviewedActive(moduleId)
for (const row of rows) {
if (row.hidden) continue
// eslint-disable-next-line no-await-in-loop
const { reserved, term } = await reservedNames.screen(row.name)
if (!reserved) continue
// eslint-disable-next-line no-await-in-loop
await moderationDb.setHidden(row.id, { hidden: true, reason: 'reserved_name', term })
hidden += 1
log.warn('team hidden by a re-screen: the reserved terms changed', { id: row.id, name: row.name, term })
}
} catch (err) {
log.error('re-screening failed', { message: err.message })
}
return hidden
}
// ── The three gated actions ────────────────────────────────────────────────
/**
* Apply a gated action, or file it for approval.
*
* The role check is answered LIVE against the database on every request by core's
* admin middleware, so "is this caller an admin" is not read from a token claim
* that a demotion would not have invalidated.
*/
async function requestOrApply({ req, actor, teamId, action, payload, reason }) {
if (!GATED_ACTIONS.includes(action)) throw new Error(`not a gated action: "${action}"`)
const team = await teamsDb.findById(teamId)
if (!team) return { ok: false, status: 404, error: 'team not found' }
if (!isAdmin(actor)) {
const id = await moderationDb.insertRequest({
teamId, action, payload, reason, requestedBy: actor.id, requestedUsername: actor.username,
})
await activity.log({
req,
action: 'team.moderation.request',
detail: `${actor.username} (#${actor.id}) requested "${action}" on team "${team.name}" (#${teamId})`
+ `${reason ? `: "${reason}"` : ''}`,
})
return { ok: true, pending: true, requestId: id }
}
await applyAction({ req, actor, team, action, payload, reason })
return { ok: true, pending: false }
}
/** The effect itself. Reached by an admin directly, or by an approval. */
async function applyAction({ req, actor, team, action, payload, reason }) {
switch (action) {
case 'unhide':
await moderationDb.setHidden(team.id, { hidden: false })
// A human has now ruled on this name, so no later sweep re-hides it.
await moderationDb.markNameReviewed(team.id)
break
case 'display_name_override':
await moderationDb.setDisplayNameOverride(team.id, payload.displayName)
await moderationDb.markNameReviewed(team.id)
break
case 'clear_display_name_override':
await moderationDb.setDisplayNameOverride(team.id, null)
break
default:
throw new Error(`not a gated action: "${action}"`)
}
await activity.log({
req,
action: `team.${action}`,
detail: `${actor.username} (#${actor.id}) applied "${action}" to team "${team.name}" (#${team.id})`
+ `${payload && payload.displayName ? ` as "${payload.displayName}"` : ''}`
+ `${reason ? `: "${reason}"` : ''}`,
})
}
/**
* Hide a Team. NOT gated — suppression is always safe (§2.11).
*
* The asymmetry is the whole design: publishing untrusted data needs a second
* pair of eyes, and withdrawing it needs to be possible at once, by whoever is
* on duty.
*/
async function hide({ req, actor, teamId, reason }) {
const team = await teamsDb.findById(teamId)
if (!team) return { ok: false, status: 404, error: 'team not found' }
await moderationDb.setHidden(teamId, { hidden: true, reason: 'staff' })
await activity.log({
req,
action: 'team.hide',
detail: `${actor.username} (#${actor.id}) hid team "${team.name}" (#${teamId})`
+ `${reason ? `: "${reason}"` : ''}`,
})
return { ok: true }
}
/**
* Decide a pending request. Admin only.
*
* The effect is applied only when the row actually moved out of `pending`, so two
* admins deciding the same request race safely: the second is told it was already
* decided rather than applying the action a second time.
*/
async function decide({ req, actor, requestId, status, note }) {
if (!isAdmin(actor)) return { ok: false, status: 403, error: 'only an admin may decide a request' }
if (!['approved', 'rejected'].includes(status)) {
return { ok: false, status: 400, error: 'status must be approved or rejected' }
}
const request = await moderationDb.findRequest(requestId)
if (!request) return { ok: false, status: 404, error: 'request not found' }
if (request.status !== 'pending') {
return { ok: false, status: 409, error: `request is already ${request.status}` }
}
const moved = await moderationDb.decideRequest(requestId, {
status, decidedBy: actor.id, decidedUsername: actor.username, note,
})
if (!moved) return { ok: false, status: 409, error: 'request was decided by someone else' }
const team = await teamsDb.findById(request.team_id)
if (status === 'approved' && team) {
await applyAction({
req,
actor,
team,
action: request.action,
payload: parsePayload(request.payload),
reason: request.reason,
})
}
await activity.log({
req,
action: `team.moderation.${status}`,
detail: `${actor.username} (#${actor.id}) ${status} request #${requestId} `
+ `("${request.action}" on team #${request.team_id}, asked by ${request.requested_username || 'a deleted user'})`
+ `${note ? `: "${note}"` : ''}`,
})
return { ok: true, applied: status === 'approved' }
}
// The driver returns JSON columns already parsed on some versions and as a string
// on others, so this normalises rather than assuming either.
function parsePayload(payload) {
if (payload == null) return {}
if (typeof payload === 'object') return payload
try {
return JSON.parse(payload)
} catch {
return {}
}
}
module.exports = {
screenForCreate,
rescreen,
requestOrApply,
hide,
decide,
reviewQueue: moderationDb.reviewQueue,
listRequests: moderationDb.listRequests,
pendingForTeam: moderationDb.pendingForTeam,
GATED_ACTIONS,
}

View File

@@ -0,0 +1,223 @@
// SQL for Team notification recipients and per-Team preferences (TEAMS.md Part 6).
//
// **The recipient set is the whole of Team scoping.** The four `team.*` streams
// are global and carry no Team in their id; who an event reaches is decided here.
// That is §6.2's design and it is not an optimisation — the push catalog is a
// static registration validated at boot, so a stream per Team is unexpressible,
// and stream ids live in `notification_subscriptions` rows that a per-Team id
// would leave behind every time a Team archived.
//
// **One recipient query serves all four streams**, because the two populations in
// §6.2's table are the same set written twice: "active members with a user_id,
// plus active forum grants" IS "everyone with resolved forum access", by the
// definition of teamAccess.forumAccess() (membership OR grant). What differs
// between the streams is only who is subtracted — the author of the post that
// caused it — and that is a caller's argument, not a second query.
//
// **Mutes are subtracted in SQL, not in the caller.** A recipient list that came
// back complete and was filtered afterwards would be one refactor away from being
// used unfiltered; there is no function here that returns an unmuted set.
const { query } = require('../../utils/db')
// The union, as a derived table both recipient functions build on. Written once
// so that "who is in a Team for notification purposes" has exactly one definition.
//
// `status = 'active'` on the membership half and `revoked_at IS NULL` on the
// grant half are the same two conditions the access resolver uses; a departed
// member and a revoked guest are both people who could still be read a private
// forum by a query that forgot one.
const RECIPIENT_UNION = `
SELECT user_id FROM team_members
WHERE team_id = ? AND status = 'active' AND user_id IS NOT NULL
UNION
SELECT user_id FROM team_forum_grants
WHERE team_id = ? AND revoked_at IS NULL`
// `Number.isInteger` alone is not enough: `Number(null)` is 0 and 0 is an
// integer, so a null slipping into a caller's list would become user id 0 and
// ride into an IN clause. No row has id 0, so it is harmless today — which is
// exactly why it would never be noticed.
const isUserId = (n) => Number.isInteger(n) && n > 0
/**
* Every user id that may be notified about `teamId`, mutes already removed.
*
* `exclude` is the author of the thing that happened. Passed rather than removed
* afterwards for the reason in the header, and taken as a list because a caller
* with nobody to exclude should not have to invent a sentinel.
*/
async function recipientIds(teamId, { exclude = [] } = {}) {
const skip = [...new Set(exclude.map(Number).filter(isUserId))]
const notMe = skip.length ? `AND r.user_id NOT IN (${skip.map(() => '?').join(',')})` : ''
const rows = await query(
`SELECT DISTINCT r.user_id
FROM (${RECIPIENT_UNION}) r
LEFT JOIN team_notification_prefs p ON p.user_id = r.user_id AND p.team_id = ?
WHERE COALESCE(p.muted, 0) = 0 ${notMe}`,
[teamId, teamId, teamId, ...skip],
)
return rows.map((r) => Number(r.user_id))
}
/**
* The same set, narrowed to those reachable by EMAIL and carrying each one's mode.
*
* A separate query rather than a join onto `recipientIds` because email has two
* conditions push does not: an address to send to, and an account still allowed to
* have one. A banned or disabled account keeps its forum grant in the ledger —
* revoking it is a separate staff decision — but must not keep receiving the
* Team's private discussion in its inbox.
*
* `email_mode` is COALESCEd to the column default rather than read as NULL — and
* that default is `'off'`, so this query returns the whole set with most of it
* marked as not wanting mail. Filtering to a mode is the CALLER's job, because
* `immediate` and `digest` are consumed by two different senders.
*/
async function emailRecipients(teamId, { exclude = [] } = {}) {
const skip = [...new Set(exclude.map(Number).filter(isUserId))]
const notMe = skip.length ? `AND u.id NOT IN (${skip.map(() => '?').join(',')})` : ''
return query(
`SELECT u.id AS user_id, u.username, u.email,
COALESCE(p.email_mode, 'off') AS email_mode,
p.last_digest_at
FROM (${RECIPIENT_UNION}) r
JOIN users u ON u.id = r.user_id
LEFT JOIN team_notification_prefs p ON p.user_id = u.id AND p.team_id = ?
WHERE COALESCE(p.muted, 0) = 0
AND u.email IS NOT NULL AND u.email <> ''
AND u.status = 'active' ${notMe}
GROUP BY u.id, u.username, u.email, p.email_mode, p.last_digest_at`,
[teamId, teamId, teamId, ...skip],
)
}
// ── Preferences ────────────────────────────────────────────────────────────
/**
* One row per Team this user may be notified about, whether or not a preference
* has ever been written for it — the account screen has to offer a Team the user
* has never touched, and a list built from the prefs table alone would be empty
* for exactly the users who have configured nothing.
*
* Archived Teams appear only when a preference row exists for them, so a mute the
* user set does not vanish from the screen the moment a guild disbands, while a
* disbanded guild nobody configured does not linger on it forever.
*/
async function prefsForUser(userId) {
return query(
`SELECT t.id AS team_id, t.slug, t.name, t.display_name_override, t.status AS team_status,
COALESCE(p.muted, 0) AS muted,
COALESCE(p.email_mode, 'off') AS email_mode
FROM teams t
LEFT JOIN team_notification_prefs p ON p.team_id = t.id AND p.user_id = ?
WHERE (
EXISTS (SELECT 1 FROM team_members m
WHERE m.team_id = t.id AND m.user_id = ? AND m.status = 'active')
OR EXISTS (SELECT 1 FROM team_forum_grants g
WHERE g.team_id = t.id AND g.user_id = ? AND g.revoked_at IS NULL)
OR p.user_id IS NOT NULL
)
ORDER BY t.status, t.name`,
[userId, userId, userId],
)
}
/** One Team's preference for one user, or undefined. Read by the mute toggle. */
async function prefFor(userId, teamId) {
const rows = await query(
`SELECT team_id, muted, email_mode, last_digest_at
FROM team_notification_prefs WHERE user_id = ? AND team_id = ?`,
[userId, teamId],
)
return rows[0]
}
/**
* Write one preference.
*
* An upsert that touches ONLY the columns it was given: the one-click unsubscribe
* writes `muted` and must not reset an `email_mode` the user chose, and the
* settings screen writes both. `last_digest_at` is never written here — it is the
* worker's column, and a preference change must not look like a delivery.
*/
async function setPref(userId, teamId, { muted, emailMode }) {
const sets = ['updated_at = CURRENT_TIMESTAMP']
if (muted != null) sets.push('muted = VALUES(muted)')
if (emailMode != null) sets.push('email_mode = VALUES(email_mode)')
await query(
`INSERT INTO team_notification_prefs (user_id, team_id, muted, email_mode)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ${sets.join(', ')}`,
[userId, teamId, muted ? 1 : 0, emailMode || 'off'],
)
}
/** Stamp a digest as delivered. The worker's column, and its only writer. */
async function stampDigest(userId, teamId, at) {
await query(
`INSERT INTO team_notification_prefs (user_id, team_id, last_digest_at)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE last_digest_at = VALUES(last_digest_at)`,
[userId, teamId, at],
)
}
/**
* Active Teams that have had forum activity since `since` — the digest worker's
* driving query.
*
* Driven from ACTIVITY rather than from the prefs table, which is what makes the
* worker's cost proportional to what was WRITTEN rather than to how many people
* once opened a settings screen. A Team nobody posted in costs one row of this
* query and no recipient computation at all.
*/
async function teamsWithForumActivitySince(since) {
return query(
`SELECT DISTINCT t.id, t.slug, t.name, t.display_name_override
FROM teams t
JOIN team_forum_threads th ON th.team_id = t.id
JOIN team_forum_posts po ON po.thread_id = th.id
WHERE t.status = 'active'
AND po.created_at > ?
AND po.status = 'visible'
AND th.status = 'visible'`,
[since],
)
}
/**
* The posts one digest covers: visible posts in visible threads, newer than the
* recipient's own `since`.
*
* Re-read at send time rather than accumulated at publish time. A queue of pending
* items would have to be garbage-collected, would replay a backlog after an outage,
* and — the reason that actually matters — could email a body a moderator hid in
* between. This query cannot: a hidden post is simply not in it.
*/
async function digestPostsSince(teamId, since, limit = 20) {
return query(
`SELECT po.id, po.thread_id, po.body_html, po.created_at, po.author_username,
th.title, th.type
FROM team_forum_posts po
JOIN team_forum_threads th ON th.id = po.thread_id
WHERE th.team_id = ?
AND po.created_at > ?
AND po.status = 'visible'
AND th.status = 'visible'
ORDER BY po.created_at
LIMIT ?`,
[teamId, since, Number(limit)],
)
}
module.exports = {
recipientIds,
emailRecipients,
prefsForUser,
prefFor,
setPref,
stampDigest,
teamsWithForumActivitySince,
digestPostsSince,
}

View File

@@ -0,0 +1,146 @@
// Per-Team notification preferences, and the recipient sets built from them
// (TEAMS.md §6.2§6.4, phase 6).
//
// **The absence of a row is the default, and the two sinks default OPPOSITE ways.**
// Push is opt-out: a user in one Team must never have to configure anything to be
// tickled about it, and the per-Team mute is how they stop. Email is opt-IN
// (`email_mode` defaults to `'off'`, deviating from §6.4 on the org lead's call):
// turning on Gmail in the admin panel must not start sending daily mail to every
// member of every Team on the deployment.
//
// Both are read the same way — COALESCE to the column default, never treat a
// missing row as "unknown" — so the asymmetry lives in ONE place, the schema, and
// not in a condition anybody has to remember.
//
// **This file never decides who may READ a Team.** It asks the same two tables
// teamAccess.forumAccess() asks, in one query, because a fan-out cannot afford a
// round trip per recipient — but it asks them for the same answer. If the access
// rule ever changes, both must; the SQL in teamNotify.db.js says so at the union
// it builds on, and the test that matters is the one asserting a revoked guest
// receives nothing.
const db = require('./teamNotify.db')
// Stored as an ENUM, restated here because a value arriving from a request body
// must be checked against something in JavaScript before it reaches the column —
// a bad value would otherwise be a 500 from the driver rather than a 400 from us.
const EMAIL_MODES = ['off', 'digest', 'immediate']
const isEmailMode = (v) => EMAIL_MODES.includes(v)
function publicPref(row) {
return {
teamId: Number(row.team_id),
slug: row.slug,
// The same `display_name_override || name` rule every other Team surface
// uses (§2.8.3). A notification screen showing the raw name would show a name
// staff have deliberately replaced everywhere else.
name: row.display_name_override || row.name,
archived: row.team_status === 'archived',
muted: Boolean(Number(row.muted)),
emailMode: row.email_mode,
}
}
/** Every Team this user could be notified about, with its current preference. */
async function listPrefs(userId) {
return (await db.prefsForUser(userId)).map(publicPref)
}
/** One Team's preference for one user, defaults applied. Never null. */
async function prefFor(userId, teamId) {
const row = await db.prefFor(userId, teamId)
return {
teamId: Number(teamId),
muted: Boolean(row && Number(row.muted)),
emailMode: (row && row.email_mode) || 'off',
}
}
/**
* Replace this user's whole set of Team preferences.
*
* PUT-the-whole-set, matching the existing subscription endpoint, and the
* Android gotcha carried forward from `docs/android/PLAN.md` §11 applies to the
* ROUTE rather than to this function: the array is required even when empty.
*
* **A preference may only be written for a Team the caller is actually in.** The
* ids are checked against `listPrefs`, not trusted from the body — otherwise any
* authenticated user could write a row naming any Team, which is a (small) write
* primitive into a table keyed by someone else's private membership. Unknown ids
* are dropped rather than 400'd: a Team the user left between loading the screen
* and saving it is an ordinary race, not a client bug.
*/
async function replacePrefs(userId, entries) {
const allowed = new Map((await listPrefs(userId)).map((p) => [p.teamId, p]))
const written = []
for (const entry of entries) {
const teamId = Number(entry && entry.teamId)
if (!allowed.has(teamId)) continue
const emailMode = isEmailMode(entry.emailMode) ? entry.emailMode : 'off'
// eslint-disable-next-line no-await-in-loop
await db.setPref(userId, teamId, { muted: Boolean(entry.muted), emailMode })
written.push(teamId)
}
// A Team the caller COULD have named and did not is returned to its defaults.
//
// Without this, "replace the whole set" was a lie the endpoint told: omitting an
// entry left the old preference standing, which made `teams: []` — the body the
// route requires precisely so that clearing everything is expressible — clear
// nothing at all.
//
// Reset rather than deleted, and the difference is `last_digest_at`. That column
// is the digest worker's state, not a preference; dropping the row with it would
// make every visit to the settings screen re-open a day-wide digest window and
// mail somebody a summary they already read.
for (const teamId of allowed.keys()) {
if (written.includes(teamId)) continue
// eslint-disable-next-line no-await-in-loop
await db.setPref(userId, teamId, { muted: false, emailMode: 'off' })
}
return { written, prefs: await listPrefs(userId) }
}
/**
* Mute one Team for one user — the one-click unsubscribe's only effect.
*
* Deliberately narrow. The unsubscribe link is reached without a session, so what
* it can do is what an attacker holding a leaked link can do: silence one Team's
* notifications for one account, visibly and reversibly on the account screen.
* It writes no other column, and there is no "unsubscribe from everything".
*/
async function mute(userId, teamId) {
await db.setPref(userId, teamId, { muted: true })
}
/** Un-mute, for the toggle's other half. */
async function unmute(userId, teamId) {
await db.setPref(userId, teamId, { muted: false })
}
module.exports = {
EMAIL_MODES,
isEmailMode,
listPrefs,
prefFor,
replacePrefs,
mute,
unmute,
// Recipient sets, passed through so callers depend on the model rather than on
// the SQL. The fan-out in utils/teamNotify.js and the digest worker are the only
// callers.
//
// Wrapped rather than re-exported (`recipientIds: db.recipientIds`), which is
// the obvious shorter form and is wrong: that captures the function OBJECT at
// require time, so the layer below can never be substituted afterwards — which
// makes the db layer untestable in isolation and, more to the point, means the
// model is not really the seam it claims to be. These resolve `db.x` at call
// time, so the boundary is real.
recipientIds: (teamId, opts) => db.recipientIds(teamId, opts),
emailRecipients: (teamId, opts) => db.emailRecipients(teamId, opts),
stampDigest: (userId, teamId, at) => db.stampDigest(userId, teamId, at),
teamsWithForumActivitySince: (since) => db.teamsWithForumActivitySince(since),
digestPostsSince: (teamId, since, limit) => db.digestPostsSince(teamId, since, limit),
}

View File

@@ -0,0 +1,239 @@
// ── Calling the Team provider ──────────────────────────────────────────────
//
// The one place core asks a module a question and waits for the answer
// (docs/website/TEAMS.md §2.3). Everything here exists to serve invariant 1:
//
// **Module unavailability is staleness, never emptiness.**
//
// No Team subsystem may apply a destructive result derived from a failed,
// timed-out or unanswered module call. This file is where "failed" is defined, and
// it is deliberately generous about what counts: a rejected promise, a timeout, a
// non-object, a missing `ok`, or a structurally malformed row all leave with the
// same `{ ok: false }` the module would have sent deliberately.
//
// **There is no shape a failure can take that core reads as "zero teams".** That
// is the whole argument for the envelope, and the reason the provider signature is
// not the obvious `getTeams(): Team[]` — a bare array has exactly one such shape,
// `[]`, and it is the one a module returns while its sidecar is still connecting.
//
// Nothing here touches the database. It calls the module and hands back a value
// the reconciler can trust the SHAPE of; whether to ACT on it is §2.4's question.
const registries = require('../../modules/registries')
const log = require('../../utils/logger')('teams')
// The budget from §2.3. A provider is answering from its own cache or its own
// sidecar client, both of which have their own timeouts well inside this; a call
// that reaches ten seconds is wedged, not slow.
const CALL_TIMEOUT_MS = 10_000
/** A uniform refusal. `reason` is for the operator, via team_sync_state. */
const fail = (reason) => ({ ok: false, reason })
/**
* Await `promise` with a timeout that cannot outlive the call.
*
* The timer is always cleared — including on the winning path — because an
* uncleared 10s timer holds the event loop open, which in a test run means the
* process hangs long after the assertions passed. The suite already learned this
* one from a mariadb pool (test/_setup.js).
*
* It is also `unref`ed, which covers the case clearing cannot: when the module's
* promise NEVER settles, the race stays pending and there is nothing to clear
* until the deadline fires. An unreffed timer still fires normally while the
* process is alive — the server's own listener is what keeps it alive — but it no
* longer holds a shutdown open for ten seconds waiting on a module that is not
* going to answer.
*/
function withTimeout(promise, ms) {
let timer
const timeout = new Promise((resolve) => {
timer = setTimeout(() => resolve(fail(`provider did not answer within ${ms}ms`)), ms)
if (typeof timer.unref === 'function') timer.unref()
})
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
}
/**
* Call one provider method and normalise whatever comes back into an envelope.
*
* `normalise` is only ever run on an `ok` answer, and may itself return a refusal
* — a structurally malformed row is treated as a failed call rather than as data
* to salvage. Salvaging is the dangerous option: dropping one unreadable member
* from a roster is indistinguishable, downstream, from that member having left,
* and the sync would mark them departed. Refusing costs one stale interval.
*/
async function call(method, normalise, ...args) {
const provider = registries.registeredTeamProvider()
if (!provider) return fail('no team provider is registered')
let answer
try {
answer = await withTimeout(Promise.resolve().then(() => provider[method](...args)), CALL_TIMEOUT_MS)
} catch (err) {
// A rejected promise is a module that threw, which is exactly as
// unauthoritative as one that answered `{ ok: false }`.
return fail(`${method}() threw: ${err.message}`)
}
if (!answer || typeof answer !== 'object' || Array.isArray(answer)) {
return fail(`${method}() returned ${Array.isArray(answer) ? 'an array' : typeof answer}, not an envelope`)
}
// `ok` must be present and true. A module that forgot the field is not one
// asserting authority, and reading a missing field as truthy would put the
// single most consequential decision in this file on a typo.
if (answer.ok !== true) return fail(answer.reason || `${method}() answered not-ok`)
const normalised = normalise(answer)
if (normalised.ok === false) {
log.warn('team provider answered with a malformed payload', {
owner: provider.owner, method, reason: normalised.reason,
})
}
return normalised
}
// `complete` defaults to TRUE when the module omits it, matching §2.3: the
// envelope's optional field marks a partial answer, so its absence is the
// ordinary authoritative case. A module that cannot enumerate exhaustively says
// so explicitly.
const isComplete = (answer) => answer.complete !== false
const str = (v) => (typeof v === 'string' ? v.trim() : '')
/** `{ ok, complete, teams: [{ externalId, name, abbr, meta }] }` */
function normaliseTeams(answer) {
if (!Array.isArray(answer.teams)) return fail('getTeams() answered ok with no teams array')
const teams = []
for (const raw of answer.teams) {
const externalId = str(raw && raw.externalId)
const name = str(raw && raw.name)
// Both are load-bearing and neither has a safe default: externalId is the
// identity the whole rename rule (§2.2) turns on, and a Team with no name has
// no slug and no page.
if (!externalId) return fail('a team in getTeams() has no externalId')
if (!name) return fail(`team "${externalId}" has no name`)
teams.push({
externalId,
name,
abbr: str(raw.abbr) || null,
// Opaque by contract (§10.5) — stored and handed back, never branched on.
meta: raw.meta && typeof raw.meta === 'object' ? raw.meta : null,
})
}
return { ok: true, complete: isComplete(answer), teams }
}
/** `{ ok, complete, members: [{ memberKey, displayName, rankLabel, leader, online, userId }] }` */
function normaliseMembers(answer) {
if (!Array.isArray(answer.members)) return fail('getTeamMembers() answered ok with no members array')
const members = []
const seen = new Set()
for (const raw of answer.members) {
const memberKey = str(raw && raw.memberKey)
if (!memberKey) return fail('a member has no memberKey')
// A duplicate key would upsert twice and inflate no count but confuse every
// reader; it also means the module's own identity rule is broken, which is
// worth surfacing rather than quietly collapsing.
if (seen.has(memberKey)) return fail(`member "${memberKey}" appears twice`)
seen.add(memberKey)
members.push({
memberKey,
displayName: str(raw.displayName) || null,
rankLabel: str(raw.rankLabel) || null,
leader: Boolean(raw.leader),
online: Boolean(raw.online),
// Resolved BY THE MODULE — it owns the game↔site link table (§2.3). Core
// takes the number and never looks it up.
userId: Number.isInteger(raw.userId) && raw.userId > 0 ? raw.userId : null,
})
}
return { ok: true, complete: isComplete(answer), members }
}
/** `{ ok, leaders: [memberKey] }` */
function normaliseLeaders(answer) {
if (!Array.isArray(answer.leaders)) return fail('getTeamLeaders() answered ok with no leaders array')
const leaders = []
for (const raw of answer.leaders) {
const key = str(raw)
if (!key) return fail('a leader entry is not a member key')
if (!leaders.includes(key)) leaders.push(key)
}
return { ok: true, leaders }
}
/**
* `{ ok, members: [memberKey] }` — WHICH rows the module permits this viewer.
*
* Deliberately a set of keys rather than a set of rows. Core already holds the
* rows and knows their public shape; asking the module for rows back would let a
* module widen what is published — re-adding a `userId` or a `memberKey` that
* §3.2 says is never published — and core's field guarantee would then rest on
* every module's good behaviour rather than on core. So the module answers the
* question it actually owns (who may be seen at this rung) and core keeps the
* question it owns (what a member row looks like in public).
*/
function normaliseVisibleKeys(answer) {
if (!Array.isArray(answer.members)) return fail('projectRoster() answered ok with no members array')
const keys = []
for (const raw of answer.members) {
const key = str(raw)
if (!key) return fail('a projectRoster() entry is not a member key')
if (!keys.includes(key)) keys.push(key)
}
return { ok: true, members: keys }
}
const getTeams = () => call('getTeams', normaliseTeams)
const getTeamMembers = (externalId) => call('getTeamMembers', normaliseMembers, externalId)
const getTeamLeaders = (externalId) => call('getTeamLeaders', normaliseLeaders, externalId)
/**
* Ask the module which roster rows this viewer may see (§3.3).
*
* The per-audience projection is the module's because the visibility framework
* and its rung configuration are module-owned (§10.5) — core does not know what a
* rung is. Core supplies the roster and a description of the viewer; the module
* returns the member keys it permits.
*
* **"No audience model" and "could not answer" are different, and the caller must
* be able to tell them apart** — so the refusal carries `projects`.
*
* `projects: false` — no provider is registered, or the registered one does not
* implement `projectRoster`. There is no rung system to consult and nothing
* is being withheld; the roster is served at core's public shape. This is why
* the member is OPTIONAL: bare core, and a module with no audience model of
* its own, both render exactly the page core writes.
*
* `projects: true` — the module HAS an audience model and core could not reach
* it (refused, threw, timed out, answered malformed). Here the caller must
* fail CLOSED, because "leave it alone" would mean publishing the very rows
* the rungs exist to withhold. This is the one place in the Team subsystem
* where unavailability is not staleness: everywhere else a refused call
* leaves data alone, and doing that to a *visibility* question is a leak.
*/
async function projectRoster(externalId, members, viewer) {
const provider = registries.registeredTeamProvider()
if (!provider) return { ...fail('no team provider is registered'), projects: false }
if (typeof provider.projectRoster !== 'function') {
return { ...fail('provider does not project rosters'), projects: false }
}
const answer = await call('projectRoster', normaliseVisibleKeys, externalId, members, viewer)
return answer.ok ? answer : { ...answer, projects: true }
}
/** Which module is authoritative, or null. The reconciler keys sync state on it. */
const providerModuleId = () => {
const provider = registries.registeredTeamProvider()
return provider ? provider.owner : null
}
module.exports = {
getTeams,
getTeamMembers,
getTeamLeaders,
projectRoster,
providerModuleId,
CALL_TIMEOUT_MS,
}

View File

@@ -0,0 +1,50 @@
// Deriving a Team's URL slug from a game-written name (TEAMS.md §2.1).
//
// A slug is derived ONCE, at create, and then frozen for the life of the row —
// like `name`, and for the same reason: the Team page URL has to stay stable, and
// a rename is an archive plus a create rather than an edit.
const MAX_SLUG = 180 // the column is 191; leaves room for a -NN suffix
/**
* Reduce a name to a URL-safe stem.
*
* Diacritics are folded rather than stripped so "Ünderdark" becomes "underdark"
* and not "nderdark". A name made entirely of characters that do not survive —
* which a guild name genuinely can be, since the game accepts far more than a URL
* does — leaves an empty stem, and the caller substitutes a stable fallback
* rather than minting a Team with no address.
*/
function slugify(name) {
return String(name || '')
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, MAX_SLUG)
.replace(/-+$/g, '')
}
/**
* A slug not already taken, given the ones that are.
*
* `taken` must include ARCHIVED teams' slugs, not only active ones. The unique
* key constrains active rows alone, so the database would allow a new Team to
* take a retired Team's slug — and §2.2 promises the retired one stays readable
* at that address, which is what a bookmark or an old Discord link resolves to.
*/
function uniqueSlug(name, taken, { fallback = 'team' } = {}) {
const base = slugify(name) || fallback
const used = new Set(taken)
if (!used.has(base)) return base
// Bounded rather than unbounded: a suffix search that cannot terminate is worse
// than a slug with an id in it, and 999 same-named teams is already absurd.
for (let n = 2; n <= 999; n++) {
const candidate = `${base}-${n}`
if (!used.has(candidate)) return candidate
}
return `${base}-${Date.now().toString(36)}`
}
module.exports = { slugify, uniqueSlug, MAX_SLUG }

View File

@@ -0,0 +1,624 @@
// ── The reconciler ─────────────────────────────────────────────────────────
//
// Core's projection of the module's Teams, kept in step (TEAMS.md §2.4). This is
// the only thing that writes `team_members`, and one of only two things that
// write `teams.status`.
//
// **The four places it refuses to act** are the point of the file, and they are
// all one rule stated four ways: *a result derived from an answer core does not
// trust is never applied.* Anything less specific tends to collapse, under
// maintenance, into "a failed call means no teams" — which is invariant 1's
// failure mode and would empty every roster on the site the first time a sidecar
// restarted.
//
// 1. `getTeams()` not ok → write sync state, touch NOTHING, return.
// 2. ok but empty, core holds ≥1 → quarantine; apply only if the NEXT
// authoritative answer agrees.
// 3. `getTeamMembers()` not ok → that Team's roster untouched and stale;
// the other Teams carry on.
// 4. ok but zero members, had some → the same two-strikes quarantine, per Team.
//
// Gates 2 and 4 exist because an authoritative-looking empty answer during a cold
// start is the one failure indistinguishable from a real wipe. "Every Team on the
// shard disbanded at once" costs one interval of delay to confirm; getting it
// wrong costs every roster on the site.
//
// Events (§2.3) are an OPTIMISATION, never the source of truth. They make the
// common case immediate; reconciliation is what makes it correct. Nothing
// destructive at Team level is ever driven by one — §2.2 scopes archival to an
// authoritative full list, so a `team.disbanded` event schedules a run rather
// than archiving, and a spurious event costs a reconcile instead of a Team.
const teamsDb = require('./teams.db')
const teamProvider = require('./teamProvider')
const moderation = require('./teamModeration.model')
const activity = require('./teamActivity.model')
const teamNotify = require('../../utils/teamNotify')
const teamVoiceSync = require('../../utils/teamVoiceSync')
const { slugify, uniqueSlug } = require('./teamSlug')
const settings = require('../settings/settings.model')
const log = require('../../utils/logger')('teams')
// At most one run per 30s (§2.4), so a sidecar flapping cannot become a
// reconciliation storm — every flap publishes events, and every event asks for a
// run.
const DEBOUNCE_MS = 30_000
const DEFAULT_INTERVAL_S = 900
const MIN_INTERVAL_S = 60
const INTERVAL_KEY = 'teams_reconcile_interval_s'
// The six kinds a module may publish (§2.3). Six rather than the four a
// membership-shaped reading suggests, because leadership is its own authority
// path and a leadership change must be expressible without pretending someone
// joined or left.
const EVENT_KINDS = new Set([
'team.created', 'team.disbanded',
'team.member.added', 'team.member.removed',
'team.leader.added', 'team.leader.removed',
])
// Kinds that can only be answered by a full list. `team.created` cannot be
// applied from a delta — a Team built from one has no name, no roster and no
// leaders — and `team.disbanded` must not be, per §2.2.
const RECONCILE_ONLY = new Set(['team.created', 'team.disbanded'])
// ── Scheduling state (in-process; one provider per deployment) ─────────────
let running = false
let rerunReason = null
let lastRunAt = 0
let debounceTimer = null
let pollTimer = null
let started = false
/** Resolve the poll interval, floored so a bad setting cannot become a hot loop. */
async function intervalSeconds() {
let raw
try {
raw = await settings.get(INTERVAL_KEY)
} catch {
return DEFAULT_INTERVAL_S
}
const n = Number.parseInt(raw, 10)
if (!Number.isFinite(n) || n < MIN_INTERVAL_S) return DEFAULT_INTERVAL_S
return n
}
/**
* Backoff, capped at the poll interval (§2.4).
*
* The cap is what keeps this a backoff rather than an outage: a module down for a
* day would otherwise reach a delay measured in weeks and stay stale long after
* it recovered.
*/
function backoffSeconds(consecutiveFailures, intervalS) {
if (!consecutiveFailures) return intervalS
return Math.min(intervalS, 2 ** Math.min(consecutiveFailures, 16) * 15)
}
// ── Applying one Team ──────────────────────────────────────────────────────
/**
* Create the row for a Team core has not seen, deriving its slug and screening
* its name against the reserved list (§2.8).
*
* The row is created whatever the screening says, and hidden if it matched. Core
* cannot refuse a name: the guild already exists in the game and core is a mirror
* of it, not an authority over it. A hidden Team is absent from public surfaces
* and completely functional for its own members — the people in it are not being
* punished for a name their leader chose.
*/
async function createTeam(moduleId, team) {
const taken = await teamsDb.slugsLike(slugify(team.name) || 'team')
const slug = uniqueSlug(team.name, taken)
const screened = await moderation.screenForCreate(team.name)
const id = await teamsDb.insertTeam({ moduleId, slug, ...team, ...screened })
log.info('team created', {
moduleId, externalId: team.externalId, name: team.name, slug, hidden: Boolean(screened.hidden),
})
return id
}
/**
* The §2.2 rename rule: same id and a different name is an archive plus a create.
*
* The old row keeps its forum, its activity and its grants, all read-only, and
* points at its successor so the old slug can explain itself instead of 404ing.
* Core never decides whether this is "really" the same team — that judgement is
* the module's, expressed in whether it reuses the external id (§10.5).
*/
async function applyRename(moduleId, existing, team) {
const successorId = await createTeam(moduleId, team)
await teamsDb.archiveTeam(existing.id, 'renamed', successorId)
log.info('team renamed; previous row archived', {
externalId: team.externalId, from: existing.name, to: team.name, archivedId: existing.id, successorId,
})
// §4.2's `core.team.renamed`, written to the SUCCESSOR rather than to the row
// that was renamed: the archived row is a read-only record of what happened
// before the rename (§2.2), and the person who wants to know a Team used to be
// called something else is looking at the live page.
//
// The old name is core's own, not game-sourced text a module handed us this
// run — it is the `name` column core has been serving all along — so §2.9's
// approval gate does not apply. It can still be a name staff suppressed, which
// is why a hidden Team's feed is not served publicly (teamActivity.feedFor).
await activity.logCore({
teamId: successorId,
kind: activity.CORE_KINDS.TEAM_RENAMED,
summary: `Renamed from ${existing.display_name_override || existing.name}`,
dedupeKey: `renamed:${existing.id}`,
}).catch((err) => log.warn('rename activity not recorded', { message: err.message }))
return successorId
}
/** Never a game-internal member key on a public page: that identifier is not published (§3.2). */
const memberLabel = (row) => (row && row.display_name) || 'A member'
/**
* Core's own membership items for one roster run (§4.2).
*
* **Suppressed on a Team's FIRST roster.** Importing a 155-member guild is one
* Team arriving, not 155 people joining, and emitting a join per member would
* bury every real event under the import and blow through the row cap on day one.
* `roster_synced_at IS NULL` is exactly "core has never held a roster for this
* Team", so the same condition covers a newly created Team and a newly installed
* module adopting an existing one.
*
* Never throws: the feed is a rendering of the sync, and a feed write failing
* must not abort the sync that is the actual source of truth.
*/
async function logRosterActivity(team, { joined, left, promoted, demoted }) {
if (!team.roster_synced_at) return
const items = [
...joined.map((row) => ({ kind: activity.CORE_KINDS.MEMBER_JOINED, row, verb: 'joined' })),
...left.map((row) => ({ kind: activity.CORE_KINDS.MEMBER_LEFT, row, verb: 'left' })),
...promoted.map((row) => ({ kind: activity.CORE_KINDS.LEADER_CHANGED, row, verb: 'became a leader' })),
...demoted.map((row) => ({ kind: activity.CORE_KINDS.LEADER_CHANGED, row, verb: 'stepped down as a leader' })),
]
for (const { kind, row, verb } of items) {
try {
// eslint-disable-next-line no-await-in-loop
await activity.logCore({
teamId: team.id,
kind,
summary: `${memberLabel(row)} ${verb}`,
actorMemberKey: row.member_key,
actorUserId: row.user_id ?? null,
})
} catch (err) {
log.warn('roster activity not recorded', { teamId: team.id, kind, message: err.message })
}
}
}
/**
* The push half of the same roster run (TEAMS.md §6.2, phase 6).
*
* **At most one tickle per stream per run, not one per member.** A tickle is
* content-free — it says "something happened in this Team" and the app pulls the
* rest — so five people joining in one sweep is five identical notifications and
* one piece of information. The feed above is per-member because it is a record;
* this is per-run because it is a nudge.
*
* **Suppressed on a Team's FIRST roster, exactly as the feed is**, and this is the
* half where it matters more: importing a 155-member guild would otherwise wake
* every one of their phones. `roster_synced_at IS NULL` is the same condition, read
* from the same row before the same stamp moves.
*
* Never throws — the fan-out swallows its own failures, and this adds the guard
* for anything the surrounding read could raise. A roster sync is the source of
* truth; a notification about it is not.
*/
async function notifyRoster(team, { joined, promoted, demoted }) {
if (!team.roster_synced_at) return
try {
// The count rides along for the Discord bridge (§7.2), which has no app on
// the other end to pull the roster after a content-free nudge. The tickle
// itself is unchanged and still carries nothing.
if (joined.length > 0) await teamNotify.memberJoined(team, { count: joined.length })
if (promoted.length > 0 || demoted.length > 0) await teamNotify.leadershipChanged(team)
} catch (err) {
log.warn('roster notification not sent', { teamId: team.id, message: err.message })
}
}
/**
* Sync one Team's roster and leadership. Gates 3 and 4 live here.
*
* Returns whether the roster was applied, so the caller can tell "synced" from
* "left alone", which is the difference between fresh and stale on that Team's
* page.
*/
async function syncRoster(team) {
const answer = await teamProvider.getTeamMembers(team.external_id)
// Gate 3. One Team's unanswerable roster is not the other Teams' problem, and
// it is certainly not an empty roster.
if (!answer.ok) {
log.warn('roster left untouched; provider could not answer', {
externalId: team.external_id, reason: answer.reason,
})
return false
}
// The full rows rather than just the keys: the activity feed needs the display
// name and the prior `is_leader` of everyone who is about to change, and both
// are gone once the upsert below has run. One read either way — this replaces
// the `memberKeys` call rather than adding to it.
const knownRows = await teamsDb.membersByTeam(team.id)
const knownByKey = new Map(knownRows.map((row) => [row.member_key, row]))
const known = knownRows.map((row) => row.member_key)
// Gate 4, the per-Team twin of gate 2.
if (answer.complete && answer.members.length === 0 && known.length > 0) {
if (!team.members_empty_since) {
await teamsDb.setMembersEmptySince(team.id, new Date())
log.warn('empty roster quarantined; awaiting a second answer', {
externalId: team.external_id, had: known.length,
})
return false
}
log.warn('empty roster confirmed by a second answer; departing every member', {
externalId: team.external_id, had: known.length,
})
} else if (team.members_empty_since) {
// Any non-empty answer clears the quarantine.
await teamsDb.setMembersEmptySince(team.id, null)
}
for (const member of answer.members) {
// eslint-disable-next-line no-await-in-loop
await teamsDb.upsertMember({
teamId: team.id,
memberKey: member.memberKey,
displayName: member.displayName,
userId: member.userId,
isLeader: member.leader,
rankLabel: member.rankLabel,
online: member.online,
})
}
// Anyone the module reports that core was not already holding. Read from the
// module's shape, since a joiner has no row yet.
const joined = answer.members
.filter((m) => !knownByKey.has(m.memberKey))
.map((m) => ({ member_key: m.memberKey, display_name: m.displayName, user_id: m.userId }))
// Removals only from a COMPLETE answer. `complete: false` means "valid but
// partial", so additions and updates apply and nothing is taken away.
let left = []
if (answer.complete) {
const seen = new Set(answer.members.map((m) => m.memberKey))
const departedKeys = known.filter((key) => !seen.has(key))
left = departedKeys.map((key) => knownByKey.get(key))
await teamsDb.markDeparted(team.id, departedKeys)
}
// Leadership is a separate question with a separate answer, and a provider that
// cannot answer it leaves the synced value alone rather than demoting everyone.
const leaders = await teamProvider.getTeamLeaders(team.external_id)
let promoted = []
let demoted = []
if (leaders.ok) {
// Diffed against the PRIOR rows, before setLeaders overwrites them. A member
// who joined this run as a leader is reported as joining, not as being
// promoted — they were never anything else here.
const nowLeader = new Set(leaders.leaders)
const departed = new Set(left.map((row) => row && row.member_key))
promoted = knownRows.filter((row) => nowLeader.has(row.member_key) && !row.is_leader)
demoted = knownRows.filter((row) => !nowLeader.has(row.member_key) && row.is_leader && !departed.has(row.member_key))
await teamsDb.setLeaders(team.id, leaders.leaders)
} else {
log.warn('leadership left untouched; provider could not answer', {
externalId: team.external_id, reason: leaders.reason,
})
}
await teamsDb.recount(team.id)
// Both read before `markRosterSynced` moves the stamp their first-roster
// suppression turns on.
await logRosterActivity(team, { joined, left: left.filter(Boolean), promoted, demoted })
await notifyRoster(team, { joined, promoted, demoted })
await teamsDb.markRosterSynced(team.id)
return true
}
// ── The run ────────────────────────────────────────────────────────────────
/**
* One full reconciliation. Callers use `request()`; this is the body it guards.
*
* Never throws: a reconcile is a background job, and a rejection here would
* surface as an unhandled rejection in the poll timer rather than as anything an
* operator could act on. The failure is recorded where it can be read — in
* `team_sync_state`, which Admin → Teams shows verbatim.
*/
async function runOnce(reason) {
const moduleId = teamProvider.providerModuleId()
if (!moduleId) return { ok: false, reason: 'no team provider is registered' }
await teamsDb.recordAttempt(moduleId)
const answer = await teamProvider.getTeams()
// Gate 1.
if (!answer.ok) {
await teamsDb.recordFailure(moduleId, answer.reason)
log.warn('reconcile refused; provider could not answer', { reason: answer.reason, trigger: reason })
return { ok: false, reason: answer.reason }
}
const existing = await teamsDb.activeByModule(moduleId)
// Gate 2. Only a COMPLETE answer can mean "there are no teams" — a partial one
// removes nothing by definition.
if (answer.complete && answer.teams.length === 0 && existing.length > 0) {
const state = await teamsDb.syncState(moduleId)
if (!state || !state.pending_empty_since) {
await teamsDb.setPendingEmpty(moduleId, new Date())
await teamsDb.recordSuccess(moduleId)
log.warn('empty team list quarantined; awaiting a second answer', { held: existing.length })
return { ok: true, quarantined: true, applied: 0 }
}
const intervalS = await intervalSeconds()
const waited = (Date.now() - new Date(state.pending_empty_since).getTime()) / 1000
if (waited < intervalS) {
await teamsDb.recordSuccess(moduleId)
log.warn('empty team list still quarantined', { waitedSeconds: Math.round(waited), intervalS })
return { ok: true, quarantined: true, applied: 0 }
}
log.warn('empty team list confirmed; archiving every active team', { count: existing.length })
} else if (answer.teams.length) {
// Any non-empty answer clears the quarantine.
await teamsDb.setPendingEmpty(moduleId, null)
}
const byExternalId = new Map(existing.map((t) => [t.external_id, t]))
const seen = new Set()
let created = 0
let renamed = 0
let rosters = 0
for (const team of answer.teams) {
seen.add(team.externalId)
const current = byExternalId.get(team.externalId)
let id
if (!current) {
id = await createTeam(moduleId, team)
created += 1
} else if (current.name !== team.name) {
id = await applyRename(moduleId, current, team)
renamed += 1
} else {
id = current.id
await teamsDb.updateTeam(id, { abbr: team.abbr, meta: team.meta })
}
// Re-read rather than reusing `current`: a create or a rename has just made a
// row this loop has never seen, and syncRoster reads the quarantine stamp off
// it. Passing a stale object would drop the second strike of gate 4.
const row = await teamsDb.findById(id)
if (row && await syncRoster(row)) rosters += 1
}
// Archive what the module no longer lists — the §2.2 disband path, and the only
// one. Guarded by `complete` for the same reason removals are.
let archived = 0
if (answer.complete) {
for (const team of existing) {
if (seen.has(team.external_id)) continue
await teamsDb.archiveTeam(team.id, 'disbanded')
archived += 1
log.info('team archived; absent from an authoritative list', {
externalId: team.external_id, name: team.name,
})
}
}
// Re-screen the names no human has ruled on. Names are immutable per row, so
// this only changes an outcome when the reserved TERMS changed — an operator
// adding one, or the deployment being renamed — which is exactly the case a
// create-time-only check would miss forever.
const rehidden = await moderation.rescreen(moduleId)
await teamsDb.recordSuccess(moduleId)
// §7.3's "after a successful Team reconcile": the voice reconciler runs off the
// projection this run just refreshed. Requested rather than awaited — it makes
// Discord calls, and a roster sync must never be slowed down, failed or held
// open by an integration hanging off it. It has its own debounce and its own
// suspensions (including the stale check, which is why it re-reads the state
// this run just wrote rather than trusting that it was called from a good one).
teamVoiceSync.request({ reason: 'reconcile' })
log.info('reconcile complete', {
trigger: reason, created, renamed, archived, rosters, rehidden, total: answer.teams.length,
})
return { ok: true, created, renamed, archived, rosters, rehidden }
}
// ── The public entry points ────────────────────────────────────────────────
/**
* Run now, awaited, with the per-module lock held. Admin → Resync uses this,
* because an operator pressing a button is owed the outcome rather than a
* promise that something will happen soon.
*
* A run already in progress is JOINED rather than queued: the caller wants "the
* projection is now current", and a run that started a moment ago delivers that.
*/
async function reconcileNow(reason = 'manual') {
if (running) {
rerunReason = reason
return { ok: true, joined: true }
}
running = true
try {
const result = await runOnce(reason)
lastRunAt = Date.now()
return result
} catch (err) {
log.error('reconcile threw', { message: err.message, trigger: reason })
return { ok: false, reason: err.message }
} finally {
running = false
const queued = rerunReason
rerunReason = null
// Something asked while this run was in flight, so it saw state this run may
// have been too early to include. Ask again, through the debounce.
if (queued) request({ reason: queued })
}
}
/**
* Ask for a reconciliation. Returns immediately and never rejects — this is what
* `ctx.teams.reconcile()` is (§2.3), and a module must not be able to make its
* own call site slow or its own errors someone else's.
*/
function request({ reason = 'module' } = {}) {
if (debounceTimer) return
const since = Date.now() - lastRunAt
if (running) {
rerunReason = reason
return
}
if (since >= DEBOUNCE_MS) {
reconcileNow(reason).catch(() => {})
return
}
debounceTimer = setTimeout(() => {
debounceTimer = null
reconcileNow(reason).catch(() => {})
}, DEBOUNCE_MS - since)
// Unreffed for the same reason the provider's deadline is: a pending debounce
// must not hold a shutdown open waiting to do background work.
if (typeof debounceTimer.unref === 'function') debounceTimer.unref()
}
/**
* Apply a module-published event (§2.3).
*
* Deltas are applied only for a Team core already knows, and only for the four
* kinds a delta can express. Everything else — an unknown Team, a create, a
* disband — asks for a reconciliation instead, because a Team invented from a
* delta has no name, no roster and no leaders, and an archive driven by one is
* destruction on the strength of a message that may simply have been repeated.
*/
async function publish(event) {
const { kind, externalId } = event || {}
if (!EVENT_KINDS.has(kind)) throw new Error(`teams.publish: unknown event kind "${kind}"`)
const id = typeof externalId === 'string' ? externalId.trim() : ''
if (!id) throw new Error(`teams.publish: ${kind} has no externalId`)
const moduleId = teamProvider.providerModuleId()
if (!moduleId) return
if (RECONCILE_ONLY.has(kind)) {
request({ reason: kind })
return
}
const team = await teamsDb.findActive(moduleId, id)
if (!team) {
request({ reason: `${kind} for an unknown team` })
return
}
const memberKey = typeof event.memberKey === 'string' ? event.memberKey.trim() : ''
if (!memberKey) throw new Error(`teams.publish: ${kind} has no memberKey`)
switch (kind) {
case 'team.member.added':
await teamsDb.upsertMember({
teamId: team.id,
memberKey,
displayName: typeof event.displayName === 'string' ? event.displayName.trim() : null,
userId: Number.isInteger(event.userId) && event.userId > 0 ? event.userId : null,
isLeader: Boolean(event.leader),
rankLabel: typeof event.rankLabel === 'string' ? event.rankLabel.trim() : null,
online: Boolean(event.online),
})
break
case 'team.member.removed':
await teamsDb.markDeparted(team.id, [memberKey])
break
case 'team.leader.added':
case 'team.leader.removed':
// A no-op when the member is unknown: the row is created by the roster, not
// by a leadership delta, and inventing one here would put a member on the
// roster whose only evidence is that someone promoted them.
await teamsDb.setMemberLeader(team.id, memberKey, kind === 'team.leader.added')
break
default:
break
}
await teamsDb.recount(team.id)
// A delta is a hint that something changed, not a claim to have applied all of
// it, so every one still asks for the run that makes it correct.
request({ reason: kind })
}
// ── The poll ───────────────────────────────────────────────────────────────
async function scheduleNextPoll() {
const intervalS = await intervalSeconds()
const moduleId = teamProvider.providerModuleId()
let delayS = intervalS
if (moduleId) {
const state = await teamsDb.syncState(moduleId).catch(() => null)
if (state) delayS = backoffSeconds(state.consecutive_failures, intervalS)
}
pollTimer = setTimeout(() => {
reconcileNow('poll').catch(() => {}).then(() => { if (started) scheduleNextPoll().catch(() => {}) })
}, delayS * 1000)
if (typeof pollTimer.unref === 'function') pollTimer.unref()
}
/**
* Start the boot reconcile and the poll. Called from the module lifecycle, after
* every module has started — the website may have been down across a whole guild
* war, so the first thing it does on the way up is ask.
*/
async function start() {
if (started) return
started = true
if (!teamProvider.providerModuleId()) {
log.info('no team provider registered; the reconciler stays idle')
return
}
await reconcileNow('boot')
await scheduleNextPoll()
}
function stop() {
started = false
if (pollTimer) clearTimeout(pollTimer)
if (debounceTimer) clearTimeout(debounceTimer)
pollTimer = null
debounceTimer = null
}
// Test-only: the scheduler is module-level state, so a test that triggers a run
// has to be able to put it back.
function _reset() {
stop()
running = false
rerunReason = null
lastRunAt = 0
}
module.exports = {
reconcileNow,
request,
publish,
start,
stop,
intervalSeconds,
backoffSeconds,
EVENT_KINDS,
DEBOUNCE_MS,
DEFAULT_INTERVAL_S,
_reset,
}

View File

@@ -0,0 +1,213 @@
// SQL for per-Team external resources — today, the Discord voice channel
// (TEAMS.md §7.3, phase 9).
//
// Two queries carry the phase. `desiredTeams` is what SHOULD have a channel and
// `holdersWithoutClaim` is what HAS one and should not; the reconciler is the
// difference between them, and keeping both as single queries is what stops a
// pass from being one round trip per Team before it has made a single Discord
// call.
//
// **`discordSubjectsFor` is the whole identity chain in one statement** (§2.6):
// team_members → users → user_identities. A member with no site account has no
// row to join, and a member with a site account but no Discord identity drops out
// at the second join — which is exactly right, because a role can only be granted
// to somebody Discord knows about. Nothing else in the phase is allowed to
// shortcut this with `teams.linked_count`, which counts hop 1 and is always the
// larger number.
const { query } = require('../../utils/db')
// The provider id a Discord identity is stored under. Matches `auth_providers.id`
// and the built-in provider in `auth/providers/discord.provider.js`; a constant
// rather than a literal because it appears in two queries and a typo in either
// would silently return an empty grant set — a Team whose channel nobody can
// enter, with no error anywhere.
const DISCORD_PROVIDER = 'discord'
// Deliberately WITHOUT `i.team_id`, and this is not tidiness.
//
// The two queries below join `teams` and already select `t.id AS team_id`, so
// including the integration row's copy produces two result columns with the same
// name — which the `mariadb` driver refuses outright: "Error in results, duplicate
// field name `team_id`". Every caller sees the whole pass fail, and no unit test
// can see it, because they stub this layer.
//
// It would also be the WRONG column even if the driver allowed it: `desiredTeams`
// LEFT JOINs, so `i.team_id` is NULL for exactly the Teams that have no channel
// yet — the create case, where knowing the Team's id matters most. The two queries
// that do not join `teams` ask for it explicitly.
const COLUMNS = `
i.id, i.platform, i.resource, i.external_ref, i.role_ref,
i.state, i.remove_after, i.last_error, i.synced_at, i.updated_at`
/**
* Every Team that qualifies for a resource, with its integration row if it has
* one.
*
* The three conditions are §7.3's provisioning gate and §2.8's publication rule
* together:
*
* - `status = 'active'` — an archived Team is a record, not a place to talk.
* - `hidden = 0` — the channel is NAMED after the Team, and a Discord channel
* name is a game-sourced string published outside the site. A hidden Team's
* name is suppressed on every public surface; a voice channel would be the
* one place it still appeared.
* - `member_count >= ?` — the operator's threshold, counting ALL active members
* regardless of what they have linked (org lead, 2026-08-18). §7.3 wrote
* `voice_min_linked_members`; the number an operator is actually judging is
* "is this Team real", and link state answers a different question.
*
* LEFT JOIN rather than two queries: the reconciler needs "should have, and does
* it" as one answer, and a Team with no row yet is the create case.
*/
async function desiredTeams({ platform, resource, minMembers }) {
return query(
`SELECT t.id AS team_id, t.name, t.display_name_override, t.slug, t.abbr,
t.member_count, t.linked_count, ${COLUMNS}
FROM teams t
LEFT JOIN team_integrations i
ON i.team_id = t.id AND i.platform = ? AND i.resource = ?
WHERE t.status = 'active' AND t.hidden = 0 AND t.member_count >= ?
ORDER BY t.id`,
[platform, resource, Number(minMembers)],
)
}
/**
* Rows that hold a resource for a Team that no longer qualifies.
*
* The mirror of `desiredTeams`, and deliberately not its negation in JavaScript:
* a Team can stop qualifying by being archived, by being hidden, by losing
* members, or by having its row deleted out from under core, and enumerating
* those in a filter would mean re-deriving the gate in a second place that could
* disagree with the first.
*
* Rows already in 'pending_removal' are included — the grace window is decided by
* the caller, which needs to see them to know whether one has expired.
*/
async function holdersWithoutClaim({ platform, resource, minMembers }) {
return query(
`SELECT t.id AS team_id, t.name, t.display_name_override, t.status, t.hidden,
t.member_count, ${COLUMNS}
FROM team_integrations i
JOIN teams t ON t.id = i.team_id
WHERE i.platform = ? AND i.resource = ?
AND (i.external_ref IS NOT NULL OR i.role_ref IS NOT NULL)
AND (t.status <> 'active' OR t.hidden = 1 OR t.member_count < ?)
ORDER BY t.id`,
[platform, resource, Number(minMembers)],
)
}
/**
* The Discord user ids of a Team's members — hop 3 of §2.6, and the only set a
* role can be granted to.
*
* DISTINCT because a user could in principle hold two rows for the same provider
* across a provider rename; the unique key prevents it for one (provider,
* subject) pair, not for one user with two subjects. Two role-adds for the same
* person is harmless and one duplicate in a diff is a phantom removal next pass,
* which is not.
*/
async function discordSubjectsFor(teamId) {
const rows = await query(
`SELECT DISTINCT ui.subject
FROM team_members m
JOIN user_identities ui ON ui.user_id = m.user_id AND ui.provider = ?
WHERE m.team_id = ? AND m.status = 'active' AND m.user_id IS NOT NULL
ORDER BY ui.subject`,
[DISCORD_PROVIDER, Number(teamId)],
)
return rows.map((row) => String(row.subject))
}
/** Every row for a platform, with the Team's name — the admin panel's listing. */
async function listForPlatform(platform, resource) {
return query(
`SELECT i.team_id, ${COLUMNS}, t.name AS team_name, t.slug AS team_slug,
t.display_name_override, t.status AS team_status, t.hidden AS team_hidden,
t.member_count, t.linked_count
FROM team_integrations i
JOIN teams t ON t.id = i.team_id
WHERE i.platform = ? AND i.resource = ?
ORDER BY t.name`,
[platform, resource],
)
}
async function getForTeam(teamId, platform, resource) {
const rows = await query(
`SELECT i.team_id, ${COLUMNS} FROM team_integrations i
WHERE i.team_id = ? AND i.platform = ? AND i.resource = ? LIMIT 1`,
[Number(teamId), platform, resource],
)
return rows[0] || null
}
/**
* Write what the reconciler believes after a pass.
*
* A full upsert of the mutable columns rather than a patch, because every caller
* has just decided all of them together: a pass that created a channel knows the
* state, the refs, the error (none) and the stamp, and letting it write three of
* the four would leave the fourth describing a previous pass.
*
* `remove_after` is written explicitly on every call, `NULL` included — a Team
* that climbs back above the threshold inside its window has to have the window
* cleared, and an upsert that skipped NULLs would leave it armed.
*/
async function upsert({ teamId, platform, resource, externalRef, roleRef, state, removeAfter, lastError, syncedAt }) {
await query(
`INSERT INTO team_integrations
(team_id, platform, resource, external_ref, role_ref, state, remove_after, last_error, synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
external_ref = VALUES(external_ref),
role_ref = VALUES(role_ref),
state = VALUES(state),
remove_after = VALUES(remove_after),
last_error = VALUES(last_error),
synced_at = VALUES(synced_at)`,
[
Number(teamId),
platform,
resource,
externalRef || null,
roleRef || null,
state,
removeAfter || null,
lastError ? String(lastError).slice(0, 500) : null,
syncedAt || null,
],
)
return getForTeam(teamId, platform, resource)
}
async function remove(teamId, platform, resource) {
const res = await query(
'DELETE FROM team_integrations WHERE team_id = ? AND platform = ? AND resource = ?',
[Number(teamId), platform, resource],
)
return Number(res && res.affectedRows) || 0
}
/** How many rows currently hold a role — the input to the 250-role ceiling. */
async function roleCount(platform) {
const rows = await query(
'SELECT COUNT(*) AS n FROM team_integrations WHERE platform = ? AND role_ref IS NOT NULL',
[platform],
)
return Number(rows[0] && rows[0].n) || 0
}
module.exports = {
DISCORD_PROVIDER,
desiredTeams,
holdersWithoutClaim,
discordSubjectsFor,
listForPlatform,
getForTeam,
upsert,
remove,
roleCount,
}

View File

@@ -0,0 +1,235 @@
// ── One voice channel per Team: what core believes, and what it wants ──────
//
// TEAMS.md §7.3, phase 9. This file answers three questions and makes no calls:
// which Teams should have a voice channel, who should be able to enter it, and
// what should happen to the ones that should not have it any more. The pass that
// actually reaches Discord is `utils/teamVoiceSync.js`.
//
// **Access is a per-Team ROLE, always.** §7.3 specified per-member permission
// overwrites with escalation to a role above ~90 members; the org lead settled on
// roles always (2026-08-18). What that changes is not just a code path:
//
// - `voice_overwrite_max`, the escalation and the `overwrites`/`role` mode
// transition all leave the design. There is no mode.
// - The binding limit moves. Overwrites are capped per channel (~100), so the
// old shape's ceiling was "one very large Team"; roles are capped per GUILD
// (250), so the new shape's ceiling is "how many Teams have voice at all". A
// limit on the number of Teams is a limit an operator has to be told about
// before they hit it, which is why `roleCap` is in the admin payload and not
// just in a `last_error` after a create failed.
// - A role is visible on a member's Discord profile and an overwrite is not, so
// membership of a Team becomes guild-visible. That is the trade the decision
// bought and it is not reversible per-deployment.
//
// **Three things §7.3 named that this codebase does not have**, all settled the
// same way — by asking the operator, because nothing in the data model can answer:
// "the staff role" (see `teamVoiceSettings`), the parent category's identity, and
// whether the bot can manage channels and roles at all.
//
// **Hidden Teams are never provisioned.** A Discord channel name is a
// game-sourced string published outside the site, which is precisely §2.8's
// concern — `utils/reservedNames.js` already names "and eventually a Discord
// channel name" as one of the surfaces it protects. So the screen that suppresses
// a Team's public page suppresses its channel too, and the interlock is free: the
// gate is `hidden = 0` in one query rather than a second policy that could drift
// from the first.
const voiceDb = require('./teamVoice.db')
const settings = require('./teamVoiceSettings.model')
const PLATFORM = 'discord'
const RESOURCE = 'voice'
// Discord's own limits on the two names this phase writes. Both are 100; kept as
// two constants because they are two independent promises and a future platform
// will not share them.
const CHANNEL_NAME_MAX = 100
const ROLE_NAME_MAX = 100
// How many role add/remove operations one pass hands the bot for one Team.
//
// A bound rather than "all of them", because each is its own Discord API call and
// an unbounded first pass on a 300-member guild is a request that outlives its own
// timeout — and a timeout is the one failure that leaves core not knowing what was
// applied. Bounded passes converge instead: the remainder is reported and the next
// pass takes the next slice.
const MEMBER_OPS_PER_PASS = 50
// Control characters, as a named constant: a literal control byte in a source
// file is invisible to every reader and to most diffs.
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g
/**
* The name a Team's channel and role carry.
*
* `display_name_override` first, because §2.8.3 gives staff a way to change what
* is DISPLAYED without touching identity, and a channel is a display surface. A
* Team whose name staff rewrote must not keep publishing the original one to
* Discord.
*
* The fallback is the Team's id, not its slug: a name that sanitises down to
* nothing is a name made entirely of characters Discord will not take, and the
* slug is derived from that same name, so it can be empty for the same reason.
*/
function displayName(team) {
return sanitiseName(team.display_name_override || team.name) || `team-${team.team_id || team.id}`
}
/**
* Strip what Discord will not carry, and nothing else.
*
* Deliberately not a slugifier. A voice channel keeps its spaces and its case —
* unlike a text channel, which Discord lowercases and hyphenates itself — so
* "The Silver Hand" should reach the guild as "The Silver Hand" and not as
* "the-silver-hand". Control characters go because they can hide the rest of a
* name; everything else a player can type is left alone, since core is a mirror of
* the game and not an editor of it.
*/
function sanitiseName(value) {
const text = String(value || '').replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim()
return text.slice(0, Math.min(CHANNEL_NAME_MAX, ROLE_NAME_MAX))
}
/** When a Team that stopped qualifying loses its channel. */
function removeAfterFrom(graceDays, now = new Date()) {
return new Date(now.getTime() + graceDays * 86400_000)
}
const isExpired = (row, now = new Date()) => !!row
&& !!row.remove_after
&& new Date(row.remove_after).getTime() <= now.getTime()
/**
* Everything one pass needs, resolved before it makes a single call.
*
* Returns `null` when voice is off, which is the answer on most deployments and
* is not an error.
*
* **Turning the feature off does not tear anything down.** A toggle that deleted
* guild structure would make "let me see what this does" destructive, and a voice
* channel that outlives its setting is inert — nobody's access changes, the
* channel simply stops being reconciled. The admin panel says how many are still
* provisioned and offers to remove them one at a time, which is a decision an
* operator makes rather than a side effect of a checkbox.
*/
async function plan({ now = new Date() } = {}) {
const config = await settings.all()
if (!config.enabled) return null
const [desired, holders] = await Promise.all([
voiceDb.desiredTeams({ platform: PLATFORM, resource: RESOURCE, minMembers: config.minMembers }),
voiceDb.holdersWithoutClaim({ platform: PLATFORM, resource: RESOURCE, minMembers: config.minMembers }),
])
// A Team that qualifies again while inside its grace window appears in BOTH
// queries only if the queries disagree, which they cannot — `desiredTeams`
// requires it to qualify and `holdersWithoutClaim` requires it not to. So the
// recovery case lands in `provision` with a row that still has `remove_after`
// set, and clearing that stamp is what "cancel the removal" means.
//
// §7.3 promises no Discord call is made when a Team recovers. As built the
// promise is narrower and truer: no DESTRUCTIVE call is made. A Team that
// regained members has members to grant, and the ordinary membership diff is
// what grants them — refusing to make any call at all would leave the people
// who brought it back above the threshold outside the channel.
const provision = desired.map((row) => ({
team: row,
name: displayName(row),
hasRow: !!row.id,
recovering: row.state === 'pending_removal',
}))
const removals = []
const scheduled = []
for (const row of holders) {
if (row.state !== 'pending_removal' || !row.remove_after) {
scheduled.push({ team: row, removeAfter: removeAfterFrom(config.graceDays, now), reason: removalReason(row) })
} else if (isExpired(row, now)) {
removals.push({ team: row, reason: removalReason(row) })
}
}
return { config, provision, scheduled, removals }
}
/**
* Why a Team is losing its channel, in the words an operator reads in the panel.
*
* Three distinguishable causes, and they are worth distinguishing: "archived" is
* expected, "below the threshold" is a Team shrinking, and "hidden" is a
* moderation decision somebody made — which is the one where a surprised operator
* would otherwise go looking for a bug.
*/
function removalReason(row) {
if (row.team_status && row.team_status !== 'active') return 'archived'
if (row.status && row.status !== 'active') return 'archived'
if (row.hidden || row.team_hidden) return 'hidden'
return 'below_threshold'
}
/** The Discord ids a Team's role should be granted to — hop 3 of §2.6. */
async function memberRefs(teamId) {
return voiceDb.discordSubjectsFor(teamId)
}
/** The admin panel's listing: every row, with the Team it belongs to. */
async function list() {
const rows = await voiceDb.listForPlatform(PLATFORM, RESOURCE)
return rows.map((row) => ({
teamId: row.team_id,
teamName: row.display_name_override || row.team_name,
teamSlug: row.team_slug,
teamStatus: row.team_status,
teamHidden: !!row.team_hidden,
memberCount: row.member_count,
linkedCount: row.linked_count,
channelRef: row.external_ref,
roleRef: row.role_ref,
state: row.state,
removeAfter: row.remove_after,
lastError: row.last_error,
syncedAt: row.synced_at,
updatedAt: row.updated_at,
}))
}
async function getForTeam(teamId) {
return voiceDb.getForTeam(teamId, PLATFORM, RESOURCE)
}
/** Record the outcome of one Team's pass. */
async function record({ teamId, channelRef, roleRef, state, removeAfter = null, lastError = null, syncedAt = null }) {
return voiceDb.upsert({
teamId,
platform: PLATFORM,
resource: RESOURCE,
externalRef: channelRef,
roleRef,
state,
removeAfter,
lastError,
syncedAt,
})
}
async function forget(teamId) {
return voiceDb.remove(teamId, PLATFORM, RESOURCE)
}
module.exports = {
PLATFORM,
RESOURCE,
CHANNEL_NAME_MAX,
MEMBER_OPS_PER_PASS,
displayName,
sanitiseName,
removeAfterFrom,
isExpired,
removalReason,
plan,
memberRefs,
list,
getForTeam,
record,
forget,
}

View File

@@ -0,0 +1,256 @@
// ── The operator's voice controls ──────────────────────────────────────────
//
// TEAMS.md §7.3, phase 9. Five `settings` keys, in their own file for the reason
// `teamForumSettings` is: two of them are not ordinary keys. `teams_voice_enabled`
// has a server-side precondition (the bot must actually be able to manage channels
// and roles — §7.3 assumed it could and the tree has never checked), and
// `teams_voice_category_ref` is written by the SERVER after the bot reports what
// it created, not by the admin who is looking at the form.
//
// teams_voice_enabled '0' | '1' default '0' — off
// teams_voice_min_members 1 … 10000 default 5
// teams_voice_grace_days 0 … 90 default 7
// teams_voice_category_ref a channel id absent until the bot makes one
// teams_voice_staff_roles CSV of role ids empty by default
//
// **Every read fails closed**, the same bargain the forum settings take: a DB
// fault reports voice off, which costs a pass that does nothing and is repeated
// fifteen minutes later. Failing open would mean creating guild structure on the
// strength of a query that did not answer.
//
// **`teams_voice_staff_roles` exists because "the staff role" does not.** §7.3
// grants the staff role an overwrite on every Team channel; this codebase has no
// staff-role concept at all — `guild_config` knows a news channel, a modlog
// channel, an autorole and a filter allowlist, and none of them means "staff".
// Guild administrators bypass channel overwrites anyway, so what is actually
// missing is a way to let NON-admin staff in, and only the operator can say which
// of their Discord roles those are. Empty is a legitimate and common answer.
const settingsDb = require('../settings/settings.db')
const ENABLED_KEY = 'teams_voice_enabled'
const MIN_MEMBERS_KEY = 'teams_voice_min_members'
const GRACE_DAYS_KEY = 'teams_voice_grace_days'
const CATEGORY_KEY = 'teams_voice_category_ref'
const STAFF_ROLES_KEY = 'teams_voice_staff_roles'
const MIN_MEMBERS_DEFAULT = 5
const MIN_MEMBERS_MAX = 10000
const GRACE_DAYS_DEFAULT = 7
const GRACE_DAYS_MAX = 90
// Discord's guild-wide role cap. It is the ceiling on how many Teams can have
// voice at all, and it is here rather than in the bot because the admin panel has
// to be able to say "you are at 231 of 250" BEFORE a create fails — §7.3's error
// state per Team is a diagnosis, not a warning.
//
// The number is Discord's and core cannot read it; a guild that gets a different
// one is a guild where this warns early, which is the harmless direction.
const ROLE_CAP = 250
// The same shape `teamIntegration.model` validates a channel with. Core treats
// every Discord id as opaque and only checks it could be one.
const SNOWFLAKE_RE = /^[0-9]{5,32}$/
/** Is voice provisioning switched on? Fail closed. */
async function enabled() {
try {
return String(await settingsDb.get(ENABLED_KEY)) === '1'
} catch {
return false
}
}
/**
* The membership threshold, counting every active member (org lead, 2026-08-18).
*
* Fails closed to the DEFAULT rather than to zero, unlike the forum's edit window:
* zero here would mean "provision every Team including the one-person ones", which
* is the expensive direction against a 250-role cap. The default is the
* conservative answer, not the permissive one.
*/
async function minMembers() {
try {
const raw = await settingsDb.get(MIN_MEMBERS_KEY)
if (raw == null || raw === '') return MIN_MEMBERS_DEFAULT
const n = Number(raw)
if (!Number.isFinite(n) || n < 1 || n > MIN_MEMBERS_MAX) return MIN_MEMBERS_DEFAULT
return Math.floor(n)
} catch {
return MIN_MEMBERS_DEFAULT
}
}
/**
* How long a Team keeps its channel after it stops qualifying (§7.3).
*
* `0` is legitimate and means "remove on the next pass" — an operator who would
* rather not have stale channels lying about. A DB fault reports the default, so a
* transient error can never turn the window off and delete something early; the
* grace window's whole job is to not act in a hurry.
*/
async function graceDays() {
try {
const raw = await settingsDb.get(GRACE_DAYS_KEY)
if (raw == null || raw === '') return GRACE_DAYS_DEFAULT
const n = Number(raw)
if (!Number.isFinite(n) || n < 0 || n > GRACE_DAYS_MAX) return GRACE_DAYS_DEFAULT
return Math.floor(n)
} catch {
return GRACE_DAYS_DEFAULT
}
}
/**
* The parent category every Team channel is created under, or null.
*
* Not an admin field. The bot creates the category on the first pass that needs
* one and reports the id back; the server stores it here so the next pass reuses
* it instead of making a second. An operator who wants a different category
* deletes this value (or the category) and the next pass makes a fresh one — which
* is why it is exposed read-only in the panel with a clear button rather than as a
* text input somebody could point at a channel that is not a category.
*/
async function categoryRef() {
try {
const value = await settingsDb.get(CATEGORY_KEY)
const text = String(value || '').trim()
return SNOWFLAKE_RE.test(text) ? text : null
} catch {
return null
}
}
async function setCategoryRef(value, actorId = null) {
const text = String(value || '').trim()
if (text && !SNOWFLAKE_RE.test(text)) throw new Error('category ref must be a numeric channel id')
return settingsDb.set(CATEGORY_KEY, text || null, actorId)
}
/**
* The roles that see every Team voice channel, in addition to that Team's own.
*
* Stored as CSV for the same reason `filter_allow_roles` is — `settings.value` is
* a VARCHAR and a JSON array in it buys nothing when the elements are numeric ids.
* Unreadable entries are DROPPED rather than rejected on read: a hand-edited row
* with one bad id should cost that id, not every staff grant on the deployment.
*/
async function staffRoles() {
try {
const raw = await settingsDb.get(STAFF_ROLES_KEY)
return parseRoles(raw)
} catch {
return []
}
}
function parseRoles(raw) {
return String(raw || '')
.split(',')
.map((part) => part.trim())
.filter((part) => SNOWFLAKE_RE.test(part))
}
/**
* Validate an operator-supplied staff-role list on the way IN, where a typo can
* still be reported to the person who made it.
*
* Rejected rather than filtered, the same call the bridge's event list makes: a
* silently-dropped id is a settings screen that shows you saved something you did
* not, and a role that was supposed to see every Team channel and does not is a
* failure nobody would think to look for.
*/
function normaliseStaffRoles(input) {
const parts = Array.isArray(input)
? input
: String(input == null ? '' : input).split(',')
const seen = []
for (const part of parts) {
const id = String(part || '').trim()
if (!id) continue
if (!SNOWFLAKE_RE.test(id)) {
const err = new Error(`not a role id: ${id}`)
err.status = 400
throw err
}
if (!seen.includes(id)) seen.push(id)
}
return seen
}
/** Everything the reconciler and the admin panel both need, in one read. */
async function all() {
const [on, min, grace, category, staff] = await Promise.all([
enabled(), minMembers(), graceDays(), categoryRef(), staffRoles(),
])
return {
enabled: on,
minMembers: min,
graceDays: grace,
categoryRef: category,
staffRoles: staff,
roleCap: ROLE_CAP,
}
}
/**
* Persist an admin's save. Returns the settings as they now read, so the panel
* renders what was stored rather than what was typed.
*
* The enable PRECONDITION is not here: it needs the bot, and a settings module
* that reached across to another process to validate a write would be impossible
* to test and surprising to read. The controller asks the bot and refuses, in the
* same shape §7.2's acknowledgement refuses — 422 before the write, never a quiet
* failure after it.
*/
async function save({ enabled: on, minMembers: min, graceDays: grace, staffRoles: staff }, actorId = null) {
const next = {}
if (on !== undefined) next[ENABLED_KEY] = on ? '1' : '0'
if (min !== undefined) {
const n = Number(min)
if (!Number.isInteger(n) || n < 1 || n > MIN_MEMBERS_MAX) {
const err = new Error(`minimum members must be between 1 and ${MIN_MEMBERS_MAX}`)
err.status = 400
throw err
}
next[MIN_MEMBERS_KEY] = String(n)
}
if (grace !== undefined) {
const n = Number(grace)
if (!Number.isInteger(n) || n < 0 || n > GRACE_DAYS_MAX) {
const err = new Error(`the grace window must be between 0 and ${GRACE_DAYS_MAX} days`)
err.status = 400
throw err
}
next[GRACE_DAYS_KEY] = String(n)
}
if (staff !== undefined) next[STAFF_ROLES_KEY] = normaliseStaffRoles(staff).join(',')
for (const [key, value] of Object.entries(next)) {
// eslint-disable-next-line no-await-in-loop
await settingsDb.set(key, value, actorId)
}
return all()
}
module.exports = {
ENABLED_KEY,
MIN_MEMBERS_KEY,
GRACE_DAYS_KEY,
CATEGORY_KEY,
STAFF_ROLES_KEY,
MIN_MEMBERS_DEFAULT,
GRACE_DAYS_DEFAULT,
ROLE_CAP,
enabled,
minMembers,
graceDays,
categoryRef,
setCategoryRef,
staffRoles,
parseRoles,
normaliseStaffRoles,
all,
save,
}

View File

@@ -0,0 +1,312 @@
// SQL for the Team tables. Raw parameterised mariadb, no ORM, per the layered
// backend convention (router → controller → model → db).
//
// This file holds statements only. Every decision about WHETHER to write — the
// four refusal gates, the quarantine, the rename rule — lives in the models above
// it, because a gate expressed as a WHERE clause is a gate nobody can find.
const { query } = require('../../utils/db')
// ── teams ──────────────────────────────────────────────────────────────────
const TEAM_COLUMNS = `
id, module_id, external_id, name, abbr, slug, status, meta,
member_count, linked_count, online_count,
hidden, hidden_reason, hidden_term, name_reviewed_at, display_name_override,
roster_synced_at, members_empty_since,
succeeded_by, created_at, archived_at, archived_reason`
/** Every ACTIVE team for a module — the set the reconciler diffs against. */
async function activeByModule(moduleId) {
return query(
`SELECT ${TEAM_COLUMNS} FROM teams WHERE module_id = ? AND status = 'active' ORDER BY id`,
[moduleId],
)
}
/**
* Every ACTIVE team, whichever module owns it.
*
* For the READ side, which must not be keyed on a provider being registered. The
* rows are core's and they outlive the module that filled them — a module
* uninstalled or disabled leaves a projection that is unmaintained, not one that
* stopped existing. Listing by provider made `/teams` empty while
* `/teams/:slug/members` still answered in full, since the lookup goes by slug:
* the index denied a Team that direct URLs served.
*/
async function allActive() {
return query(`SELECT ${TEAM_COLUMNS} FROM teams WHERE status = 'active' ORDER BY id`)
}
/** The ACTIVE row for an external id, or undefined. At most one, by uq_teams_active. */
async function findActive(moduleId, externalId) {
const rows = await query(
`SELECT ${TEAM_COLUMNS} FROM teams WHERE module_id = ? AND external_id = ? AND status = 'active'`,
[moduleId, externalId],
)
return rows[0]
}
async function findById(id) {
const rows = await query(`SELECT ${TEAM_COLUMNS} FROM teams WHERE id = ?`, [id])
return rows[0]
}
/** By slug, ACTIVE or ARCHIVED — an archived Team stays reachable at its old slug (§2.2). */
async function findBySlug(slug) {
const rows = await query(
`SELECT ${TEAM_COLUMNS} FROM teams WHERE slug = ? ORDER BY (status = 'active') DESC, id DESC LIMIT 1`,
[slug],
)
return rows[0]
}
/**
* Slugs already taken, ACTIVE OR ARCHIVED.
*
* The unique key only constrains active rows, and this deliberately checks more
* than the key does: §2.2 promises an archived Team stays readable at its old
* slug, and handing that slug to a new Team would silently break every bookmark
* and Discord link pointing at the old one.
*/
async function slugsLike(base) {
const rows = await query('SELECT slug FROM teams WHERE slug = ? OR slug LIKE ?', [base, `${base}-%`])
return rows.map((r) => r.slug)
}
async function insertTeam({ moduleId, externalId, name, abbr, slug, meta, hidden, hiddenReason, hiddenTerm }) {
const res = await query(
`INSERT INTO teams (module_id, external_id, name, abbr, slug, meta, hidden, hidden_reason, hidden_term)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[moduleId, externalId, name, abbr, slug, meta == null ? null : JSON.stringify(meta),
hidden ? 1 : 0, hiddenReason || null, hiddenTerm || null],
)
return res.insertId
}
/** Update the mutable fields. `name` and `slug` are absent by design — §2.2 freezes both. */
async function updateTeam(id, { abbr, meta }) {
await query('UPDATE teams SET abbr = ?, meta = ? WHERE id = ?',
[abbr, meta == null ? null : JSON.stringify(meta), id])
}
async function archiveTeam(id, reason, succeededBy = null) {
await query(
`UPDATE teams SET status = 'archived', archived_at = NOW(), archived_reason = ?, succeeded_by = ?
WHERE id = ? AND status = 'active'`,
[reason, succeededBy, id],
)
}
/**
* Recompute the three denormalised counts from the projection.
*
* Derived in one statement rather than incremented as rows change, so a missed
* delta can never leave a count drifting from the table it summarises — the count
* is only ever as wrong as the projection is.
*/
async function recount(teamId) {
await query(
`UPDATE teams t SET
member_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active'),
linked_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active' AND m.user_id IS NOT NULL),
online_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active' AND m.online = 1)
WHERE t.id = ?`,
[teamId],
)
}
// ── team_members ───────────────────────────────────────────────────────────
const MEMBER_COLUMNS = `
team_id, member_key, display_name, user_id, is_leader, rank_label, online, status,
first_seen_at, last_seen_at, departed_at`
async function membersByTeam(teamId, { includeDeparted = false } = {}) {
return query(
`SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ?` +
(includeDeparted ? '' : " AND status = 'active'") +
' ORDER BY is_leader DESC, display_name, member_key',
[teamId],
)
}
async function memberKeys(teamId) {
const rows = await query("SELECT member_key FROM team_members WHERE team_id = ? AND status = 'active'", [teamId])
return rows.map((r) => r.member_key)
}
async function findMember(teamId, memberKey) {
const rows = await query(`SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ? AND member_key = ?`,
[teamId, memberKey])
return rows[0]
}
/** The caller's ACTIVE membership of a team, or undefined. Path 1 of §2.5, and only path 1. */
async function activeByUser(teamId, userId) {
const rows = await query(
`SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ? AND user_id = ? AND status = 'active'`,
[teamId, userId],
)
return rows[0]
}
/** Every ACTIVE membership a user holds, with the team joined on. */
async function activeTeamsForUser(userId) {
return query(
`SELECT ${TEAM_COLUMNS.split(',').map((c) => `t.${c.trim()}`).join(', ')},
m.member_key, m.is_leader, m.rank_label, m.display_name AS member_display_name
FROM team_members m JOIN teams t ON t.id = m.team_id
WHERE m.user_id = ? AND m.status = 'active' AND t.status = 'active'
ORDER BY t.name`,
[userId],
)
}
/**
* Insert or refresh one member row.
*
* `first_seen_at` is never overwritten, so a member who leaves and rejoins keeps
* the date they first appeared; `status` returns to active on the same statement,
* which is what makes a rejoin a revived row rather than a second one.
*
* **`is_leader` is set on INSERT only, and deliberately not on update.** Path 2 of
* §2.5 is answered by `getTeamLeaders()`, not by the roster — two writers for one
* column is how a refused leadership answer turns into a silent demotion, because
* the roster would already have written `leader: false` before the authoritative
* call was even made. Seeding it on insert means a Team whose leadership call is
* failing is not leaderless from the start; after that, only setLeaders() moves it.
*/
async function upsertMember({ teamId, memberKey, displayName, userId, isLeader, rankLabel, online }) {
await query(
`INSERT INTO team_members (team_id, member_key, display_name, user_id, is_leader, rank_label, online)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name),
user_id = VALUES(user_id),
rank_label = VALUES(rank_label),
online = VALUES(online),
status = 'active',
departed_at = NULL,
last_seen_at = NOW()`,
[teamId, memberKey, displayName, userId, isLeader ? 1 : 0, rankLabel, online ? 1 : 0],
)
}
/** Soft-depart the named members. Rows are kept so history and rejoins survive. */
async function markDeparted(teamId, memberKeys_) {
if (!memberKeys_.length) return
const holes = memberKeys_.map(() => '?').join(', ')
await query(
`UPDATE team_members SET status = 'departed', departed_at = NOW(), online = 0
WHERE team_id = ? AND status = 'active' AND member_key IN (${holes})`,
[teamId, ...memberKeys_],
)
}
/** Set is_leader for a whole team in one pass — the sync's path-2 write. */
async function setLeaders(teamId, leaderKeys) {
if (leaderKeys.length) {
const holes = leaderKeys.map(() => '?').join(', ')
await query(
`UPDATE team_members SET is_leader = (member_key IN (${holes})) WHERE team_id = ?`,
[...leaderKeys, teamId],
)
} else {
await query('UPDATE team_members SET is_leader = 0 WHERE team_id = ?', [teamId])
}
}
async function setMemberLeader(teamId, memberKey, isLeader) {
await query('UPDATE team_members SET is_leader = ? WHERE team_id = ? AND member_key = ?',
[isLeader ? 1 : 0, teamId, memberKey])
}
// ── team_sync_state ────────────────────────────────────────────────────────
async function syncState(moduleId) {
const rows = await query(
`SELECT module_id, last_attempt_at, last_success_at, consecutive_failures, last_error, pending_empty_since
FROM team_sync_state WHERE module_id = ?`,
[moduleId],
)
return rows[0]
}
async function recordAttempt(moduleId) {
await query(
`INSERT INTO team_sync_state (module_id, last_attempt_at) VALUES (?, NOW())
ON DUPLICATE KEY UPDATE last_attempt_at = NOW()`,
[moduleId],
)
}
async function recordFailure(moduleId, error) {
await query(
`INSERT INTO team_sync_state (module_id, last_attempt_at, consecutive_failures, last_error)
VALUES (?, NOW(), 1, ?)
ON DUPLICATE KEY UPDATE
last_attempt_at = NOW(),
consecutive_failures = consecutive_failures + 1,
last_error = VALUES(last_error)`,
[moduleId, String(error || '').slice(0, 500)],
)
}
async function recordSuccess(moduleId) {
await query(
`INSERT INTO team_sync_state (module_id, last_attempt_at, last_success_at, consecutive_failures, last_error)
VALUES (?, NOW(), NOW(), 0, NULL)
ON DUPLICATE KEY UPDATE
last_attempt_at = NOW(), last_success_at = NOW(), consecutive_failures = 0, last_error = NULL`,
[moduleId],
)
}
/** Bumped only when a roster was actually APPLIED — never on a refused call. */
async function markRosterSynced(teamId) {
await query('UPDATE teams SET roster_synced_at = NOW() WHERE id = ?', [teamId])
}
/** §2.4 gate 4's per-Team quarantine. `since = null` clears it. */
async function setMembersEmptySince(teamId, since) {
await query('UPDATE teams SET members_empty_since = ? WHERE id = ?', [since, teamId])
}
/** The §2.4 gate-2 quarantine. `since = null` clears it. */
async function setPendingEmpty(moduleId, since) {
await query(
`INSERT INTO team_sync_state (module_id, pending_empty_since) VALUES (?, ?)
ON DUPLICATE KEY UPDATE pending_empty_since = VALUES(pending_empty_since)`,
[moduleId, since],
)
}
module.exports = {
activeByModule,
allActive,
findActive,
findById,
findBySlug,
slugsLike,
insertTeam,
updateTeam,
archiveTeam,
recount,
markRosterSynced,
setMembersEmptySince,
membersByTeam,
memberKeys,
findMember,
activeByUser,
activeTeamsForUser,
upsertMember,
markDeparted,
setLeaders,
setMemberLeader,
syncState,
recordAttempt,
recordFailure,
recordSuccess,
setPendingEmpty,
}

View File

@@ -0,0 +1,374 @@
// ── The Team read model ────────────────────────────────────────────────────
//
// What the three API tiers are allowed to see (TEAMS.md §2.11), assembled from
// the projection, the resolver and the sync state.
//
// **Two rules shape every function here.**
//
// 1. *Hidden means absent from every public surface* (§2.8.3) — the index, the
// lookup, the roster. Not archived, not deleted, and completely functional for
// its own members. A hidden Team that 404s publicly but answers for a member
// is the intended behaviour, not an inconsistency.
//
// 2. *Staleness is surfaced, never silent* (§2.4). Every public payload carries
// `{ stale, lastSyncAt }`, so a page can say "roster last confirmed 14 minutes
// ago" rather than presenting a stale roster as current. A projection nobody
// can tell is stale is worse than one that is obviously old.
//
// The per-audience FIELD projection of a roster row is the module's, not core's
// (§10.5, §3.3) — the visibility framework and its config are module-owned. This
// phase serves a conservative core projection: a public roster carries in-game
// display names and never a site account id or a game member key. The module's
// rung-aware projection lands with the Team pages in phase 3.
const teamsDb = require('./teams.db')
const teamProvider = require('./teamProvider')
const access = require('./teamAccess.model')
const teamSync = require('./teamSync.model')
// Past this multiple of the poll interval a projection is reported stale. Two
// intervals rather than one, so an ordinary late poll does not make every page
// cry wolf — the threshold has to mean "something is wrong", not "a run is due".
const STALE_INTERVALS = 2
/** The public shape of a Team. Deliberately small. */
function publicTeam(row) {
return {
slug: row.slug,
// What is DISPLAYED may have been overridden by staff; what the row IS never
// changes (§2.2, §2.8.3). Public callers only ever see the former.
name: row.display_name_override || row.name,
abbr: row.abbr,
memberCount: row.member_count,
linkedCount: row.linked_count,
onlineCount: row.online_count,
meta: row.meta ?? null,
status: row.status,
createdAt: row.created_at,
rosterSyncedAt: row.roster_synced_at,
...(row.status === 'archived' ? { archivedAt: row.archived_at, archivedReason: row.archived_reason } : {}),
}
}
/**
* The public shape of a roster row.
*
* `member_key` and `user_id` are both withheld: the first is a game-internal
* identifier and the second names a site account. `linked` answers the only
* question a public page has — whether this character has an account behind it —
* without publishing which one.
*/
function publicMember(row) {
return {
displayName: row.display_name,
rankLabel: row.rank_label,
isLeader: Boolean(row.is_leader),
online: Boolean(row.online),
linked: row.user_id != null,
}
}
/** The admin shape: everything, including what a decision overrode. */
function adminTeam(row) {
return {
id: row.id,
moduleId: row.module_id,
externalId: row.external_id,
slug: row.slug,
name: row.name,
displayName: row.display_name_override || row.name,
displayNameOverride: row.display_name_override,
abbr: row.abbr,
status: row.status,
hidden: Boolean(row.hidden),
hiddenReason: row.hidden_reason,
hiddenTerm: row.hidden_term,
nameReviewedAt: row.name_reviewed_at,
memberCount: row.member_count,
linkedCount: row.linked_count,
onlineCount: row.online_count,
rosterSyncedAt: row.roster_synced_at,
membersEmptySince: row.members_empty_since,
succeededBy: row.succeeded_by,
createdAt: row.created_at,
archivedAt: row.archived_at,
archivedReason: row.archived_reason,
meta: row.meta ?? null,
}
}
function adminMember(row) {
return {
memberKey: row.member_key,
displayName: row.display_name,
userId: row.user_id,
rankLabel: row.rank_label,
isLeader: Boolean(row.is_leader),
isLeaderSynced: Boolean(row.is_leader_synced),
leaderOverride: row.leader_override || null,
online: Boolean(row.online),
status: row.status,
firstSeenAt: row.first_seen_at,
lastSeenAt: row.last_seen_at,
departedAt: row.departed_at,
}
}
/**
* Freshness, as every public payload reports it.
*
* With no provider registered there is nothing to be stale ABOUT, so this reports
* `stale: false` and a null timestamp rather than "very stale" — a deployment
* with no game module is not a broken one.
*/
async function syncStatus() {
const moduleId = teamProvider.providerModuleId()
if (!moduleId) return { stale: false, lastSyncAt: null, configured: false }
const [state, intervalS] = await Promise.all([
teamsDb.syncState(moduleId),
teamSync.intervalSeconds(),
])
const lastSyncAt = state ? state.last_success_at : null
const ageS = lastSyncAt ? (Date.now() - new Date(lastSyncAt).getTime()) / 1000 : Infinity
return {
configured: true,
lastSyncAt,
// Never synced at all is stale: a page must not present an empty projection
// as a confirmed empty shard.
stale: ageS > intervalS * STALE_INTERVALS,
consecutiveFailures: state ? state.consecutive_failures : 0,
}
}
// ── Public ─────────────────────────────────────────────────────────────────
async function listPublic({ limit = 50, offset = 0 } = {}) {
// Every active Team, not just the registered provider's. The rows are core's
// and they outlive the module that filled them: keying the index on a provider
// made an uninstalled module's Teams vanish from /teams while
// /teams/:slug/members still served them in full, because the lookup goes by
// slug. `configured: false` is how a client learns the projection is no longer
// being maintained -- an empty list would have said something untrue instead.
const [rows, sync] = await Promise.all([teamsDb.allActive(), syncStatus()])
const visible = rows.filter((r) => !r.hidden)
return {
teams: visible.slice(offset, offset + limit).map(publicTeam),
total: visible.length,
...sync,
// What the `teams` nav feature flag resolves from (§3.5). True if a provider
// is registered OR any Team exists — the second half matters because Team
// rows outlive the module that filled them, and hiding the nav entry the
// moment a module is uninstalled would make every existing Team page
// unreachable from the site while still answering by URL.
//
// False only when there is nothing and no prospect of anything, which is
// exactly the bare-core case the flag exists for: a link to a permanently
// empty page is worse than no link.
enabled: Boolean(sync.configured) || visible.length > 0,
}
}
/**
* One Team by slug, for a public caller.
*
* An ARCHIVED Team resolves rather than 404ing (§2.2): a bookmark or a Discord
* link from before a rename must land somewhere that explains itself. A HIDDEN
* one does not resolve at all — that is the difference between retired and
* suppressed.
*/
async function getPublic(slug) {
const row = await teamsDb.findBySlug(slug)
if (!row || row.hidden) return null
const sync = await syncStatus()
const successor = row.succeeded_by ? await teamsDb.findById(row.succeeded_by) : null
return {
...publicTeam(row),
// The three props the `team.overview` extension slot is declared with
// (§3.4). A module's slot component runs in the browser and has to know
// WHICH Team it is looking at, in its own vocabulary — `slug` is core's name
// for it and resolves nothing on the module's side.
//
// On this route only, deliberately: the index has no slot and would
// otherwise publish a module-internal identifier per row for nothing. None
// of the three names a person — they are a core row id, a game-side group
// id and a module name, and the identifiers §3.2 withholds (member keys,
// site account ids) are not among them.
id: row.id,
externalId: row.external_id,
moduleId: row.module_id,
...sync,
successor: successor && !successor.hidden
? { slug: successor.slug, name: successor.display_name_override || successor.name }
: null,
}
}
/**
* A Team's roster, projected for the caller's audience rung (§3.3).
*
* The ROW filter is the module's: it owns the visibility framework and its
* configuration (§10.5), and core does not know what a rung is. The FIELD shape
* stays core's — every row that survives goes through `publicMember`, which
* withholds the member key and the user id whatever the module answers. So a
* module can narrow what is published and cannot widen it, and core's "neither is
* published" guarantee does not rest on every module's good behaviour.
*
* **A module that HAS a rung system and cannot answer withholds the roster.** That
* is the one Team call where a refusal is not staleness: leaving a visibility
* answer "alone" would publish the very rows the rungs exist to withhold. A
* deployment with no module, or one whose module does not project at all, is a
* different case entirely — nothing is being withheld there, so the roster is
* served whole at core's public shape (`projects: false`).
*/
/**
* One Team named the way the MODULE names it (§3.4 as amended).
*
* The lookup a module's page needs. A module holds its own identity for a Team —
* a ServUO guild serial — and never core's row id or slug, deliberately: core's
* identifiers are core-internal (§10.3), and handing them out is how a module
* ends up storing them and then depending on them.
*
* Scoped to the naming module's OWN Teams. `module_id` comes from the path and is
* matched, not trusted: it cannot be used to read another module's Team, which
* matters because `external_id` is only unique within a module.
*/
async function getPublicByExternalId(moduleId, externalId) {
const row = await teamsDb.findActive(moduleId, externalId)
if (!row || row.hidden) return null
return { ...publicTeam(row), id: row.id, externalId: row.external_id, moduleId: row.module_id, ...(await syncStatus()) }
}
async function rosterPublic(slug, viewer = null) {
const row = await teamsDb.findBySlug(slug)
if (!row || row.hidden) return null
const [members, sync] = await Promise.all([
access.rosterWithOverrides(row.id),
syncStatus(),
])
// The module gets the rows as it supplied them — this is its own data coming
// home — plus who is asking, which is all a rung decision needs.
const answer = await teamProvider.projectRoster(row.external_id, members, viewer)
let visible
if (answer.ok) visible = members.filter((m) => answer.members.includes(m.member_key))
else if (answer.projects) visible = [] // fail closed: it has rungs and we could not ask
else visible = members // nothing to fail closed ABOUT
return {
members: visible.map(publicMember),
...sync,
rosterSyncedAt: row.roster_synced_at,
// Stated rather than implied. An empty roster has three quite different
// causes — a Team with no members, a rung that shows none, and a module that
// could not be asked — and a page that cannot tell them apart will report the
// last one as the first.
projected: answer.ok,
...(answer.ok || !answer.projects ? {} : { projectionUnavailable: true }),
}
}
// ── Player ─────────────────────────────────────────────────────────────────
/**
* The caller's Teams — membership and grants — each with the REASON it is listed.
*
* The two are read from their own tables and merged here rather than by a query
* that unions them, so the reason survives into the payload. `both` is a real
* state and the UI needs it: a member who also holds a historical grant should
* see membership as the current reason without the grant vanishing.
*
* A hidden Team IS listed here. Suppression is a public-surface rule; a member is
* not a member of the public.
*/
async function listForUser(userId) {
const memberships = await teamsDb.activeTeamsForUser(userId)
const byId = new Map()
for (const row of memberships) {
byId.set(row.id, { ...publicTeam(row), reason: 'membership', isLeader: Boolean(row.is_leader) })
}
// Grants are per Team, so the visible set is walked rather than queried the
// other way round; the population is small (a user's Teams), and it keeps path
// 3's read on path 3's table.
const all = await teamsDb.allActive()
for (const row of all) {
// eslint-disable-next-line no-await-in-loop
const resolved = await access.forumAccess(row.id, userId)
if (!resolved.viaGrant) continue
const existing = byId.get(row.id)
if (existing) existing.reason = 'both'
else byId.set(row.id, { ...publicTeam(row), reason: 'grant', isLeader: false })
}
return { teams: [...byId.values()], ...(await syncStatus()) }
}
/** The caller's own resolved access on one Team. */
async function accessForUser(slug, userId) {
const row = await teamsDb.findBySlug(slug)
if (!row) return null
const resolved = await access.forumAccess(row.id, userId)
return { slug: row.slug, ...resolved }
}
// ── Admin ──────────────────────────────────────────────────────────────────
async function listAdmin({ includeArchived = false } = {}) {
const moduleId = teamProvider.providerModuleId()
const rows = await teamsDb.allActive()
const sync = await syncStatus()
const state = moduleId ? await teamsDb.syncState(moduleId) : null
return {
teams: rows.map(adminTeam),
...sync,
// Shown verbatim on Admin → Teams, including the last error: an operator
// debugging a stale projection needs what the provider actually said.
syncState: state
? {
moduleId: state.module_id,
lastAttemptAt: state.last_attempt_at,
lastSuccessAt: state.last_success_at,
consecutiveFailures: state.consecutive_failures,
lastError: state.last_error,
pendingEmptySince: state.pending_empty_since,
}
: null,
includeArchived,
}
}
async function getAdmin(id) {
const row = await teamsDb.findById(id)
if (!row) return null
const [members, grants, pending] = await Promise.all([
access.rosterWithOverrides(row.id, { includeDeparted: true }),
access.grantLedger(row.id),
// eslint-disable-next-line global-require
require('./teamModeration.model').pendingForTeam(row.id),
])
return {
...adminTeam(row),
members: members.map(adminMember),
grants,
pendingRequests: pending,
}
}
module.exports = {
listPublic,
getPublic,
getPublicByExternalId,
rosterPublic,
listForUser,
accessForUser,
listAdmin,
getAdmin,
syncStatus,
publicTeam,
publicMember,
adminTeam,
adminMember,
STALE_INTERVALS,
}

View File

@@ -31,6 +31,33 @@ const log = require('../utils/logger')('modules')
// such budget on purpose — it delays the listener binding, which is the feature. // such budget on purpose — it delays the listener binding, which is the feature.
const SHUTDOWN_BUDGET_MS = 5000 const SHUTDOWN_BUDGET_MS = 5000
/**
* Nudge the bot to re-pull the slash-command set, from the two places that
* actually change it in a live process: a boot, and an operator disabling a
* module (which `remove` and `purge` both run through).
*
* Enabling and installing are deliberately NOT here — both ask for a restart
* before the module runs, and a command whose handler is not registered yet is a
* command that would answer "unknown". The nudge follows the state, not the
* intention.
*
* Required lazily, and deliberately NOT awaited by either caller: the bot is
* optional infrastructure, and neither a boot nor an operator's disable should
* wait out `botInternalClient`'s 4s timeout because a bot container is wedged.
* Nothing here throws — a failed nudge is a log line, and the bot re-pulls on its
* next `ready` regardless.
*/
async function nudgeBot(why) {
try {
// eslint-disable-next-line global-require
const bot = require('../utils/botInternalClient')
const res = await bot.refreshCommands()
if (!res.ok) log.info('bot did not take the slash-command nudge', { why, error: res.error })
} catch (err) {
log.warn('slash-command nudge failed', { why, message: err.message })
}
}
/** /**
* Run one database call for one module without letting it become everyone's * Run one database call for one module without letting it become everyone's
* failure. Returns null on failure, having logged it. * failure. Returns null on failure, having logged it.
@@ -170,6 +197,23 @@ async function boot({ modules, model } = {}) {
}) })
} }
} }
// The Team reconciler's boot trigger (TEAMS.md §2.4), last — after every module
// has started, because the provider is registered by a module and a module that
// warms a cache in onBoot must be allowed to finish before it is asked anything.
//
// `safe` for the same reason every step above uses it: an unreachable provider
// is a stale projection, never a site that will not start.
// eslint-disable-next-line global-require
await safe('starting the team reconciler', () => require('../model/teams/teamSync.model').start())
// Tell the bot the slash-command set may have moved (TEAMS.md §7.1).
//
// The bot pulls on its own `ready` too, so this is not the only path — it is
// the path for the case `ready` does not cover: the APP restarting while the
// bot stays connected, which is every ordinary redeploy. Without it, a module
// added in that deploy has no command until someone restarts the bot.
nudgeBot('boot')
} }
/** Reject if `fn`'s promise has not settled within `ms`. */ /** Reject if `fn`'s promise has not settled within `ms`. */
@@ -271,6 +315,7 @@ async function stop(id, { modules, model, budgetMs = SHUTDOWN_BUDGET_MS } = {})
} }
await safe(`disabling module "${id}"`, () => rows.disable(id)) await safe(`disabling module "${id}"`, () => rows.disable(id))
nudgeBot(`disable:${id}`)
return { stopped, error } return { stopped, error }
} }

View File

@@ -119,6 +119,8 @@ function buildCtx(id, moduleRoot) {
const uploads = require('../router/v1/admin/imageUpload') const uploads = require('../router/v1/admin/imageUpload')
const activity = require('../model/activity/activity.model') const activity = require('../model/activity/activity.model')
const users = require('../model/users/users.model') const users = require('../model/users/users.model')
const teams = require('../model/teams/teamSync.model')
const teamActivity = require('../model/teams/teamActivity.model')
const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit') const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
/* eslint-enable global-require */ /* eslint-enable global-require */
@@ -174,6 +176,37 @@ function buildCtx(id, moduleRoot) {
// a place nobody looks. `list` stays core's: reading the log is the admin // a place nobody looks. `list` stays core's: reading the log is the admin
// panel's job, and it spans every actor. // panel's job, and it spans every actor.
activity: { log: activity.log }, activity: { log: activity.log },
// Teams (API 1.6.0, TEAMS.md §2.3). Push, to the pull the provider answers.
//
// Both are fire-and-forget by contract. `publish` is an OPTIMISATION — it
// makes a membership change visible at once — and `reconcile` is a REQUEST,
// debounced and never awaited, so a module cannot make its own call site slow
// or turn a background failure into its own error. Correctness comes from the
// reconciler either way; these only decide how soon.
//
// There is deliberately no reader here. A module answers questions about
// Teams; it does not ask them. Every Team table is core-internal (§10.3), and
// a `getTeamRoster` on ctx would be core offering to read back the module's
// own answer — which is the module's data, in the module's own store.
teams: {
publish: (event) => teams.publish(event),
reconcile: (opts) => teams.request(opts),
// §4's activity feed (phase 3). `source` is bound to the CALLING module and
// is never taken from the item — a module writes its own items, under its
// own name, and items name their Team by the module's own `externalId`, so
// there is no id a module could send that reaches another module's Team.
//
// Like `publish` and `reconcile` above, a failure here never reaches the
// module: this is called from inside a game-event handler, and a storage
// problem of core's must not become the module's control flow. A rejected
// write is logged and the promise still resolves.
activity: {
push: (items) => teamActivity.push(id, items).then(
(stored) => { void stored },
(err) => { log.error('ctx.teams.activity.push failed', { module: id, message: err.message }) },
),
},
},
// One function, for one caller: the `admin.users.detail` slot router needs // One function, for one caller: the `admin.users.detail` slot router needs
// the user its prefix names. Narrowed like `ctx.posts` — the users model // the user its prefix names. Narrowed like `ctx.posts` — the users model
// exports creation, role changes and password handling, none of which is a // exports creation, role changes and password handling, none of which is a
@@ -240,6 +273,26 @@ function buildApi(record) {
record.staged.registerNotificationStreams(streams) record.staged.registerNotificationStreams(streams)
}, },
registerAnnounceLeg: record.staged.registerAnnounceLeg, registerAnnounceLeg: record.staged.registerAnnounceLeg,
// The Team provider (API 1.6.0, TEAMS.md §2.3). Unlike every registration
// above, this one is core CALLING THE MODULE and waiting for an answer — the
// same direction registerAnnounceLeg's dispatch already goes, which is why it
// is modelled on it rather than invented. `once` because a module registering
// twice means two answers to a question that has one.
registerTeamProvider(provider) {
once('registerTeamProvider')
record.staged.registerTeamProvider(provider)
},
// Chat-platform slash commands (API 1.6.0, §7.1), live since phase 7. Like
// registerTeamProvider above, the handler this stages is core CALLING THE
// MODULE and waiting for an answer — but from further away than any other
// member: the caller is a bot in another container, holding a Discord
// interaction open on a deadline. `once` for the same reason the two
// registries above take it — a second call is a module changing its mind
// halfway through register(), not adding to what it already said.
registerSlashCommands(commands) {
once('registerSlashCommands')
record.staged.registerSlashCommands(commands)
},
// The two lifecycle hooks (§2.5). Registered here, dispatched from // The two lifecycle hooks (§2.5). Registered here, dispatched from
// lifecycle.js — this file runs with no database and the hooks run with one. // lifecycle.js — this file runs with no database and the hooks run with one.
// Both are optional: a module with no warm-up and nothing to close simply // Both are optional: a module with no warm-up and nothing to close simply

View File

@@ -58,6 +58,28 @@ const legs = new Map()
// a collision with a name attached rather than a silently doubled side effect. // a collision with a name attached rather than a silently doubled side effect.
const postHooks = new Map() const postHooks = new Map()
// { owner, getTeams, getTeamMembers, getTeamLeaders } or null — the Team provider
// (API 1.6.0, TEAMS.md §2.3).
//
// A SINGLE value rather than a Map, unlike every registry above it, and that is
// the contract: one provider per deployment. Teams have one authoritative source
// by construction — two modules answering "what teams exist" would produce two
// disjoint sets under one `teams` table with no rule for merging them, so a
// second registration is a collision rather than an addition.
let teamProvider = null
// command name → { owner, name, description, options, access, handler }. Slash
// commands a registrant has published for the chat platform (API 1.6.0,
// TEAMS.md §7.1).
//
// The DEFINITION and the HANDLER are registered together and the handler runs
// HERE, in the website process; the bot pulls the definitions over the internal
// API and owns every Discord-specific concern. That split is forced — the bot
// container has no `modules` volume, so a module physically cannot put a handler
// in it (§0.4) — and it is also the boundary we would pick anyway: a module
// calling `interaction.deferReply()` would be a module holding a Discord handle.
const slashCommands = new Map()
let coreRegistered = false let coreRegistered = false
// Stream ids that predate the module system and may not carry their owner's // Stream ids that predate the module system and may not carry their owner's
@@ -196,6 +218,31 @@ const announceLegIds = () => [...legs.keys()]
/** One leg, or null. */ /** One leg, or null. */
const announceLeg = (leg) => legs.get(leg) || null const announceLeg = (leg) => legs.get(leg) || null
// ── Team provider (TEAMS.md §2.3) ──────────────────────────────────────────
/** The registered provider, or null when no module supplies one. */
const registeredTeamProvider = () => teamProvider
/** Is there a Team provider at all? Read by the reconciler and the read API. */
const hasTeamProvider = () => teamProvider !== null
// ── Slash commands (TEAMS.md §7.1) ─────────────────────────────────────────
/**
* Every registered command WITHOUT its handler — what `/internal/commands`
* serves to the bot.
*
* The handler is stripped rather than merely un-serialisable-and-ignored: this
* is the object that crosses a process boundary, and the definition half is the
* whole of what the bot is allowed to know. `owner` rides along so the bot can
* name the module in a collision warning.
*/
const slashCommandDefinitions = () =>
[...slashCommands.values()].map(({ handler, ...definition }) => definition)
/** One command, handler included. The dispatcher's lookup. */
const slashCommand = (name) => slashCommands.get(name) || null
// ── Shape checks, run the moment a registrant calls ──────────────────────── // ── Shape checks, run the moment a registrant calls ────────────────────────
// //
// Split from the collision checks below on the same line PR 3 drew through // Split from the collision checks below on the same line PR 3 drew through
@@ -225,6 +272,166 @@ function checkLegShape(entry) {
return { leg, label: label || leg, dispatch, classify } return { leg, label: label || leg, dispatch, classify }
} }
// Three methods are REQUIRED, with no optional half. A provider that could list
// Teams but not their members would leave core holding Teams it can never
// populate, and the reconciler has no sensible behaviour for that — it is not the
// same as a call that fails, which is staleness and already handled (§2.4). A
// module unable to answer one of the three answers `{ ok: false }` at call time.
//
// `projectRoster` is the fourth and is OPTIONAL (TEAMS.md §3.3): it expresses an
// audience model, and a module with no rung system of its own has no opinion to
// express. Omitting it means core serves rosters at its own public shape;
// implementing it means core fails CLOSED when the call cannot be made, so this
// is a member to add deliberately rather than by habit.
//
// `pageUrlTemplate` is the fifth, also OPTIONAL, and is data rather than a method
// — see its own comment below. A module that omits it costs its deployment
// clickable links in Team notification email and nothing else.
//
// The copy is explicit rather than a spread: this object is what core calls, so
// anything not named here is not part of the contract and must not survive
// registration. A method that silently rode along would look implemented from the
// module's side and be invisible from core's.
function checkTeamProviderShape(entry) {
const provider = entry || {}
const out = {}
for (const name of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
if (typeof provider[name] !== 'function') {
throw new Error(`registerTeamProvider: ${name}() is missing or not a function`)
}
out[name] = provider[name]
}
if (provider.projectRoster !== undefined) {
if (typeof provider.projectRoster !== 'function') {
throw new Error('registerTeamProvider: projectRoster must be a function if present')
}
out.projectRoster = provider.projectRoster
}
if (provider.pageUrlTemplate !== undefined) {
out.pageUrlTemplate = checkPageUrlTemplate(provider.pageUrlTemplate)
}
return out
}
// `pageUrlTemplate` is the fifth member and OPTIONAL (TEAMS.md §6.4, phase 6).
//
// **Why a module has to supply this at all.** Teams are a contract primitive with
// no core surface: core owns the tables and the access rules, and the MODULE owns
// the page, because core does not own the word for a Team. That is settled and
// right — but it leaves core unable to write a link to one, and a notification
// email that cannot link to the thread it is about is most of the way to useless.
// So the module that owns the page says where it is.
//
// **A template, not a callback.** Core substitutes `{externalId}` and `{slug}`
// into a relative path and does nothing else with it. A function would be a
// module hook on the mail path — one more thing that can hang or throw between a
// forum reply and the mail about it — to produce a string that never varies.
//
// Validated hard, because the output goes into an email as a link. Relative only:
// a template naming its own host would let a module redirect the site's outbound
// mail somewhere else, and there is no reason for one to.
// One leading slash, and the second character may not be another. `//evil.test/x`
// passes an "is it rooted" check and is a PROTOCOL-RELATIVE url — core prefixing
// its own base makes it harmless today, but a template is a string that ends up
// in an href sooner or later, and this is a character class rather than a
// judgement call about who concatenates it.
const PAGE_URL_TEMPLATE = /^\/(?!\/)[A-Za-z0-9\-._~/{}]*$/
function checkPageUrlTemplate(value) {
if (typeof value !== 'string' || !PAGE_URL_TEMPLATE.test(value)) {
throw new Error(`registerTeamProvider: pageUrlTemplate must be a relative path, got "${value}"`)
}
return value
}
// A slash command's name and description are validated HERE and not only at the
// bot, for a reason worth stating: the bot registers the whole set in a single
// `REST.put(applicationGuildCommands)`, so ONE malformed definition is rejected
// by Discord as a batch and takes every other command down with it — including
// the bot's own. A definition that cannot be registered must therefore fail at
// `register()`, where it belongs to a module that can be named and marked
// failed, rather than at the next `ready` where it looks like the bot is broken.
//
// **Commands are NOT namespaced under their owner, unlike every other id in this
// file.** Discord's name grammar has no `.` in it, so `uo.guild` is unregistrable
// and the prefix rule cannot be expressed. Collisions are caught by first-come
// instead, with the holder named — and the bot resolves the one collision core
// cannot see (a pulled name against its own built-ins) in the module's disfavour.
const SLASH_NAME = /^[a-z0-9_-]{1,32}$/
const SLASH_ACCESS = ['everyone', 'linked', 'staff']
// §7.1.1: `string | integer | boolean | user`, and deliberately nothing else. No
// subcommand groups, autocomplete, attachments, modals or component
// interactions. Those are exactly the features whose semantics do not survive a
// second platform, and admitting one here is how Discord specifics leak into a
// platform-agnostic registration API by accident.
const SLASH_OPTION_TYPES = ['string', 'integer', 'boolean', 'user']
function checkSlashOption(command, option) {
const { name, type, description, required, choices } = option || {}
const where = `registerSlashCommands: ${command}`
if (!SLASH_NAME.test(name || '')) throw new Error(`${where}: bad option name "${name}"`)
if (!SLASH_OPTION_TYPES.includes(type)) {
throw new Error(`${where}: option "${name}" has unsupported type "${type}" (§7.1.1)`)
}
if (!description || description.length > 100) {
throw new Error(`${where}: option "${name}" needs a description of 1-100 characters`)
}
const out = { name, type, description, required: Boolean(required) }
if (choices !== undefined) {
if (!Array.isArray(choices) || !choices.length) {
throw new Error(`${where}: option "${name}" has an empty choices list`)
}
// Only the two option types Discord itself allows choices on. `boolean` is
// already a two-value choice and `user` is a picker; a choices list on
// either is a misunderstanding worth failing rather than dropping.
if (type !== 'string' && type !== 'integer') {
throw new Error(`${where}: option "${name}" is ${type}; choices need string or integer`)
}
out.choices = choices.map((c) => {
if (!c || !c.name || c.value === undefined) {
throw new Error(`${where}: option "${name}" has a choice with no name/value`)
}
return { name: String(c.name), value: c.value }
})
}
return out
}
/**
* `registerSlashCommands([{ name, description, options, access, handler }])`.
*
* `access` is enforced TWICE and this copy is not the gate: the bot sets
* Discord-side default member permissions from it where it can, and the
* dispatcher re-checks it on every call. Client-side is about not advertising a
* dead end; the server is the boundary — the same principle the nav follows.
*/
function checkSlashCommandShape(entry) {
const { name, description, options, access, handler } = entry || {}
if (!SLASH_NAME.test(name || '')) {
throw new Error(`registerSlashCommands: bad command name "${name}" (lowercase, 1-32, no dots)`)
}
if (!description || description.length > 100) {
throw new Error(`registerSlashCommands: ${name} needs a description of 1-100 characters`)
}
if (typeof handler !== 'function') throw new Error(`registerSlashCommands: ${name} has no handler()`)
if (access !== undefined && !SLASH_ACCESS.includes(access)) {
throw new Error(`registerSlashCommands: ${name} has unknown access "${access}"`)
}
if (options !== undefined && !Array.isArray(options)) {
throw new Error(`registerSlashCommands: ${name} options must be an array`)
}
const checked = (options || []).map((o) => checkSlashOption(name, o))
// Discord rejects a definition that puts an optional option before a required
// one, and does it for the whole batch. Sorting silently would change what the
// module wrote; this is the module's own ordering bug and it gets its name.
const firstOptional = checked.findIndex((o) => !o.required)
if (firstOptional !== -1 && checked.slice(firstOptional).some((o) => o.required)) {
throw new Error(`registerSlashCommands: ${name} lists a required option after an optional one`)
}
return { name, description, options: checked, access: access || 'everyone', handler }
}
/** /**
* `registerPostHook({ onSaved, onDeleted })` — both optional, at least one * `registerPostHook({ onSaved, onDeleted })` — both optional, at least one
* required. A registration with neither is a subscription that can never fire, * required. A registration with neither is a subscription that can never fire,
@@ -265,7 +472,9 @@ function checkExtensionShape(slot, router, specFile) {
* `allStreams()` / `announceLeg()` / the slot routers until `apply()`. * `allStreams()` / `announceLeg()` / the slot routers until `apply()`.
*/ */
function stage(owner) { function stage(owner) {
const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [] } const staged = {
owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [], slashCommands: [],
}
return { return {
staged, staged,
registerNotificationStreams(entries) { registerNotificationStreams(entries) {
@@ -281,6 +490,13 @@ function stage(owner) {
registerPostHook(entry) { registerPostHook(entry) {
staged.postHooks.push(checkPostHookShape(entry)) staged.postHooks.push(checkPostHookShape(entry))
}, },
registerTeamProvider(entry) {
staged.teamProviders.push(checkTeamProviderShape(entry))
},
registerSlashCommands(entries) {
if (!Array.isArray(entries)) throw new Error('registerSlashCommands: expected an array')
for (const e of entries) staged.slashCommands.push(checkSlashCommandShape(e))
},
} }
} }
@@ -293,7 +509,15 @@ function stage(owner) {
* PR 2 learned to protect (mounting inside the scan loop made every collision * PR 2 learned to protect (mounting inside the scan loop made every collision
* look like it was with core). * look like it was with core).
*/ */
function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExtensions, postHooks: newPostHooks = [] }) { function apply({
owner,
streams: newStreams,
legs: newLegs,
extensions: newExtensions,
postHooks: newPostHooks = [],
teamProviders: newTeamProviders = [],
slashCommands: newSlashCommands = [],
}) {
// ── validate ── // ── validate ──
const seenStreams = new Set() const seenStreams = new Set()
for (const s of newStreams) { for (const s of newStreams) {
@@ -332,6 +556,19 @@ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExten
throw new Error(`"${owner}" already registered a post hook`) throw new Error(`"${owner}" already registered a post hook`)
} }
if (newTeamProviders.length > 1) throw new Error(`"${owner}" registered more than one team provider`)
if (newTeamProviders.length && teamProvider) {
throw new Error(`a team provider is already registered by "${teamProvider.owner}"`)
}
const seenCommands = new Set()
for (const c of newSlashCommands) {
const held = slashCommands.get(c.name)
if (held) throw new Error(`slash command "/${c.name}" is already registered by "${held.owner}"`)
if (seenCommands.has(c.name)) throw new Error(`slash command "/${c.name}" registered twice`)
seenCommands.add(c.name)
}
// ── commit — nothing below can fail ── // ── commit — nothing below can fail ──
for (const s of newStreams) { for (const s of newStreams) {
streamOwners.set(s.id, owner) streamOwners.set(s.id, owner)
@@ -345,6 +582,8 @@ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExten
entry.router.use(x.router) entry.router.use(x.router)
} }
for (const h of newPostHooks) postHooks.set(owner, h) for (const h of newPostHooks) postHooks.set(owner, h)
for (const p of newTeamProviders) teamProvider = { owner, ...p }
for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c })
} }
// ── Core's own registrations ─────────────────────────────────────────────── // ── Core's own registrations ───────────────────────────────────────────────
@@ -410,6 +649,8 @@ function _reset() {
streamOwners.clear() streamOwners.clear()
legs.clear() legs.clear()
postHooks.clear() postHooks.clear()
teamProvider = null
slashCommands.clear()
coreRegistered = false coreRegistered = false
} }
@@ -427,6 +668,10 @@ module.exports = {
announceLeg, announceLeg,
postHookEntries, postHookEntries,
dispatchPostHook, dispatchPostHook,
registeredTeamProvider,
hasTeamProvider,
slashCommandDefinitions,
slashCommand,
stage, stage,
apply, apply,
registerCore, registerCore,

View File

@@ -9,6 +9,20 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version. // has nothing to say about a website module) and from any module's own version.
// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Additions only, so
// minor: `api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`,
// `ctx.teams.publish(event)`, `ctx.teams.reconcile({ reason })`,
// `ctx.teams.activity.push(items)`, `api.registerSlashCommands([...])`, and the
// client slots `team.overview` / `team.member.row`. module-uo's `coreApi:
// "^1.3.0"` still resolves.
//
// **The number covers the whole surface; the members arrived by phase, and all of
// them have now arrived.** `activity.push` landed with the Team activity feed
// (§4, phase 3) and `registerSlashCommands` with the Discord commands (§7.1,
// phase 7); until each did, it was present and THREW rather than being absent or,
// worse, silently accepting data into a table that did not exist. Nothing in
// 1.6.0 throws any more.
//
// 1.5.0 — a CLIENT addition: `PublicLayout` takes an optional `shell` prop that // 1.5.0 — a CLIENT addition: `PublicLayout` takes an optional `shell` prop that
// renders the page body wrapper core's own pages write by hand (MODULE_API.md // renders the page body wrapper core's own pages write by hand (MODULE_API.md
// §3.4). Minor, not major: §3.4 makes *changing* a kit component's props a major // §3.4). Minor, not major: §3.4 makes *changing* a kit component's props a major
@@ -44,6 +58,6 @@
// an admin action a module performs belongs in core's one audit log, the // an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module // extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor. // reading core's `APP_BASE_URL` for itself. Additions only, so minor.
const MODULE_API_VERSION = '1.5.0' const MODULE_API_VERSION = '1.6.0'
module.exports = { MODULE_API_VERSION } module.exports = { MODULE_API_VERSION }

View File

@@ -9,6 +9,7 @@ const trustedDevices = require('../../../model/trustedDevices/trustedDevices.mod
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model') const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
const registries = require('../../../modules/registries') const registries = require('../../../modules/registries')
const announceJobs = require('../../../model/announceJobs/announceJobs.model') const announceJobs = require('../../../model/announceJobs/announceJobs.model')
const forumSettings = require('../../../model/teams/teamForumSettings.model')
const pushDispatch = require('../../../utils/pushDispatch') const pushDispatch = require('../../../utils/pushDispatch')
const { cleanBody } = require('../../../utils/sanitizeHtml') const { cleanBody } = require('../../../utils/sanitizeHtml')
const { parseJsonSetting } = require('../../../utils/settingsJson') const { parseJsonSetting } = require('../../../utils/settingsJson')
@@ -589,8 +590,64 @@ async function updateSettings(req, res) {
if (!check.ok) return res.status(400).json({ message: check.message }) if (!check.ok) return res.status(400).json({ message: check.message })
updates[key] = JSON.stringify(resolveNavOverrides(parsed, key)) updates[key] = JSON.stringify(resolveNavOverrides(parsed, key))
} }
// The Team-forum controls (TEAMS.md §5.5). Two enum keys and one PRECONDITION —
// the only key on this endpoint whose write depends on something other than its
// own value. `acknowledge` is a request field, not a setting: it is consumed
// here and never stored, because what gets stored is the text VERSION the
// operator accepted, written by recordAck() below.
if (forumSettings.ENABLED_KEY in updates) {
const v = updates[forumSettings.ENABLED_KEY]
if (v !== '0' && v !== '1' && v !== true && v !== false) {
return res.status(400).json({ message: 'Invalid teams_forums_enabled value' })
}
updates[forumSettings.ENABLED_KEY] = v === true || v === '1' ? '1' : '0'
}
const nextImageMode = updates[forumSettings.IMAGES_KEY]
if (forumSettings.IMAGES_KEY in updates) {
if (!forumSettings.IMAGE_MODES.includes(nextImageMode)) {
return res.status(400).json({ message: 'Invalid teams_forum_images value' })
}
// THE GATE (§5.5.5). Server-side, and rejected 400 with the admin UI's
// checkbox bypassed — a checkbox is how the gate is presented, never the gate.
const gate = await forumSettings.assertAcknowledged(nextImageMode, req.body.acknowledge)
if (!gate.ok) return res.status(gate.status).json({ message: gate.error })
}
if (forumSettings.EDIT_WINDOW_KEY in updates) {
// The post edit window (phase 5). An ordinary key with a range, validated
// here rather than left to the model's read-side clamp: a read that silently
// coerces a nonsense value back to the default is right for a hand-edited
// row and wrong for an admin who just typed one, who should be told.
const raw = updates[forumSettings.EDIT_WINDOW_KEY]
const n = Number(raw)
if (!Number.isInteger(n) || n < 0 || n > forumSettings.EDIT_WINDOW_MAX) {
return res.status(400).json({
message: `teams_forum_edit_window_minutes must be a whole number of minutes between 0 and ${forumSettings.EDIT_WINDOW_MAX}`,
})
}
updates[forumSettings.EDIT_WINDOW_KEY] = String(n)
}
{
// The stale-acknowledgement lock: a reworded notice freezes the forum
// settings until it is re-given, and does NOT turn uploads off (§5.5.5).
const writable = await forumSettings.assertSettingsWritable(Object.keys(updates), req.body.acknowledge)
if (!writable.ok) return res.status(writable.status).json({ message: writable.error })
}
const acknowledging = String(req.body.acknowledge ?? '') === forumSettings.ACK_VERSION
delete updates.acknowledge
try { try {
await settings.setMany(updates, req.user.id) await settings.setMany(updates, req.user.id)
if (acknowledging && (nextImageMode === 'uploads' || forumSettings.IMAGES_KEY in updates)) {
// Recorded, not merely displayed: `updated_by`/`updated_at` come from the
// settings schema, and the activity_log row puts it in the staff audit trail
// with the acting admin's IP alongside every other consequential action.
await forumSettings.recordAck(req.user.id)
await activity.log({
req,
action: 'team.forum.uploads.acknowledged',
detail: `${req.user.username} (#${req.user.id}) acknowledged the image-upload notice `
+ `(version ${forumSettings.ACK_VERSION})`,
})
}
// The HTML shell is templated from brand_assets and theme_visual, and is // The HTML shell is templated from brand_assets and theme_visual, and is
// cached per process (utils/htmlShell.js) — a write that can change it has // cached per process (utils/htmlShell.js) — a write that can change it has
// to say so, or the favicon an admin just uploaded appears only after the // to say so, or the favicon an admin just uploaded appears only after the

View File

@@ -31,6 +31,8 @@ const emailRouter = require('./email.router')
const discordBotRouter = require('./discordBot.router') const discordBotRouter = require('./discordBot.router')
const settingsRouter = require('./settings.router') const settingsRouter = require('./settings.router')
const modulesRouter = require('./modules.router') const modulesRouter = require('./modules.router')
const teamsRouter = require('./teams.router')
const teamsVoiceRouter = require('./teamsVoice.router')
const dashboardRouter = require('./dashboard.router') const dashboardRouter = require('./dashboard.router')
const adminRouter = express.Router() const adminRouter = express.Router()
@@ -79,6 +81,21 @@ adminRouter.use('/settings', settingsRouter)
// here alongside the other configuration capabilities, and admin-only per route // here alongside the other configuration capabilities, and admin-only per route
// rather than at this line, so the gate sits next to what it is guarding. // rather than at this line, so the gate sits next to what it is guarding.
adminRouter.use('/modules', modulesRouter) adminRouter.use('/modules', modulesRouter)
// Teams. Staff-wide, like /activity: a moderator runs the reserved-name review
// queue. The three actions that PUBLISH untrusted game-sourced strings are gated
// per request inside the controller, not per route — a moderator may call them,
// and calling them files a request rather than applying one (TEAMS.md §2.9).
// Voice channels (TEAMS.md §7.3, phase 9) are mounted at the more specific prefix
// FIRST, so /teams/voice/* never reaches the teams router's `/:id`.
//
// They live out here rather than inside `teams.router.js` beside the bridge they
// belong with, for a mechanical reason worth recording: that file sits exactly at
// swagger-autogen's per-file limit. At twenty `teamsRouter.*` statements
// `npm run swagger` dies with "invalid array length — heap out of memory"; at
// nineteen it generates. One more statement of any shape tips it, a mount
// included, so the mount is here and the file keeps its nineteen.
adminRouter.use('/teams/voice', teamsVoiceRouter)
adminRouter.use('/teams', teamsRouter)
// The two singletons that own no path segment of their own: GET /dashboard and // The two singletons that own no path segment of their own: GET /dashboard and
// PUT /site-mode. Mounted at the group root, last, exactly where the residual // PUT /site-mode. Mounted at the group root, last, exactly where the residual

View File

@@ -6,6 +6,7 @@ const moderation = require('../../../model/moderation/moderation.model')
const modNotes = require('../../../model/modNotes/modNotes.model') const modNotes = require('../../../model/modNotes/modNotes.model')
const modNotesDb = require('../../../model/modNotes/modNotes.db') const modNotesDb = require('../../../model/modNotes/modNotes.db')
const appeals = require('../../../model/appeals/appeals.model') const appeals = require('../../../model/appeals/appeals.model')
const contentReports = require('../../../model/reports/contentReports.model')
const { isTerminal, isAppealableType, reversalStatusFor } = require('../../../model/appeals/appeals.pure') const { isTerminal, isAppealableType, reversalStatusFor } = require('../../../model/appeals/appeals.pure')
const botInternalClient = require('../../../utils/botInternalClient') const botInternalClient = require('../../../utils/botInternalClient')
const activity = require('../../../model/activity/activity.model') const activity = require('../../../model/activity/activity.model')
@@ -295,6 +296,68 @@ async function getUserAppeals(req, res) {
} }
} }
// ── Content reports (TEAMS.md §5.6) ───────────────────────────────────────
//
// Mounted here rather than under Teams, and that placement is the design: 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. `target_type` is a
// VARCHAR precisely so the next consumer — a wiki page, a news comment — arrives
// as a value in this same queue and not as a second screen.
//
// **This is the only view of the queue that exists.** Team leaders have no
// report-facing surface at all, because the gap §5.6 closes is that a Team's
// leaders are exactly the people who will not report their own Team. Org lead,
// 2026-08-18: reports are site administration only.
async function getContentReports(req, res) {
try {
const { limit, offset } = pageParams(req)
const status = typeof req.query.status === 'string' ? req.query.status : undefined
if (status && status !== 'all' && !contentReports.STATUSES.includes(status)) {
return res.status(400).json({ message: 'Unknown report status' })
}
const teamId = Number(req.query.teamId) || undefined
return res.json({
reports: await contentReports.queue({ status, teamId, limit, offset }),
openCount: await contentReports.openCount(),
})
} catch (err) {
log.error('getContentReports failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
/**
* Move a report along the queue.
*
* Every transition writes `activity_log`, including `dismissed` — especially
* `dismissed`. A queue where acting is audited and declining to act is not is one
* where the cheapest way to make a report disappear leaves no trace, and the
* reports most worth auditing are exactly the ones somebody wanted gone.
*/
async function handleContentReport(req, res) {
try {
const result = await contentReports.handle({
id: Number(req.params.id),
actor: req.user,
status: req.body.status,
note: req.body.note,
})
if (!result.ok) return res.status(result.status || 400).json({ message: result.error })
await activity.log({
req,
action: 'moderation.report.handle',
detail: `${req.user.username} (#${req.user.id}) set report #${req.params.id} to ${req.body.status}`
+ `${req.body.note ? `: "${req.body.note}"` : ''}`,
})
return res.json(result.report)
} catch (err) {
log.error('handleContentReport failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { module.exports = {
getSummary, getSummary,
getRecent, getRecent,
@@ -311,4 +374,6 @@ module.exports = {
claimAppeal, claimAppeal,
resolveAppeal, resolveAppeal,
getUserAppeals, getUserAppeals,
getContentReports,
handleContentReport,
} }

View File

@@ -1,4 +1,5 @@
// Admin · Moderation — the moderation dashboard and the appeals queue. // Admin · Moderation — the moderation dashboard, the appeals queue and the
// member-raised content-report queue (TEAMS.md §5.6).
// //
// Mounted at /api/v1/admin/moderation by admin/index.js, which already applied // Mounted at /api/v1/admin/moderation by admin/index.js, which already applied
// `noindex, isLoggedIn, staffOnly`. Read-only views over the Discord bot's // `noindex, isLoggedIn, staffOnly`. Read-only views over the Discord bot's
@@ -16,6 +17,7 @@ const express = require('express')
const { body, param } = require('express-validator') const { body, param } = require('express-validator')
const moderation = require('./moderation.controller') const moderation = require('./moderation.controller')
const contentReports = require('../../../model/reports/contentReports.model')
const { requireRole } = require('../../../utils/auth') const { requireRole } = require('../../../utils/auth')
const validate = require('../../../middleware/validate') const validate = require('../../../middleware/validate')
@@ -171,4 +173,34 @@ moderationRouter.get(
moderation.getUserAppeals, moderation.getUserAppeals,
) )
// ── Content reports (TEAMS.md §5.6) ───────────────────────────────────────
// Beside appeals rather than under Teams: a staffer working a queue should have
// one place to work. There is no leader-facing counterpart to these two routes
// and there is not meant to be — see the controller.
moderationRouter.get(
'/reports',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'The member-raised content report queue'
// #swagger.description = 'Defaults to the open work (`open` + `reviewing`); filter with ?status=<open|reviewing|actioned|dismissed|all> and ?teamId=, page with ?limit&offset. Each row carries its TARGET already resolved — a posts excerpt and author, a threads title, or an uploads uploader, byte size and SNIFFED mimetype — so triage never means hunting for what was reported. A target that has since been hard-deleted comes back as null and the report still lists: "somebody reported this and by the time we looked it was gone" is a fact worth seeing.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The queue', content: { "application/json": { schema: { type: 'object', properties: { reports: { type: 'array', items: { $ref: "#/components/schemas/ContentReport" } }, openCount: { type: 'integer' } } } } } } */
moderation.getContentReports,
)
moderationRouter.post(
'/reports/:id/handle',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Claim, action or dismiss a content report'
// #swagger.description = 'Handling a report is bookkeeping about the report, not moderation of the content — acting on the content itself is the ordinary forum moderation route, or a site-wide sanction against the account. Every transition writes activity_log, `dismissed` included: a queue where acting is audited and declining to act is not is one where the cheapest way to make a report vanish leaves no trace.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Report id.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['status'], properties: { status: { type: 'string', enum: ['open','reviewing','actioned','dismissed'] }, note: { type: 'string', maxLength: 500 } } } } } } */
/* #swagger.responses[200] = { description: 'The updated report', content: { "application/json": { schema: { $ref: "#/components/schemas/ContentReport" } } } } */
/* #swagger.responses[404] = { description: 'Report not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }),
body('status').isIn(contentReports.STATUSES),
body('note').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }),
validate,
moderation.handleContentReport,
)
module.exports = moderationRouter module.exports = moderationRouter

View File

@@ -0,0 +1,490 @@
// Admin · Teams — the staff surface (TEAMS.md §2.11).
//
// The role split inside this file is the §2.9 gate, and it is enforced HERE
// rather than in the router, because it is not a matter of which routes a role
// may call: a moderator may call all of them, and three of them mean something
// different when they do. `requestOrApply` is what decides, from the caller's
// live role, whether an action applies or is filed for approval.
const teams = require('../../../model/teams/teams.model')
const moderation = require('../../../model/teams/teamModeration.model')
const access = require('../../../model/teams/teamAccess.model')
const teamSync = require('../../../model/teams/teamSync.model')
const teamsDb = require('../../../model/teams/teams.db')
const activity = require('../../../model/activity/activity.model')
const forum = require('../../../model/teams/teamForum.model')
const forumDb = require('../../../model/teams/teamForum.db')
const forumUploadsModel = require('../../../model/teams/teamForumUploads.model')
const forumSettings = require('../../../model/teams/teamForumSettings.model')
const integration = require('../../../model/teams/teamIntegration.model')
const voice = require('../../../model/teams/teamVoice.model')
const voiceSettings = require('../../../model/teams/teamVoiceSettings.model')
const voiceSync = require('../../../utils/teamVoiceSync')
const log = require('../../../utils/logger')('teams')
const fail = (res, err, what) => {
log.error(`admin teams: ${what} failed`, { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
/** Translate a model result's { ok, status, error } into a response. */
const send = (res, result, body = { ok: true }) =>
(result.ok ? res.json({ ...body, ...result }) : res.status(result.status || 400).json({ message: result.error }))
async function listTeams(req, res) {
try {
return res.json(await teams.listAdmin({ includeArchived: req.query.archived === '1' }))
} catch (err) {
return fail(res, err, 'list')
}
}
async function getTeam(req, res) {
try {
const team = await teams.getAdmin(Number(req.params.id))
if (!team) return res.status(404).json({ message: 'Team not found' })
return res.json(team)
} catch (err) {
return fail(res, err, 'get')
}
}
/**
* The operator's escape hatch.
*
* Awaited rather than fire-and-forget: someone who pressed a button is owed the
* outcome, including the provider's error when it refused. `ctx.teams.reconcile()`
* is the debounced, unawaited path — this is not that.
*/
async function resync(req, res) {
try {
const result = await teamSync.reconcileNow('admin')
await activity.log({ req, action: 'team.resync', detail: `${req.user.username} (#${req.user.id}) ran a Team resync` })
return res.json(result)
} catch (err) {
return fail(res, err, 'resync')
}
}
async function archive(req, res) {
try {
const id = Number(req.params.id)
const team = await teamsDb.findById(id)
if (!team) return res.status(404).json({ message: 'Team not found' })
await teamsDb.archiveTeam(id, 'staff')
await activity.log({
req,
action: 'team.archive',
detail: `${req.user.username} (#${req.user.id}) archived team "${team.name}" (#${id})`
+ `${req.body.reason ? `: "${req.body.reason}"` : ''}`,
})
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'archive')
}
}
async function grants(req, res) {
try {
return res.json({ grants: await access.grantLedger(Number(req.params.id)) })
} catch (err) {
return fail(res, err, 'grants')
}
}
// ── Forum: the ledger and the upload attribution view (§5.4) ──────────────
/**
* A Team's forum moderation ledger.
*
* Served whether or not the forum is switched on, unlike every /player forum
* route. The switch guards the forum as a FEATURE — what members can read and
* write — and an operator who turned it off to deal with a problem is precisely
* the operator who needs to see what was moderated (§5.5.1: no data is deleted).
*/
async function forumModeration(req, res) {
try {
const id = Number(req.params.id)
const team = await teamsDb.findById(id)
if (!team) return res.status(404).json({ message: 'Team not found' })
return res.json({ entries: await forum.moderationLedger(id, { limit: 200 }) })
} catch (err) {
return fail(res, err, 'forum moderation')
}
}
/**
* Who uploaded what, when, and how much — across every Team.
*
* This view is the reason §5.5.4 added an attribution table at all: the
* acknowledgement an operator gives before enabling uploads is meaningless if the
* question it makes them responsible for cannot be answered afterwards.
*/
async function forumUploads(req, res) {
try {
return res.json({
uploads: await forumDb.listUploads({
limit: Number(req.query.limit) || 100,
offset: Number(req.query.offset) || 0,
includeDeleted: req.query.deleted === '1',
}),
quota: {
dailyBytes: forumUploadsModel.DAILY_QUOTA_BYTES,
retentionDays: forumUploadsModel.RETENTION_DAYS,
},
})
} catch (err) {
return fail(res, err, 'forum uploads')
}
}
/** The forum settings' own state — the acknowledgement, which is not a public key. */
async function forumSettingsState(req, res) {
try {
return res.json({
enabled: await forumSettings.forumsEnabled(),
imageMode: await forumSettings.imageMode(),
// Served here rather than published as a public setting: the client that
// needs the NUMBER is the settings screen, and the client that needs the
// DECISION already gets it per post as `canEdit`/`editableUntil`. Publishing
// the window would invite a client to compute the permission itself, which
// is the one thing a time-bounded permission must not let the bounded party
// do.
editWindowMinutes: await forumSettings.editWindowMinutes(),
editWindowMax: forumSettings.EDIT_WINDOW_MAX,
acknowledgement: await forumSettings.ackState(),
})
} catch (err) {
return fail(res, err, 'forum settings')
}
}
// ── Leadership overrides (§2.5.1) — NOT gated ─────────────────────────────
async function setLeaderOverride(req, res) {
try {
const id = Number(req.params.id)
const team = await teamsDb.findById(id)
if (!team) return res.status(404).json({ message: 'Team not found' })
const { memberKey, effect, reason } = req.body
await access.setLeaderOverride({
teamId: id,
memberKey,
effect,
actorUserId: req.user.id,
actorUsername: req.user.username,
reason: reason || null,
})
await activity.log({
req,
action: 'team.leader.override',
detail: `${req.user.username} (#${req.user.id}) set a "${effect}" leadership override on `
+ `${memberKey} in team "${team.name}" (#${id})${reason ? `: "${reason}"` : ''}`,
})
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'leader-override')
}
}
async function clearLeaderOverride(req, res) {
try {
const id = Number(req.params.id)
const removed = await access.clearLeaderOverride(id, req.params.memberKey)
if (!removed) return res.status(404).json({ message: 'No such override' })
await activity.log({
req,
action: 'team.leader.override',
detail: `${req.user.username} (#${req.user.id}) cleared the leadership override on `
+ `${req.params.memberKey} in team #${id}`,
})
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'leader-override')
}
}
// ── The three gated actions, plus the ungated hide (§2.9) ─────────────────
async function unhide(req, res) {
try {
return send(res, await moderation.requestOrApply({
req, actor: req.user, teamId: Number(req.params.id), action: 'unhide', reason: req.body.reason,
}))
} catch (err) {
return fail(res, err, 'unhide')
}
}
async function hide(req, res) {
try {
return send(res, await moderation.hide({
req, actor: req.user, teamId: Number(req.params.id), reason: req.body.reason,
}))
} catch (err) {
return fail(res, err, 'hide')
}
}
async function displayName(req, res) {
try {
const { displayName: value, reason } = req.body
// An empty string is how a UI says "clear it", and clearing is its own gated
// action rather than an override set to nothing — otherwise the audit line
// would read as though someone published a blank name.
const action = value ? 'display_name_override' : 'clear_display_name_override'
return send(res, await moderation.requestOrApply({
req, actor: req.user, teamId: Number(req.params.id), action, payload: { displayName: value || null }, reason,
}))
} catch (err) {
return fail(res, err, 'display-name')
}
}
async function reviewQueue(req, res) {
try {
return res.json({ teams: await moderation.reviewQueue() })
} catch (err) {
return fail(res, err, 'review queue')
}
}
async function listRequests(req, res) {
try {
return res.json({ requests: await moderation.listRequests({ status: req.query.status || 'pending' }) })
} catch (err) {
return fail(res, err, 'requests')
}
}
async function decideRequest(req, res) {
try {
return send(res, await moderation.decide({
req, actor: req.user, requestId: Number(req.params.id), status: req.body.status, note: req.body.note,
}))
} catch (err) {
return fail(res, err, 'decide')
}
}
// ── The integration bridge (§7.2, phase 8) — admin only ───────────────────
//
// Admin-only at the ROUTER, unlike everything above it. The §2.9 gate exists
// because a moderator's action publishes untrusted game strings to the public
// site; this is a different risk in the other direction — it decides that
// members-only forum text leaves the site altogether, for a destination core
// cannot see. That is a deployment-configuration decision, and it sits with the
// role that holds the bot token rather than with the queue.
async function integrationConfig(req, res) {
try {
return res.json({
platform: integration.DISCORD,
events: integration.BRIDGEABLE.map((id) => ({ id, membersOnly: integration.isMembersOnly(id) })),
rows: await integration.list(integration.DISCORD),
})
} catch (err) {
return fail(res, err, 'integration config')
}
}
async function saveIntegrationConfig(req, res) {
try {
// `teamId` null is the deployment default and is a legitimate body, so the
// absent-vs-null distinction matters: a PUT with no teamId edits the default.
const teamId = req.body.teamId === undefined || req.body.teamId === null ? null : Number(req.body.teamId)
if (teamId !== null && !(await teamsDb.findById(teamId))) {
return res.status(404).json({ message: 'Team not found' })
}
const row = await integration.save(
{
platform: integration.DISCORD,
teamId,
events: req.body.events,
channelRef: req.body.channelRef,
enabled: req.body.enabled,
membersAck: req.body.membersAck,
},
req.user.id,
)
await activity.log({
req,
action: 'team.integration.save',
detail:
`${req.user.username} (#${req.user.id}) saved the ${integration.DISCORD} bridge for ` +
`${teamId === null ? 'all Teams (default)' : `Team #${teamId}`}: ` +
`${row.enabled ? 'enabled' : 'disabled'}, events [${row.events.join(', ')}]` +
`${row.members_ack ? ', members-only destination acknowledged' : ''}`,
})
return res.json(row)
} catch (err) {
// A validation refusal carries its own status and its own wording — the
// acknowledgement message in particular is the whole explanation of why the
// save was refused, and collapsing it into a 500 would leave the operator
// with a screen that will not save and no reason given.
if (err.status) return res.status(err.status).json({ message: err.message, code: err.code })
return fail(res, err, 'save integration config')
}
}
async function deleteIntegrationConfig(req, res) {
try {
const teamId = req.params.teamId === 'default' ? null : Number(req.params.teamId)
const removed = await integration.remove(integration.DISCORD, teamId)
if (removed === 0) return res.status(404).json({ message: 'No configuration for that Team' })
await activity.log({
req,
action: 'team.integration.delete',
detail:
`${req.user.username} (#${req.user.id}) removed the ${integration.DISCORD} bridge for ` +
`${teamId === null ? 'all Teams (default)' : `Team #${teamId}`}`,
})
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'delete integration config')
}
}
// ── Voice channels (§7.3, phase 9) — admin only ────────────────────────────
//
// Admin-only for the same reason the bridge is: this creates and destroys
// structure in somebody's Discord guild, which is deployment configuration and
// not the kind of decision §2.9 files a request for.
/**
* Everything the panel renders, in one call: the settings, the live rows, and
* the bot's own answer about whether it can do the job.
*
* The preflight is here rather than behind a separate endpoint the panel polls,
* because it is not a detail — an operator whose bot lacks Manage Roles has a
* screen full of controls that cannot work, and finding that out needs to be the
* first thing on the page rather than the result of pressing something.
*/
async function voiceConfig(req, res) {
try {
const [config, rows, flight] = await Promise.all([
voiceSettings.all(),
voice.list(),
// Never fatal: a bot container that is down must not take the settings
// screen with it, since fixing the settings may be exactly why the operator
// came. `preflight` already turns every failure into a `ready: false`.
voiceSync.preflight().catch((err) => ({ ready: false, connected: false, reason: err.message })),
])
return res.json({
platform: voice.PLATFORM,
settings: config,
preflight: flight,
rows,
lastPass: voiceSync.lastPass(),
})
} catch (err) {
return fail(res, err, 'voice config')
}
}
/**
* Save the settings, with one precondition.
*
* **Switching voice ON is refused 422 while the bot cannot act.** The same shape
* §7.2's acknowledgement takes, and for the same reason: a setting that saves and
* then quietly does nothing is worse than one that will not save. Turning it OFF
* is never gated — an operator disabling a feature because it is misbehaving must
* not be blocked by the misbehaviour.
*/
async function saveVoiceConfig(req, res) {
try {
const turningOn = req.body.enabled === true && !(await voiceSettings.enabled())
if (turningOn) {
const flight = await voiceSync.preflight()
if (!flight.ready) {
return res.status(422).json({
message: flight.reason || 'the bot cannot manage channels and roles in this guild yet',
code: 'voice_preflight_failed',
preflight: flight,
})
}
}
const config = await voiceSettings.save(req.body, req.user.id)
await activity.log({
req,
action: 'team.voice.settings',
detail:
`${req.user.username} (#${req.user.id}) saved the Team voice settings: `
+ `${config.enabled ? 'enabled' : 'disabled'}, minimum ${config.minMembers} members, `
+ `${config.graceDays}-day grace window, ${config.staffRoles.length} staff role(s)`,
})
// A save that just switched it on should not wait fifteen minutes for the
// first channel to appear.
if (config.enabled) voiceSync.request({ reason: 'settings saved' })
return res.json(config)
} catch (err) {
if (err.status) return res.status(err.status).json({ message: err.message, code: err.code })
return fail(res, err, 'save voice config')
}
}
/** Run a pass now, awaited, so the operator gets the outcome and not a promise. */
async function voicePass(req, res) {
try {
return res.json(await voiceSync.passNow('admin'))
} catch (err) {
return fail(res, err, 'voice pass')
}
}
/**
* Remove one Team's channel and role now, ignoring the grace window.
*
* The window exists to stop churn on a Team crossing the threshold twice in a
* week; an operator pressing remove is not churn. It is also the only way to
* clean up while voice is switched off, which is the one state where no pass will
* ever reach the row.
*/
async function removeVoice(req, res) {
try {
const teamId = Number(req.params.teamId)
const result = await voiceSync.removeNow(teamId)
if (!result.ok) return res.status(result.status || 400).json({ message: result.message })
await activity.log({
req,
action: 'team.voice.remove',
detail: `${req.user.username} (#${req.user.id}) removed the voice channel and role for Team #${teamId}`,
})
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'remove voice')
}
}
module.exports = {
voiceConfig,
saveVoiceConfig,
voicePass,
removeVoice,
integrationConfig,
saveIntegrationConfig,
deleteIntegrationConfig,
forumModeration,
forumUploads,
forumSettingsState,
listTeams,
getTeam,
resync,
archive,
grants,
setLeaderOverride,
clearLeaderOverride,
unhide,
hide,
displayName,
reviewQueue,
listRequests,
decideRequest,
}

View File

@@ -0,0 +1,331 @@
// Admin · Teams — sync state, the review queue, the approval queue, and the staff
// actions on a Team (TEAMS.md §2.11).
//
// Mounted at /api/v1/admin/teams by admin/index.js, which already applied
// `noindex, isLoggedIn, staffOnly`. Staff-wide, like /admin/activity: a moderator
// runs the review queue, and the three actions that PUBLISH untrusted
// game-sourced strings are gated per request inside the controller rather than
// per route here — a moderator may call them, and calling them files a request
// instead of applying one.
//
// **Declaration order matters in this file.** `/review`, `/requests` and `/resync`
// are literal paths that would otherwise be captured by `/:id`, so every literal
// route is declared before the first :param route. Express is first-match-wins and
// a `/:id` ahead of `/review` would silently turn a queue into a lookup for a Team
// whose id is "review".
const express = require('express')
const { body, param, query } = require('express-validator')
const ctrl = require('./teams.controller')
const validate = require('../../../middleware/validate')
const { requireRole } = require('../../../utils/auth')
const teamsRouter = express.Router()
// The one ADMIN-only corner of a staff-wide router (§7.2, phase 8). Configuring
// where a Team's events leave the site for is not the §2.9 kind of decision a
// moderator files a request for; it is deployment configuration, and it sits with
// the role that already holds the bot token.
// Hoisted rather than written inline, and it has to stay that way: a regex
// LITERAL followed directly by `.test(` makes swagger-autogen's static parser run
// away, and `npm run swagger` dies with "invalid array length — heap out of
// memory" instead of generating a spec. Phase 8 shipped it inline and left the
// generator unable to run at all; the same regex reached through a const (the
// idiom `modules.router.js` already uses) parses fine.
const TEAM_ID = /^[0-9]+$/
const adminOnly = requireRole('admin')
// ── Literal paths, first ───────────────────────────────────────────────────
teamsRouter.get(
'/',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'List Teams with sync state'
// #swagger.description = 'Includes hidden Teams and the modules sync state verbatim — last attempt, last success, consecutive failures and the last error — which is what an operator debugging a stale projection needs.'
// #swagger.parameters['archived'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Set to 1 to include archived Teams.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Teams and sync state', content: { "application/json": { schema: { $ref: "#/components/schemas/AdminTeamList" } } } } */
query('archived').optional().isIn(['0', '1']),
validate,
ctrl.listTeams,
)
teamsRouter.post(
'/resync',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Run a reconciliation now'
// #swagger.description = 'Awaited, so the response carries the outcome including the providers own error when it refused. The four refusal gates still apply — a manual resync cannot make core act on an answer it does not trust.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The reconciliation result', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamResyncResult" } } } } */
ctrl.resync,
)
teamsRouter.get(
'/review',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'The reserved-name review queue'
// #swagger.description = 'Teams auto-hidden because their name matched a reserved term, each showing which term matched. A Team a human has already ruled on leaves the queue and is never re-hidden by a later sweep.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Auto-hidden Teams awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReviewQueue" } } } } */
ctrl.reviewQueue,
)
teamsRouter.get(
'/requests',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'The moderation approval queue'
// #swagger.description = 'Requests filed by moderators for the three actions that publish untrusted game-sourced strings. Decided rows are kept — the record that a moderator asked to publish a name and an admin refused is the part worth having.'
// #swagger.parameters['status'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'pending (default) | approved | rejected | withdrawn | all' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Moderation requests', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamRequestQueue" } } } } */
query('status').optional().isIn(['pending', 'approved', 'rejected', 'withdrawn', 'all']),
validate,
ctrl.listRequests,
)
teamsRouter.post(
'/requests/:id/decide',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Approve or reject a moderation request (admin only)'
// #swagger.description = 'Admin only, checked live against the database rather than from a token claim. Approving applies the action; rejecting keeps the row and changes nothing. A request already decided returns 409, so two admins deciding at once cannot double-apply.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Request id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamDecideRequest" } } } } */
/* #swagger.responses[200] = { description: 'Decided', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[403] = { description: 'Only an admin may decide a request', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such request', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Already decided', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
body('status').isIn(['approved', 'rejected']),
body('note').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.decideRequest,
)
// ── :id paths ──────────────────────────────────────────────────────────────
// Both literal, and both under '/forum' rather than '/:id/forum', so they cannot
// be captured by the '/:id' lookup below — 'forum' is not an integer, but relying
// on the validator to reject it would mean the route table's meaning depended on
// a param check three lines further down.
teamsRouter.get(
'/forum/uploads',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Upload attribution across every Team forum'
// #swagger.description = 'Who uploaded what, when and how much. This view is why an attribution table exists at all: the liability an operator accepts before enabling uploads is meaningless if "who uploaded this" cannot be answered afterwards. Deleted rows are excluded unless `deleted=1` — a soft-deleted upload still has bytes on disk until the sweep runs.'
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size (default 100).' }
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
// #swagger.parameters['deleted'] = { in: 'query', required: false, schema: { type: 'string', enum: ['0','1'] }, description: 'Include soft-deleted uploads.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Uploads with their attribution', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumUploadList" } } } } */
query('limit').optional().isInt({ min: 1, max: 500 }).toInt(),
query('offset').optional().isInt({ min: 0 }).toInt(),
query('deleted').optional().isIn(['0', '1']),
validate,
ctrl.forumUploads,
)
teamsRouter.get(
'/forum/settings',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'The forum switch, the image policy, and the acknowledgements state'
// #swagger.description = 'The two settings themselves ride the ordinary admin settings endpoint and are published to every client; this route adds the one thing that is NOT public — whether the uploads acknowledgement has been given, by whom, and whether the notice has been reworded since. A stale acknowledgement does not disable uploads: it raises a banner and freezes every other forum setting until it is re-given.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Forum settings state', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumSettingsState" } } } } */
ctrl.forumSettingsState,
)
// ── The integration bridge (§7.2) — literal, and before /:id ──────────────
teamsRouter.get(
'/integrations',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'The Team notification bridges configuration (admin only)'
// #swagger.description = 'Every configured destination for the platform, the deployment-wide default first, alongside the events that may be bridged and which of them are members-only. A members-only event carries content nobody outside the Team may read, so enabling one requires an acknowledgement that the destination channel is restricted to that Teams members — recorded here with who gave it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Bridge configuration', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamIntegrationConfig" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
ctrl.integrationConfig,
)
teamsRouter.put(
'/integrations',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Create or replace one bridge destination (admin only)'
// #swagger.description = 'Omit teamId (or send null) to edit the deployment-wide default; a per-Team row overrides it. Enabling a bridge that carries team.forum.post or team.announcement without membersAck is refused 422 — the events are members-only always, and core cannot see a Discord channels permissions, so the operators acknowledgement is the only thing that can stand in for the check. Changing the channel clears a previous acknowledgement: it was given for a destination, not for a row.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The saved row', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamIntegrationRow" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[422] = { description: 'Not enableable — no channel, no events, or a members-only event without the acknowledgement', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('teamId').optional({ nullable: true }).isInt({ min: 1 }).toInt(),
body('events').isArray({ max: 8 }),
body('channelRef').optional({ nullable: true }).isString().trim().isLength({ max: 64 }),
body('enabled').optional().isBoolean().toBoolean(),
body('membersAck').optional().isBoolean().toBoolean(),
validate,
ctrl.saveIntegrationConfig,
)
teamsRouter.delete(
'/integrations/:teamId',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Remove one bridge destination (admin only)'
// #swagger.description = 'Pass the literal string default to remove the deployment-wide row. Removing a per-Team override makes that Team fall back to the default, which is not the same as disabling it — disable the row instead if that is what is wanted.'
// #swagger.parameters['teamId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Team id, or the literal string default.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[404] = { description: 'Nothing configured for that Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
param('teamId').custom((v) => v === 'default' || TEAM_ID.test(v)),
validate,
ctrl.deleteIntegrationConfig,
)
teamsRouter.get(
'/:id',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Get one Team, with its roster, grant ledger and pending requests'
// #swagger.description = 'The roster carries the resolved leadership and what the game actually said, so an override is visible as a decision rather than presented as fact. Departed members are included.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/AdminTeam" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
validate,
ctrl.getTeam,
)
teamsRouter.get(
'/:id/grants',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'The full forum-grant ledger for a Team, revoked rows included'
// #swagger.description = 'The structured record the access resolver reads. The grant/revoke flow itself lands in the forum phase; this is the read side.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The grant ledger', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamGrantLedger" } } } } */
param('id').isInt({ min: 1 }).toInt(),
validate,
ctrl.grants,
)
teamsRouter.get(
'/:id/forum/moderation',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'A Teams forum moderation ledger'
// #swagger.description = 'Append-only, and deliberately separate from the sites mod_actions/appeals pair (§5.3): that one is Discord-sanction-shaped and bot-owned, and routing a guild leader locking a thread through it would make ordinary housekeeping an appealable sanction. `actorRole` records which authority was exercised — a leaders action appears only here, a staffers appears here AND in activity_log. Answers whether or not the forum is switched on.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The ledger, newest first', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumModerationLedger" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
validate,
ctrl.forumModeration,
)
teamsRouter.post(
'/:id/archive',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Archive a Team (staff)'
// #swagger.description = 'Not gated: archiving withdraws a Team from public surfaces rather than publishing anything.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
/* #swagger.responses[200] = { description: 'Archived', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.archive,
)
teamsRouter.post(
'/:id/hide',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Hide a Team from public surfaces (staff)'
// #swagger.description = 'Deliberately NOT gated. Publishing untrusted data needs a second pair of eyes; withdrawing it needs to be possible at once, by whoever is on duty.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
/* #swagger.responses[200] = { description: 'Hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.hide,
)
teamsRouter.post(
'/:id/unhide',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Un-hide a Team — admin applies, moderator requests'
// #swagger.description = 'One of the three gated actions: it publishes a name that tripped the impersonation list. An admin applies it at once; a moderator files a pending request and nothing changes publicly until an admin approves.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
/* #swagger.responses[200] = { description: 'Applied, or filed for approval — see `pending`', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamModerationResult" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.unhide,
)
teamsRouter.post(
'/:id/display-name',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Set or clear a Teams display name — admin applies, moderator requests'
// #swagger.description = 'Gated for the same reason as un-hiding: it substitutes free text into the same public surfaces. Identity is untouched — the Teams `name` stays frozen for the life of the row, and only what is rendered changes. An empty displayName clears the override.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamDisplayNameRequest" } } } } */
/* #swagger.responses[200] = { description: 'Applied, or filed for approval — see `pending`', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamModerationResult" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
body('displayName').optional({ nullable: true }).isString().trim().isLength({ max: 160 }),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.displayName,
)
teamsRouter.post(
'/:id/leader-override',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Grant or deny leadership for one member (staff)'
// #swagger.description = 'Applied on top of the synced value at READ time; the projection is never mutated. That is what makes an override survive a resync — one written into team_members would be undone by the next reconciliation. Not gated: it publishes no game-sourced string.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamLeaderOverrideRequest" } } } } */
/* #swagger.responses[200] = { description: 'Override set', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
body('memberKey').isString().trim().isLength({ min: 1, max: 191 }),
body('effect').isIn(['grant', 'deny']),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.setLeaderOverride,
)
teamsRouter.delete(
'/:id/leader-override/:memberKey',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Clear a leadership override (staff)'
// #swagger.description = 'The member reverts to whatever the game says at the next read; nothing in the projection changes, because nothing in it was ever changed.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
// #swagger.parameters['memberKey'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The modules member key.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Override cleared', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[404] = { description: 'No such override', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
param('memberKey').isString().trim().isLength({ min: 1, max: 191 }),
validate,
ctrl.clearLeaderOverride,
)
module.exports = teamsRouter

View File

@@ -0,0 +1,93 @@
// Admin · Teams · Voice channels (TEAMS.md §7.3, phase 9).
//
// Mounted at /api/v1/admin/teams/voice by `admin/index.js`, which has already
// applied `noindex, isLoggedIn, staffOnly` above it — and which mounts this
// prefix BEFORE `/teams`, so these paths never reach the teams router's `/:id`.
// Every route here adds `adminOnly` on top: this creates and destroys structure
// in somebody's Discord guild, which is deployment configuration and not the §2.9
// kind of decision a moderator files a request for.
//
// **Its own file for a mechanical reason, and the reason is worth recording.**
// These four routes belong beside the notification bridge's three in
// `teams.router.js`, and they started there. That file sits exactly at
// swagger-autogen's per-file limit: at twenty `teamsRouter.*` statements
// `npm run swagger` dies with "invalid array length — heap out of memory", and at
// nineteen it generates. ONE more statement of any shape tips it — a route with no
// annotations at all does, and so does a bare `use`, which is why the mount is in
// `admin/index.js` rather than here in the file it logically belongs to. The same
// probe route added to `discordBot.router.js` generates fine, so the limit is
// per-file and not tree-wide.
//
// So: if this file grows, split it again rather than moving it back.
const express = require('express')
const { body, param } = require('express-validator')
const ctrl = require('./teams.controller')
const validate = require('../../../middleware/validate')
const { requireRole } = require('../../../utils/auth')
const voiceRouter = express.Router()
const adminOnly = requireRole('admin')
// `/sync` before `/:teamId`, the same first-match-wins rule the parent file
// follows: a `:teamId` declared first would turn the pass into a lookup for a Team
// whose id is "sync".
voiceRouter.get(
'/',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Team voice channel configuration and state (admin only)'
// #swagger.description = 'The settings, every provisioned channel with its state and last error, and the bots own preflight — whether it is connected, whether it holds Manage Channels and Manage Roles, and how close the guild is to Discords cap of 250 roles. Access is granted with a role per Team, so that cap is the ceiling on how many Teams can have voice at all.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Voice configuration and state', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoiceConfig" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
ctrl.voiceConfig,
)
voiceRouter.put(
'/',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Save the Team voice settings (admin only)'
// #swagger.description = 'Switching voice on is refused 422 while the bot cannot manage channels and roles in the guild — a setting that saves and then quietly does nothing is worse than one that will not save. Switching it off is never gated, and never tears anything down: existing channels stop being reconciled and are removed one at a time by an operator who means it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The saved settings', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoiceSettings" } } } } */
/* #swagger.responses[422] = { description: 'The bot cannot manage channels or roles yet', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('enabled').optional().isBoolean().toBoolean(),
body('minMembers').optional().isInt({ min: 1, max: 10000 }).toInt(),
body('graceDays').optional().isInt({ min: 0, max: 90 }).toInt(),
body('staffRoles').optional({ nullable: true }),
validate,
ctrl.saveVoiceConfig,
)
voiceRouter.post(
'/sync',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Run a voice reconciliation now (admin only)'
// #swagger.description = 'Awaited, so the response carries the outcome. The three suspensions still apply — a manual pass will not run while voice is off, while the Team projection is stale, or while the bot cannot act — and the response says which one stopped it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The pass result', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoicePassResult" } } } } */
adminOnly,
ctrl.voicePass,
)
voiceRouter.delete(
'/:teamId',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Remove one Teams voice channel and role (admin only)'
// #swagger.description = 'Immediate, ignoring the grace window: the window exists to stop churn on a Team that crosses the threshold twice in a week, and an operator pressing remove is not churn. The channel and the role go together — a role for a channel that no longer exists is a badge for nowhere.'
// #swagger.parameters['teamId'] = { in: 'path', required: true, schema: { type: 'integer' } }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[404] = { description: 'That Team has no voice channel', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
param('teamId').isInt({ min: 1 }).toInt(),
validate,
ctrl.removeVoice,
)
module.exports = voiceRouter

View File

@@ -6,6 +6,7 @@
const pushDevices = require('../../../model/pushDevices/pushDevices.model') const pushDevices = require('../../../model/pushDevices/pushDevices.model')
const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model') const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model')
const registries = require('../../../modules/registries') const registries = require('../../../modules/registries')
const teamPrefs = require('../../../model/teams/teamNotify.model')
const { isAllowedEndpoint } = require('../../../utils/pushDispatch') const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
const log = require('../../../utils/logger')('notifications') const log = require('../../../utils/logger')('notifications')
@@ -78,6 +79,39 @@ async function putSubscriptions(req, res) {
} }
} }
// GET /auth/me/notifications/teams — this user's per-Team preferences, one row
// per Team they could be notified about whether or not they have ever set one.
//
// Not gated on `teams_forums_enabled`: two of the four streams (member joined,
// leadership changed) have nothing to do with the forum, so a deployment with
// forums switched off still has preferences worth showing.
async function getTeamPrefs(req, res) {
try {
return res.json({ teams: await teamPrefs.listPrefs(req.user.id) })
} catch (err) {
log.error('getTeamPrefs', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// PUT /auth/me/notifications/teams — replace the caller's whole preference set.
//
// PUT-the-whole-set, matching the subscriptions endpoint beside it, and the
// `teams` array is REQUIRED even when empty — the Android gotcha in
// docs/android/PLAN.md §11: a DTO field with a default is dropped by kotlinx when
// it equals that default, so clearing the last entry would arrive as a body with
// no array at all and 400. Entries naming a Team the caller is not in are dropped
// by the model rather than refused here (an ordinary race, not a client bug).
async function putTeamPrefs(req, res) {
try {
const { prefs } = await teamPrefs.replacePrefs(req.user.id, req.body.teams)
return res.json({ teams: prefs })
} catch (err) {
log.error('putTeamPrefs', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { module.exports = {
registerDevice, registerDevice,
listDevices, listDevices,
@@ -85,4 +119,6 @@ module.exports = {
getStreams, getStreams,
getSubscriptions, getSubscriptions,
putSubscriptions, putSubscriptions,
getTeamPrefs,
putTeamPrefs,
} }

View File

@@ -13,6 +13,7 @@ const notif = require('./notifications.controller')
const { requireAuth } = require('../../../auth/session.middleware') const { requireAuth } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex') const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate') const validate = require('../../../middleware/validate')
const { EMAIL_MODES } = require('../../../model/teams/teamNotify.model')
const notifRouter = express.Router() const notifRouter = express.Router()
@@ -97,4 +98,39 @@ notifRouter.put(
notif.putSubscriptions, notif.putSubscriptions,
) )
// ── Per-Team preferences (TEAMS.md §6.3, phase 6) ──────────────────────────
//
// The granularity per-stream opt-in cannot express: "I am in five Teams and want
// notifications from one". Opt-OUT for push (no row means notified) and opt-IN
// for email, so a user who never opens this screen is in the state the schema
// documents rather than in one this router has to describe.
notifRouter.get(
'/notifications/teams',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Get the current users per-Team notification preferences'
// #swagger.description = 'One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they have a stored preference for. Defaults are applied server-side: `muted` false, `emailMode` "off".'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Per-Team preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
notif.getTeamPrefs,
)
notifRouter.put(
'/notifications/teams',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Replace the current users per-Team notification preferences'
// #swagger.description = 'Replaces the whole set. The `teams` array is required even when empty. Entries naming a Team the caller has no access to are ignored; the stored set is echoed back.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */
/* #swagger.responses[200] = { description: 'Updated preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('teams').isArray(),
body('teams.*.teamId').isInt({ min: 1 }),
body('teams.*.muted').optional().isBoolean(),
body('teams.*.emailMode').optional().isIn(EMAIL_MODES),
validate,
notif.putTeamPrefs,
)
module.exports = notifRouter module.exports = notifRouter

View File

@@ -1,4 +1,5 @@
const botConfig = require('../../../model/botConfig/botConfig.model') const botConfig = require('../../../model/botConfig/botConfig.model')
const slashCommands = require('../../../utils/slashCommands')
const log = require('../../../utils/logger')('internal') const log = require('../../../utils/logger')('internal')
// GET /internal/bot-config — called by the bot process on its own boot so a // GET /internal/bot-config — called by the bot process on its own boot so a
@@ -16,4 +17,40 @@ async function getBotConfig(req, res) {
} }
} }
module.exports = { getBotConfig } // GET /internal/commands — the registered slash-command definitions, pulled by
// the bot on `ready` and again whenever it is nudged (TEAMS.md §7.1).
//
// `version` is `modules.version()`, the counter every module state change bumps.
// The bot holds the value it registered with and re-PUTs only when it differs,
// which is what makes DEREGISTRATION free: the bot's single whole-set
// `REST.put(applicationGuildCommands)` means a module that is gone is simply
// absent from the next pull, with nobody having to remember to unregister it.
function listCommands(req, res) {
return res.json(slashCommands.definitions())
}
// POST /internal/commands/dispatch — run one command and answer with the
// envelope. Never 500s on a handler's behalf: `dispatch` catches per handler and
// reports `{ ok: false, reason }`, so the bot always has something to render and
// a module's failure is its own.
async function dispatchCommand(req, res) {
const { command, options, platform, platformUserId, guildId } = req.body || {}
if (!command) return res.status(400).json({ ok: false, reason: 'unknown' })
try {
const result = await slashCommands.dispatch({
command,
options: options && typeof options === 'object' ? options : {},
platform: platform || 'discord',
platformUserId,
guildId,
})
return res.json(result)
} catch (err) {
// dispatch() is documented never to throw; if it ever does, that is core's
// bug and not the module's, and it is logged as one.
log.error('internal.dispatchCommand', err)
return res.status(500).json({ ok: false, reason: 'error' })
}
}
module.exports = { getBotConfig, listCommands, dispatchCommand }

View File

@@ -16,4 +16,20 @@ router.get(
ctrl.getBotConfig, ctrl.getBotConfig,
) )
// The slash-command seam (TEAMS.md §7.1). Both stay off the public API and out
// of the OpenAPI document for the same reason /bot-config does: the caller is
// the bot process on the private compose network, and `/internal/*` is not a
// published contract.
router.get(
'/commands',
// #swagger.ignore = true
ctrl.listCommands,
)
router.post(
'/commands/dispatch',
// #swagger.ignore = true
ctrl.dispatchCommand,
)
module.exports = router module.exports = router

View File

@@ -26,6 +26,8 @@ const noindex = require('../../../middleware/noindex')
const accountRouter = require('./account.router') const accountRouter = require('./account.router')
const appealsRouter = require('./appeals.router') const appealsRouter = require('./appeals.router')
const teamsRouter = require('./teams.router')
const teamForumRouter = require('./teamForum.router')
const playerRouter = express.Router() const playerRouter = express.Router()
@@ -39,5 +41,10 @@ playerRouter.use(noindex, requireAuth)
playerRouter.use('/account', accountRouter) playerRouter.use('/account', accountRouter)
playerRouter.use('/appeals', appealsRouter) playerRouter.use('/appeals', appealsRouter)
playerRouter.use('/teams', teamsRouter)
// Same prefix, second router. The forum and the leader-exercised grant flow are a
// different capability from "the caller's own Teams", and splitting them keeps
// each file about one thing; no path in the two collides.
playerRouter.use('/teams', teamForumRouter)
module.exports = playerRouter module.exports = playerRouter

View File

@@ -0,0 +1,485 @@
// Player · Team forums — the participant surface (TEAMS.md §5.4).
//
// Under `/player` rather than `/admin` for the reason §2.11 gives: a forum
// participant may be a plain player, a LEADER is a player, and the `/admin` tier
// gate is `requireRole('admin','editor','moderator')` — putting a leader endpoint
// behind it would mean widening that gate. The leader check is a per-handler
// question on top of the tier's `requireAuth`.
//
// **Two guards run before anything else in this file, in this order:**
//
// 1. `teams_forums_enabled` — off means every route here answers 404, not 403.
// A 403 says "this exists and you may not have it", which advertises a
// feature the operator deliberately turned off; 404 says "not a thing on
// this site", which is the true statement (§5.5.1).
// 2. the §2.5 access resolver — and never a membership check. Both a member and
// a granted non-member reach the forum, and asking `team_members` directly
// here is precisely how paths 1 and 3 drift back together.
//
// Both live in `resolveForum` below so a handler cannot forget either.
const teamsDb = require('../../../model/teams/teams.db')
const access = require('../../../model/teams/teamAccess.model')
const grants = require('../../../model/teams/teamGrants.model')
const forum = require('../../../model/teams/teamForum.model')
const forumSettings = require('../../../model/teams/teamForumSettings.model')
const uploads = require('../../../model/teams/teamForumUploads.model')
const reports = require('../../../model/reports/contentReports.model')
const activity = require('../../../model/activity/activity.model')
const teamNotify = require('../../../utils/teamNotify')
const log = require('../../../utils/logger')('teams')
const STAFF_ROLES = ['admin', 'moderator']
const isStaff = (user) => STAFF_ROLES.includes(user?.role)
const fail = (res, err, what) => {
log.error(`player team forum: ${what} failed`, { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
const send = (res, result, body = { ok: true }) =>
(result.ok ? res.json({ ...body, ...result }) : res.status(result.status || 400).json({ message: result.error }))
/**
* The two guards, plus the Team, plus what this caller may do in it.
*
* Returns null when the caller should see a 404 — which covers three different
* situations on purpose: the forum is switched off, the Team does not exist, and
* the caller has no access to it. A private room's contents and its existence are
* the same secret.
*/
async function resolveForum(req) {
if (!(await forumSettings.forumsEnabled())) return null
const team = await teamsDb.findBySlug(req.params.slug)
if (!team) return null
const resolved = await access.forumAccess(team.id, req.user.id)
const staff = isStaff(req.user)
if (!resolved.allowed && !staff) return null
return {
team,
access: resolved,
staff,
// Staff moderate anywhere; a leader moderates their own Team. `actorRole`
// records WHICH of the two was exercised, and leadership wins when both are
// true: a leader who is also a moderator acting on their own Team is doing
// ordinary housekeeping, and logging it as a staff intervention would put a
// guild's day-to-day tidying into the site's staff-accountability trail.
canModerate: resolved.isLeader || staff,
actorRole: resolved.isLeader ? 'leader' : 'staff',
}
}
/**
* Who is reading, for the read path's per-post `canEdit`.
*
* A separate read of the edit window rather than one folded into `resolveForum`,
* because only the two routes that render posts need it and `resolveForum` runs
* on every route in this file including the ones that never look at a body.
*/
async function viewerFor(ctx, user) {
return {
userId: user.id,
isStaff: ctx.staff,
windowMinutes: await forumSettings.editWindowMinutes(),
}
}
/**
* Fan a new thread or reply out to the Team (TEAMS.md Part 6, phase 6).
*
* **Here rather than in the forum model**, because the model takes an
* already-resolved access decision and reads no membership table by design, and
* the fan-out reads both to compute its recipients. A notification call inside the
* model would make it transitively depend on what its own header says it must not.
*
* **Awaited, and it still cannot fail the request.** `teamNotify.forumPost` catches
* everything and returns; awaiting it costs the response the time of one recipient
* query plus, in `immediate` mode, the SMTP calls — which is why the alternative
* (fire-and-forget) is tempting and wrong here: an un-awaited rejection in an
* Express handler is an unhandled rejection, and the tests would have no moment at
* which to assert the fan-out happened.
*/
async function announce(ctx, actor, notify) {
if (!notify) return
await teamNotify.forumPost({
team: ctx.team,
threadId: notify.threadId,
threadTitle: notify.title,
type: notify.type,
authorUserId: actor.id,
authorName: actor.username,
bodyHtml: notify.bodyHtml,
})
}
// ── threads ────────────────────────────────────────────────────────────────
async function listThreads(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
return res.json({
threads: await forum.listThreads(ctx.team.id, { canModerate: ctx.canModerate }),
// Two capabilities, not one. Phase 4 had a single `canPost` because there
// was a single kind of thread to post; phase 5 opened discussion to every
// participant while announcements stayed with the leaders, so a client that
// read one boolean would have to guess which right it described.
// `canPost` is kept and now means "may open a discussion", which is what a
// 5a client's composer was for — an old client offering the composer to a
// member is a client offering the thing the server now allows.
canPost: true,
canAnnounce: ctx.canModerate,
canModerate: ctx.canModerate,
imageMode: await forumSettings.imageMode(),
})
} catch (err) {
return fail(res, err, 'list threads')
}
}
async function getThread(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
const thread = await forum.getThread(ctx.team.id, Number(req.params.id), {
canModerate: ctx.canModerate,
viewer: await viewerFor(ctx, req.user),
})
if (!thread) return res.status(404).json({ message: 'Not found' })
return res.json({ ...thread, canModerate: ctx.canModerate })
} catch (err) {
return fail(res, err, 'get thread')
}
}
/**
* Open a thread.
*
* **The check splits by TYPE, which is what phase 4 said would happen here.** An
* announcement is leader-authored; a discussion is open to every participant — and
* "participant" means anyone `resolveForum` let through, which includes a granted
* non-member with no game identity at all. That is path 3 doing its job: a forum
* guest reads and writes exactly as a member does, because the alternative is a
* second class of reader whose rights have to be tracked somewhere else.
*
* The default type is still `announcement`, unchanged from 5a: a client that
* posts without saying what it is posting is a 5a client, and a 5a client only
* ever posted announcements. Defaulting the other way would silently turn its
* announcements into discussions.
*/
async function createThread(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
const type = req.body.type || 'announcement'
if (type === 'announcement' && !ctx.canModerate) {
return res.status(403).json({ message: 'Only Team leaders may post announcements' })
}
const { notify, ...result } = await forum.createThread({
team: ctx.team,
actor: req.user,
type,
title: req.body.title,
body: req.body.body,
})
if (result.ok) await announce(ctx, req.user, notify)
return send(res, result)
} catch (err) {
return fail(res, err, 'create thread')
}
}
/** Reply to a discussion thread. Every participant may; the model decides the rest. */
async function createPost(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
const { notify, ...result } = await forum.createPost({
team: ctx.team,
threadId: Number(req.params.id),
actor: req.user,
body: req.body.body,
})
if (result.ok) await announce(ctx, req.user, notify)
return send(res, result)
} catch (err) {
return fail(res, err, 'create post')
}
}
/**
* Edit a post.
*
* A staff edit of somebody else's words is an intervention and writes
* `activity_log` (§5.3) — the one asymmetry that keeps the site's
* staff-accountability trail complete without dragging a member fixing their own
* typo into it. The model reports which case this was; the controller never
* re-derives it, because the two would disagree the day one of them changed.
*/
async function editPost(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
const result = await forum.editPost({
team: ctx.team,
postId: Number(req.params.id),
actor: req.user,
isStaff: ctx.staff,
windowMinutes: await forumSettings.editWindowMinutes(),
body: req.body.body,
})
if (result.ok && result.staffEdit) {
await activity.log({
req,
action: 'team.forum.edit',
detail: `${req.user.username} (#${req.user.id}) edited post #${req.params.id} `
+ `on team "${ctx.team.name}" (#${ctx.team.id})`,
})
}
return send(res, result)
} catch (err) {
return fail(res, err, 'edit post')
}
}
/** Hide, unhide, delete or restore one post. Pin and lock belong to threads. */
async function moderatePost(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
if (!ctx.canModerate) return res.status(403).json({ message: 'Not a leader of this Team' })
const result = await forum.moderatePost({
team: ctx.team,
postId: Number(req.params.id),
action: req.body.action,
actor: req.user,
actorRole: ctx.actorRole,
reason: req.body.reason,
})
if (result.ok && ctx.actorRole === 'staff') {
await activity.log({
req,
action: 'team.forum.moderate',
detail: `${req.user.username} (#${req.user.id}) ${req.body.action} post #${req.params.id} `
+ `on team "${ctx.team.name}" (#${ctx.team.id})`
+ `${req.body.reason ? `: "${req.body.reason}"` : ''}`,
})
}
return send(res, result)
} catch (err) {
return fail(res, err, 'moderate post')
}
}
/**
* Pin / lock / hide / delete a thread, and its opposites.
*
* A staff-exercised action ALSO writes `activity_log`; a leader-exercised one
* writes only the forum ledger (§5.3). That asymmetry is the whole reason the two
* ledgers are cross-referenced rather than merged: routing a guild leader locking
* a thread into the site's sanction pipeline would make ordinary housekeeping an
* appealable staff action.
*/
async function moderateThread(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
if (!ctx.canModerate) return res.status(403).json({ message: 'Not a leader of this Team' })
const result = await forum.moderateThread({
team: ctx.team,
threadId: Number(req.params.id),
action: req.body.action,
actor: req.user,
actorRole: ctx.actorRole,
reason: req.body.reason,
})
if (result.ok && ctx.actorRole === 'staff') {
await activity.log({
req,
action: 'team.forum.moderate',
detail: `${req.user.username} (#${req.user.id}) ${req.body.action} thread #${req.params.id} `
+ `on team "${ctx.team.name}" (#${ctx.team.id})`
+ `${req.body.reason ? `: "${req.body.reason}"` : ''}`,
})
}
return send(res, result)
} catch (err) {
return fail(res, err, 'moderate thread')
}
}
// ── grants (§2.5 path 3, leader-exercised) ─────────────────────────────────
/**
* The grant surface is reachable whether or not the FORUM is on.
*
* Not an oversight: §5.5.1 says a toggle-off revokes no grant and that the rows
* stay authoritative, so a leader must still be able to see and manage them —
* they simply have nothing to grant access to for the moment. What the switch
* guards is the forum's CONTENT, not its access list.
*/
async function listGrants(req, res) {
try {
const team = await teamsDb.findBySlug(req.params.slug)
if (!team) return res.status(404).json({ message: 'Team not found' })
const authority = await grants.authorityFor(team.id, req.user)
if (!authority.may) return res.status(403).json({ message: 'Not a leader of this Team' })
return res.json({
guests: await grants.forumGuests(team.id),
cap: await grants.grantCap(),
as: authority.as,
})
} catch (err) {
return fail(res, err, 'list grants')
}
}
async function createGrant(req, res) {
try {
const team = await teamsDb.findBySlug(req.params.slug)
if (!team) return res.status(404).json({ message: 'Team not found' })
const result = await grants.grant({
team,
actor: req.user,
userId: req.body.userId,
username: req.body.username,
reason: req.body.reason,
})
if (result.ok && result.as === 'staff') {
await activity.log({
req,
action: 'team.forum.grant',
detail: `${req.user.username} (#${req.user.id}) granted forum access to ${result.grantee} `
+ `on team "${team.name}" (#${team.id})`,
})
}
return send(res, result)
} catch (err) {
return fail(res, err, 'create grant')
}
}
async function revokeGrant(req, res) {
try {
const team = await teamsDb.findBySlug(req.params.slug)
if (!team) return res.status(404).json({ message: 'Team not found' })
const result = await grants.revoke({
team,
actor: req.user,
userId: Number(req.params.userId),
reason: req.body.reason,
})
if (result.ok && result.as === 'staff') {
await activity.log({
req,
action: 'team.forum.revoke',
detail: `${req.user.username} (#${req.user.id}) revoked forum access from ${result.grantee} `
+ `on team "${team.name}" (#${team.id})`,
})
}
return send(res, result)
} catch (err) {
return fail(res, err, 'revoke grant')
}
}
// ── abuse reports (§5.6) ───────────────────────────────────────────────────
/**
* File a report about a thread, a post or an upload.
*
* **This is the one write in this file that does nothing to the content.** A
* report opens a queue item and changes no status, no flag and no counter — which
* is what keeps it out of §5.3's moderation ledger, and what stops "report" from
* becoming a way for any participant to hide anything.
*
* It reaches SITE STAFF and nobody else. The hole §5.6 closes is that leaders
* moderate their own Team and a Team's leaders are exactly the people who will
* not report their own Team, so a leader-visible queue would hand a complaint
* about a leader straight back to them. There is deliberately no leader-facing
* view anywhere in this phase (org lead, 2026-08-18).
*
* The route sits behind the same `resolveForum` guard as everything else, so a
* reporter is by construction someone who can already see what they are
* reporting — and the model additionally checks the target really belongs to the
* Team the request came through, or the queue's per-Team filter would be lying.
*/
async function createReport(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
return send(res, await reports.file({
team: ctx.team,
actor: req.user,
targetType: req.body.targetType,
targetId: Number(req.body.targetId),
reason: req.body.reason,
detail: req.body.detail,
}))
} catch (err) {
return fail(res, err, 'create report')
}
}
// ── uploads (§5.5.4) ───────────────────────────────────────────────────────
/**
* The same 404 guard, applied at a second level: these routes answer 404 in any
* image mode but `uploads`, for the same reason the forum's do when the switch is
* off. An upload control the client offers and the server refuses is worse than
* no control, which is why the mode is published (§5.5.6) — but the SERVER is
* still what enforces it.
*/
async function createUpload(req, res) {
try {
if (!(await forumSettings.uploadsEnabled())) return res.status(404).json({ message: 'Not found' })
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
return send(res, await uploads.accept({ team: ctx.team, actor: req.user, file: req.file }))
} catch (err) {
return fail(res, err, 'upload')
}
}
async function deleteUpload(req, res) {
try {
if (!(await forumSettings.uploadsEnabled())) return res.status(404).json({ message: 'Not found' })
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
return send(res, await uploads.remove({
id: Number(req.params.id),
actor: req.user,
isStaff: isStaff(req.user),
}))
} catch (err) {
return fail(res, err, 'delete upload')
}
}
module.exports = {
listThreads,
getThread,
createThread,
createPost,
editPost,
moderateThread,
moderatePost,
listGrants,
createGrant,
revokeGrant,
createUpload,
deleteUpload,
createReport,
}

View File

@@ -0,0 +1,297 @@
// Player · Team forums (TEAMS.md §5.4) and the leader-exercised grant flow (§2.11).
//
// Mounted at /api/v1/player/teams by player/index.js — the SAME prefix as
// teams.router.js, which is why this file exists separately rather than being
// merged into it: that router is the caller's own Team reads, this one is the
// forum and the grants. Express walks both in mount order and no path collides
// ('/:slug/access' vs '/:slug/forum/*' and '/:slug/grants').
//
// Every forum route here 404s while `teams_forums_enabled` is off, and the upload
// routes 404 in any image mode but `uploads`. Both guards are in the controller
// rather than in middleware here, because both need the resolved Team and the
// caller's access to decide, and a guard that answers before those are known
// would have to answer 403 — which is the thing §5.5.1 says not to say.
const express = require('express')
const { body, param } = require('express-validator')
const ctrl = require('./teamForum.controller')
const contentReports = require('../../../model/reports/contentReports.model')
const validate = require('../../../middleware/validate')
const { makeLimiter } = require('../../../middleware/rateLimit')
const { upload } = require('../admin/imageUpload')
const forumRouter = express.Router()
// Writes are rate-limited, reads are not. The caps are per IP and generous enough
// that a Team having a busy afternoon never meets them; what they stop is a script.
const postLimiter = makeLimiter({
windowMs: 10 * 60 * 1000,
max: 20,
label: 'team-forum-post',
message: 'Too many forum posts. Please slow down.',
})
// Tighter than posting, and for a different reason: §2.5 caps how many active
// grants a Team may hold, and this caps how fast a leader may approach that cap.
const grantLimiter = makeLimiter({
windowMs: 10 * 60 * 1000,
max: 15,
label: 'team-forum-grant',
message: 'Too many grant changes. Please slow down.',
})
// Tightest of the three, and §5.6's third rule is why: a report costs the
// reporter nothing and costs a staffer attention, so the queue is the one surface
// here that can be used as a harassment tool. The unique key already stops
// duplicate open reports on one target; this stops a spread of them.
const reportLimiter = makeLimiter({
windowMs: 60 * 60 * 1000,
max: 10,
label: 'team-forum-report',
message: 'Too many reports. Please give staff a chance to look at the ones you have raised.',
})
// Bytes, not requests: the per-account daily quota lives in the uploads model,
// and this is the per-IP flood guard in front of it.
const uploadLimiter = makeLimiter({
windowMs: 10 * 60 * 1000,
max: 30,
label: 'team-forum-upload',
message: 'Too many uploads. Please slow down.',
})
forumRouter.get(
'/:slug/forum/threads',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'List a Team forums threads'
// #swagger.description = 'Reachable by a member (path 1) OR a granted account (path 3) — a forum guest with no linked game identity reads exactly as a member does. Answers 404 while `teams_forums_enabled` is off, and 404 (never 403) to a caller with no access: in a private room, the contents and the existence are the same secret. Hidden threads are included for a leader or staff and for nobody else.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The thread list, with what this caller may do', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumThreadList" } } } } */
/* #swagger.responses[404] = { description: 'Forum off, no such Team, or no access', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.listThreads,
)
forumRouter.post(
'/:slug/forum/threads',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Open a thread — an announcement or a discussion'
// #swagger.description = 'Two kinds of thread, two authorities: an `announcement` is leader-authored and takes no replies, a `discussion` may be opened by any forum participant — including a granted non-member with no game identity, who reads and writes exactly as a member does. `type` defaults to `announcement` so a phase-4 client keeps meaning what it meant. The body is sanitised with the FORUMs own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['title','body'], properties: { type: { type: 'string', enum: ['announcement','discussion'], default: 'announcement' }, title: { type: 'string', maxLength: 200 }, body: { type: 'string' } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, threadId: { type: 'integer' } } } } } } */
/* #swagger.responses[403] = { description: 'Only a leader may post an announcement', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
postLimiter,
param('slug').isString().trim().isLength({ min: 1, max: 191 }),
body('type').optional().isIn(['announcement', 'discussion']),
body('title').isString().trim().isLength({ min: 1, max: 200 }),
body('body').isString().isLength({ min: 1, max: 40000 }),
validate,
ctrl.createThread,
)
forumRouter.get(
'/:slug/forum/threads/:id',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Read one thread and its posts'
// #swagger.description = 'Post bodies are rendered under the CURRENT image policy: `disabled` serves the stored HTML unchanged, `remote` and `uploads` add a core-generated <img> beneath each link that names an image. The stored HTML is identical in all three — flipping the policy back to disabled un-renders every image on every existing post with no data migration.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The thread id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The thread', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumThread" } } } } */
/* #swagger.responses[404] = { description: 'Forum off, no such thread, or no access', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
validate,
ctrl.getThread,
)
forumRouter.post(
'/:slug/forum/threads/:id/moderate',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Pin, lock, hide or delete a thread'
// #swagger.description = 'Leader or staff. Every action writes the Teams own append-only moderation ledger recording WHICH authority was exercised; a staff-exercised one additionally writes activity_log, so the sites staff-accountability trail sees it while a leaders ordinary housekeeping stays out of it. Deliberately not routed through the sites mod_actions/appeals pair, which is Discord-sanction-shaped.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The thread id.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['action'], properties: { action: { type: 'string', enum: ['pin','unpin','lock','unlock','hide','unhide','delete','restore'] }, reason: { type: 'string', maxLength: 255 } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Applied', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, action: { type: 'string' }, threadId: { type: 'integer' } } } } } } */
/* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
body('action').isIn(['pin', 'unpin', 'lock', 'unlock', 'hide', 'unhide', 'delete', 'restore']),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.moderateThread,
)
forumRouter.post(
'/:slug/forum/threads/:id/posts',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Reply to a discussion thread'
// #swagger.description = 'Any forum participant — member or granted guest. Three refusals with deliberately different codes: 404 for a thread that is absent or hidden from this caller, 400 for an announcement (which takes no replies by TYPE, not by being closed), and **409 for a locked thread**, because the request is well formed and the threads state is what refuses. Locked refuses staff too: they hold `unlock`, so unlock/post/relock reaches the same place leaving three ledger rows that say what happened.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The thread id.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['body'], properties: { body: { type: 'string' } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, threadId: { type: 'integer' }, postId: { type: 'integer' } } } } } } */
/* #swagger.responses[400] = { description: 'Announcements do not take replies', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'The thread is locked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
postLimiter,
param('id').isInt({ min: 1 }).toInt(),
body('body').isString().isLength({ min: 1, max: 40000 }),
validate,
ctrl.createPost,
)
forumRouter.patch(
'/:slug/forum/posts/:id',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Edit a post'
// #swagger.description = 'The author inside `teams_forum_edit_window_minutes` (default 15), staff at any time. **The window is decided on the server, twice**: the read path stamps every post with `canEdit`/`editableUntil` so the client knows whether to draw the control, and this route re-derives it from `created_at` before allowing the write — a time-bounded permission must not take its clock from the party it bounds. A staff edit of someone elses post additionally writes `activity_log`; a member fixing their own typo does not.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The post id.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['body'], properties: { body: { type: 'string' } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Edited', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, postId: { type: 'integer' }, threadId: { type: 'integer' } } } } } } */
/* #swagger.responses[403] = { description: 'Not your post, or the edit window has closed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Forum off, no such post, or no access', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
postLimiter,
param('id').isInt({ min: 1 }).toInt(),
body('body').isString().isLength({ min: 1, max: 40000 }),
validate,
ctrl.editPost,
)
forumRouter.post(
'/:slug/forum/posts/:id/moderate',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Hide, unhide, delete or restore a post'
// #swagger.description = 'Leader or staff, and the same append-only ledger the thread route writes — one table with `target_type` of `thread` or `post`, so "everything moderated in this Team" stays one query. `pin` and `lock` are refused by name rather than as an unknown action: they describe a threads place in a list and its openness to replies, neither of which a post has. Deleting a post soft-deletes the images attached to it and restoring brings them back, so the pair is reversible inside the retention window.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The post id.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['action'], properties: { action: { type: 'string', enum: ['hide','unhide','delete','restore'] }, reason: { type: 'string', maxLength: 255 } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Applied', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, action: { type: 'string' }, postId: { type: 'integer' }, threadId: { type: 'integer' } } } } } } */
/* #swagger.responses[400] = { description: 'An action that applies to a thread, not a post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
// **Deliberately the FULL action list, not the four a post accepts.** The model
// answers `pin` with "that applies to a thread, not to a post" and an invented
// action with "unknown", and a validator that allowed only the four would turn
// the first of those into a generic "Validation failed" — leaving the precise
// message reachable only from a unit test. Found on the live rig, where `pin`
// came back as a validation error rather than as the sentence written for it.
// Both are 400 and neither is a security boundary; the difference is entirely
// whether the caller is told which mistake they made.
body('action').isIn(['pin', 'unpin', 'lock', 'unlock', 'hide', 'unhide', 'delete', 'restore']),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.moderatePost,
)
// ── grants ─────────────────────────────────────────────────────────────────
forumRouter.get(
'/:slug/grants',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'The Teams forum guests, and the per-Team cap'
// #swagger.description = 'Leader or staff. Lists ACTIVE grants for accounts that are not members — someone who is both is a member, appears on the roster, and is absent here. Answers regardless of whether the forum is switched on: a toggle-off revokes no grant, so the access list stays manageable while there is temporarily nothing to grant access to.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Forum guests', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumGuestList" } } } } */
/* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.listGrants,
)
forumRouter.post(
'/:slug/grants',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Grant forum access to an account'
// #swagger.description = 'A grant may name ANY Runic Gateway account, including one with no linked game identity — that is the point of it, since letting an unlinked guildmate into the forum must not be a staff ticket. It never writes team_members: the grantee stays off the roster, out of every membership count, and ineligible for external-platform access. A leader is capped at `teams_max_grants_per_team` active grants (default 50) and rate-limited; staff are exempt and are warned on the way past.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', properties: { userId: { type: 'integer' }, username: { type: 'string' }, reason: { type: 'string', maxLength: 255 } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Granted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, grantee: { type: 'string' }, warning: { type: 'string' } } } } } } */
/* #swagger.responses[409] = { description: 'Already granted, or the Team is at its cap', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
grantLimiter,
body('userId').optional().isInt({ min: 1 }).toInt(),
body('username').optional().isString().trim().isLength({ min: 1, max: 32 }),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.createGrant,
)
forumRouter.delete(
'/:slug/grants/:userId',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Revoke forum access'
// #swagger.description = 'The grant row is updated rather than deleted — the table is the audit ledger as well as the current state. A leader may not revoke a STAFF-issued grant, which is what stops a leader undoing a moderation decision; the issuers role is checked at revoke time, so an account that has since lost its staff role stops protecting the grants it made.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['userId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The grantees account id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, grantee: { type: 'string' } } } } } } */
/* #swagger.responses[403] = { description: 'Not a leader, or the grant was staff-issued', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
grantLimiter,
param('userId').isInt({ min: 1 }).toInt(),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.revokeGrant,
)
// ── abuse reports (§5.6) ───────────────────────────────────────────────────
forumRouter.post(
'/:slug/forum/report',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Report a thread, post or upload to site staff'
// #swagger.description = 'The first user-facing report flow core has ever had. **A report is not a moderation action** — it changes nothing about the content and opens a queue item, which is what keeps it out of the Teams moderation ledger and stops "report" becoming a way for any participant to hide anything. It reaches SITE STAFF and nobody else: leaders moderate their own Team, and a Teams leaders are exactly the people who will not report their own Team, so there is no leader-facing view of this queue anywhere. One open report per (target, reporter) — a second answers 409 rather than pretending to succeed — plus an hourly per-IP cap.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['targetType','targetId','reason'], properties: { targetType: { type: 'string', enum: ['team_forum_thread','team_forum_post','team_forum_upload'] }, targetId: { type: 'integer' }, reason: { type: 'string', enum: ['spam','abuse','sexual','illegal','impersonation','other'] }, detail: { type: 'string', maxLength: 500 } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Raised', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, reportId: { type: 'integer' } } } } } } */
/* #swagger.responses[404] = { description: 'Forum off, no access, or the target is not in this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'You already have an open report on this', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
reportLimiter,
body('targetType').isIn(contentReports.TARGET_TYPES),
body('targetId').isInt({ min: 1 }).toInt(),
body('reason').isIn(contentReports.REASONS),
body('detail').optional().isString().trim().isLength({ max: 500 }),
validate,
ctrl.createReport,
)
// ── uploads ────────────────────────────────────────────────────────────────
forumRouter.post(
'/:slug/forum/uploads',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Upload an image to a Team forum'
// #swagger.description = 'Multipart. Answers 404 in any image mode but `uploads`. Beyond the admin upload paths 8 MB cap, mimetype allowlist and random filename, this one assumes a hostile uploader: the leading bytes are sniffed and a mismatch with the declared type is rejected (a clients Content-Type header is a claim, not a fact), a rolling per-account byte quota applies, and every accepted file gets an attribution row naming who uploaded it.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
/* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: 'object', properties: { image: { type: 'string', format: 'binary' } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Stored', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, id: { type: 'integer' }, url: { type: 'string' }, bytes: { type: 'integer' } } } } } } */
/* #swagger.responses[400] = { description: 'Not the image type it claims to be', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Daily upload quota reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
uploadLimiter,
upload.single('image'),
ctrl.createUpload,
)
forumRouter.delete(
'/:slug/forum/uploads/:id',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Remove an uploaded image'
// #swagger.description = 'The uploader or staff. Soft: the row is marked and the bytes go with the nightly sweep after a retention window, so a mis-click is recoverable. Note that disabling uploads later stops new files being accepted and does not remove files already uploaded — that is what this route is for.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The upload id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' } } } } } } */
/* #swagger.responses[403] = { description: 'Not your upload', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
validate,
ctrl.deleteUpload,
)
module.exports = forumRouter

View File

@@ -0,0 +1,28 @@
// Player · Teams — self-scoped reads. Neither handler takes an identity from the
// caller; both use req.user.id, which the tier's requireAuth has already proved.
const teams = require('../../../model/teams/teams.model')
const log = require('../../../utils/logger')('teams')
async function listMine(req, res) {
try {
return res.json(await teams.listForUser(req.user.id))
} catch (err) {
log.error('player teams: list failed', { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getMyAccess(req, res) {
try {
const resolved = await teams.accessForUser(req.params.slug, req.user.id)
if (!resolved) return res.status(404).json({ message: 'Team not found' })
return res.json(resolved)
} catch (err) {
log.error('player teams: access failed', { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { listMine, getMyAccess }

Some files were not shown because too many files have changed in this diff Show More