Compare commits
49 Commits
feature/mo
...
aca4d23179
| Author | SHA1 | Date | |
|---|---|---|---|
| aca4d23179 | |||
| cecd72915f | |||
| b1d3b87cd6 | |||
| 13312d7fc3 | |||
| 5fa88baa0a | |||
| b458c1f46f | |||
| 2a56cbf22a | |||
| 686a214979 | |||
| 26c23bd603 | |||
| 0467c71ea1 | |||
| c970caee16 | |||
| 3f7e61af1c | |||
| 128de0ff2e | |||
| fff14848f1 | |||
| ae0d27cf27 | |||
| 763de66ebb | |||
| 5baada08ef | |||
| 16e31de087 | |||
| 57286594e7 | |||
| cbb7339a3a | |||
| 4ac353684a | |||
| e27c368234 | |||
| fb70013adf | |||
| 11fd9821bf | |||
| 7ed2ac9983 | |||
| 5d9d10b245 | |||
| 203ce9c654 | |||
| 8f4aff6946 | |||
| 03631d7d40 | |||
| aa332eda82 | |||
| 1f175786a7 | |||
| cf2666e5bc | |||
| 8fe2e01466 | |||
| bfd844e8fb | |||
| 92631347f9 | |||
| 8b63ffc725 | |||
| 225663d62e | |||
| e0c961c690 | |||
| 3669696532 | |||
| 953d0c25f6 | |||
| 4ad8b2bb0e | |||
| 1433b60d6c | |||
| 1b692bf624 | |||
| 5410e7e0b3 | |||
| c3120ea3da | |||
| a4da1cc438 | |||
| 12df79430f | |||
| ec2b530be7 | |||
| 8bc09d8b53 |
@@ -28,3 +28,36 @@ TOTP_ISSUER=UOMysticmoon
|
||||
DB_NAME=uomysticmoon
|
||||
DB_USER=uomm
|
||||
COOKIE_NAME=uomm_token
|
||||
|
||||
# ── The UO module — REQUIRED for this instance, not optional like the vars above.
|
||||
#
|
||||
# Core is game-agnostic (docs/website/MODULE_SYSTEM.md): every shard-facing
|
||||
# surface this instance runs — the shard pages, the player's characters, vendors
|
||||
# and houses, Admin → Shard, and the uo-link connection itself — lives in
|
||||
# RunicGateway/Module-uo and reaches the deployment through this line. Without
|
||||
# it, the same image is a perfectly working site with no game on it.
|
||||
#
|
||||
# It is declared here rather than left to Admin → Modules because a compose host
|
||||
# should arrive at its own set at boot, and because this instance has a shard to
|
||||
# be down for: the panel path would leave the site game-less between the image
|
||||
# roll and someone clicking install.
|
||||
#
|
||||
# Bump the version deliberately, and read Module-uo's release notes when you do —
|
||||
# the container resolves this at every start, so changing the version here is
|
||||
# what upgrades the module. A version already unpacked is a no-op that makes no
|
||||
# network call at all.
|
||||
#
|
||||
# This owns what is ON the volume, never whether the module RUNS: disabling it in
|
||||
# Admin → Modules keeps it disabled across restarts even though its files return.
|
||||
MODULES=uo@0.3.0=https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json
|
||||
|
||||
# Module-uo reads these as the DEFAULTS for its uo-link connection, used only
|
||||
# until Admin → Shard has been saved once — after that the encrypted DB config
|
||||
# (`uo_link_config`) is authoritative and these are ignored. Left unset here on
|
||||
# purpose: an instance that has already saved Admin → Shard keeps that config
|
||||
# across the extraction (the module's schema fragment is CREATE TABLE IF NOT
|
||||
# EXISTS, so the existing row is untouched), and setting them would suggest they
|
||||
# still decide something. Module-uo's README documents them.
|
||||
# UOLINK_BASE_URL=
|
||||
# UOLINK_WS_URL=
|
||||
# UOLINK_PROTOCOL=
|
||||
|
||||
@@ -86,9 +86,13 @@ jobs:
|
||||
- name: Build client
|
||||
run: npm run build --prefix client
|
||||
|
||||
bot-install:
|
||||
# No tests/build to run; a clean install still catches a broken or
|
||||
# out-of-sync lockfile before it ships in the bot image.
|
||||
bot-tests:
|
||||
# The install still runs first and still catches a broken or out-of-sync
|
||||
# lockfile before it ships in the bot image — that was this job's whole
|
||||
# purpose until phase 7 (TEAMS.md §7.1) put real logic in the bot: it now
|
||||
# pulls slash-command definitions from the app, merges them into the
|
||||
# whole-set PUT, and runs the defer→dispatch→edit path. None of that is
|
||||
# reachable from the server suite, and phases 8 and 9 add more of it.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -99,3 +103,7 @@ jobs:
|
||||
cache-dependency-path: bot/package-lock.json
|
||||
- name: Install bot deps
|
||||
run: npm ci --prefix bot
|
||||
- name: Run bot tests
|
||||
# Node's built-in runner, no browser and no Discord connection — the
|
||||
# interaction is a fake that records what was called on it.
|
||||
run: npm test --prefix bot
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test test/*.test.js",
|
||||
"dev": "nodemon src/server.js"
|
||||
},
|
||||
"keywords": ["discord", "discord.js"],
|
||||
|
||||
@@ -5,6 +5,7 @@ const { Client, GatewayIntentBits, REST, Routes } = require('discord.js')
|
||||
|
||||
const createLogger = require('../utils/logger')
|
||||
const commands = require('./commands')
|
||||
const dynamicCommands = require('./dynamicCommands')
|
||||
const messageFilter = require('./messageFilter')
|
||||
const scheduler = require('../scheduler/scheduler')
|
||||
const roleMenuHandler = require('./roleMenuHandler')
|
||||
@@ -22,12 +23,46 @@ let status = 'disconnected' // disconnected | connecting | connected | error
|
||||
let statusDetail = null
|
||||
let lastConnectedAt = null
|
||||
|
||||
// One whole-set PUT of the bot's own commands plus whatever the app has
|
||||
// registered (TEAMS.md §7.1). Because it replaces the set rather than adding to
|
||||
// it, DEREGISTRATION is free: a module that is gone is simply absent from the
|
||||
// next pull, and nobody has to remember to take its command back.
|
||||
async function registerCommands(applicationId, targetGuildId) {
|
||||
const dynamic = dynamicCommands.definitions()
|
||||
const rest = new REST({ version: '10' }).setToken(client.token)
|
||||
await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), {
|
||||
body: commands.all.map((c) => c.data),
|
||||
body: [...commands.all.map((c) => c.data), ...dynamic],
|
||||
})
|
||||
log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length })
|
||||
log.info('registered guild slash commands', {
|
||||
guildId: targetGuildId,
|
||||
builtIn: commands.all.length,
|
||||
fromApp: dynamic.length,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-pull the app's commands and re-register the set if it moved.
|
||||
*
|
||||
* Called on `ready` and again whenever the app nudges
|
||||
* (`POST /internal/refresh-commands`). A no-op when nothing changed, so a nudge
|
||||
* per module state change costs one cheap GET rather than a REST.put per
|
||||
* install — and a disconnected bot does nothing at all, since there is no
|
||||
* application to register against until it logs in.
|
||||
*/
|
||||
async function refreshCommands() {
|
||||
const result = await dynamicCommands.pull()
|
||||
if (!result.ok || !result.changed) return result
|
||||
if (!client || !client.isReady()) return result
|
||||
try {
|
||||
await registerCommands(client.application.id, guildId)
|
||||
} catch (err) {
|
||||
// The PUT is all-or-nothing: a definition Discord rejects costs every
|
||||
// command, the built-ins included. Loud, and never fatal to the process.
|
||||
log.error('re-registering slash commands failed — the previous set is still live', {
|
||||
message: err.message,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
@@ -54,6 +89,11 @@ async function stop() {
|
||||
// failure here leaves the client connected but flags an error status.
|
||||
async function onReady() {
|
||||
try {
|
||||
// Pull BEFORE the single PUT, so the app's commands are in the very first
|
||||
// registration rather than appearing a beat later. The pull never throws —
|
||||
// an unreachable app costs the module commands and nothing else, and the
|
||||
// bot's own set registers exactly as it always did.
|
||||
await dynamicCommands.pull()
|
||||
await registerCommands(client.application.id, guildId)
|
||||
await scheduler.start(client)
|
||||
tempRoleSweeper.start(client)
|
||||
@@ -70,14 +110,19 @@ async function onReady() {
|
||||
}
|
||||
}
|
||||
|
||||
// Route an interaction: role-menu handler first, then chat-input slash commands.
|
||||
// Route an interaction: role-menu handler first, then chat-input slash commands
|
||||
// — the bot's own, then the app's. Built-ins are consulted FIRST and the pull
|
||||
// already drops any module name that collides with one, so the two orderings
|
||||
// agree; checking here as well means a name that somehow reached Discord twice
|
||||
// still runs the bot's version rather than whichever registry answered first.
|
||||
async function onInteractionCreate(interaction) {
|
||||
if (await roleMenuHandler.handleInteraction(interaction)) return
|
||||
if (!interaction.isChatInputCommand()) return
|
||||
const command = commands.get(interaction.commandName)
|
||||
if (!command) return
|
||||
if (!command && !dynamicCommands.has(interaction.commandName)) return
|
||||
try {
|
||||
await command.execute(interaction)
|
||||
if (command) await command.execute(interaction)
|
||||
else await dynamicCommands.execute(interaction)
|
||||
} catch (err) {
|
||||
log.error('command execution failed', { command: interaction.commandName, message: err.message })
|
||||
const payload = { content: 'Something went wrong running that command.', ephemeral: true }
|
||||
@@ -146,4 +191,4 @@ function getConnection() {
|
||||
return { client, guildId }
|
||||
}
|
||||
|
||||
module.exports = { start, stop, getStatus, getConnection }
|
||||
module.exports = { start, stop, getStatus, getConnection, refreshCommands }
|
||||
|
||||
242
bot/src/discord/dynamicCommands.js
Normal file
242
bot/src/discord/dynamicCommands.js
Normal 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 }
|
||||
@@ -99,4 +99,26 @@ 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 })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setConfig, getStatus: getStatusHandler, announce, reverseModAction, refreshCommands }
|
||||
|
||||
@@ -11,5 +11,6 @@ router.post('/config', ctrl.setConfig)
|
||||
router.get('/status', ctrl.getStatus)
|
||||
router.post('/announce', ctrl.announce)
|
||||
router.post('/mod-reverse', ctrl.reverseModAction)
|
||||
router.post('/refresh-commands', ctrl.refreshCommands)
|
||||
|
||||
module.exports = router
|
||||
|
||||
76
bot/src/site/appInternalClient.js
Normal file
76
bot/src/site/appInternalClient.js
Normal 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 }
|
||||
77
bot/test/appInternalClient.test.js
Normal file
77
bot/test/appInternalClient.test.js
Normal 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 listener’s origin, not its bot-config path', async () => {
|
||||
const { client, seen } = withFetch(ok({ version: 3, commands: [] }))
|
||||
const res = await client.fetchCommands()
|
||||
assert.equal(seen.url, 'http://app:3001/internal/commands')
|
||||
assert.equal(seen.init.headers['X-Internal-Key'], 'shh')
|
||||
assert.deepEqual(res.data, { version: 3, commands: [] })
|
||||
})
|
||||
|
||||
test('a dispatch names the platform, so the app never has to guess', async () => {
|
||||
const { client, seen } = withFetch(ok({ ok: true, response: {} }))
|
||||
await client.dispatchCommand({ command: 'guild', options: { name: 'KOC' }, platformUserId: '5', guildId: '9' })
|
||||
assert.equal(seen.url, 'http://app:3001/internal/commands/dispatch')
|
||||
assert.deepEqual(JSON.parse(seen.init.body), {
|
||||
command: 'guild', options: { name: 'KOC' }, platform: 'discord', platformUserId: '5', guildId: '9',
|
||||
})
|
||||
})
|
||||
|
||||
// A bot with no internal URL configured is an ordinary deployment state (the
|
||||
// warning already exists in bootstrap.js); it must not become an exception on
|
||||
// every `ready`.
|
||||
test('an unconfigured or unparseable SITE_INTERNAL_URL is a refusal, not a throw', async () => {
|
||||
delete process.env.SITE_INTERNAL_URL
|
||||
const { client } = withFetch(ok({}))
|
||||
assert.equal((await client.fetchCommands()).ok, false)
|
||||
|
||||
delete require.cache[require.resolve('../src/site/appInternalClient')]
|
||||
process.env.SITE_INTERNAL_URL = 'not a url'
|
||||
// eslint-disable-next-line global-require
|
||||
assert.equal((await require('../src/site/appInternalClient').fetchCommands()).ok, false)
|
||||
})
|
||||
|
||||
test('a non-2xx carries its status so the caller can tell "down" from "rejected"', async () => {
|
||||
const { client } = withFetch({ ok: false, status: 401, json: async () => ({}) })
|
||||
const res = await client.fetchCommands()
|
||||
assert.equal(res.ok, false)
|
||||
assert.equal(res.status, 401)
|
||||
})
|
||||
269
bot/test/dynamicCommands.test.js
Normal file
269
bot/test/dynamicCommands.test.js
Normal 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 Discord’s numeric option types, not the contract’s names', async () => {
|
||||
answers([definition({
|
||||
options: [
|
||||
{ name: 'who', type: 'user', description: 'A member', required: true },
|
||||
{ name: 'n', type: 'integer', description: 'How many', choices: [{ name: 'one', value: 1 }] },
|
||||
],
|
||||
})])
|
||||
await dynamic.pull()
|
||||
const [data] = dynamic.definitions()
|
||||
assert.deepEqual(data.options.map((o) => o.type), [6, 4])
|
||||
assert.deepEqual(data.options[1].choices, [{ name: 'one', value: 1 }])
|
||||
assert.equal(data.default_member_permissions, undefined)
|
||||
})
|
||||
|
||||
// `linked` has no Discord equivalent — there is no "has a website account"
|
||||
// predicate — so only `staff` maps, and the app re-checks both regardless.
|
||||
test('only access: staff becomes a Discord permission default', async () => {
|
||||
answers([definition({ access: 'staff' }), definition({ name: 'other', access: 'linked' })])
|
||||
await dynamic.pull()
|
||||
const [staff, linked] = dynamic.definitions()
|
||||
assert.equal(typeof staff.default_member_permissions, 'string')
|
||||
assert.equal(linked.default_member_permissions, undefined)
|
||||
})
|
||||
|
||||
// ── Executing ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('the deferral happens before the dispatch, always', async () => {
|
||||
answers([definition()])
|
||||
await dynamic.pull()
|
||||
let deferredFirst = false
|
||||
const interaction = fakeInteraction()
|
||||
appInternal.dispatchCommand = async () => {
|
||||
deferredFirst = interaction.calls.length === 1 && interaction.calls[0][0] === 'defer'
|
||||
return { ok: true, data: { ok: true, response: { text: 'hi' } } }
|
||||
}
|
||||
await dynamic.execute(interaction)
|
||||
assert.ok(deferredFirst, 'the website is never in Discord’s 3-second ack path')
|
||||
assert.deepEqual(interaction.calls.at(-1), ['edit', { content: 'hi' }])
|
||||
})
|
||||
|
||||
test('the options the member supplied are passed by name, as plain values', async () => {
|
||||
answers([definition({
|
||||
options: [
|
||||
{ name: 'name', type: 'string', description: 'd' },
|
||||
{ name: 'who', type: 'user', description: 'd' },
|
||||
{ name: 'missing', type: 'string', description: 'd' },
|
||||
],
|
||||
})])
|
||||
await dynamic.pull()
|
||||
let sent = null
|
||||
appInternal.dispatchCommand = async (body) => {
|
||||
sent = body
|
||||
return { ok: true, data: { ok: true, response: {} } }
|
||||
}
|
||||
await dynamic.execute(fakeInteraction({ options: { name: 'KOC', who: '42' } }))
|
||||
assert.deepEqual(sent.options, { name: 'KOC', who: '42' })
|
||||
assert.equal(sent.platformUserId, '555')
|
||||
assert.equal(sent.guildId, '999')
|
||||
})
|
||||
|
||||
test('a title or fields render as an embed; a bare text does not', async () => {
|
||||
answers([definition()])
|
||||
await dynamic.pull()
|
||||
appInternal.dispatchCommand = async () => ({
|
||||
ok: true,
|
||||
data: { ok: true, response: { title: 'Knights', text: 'Alliance: Accord', fields: [{ name: 'Members', value: '12' }], url: 'https://site.test/uo/guilds/7' } },
|
||||
})
|
||||
const interaction = fakeInteraction()
|
||||
await dynamic.execute(interaction)
|
||||
const [, payload] = interaction.calls.at(-1)
|
||||
assert.equal(payload.embeds[0].title, 'Knights')
|
||||
assert.equal(payload.embeds[0].description, 'Alliance: Accord')
|
||||
assert.equal(payload.embeds[0].url, 'https://site.test/uo/guilds/7')
|
||||
})
|
||||
|
||||
// §9 answer 5: the public projection, plus a private nudge to link. One reply
|
||||
// cannot be both, so the aside is a follow-up — which is the bot's decision to
|
||||
// make, not the handler's.
|
||||
test('a notice becomes an ephemeral follow-up beside a public answer', async () => {
|
||||
answers([definition()])
|
||||
await dynamic.pull()
|
||||
appInternal.dispatchCommand = async () => ({
|
||||
ok: true,
|
||||
data: { ok: true, response: { text: 'public', notice: 'Link your account' } },
|
||||
})
|
||||
const interaction = fakeInteraction()
|
||||
await dynamic.execute(interaction)
|
||||
assert.deepEqual(interaction.calls.at(-1), ['followUp', { content: 'Link your account', ephemeral: true }])
|
||||
})
|
||||
|
||||
test('a notice is not repeated when the answer was already private', async () => {
|
||||
answers([definition({ access: 'linked' })])
|
||||
await dynamic.pull()
|
||||
appInternal.dispatchCommand = async () => ({
|
||||
ok: true,
|
||||
data: { ok: true, response: { text: 'private', notice: 'Link your account' } },
|
||||
})
|
||||
const interaction = fakeInteraction()
|
||||
await dynamic.execute(interaction)
|
||||
assert.deepEqual(interaction.calls[0], ['defer', { ephemeral: true }])
|
||||
assert.equal(interaction.calls.some(([kind]) => kind === 'followUp'), false)
|
||||
})
|
||||
|
||||
// Every failure path EDITS. Replying to a deferred interaction throws, so a
|
||||
// refusal that used reply() would turn a clean "no" into an unhandled error.
|
||||
// Ephemerality is fixed at the DEFERRAL, which happens before the handler has
|
||||
// said anything — so honouring a per-answer flag needs the deferred reply
|
||||
// withdrawn. The live walk caught the version that ignored it posting "guild
|
||||
// information is not shown to your account" into the channel, which announces a
|
||||
// member's access level to everyone in it.
|
||||
test('a handler asking for privacy gets it, even though the deferral was public', async () => {
|
||||
answers([definition()])
|
||||
await dynamic.pull()
|
||||
appInternal.dispatchCommand = async () => ({
|
||||
ok: true, data: { ok: true, response: { text: 'just for you', ephemeral: true } },
|
||||
})
|
||||
const interaction = fakeInteraction()
|
||||
await dynamic.execute(interaction)
|
||||
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'delete', 'followUp'])
|
||||
assert.deepEqual(interaction.calls.at(-1)[1], { content: 'just for you', ephemeral: true })
|
||||
})
|
||||
|
||||
test('an already-private deferral just edits — no second message', async () => {
|
||||
answers([definition({ access: 'linked' })])
|
||||
await dynamic.pull()
|
||||
appInternal.dispatchCommand = async () => ({
|
||||
ok: true, data: { ok: true, response: { text: 'private', ephemeral: true } },
|
||||
})
|
||||
const interaction = fakeInteraction()
|
||||
await dynamic.execute(interaction)
|
||||
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'edit'])
|
||||
})
|
||||
|
||||
// "You do not have access to that" is about one member and belongs to one
|
||||
// member, whatever the command's usual privacy.
|
||||
test('a refusal is always private', async () => {
|
||||
answers([definition()])
|
||||
await dynamic.pull()
|
||||
appInternal.dispatchCommand = async () => ({ ok: true, data: { ok: false, reason: 'forbidden' } })
|
||||
const interaction = fakeInteraction()
|
||||
await dynamic.execute(interaction)
|
||||
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'delete', 'followUp'])
|
||||
assert.equal(interaction.calls.at(-1)[1].ephemeral, true)
|
||||
})
|
||||
|
||||
test('a refusal is phrased by the bot and edited into the deferred reply', async () => {
|
||||
answers([definition({ access: 'linked' })])
|
||||
await dynamic.pull()
|
||||
appInternal.dispatchCommand = async () => ({
|
||||
ok: true, data: { ok: false, reason: 'forbidden', access: 'linked', isLinked: false },
|
||||
})
|
||||
const interaction = fakeInteraction()
|
||||
await dynamic.execute(interaction)
|
||||
assert.match(interaction.calls.at(-1)[1].content, /Link your Discord account/)
|
||||
// Deferred ephemerally (access: 'linked'), so the refusal is one edit and no
|
||||
// withdrawal — replying twice to a deferred interaction is what throws.
|
||||
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'edit'])
|
||||
})
|
||||
|
||||
test('an unreachable app is the same sentence to the member and a different line in the log', async () => {
|
||||
answers([definition()])
|
||||
await dynamic.pull()
|
||||
appInternal.dispatchCommand = async () => ({ ok: false, error: 'timeout' })
|
||||
const interaction = fakeInteraction()
|
||||
await dynamic.execute(interaction)
|
||||
assert.match(interaction.calls.at(-1)[1].content, /Something went wrong/)
|
||||
assert.equal(interaction.calls.at(-1)[1].ephemeral, true)
|
||||
})
|
||||
|
||||
test('an interaction for a command the app no longer serves is left alone', async () => {
|
||||
answers([definition()])
|
||||
await dynamic.pull()
|
||||
const interaction = fakeInteraction({ commandName: 'gone' })
|
||||
assert.equal(await dynamic.execute(interaction), false)
|
||||
assert.deepEqual(interaction.calls, [], 'nothing is deferred for a command that is not ours')
|
||||
})
|
||||
@@ -42,10 +42,12 @@ import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||
import UserDetail from './routes/admin/views/UserDetail.jsx'
|
||||
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
|
||||
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
|
||||
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
|
||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||
import Appeals from './routes/admin/views/Appeals.jsx'
|
||||
import ContentReports from './routes/admin/views/ContentReports.jsx'
|
||||
|
||||
// Player portal
|
||||
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
||||
@@ -55,6 +57,8 @@ import ResetPassword from './routes/player/ResetPassword.jsx'
|
||||
import AcceptInvite from './routes/player/AcceptInvite.jsx'
|
||||
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
|
||||
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
||||
import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
|
||||
import Unsubscribe from './routes/player/Unsubscribe.jsx'
|
||||
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
||||
|
||||
export default function App() {
|
||||
@@ -162,6 +166,7 @@ export default function App() {
|
||||
<Route index element={<Moderation />} />
|
||||
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||
<Route path="appeals" element={<Appeals />} />
|
||||
<Route path="reports" element={<ContentReports />} />
|
||||
</Route>
|
||||
<Route path="activity" element={<ActivityAdmin />} />
|
||||
<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
|
||||
core's routes, above the module-supplied ones below. */}
|
||||
<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 />} />
|
||||
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
||||
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/reset/:token" element={<ResetPassword />} />
|
||||
<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
|
||||
element={
|
||||
<RequirePlayer>
|
||||
@@ -211,6 +224,7 @@ export default function App() {
|
||||
<Route path="/player" element={<PlayerIndex />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
<Route path="/account/notifications" element={<PlayerNotifications />} />
|
||||
{/* Installed modules' player-portal pages, at /player/<id>/…. This
|
||||
group's own routes are absolute (its layout route has no path),
|
||||
so the prefix is written here rather than inherited — the one
|
||||
|
||||
@@ -133,6 +133,80 @@ export const api = {
|
||||
return req(`/public/wiki${withQs(s)}`)
|
||||
},
|
||||
wikiCategories: () => req('/public/wiki/categories'),
|
||||
|
||||
// ----- Teams (TEAMS.md §2.11, §4.3) -----
|
||||
//
|
||||
// Only the two calls CORE's own client makes. Core renders no Team pages — the
|
||||
// vocabulary belongs to whichever module owns the surface — so the index, the
|
||||
// roster and the player list are not here; a module that renders those calls
|
||||
// the same public API from its own client.
|
||||
//
|
||||
// The lookup exists because a module names a Team in its own terms and core
|
||||
// keys the feed by slug. Resolving that is core's job precisely so a module
|
||||
// never has to hold core's identifiers.
|
||||
teamByExternalId: (moduleId, externalId) =>
|
||||
req(`/public/teams/by-external/${encodeURIComponent(moduleId)}/${encodeURIComponent(externalId)}`),
|
||||
teamActivity: (slug, opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.limit != null) qs.set('limit', String(opts.limit))
|
||||
if (opts.offset != null) qs.set('offset', String(opts.offset))
|
||||
return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`)
|
||||
},
|
||||
// The Team FORUM, under /player because a participant may be a plain player and
|
||||
// a leader is a player (TEAMS.md §2.11). Core's, for the same reason the feed is
|
||||
// core's: only core resolves whether this viewer is inside the Team, and the
|
||||
// member/guest split is a security boundary. The module renders the PLACE.
|
||||
teamForumThreads: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`),
|
||||
teamForumThread: (slug, id) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}`),
|
||||
teamForumPost: (slug, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
|
||||
teamForumModerate: (slug, id, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }),
|
||||
// Phase 5 ("5b"). A reply, an edit and post-level moderation are separate
|
||||
// routes from their thread-level cousins rather than the same route with a
|
||||
// target kind, because they answer to different rules: a reply is refused by a
|
||||
// lock, an edit by a clock, and `pin`/`lock` mean nothing to a post at all.
|
||||
teamForumReply: (slug, threadId, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${threadId}/posts`, { method: 'POST', body }),
|
||||
teamForumEditPost: (slug, postId, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}`, { method: 'PATCH', body }),
|
||||
teamForumModeratePost: (slug, postId, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}/moderate`, { method: 'POST', body }),
|
||||
// The report goes to SITE STAFF, never to the Team's leaders — the whole point
|
||||
// of it is a path that routes around a Team's own leadership (TEAMS.md §5.6).
|
||||
// There is no leader-facing counterpart to this call and there should not be.
|
||||
teamForumReport: (slug, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/report`, { method: 'POST', body }),
|
||||
teamForumUpload: (slug, file) => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
return req(`/player/teams/${encodeURIComponent(slug)}/forum/uploads`, { method: 'POST', body: fd, raw: true })
|
||||
},
|
||||
teamGrantList: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/grants`),
|
||||
teamGrantAdd: (slug, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }),
|
||||
teamGrantRevoke: (slug, userId) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- notifications (TEAMS.md Part 6) -----
|
||||
//
|
||||
// Under /auth/me rather than /player: these are role-agnostic self-service, the
|
||||
// same rule that put the forum under /player rather than behind a staff gate.
|
||||
// The streams catalog and the per-stream subscriptions were built for the app
|
||||
// and had no web consumer at all until phase 6 gave them one.
|
||||
notificationStreams: () => req('/auth/me/notifications/streams'),
|
||||
notificationSubscriptions: () => req('/auth/me/notifications/subscriptions'),
|
||||
// `streams` is always sent, empty array included — the endpoint requires the
|
||||
// field, so clearing the last subscription must not become an absent key.
|
||||
setNotificationSubscriptions: (streams) =>
|
||||
req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }),
|
||||
teamNotificationPrefs: () => req('/auth/me/notifications/teams'),
|
||||
setTeamNotificationPrefs: (teams) =>
|
||||
req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }),
|
||||
// Unauthenticated, and the one write in the public tier: the caller is reading
|
||||
// their mail, not signed in. Always resolves 200 whatever the token was.
|
||||
unsubscribeTeam: (token) =>
|
||||
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
|
||||
wikiTags: () => req('/public/wiki/tags'),
|
||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
||||
@@ -247,8 +321,50 @@ export const api = {
|
||||
setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
|
||||
restartServer: () => req('/admin/modules/restart', { method: 'POST' }),
|
||||
|
||||
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
|
||||
// different depending on who calls them: for a moderator, unhide and
|
||||
// setTeamDisplayName file a request and the response says `pending: true`.
|
||||
// The caller does not choose — the server decides from the live role — so
|
||||
// there is deliberately no "asRequest" argument to get wrong.
|
||||
listTeams: () => req('/admin/teams'),
|
||||
getTeam: (id) => req(`/admin/teams/${id}`),
|
||||
resyncTeams: () => req('/admin/teams/resync', { method: 'POST' }),
|
||||
archiveTeam: (id, reason) => req(`/admin/teams/${id}/archive`, { method: 'POST', body: { reason } }),
|
||||
teamGrants: (id) => req(`/admin/teams/${id}/grants`),
|
||||
hideTeam: (id, reason) => req(`/admin/teams/${id}/hide`, { method: 'POST', body: { reason } }),
|
||||
unhideTeam: (id, reason) => req(`/admin/teams/${id}/unhide`, { method: 'POST', body: { reason } }),
|
||||
setTeamDisplayName: (id, displayName, reason) =>
|
||||
req(`/admin/teams/${id}/display-name`, { method: 'POST', body: { displayName, reason } }),
|
||||
setTeamLeaderOverride: (id, body) =>
|
||||
req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }),
|
||||
clearTeamLeaderOverride: (id, memberKey) =>
|
||||
req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }),
|
||||
teamForumSettings: () => req('/admin/teams/forum/settings'),
|
||||
teamForumUploads: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.deleted) qs.set('deleted', '1')
|
||||
return req(`/admin/teams/forum/uploads${withQs(qs.toString())}`)
|
||||
},
|
||||
teamForumModeration: (id) => req(`/admin/teams/${id}/forum/moderation`),
|
||||
teamReviewQueue: () => req('/admin/teams/review'),
|
||||
teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`),
|
||||
decideTeamRequest: (id, status, note) =>
|
||||
req(`/admin/teams/requests/${id}/decide`, { method: 'POST', body: { status, note } }),
|
||||
|
||||
// ----- moderation dashboard (admin + moderator) -----
|
||||
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||
// The content-report queue (TEAMS.md §5.6). Under moderation rather than
|
||||
// under Teams because a staffer working a queue should have one place to
|
||||
// work, and a report about a forum post is the same job as a report about
|
||||
// anything else — which is also why `targetType` is open-ended.
|
||||
contentReports: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.status) qs.set('status', opts.status)
|
||||
if (opts.teamId) qs.set('teamId', String(opts.teamId))
|
||||
return req(`/admin/moderation/reports${withQs(qs.toString())}`)
|
||||
},
|
||||
handleContentReport: (id, body) =>
|
||||
req(`/admin/moderation/reports/${id}/handle`, { method: 'POST', body }),
|
||||
modRecent: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.type) qs.set('type', params.type)
|
||||
|
||||
@@ -1,12 +1,36 @@
|
||||
import SiteHeader from './SiteHeader.jsx'
|
||||
import SiteFooter from './SiteFooter.jsx'
|
||||
import { shellClass } from '../lib/pageShell.js'
|
||||
|
||||
// Standard page chrome for the public site + wiki.
|
||||
export default function PublicLayout({ section = 'website', header = true, children }) {
|
||||
//
|
||||
// ── `shell` — added in MODULE_API_VERSION 1.5.0 ────────────────────────────
|
||||
//
|
||||
// This component supplies the chrome and NOT the body: every core public page
|
||||
// wraps its own content in `<div className="shell-… page-body">`, which is what
|
||||
// centres it in a max-width column, gives it its top and bottom padding, and —
|
||||
// through `page-body { flex: 1 }` — pushes the footer to the bottom of the
|
||||
// viewport. Nine of nine core pages do it, so the omission has never shown.
|
||||
//
|
||||
// A module page cannot: it is handed `PublicLayout` through the UI kit
|
||||
// (MODULE_API.md §3.4) and has no way to learn about two class names that appear
|
||||
// in no contract. The Integration Kit's acceptance run built a module exactly as
|
||||
// the kit teaches and it rendered full-bleed at x=0 with the footer riding up
|
||||
// under the content — the precise failure §3.4 says the kit exists to prevent
|
||||
// ("a module page that does not look like the site it is installed in").
|
||||
//
|
||||
// So the wrapper moves behind the component a module already has. `shell` is
|
||||
// OPT-IN and omitting it is exactly today's behaviour, which is why core's own
|
||||
// nine pages are untouched by this change — they keep their own wrapper, and a
|
||||
// page wanting an unusual body still writes its own. The width mapping and its
|
||||
// fallback are in lib/pageShell.js, where the DOM-less test runner can reach them.
|
||||
export default function PublicLayout({ section = 'website', header = true, shell, children }) {
|
||||
const bodyClass = shellClass(shell)
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{header && <SiteHeader section={section} />}
|
||||
{children}
|
||||
{bodyClass ? <div className={bodyClass}>{children}</div> : children}
|
||||
<SiteFooter />
|
||||
</div>
|
||||
)
|
||||
|
||||
26
client/src/lib/pageShell.js
Normal file
26
client/src/lib/pageShell.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// The page-body shell core's public pages sit in, as plain JS.
|
||||
//
|
||||
// Extracted from PublicLayout.jsx for the reason lib/adminNav.js was: the client
|
||||
// test runner has no DOM and cannot import a .jsx file at all
|
||||
// (client/test/moduleRegistry.test.js says the same about modules/shared.js), so
|
||||
// anything with a rule worth asserting has to live outside the component.
|
||||
//
|
||||
// The rule worth asserting here is the fallback. `shell` is part of the module
|
||||
// contract as of MODULE_API_VERSION 1.5.0 (MODULE_API.md §3.4), which means the
|
||||
// value can come from a module core has never seen, written against a version of
|
||||
// this list that is older or newer than the one running. An unknown width must
|
||||
// therefore still produce a wrapper: a module page at the wrong width looks like
|
||||
// the site, and a page with no wrapper does not — it renders full-bleed with the
|
||||
// footer riding up under it, which is the defect the prop exists to fix.
|
||||
|
||||
const SHELLS = { narrow: 'shell-narrow', mid: 'shell-mid', wide: 'shell-wide' }
|
||||
|
||||
export const SHELL_WIDTHS = Object.keys(SHELLS)
|
||||
|
||||
// Returns the className for a page body, or null when no shell was asked for —
|
||||
// null is "render children bare", which is every core page written before 1.5.0
|
||||
// and stays the default forever.
|
||||
export function shellClass(shell) {
|
||||
if (!shell) return null
|
||||
return `${SHELLS[shell] || SHELLS.narrow} page-body`
|
||||
}
|
||||
100
client/src/lib/teamActivity.js
Normal file
100
client/src/lib/teamActivity.js
Normal 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
140
client/src/lib/teamAdmin.js
Normal 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 moderator’s un-hide or display-name change '
|
||||
+ 'is filed for approval. Hiding is not gated — suppression is always safe.'
|
||||
|
||||
/** A one-line description of a queued request, for the approval queue. */
|
||||
export function describeRequest(request = {}) {
|
||||
const payload = parsePayload(request.payload)
|
||||
const who = request.requested_username || 'a deleted user'
|
||||
switch (request.action) {
|
||||
case 'unhide':
|
||||
return `${who} asks to publish “${request.team_name}”`
|
||||
case 'display_name_override':
|
||||
return `${who} asks to display “${request.team_name}” as “${payload.displayName || ''}”`
|
||||
case 'clear_display_name_override':
|
||||
return `${who} asks to clear the display name on “${request.team_name}”`
|
||||
default:
|
||||
return `${who} asks for “${request.action}” on “${request.team_name}”`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload may arrive parsed or as a JSON string depending on the driver, so
|
||||
* this normalises rather than assuming either. The server has the same note.
|
||||
*/
|
||||
export function parsePayload(payload) {
|
||||
if (payload == null) return {}
|
||||
if (typeof payload === 'object') return payload
|
||||
try {
|
||||
return JSON.parse(payload)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a member's leadership should read.
|
||||
*
|
||||
* An override is shown AS an override rather than folded into the answer: staff
|
||||
* looking at a roster need to see that a decision was made, not a fact that looks
|
||||
* like the game's.
|
||||
*/
|
||||
export function leadershipOf(member = {}) {
|
||||
if (!member.leaderOverride) {
|
||||
return { isLeader: Boolean(member.isLeader), overridden: false, note: null }
|
||||
}
|
||||
const granted = member.leaderOverride.effect === 'grant'
|
||||
return {
|
||||
isLeader: granted,
|
||||
overridden: true,
|
||||
note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}`
|
||||
+ `${member.leaderOverride.reason ? ` — ${member.leaderOverride.reason}` : ''}`
|
||||
+ ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`,
|
||||
}
|
||||
}
|
||||
85
client/src/lib/teamForum.js
Normal file
85
client/src/lib/teamForum.js
Normal 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
|
||||
// "<script>" become a real tag the next pass then removes, which is a
|
||||
// different string from the one the author wrote.
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
// `&` last of all, or "&lt;" would decode two steps into "<".
|
||||
.replace(/&/g, '&')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line summary under a thread's title in the list.
|
||||
*
|
||||
* `postCount` counts every post including the opening one, so a discussion's
|
||||
* REPLY count is one less — and an announcement has no replies to count at all,
|
||||
* which is why the count is omitted rather than shown as zero.
|
||||
*/
|
||||
export function threadSummary(thread) {
|
||||
const parts = []
|
||||
if (thread.type === 'announcement') parts.push('Announcement')
|
||||
parts.push(thread.author)
|
||||
if (thread.type === 'discussion' && thread.postCount > 1) {
|
||||
const replies = thread.postCount - 1
|
||||
parts.push(`${replies} ${replies === 1 ? 'reply' : 'replies'}`)
|
||||
}
|
||||
if (thread.status === 'hidden') parts.push('hidden')
|
||||
return parts.join(' · ')
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import { publishSharedDependencies } from './modules/shared.js'
|
||||
import { declareSlot } from './modules/registry.js'
|
||||
import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js'
|
||||
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
|
||||
import TeamForumPanel from './modules/TeamForumPanel.jsx'
|
||||
import TeamNotifyToggle from './modules/TeamNotifyToggle.jsx'
|
||||
import './styles/theme.css'
|
||||
|
||||
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
||||
@@ -18,8 +21,6 @@ publishSharedDependencies()
|
||||
// and namespace `uo`, so that the seam was exercised by real content from the
|
||||
// day it was built. That prediction paid out exactly as written: the extraction
|
||||
// deleted the registration and the hook it named, and SiteHeader was not touched.
|
||||
// There is nothing for core to register now — no core nav row carries a
|
||||
// `feature` — and the filter is a correct no-op until a module supplies one.
|
||||
|
||||
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
|
||||
//
|
||||
@@ -56,6 +57,40 @@ declareSlot('player.invite.accepted')
|
||||
// all three, and core's own fills had to go for it to be able to — the first
|
||||
// fill wins, and core registered first (§3.7).
|
||||
|
||||
// ── The inverted direction: core fills a MODULE's slot ─────────────────────
|
||||
//
|
||||
// Teams is a contract PRIMITIVE, not a surface (TEAMS.md Part 3). Core owns the
|
||||
// tables, the sync, the access rules and the activity feed; it does not own the
|
||||
// word for one — a UO shard says guild, and the module that comes after it will
|
||||
// say clan. So core publishes no Team page and no Team nav row, and the module
|
||||
// that owns the vocabulary owns the page.
|
||||
//
|
||||
// The activity feed is the one piece of that page core cannot hand over: only
|
||||
// core can resolve whether this viewer is inside the Team, and the public/members
|
||||
// split is a security boundary. So the module declares the place and core fills
|
||||
// it. Registered here, applied at mount — `applyCoreFills` runs after every
|
||||
// module chunk has evaluated, which is the only moment a module-declared slot
|
||||
// exists to be filled.
|
||||
//
|
||||
// Naming a slot no installed module declares is not an error. On a deployment
|
||||
// with no game module this fill simply never lands, which is the mirror of an
|
||||
// unfilled slot rendering nothing.
|
||||
fillModuleSlot('uo.guild.detail', TeamActivityFeed)
|
||||
|
||||
// The forum is core's for the same reason and goes in a SECOND place the module
|
||||
// declares, rather than joining the feed in the first: a slot takes one component
|
||||
// (first fill wins), and stacking two unrelated panels into one fill 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.
|
||||
fillModuleSlot('uo.guild.forum', TeamForumPanel)
|
||||
|
||||
// And the notification control, in a third place the module declares ABOVE its
|
||||
// roster. A third slot 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.
|
||||
fillModuleSlot('uo.guild.header', TeamNotifyToggle)
|
||||
|
||||
// Render on DOMContentLoaded rather than immediately, and that is the one line
|
||||
// of core's boot the module system changes.
|
||||
//
|
||||
@@ -82,6 +117,10 @@ declareSlot('player.invite.accepted')
|
||||
// static deferred script, so this branch is the genuine "the event has already
|
||||
// been and gone" case and not a wrong guess about our own timing.
|
||||
function mount() {
|
||||
// Every module chunk has evaluated by now, so any slot a module declared is
|
||||
// present and core's pending fills can land. Must happen before the first
|
||||
// render: `extensionFor` is read during render and there is no subscription.
|
||||
applyCoreFills()
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
|
||||
96
client/src/modules/TeamActivityFeed.jsx
Normal file
96
client/src/modules/TeamActivityFeed.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
754
client/src/modules/TeamForumPanel.jsx
Normal file
754
client/src/modules/TeamForumPanel.jsx
Normal 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’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>
|
||||
)
|
||||
}
|
||||
102
client/src/modules/TeamNotifyToggle.jsx
Normal file
102
client/src/modules/TeamNotifyToggle.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -135,6 +135,69 @@ export function declareSlot(name) {
|
||||
slots.set(name, { Component: null, filledBy: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* **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 registers its fills
|
||||
* through `fillModuleSlot` below, which is applied after every module chunk has
|
||||
* evaluated — see main.jsx.
|
||||
*/
|
||||
export function declareModuleSlot(id, name) {
|
||||
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`)
|
||||
slots.set(name, { Component: null, filledBy: null, declaredBy: id })
|
||||
}
|
||||
|
||||
// Core's pending fills for module-declared slots, applied once every module
|
||||
// chunk has evaluated. Kept as a list rather than applied eagerly because the
|
||||
// slot does not exist when core asks — see the ordering note above.
|
||||
const coreFills = []
|
||||
|
||||
/**
|
||||
* Core: "fill this module-declared slot when it turns up."
|
||||
*
|
||||
* Deliberately not an error when the slot never appears. A module that is not
|
||||
* installed declares nothing, and core offering content for a page that does not
|
||||
* exist is the ordinary case on any deployment — not a misconfiguration. That is
|
||||
* the mirror of an unfilled slot rendering nothing.
|
||||
*/
|
||||
export function fillModuleSlot(name, Component) {
|
||||
if (typeof Component !== 'function') throw new Error(`fillModuleSlot: ${name} is not a component`)
|
||||
coreFills.push([name, Component])
|
||||
}
|
||||
|
||||
/** Apply core's fills. Called once from main.jsx, after module chunks have run. */
|
||||
export function applyCoreFills() {
|
||||
for (const [name, Component] of coreFills) {
|
||||
const entry = slots.get(name)
|
||||
if (!entry) continue // the declaring module is not installed
|
||||
if (entry.filledBy) continue // a module already claimed it; first fill wins
|
||||
entry.Component = Component
|
||||
entry.filledBy = 'core'
|
||||
}
|
||||
coreFills.length = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a declared slot with a component.
|
||||
*
|
||||
@@ -207,6 +270,7 @@ export function _reset() {
|
||||
nav[area].length = 0
|
||||
}
|
||||
providers.clear()
|
||||
coreFills.length = 0
|
||||
// Declarations go too, unlike the server's, where a slot is declared once at
|
||||
// require time by the router that owns it. Core declares its slots in
|
||||
// main.jsx — the one file no test loads — so on this side there is nothing
|
||||
@@ -224,6 +288,8 @@ export const registry = {
|
||||
registerNav,
|
||||
registerFeatureProvider,
|
||||
registerExtension,
|
||||
// The inverted direction (TEAMS.md Part 3): the module declares, core fills.
|
||||
declareModuleSlot,
|
||||
routesFor,
|
||||
navFor,
|
||||
featureProviderFor,
|
||||
|
||||
@@ -34,13 +34,15 @@ import { MODULE_API_VERSION } from './version.js'
|
||||
import PublicLayout from '../components/PublicLayout.jsx'
|
||||
import PageHeader from '../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
|
||||
import Slot from './Slot.jsx'
|
||||
import { useAsync } from '../lib/useAsync.js'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import { request, ApiError, BASE } from '../api/client.js'
|
||||
|
||||
// The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
|
||||
// seven are what the smallest UO page already needs beyond React and the router:
|
||||
// eight exports — five table rows in §3.4, since `PageState` contributes three —
|
||||
// are what the smallest UO page already needs beyond React and the router:
|
||||
// without them a module either reaches into core's tree — violating the
|
||||
// zero-import rule the whole boundary rests on — or ships its own copies, which
|
||||
// means a module page that does not look like the site it is installed in, and
|
||||
@@ -50,11 +52,12 @@ import { request, ApiError, BASE } from '../api/client.js'
|
||||
// is a MAJOR one. That is a real constraint on core's own refactoring and it is
|
||||
// the price of the boundary being worth anything.
|
||||
//
|
||||
// `AdminPage` appears in §3.4's table and is deliberately absent: core has no
|
||||
// such component — admin views are plain markup inside AdminLayout — and
|
||||
// inventing one to satisfy a table would be a core change with no consumer until
|
||||
// Phase 3. The contract is amended rather than the code padded, and adding it
|
||||
// later costs a minor bump, which is exactly the case the versioning is for.
|
||||
// `AdminPage` was in an early draft of §3.4's table and is deliberately absent:
|
||||
// core has no such component — admin views are plain markup inside AdminLayout —
|
||||
// and inventing one to satisfy a table would be a core change with no consumer
|
||||
// until Phase 3. The contract was amended rather than the code padded (it no
|
||||
// longer lists it), and adding it later costs a minor bump, which is exactly the
|
||||
// case the versioning is for.
|
||||
const ui = {
|
||||
PublicLayout,
|
||||
PageHeader,
|
||||
@@ -64,6 +67,13 @@ const ui = {
|
||||
useAsync,
|
||||
useAuth,
|
||||
useSite,
|
||||
// The eighth 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
|
||||
|
||||
@@ -11,6 +11,25 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Nothing on this half
|
||||
// changed yet: the two client additions the version covers are the `team.overview`
|
||||
// and `team.member.row` slots, and a slot can only be declared by the page that
|
||||
// hosts it, which lands with the Team pages in phase 3. This file bumps anyway,
|
||||
// for the reason at the top — the two halves state ONE version, and a module
|
||||
// declares one `coreApi` range against both.
|
||||
//
|
||||
// 1.5.0 — `PublicLayout` takes an optional `shell` prop ('narrow' | 'mid' |
|
||||
// 'wide') that renders the `shell-… page-body` wrapper core's own pages write by
|
||||
// hand. Additive: omitting it is 1.4.0's behaviour, so §3.4's "changing a kit
|
||||
// component's props is major" does not bite — nothing already written changes
|
||||
// meaning. It exists because the kit's acceptance run proved a module cannot
|
||||
// discover the wrapper: the class names are theme.css's and appear in no
|
||||
// contract, so a module page rendered outside the site's column while doing
|
||||
// everything the kit said (docs/modules/kit-acceptance.md).
|
||||
// 1.4.0 — a rule, not a member: §2.7 forbids a module opening a connection to a
|
||||
// game server from the website process (it talks to a sidecar, which owns the
|
||||
// durable copy). Nothing on window.__rg changed and nothing on the server's ctx
|
||||
// changed either; this half bumps because the two halves state ONE version.
|
||||
// 1.3.0 — three additions, all from Phase 3 slice 3 needing them: a nav item may
|
||||
// carry an `icon` component (§3.3), core declares a third slot
|
||||
// `player.invite.accepted` (§3.7), and `window.__rg.api` gained `BASE`, which
|
||||
@@ -26,4 +45,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.3.0'
|
||||
export const MODULE_API_VERSION = '1.6.0'
|
||||
|
||||
@@ -76,6 +76,17 @@ export const NAV = [
|
||||
items: [
|
||||
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||
// Member-raised reports (TEAMS.md §5.6). Here rather than under Teams
|
||||
// because a staffer working a queue should have one place to work — and
|
||||
// because the queue is deliberately generic, so the next thing that can
|
||||
// be reported arrives as a row rather than as another nav entry.
|
||||
{ to: '/admin/moderation/reports', label: 'Reports', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||
// Moderation rather than System: the screen's daily job is the
|
||||
// reserved-name review queue, which is moderator work. The three actions
|
||||
// that publish a game-written name are gated to admins server-side, so a
|
||||
// moderator reaching this screen is correct — what they do here is file a
|
||||
// request (TEAMS.md §2.9).
|
||||
{ to: '/admin/teams', label: 'Teams', icon: IconUsers, roles: ['admin', 'moderator'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -134,6 +145,7 @@ const TITLES = {
|
||||
'/admin/hero': 'Hero Editor',
|
||||
'/admin/moderation': 'Moderation',
|
||||
'/admin/moderation/appeals': 'Appeals',
|
||||
'/admin/moderation/reports': 'Reports',
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/appearance': 'Appearance',
|
||||
'/admin/navigation': 'Navigation',
|
||||
|
||||
310
client/src/routes/admin/views/ContentReports.jsx
Normal file
310
client/src/routes/admin/views/ContentReports.jsx
Normal 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’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)' }
|
||||
@@ -3,6 +3,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import EmailDelivery from './EmailDelivery.jsx'
|
||||
import TeamForumSettings from './TeamForumSettings.jsx'
|
||||
|
||||
// Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor).
|
||||
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
|
||||
@@ -143,6 +144,8 @@ export default function SettingsAdmin() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TeamForumSettings />
|
||||
|
||||
<EmailDelivery />
|
||||
</section>
|
||||
)
|
||||
|
||||
276
client/src/routes/admin/views/TeamForumSettings.jsx
Normal file
276
client/src/routes/admin/views/TeamForumSettings.jsx
Normal 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 visitor’s browser directly from the'
|
||||
+ ' site hosting them. That site can see your visitors’ IP addresses, and you do not control whether'
|
||||
+ ' the image changes or disappears.'
|
||||
|
||||
// §5.5.5(b). Shown only when changing the mode TO uploads.
|
||||
const DIALOG_CHECKS = [
|
||||
'I understand that uploaded files will be stored on infrastructure that I control.',
|
||||
'I understand that I am responsible for community moderation policies on this installation.',
|
||||
]
|
||||
// Addition 2 — the expectation gap most likely to bite. An operator who turns
|
||||
// uploads off because of a problem will assume the problem goes with it.
|
||||
const DIALOG_TAIL = 'Disabling uploads later stops new files being accepted. It does not delete files'
|
||||
+ ' already uploaded — remove those from the forum moderation tools.'
|
||||
|
||||
const MODES = [
|
||||
{ value: 'disabled', label: 'Disabled — image URLs stay plain links' },
|
||||
{ value: 'remote', label: 'Remote — images hosted elsewhere are shown' },
|
||||
{ value: 'uploads', label: 'Uploads — members may upload images to this server' },
|
||||
]
|
||||
|
||||
export default function TeamForumSettings() {
|
||||
const { refresh: refreshSite } = useSite()
|
||||
const [state, setState] = useState(null)
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [mode, setMode] = useState('disabled')
|
||||
const [editWindow, setEditWindow] = useState('15')
|
||||
const [dialog, setDialog] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const s = await api.admin.teamForumSettings()
|
||||
setState(s)
|
||||
setEnabled(s.enabled)
|
||||
setMode(s.imageMode)
|
||||
setEditWindow(String(s.editWindowMinutes ?? 15))
|
||||
} catch {
|
||||
setError('Could not load forum settings.')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
if (!state) return null
|
||||
|
||||
const stale = state.acknowledgement?.stale
|
||||
|
||||
async function persist(next, acknowledge) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.updateSettings({
|
||||
teams_forums_enabled: next.enabled ? '1' : '0',
|
||||
teams_forum_images: next.mode,
|
||||
teams_forum_edit_window_minutes: String(next.editWindow),
|
||||
...(acknowledge ? { acknowledge } : {}),
|
||||
})
|
||||
setSaved(true)
|
||||
await load()
|
||||
await refreshSite()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save forum settings.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Moving TO uploads asks first; every other change saves directly. A stale
|
||||
// acknowledgement also routes through the dialog, because re-acknowledging is
|
||||
// the only thing that unfreezes these settings.
|
||||
function save() {
|
||||
setSaved(false)
|
||||
if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) {
|
||||
setDialog({ enabled, mode, editWindow })
|
||||
return
|
||||
}
|
||||
if (stale) {
|
||||
setDialog({ enabled, mode, editWindow })
|
||||
return
|
||||
}
|
||||
persist({ enabled, mode, editWindow })
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
384
client/src/routes/admin/views/TeamsAdmin.jsx
Normal file
384
client/src/routes/admin/views/TeamsAdmin.jsx
Normal file
@@ -0,0 +1,384 @@
|
||||
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'
|
||||
|
||||
// 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)} />}
|
||||
|
||||
<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 }
|
||||
268
client/src/routes/player/PlayerNotifications.jsx
Normal file
268
client/src/routes/player/PlayerNotifications.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 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;
|
||||
// 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`.
|
||||
export const NAV = [
|
||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||
{ to: '/account/notifications', label: 'Notifications', icon: IconBell },
|
||||
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
||||
]
|
||||
|
||||
@@ -56,6 +58,7 @@ export const NAV = [
|
||||
const TITLES = {
|
||||
'/account': 'Account',
|
||||
'/account/appeals': 'Appeals',
|
||||
'/account/notifications': 'Notifications',
|
||||
}
|
||||
|
||||
function moduleTitle(baseNav, pathname) {
|
||||
|
||||
69
client/src/routes/player/Unsubscribe.jsx
Normal file
69
client/src/routes/player/Unsubscribe.jsx
Normal 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’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>
|
||||
)
|
||||
}
|
||||
@@ -298,6 +298,18 @@ button[disabled] {
|
||||
}
|
||||
|
||||
/* ===== Rich prose (wiki / newsletter body) ===== */
|
||||
.forum-embed {
|
||||
/* The image a Team-forum post's URL renders as, in `remote`/`uploads` mode.
|
||||
Emitted by the server (utils/forumHtml.js), never by an author — which is
|
||||
what makes the operator's image policy enforceable. Block, so it sits
|
||||
beneath its link rather than beside it; capped, because a remote image is
|
||||
whatever size its host decided and one post must not blow out the column. */
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-input);
|
||||
}
|
||||
.prose {
|
||||
color: var(--text);
|
||||
font-size: 1.06rem;
|
||||
|
||||
@@ -185,3 +185,70 @@ test('a module id is URL-encoded on the way into the path', async () => {
|
||||
await api.admin.disableModule('a b/c')
|
||||
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable')
|
||||
})
|
||||
|
||||
// ── Team forum, phase 5 ("5b") ──────────────────────────────────────────
|
||||
//
|
||||
// The URL shapes matter more here than they look. Replies hang off a THREAD;
|
||||
// edits and post moderation hang off a POST; and the report route hangs off the
|
||||
// forum rather than off either, because a report can name a thread, a post or an
|
||||
// upload and is not moderation of any of them.
|
||||
|
||||
test('a reply hangs off its thread and an edit hangs off its post', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumReply('ossuary', 5, { body: 'hi' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/posts')
|
||||
assert.equal(calls[0].opts.method, 'POST')
|
||||
|
||||
calls = []
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumEditPost('ossuary', 80, { body: 'fixed' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80')
|
||||
// PATCH, not POST: an edit replaces part of a post that already exists, and the
|
||||
// server's route is mounted on the verb.
|
||||
assert.equal(calls[0].opts.method, 'PATCH')
|
||||
})
|
||||
|
||||
test('post moderation is a different route from thread moderation', async () => {
|
||||
// Not the same route with a target kind, because the two answer to different
|
||||
// rules — `pin` and `lock` mean nothing to a post at all.
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumModeratePost('ossuary', 80, { action: 'hide' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80/moderate')
|
||||
|
||||
calls = []
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumModerate('ossuary', 5, { action: 'pin' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/moderate')
|
||||
})
|
||||
|
||||
test('a report goes to the forum, and its queue is under admin moderation', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumReport('ossuary', { targetType: 'team_forum_post', targetId: 80, reason: 'abuse' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/report')
|
||||
assert.deepEqual(JSON.parse(calls[0].opts.body), {
|
||||
targetType: 'team_forum_post', targetId: 80, reason: 'abuse',
|
||||
})
|
||||
|
||||
// Under /admin/moderation and NOT under /admin/teams: a staffer working a queue
|
||||
// should have one place to work, and there is deliberately no leader-facing
|
||||
// counterpart to this call anywhere in the client (TEAMS.md §5.6).
|
||||
calls = []
|
||||
willReply({ body: { reports: [] } })
|
||||
await api.admin.contentReports({ status: 'open' })
|
||||
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports?status=open')
|
||||
})
|
||||
|
||||
test('the report queue defaults to the open work rather than to everything', async () => {
|
||||
willReply({ body: { reports: [] } })
|
||||
await api.admin.contentReports()
|
||||
// No query string at all — the server's default is open + reviewing, and a
|
||||
// client that pinned `status=all` here would put the archive in front of a
|
||||
// staffer every time they opened the screen.
|
||||
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports')
|
||||
})
|
||||
|
||||
test('a Team slug is URL-encoded on every forum path', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
|
||||
})
|
||||
|
||||
@@ -160,6 +160,9 @@ test('the registry object handed to modules exposes the whole surface', () => {
|
||||
// window.__rg.registry is the ONLY way a module reaches any of this, so a
|
||||
// member missing from the object is a member that does not exist.
|
||||
assert.deepEqual(Object.keys(registry).sort(), [
|
||||
// `declareModuleSlot` is the INVERTED direction added in 1.6.0: the module
|
||||
// declares a place on its own page and core fills it (TEAMS.md Part 3).
|
||||
'declareModuleSlot',
|
||||
'featureProviderFor',
|
||||
'navFor',
|
||||
'registerExtension',
|
||||
|
||||
@@ -4,6 +4,9 @@ import assert from 'node:assert/strict'
|
||||
import {
|
||||
registry,
|
||||
declareSlot,
|
||||
declareModuleSlot,
|
||||
fillModuleSlot,
|
||||
applyCoreFills,
|
||||
registerExtension,
|
||||
extensionFor,
|
||||
registeredIds,
|
||||
@@ -92,3 +95,76 @@ test('declareSlot and extensionFor are not on the module-facing registry', () =>
|
||||
assert.equal(registry.extensionFor, undefined)
|
||||
assert.equal(typeof registry.registerExtension, 'function')
|
||||
})
|
||||
|
||||
// ── The INVERTED direction: the module declares, core fills ────────────────
|
||||
//
|
||||
// Added in 1.6.0 for Teams (TEAMS.md Part 3). Teams are a core primitive with no
|
||||
// core surface — core owns the tables and the activity feed, the module owns the
|
||||
// page and the word "guild" — so the content flows the other way for the first
|
||||
// time. The rules below are the ones that direction gets wrong.
|
||||
|
||||
const Feed = () => null
|
||||
|
||||
test('a module-declared slot must be namespaced under the declaring module', () => {
|
||||
// Enforced rather than conventional: this is the only thing keeping two
|
||||
// modules from claiming the same slot name.
|
||||
assert.throws(() => declareModuleSlot('uo', 'guild.detail'), /must be namespaced/)
|
||||
assert.doesNotThrow(() => declareModuleSlot('uo', 'uo.guild.detail'))
|
||||
})
|
||||
|
||||
test('core fills a module slot only after the module has declared it', () => {
|
||||
// The ordering that makes this a separate call: core's bundle evaluates BEFORE
|
||||
// any module chunk, so at the moment core registers its fill the slot does not
|
||||
// exist yet. Filling eagerly would silently do nothing.
|
||||
fillModuleSlot('uo.guild.detail', Feed)
|
||||
assert.equal(extensionFor('uo.guild.detail'), null, 'not filled before the module declared it')
|
||||
|
||||
declareModuleSlot('uo', 'uo.guild.detail')
|
||||
assert.equal(extensionFor('uo.guild.detail'), null, 'and not before the fills are applied')
|
||||
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), Feed)
|
||||
})
|
||||
|
||||
test('a fill for a slot nobody declared is not an error', () => {
|
||||
// The module is not installed. Core offering content for a page that does not
|
||||
// exist is the ordinary case on any deployment, not a misconfiguration — the
|
||||
// mirror of an unfilled slot rendering nothing.
|
||||
fillModuleSlot('rust.clan.detail', Feed)
|
||||
assert.doesNotThrow(() => applyCoreFills())
|
||||
assert.equal(extensionFor('rust.clan.detail'), null)
|
||||
})
|
||||
|
||||
test('a module that fills its own slot first keeps it', () => {
|
||||
const Own = () => null
|
||||
declareModuleSlot('uo', 'uo.guild.detail')
|
||||
registerExtension('uo', 'uo.guild.detail', Own)
|
||||
fillModuleSlot('uo.guild.detail', 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')
|
||||
fillModuleSlot('uo.guild.detail', Feed)
|
||||
applyCoreFills()
|
||||
assert.doesNotThrow(() => applyCoreFills())
|
||||
assert.equal(extensionFor('uo.guild.detail'), Feed)
|
||||
})
|
||||
|
||||
test('a non-component fill is refused at the call site, not at render', () => {
|
||||
assert.throws(() => fillModuleSlot('uo.guild.detail', 'nope'), /is not a component/)
|
||||
})
|
||||
|
||||
test('_reset clears pending fills, so one test cannot leak into the next', () => {
|
||||
fillModuleSlot('uo.guild.detail', Feed)
|
||||
_reset()
|
||||
declareModuleSlot('uo', 'uo.guild.detail')
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), null)
|
||||
})
|
||||
|
||||
59
client/test/pageShell.test.js
Normal file
59
client/test/pageShell.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { shellClass, SHELL_WIDTHS } from '../src/lib/pageShell.js'
|
||||
|
||||
// `PublicLayout`'s `shell` prop (MODULE_API.md §3.4, MODULE_API_VERSION 1.5.0).
|
||||
// The component itself is .jsx and unreachable from this runner — there is no DOM
|
||||
// here — so the rule lives in lib/pageShell.js and is asserted here, and the
|
||||
// rendering is proved in a browser (MODULE_API.md §7.7), which is where the
|
||||
// defect that produced this prop was found in the first place.
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
test('no shell means no wrapper — the behaviour every page had before 1.5.0', () => {
|
||||
// null, not an empty string: PublicLayout branches on it to render `children`
|
||||
// bare, and '' would render a <div class=""> that changes core's nine pages.
|
||||
assert.equal(shellClass(undefined), null)
|
||||
assert.equal(shellClass(null), null)
|
||||
assert.equal(shellClass(''), null)
|
||||
assert.equal(shellClass(false), null)
|
||||
})
|
||||
|
||||
test('each documented width maps to its theme.css class, plus page-body', () => {
|
||||
assert.equal(shellClass('narrow'), 'shell-narrow page-body')
|
||||
assert.equal(shellClass('mid'), 'shell-mid page-body')
|
||||
assert.equal(shellClass('wide'), 'shell-wide page-body')
|
||||
})
|
||||
|
||||
test('page-body is always present — it is what pushes the footer down', () => {
|
||||
// `.page` is a flex column and `.page-body { flex: 1 }` is the only thing
|
||||
// filling it. A width class on its own centres the content and still lets the
|
||||
// footer ride up under it, which is half the reported defect and the half that
|
||||
// is easy to lose in a refactor.
|
||||
for (const w of SHELL_WIDTHS) {
|
||||
assert.match(shellClass(w), /\bpage-body\b/)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unknown width still renders a wrapper, at the narrow default', () => {
|
||||
// The value can arrive from a module built against a different version of this
|
||||
// list, so the failure mode has to be "wrong width" and never "no wrapper".
|
||||
assert.equal(shellClass('enormous'), 'shell-narrow page-body')
|
||||
assert.equal(shellClass(true), 'shell-narrow page-body')
|
||||
assert.equal(shellClass('NARROW'), 'shell-narrow page-body')
|
||||
})
|
||||
|
||||
test('every width this module offers is a class theme.css actually defines', () => {
|
||||
// The contract now names these widths to module authors, so a rename in
|
||||
// theme.css has to fail here rather than silently in a module's page.
|
||||
const css = fs.readFileSync(path.join(HERE, '../src/styles/theme.css'), 'utf8')
|
||||
for (const w of SHELL_WIDTHS) {
|
||||
const cls = shellClass(w).split(' ')[0]
|
||||
assert.ok(css.includes(`.${cls} {`), `theme.css defines .${cls}`)
|
||||
}
|
||||
assert.ok(css.includes('.page-body {'), 'theme.css defines .page-body')
|
||||
})
|
||||
78
client/test/teamActivity.test.js
Normal file
78
client/test/teamActivity.test.js
Normal 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/)
|
||||
})
|
||||
140
client/test/teamAdmin.test.js
Normal file
140
client/test/teamAdmin.test.js
Normal 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/)
|
||||
})
|
||||
120
client/test/teamForum.test.js
Normal file
120
client/test/teamForum.test.js
Normal 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 & Jerry</p>'), 'Tom & Jerry')
|
||||
assert.equal(stripToText('<p>"quoted"</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><script></p>'), '<script>')
|
||||
// And decoding & first would turn "&lt;" into "<" in two steps.
|
||||
assert.equal(stripToText('<p>&lt;</p>'), '<')
|
||||
})
|
||||
|
||||
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))
|
||||
})
|
||||
87
client/test/teamNotify.test.js
Normal file
87
client/test/teamNotify.test.js
Normal 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$/)
|
||||
})
|
||||
@@ -845,6 +845,466 @@ CREATE TABLE IF NOT EXISTS installed_modules (
|
||||
INDEX idx_installed_modules_state (state)
|
||||
) 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;
|
||||
|
||||
-- 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
|
||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||
@@ -875,6 +1335,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.
|
||||
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 category_id INT NULL;
|
||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;
|
||||
|
||||
@@ -345,6 +345,26 @@
|
||||
"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",
|
||||
"path": "/api/v1/admin/moderation/search",
|
||||
@@ -730,6 +750,176 @@
|
||||
"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/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": "POST",
|
||||
"path": "/api/v1/admin/uploads",
|
||||
@@ -1197,6 +1387,26 @@
|
||||
"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",
|
||||
"path": "/api/v1/auth/me/sessions",
|
||||
@@ -1499,6 +1709,162 @@
|
||||
"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",
|
||||
"path": "/api/v1/public/contact",
|
||||
@@ -1556,6 +1922,60 @@
|
||||
"handlers": 1,
|
||||
"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",
|
||||
"path": "/api/v1/public/version",
|
||||
@@ -1627,6 +2047,22 @@
|
||||
"gates": [
|
||||
"requireInternalKey"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/internal/commands",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"requireInternalKey"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/internal/commands/dispatch",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"requireInternalKey"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -145,6 +145,14 @@
|
||||
"method": "GET",
|
||||
"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",
|
||||
"path": "/api/v1/admin/moderation/search"
|
||||
@@ -293,6 +301,70 @@
|
||||
"method": "PUT",
|
||||
"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/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": "POST",
|
||||
"path": "/api/v1/admin/uploads"
|
||||
@@ -477,6 +549,14 @@
|
||||
"method": "PUT",
|
||||
"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",
|
||||
"path": "/api/v1/auth/me/sessions"
|
||||
@@ -605,6 +685,66 @@
|
||||
"method": "GET",
|
||||
"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",
|
||||
"path": "/api/v1/public/contact"
|
||||
@@ -637,6 +777,34 @@
|
||||
"method": "GET",
|
||||
"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",
|
||||
"path": "/api/v1/public/version"
|
||||
@@ -674,6 +842,14 @@
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/internal/bot-config"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/internal/commands"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/internal/commands/dispatch"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// first so req.user is populated. Use for admin-only endpoints (users, site
|
||||
// mode, settings) so a lower-privilege editor cannot reach them.
|
||||
@@ -91,6 +129,7 @@ function requireRole(...roles) {
|
||||
|
||||
module.exports = {
|
||||
attachSession,
|
||||
optionalAuth,
|
||||
requireAuth,
|
||||
requireRole,
|
||||
}
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
//
|
||||
// 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
|
||||
// core, the CATALOG is content). Exactly one stream is core's: `news.post` is
|
||||
// produced by the website's own posts path, not by any game feed.
|
||||
// core, the CATALOG is content). `news.post` is produced by the website's own
|
||||
// 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
|
||||
// through it — nothing imports this file to get "the catalog", because the
|
||||
// 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
|
||||
// ({ stream, ref }); the app wakes and PULLS the real, ownership-checked content
|
||||
// over the authenticated API (docs/android/PLAN.md §11).
|
||||
@@ -21,6 +26,55 @@ const STREAMS = [
|
||||
personal: 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 }
|
||||
|
||||
@@ -48,4 +48,44 @@ const endpointsForUserStream = (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,
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
const endpointsForStream = (streamId) => db.endpointsForStream(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,
|
||||
}
|
||||
|
||||
154
server/src/model/reports/contentReports.db.js
Normal file
154
server/src/model/reports/contentReports.db.js
Normal 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,
|
||||
}
|
||||
243
server/src/model/reports/contentReports.model.js
Normal file
243
server/src/model/reports/contentReports.model.js
Normal 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,
|
||||
}
|
||||
@@ -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.
|
||||
async function seedDefault(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])
|
||||
}
|
||||
|
||||
module.exports = { getAll, get, set, seedDefault, remove }
|
||||
module.exports = { getAll, get, getRow, set, seedDefault, remove }
|
||||
|
||||
@@ -16,6 +16,18 @@ const PUBLIC_KEYS = [
|
||||
'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.
|
||||
'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).
|
||||
|
||||
140
server/src/model/teams/teamAccess.db.js
Normal file
140
server/src/model/teams/teamAccess.db.js
Normal 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,
|
||||
}
|
||||
131
server/src/model/teams/teamAccess.model.js
Normal file
131
server/src/model/teams/teamAccess.model.js
Normal 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,
|
||||
}
|
||||
133
server/src/model/teams/teamActivity.db.js
Normal file
133
server/src/model/teams/teamActivity.db.js
Normal 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,
|
||||
}
|
||||
312
server/src/model/teams/teamActivity.model.js
Normal file
312
server/src/model/teams/teamActivity.model.js
Normal 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,
|
||||
}
|
||||
294
server/src/model/teams/teamForum.db.js
Normal file
294
server/src/model/teams/teamForum.db.js
Normal 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,
|
||||
}
|
||||
437
server/src/model/teams/teamForum.model.js
Normal file
437
server/src/model/teams/teamForum.model.js
Normal 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,
|
||||
}
|
||||
197
server/src/model/teams/teamForumSettings.model.js
Normal file
197
server/src/model/teams/teamForumSettings.model.js
Normal 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,
|
||||
}
|
||||
177
server/src/model/teams/teamForumUploads.model.js
Normal file
177
server/src/model/teams/teamForumUploads.model.js
Normal 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,
|
||||
}
|
||||
169
server/src/model/teams/teamGrants.model.js
Normal file
169
server/src/model/teams/teamGrants.model.js
Normal 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,
|
||||
}
|
||||
123
server/src/model/teams/teamModeration.db.js
Normal file
123
server/src/model/teams/teamModeration.db.js
Normal 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,
|
||||
}
|
||||
239
server/src/model/teams/teamModeration.model.js
Normal file
239
server/src/model/teams/teamModeration.model.js
Normal 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,
|
||||
}
|
||||
223
server/src/model/teams/teamNotify.db.js
Normal file
223
server/src/model/teams/teamNotify.db.js
Normal 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,
|
||||
}
|
||||
146
server/src/model/teams/teamNotify.model.js
Normal file
146
server/src/model/teams/teamNotify.model.js
Normal 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),
|
||||
}
|
||||
239
server/src/model/teams/teamProvider.js
Normal file
239
server/src/model/teams/teamProvider.js
Normal 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,
|
||||
}
|
||||
50
server/src/model/teams/teamSlug.js
Normal file
50
server/src/model/teams/teamSlug.js
Normal 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 }
|
||||
612
server/src/model/teams/teamSync.model.js
Normal file
612
server/src/model/teams/teamSync.model.js
Normal file
@@ -0,0 +1,612 @@
|
||||
// ── 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 { 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 {
|
||||
if (joined.length > 0) await teamNotify.memberJoined(team)
|
||||
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)
|
||||
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,
|
||||
}
|
||||
312
server/src/model/teams/teams.db.js
Normal file
312
server/src/model/teams/teams.db.js
Normal 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,
|
||||
}
|
||||
374
server/src/model/teams/teams.model.js
Normal file
374
server/src/model/teams/teams.model.js
Normal 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,
|
||||
}
|
||||
@@ -31,6 +31,33 @@ const log = require('../utils/logger')('modules')
|
||||
// such budget on purpose — it delays the listener binding, which is the feature.
|
||||
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
|
||||
* 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`. */
|
||||
@@ -271,6 +315,7 @@ async function stop(id, { modules, model, budgetMs = SHUTDOWN_BUDGET_MS } = {})
|
||||
}
|
||||
|
||||
await safe(`disabling module "${id}"`, () => rows.disable(id))
|
||||
nudgeBot(`disable:${id}`)
|
||||
return { stopped, error }
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,8 @@ function buildCtx(id, moduleRoot) {
|
||||
const uploads = require('../router/v1/admin/imageUpload')
|
||||
const activity = require('../model/activity/activity.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')
|
||||
/* 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
|
||||
// panel's job, and it spans every actor.
|
||||
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
|
||||
// the user its prefix names. Narrowed like `ctx.posts` — the users model
|
||||
// exports creation, role changes and password handling, none of which is a
|
||||
@@ -240,6 +273,26 @@ function buildApi(record) {
|
||||
record.staged.registerNotificationStreams(streams)
|
||||
},
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -58,6 +58,28 @@ const legs = new Map()
|
||||
// a collision with a name attached rather than a silently doubled side effect.
|
||||
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
|
||||
|
||||
// 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. */
|
||||
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 ────────────────────────
|
||||
//
|
||||
// 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 }
|
||||
}
|
||||
|
||||
// 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
|
||||
* 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()`.
|
||||
*/
|
||||
function stage(owner) {
|
||||
const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [] }
|
||||
const staged = {
|
||||
owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [], slashCommands: [],
|
||||
}
|
||||
return {
|
||||
staged,
|
||||
registerNotificationStreams(entries) {
|
||||
@@ -281,6 +490,13 @@ function stage(owner) {
|
||||
registerPostHook(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
|
||||
* 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 ──
|
||||
const seenStreams = new Set()
|
||||
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`)
|
||||
}
|
||||
|
||||
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 ──
|
||||
for (const s of newStreams) {
|
||||
streamOwners.set(s.id, owner)
|
||||
@@ -345,6 +582,8 @@ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExten
|
||||
entry.router.use(x.router)
|
||||
}
|
||||
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 ───────────────────────────────────────────────
|
||||
@@ -410,6 +649,8 @@ function _reset() {
|
||||
streamOwners.clear()
|
||||
legs.clear()
|
||||
postHooks.clear()
|
||||
teamProvider = null
|
||||
slashCommands.clear()
|
||||
coreRegistered = false
|
||||
}
|
||||
|
||||
@@ -427,6 +668,10 @@ module.exports = {
|
||||
announceLeg,
|
||||
postHookEntries,
|
||||
dispatchPostHook,
|
||||
registeredTeamProvider,
|
||||
hasTeamProvider,
|
||||
slashCommandDefinitions,
|
||||
slashCommand,
|
||||
stage,
|
||||
apply,
|
||||
registerCore,
|
||||
|
||||
@@ -9,6 +9,38 @@
|
||||
// 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.
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// bump because that breaks a call already written, and adding an optional one
|
||||
// breaks nothing — omitting `shell` is 1.4.0's behaviour exactly. Nothing on the
|
||||
// server changed; this file bumps for the reason below. Found by the Integration
|
||||
// Kit's acceptance run (docs/modules/kit-acceptance.md), where a module built
|
||||
// exactly as the kit teaches rendered outside the site's page column.
|
||||
//
|
||||
// 1.4.0 — no member changed. §2.7 gained one prohibition: a module does not open
|
||||
// a connection to a game server from the website process; it talks to a sidecar,
|
||||
// which owns the durable copy of the game's state. Minor rather than major
|
||||
// because the SURFACE is identical to 1.3.0 — module-uo's `coreApi: "^1.3.0"`
|
||||
// still resolves, and it already complies — but a module written against 1.3.0
|
||||
// could satisfy every member and still be built the wrong way round, which is
|
||||
// what this number now says. The one §2.7 rule with no CI behind it: an outbound
|
||||
// socket is not statically detectable the way an internal require is.
|
||||
//
|
||||
// 1.3.0 — three CLIENT additions from Phase 3 slice 3: a nav item may carry an
|
||||
// `icon`, core declares a `player.invite.accepted` slot, and `window.__rg.api`
|
||||
// gained `BASE` (which §3.5 always specified and shared.js never published).
|
||||
@@ -26,6 +58,6 @@
|
||||
// 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
|
||||
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
||||
const MODULE_API_VERSION = '1.3.0'
|
||||
const MODULE_API_VERSION = '1.6.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
@@ -9,6 +9,7 @@ const trustedDevices = require('../../../model/trustedDevices/trustedDevices.mod
|
||||
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
|
||||
const registries = require('../../../modules/registries')
|
||||
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||
const pushDispatch = require('../../../utils/pushDispatch')
|
||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||
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 })
|
||||
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 {
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -31,6 +31,7 @@ const emailRouter = require('./email.router')
|
||||
const discordBotRouter = require('./discordBot.router')
|
||||
const settingsRouter = require('./settings.router')
|
||||
const modulesRouter = require('./modules.router')
|
||||
const teamsRouter = require('./teams.router')
|
||||
const dashboardRouter = require('./dashboard.router')
|
||||
|
||||
const adminRouter = express.Router()
|
||||
@@ -79,6 +80,11 @@ adminRouter.use('/settings', settingsRouter)
|
||||
// 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.
|
||||
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).
|
||||
adminRouter.use('/teams', teamsRouter)
|
||||
|
||||
// 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
|
||||
|
||||
@@ -6,6 +6,7 @@ const moderation = require('../../../model/moderation/moderation.model')
|
||||
const modNotes = require('../../../model/modNotes/modNotes.model')
|
||||
const modNotesDb = require('../../../model/modNotes/modNotes.db')
|
||||
const appeals = require('../../../model/appeals/appeals.model')
|
||||
const contentReports = require('../../../model/reports/contentReports.model')
|
||||
const { isTerminal, isAppealableType, reversalStatusFor } = require('../../../model/appeals/appeals.pure')
|
||||
const botInternalClient = require('../../../utils/botInternalClient')
|
||||
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 = {
|
||||
getSummary,
|
||||
getRecent,
|
||||
@@ -311,4 +374,6 @@ module.exports = {
|
||||
claimAppeal,
|
||||
resolveAppeal,
|
||||
getUserAppeals,
|
||||
getContentReports,
|
||||
handleContentReport,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// `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 moderation = require('./moderation.controller')
|
||||
const contentReports = require('../../../model/reports/contentReports.model')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
@@ -171,4 +173,34 @@ moderationRouter.get(
|
||||
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 post’s excerpt and author, a thread’s title, or an upload’s 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
|
||||
|
||||
@@ -265,9 +265,13 @@ async function disable(req, res) {
|
||||
// this is about to delete, so after an uninstall there is nothing left to purge
|
||||
// with. Ticking the box is the last moment the file exists.
|
||||
//
|
||||
// The order below is the whole of it, and each step depends on the one above:
|
||||
// purge while the SQL is still readable, stop while the code is still loaded,
|
||||
// then delete.
|
||||
// The order below is the whole of it: locate the purge file, STOP, purge, then
|
||||
// delete. Only the last step is destructive to the filesystem, so the file stays
|
||||
// readable the whole way through — which is why stopping comes first. Dropping a
|
||||
// module's tables while it is still started leaves it serving and ingesting
|
||||
// against a schema that no longer exists: module-uo's uo-link WebSocket keeps
|
||||
// writing shard events for as long as its onShutdown takes to run, and requests
|
||||
// in flight answer 500 where a stopped module answers 404.
|
||||
async function remove(req, res) {
|
||||
const { id } = req.params
|
||||
const purge = req.query.purge === 'true' || req.query.purge === '1'
|
||||
@@ -276,23 +280,26 @@ async function remove(req, res) {
|
||||
const onVolume = install.isInstalled(id)
|
||||
if (!current && !onVolume) return res.status(404).json({ message: 'No such module.' })
|
||||
|
||||
let purged = null
|
||||
// Resolved before anything happens, because this branch is a 400: a request
|
||||
// that is going to be refused must not stop the module on its way out.
|
||||
let purgeSql = null
|
||||
if (purge) {
|
||||
const file = install.purgeFile(id)
|
||||
if (!file) {
|
||||
purgeSql = install.purgeFile(id)
|
||||
if (!purgeSql) {
|
||||
return res.status(400).json({
|
||||
message: 'This module ships no purge.sql, so its data cannot be deleted. Uninstall without purging instead.',
|
||||
})
|
||||
}
|
||||
purged = await schema.runPurge(file)
|
||||
}
|
||||
|
||||
// Stop it before its files vanish. A module whose directory is deleted out
|
||||
// from under a running onShutdown is being asked to tear down a world whose
|
||||
// code may already be half-unreadable — and its sockets would otherwise stay
|
||||
// open until the restart, holding a connection on behalf of a module that no
|
||||
// longer exists on disk.
|
||||
// Stop it before its tables and then its files vanish. A module whose
|
||||
// directory is deleted out from under a running onShutdown is being asked to
|
||||
// tear down a world whose code may already be half-unreadable — and its
|
||||
// sockets would otherwise stay open until the restart, holding a connection
|
||||
// on behalf of a module that no longer exists on disk.
|
||||
await lifecycle.stop(id)
|
||||
|
||||
const purged = purgeSql ? await schema.runPurge(purgeSql) : null
|
||||
const removed = await install.removeDir(id)
|
||||
|
||||
// A purge leaves nothing: no directory, no tables, no data. Keeping a
|
||||
|
||||
285
server/src/router/v1/admin/teams.controller.js
Normal file
285
server/src/router/v1/admin/teams.controller.js
Normal file
@@ -0,0 +1,285 @@
|
||||
// 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 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')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
forumModeration,
|
||||
forumUploads,
|
||||
forumSettingsState,
|
||||
listTeams,
|
||||
getTeam,
|
||||
resync,
|
||||
archive,
|
||||
grants,
|
||||
setLeaderOverride,
|
||||
clearLeaderOverride,
|
||||
unhide,
|
||||
hide,
|
||||
displayName,
|
||||
reviewQueue,
|
||||
listRequests,
|
||||
decideRequest,
|
||||
}
|
||||
268
server/src/router/v1/admin/teams.router.js
Normal file
268
server/src/router/v1/admin/teams.router.js
Normal file
@@ -0,0 +1,268 @@
|
||||
// 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 teamsRouter = express.Router()
|
||||
|
||||
// ── Literal paths, first ───────────────────────────────────────────────────
|
||||
|
||||
teamsRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'List Teams with sync state'
|
||||
// #swagger.description = 'Includes hidden Teams and the module’s 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 provider’s 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 acknowledgement’s 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,
|
||||
)
|
||||
|
||||
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 Team’s forum moderation ledger'
|
||||
// #swagger.description = 'Append-only, and deliberately separate from the site’s 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 leader’s action appears only here, a staffer’s 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 Team’s 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 Team’s `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 module’s 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
|
||||
@@ -6,6 +6,7 @@
|
||||
const pushDevices = require('../../../model/pushDevices/pushDevices.model')
|
||||
const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model')
|
||||
const registries = require('../../../modules/registries')
|
||||
const teamPrefs = require('../../../model/teams/teamNotify.model')
|
||||
const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
|
||||
|
||||
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 = {
|
||||
registerDevice,
|
||||
listDevices,
|
||||
@@ -85,4 +119,6 @@ module.exports = {
|
||||
getStreams,
|
||||
getSubscriptions,
|
||||
putSubscriptions,
|
||||
getTeamPrefs,
|
||||
putTeamPrefs,
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const notif = require('./notifications.controller')
|
||||
const { requireAuth } = require('../../../auth/session.middleware')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const { EMAIL_MODES } = require('../../../model/teams/teamNotify.model')
|
||||
|
||||
const notifRouter = express.Router()
|
||||
|
||||
@@ -97,4 +98,39 @@ notifRouter.put(
|
||||
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 user’s 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 user’s 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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const botConfig = require('../../../model/botConfig/botConfig.model')
|
||||
const slashCommands = require('../../../utils/slashCommands')
|
||||
const log = require('../../../utils/logger')('internal')
|
||||
|
||||
// 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 }
|
||||
|
||||
@@ -16,4 +16,20 @@ router.get(
|
||||
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
|
||||
|
||||
@@ -26,6 +26,8 @@ const noindex = require('../../../middleware/noindex')
|
||||
|
||||
const accountRouter = require('./account.router')
|
||||
const appealsRouter = require('./appeals.router')
|
||||
const teamsRouter = require('./teams.router')
|
||||
const teamForumRouter = require('./teamForum.router')
|
||||
|
||||
const playerRouter = express.Router()
|
||||
|
||||
@@ -39,5 +41,10 @@ playerRouter.use(noindex, requireAuth)
|
||||
|
||||
playerRouter.use('/account', accountRouter)
|
||||
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
|
||||
|
||||
485
server/src/router/v1/player/teamForum.controller.js
Normal file
485
server/src/router/v1/player/teamForum.controller.js
Normal 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,
|
||||
}
|
||||
297
server/src/router/v1/player/teamForum.router.js
Normal file
297
server/src/router/v1/player/teamForum.router.js
Normal 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 forum’s 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 FORUM’s 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 Team’s own append-only moderation ledger recording WHICH authority was exercised; a staff-exercised one additionally writes activity_log, so the site’s staff-accountability trail sees it while a leader’s ordinary housekeeping stays out of it. Deliberately not routed through the site’s 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 thread’s state is what refuses. Locked refuses staff too: they hold `unlock`, so unlock/post/relock reaches the same place leaving three ledger rows that say what happened.'
|
||||
// #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 else’s 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 thread’s 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 Team’s 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 issuer’s 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 grantee’s 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 Team’s 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 Team’s 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 path’s 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 client’s 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
|
||||
28
server/src/router/v1/player/teams.controller.js
Normal file
28
server/src/router/v1/player/teams.controller.js
Normal 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 }
|
||||
46
server/src/router/v1/player/teams.router.js
Normal file
46
server/src/router/v1/player/teams.router.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// Player · Teams — the caller's own Teams and their own access on one.
|
||||
//
|
||||
// Mounted at /api/v1/player/teams by player/index.js, which already applied
|
||||
// `noindex, requireAuth`. No extra gate: both handlers are self-scoped to
|
||||
// req.user.id and neither takes a user id from the caller.
|
||||
//
|
||||
// **Staff are a superset of players.** This group is open to any authenticated
|
||||
// account, not just role 'player' — a moderator is in guilds too, and gating on
|
||||
// the role would 403 them off their own Teams. That mistake has been made here
|
||||
// once already (see player/index.js).
|
||||
//
|
||||
// Leader-exercised actions — granting forum access — land in phase 4 and will
|
||||
// live under this same prefix rather than under /admin: a leader is a player, and
|
||||
// the /admin tier gate is requireRole('admin','editor','moderator'), so putting a
|
||||
// leader endpoint behind it would mean widening that gate.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./teams.controller')
|
||||
|
||||
const teamsRouter = express.Router()
|
||||
|
||||
teamsRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'List the caller’s Teams, with the reason for each'
|
||||
// #swagger.description = 'Membership and forum grants are separate authority paths, so each Team carries `reason`: membership | grant | both. A Team hidden from public surfaces is still listed here — suppression is a public-surface rule, and a member is not a member of the public.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The caller’s Teams', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerTeamList" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Account not active (disabled/banned)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.listMine,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/:slug/access',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'The caller’s own resolved access on one Team'
|
||||
// #swagger.description = 'Reports viaMembership and viaGrant separately, and keeps both when both hold: the UI presents membership as the current reason while the grant survives as audit history.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The caller’s access', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerTeamAccess" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.getMyAccess,
|
||||
)
|
||||
|
||||
module.exports = teamsRouter
|
||||
@@ -21,6 +21,7 @@ const postsRouter = require('./posts.router')
|
||||
const wikiRouter = require('./wiki.router')
|
||||
const pagesRouter = require('./pages.router')
|
||||
const modulesRouter = require('./modules.router')
|
||||
const teamsRouter = require('./teams.router')
|
||||
const siteRouter = require('./site.router')
|
||||
|
||||
const publicRouter = express.Router()
|
||||
@@ -36,6 +37,10 @@ publicRouter.use('/pages', pagesRouter)
|
||||
// /modules unclaimable by a module. Never site-mode gated: a client must be able
|
||||
// to feature-detect while the site is in maintenance.
|
||||
publicRouter.use('/modules', modulesRouter)
|
||||
// Teams. A core prefix, not a module's: the entity is core's even though a module
|
||||
// is what populates it (TEAMS.md §10.3). Site-mode gated per route, like the
|
||||
// content above it.
|
||||
publicRouter.use('/teams', teamsRouter)
|
||||
|
||||
// The four singletons that own no path segment of their own: /settings, /status,
|
||||
// /version and /contact. Mounted at the group root, last — safe only because
|
||||
|
||||
151
server/src/router/v1/public/teams.controller.js
Normal file
151
server/src/router/v1/public/teams.controller.js
Normal file
@@ -0,0 +1,151 @@
|
||||
// Public · Teams — the anonymous read surface (TEAMS.md §2.11).
|
||||
//
|
||||
// Every handler here is a projection over core's own tables; nothing calls the
|
||||
// module. A Team page must render while the shard is down, showing a roster
|
||||
// marked stale, because that is what the projection is for.
|
||||
|
||||
const teams = require('../../../model/teams/teams.model')
|
||||
const teamActivity = require('../../../model/teams/teamActivity.model')
|
||||
const teamPrefs = require('../../../model/teams/teamNotify.model')
|
||||
const unsubscribeToken = require('../../../utils/unsubscribeToken')
|
||||
|
||||
const log = require('../../../utils/logger')('teams')
|
||||
|
||||
const fail = (res, err, what) => {
|
||||
log.error(`public teams: ${what} failed`, { message: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
async function listTeams(req, res) {
|
||||
try {
|
||||
const limit = Math.min(Number.parseInt(req.query.limit, 10) || 50, 200)
|
||||
const offset = Math.max(Number.parseInt(req.query.offset, 10) || 0, 0)
|
||||
return res.json(await teams.listPublic({ limit, offset }))
|
||||
} catch (err) {
|
||||
return fail(res, err, 'list')
|
||||
}
|
||||
}
|
||||
|
||||
async function getTeam(req, res) {
|
||||
try {
|
||||
const team = await teams.getPublic(req.params.slug)
|
||||
// A hidden Team is indistinguishable from a missing one here, deliberately:
|
||||
// "absent from every public surface" includes not confirming it exists.
|
||||
if (!team) return res.status(404).json({ message: 'Team not found' })
|
||||
return res.json(team)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'get')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One Team, named the way the calling MODULE names it (§3.4 as amended).
|
||||
*
|
||||
* The one route that exists purely so a module's page can find core's Team
|
||||
* without holding core's identifiers. Both parameters come from the path and the
|
||||
* module id is MATCHED, not trusted: `external_id` is unique only within a
|
||||
* module, so scoping the lookup is what stops one module reading another's Team
|
||||
* by guessing a serial.
|
||||
*/
|
||||
async function getTeamByExternalId(req, res) {
|
||||
try {
|
||||
const team = await teams.getPublicByExternalId(req.params.moduleId, req.params.externalId)
|
||||
if (!team) return res.status(404).json({ message: 'Team not found' })
|
||||
return res.json(team)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'by-external')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The roster, projected for whoever is asking (§3.3).
|
||||
*
|
||||
* The viewer is described to the module rather than handed over: it gets the
|
||||
* caller's id and role, which is what a rung decision turns on, and not the user
|
||||
* row — a module has `ctx.users.getById` if it needs more, and passing the whole
|
||||
* record here would make every column of `users` part of this contract.
|
||||
*/
|
||||
async function getRoster(req, res) {
|
||||
try {
|
||||
const viewer = req.user ? { userId: req.user.id, role: req.user.role } : null
|
||||
const roster = await teams.rosterPublic(req.params.slug, viewer)
|
||||
if (!roster) return res.status(404).json({ message: 'Team not found' })
|
||||
return res.json(roster)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'roster')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Team's activity feed (§4.3).
|
||||
*
|
||||
* The only handler in this tier that reads `req.user`, and it reads nothing else
|
||||
* from the caller about what they may see: `limit` and `offset` are page
|
||||
* controls, and the visibility filter is resolved from the session alone. A
|
||||
* request parameter naming its own visibility is the bug the ENUM exists to
|
||||
* prevent, so there is deliberately no way to ask for one.
|
||||
*
|
||||
* The cap is 100 rather than the index's 200 — every row carries a summary and an
|
||||
* opaque payload, so a page of these is much larger than a page of Teams.
|
||||
*/
|
||||
async function getActivity(req, res) {
|
||||
try {
|
||||
const limit = Math.min(Math.max(Number.parseInt(req.query.limit, 10) || 50, 1), 100)
|
||||
const offset = Math.max(Number.parseInt(req.query.offset, 10) || 0, 0)
|
||||
const feed = await teamActivity.feedFor(req.params.slug, req.user ? req.user.id : null, { limit, offset })
|
||||
if (!feed) return res.status(404).json({ message: 'Team not found' })
|
||||
return res.json(feed)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'activity')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /public/teams/unsubscribe/:token — one-click unsubscribe (TEAMS.md §6.4).
|
||||
*
|
||||
* **The one write in this tier, and it is unauthenticated on purpose.** A person
|
||||
* reading their mail is not logged into the site, and an unsubscribe that first
|
||||
* demands a login is an unsubscribe most people do not complete. The token is what
|
||||
* stands in for the session, and the capability it carries is deliberately the
|
||||
* narrowest one that does the job: set `muted` for ONE (user, Team) pair. It reads
|
||||
* nothing, cannot un-mute, and names no other Team.
|
||||
*
|
||||
* **Always 200, whatever the token was.** A response that distinguished a valid
|
||||
* token from a forged one would turn this into an oracle for which (user, Team)
|
||||
* pairs exist, on an endpoint with no session behind it. The page says "you will
|
||||
* not receive further emails about this team" either way, which is true either way.
|
||||
*
|
||||
* Reached two ways with the same effect: a mail client's RFC 8058 one-click POST
|
||||
* (the `List-Unsubscribe-Post` header), and the site's own /unsubscribe page,
|
||||
* which POSTs here after a human clicks the link in the body.
|
||||
*/
|
||||
async function unsubscribe(req, res) {
|
||||
const claim = unsubscribeToken.verify(req.params.token)
|
||||
if (claim) {
|
||||
try {
|
||||
await teamPrefs.mute(claim.userId, claim.teamId)
|
||||
} catch (err) {
|
||||
// Logged, not surfaced. A failed write here is worth an operator's
|
||||
// attention and is not worth telling an anonymous caller about — and a 500
|
||||
// would make a mail client retry a request it should not repeat.
|
||||
log.error('unsubscribe', err)
|
||||
}
|
||||
}
|
||||
return res.json({ ok: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET on the same path — for a mail client that shows the `List-Unsubscribe` URL
|
||||
* as a link and has no one-click support.
|
||||
*
|
||||
* Redirects to the site's own page rather than acting, because a GET must not
|
||||
* mutate: a link prefetcher or a mail client's link scanner would otherwise
|
||||
* silently mute Teams nobody asked to leave. The page it lands on does the POST
|
||||
* once a human is looking at it.
|
||||
*/
|
||||
function unsubscribeLanding(req, res) {
|
||||
const base = (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
return res.redirect(302, `${base}/unsubscribe/${encodeURIComponent(req.params.token)}`)
|
||||
}
|
||||
|
||||
module.exports = { listTeams, getTeam, getTeamByExternalId, getRoster, getActivity, unsubscribe, unsubscribeLanding }
|
||||
127
server/src/router/v1/public/teams.router.js
Normal file
127
server/src/router/v1/public/teams.router.js
Normal file
@@ -0,0 +1,127 @@
|
||||
// Public · Teams — the anonymous Team surface (TEAMS.md §2.11).
|
||||
//
|
||||
// Mounted at /api/v1/public/teams by public/index.js. No group gate: this is the
|
||||
// anonymous surface, and `siteMode` is applied per route as everywhere else in
|
||||
// this tier — during maintenance only an admin with a valid session sees content.
|
||||
//
|
||||
// Declaration order: '/' is literal and precedes the two :slug routes, and
|
||||
// '/:slug/members' is deeper than '/:slug', so nothing here can shadow anything
|
||||
// else.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./teams.controller')
|
||||
const siteMode = require('../../../middleware/siteMode')
|
||||
const { optionalAuth } = require('../../../auth/session.middleware')
|
||||
|
||||
const teamsRouter = express.Router()
|
||||
|
||||
teamsRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Public · Teams']
|
||||
// #swagger.summary = 'List active, publicly visible Teams'
|
||||
// #swagger.description = 'Teams hidden by reserved-name screening or by staff are absent. The response carries { stale, lastSyncAt } so a client can say how recently the projection was confirmed against the game.'
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 200 (default 50).' }
|
||||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||||
/* #swagger.responses[200] = { description: 'Publicly visible Teams, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamList" } } } } */
|
||||
siteMode,
|
||||
ctrl.listTeams,
|
||||
)
|
||||
|
||||
// Declared before the ':slug' routes. It cannot be shadowed by them — it has
|
||||
// three path segments and they have one or two — but keeping it above makes the
|
||||
// relationship visible to whoever adds the next route here.
|
||||
teamsRouter.get(
|
||||
'/by-external/:moduleId/:externalId',
|
||||
// #swagger.tags = ['Public · Teams']
|
||||
// #swagger.summary = 'Get one Team by the owning module’s own identifier'
|
||||
// #swagger.description = 'Exists so a module’s page can find core’s Team without holding core’s identifiers, which are core-internal. The module id is matched rather than trusted: an external id is unique only within a module, so the scope is what stops one module reading another’s Team by guessing a serial. A hidden Team returns 404, like every other public lookup.'
|
||||
// #swagger.parameters['moduleId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The module that owns the Team.' }
|
||||
// #swagger.parameters['externalId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'That module’s own identifier for it.' }
|
||||
/* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeam" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
ctrl.getTeamByExternalId,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/:slug',
|
||||
// #swagger.tags = ['Public · Teams']
|
||||
// #swagger.summary = 'Get one Team by slug'
|
||||
// #swagger.description = 'An archived Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A hidden Team returns 404, indistinguishable from one that does not exist.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
/* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeam" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
ctrl.getTeam,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/:slug/members',
|
||||
// #swagger.tags = ['Public · Teams']
|
||||
// #swagger.summary = 'Get a Team roster'
|
||||
// #swagger.description = 'In-game display names only. A member key is a game-internal identifier and a user id names a site account; neither is published, whatever the module’s projection answers. `linked` answers whether a character has an account behind it without saying which. WHICH rows appear is the module’s audience projection; sending a session is optional and may widen it.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The roster, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamRoster" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
optionalAuth,
|
||||
ctrl.getRoster,
|
||||
)
|
||||
|
||||
// The one route in this tier that reads the caller's identity. `optionalAuth`
|
||||
// serves anonymous callers rather than rejecting them, and identifies an
|
||||
// authenticated one properly enough that a banned or logged-out account drops
|
||||
// back to the public half of the feed at once (TEAMS.md §4.3).
|
||||
teamsRouter.get(
|
||||
'/:slug/activity',
|
||||
// #swagger.tags = ['Public · Teams']
|
||||
// #swagger.summary = 'A Team’s activity feed, filtered to what the caller may see'
|
||||
// #swagger.description = 'Items are `public` or `members`. Anyone who can see the Team gets the public ones; members and forum-granted users also get the members-only ones, and the response says which via `scope` so a client can render "some items are hidden" rather than presenting a filtered feed as the whole one. Sending a session is optional.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 100 (default 50).' }
|
||||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||||
// #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'One page of the feed', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamActivity" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden from this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
optionalAuth,
|
||||
ctrl.getActivity,
|
||||
)
|
||||
|
||||
// ── One-click unsubscribe (TEAMS.md §6.4) ──────────────────────────────────
|
||||
//
|
||||
// Declared last, and the shadowing question is worth answering rather than
|
||||
// assuming: these are two segments, so the one-segment '/:slug' cannot take them,
|
||||
// and the two-segment '/:slug/members' and '/:slug/activity' both pin a LITERAL
|
||||
// second segment. Only a token spelled exactly "members" or "activity" could
|
||||
// collide, and a token is `<v>.<uid>.<tid>.<mac>`.
|
||||
//
|
||||
// No `siteMode`, unlike every other route in this file. An unsubscribe has to work
|
||||
// while the site is in maintenance: the mail that carried the link went out before
|
||||
// the site went down, and "we are doing maintenance" is not an answer to "stop
|
||||
// emailing me".
|
||||
teamsRouter.post(
|
||||
'/unsubscribe/:token',
|
||||
// #swagger.tags = ['Public · Teams']
|
||||
// #swagger.summary = 'Unsubscribe from one Team’s notification emails'
|
||||
// #swagger.description = 'Honours the tokened link in a Team notification email, including RFC 8058 one-click. Sets the same per-Team mute the account screen shows. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, Team) pairs exist.'
|
||||
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
|
||||
// #swagger.security = [{}]
|
||||
/* #swagger.responses[200] = { description: 'Acknowledged', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
|
||||
ctrl.unsubscribe,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/unsubscribe/:token',
|
||||
// #swagger.tags = ['Public · Teams']
|
||||
// #swagger.summary = 'Land a human on the unsubscribe page'
|
||||
// #swagger.description = 'For mail clients that render the List-Unsubscribe URL as an ordinary link. Redirects to the site’s own confirmation page and changes nothing — a GET must not mutate, or a link scanner would mute Teams nobody asked to leave.'
|
||||
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
|
||||
// #swagger.security = [{}]
|
||||
/* #swagger.responses[302] = { description: 'Redirect to the site’s unsubscribe page' } */
|
||||
ctrl.unsubscribeLanding,
|
||||
)
|
||||
|
||||
module.exports = teamsRouter
|
||||
@@ -9,6 +9,9 @@ const http = require('http')
|
||||
// now because none of it reaches the loader's scan.
|
||||
const botScore = require('./middleware/botScore')
|
||||
const announceWorker = require('./utils/announceWorker')
|
||||
const teamActivityPrune = require('./utils/teamActivityPrune')
|
||||
const teamForumUploadSweep = require('./utils/teamForumUploadSweep')
|
||||
const teamDigestWorker = require('./utils/teamDigestWorker')
|
||||
const { ensureSchema, close } = require('./utils/db')
|
||||
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
||||
const settings = require('./model/settings/settings.model')
|
||||
@@ -150,6 +153,13 @@ async function start() {
|
||||
// retry per leg. No-op until a news post is actually published.
|
||||
announceWorker.start()
|
||||
|
||||
// Bound the per-Team activity feed (TEAMS.md §4.2). A feed fed by a game loop
|
||||
// is the obvious unbounded-growth failure, so retention starts with the feed
|
||||
// rather than after someone notices. No-op on a deployment with no Teams.
|
||||
teamActivityPrune.start()
|
||||
teamForumUploadSweep.start()
|
||||
teamDigestWorker.start()
|
||||
|
||||
setupShutdown(server, internalServer)
|
||||
}
|
||||
|
||||
@@ -167,6 +177,9 @@ function setupShutdown(server, internalServer) {
|
||||
await moduleLifecycle.shutdown()
|
||||
botScore.stopSweeper() // stop the bot-store cleanup interval
|
||||
announceWorker.stop() // stop the news-announcement dispatcher poller
|
||||
teamActivityPrune.stop() // stop the Team activity retention timer
|
||||
teamForumUploadSweep.stop() // stop the forum upload sweep
|
||||
teamDigestWorker.stop() // stop the Team forum digest timer
|
||||
server.close(() => log.info('http server closed'))
|
||||
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
|
||||
try {
|
||||
|
||||
@@ -72,4 +72,16 @@ function reverseModAction({ discordUserId, actionType, appealId }) {
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { pushConfig, getStatus, announce, reverseModAction }
|
||||
// Site -> bot: the registered slash-command set has moved, re-pull it
|
||||
// (TEAMS.md §7.1). Best-effort like everything else here — a bot that is down
|
||||
// re-pulls on its next `ready` anyway, so a missed nudge costs nothing but the
|
||||
// delay until the bot reconnects.
|
||||
//
|
||||
// Its own endpoint rather than a field on pushConfig, whose body carries the
|
||||
// DECRYPTED bot token: telling the bot that a module changed should not require
|
||||
// reading a secret out of the database.
|
||||
function refreshCommands() {
|
||||
return call('/internal/refresh-commands', { method: 'POST', body: {} })
|
||||
}
|
||||
|
||||
module.exports = { pushConfig, getStatus, announce, reverseModAction, refreshCommands }
|
||||
|
||||
213
server/src/utils/forumHtml.js
Normal file
213
server/src/utils/forumHtml.js
Normal file
@@ -0,0 +1,213 @@
|
||||
// ── The forum's own HTML profile, and core's image renderer ────────────────
|
||||
//
|
||||
// TEAMS.md §5.5.3, which is the load-bearing decision of the whole forum design
|
||||
// and is deliberately NOT how the rest of the site works.
|
||||
//
|
||||
// **The author never writes an `<img>` tag.** Core's shared sanitizer
|
||||
// (utils/sanitizeHtml.js) allows `<img>` from any http/https host — it is tuned
|
||||
// for rich text from the ADMIN editor, where the author is already trusted.
|
||||
// Handing that profile to arbitrary players would make `teams_forum_images`
|
||||
// unenforceable: every post could hotlink in every mode and the setting would be
|
||||
// decoration. So the forum derives its own profile in which `img` is never an
|
||||
// allowed tag, in any mode.
|
||||
//
|
||||
// What an author writes is a URL. What decides whether it becomes a picture is
|
||||
// this file's renderer, at READ time:
|
||||
//
|
||||
// author types: https://example.com/banner.png
|
||||
// stored HTML: <a href="…" rel="noopener noreferrer nofollow">https://…</a>
|
||||
// rendered: that link, and — in `remote`/`uploads` mode only — a
|
||||
// core-generated <img> beneath it
|
||||
//
|
||||
// Five properties fall out, and they are the reason for the design:
|
||||
//
|
||||
// 1. The policy is ENFORCEABLE, because the only code that can emit an <img>
|
||||
// is this file.
|
||||
// 2. Flipping the setting back to `disabled` retroactively un-renders every
|
||||
// image on every existing post, with NO data migration — the images were
|
||||
// never in the stored HTML.
|
||||
// 3. No attribute smuggling: no author-supplied srcset, onerror, width=99999
|
||||
// or style. Core emits a fixed attribute set.
|
||||
// 4. The link always survives. A blocked, dead or 404ing image degrades to the
|
||||
// URL the author actually wrote, which is what the reader wanted anyway.
|
||||
// 5. It matches how forums conventionally behave.
|
||||
//
|
||||
// **Never proxy or cache a remote image server-side.** The moment the server
|
||||
// fetches a user-supplied URL it is an SSRF vector, and an allow-set is useless
|
||||
// here because the whole point is arbitrary hosts. The browser fetches; the
|
||||
// server never does. Written down so nobody adds a proxy "for performance".
|
||||
|
||||
const sanitizeHtml = require('sanitize-html')
|
||||
|
||||
// Derived from the shared profile with the image family removed. `figure` and
|
||||
// `figcaption` go with `img` rather than surviving it: without an image inside,
|
||||
// a figure is an empty box, and leaving them would let an author build a caption
|
||||
// for a picture core decided not to render.
|
||||
const FORUM_OPTIONS = {
|
||||
allowedTags: [
|
||||
'h3', 'h4', 'h5', 'h6',
|
||||
'p', 'br', 'hr', 'blockquote', 'pre', 'code',
|
||||
'ul', 'ol', 'li',
|
||||
'strong', 'b', 'em', 'i', 'u', 's', 'sup', 'sub', 'mark', 'span',
|
||||
'a',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||
],
|
||||
allowedAttributes: {
|
||||
// `rel` is allowed only so the transform below can WRITE it — an author's own
|
||||
// rel is overwritten, not merged. Without it here, sanitize-html strips the
|
||||
// very attribute the transform just added and every link ships without
|
||||
// noopener.
|
||||
a: ['href', 'title', 'rel'],
|
||||
th: ['colspan', 'rowspan'],
|
||||
td: ['colspan', 'rowspan'],
|
||||
},
|
||||
// No `style` at all, and therefore no allowedStyles. The shared profile permits
|
||||
// text-align for the admin editor's block alignment; a forum post has no such
|
||||
// editor and every style attribute a player could send is one more thing to
|
||||
// reason about.
|
||||
allowedSchemes: ['http', 'https', 'mailto'],
|
||||
allowProtocolRelative: false,
|
||||
transformTags: {
|
||||
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }, true),
|
||||
},
|
||||
disallowedTagsMode: 'discard',
|
||||
}
|
||||
|
||||
// What may become a picture. Conservative on purpose: guessing wrong renders an
|
||||
// <img> pointed at something that is not an image, which reads as a broken site.
|
||||
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif']
|
||||
|
||||
// Tags whose text is left alone by the linkifier. Inside an anchor because
|
||||
// nesting one is invalid; inside code/pre because a URL in a code sample is
|
||||
// being shown, not offered.
|
||||
const NO_LINKIFY = new Set(['a', 'code', 'pre'])
|
||||
|
||||
// Absolute http(s) URLs, and root-relative `/uploads/…` paths.
|
||||
//
|
||||
// The second alternative is not a nicety. In `uploads` mode the composer hands
|
||||
// the author a path like `/uploads/1787…-ab12.png`, puts it in the body as TEXT
|
||||
// (the author never writes markup — that is the whole design), and the renderer
|
||||
// only ever rewrites ANCHORS. Without this branch the write path cannot produce
|
||||
// the anchor the read path looks for, so an uploaded image could never become a
|
||||
// picture — even though `isEmbeddableImageUrl` was written to accept exactly
|
||||
// these paths. The two halves disagreed, and only a real upload showed it.
|
||||
//
|
||||
// Deliberately narrow: `/uploads/` and nothing else, so ordinary prose that
|
||||
// happens to contain a slash is left alone.
|
||||
const BARE_URL = /\bhttps?:\/\/[^\s<>"']+|(?:^|(?<=[\s(]))\/uploads\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*/g
|
||||
|
||||
/**
|
||||
* Sanitise a forum post body. Runs on WRITE; the stored value is already safe and
|
||||
* is served without re-sanitising — the same contract the wiki and the CMS follow.
|
||||
*/
|
||||
function cleanForumBody(html) {
|
||||
if (html == null || html === '') return html
|
||||
return linkify(sanitizeHtml(String(html), FORUM_OPTIONS))
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn bare URLs in text into anchors.
|
||||
*
|
||||
* Runs AFTER sanitising, over the sanitiser's own output, and only on text
|
||||
* outside tags. That ordering is what makes it safe: every text node has already
|
||||
* been HTML-escaped, so the matched URL can go into both the href and the link
|
||||
* text unchanged — `&` is already `&`, which is what an attribute wants.
|
||||
*/
|
||||
function linkify(html) {
|
||||
const tokens = String(html).split(/(<[^>]+>)/)
|
||||
const openStack = []
|
||||
return tokens
|
||||
.map((token) => {
|
||||
if (token.startsWith('<')) {
|
||||
const match = /^<\s*(\/?)\s*([a-zA-Z0-9]+)/.exec(token)
|
||||
if (match) {
|
||||
const [, closing, name] = match
|
||||
const tag = name.toLowerCase()
|
||||
if (closing) {
|
||||
const at = openStack.lastIndexOf(tag)
|
||||
if (at !== -1) openStack.splice(at, 1)
|
||||
} else if (!token.endsWith('/>')) {
|
||||
openStack.push(tag)
|
||||
}
|
||||
}
|
||||
return token
|
||||
}
|
||||
if (openStack.some((tag) => NO_LINKIFY.has(tag))) return token
|
||||
return token.replace(BARE_URL, (url) => {
|
||||
// Trailing punctuation is far more likely to be the sentence's than the
|
||||
// URL's — "see https://example.com." should not link the full stop.
|
||||
const trimmed = url.replace(/[.,;:!?)\]]+$/, '')
|
||||
const tail = url.slice(trimmed.length)
|
||||
return `<a href="${trimmed}" rel="noopener noreferrer nofollow">${trimmed}</a>${tail}`
|
||||
})
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* May this URL become a picture?
|
||||
*
|
||||
* `https:` only, because the CSP is `img-src 'self' data: https:` (config/csp.js)
|
||||
* — an `http:` image is blocked by the browser and renders as a broken picture,
|
||||
* so an `http:` URL stays a plain link. This is a real mismatch with the SHARED
|
||||
* sanitizer, which permits `http` for `img`, and it is exactly the sort of thing
|
||||
* that presents as "images are broken on my forum" with nothing in any log.
|
||||
*
|
||||
* Same-origin `/uploads/…` paths are embeddable too — that is where `uploads`
|
||||
* mode puts a file, and `'self'` covers them under the same CSP.
|
||||
*/
|
||||
function isEmbeddableImageUrl(href) {
|
||||
if (typeof href !== 'string' || href === '') return false
|
||||
const decoded = href.replace(/&/g, '&')
|
||||
let pathname
|
||||
if (decoded.startsWith('/uploads/')) {
|
||||
pathname = decoded.split(/[?#]/)[0]
|
||||
} else {
|
||||
let url
|
||||
try {
|
||||
url = new URL(decoded)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (url.protocol !== 'https:') return false
|
||||
pathname = url.pathname
|
||||
}
|
||||
const lower = pathname.toLowerCase()
|
||||
return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a stored body for one viewer under one image policy.
|
||||
*
|
||||
* `disabled` returns the stored HTML byte-for-byte. The other two append a core-
|
||||
* generated <img> after each anchor whose href looks like an image — which is why
|
||||
* the stored HTML is identical between the three modes, the property this whole
|
||||
* design exists to give.
|
||||
*/
|
||||
function renderForumBody(storedHtml, mode) {
|
||||
if (storedHtml == null || storedHtml === '') return storedHtml
|
||||
if (mode !== 'remote' && mode !== 'uploads') return storedHtml
|
||||
return String(storedHtml).replace(/<a\s[^>]*href="([^"]*)"[^>]*>.*?<\/a>/gi, (anchor, href) => {
|
||||
if (!isEmbeddableImageUrl(href)) return anchor
|
||||
// A fixed attribute set, every time. `no-referrer` limits what leaks to the
|
||||
// third-party host — it cannot prevent the request itself, which is the
|
||||
// privacy cost stated in the admin help text rather than hidden.
|
||||
//
|
||||
// `class` rather than an inline style, for two things the live rig showed:
|
||||
// the embed has to sit BENEATH the link (§5.5.3) and an <img> is inline, so
|
||||
// without it the picture lands beside the URL; and a remote image is any size
|
||||
// its host chooses, so it needs a max-width or one post can blow the column
|
||||
// out. Both live in core's stylesheet (`.forum-embed`) because a style
|
||||
// attribute would then have to survive the client's DOMPurify pass, and its
|
||||
// CSS sanitiser is a larger thing to reason about than one class name.
|
||||
return `${anchor}<img class="forum-embed" src="${href}" loading="lazy" referrerpolicy="no-referrer" alt="">`
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cleanForumBody,
|
||||
renderForumBody,
|
||||
isEmbeddableImageUrl,
|
||||
IMAGE_EXTENSIONS,
|
||||
FORUM_OPTIONS,
|
||||
}
|
||||
@@ -187,4 +187,81 @@ async function sendPasswordReset({ to, resetUrl, username }) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { isConfigured, sendContactMessage, sendTest, sendInvite, sendPasswordReset }
|
||||
/**
|
||||
* Send a Team notification — one event (`immediate` mode) or a day's worth
|
||||
* (`digest` mode). TEAMS.md §6.4.
|
||||
*
|
||||
* **This one carries CONTENT, and the push tickle beside it deliberately does
|
||||
* not.** A tickle goes to ntfy, an untrusted relay reachable by an unguessable
|
||||
* topic, so it carries `{ stream, ref }` and the app pulls the real thing over an
|
||||
* access-checked API. A mailbox is a destination the recipient chose. Same
|
||||
* reasoning as the Discord bridge (§7.2), and it is why this function takes
|
||||
* excerpts rather than ids.
|
||||
*
|
||||
* **Excerpts, never full posts.** Partly courtesy, mostly so that the blast radius
|
||||
* of a mis-addressed or forwarded mail is a sentence rather than a thread. The
|
||||
* caller does the truncation, because it is the caller that knows the body was
|
||||
* already stripped of markup.
|
||||
*
|
||||
* The `List-Unsubscribe` pair is what makes a mail client's own unsubscribe button
|
||||
* appear, and both halves are needed: the `mailto:`-free URL form for clients that
|
||||
* open the link, and `List-Unsubscribe-Post` for RFC 8058 one-click, which POSTs
|
||||
* without ever showing the user a page. Both reach the same tokened endpoint that
|
||||
* writes the same per-Team mute the site shows.
|
||||
*
|
||||
* Never throws. A notification failing must not fail the forum write that caused
|
||||
* it, and there is nobody up the stack to catch it — the digest worker runs on a
|
||||
* timer and the immediate send is fired from a request that has already replied.
|
||||
*/
|
||||
async function sendTeamNotification({ to, subject, intro, items, teamUrl, unsubscribeUrl, unsubscribeApiUrl }) {
|
||||
const built = await buildTransport()
|
||||
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
|
||||
const { transport, config } = built
|
||||
|
||||
const lines = [intro, '']
|
||||
for (const item of items || []) {
|
||||
lines.push(`${item.heading}`)
|
||||
if (item.excerpt) lines.push(` ${item.excerpt}`)
|
||||
if (item.url) lines.push(` ${item.url}`)
|
||||
lines.push('')
|
||||
}
|
||||
if (teamUrl) lines.push(teamUrl, '')
|
||||
if (unsubscribeUrl) {
|
||||
lines.push('To stop these emails for this team, use this link:', unsubscribeUrl)
|
||||
}
|
||||
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to,
|
||||
subject,
|
||||
text: lines.join('\n'),
|
||||
// The header carries the API url, not the one in the body: a one-click
|
||||
// client POSTs to whatever is here without rendering anything, so it has to
|
||||
// be an endpoint. Falls back to the body's url when no API one was passed.
|
||||
headers: (unsubscribeApiUrl || unsubscribeUrl)
|
||||
? {
|
||||
'List-Unsubscribe': `<${unsubscribeApiUrl || unsubscribeUrl}>`,
|
||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
// Logged and swallowed, unlike every other sender in this file. Those are
|
||||
// called by a request that can report the failure to whoever caused it; this
|
||||
// one is not, and recordStatus already puts the error where an admin reads it.
|
||||
log.warn('team notification send failed', { message: err.message })
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message }).catch(() => {})
|
||||
return { sent: false, reason: 'SEND_FAILED' }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isConfigured,
|
||||
sendContactMessage,
|
||||
sendTest,
|
||||
sendInvite,
|
||||
sendPasswordReset,
|
||||
sendTeamNotification,
|
||||
}
|
||||
|
||||
@@ -108,4 +108,40 @@ async function publish(streamId, { ref, ownerUserId } = {}, deps = {}) {
|
||||
await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps)))
|
||||
}
|
||||
|
||||
module.exports = { publish, isAllowedEndpoint }
|
||||
// `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
|
||||
|
||||
/**
|
||||
* Publish one content-free tickle to a COMPUTED SET of users (TEAMS.md §6.2).
|
||||
*
|
||||
* The third fan-out shape. `publish` answers "everyone subscribed" and "this one
|
||||
* owner"; Team notifications need "these N users", because the four `team.*`
|
||||
* streams are global and which Team an event belongs to is expressed by who is in
|
||||
* the set. Nothing about the tickle changes — same `{ stream, ref }`, same
|
||||
* untrusted-relay assumption, same SSRF gate on every endpoint.
|
||||
*
|
||||
* The set arrives already resolved: the caller has asked the access resolver who
|
||||
* may read this Team and subtracted the per-Team mutes. What this function still
|
||||
* enforces is each recipient's own stream subscription, in the query. Never
|
||||
* throws — a notification failing must not fail the write that produced it.
|
||||
*/
|
||||
async function publishToUsers(streamId, { ref, userIds } = {}, deps = {}) {
|
||||
const devices = deps.pushDevices || pushDevicesModel
|
||||
const ids = [...new Set((userIds || []).map(Number).filter(isUserId))]
|
||||
if (ids.length === 0) return
|
||||
let rows
|
||||
try {
|
||||
rows = await devices.endpointsForUsersStream(ids, streamId)
|
||||
} catch (err) {
|
||||
log.warn('push endpoint lookup failed', { streamId, message: err.message })
|
||||
return
|
||||
}
|
||||
if (!rows || rows.length === 0) return
|
||||
const bodyStr = JSON.stringify({ stream: streamId, ref: ref ?? null })
|
||||
await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps)))
|
||||
}
|
||||
|
||||
module.exports = { publish, publishToUsers, isAllowedEndpoint }
|
||||
|
||||
212
server/src/utils/reservedNames.js
Normal file
212
server/src/utils/reservedNames.js
Normal file
@@ -0,0 +1,212 @@
|
||||
// ── Reserved-name screening ────────────────────────────────────────────────
|
||||
//
|
||||
// The one place untrusted game data becomes a public page (TEAMS.md §2.8).
|
||||
//
|
||||
// A Team's name is written by a player, inside the game, with no review, and the
|
||||
// platform then turns it into a public page, a URL, a nav-reachable entity and
|
||||
// eventually a Discord channel name. Someone naming their guild "Admin",
|
||||
// "Moderator" or "<Brand> Staff" gets an official-looking page on the operator's
|
||||
// own site for free, by typing a name into a guild stone.
|
||||
//
|
||||
// **Hide, never reject.** 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 match hides
|
||||
// the Team from public surfaces and puts it in a review queue, and it keeps
|
||||
// working completely for its own members — the people in it are not being
|
||||
// punished for a name their leader chose.
|
||||
//
|
||||
// That asymmetry is what lets this matcher be conservative without being clever:
|
||||
// **a false positive costs a human glance, a false negative costs an impersonated
|
||||
// staff page.**
|
||||
//
|
||||
// NOT `filter_words`. That table exists but is bot-owned (its own pool, never
|
||||
// read by the website — MODERATION_APPEALS.md §2), and it is a profanity filter,
|
||||
// which is a different question with a different answer. Reusing it would cross
|
||||
// an ownership boundary to get the wrong list.
|
||||
//
|
||||
// Also NOT `auth/usernamePolicy.js`'s RESERVED_USERNAMES. That list answers
|
||||
// "may someone register under this handle", matched exactly against a whole
|
||||
// username; this one answers "does this phrase impersonate authority", matched
|
||||
// word by word inside a name that is usually several words long. Sharing them
|
||||
// would give each question the other's answer — "Support" is a fine guild name
|
||||
// and an unacceptable username.
|
||||
|
||||
const brand = require('../config/brand')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const log = require('./logger')('teams')
|
||||
|
||||
// The `users.role` enum plus the words people actually use for those roles. Kept
|
||||
// here rather than derived from the enum alone, because 'gm' and 'staff' are not
|
||||
// roles in the database and are exactly what a would-be impersonator reaches for.
|
||||
const ROLE_TERMS = [
|
||||
'admin', 'editor', 'moderator', 'player',
|
||||
'staff', 'administrator', 'mod', 'owner', 'gm',
|
||||
]
|
||||
|
||||
// Impersonating the software project is as much a problem as impersonating the
|
||||
// operator. Stored in its correct two-word form; §2.8.2's whitespace-insensitive
|
||||
// comparison is what also catches RunicGateway, runic-gateway and Runic_Gateway.
|
||||
const PROJECT_TERMS = ['Runic Gateway']
|
||||
|
||||
const OPERATOR_TERMS_KEY = 'teams_reserved_terms'
|
||||
|
||||
/**
|
||||
* Case-fold, strip punctuation, collapse repeats and whitespace.
|
||||
*
|
||||
* Repeated characters are squeezed so "Adminnn" folds to "admin". Deliberately
|
||||
* NO leet-speak folding in v1 (`4dm1n`): it multiplies false positives, and the
|
||||
* consequence of a miss is a Team hidden by a human rather than a breach.
|
||||
*/
|
||||
function normalise(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]+/g, ' ')
|
||||
.replace(/(.)\1{1,}/g, '$1')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const words = (value) => (value ? value.split(' ') : [])
|
||||
|
||||
/**
|
||||
* The words of a name, plus the acronyms its punctuation was hiding.
|
||||
*
|
||||
* "G.M." normalises to `g m`, and neither token is the reserved term `gm` — so a
|
||||
* run of two or more single-letter words is ALSO offered as one joined token.
|
||||
* "GM" is a live impersonation vector on a game server, and spelling it with dots
|
||||
* is the obvious way around a word-level check.
|
||||
*
|
||||
* The individual letters are kept as well as the joined form, so this only ever
|
||||
* adds matches. And the join is deliberately not the whole-name condensation used
|
||||
* for multi-word terms: condensing every name would let a single-word term match
|
||||
* inside an ordinary word again, which is the substring matching this whole design
|
||||
* refuses.
|
||||
*/
|
||||
function tokens(normalised) {
|
||||
const list = words(normalised)
|
||||
const out = [...list]
|
||||
let run = []
|
||||
const flush = () => {
|
||||
if (run.length > 1) out.push(run.join(''))
|
||||
run = []
|
||||
}
|
||||
for (const word of list) {
|
||||
if (word.length === 1) run.push(word)
|
||||
else flush()
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-word term matches a name word, or that word's singular.
|
||||
*
|
||||
* A guild called "Moderators" impersonates staff exactly as much as one called
|
||||
* "Moderator", and a check that misses the plural misses the more natural name of
|
||||
* the two. Only a trailing `s` is stripped, and only when the remainder is the
|
||||
* whole term — so "Nomads" still does not match "mod" and "Playerless" still does
|
||||
* not match "player".
|
||||
*/
|
||||
const wordMatches = (word, term) =>
|
||||
word === term || (word.length > 1 && word.endsWith('s') && word.slice(0, -1) === term)
|
||||
|
||||
/**
|
||||
* Every reserved term for this deployment, resolved AT CHECK TIME.
|
||||
*
|
||||
* Never baked in: the brand is runtime configuration, so a deployment that
|
||||
* renames itself must be protected under its new name without a redeploy.
|
||||
*
|
||||
* A settings read that fails must not open the gate, so a failure falls back to
|
||||
* the static terms rather than to an empty list — screening fewer terms is bad,
|
||||
* screening none is the whole hole.
|
||||
*/
|
||||
async function reservedTerms() {
|
||||
const terms = [...ROLE_TERMS, ...PROJECT_TERMS]
|
||||
|
||||
try {
|
||||
const instanceName = await settings.getInstanceName()
|
||||
if (instanceName) terms.push(instanceName)
|
||||
} catch (err) {
|
||||
log.warn('could not resolve the instance name for reserved-name screening', { message: err.message })
|
||||
}
|
||||
|
||||
if (brand.name) terms.push(brand.name)
|
||||
if (brand.shortName) terms.push(brand.shortName)
|
||||
|
||||
try {
|
||||
const extra = await settings.get(OPERATOR_TERMS_KEY)
|
||||
if (extra) terms.push(...String(extra).split(',').map((t) => t.trim()).filter(Boolean))
|
||||
} catch (err) {
|
||||
log.warn('could not read operator reserved terms', { message: err.message })
|
||||
}
|
||||
|
||||
// De-duplicated on the normalised form: the brand and an operator term are
|
||||
// frequently the same word, and reporting the same match twice is noise in a
|
||||
// review queue.
|
||||
const seen = new Set()
|
||||
return terms.filter((term) => {
|
||||
const key = normalise(term)
|
||||
if (!key || seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `name` contain `term`?
|
||||
*
|
||||
* Whole WORDS, after normalisation — never substrings. Core already has the
|
||||
* precedent and the scar tissue for this: scripts/checkModuleIdentifiers.js
|
||||
* tokenises and compares word by word precisely so `defaultImage` does not match
|
||||
* "ultIma". The same discipline applies for the same reason — a substring match
|
||||
* flags "Badminton" for containing "admin", and a check that cries wolf is a
|
||||
* check people switch off.
|
||||
*
|
||||
* A MULTI-WORD term is additionally compared with the whitespace removed on both
|
||||
* sides, so "Runic Gateway" matches "RunicGateway". Without that the whole-word
|
||||
* rule fails on exactly the case that matters: the condensed form is a SINGLE
|
||||
* word and could never match a two-word term — and it is the form an impersonator
|
||||
* would reach for, because it is what the Gitea org and every URL already use.
|
||||
*
|
||||
* The widening applies only to terms containing whitespace, which keeps it away
|
||||
* from the single-word terms where whole-word matching is doing the false-positive
|
||||
* work. A two-word term is specific enough that running its letters together
|
||||
* cannot collide with ordinary vocabulary.
|
||||
*/
|
||||
function matches(nameWords, condensedName, term) {
|
||||
const normalisedTerm = normalise(term)
|
||||
if (!normalisedTerm) return false
|
||||
const termWords = words(normalisedTerm)
|
||||
|
||||
if (termWords.length === 1) return nameWords.some((w) => wordMatches(w, termWords[0]))
|
||||
|
||||
// A multi-word term matches as a consecutive run of words …
|
||||
for (let i = 0; i + termWords.length <= nameWords.length; i++) {
|
||||
if (termWords.every((w, j) => nameWords[i + j] === w)) return true
|
||||
}
|
||||
// … or as its condensed form appearing as a whole word in the condensed name.
|
||||
const condensedTerm = termWords.join('')
|
||||
return condensedName.includes(condensedTerm)
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen a name. Returns `{ reserved, term }` — `term` is the term that matched,
|
||||
* in its stored form, which is what the review queue shows a human.
|
||||
*/
|
||||
async function screen(name) {
|
||||
const normalised = normalise(name)
|
||||
if (!normalised) return { reserved: false, term: null }
|
||||
|
||||
const nameWords = tokens(normalised)
|
||||
// The condensed name is the whole thing with spaces removed, so a multi-word
|
||||
// term can be found inside a run-together name.
|
||||
const condensed = words(normalised).join('')
|
||||
|
||||
for (const term of await reservedTerms()) {
|
||||
if (matches(nameWords, condensed, term)) return { reserved: true, term }
|
||||
}
|
||||
return { reserved: false, term: null }
|
||||
}
|
||||
|
||||
module.exports = { screen, normalise, reservedTerms, ROLE_TERMS, PROJECT_TERMS, OPERATOR_TERMS_KEY }
|
||||
239
server/src/utils/slashCommands.js
Normal file
239
server/src/utils/slashCommands.js
Normal file
@@ -0,0 +1,239 @@
|
||||
// Chat-platform slash commands: the actor resolver, the access gate, and the
|
||||
// dispatcher that calls a registrant's handler (TEAMS.md §7.1, API 1.6.0).
|
||||
//
|
||||
// Where this sits. The bot owns every Discord-specific concern — deferral, the
|
||||
// 3-second ack, ephemerality, follow-ups, interaction tokens, embeds — and this
|
||||
// file owns everything that is not Discord-shaped: who is asking, whether they
|
||||
// may, and what the answer is. Nothing below imports discord.js or knows what an
|
||||
// interaction is, which is the whole point: the second platform reuses all of it.
|
||||
//
|
||||
// The handler runs HERE rather than in the bot because the bot container has no
|
||||
// `modules` volume and physically cannot load module code (§0.4).
|
||||
const authProviders = require('../model/authProviders/authProviders.model')
|
||||
const userIdentities = require('../model/userIdentities/userIdentities.model')
|
||||
const users = require('../model/users/users.model')
|
||||
const modules = require('../modules/loader')
|
||||
const registries = require('../modules/registries')
|
||||
const log = require('./logger')('slash-commands')
|
||||
|
||||
// The same two roles every other Team surface calls staff (teamGrants.STAFF_ROLES).
|
||||
// Required here rather than duplicated, so a change to what "staff" means reaches
|
||||
// the Discord surface without anyone having to remember this file exists.
|
||||
const { STAFF_ROLES } = require('../model/teams/teamGrants.model')
|
||||
|
||||
// The handler's own budget, deliberately UNDER the bot's 4s dispatch timeout.
|
||||
//
|
||||
// If the bot's abort fires first, the app is left running a handler whose answer
|
||||
// nobody will read, and the bot reports the same "that failed" either way. Losing
|
||||
// the race on purpose means the wedged handler is identified HERE, in a log line
|
||||
// that names the module, and the request ends.
|
||||
const HANDLER_TIMEOUT_MS = 3000
|
||||
|
||||
// Discord's hard limits on what can be rendered. Enforced on the way OUT because
|
||||
// an oversized reply fails inside the bot's `editReply`, where the module that
|
||||
// produced it cannot be seen — the caller would get "that command failed" for a
|
||||
// command whose handler worked perfectly. Truncation is silent to the user and
|
||||
// logged for the operator.
|
||||
const MAX_TEXT = 2000
|
||||
const MAX_FIELDS = 25
|
||||
const MAX_FIELD_NAME = 256
|
||||
const MAX_FIELD_VALUE = 1024
|
||||
const MAX_URL = 512
|
||||
|
||||
const clamp = (value, max) => (String(value).length > max ? `${String(value).slice(0, max - 1)}…` : String(value))
|
||||
|
||||
/**
|
||||
* Is this registrant's command answerable right now?
|
||||
*
|
||||
* **The registries have no removal path.** Registration happens once, during
|
||||
* `load()`, and nothing takes a claim back — a module the operator disables at
|
||||
* runtime keeps its streams and its announce legs, and would keep its commands
|
||||
* too. That is tolerable for a stream (a catalog entry nobody publishes to) and
|
||||
* NOT tolerable for a command, whose handler is live code an operator believes
|
||||
* they just switched off.
|
||||
*
|
||||
* So liveness is asked at the two moments it matters — the pull and the
|
||||
* dispatch — and it is asked HERE, once, so the two can never disagree. §7.1's
|
||||
* "deregistration on module unload is free" holds across the restart an
|
||||
* uninstall asks for; this is the same promise kept for the runtime toggle,
|
||||
* which that paragraph did not consider.
|
||||
*
|
||||
* `core` is not a module and has no record; it is live whenever the process is.
|
||||
*/
|
||||
function ownerIsLive(owner) {
|
||||
if (owner === 'core') return true
|
||||
if (!modules.isLoaded()) return false
|
||||
const record = modules.list().find((m) => m.id === owner)
|
||||
return Boolean(record && record.state === 'started')
|
||||
}
|
||||
|
||||
/**
|
||||
* What the bot pulls: the live definitions, and the counter it re-registers on.
|
||||
*
|
||||
* Before `load()` has run there is nothing registered and nothing to say, which
|
||||
* is a legitimate answer rather than an error — a bot that connects during boot
|
||||
* pulls an empty set and is nudged once the modules are up. Throwing here would
|
||||
* look to the bot exactly like the app being broken.
|
||||
*/
|
||||
function definitions() {
|
||||
if (!modules.isLoaded()) return { version: 0, commands: [] }
|
||||
return {
|
||||
version: modules.version(),
|
||||
commands: registries.slashCommandDefinitions().filter((c) => ownerIsLive(c.owner)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Who is running this command, in platform-neutral terms.
|
||||
*
|
||||
* A handler never parses a platform payload and never learns anything
|
||||
* platform-shaped beyond `platform` itself. Everything here is resolved before
|
||||
* the handler is entered, so a module cannot decide for itself who it is talking
|
||||
* to — the actor is core's answer, not the caller's claim.
|
||||
*
|
||||
* **The Discord provider is found by `kind`, not by id.** `auth_providers.id` is
|
||||
* an operator-chosen slug and only the CONVENTIONAL deployment calls it
|
||||
* "discord"; the kind column is the enum. Resolving by id would silently return
|
||||
* "not linked" for every user on a deployment that named its provider anything
|
||||
* else, which reads as a bug in linking rather than a bug here.
|
||||
*
|
||||
* A non-active account resolves to UNLINKED rather than to itself: a banned or
|
||||
* disabled user keeping `access: 'linked'` commands would make Discord the one
|
||||
* surface a ban does not reach.
|
||||
*/
|
||||
async function resolveActor({ platform, platformUserId, guildId = null }) {
|
||||
const actor = {
|
||||
platform,
|
||||
platformUserId: platformUserId ? String(platformUserId) : null,
|
||||
guildId: guildId || null,
|
||||
userId: null,
|
||||
role: null,
|
||||
isLinked: false,
|
||||
isStaff: false,
|
||||
}
|
||||
if (platform !== 'discord' || !actor.platformUserId) return actor
|
||||
|
||||
const providers = (await authProviders.list()).filter((p) => p.kind === 'discord')
|
||||
for (const provider of providers) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const identity = await userIdentities.findByProviderSubject(provider.id, actor.platformUserId)
|
||||
if (!identity) continue
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const user = await users.getById(identity.user_id)
|
||||
if (!user || user.status !== 'active') continue
|
||||
actor.userId = user.id
|
||||
// `role` alongside `isStaff`, because the two answer different questions and
|
||||
// collapsing them loses one. `isStaff` is core's gate for `access: 'staff'`;
|
||||
// `role` is what a module needs to place the caller on its OWN ladder — a
|
||||
// module with audience rungs distinguishes admin from moderator and cannot
|
||||
// from a boolean. It is the same pair `projectRoster`'s viewer already
|
||||
// carries (§3.3), not a new class of disclosure.
|
||||
actor.role = user.role
|
||||
actor.isLinked = true
|
||||
actor.isStaff = STAFF_ROLES.includes(user.role)
|
||||
break
|
||||
}
|
||||
return actor
|
||||
}
|
||||
|
||||
/** Does this actor clear the command's declared access level? */
|
||||
function permitted(access, actor) {
|
||||
if (access === 'staff') return actor.isStaff
|
||||
if (access === 'linked') return actor.isLinked
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape and clamp whatever a handler returned into the response envelope.
|
||||
*
|
||||
* A handler returning nothing at all is a handler that ran and had nothing to
|
||||
* say, which is not an error — the bot renders it as an empty acknowledgement.
|
||||
*/
|
||||
function envelope(result, command) {
|
||||
const out = {}
|
||||
const value = result || {}
|
||||
if (value.text) out.text = clamp(value.text, MAX_TEXT)
|
||||
if (value.title) out.title = clamp(value.title, MAX_FIELD_NAME)
|
||||
if (Array.isArray(value.fields) && value.fields.length) {
|
||||
if (value.fields.length > MAX_FIELDS) {
|
||||
log.warn('handler returned too many fields; truncated', { command, fields: value.fields.length })
|
||||
}
|
||||
out.fields = value.fields.slice(0, MAX_FIELDS).map((f) => ({
|
||||
name: clamp(f && f.name ? f.name : '—', MAX_FIELD_NAME),
|
||||
value: clamp(f && f.value ? f.value : '—', MAX_FIELD_VALUE),
|
||||
inline: Boolean(f && f.inline),
|
||||
}))
|
||||
}
|
||||
// Absolute http(s) only. A handler builds its own link from `ctx.site.baseUrl`
|
||||
// — core cannot, since Teams have no core page to link to (§3.1 as amended) —
|
||||
// so the scheme check is the whole of what is enforced here: anything else,
|
||||
// `javascript:` above all, is dropped rather than posted into a channel the
|
||||
// operator's members trust.
|
||||
if (value.url && /^https?:\/\//i.test(value.url)) out.url = clamp(value.url, MAX_URL)
|
||||
out.ephemeral = Boolean(value.ephemeral)
|
||||
// A private aside to the caller, delivered ALONGSIDE a public answer — §9
|
||||
// answer 5's "the public projection plus an ephemeral prompt to link". One
|
||||
// reply cannot be both public and ephemeral, so this is a second message, and
|
||||
// that it is a second message is the platform's business rather than the
|
||||
// handler's: `notice` says "say this to the caller only" and the bot decides it
|
||||
// is a follow-up.
|
||||
if (value.notice) out.notice = clamp(value.notice, MAX_TEXT)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one command and answer with `{ ok, ... }`.
|
||||
*
|
||||
* **Never throws, and never returns a handler's own `ok`.** The result is nested
|
||||
* under `response` precisely so a module cannot forge the success flag the bot
|
||||
* branches on — the envelope is data the handler produced, `ok` is core's verdict
|
||||
* on whether it produced it.
|
||||
*
|
||||
* A refusal carries a `reason` and no user-facing copy: the phrasing of "you need
|
||||
* to link your account" is a platform's own business, and putting the sentence
|
||||
* here would be core writing Discord's voice.
|
||||
*
|
||||
* Failure isolation is per handler, per call. A module whose handler throws or
|
||||
* wedges costs its own command and nothing else — and cannot cost the bot
|
||||
* anything at all, because the handler does not run there.
|
||||
*/
|
||||
async function dispatch({ command, options = {}, platform = 'discord', platformUserId = null, guildId = null }) {
|
||||
const entry = registries.slashCommand(command)
|
||||
// A disabled module's command is UNKNOWN, not forbidden: Discord may still be
|
||||
// advertising it — the whole-set PUT that removes it has not necessarily
|
||||
// happened yet — and "there is no such command" is the truthful answer for one
|
||||
// whose owner is switched off.
|
||||
if (!entry || !ownerIsLive(entry.owner)) return { ok: false, reason: 'unknown' }
|
||||
|
||||
let actor
|
||||
try {
|
||||
actor = await resolveActor({ platform, platformUserId, guildId })
|
||||
} catch (err) {
|
||||
// Identity resolution is core's, not the module's — a database hiccup here
|
||||
// is not the command failing, and saying so would send an operator to read
|
||||
// module code that never ran.
|
||||
log.error('actor resolution failed', { command, message: err.message })
|
||||
return { ok: false, reason: 'error' }
|
||||
}
|
||||
|
||||
// The gate. The bot also sets Discord-side default member permissions from
|
||||
// `access` where it can, but that is advertising; this is the boundary.
|
||||
if (!permitted(entry.access, actor)) {
|
||||
return { ok: false, reason: 'forbidden', access: entry.access, isLinked: actor.isLinked }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
entry.handler({ command, options, actor }),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('handler timed out')), HANDLER_TIMEOUT_MS).unref()
|
||||
}),
|
||||
])
|
||||
return { ok: true, response: envelope(result, command) }
|
||||
} catch (err) {
|
||||
log.error('slash command handler failed', { command, owner: entry.owner, message: err.message })
|
||||
return { ok: false, reason: 'error' }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { definitions, resolveActor, dispatch, envelope, ownerIsLive, HANDLER_TIMEOUT_MS }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user