Compare commits
73 Commits
feature/mo
...
ea3499e70b
| Author | SHA1 | Date | |
|---|---|---|---|
| ea3499e70b | |||
| 563199a096 | |||
| 6016b325bb | |||
| fbb4b0bd91 | |||
| c2e4df5b3d | |||
| 6e61146678 | |||
| f5aa32e0ed | |||
| b77e817fb1 | |||
| c4ab8b9b9d | |||
| 47c8b37d45 | |||
| e25e7ade80 | |||
| 3bca112502 | |||
| c43e092248 | |||
| 0f96a372cf | |||
| 68f038f456 | |||
| 963d734dcc | |||
| 48a3e33be4 | |||
| 335d69d122 | |||
| 9619fdf1e1 | |||
| f72c92ffbe | |||
| 61abb3ec89 | |||
| d1d56cf847 | |||
| 11b4368b57 | |||
| 46f43a5fd6 | |||
| 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 |
27
.env.example
27
.env.example
@@ -56,6 +56,21 @@ DB_ROOT_PASSWORD=change-me-root-password
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=change-me-to-a-long-random-string
|
||||
# Encrypts every secret this site stores at rest (AES-256-GCM): OAuth client
|
||||
# secrets, the Discord bot token, the mail transport credentials, the uo-link auth
|
||||
# token. REQUIRED in production — with NODE_ENV=production the app REFUSES TO
|
||||
# START without it (utils/secretBox.js), so a Compose deployment that leaves it
|
||||
# blank crash-loops before it ever listens. Development falls back to a key
|
||||
# derived from JWT_SECRET, with a warning.
|
||||
#
|
||||
# Any string; it is hashed to 32 bytes. Generate a long random one and treat it
|
||||
# like the database password.
|
||||
#
|
||||
# Changing it on a live instance does NOT re-encrypt anything: every secret
|
||||
# already stored becomes unreadable and has to be entered again from the admin
|
||||
# panel. That is also the reason it is a dedicated key rather than a reuse of
|
||||
# JWT_SECRET — rotating a session secret must not orphan stored credentials.
|
||||
SECRET_ENC_KEY=change-me-to-a-different-long-random-string
|
||||
JWT_EXPIRES_IN=1d
|
||||
# auto = Secure cookie only when the request arrives over HTTPS (Pangolin).
|
||||
# Leave as auto so login works both via the LAN IP (HTTP) and the proxy (HTTPS).
|
||||
@@ -83,10 +98,14 @@ TOTP_CHALLENGE_TTL=5m
|
||||
ADMIN_USERNAME=
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not via
|
||||
# env. It reuses the Google auth provider's OAuth client and stores an encrypted
|
||||
# refresh token in the DB. Until it's connected, the contact form falls back to
|
||||
# a mailto: link (recipient = the `contact_email` site setting).
|
||||
# Email is configured in Admin → Settings → Email, not via env: pick a mail
|
||||
# transport (SMTP) and enter its host, port and credentials, which are stored
|
||||
# encrypted in the DB. Three postures work — a relay (Mailgun/SES/Postmark) is
|
||||
# the recommended one, a mailbox provider over SMTP (e.g. smtp.gmail.com:587
|
||||
# with an app password) is the simplest, and an unauthenticated local MTA on
|
||||
# port 25 needs no credentials at all. Until one is configured the contact form
|
||||
# falls back to a mailto: link (recipient = the `contact_email` site setting).
|
||||
# Upgrading from the removed Gmail connect flow: see docs/website/UPGRADE_NOTES.md.
|
||||
|
||||
# CORS — only needed for local dev when the Vite dev server is a different origin.
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -56,6 +56,12 @@ jobs:
|
||||
# something found under a pile of unrelated failures, and it costs
|
||||
# nothing when it passes.
|
||||
run: npm run check:modules
|
||||
- name: Check the engagement subsystem names no external host
|
||||
# ENGAGEMENT.md §3.2 rule 4 — no transport may ship a default host,
|
||||
# endpoint or sender. Dependency-free and runs before the install for the
|
||||
# same reason as the check above: a phone-home is a design break, not a
|
||||
# test failure, and it should be the first thing a reviewer sees.
|
||||
run: npm run check:hosts
|
||||
- name: Install server deps
|
||||
run: npm ci --prefix server
|
||||
- name: Run server tests
|
||||
@@ -69,6 +75,15 @@ jobs:
|
||||
# of a reviewer instead of letting it pass silently.
|
||||
run: npm run routes:manifest --prefix server -- --check
|
||||
|
||||
- name: Check the engagement trigger manifest is current
|
||||
# ENGAGEMENT.md 4.3 property 4 - the same mechanism as the route manifest
|
||||
# above, for the event contract instead of the URL surface. A trigger
|
||||
# declaration is what a stored template interpolates and what a stored
|
||||
# rule is written against, so renaming a variable or widening a ceiling
|
||||
# breaks them silently, at send time, in mail someone already received.
|
||||
# Regenerating and diffing makes that change something a reviewer reads.
|
||||
run: npm run engagement:manifest --prefix server -- --check
|
||||
|
||||
client-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -86,9 +101,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 +118,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
|
||||
|
||||
17
README.md
17
README.md
@@ -151,7 +151,7 @@ flowchart TB
|
||||
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
|
||||
| Database | MariaDB 11 (own container) |
|
||||
| Frontend | React 18, Vite 5, React Router 6 |
|
||||
| Email | Nodemailer via Gmail OAuth2 (configured in admin), with a `mailto:` fallback |
|
||||
| Email | Nodemailer over a configurable mail transport — SMTP (relay, mailbox provider or your own MTA), set up in the admin panel — with a `mailto:` fallback |
|
||||
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
|
||||
| Deploy | Docker Compose, any reverse proxy (Pangolin, Nginx, Caddy, Traefik, …) |
|
||||
|
||||
@@ -216,7 +216,12 @@ cp .env.example .env
|
||||
# Edit .env and set at least:
|
||||
# DB_PASSWORD, DB_ROOT_PASSWORD (any strong values)
|
||||
# JWT_SECRET (a long random string)
|
||||
# SECRET_ENC_KEY (a different long random string)
|
||||
# BOT_INTERNAL_KEY (a third one, 16+ chars — even with no bot)
|
||||
# ADMIN_USERNAME, ADMIN_PASSWORD (your first admin login)
|
||||
#
|
||||
# SECRET_ENC_KEY and BOT_INTERNAL_KEY are not optional in production: the app
|
||||
# refuses to start without them, so the container crash-loops before it listens.
|
||||
|
||||
docker compose pull && docker compose up -d # IMAGE_TAG defaults to `latest`
|
||||
# pin a specific build (reproducible deploy / rollback):
|
||||
@@ -579,7 +584,7 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
||||
| `TOTP_ISSUER` | `BRAND_NAME` | label shown in authenticator apps for optional per-user 2FA |
|
||||
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
|
||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
|
||||
| _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting |
|
||||
| _Email_ | — | configured in Admin → Settings → Email (transport + credentials), never via env; recipient = `contact_email` setting. Upgrading from the removed Gmail connect flow: see [`docs/website/UPGRADE_NOTES.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/UPGRADE_NOTES.md) |
|
||||
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
||||
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
|
||||
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
|
||||
@@ -672,9 +677,11 @@ run this repo as UOMysticmoon.
|
||||
|
||||
- `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs
|
||||
behind a reverse proxy (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
|
||||
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail
|
||||
OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the
|
||||
contact form falls back to a `mailto:` link when unconfigured.
|
||||
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through a mail
|
||||
transport configured in the admin, whose credentials are stored AES-GCM-encrypted and are
|
||||
write-only over the API (never returned, never in env); no transport ships a default host or
|
||||
sender, so an unconfigured deployment sends nowhere. The contact form falls back to a `mailto:`
|
||||
link when unconfigured.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 }
|
||||
80
bot/src/discord/teamNotify.js
Normal file
80
bot/src/discord/teamNotify.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// Team notifications posted into an operator-configured channel (TEAMS.md §7.2).
|
||||
//
|
||||
// **The channel comes from the app, not from guild_config.** `newsAnnounce` looks
|
||||
// its channel up here because there is exactly one #news; a Team's destination is
|
||||
// per-Team configuration living in `team_integration_config`, and a bot that
|
||||
// resolved it would need a second copy of that table and a second place for it to
|
||||
// drift. The app sends the id it already decided on.
|
||||
//
|
||||
// **Everything this file knows about a Team it was told.** No lookups, no
|
||||
// membership checks, no access decisions: whether this content may reach this
|
||||
// channel was settled on the site, where the acknowledgement that gates it lives.
|
||||
// The bot is the transport, exactly as it is for slash commands.
|
||||
const { EmbedBuilder } = require('discord.js')
|
||||
|
||||
const brand = require('../brand')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('team-notify')
|
||||
|
||||
// Discord's own limits. Truncating here rather than trusting the app is not
|
||||
// distrust — an embed that exceeds them is rejected wholesale, and a message
|
||||
// silently not appearing is the worst failure mode this path has.
|
||||
const TITLE_MAX = 256
|
||||
const DESCRIPTION_MAX = 4096
|
||||
|
||||
const clamp = (value, max) => {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return null
|
||||
return text.length > max ? `${text.slice(0, max - 1)}…` : text
|
||||
}
|
||||
|
||||
// What each stream is called in a channel. The app composes the BODY; this is
|
||||
// only the label above it, and it is here because it is Discord presentation —
|
||||
// the same reason the embed colour is.
|
||||
const HEADINGS = {
|
||||
'team.member.joined': 'New member',
|
||||
'team.leadership.changed': 'Leadership change',
|
||||
'team.forum.post': 'New forum post',
|
||||
'team.announcement': 'Announcement',
|
||||
}
|
||||
|
||||
async function postTeamNotification(client, { channelId, stream, teamName, teamUrl, title, body, url }) {
|
||||
if (!channelId) throw new Error('No channel id supplied.')
|
||||
|
||||
const channel = await client.channels.fetch(channelId).catch(() => null)
|
||||
if (!channel || !channel.isTextBased()) {
|
||||
throw new Error('Configured channel is missing, not text-based, or not visible to the bot.')
|
||||
}
|
||||
|
||||
const heading = HEADINGS[stream] || 'Team update'
|
||||
const name = clamp(teamName, 120) || 'A team'
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(brand.accentInt)
|
||||
// The Team is the AUTHOR line and the event is the title, not the other way
|
||||
// round: a channel carrying one Team's events would otherwise repeat its name
|
||||
// as every heading, and a channel carrying several needs the name to be the
|
||||
// thing the eye lands on first.
|
||||
.setAuthor(teamUrl ? { name, url: teamUrl } : { name })
|
||||
.setTitle(clamp(title, TITLE_MAX) || heading)
|
||||
|
||||
if (url) embed.setURL(url)
|
||||
|
||||
// Both a title and a body means a forum post: the heading has to go somewhere
|
||||
// or "New forum post" and "Announcement" become indistinguishable once the
|
||||
// thread title takes the title slot.
|
||||
//
|
||||
// **Clamped AFTER the heading is prepended, not before.** Clamping the body and
|
||||
// then adding a prefix produces a description one heading longer than the limit,
|
||||
// which discord.js rejects outright — so an over-long post would not arrive at
|
||||
// all rather than arriving truncated. The prefix is part of what has to fit.
|
||||
const composed = title && body ? `**${heading}**\n${String(body)}` : body
|
||||
const description = clamp(composed, DESCRIPTION_MAX)
|
||||
if (description) embed.setDescription(description)
|
||||
|
||||
await channel.send({ embeds: [embed] })
|
||||
log.info('team notification posted', { stream, channelId, team: name })
|
||||
}
|
||||
|
||||
module.exports = { postTeamNotification, HEADINGS, clamp, TITLE_MAX, DESCRIPTION_MAX }
|
||||
315
bot/src/discord/teamVoice.js
Normal file
315
bot/src/discord/teamVoice.js
Normal file
@@ -0,0 +1,315 @@
|
||||
// Per-Team voice channels (TEAMS.md §7.3, phase 9).
|
||||
//
|
||||
// **The site decides; this file compares and applies.** Every judgement — which
|
||||
// Teams qualify, who may enter, what the channel is called — was made on the site
|
||||
// and arrives in the request. What cannot be made there is the DIFF: which of
|
||||
// those people already hold the role, whether the channel still exists, whether
|
||||
// the category was deleted last week. That is live guild state, only this process
|
||||
// can see it, and shipping it to the site to be compared and shipped back would
|
||||
// be a copy of the guild in a database that cannot watch it change.
|
||||
//
|
||||
// So the contract is "make it look like this", not "do these calls".
|
||||
//
|
||||
// **Access is a per-Team ROLE.** §7.3 designed per-member permission overwrites
|
||||
// with a role only above ~90 members; the org lead settled on roles always
|
||||
// (2026-08-18). The channel therefore carries exactly three kinds of overwrite —
|
||||
// @everyone denied, the Team's role allowed, and each operator-designated staff
|
||||
// role allowed — and membership is the role's member list rather than a hundred
|
||||
// entries on the channel.
|
||||
const { ChannelType, PermissionFlagsBits } = require('discord.js')
|
||||
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('team-voice')
|
||||
|
||||
// The category every Team channel is created under. Created on the first pass
|
||||
// that needs one; the site stores the id and sends it back next time.
|
||||
const CATEGORY_NAME = 'Teams'
|
||||
|
||||
// discord.js REST error codes for "the thing you are addressing is already gone".
|
||||
// A teardown that finds its target missing has SUCCEEDED — the desired end state
|
||||
// holds — and the same is true of a sync that finds a channel a human deleted,
|
||||
// which simply becomes a create.
|
||||
const UNKNOWN_CHANNEL = 10003
|
||||
const UNKNOWN_ROLE = 10011
|
||||
|
||||
const isMissing = (err) => err && (err.code === UNKNOWN_CHANNEL || err.code === UNKNOWN_ROLE)
|
||||
|
||||
// What a Team member may do in their channel, and what @everyone may not. Both
|
||||
// halves are needed: denying ViewChannel alone still leaves Connect resolvable
|
||||
// for anyone who has the id, and allowing ViewChannel alone shows a channel
|
||||
// nobody can enter.
|
||||
const ACCESS_BITS = [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect]
|
||||
|
||||
/**
|
||||
* Can this bot do §7.3's job in this guild?
|
||||
*
|
||||
* Asked before an operator may switch voice on, and again at the top of every
|
||||
* pass. The site has no way to know: the operator invites the bot by hand, there
|
||||
* is no invite URL with a permission integer anywhere in this project, and an
|
||||
* unticked box means every call fails with nothing to point at.
|
||||
*
|
||||
* `bot_role_position` is reported because it is the second, quieter failure:
|
||||
* ManageRoles lets the bot create a role, but it can only GRANT roles below its
|
||||
* own highest one. A bot sitting at the bottom of the role list creates roles it
|
||||
* then cannot hand to anybody — which looks exactly like a channel nobody can
|
||||
* enter, with no error anywhere.
|
||||
*/
|
||||
async function preflight(client, guildId) {
|
||||
const guild = await client.guilds.fetch(guildId)
|
||||
const me = guild.members.me || (await guild.members.fetchMe())
|
||||
return {
|
||||
connected: true,
|
||||
guild_id: guild.id,
|
||||
can_manage_channels: me.permissions.has(PermissionFlagsBits.ManageChannels),
|
||||
can_manage_roles: me.permissions.has(PermissionFlagsBits.ManageRoles),
|
||||
// The guild's whole role list, not just the ones this feature made. The
|
||||
// 250-role cap is guild-wide and shared with everything the operator created
|
||||
// themselves, so counting ours would promise headroom that is not there.
|
||||
role_count: guild.roles.cache.size,
|
||||
bot_role_position: me.roles.highest.position,
|
||||
}
|
||||
}
|
||||
|
||||
/** The `Teams` category, reusing the one we were given when it is still there. */
|
||||
async function ensureCategory(guild, categoryId) {
|
||||
if (categoryId) {
|
||||
const existing = await guild.channels.fetch(categoryId).catch(() => null)
|
||||
if (existing && existing.type === ChannelType.GuildCategory) return existing
|
||||
log.warn('the configured Teams category is gone; making another', { categoryId })
|
||||
}
|
||||
const created = await guild.channels.create({
|
||||
name: CATEGORY_NAME,
|
||||
type: ChannelType.GuildCategory,
|
||||
reason: 'Team voice channels',
|
||||
})
|
||||
log.info('created the Teams category', { categoryId: created.id })
|
||||
return created
|
||||
}
|
||||
|
||||
/**
|
||||
* The Team's own role.
|
||||
*
|
||||
* A rename is applied but never allowed to fail the pass: a Team's name is the
|
||||
* least important thing here and Discord rate-limits name edits hard, so losing
|
||||
* one is worth strictly less than losing the access change in the same request.
|
||||
*/
|
||||
async function ensureRole(guild, roleId, name) {
|
||||
let role = roleId ? await guild.roles.fetch(roleId).catch(() => null) : null
|
||||
let created = false
|
||||
if (!role) {
|
||||
role = await guild.roles.create({
|
||||
name,
|
||||
// Not mentionable and not hoisted: this role exists to open a door, and a
|
||||
// Team with two hundred members should not become a way to ping them all or
|
||||
// a second copy of the member list down the sidebar.
|
||||
mentionable: false,
|
||||
hoist: false,
|
||||
reason: 'Team voice access',
|
||||
})
|
||||
created = true
|
||||
log.info('created a team role', { roleId: role.id, name })
|
||||
} else if (role.name !== name) {
|
||||
await role.setName(name, 'Team renamed').catch((err) => {
|
||||
log.warn('could not rename the team role', { roleId: role.id, message: err.message })
|
||||
})
|
||||
}
|
||||
return { role, created }
|
||||
}
|
||||
|
||||
/** The overwrites a Team channel carries, in the order Discord takes them. */
|
||||
function overwritesFor(guild, role, staffRoleIds) {
|
||||
const overwrites = [
|
||||
{ id: guild.roles.everyone.id, deny: ACCESS_BITS },
|
||||
{ id: role.id, allow: ACCESS_BITS },
|
||||
]
|
||||
for (const staffId of staffRoleIds) {
|
||||
// A staff role the operator has since deleted would make Discord reject the
|
||||
// WHOLE set, taking the Team's own grant down with it. Filtered here rather
|
||||
// than validated on the site, which cannot see the guild's role list.
|
||||
if (!guild.roles.cache.has(staffId)) {
|
||||
log.warn('a configured staff role is not in this guild; skipping it', { roleId: staffId })
|
||||
continue
|
||||
}
|
||||
overwrites.push({ id: staffId, allow: ACCESS_BITS })
|
||||
}
|
||||
return overwrites
|
||||
}
|
||||
|
||||
async function ensureChannel(guild, channelId, { name, category, role, staffRoleIds }) {
|
||||
const overwrites = overwritesFor(guild, role, staffRoleIds)
|
||||
let channel = channelId ? await guild.channels.fetch(channelId).catch(() => null) : null
|
||||
|
||||
if (channel && channel.type !== ChannelType.GuildVoice) {
|
||||
// Somebody pointed us at, or converted this into, something that is not a
|
||||
// voice channel. Not ours to repurpose — make the right one and leave theirs.
|
||||
log.warn('the stored channel is not a voice channel; making a new one', { channelId })
|
||||
channel = null
|
||||
}
|
||||
|
||||
if (!channel) {
|
||||
const created = await guild.channels.create({
|
||||
name,
|
||||
type: ChannelType.GuildVoice,
|
||||
parent: category.id,
|
||||
permissionOverwrites: overwrites,
|
||||
reason: 'Team voice channel',
|
||||
})
|
||||
log.info('created a team voice channel', { channelId: created.id, name })
|
||||
return { channel: created, created: true }
|
||||
}
|
||||
|
||||
// Overwrites are re-set on every pass rather than diffed: the set is three or
|
||||
// four entries, `set` is one API call, and re-asserting it is what repairs a
|
||||
// channel somebody edited by hand.
|
||||
await channel.permissionOverwrites.set(overwrites, 'Team voice access')
|
||||
if (channel.parentId !== category.id) {
|
||||
await channel.setParent(category.id, { lockPermissions: false, reason: 'Team voice channel' })
|
||||
}
|
||||
if (channel.name !== name) {
|
||||
await channel.setName(name, 'Team renamed').catch((err) => {
|
||||
log.warn('could not rename the team voice channel', { channelId: channel.id, message: err.message })
|
||||
})
|
||||
}
|
||||
return { channel, created: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the role's member list to the site's list, up to `maxOps` changes.
|
||||
*
|
||||
* **Bounded, and the remainder is reported rather than dropped.** Each grant is
|
||||
* its own API call under its own rate limit, so an unbounded first pass on a
|
||||
* large guild is a request that outlives its own timeout — and a timeout is the
|
||||
* one outcome that leaves the site not knowing what was applied. The site asks
|
||||
* again until `pending` reaches zero.
|
||||
*
|
||||
* **A member the site names who is not in this guild is skipped silently.** They
|
||||
* linked their Discord account to the site and never joined the guild, which is
|
||||
* an ordinary state (§2.6 hop 3 without hop 4) and not something an operator
|
||||
* needs to see a hundred of.
|
||||
*/
|
||||
async function syncRoleMembers(guild, role, memberIds, maxOps) {
|
||||
// One fetch of the whole member list, so `role.members` and the "are they even
|
||||
// here" check both read from a cache that is actually populated. discord.js
|
||||
// keeps it current from gateway events afterwards; without the fetch, a bot
|
||||
// that has been up for five minutes knows only the members who spoke.
|
||||
await guild.members.fetch()
|
||||
|
||||
const desired = new Set(memberIds.map(String))
|
||||
const current = new Set(role.members.map((member) => member.id))
|
||||
|
||||
const toAdd = [...desired].filter((id) => !current.has(id) && guild.members.cache.has(id))
|
||||
const toRemove = [...current].filter((id) => !desired.has(id))
|
||||
|
||||
let ops = 0
|
||||
let added = 0
|
||||
let removed = 0
|
||||
|
||||
for (const id of toAdd) {
|
||||
if (ops >= maxOps) break
|
||||
const member = guild.members.cache.get(id)
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await member.roles.add(role, 'Team member')
|
||||
added += 1
|
||||
} catch (err) {
|
||||
// One member the bot cannot touch — almost always the role hierarchy, when
|
||||
// the member outranks the bot — must not cost the other forty-nine.
|
||||
log.warn('could not grant the team role', { userId: id, roleId: role.id, message: err.message })
|
||||
}
|
||||
ops += 1
|
||||
}
|
||||
|
||||
for (const id of toRemove) {
|
||||
if (ops >= maxOps) break
|
||||
const member = guild.members.cache.get(id)
|
||||
if (!member) continue
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await member.roles.remove(role, 'No longer a team member')
|
||||
removed += 1
|
||||
} catch (err) {
|
||||
log.warn('could not revoke the team role', { userId: id, roleId: role.id, message: err.message })
|
||||
}
|
||||
ops += 1
|
||||
}
|
||||
|
||||
return { added, removed, pending: Math.max(0, toAdd.length + toRemove.length - ops) }
|
||||
}
|
||||
|
||||
/** One Team, reconciled. */
|
||||
async function syncTeamVoice(client, guildId, {
|
||||
teamId, name, categoryId, channelId, roleId, staffRoleIds = [], memberIds = [], maxMemberOps = 50,
|
||||
}) {
|
||||
const guild = await client.guilds.fetch(guildId)
|
||||
const category = await ensureCategory(guild, categoryId)
|
||||
const { role, created: roleCreated } = await ensureRole(guild, roleId, name)
|
||||
const { channel, created: channelCreated } = await ensureChannel(guild, channelId, {
|
||||
name, category, role, staffRoleIds,
|
||||
})
|
||||
const members = await syncRoleMembers(guild, role, memberIds, maxMemberOps)
|
||||
|
||||
log.info('team voice reconciled', {
|
||||
teamId, name, channelId: channel.id, roleId: role.id, ...members,
|
||||
})
|
||||
|
||||
return {
|
||||
category_id: category.id,
|
||||
channel_id: channel.id,
|
||||
role_id: role.id,
|
||||
created: { channel: channelCreated, role: roleCreated },
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a Team's channel and role.
|
||||
*
|
||||
* Both, in one call, because they are one lifecycle: deleting the channel and
|
||||
* leaving the role would leave every member wearing a badge for a place that no
|
||||
* longer exists. Either being already gone is success.
|
||||
*/
|
||||
async function removeTeamVoice(client, guildId, { channelId, roleId }) {
|
||||
const guild = await client.guilds.fetch(guildId)
|
||||
const result = { channel_deleted: false, role_deleted: false }
|
||||
|
||||
if (channelId) {
|
||||
const channel = await guild.channels.fetch(channelId).catch(() => null)
|
||||
if (channel) {
|
||||
try {
|
||||
await channel.delete('Team no longer qualifies for a voice channel')
|
||||
result.channel_deleted = true
|
||||
} catch (err) {
|
||||
if (!isMissing(err)) throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (roleId) {
|
||||
const role = await guild.roles.fetch(roleId).catch(() => null)
|
||||
if (role) {
|
||||
try {
|
||||
await role.delete('Team no longer qualifies for a voice channel')
|
||||
result.role_deleted = true
|
||||
} catch (err) {
|
||||
if (!isMissing(err)) throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info('team voice removed', { channelId, roleId, ...result })
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CATEGORY_NAME,
|
||||
ACCESS_BITS,
|
||||
preflight,
|
||||
ensureCategory,
|
||||
ensureRole,
|
||||
ensureChannel,
|
||||
overwritesFor,
|
||||
syncRoleMembers,
|
||||
syncTeamVoice,
|
||||
removeTeamVoice,
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
const discordManager = require('../discord/discordManager')
|
||||
const newsAnnounce = require('../discord/newsAnnounce')
|
||||
const teamNotify = require('../discord/teamNotify')
|
||||
const teamVoice = require('../discord/teamVoice')
|
||||
const modLog = require('../discord/modLog')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
@@ -99,4 +101,142 @@ async function reverseModAction(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setConfig, getStatus: getStatusHandler, announce, reverseModAction }
|
||||
// POST /internal/refresh-commands — the app's nudge that its registered
|
||||
// slash-command set has moved (TEAMS.md §7.1). No body: the bot re-pulls
|
||||
// `/internal/commands` and re-registers only if the set actually changed, so the
|
||||
// nudge stays a cheap thing the app can send on every module state change.
|
||||
//
|
||||
// Deliberately its OWN endpoint rather than riding on /internal/config, which
|
||||
// carries the decrypted bot token: saying "commands changed" should not require
|
||||
// the app to read a secret out of the database.
|
||||
//
|
||||
// Answers 200 even when disconnected — there is no application to register
|
||||
// against until the bot logs in, and `ready` pulls again anyway. A 5xx here
|
||||
// would make an ordinary module install look like a failure in the admin panel.
|
||||
async function refreshCommands(req, res) {
|
||||
try {
|
||||
const result = await discordManager.refreshCommands()
|
||||
return res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
log.error('refresh-commands failed', { message: err.message })
|
||||
return res.json({ ok: false, error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /internal/team-notify — a Team notification the site has already decided
|
||||
// belongs in a channel (TEAMS.md §7.2). Body: { channel_id, stream, team_name,
|
||||
// team_url, title, body, url }.
|
||||
//
|
||||
// **The site chose the channel and the site checked the access.** Whether
|
||||
// members-only forum text may reach this channel is an acknowledgement recorded
|
||||
// against team_integration_config, and re-deciding it here would mean the bot
|
||||
// holding a copy of a policy it cannot see the inputs to.
|
||||
//
|
||||
// 503 when disconnected and 400 for a channel the bot cannot post to, matching
|
||||
// /internal/announce — the caller is one-shot and best-effort and only logs the
|
||||
// difference, but an operator debugging a silent channel needs the two to read
|
||||
// differently in the bot's log.
|
||||
async function teamNotifyHandler(req, res) {
|
||||
const connection = discordManager.getConnection()
|
||||
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
|
||||
|
||||
const { channel_id: channelId, stream, team_name: teamName, team_url: teamUrl, title, body, url } = req.body || {}
|
||||
if (!channelId || !stream) {
|
||||
return res.status(400).json({ message: 'channel_id and stream are required' })
|
||||
}
|
||||
|
||||
try {
|
||||
await teamNotify.postTeamNotification(connection.client, { channelId, stream, teamName, teamUrl, title, body, url })
|
||||
return res.json({ posted: true })
|
||||
} catch (err) {
|
||||
log.warn('team-notify failed', { message: err.message, stream, channelId })
|
||||
return res.status(400).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Voice channels (TEAMS.md §7.3, phase 9) ────────────────────────────────
|
||||
|
||||
// GET /internal/team-voice/preflight — can this bot do the job at all?
|
||||
//
|
||||
// Its own endpoint, and the app asks it BEFORE letting an operator switch voice
|
||||
// on. §7.3 assumed the bot could manage channels and roles; nothing in this
|
||||
// project has ever checked, because the operator invites the bot by hand and
|
||||
// there is no invite URL with a permission integer anywhere in the tree. Without
|
||||
// this the first symptom of an unticked box is every Team recording its own
|
||||
// identical error, which reads like forty problems instead of one.
|
||||
async function voicePreflight(req, res) {
|
||||
const connection = discordManager.getConnection()
|
||||
if (!connection) return res.status(503).json({ connected: false, message: 'Bot is not connected' })
|
||||
try {
|
||||
return res.json(await teamVoice.preflight(connection.client, connection.guildId))
|
||||
} catch (err) {
|
||||
log.warn('voice preflight failed', { message: err.message })
|
||||
return res.status(400).json({ connected: true, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /internal/team-voice/sync — make one Team's channel, role and role
|
||||
// membership match what the site sent.
|
||||
//
|
||||
// The site sends DESIRED STATE and this works out the calls, which is the
|
||||
// opposite of the split every other endpoint here uses. The decisions are all
|
||||
// still the site's; what is here is the comparison against live guild state,
|
||||
// which only this process can see.
|
||||
async function voiceSync(req, res) {
|
||||
const connection = discordManager.getConnection()
|
||||
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
|
||||
|
||||
const {
|
||||
team_id: teamId, name, category_id: categoryId, channel_id: channelId, role_id: roleId,
|
||||
staff_role_ids: staffRoleIds, member_ids: memberIds, max_member_ops: maxMemberOps,
|
||||
} = req.body || {}
|
||||
|
||||
if (!name) return res.status(400).json({ message: 'name is required' })
|
||||
|
||||
try {
|
||||
const result = await teamVoice.syncTeamVoice(connection.client, connection.guildId, {
|
||||
teamId,
|
||||
name,
|
||||
categoryId: categoryId || null,
|
||||
channelId: channelId || null,
|
||||
roleId: roleId || null,
|
||||
staffRoleIds: Array.isArray(staffRoleIds) ? staffRoleIds.map(String) : [],
|
||||
memberIds: Array.isArray(memberIds) ? memberIds.map(String) : [],
|
||||
maxMemberOps: Number(maxMemberOps) > 0 ? Number(maxMemberOps) : 50,
|
||||
})
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
// 400 rather than 500, matching /internal/announce: from the app's side this
|
||||
// is "Discord refused", which is a condition it records against the Team and
|
||||
// retries next pass — not a bug in this process.
|
||||
log.warn('voice sync failed', { message: err.message, teamId, name })
|
||||
return res.status(400).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /internal/team-voice/remove — the grace window expired, or an admin said so.
|
||||
async function voiceRemove(req, res) {
|
||||
const connection = discordManager.getConnection()
|
||||
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
|
||||
|
||||
const { channel_id: channelId, role_id: roleId } = req.body || {}
|
||||
try {
|
||||
const result = await teamVoice.removeTeamVoice(connection.client, connection.guildId, { channelId, roleId })
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.warn('voice remove failed', { message: err.message, channelId, roleId })
|
||||
return res.status(400).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setConfig,
|
||||
getStatus: getStatusHandler,
|
||||
announce,
|
||||
reverseModAction,
|
||||
refreshCommands,
|
||||
teamNotify: teamNotifyHandler,
|
||||
voicePreflight,
|
||||
voiceSync,
|
||||
voiceRemove,
|
||||
}
|
||||
|
||||
@@ -11,5 +11,10 @@ router.post('/config', ctrl.setConfig)
|
||||
router.get('/status', ctrl.getStatus)
|
||||
router.post('/announce', ctrl.announce)
|
||||
router.post('/mod-reverse', ctrl.reverseModAction)
|
||||
router.post('/refresh-commands', ctrl.refreshCommands)
|
||||
router.post('/team-notify', ctrl.teamNotify)
|
||||
router.get('/team-voice/preflight', ctrl.voicePreflight)
|
||||
router.post('/team-voice/sync', ctrl.voiceSync)
|
||||
router.post('/team-voice/remove', ctrl.voiceRemove)
|
||||
|
||||
module.exports = router
|
||||
|
||||
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')
|
||||
})
|
||||
138
bot/test/teamNotify.test.js
Normal file
138
bot/test/teamNotify.test.js
Normal file
@@ -0,0 +1,138 @@
|
||||
// ── The bot's half of the Team notifications bridge (TEAMS.md §7.2) ────────
|
||||
//
|
||||
// Nothing here talks to Discord. `channel` is a fake that records what was sent,
|
||||
// and the assertions are about the three things this side genuinely owns:
|
||||
//
|
||||
// 1. **the channel comes from the app and is never looked up.** `newsAnnounce`
|
||||
// reads guild_config because there is one #news; a Team's destination is
|
||||
// per-Team configuration, and a bot that resolved it would hold a second
|
||||
// copy of a table it cannot see the inputs to;
|
||||
// 2. **a channel the bot cannot post to fails loudly rather than silently.** A
|
||||
// caller that is one-shot and best-effort only logs the difference, but an
|
||||
// operator debugging a quiet channel needs the bot's log to distinguish
|
||||
// "not connected" from "that id is not a text channel";
|
||||
// 3. **Discord's own limits are enforced here.** An embed that exceeds them is
|
||||
// rejected WHOLESALE, so a long forum body must be truncated on this side
|
||||
// even though the app already excerpted it — the app's limit is a product
|
||||
// decision and this one is a protocol constraint.
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const teamNotify = require('../src/discord/teamNotify')
|
||||
|
||||
// A fake channel that records what it was sent. `isTextBased` is the one method
|
||||
// the code branches on, so it is the one worth making configurable.
|
||||
function fakeChannel({ textBased = true } = {}) {
|
||||
const sends = []
|
||||
return {
|
||||
sends,
|
||||
isTextBased: () => textBased,
|
||||
send: async (payload) => { sends.push(payload); return { id: 'm1' } },
|
||||
}
|
||||
}
|
||||
|
||||
function fakeClient(channel, { throws = false } = {}) {
|
||||
return {
|
||||
channels: {
|
||||
fetch: async (id) => {
|
||||
if (throws) throw new Error('Unknown Channel')
|
||||
return id === 'chan-1' ? channel : null
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const post = (client, over = {}) => teamNotify.postTeamNotification(client, {
|
||||
channelId: 'chan-1',
|
||||
stream: 'team.forum.post',
|
||||
teamName: 'Blackthorn’s Legion',
|
||||
teamUrl: 'https://site/guilds/blackthorns-legion',
|
||||
title: 'Siege tonight',
|
||||
body: 'Meet at the moongate.',
|
||||
url: 'https://site/guilds/blackthorns-legion?thread=41',
|
||||
...over,
|
||||
})
|
||||
|
||||
// ── 1. The channel is the app's decision ───────────────────────────────────
|
||||
|
||||
test('the message goes to the channel the app named', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel))
|
||||
assert.equal(channel.sends.length, 1)
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.equal(embed.data.title, 'Siege tonight')
|
||||
assert.equal(embed.data.author.name, 'Blackthorn’s Legion')
|
||||
assert.equal(embed.data.url, 'https://site/guilds/blackthorns-legion?thread=41')
|
||||
})
|
||||
|
||||
test('no channel id at all is refused before anything is fetched', async () => {
|
||||
await assert.rejects(() => post(fakeClient(fakeChannel()), { channelId: '' }), /No channel id/)
|
||||
})
|
||||
|
||||
// ── 2. A channel the bot cannot use ────────────────────────────────────────
|
||||
|
||||
test('a channel the bot cannot see is a clear error, not a silent no-op', async () => {
|
||||
await assert.rejects(() => post(fakeClient(null)), /missing, not text-based, or not visible/)
|
||||
})
|
||||
|
||||
test('a fetch that throws is reported the same way — the bot does not distinguish gone from hidden', async () => {
|
||||
await assert.rejects(() => post(fakeClient(fakeChannel(), { throws: true })), /missing, not text-based/)
|
||||
})
|
||||
|
||||
test('a voice channel is refused', async () => {
|
||||
await assert.rejects(() => post(fakeClient(fakeChannel({ textBased: false }))), /not text-based/)
|
||||
})
|
||||
|
||||
// ── 3. Discord's limits, and the heading ───────────────────────────────────
|
||||
|
||||
test('an over-long title is truncated rather than rejected by Discord as a whole', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { title: 'y'.repeat(400) })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.equal(embed.data.title.length, teamNotify.TITLE_MAX)
|
||||
assert.ok(embed.data.title.endsWith('…'))
|
||||
})
|
||||
|
||||
test('an over-long body is truncated to the description limit', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { body: 'z'.repeat(9000) })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.ok(embed.data.description.length <= teamNotify.DESCRIPTION_MAX + 32)
|
||||
})
|
||||
|
||||
test('a titled event keeps its heading, so a post and an announcement stay distinguishable', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { stream: 'team.announcement' })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.match(embed.data.description, /^\*\*Announcement\*\*/)
|
||||
assert.match(embed.data.description, /Meet at the moongate\./)
|
||||
})
|
||||
|
||||
test('a roster event has no title, so the heading becomes the title', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { stream: 'team.member.joined', title: null, body: '3 new members joined.' })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.equal(embed.data.title, 'New member')
|
||||
assert.equal(embed.data.description, '3 new members joined.', 'no heading prefix when the title already is one')
|
||||
})
|
||||
|
||||
test('an unknown stream still posts, under a neutral heading', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { stream: 'team.something.new', title: null })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.equal(embed.data.title, 'Team update')
|
||||
})
|
||||
|
||||
test('a missing team name does not produce an embed with an empty author line', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { teamName: '', teamUrl: null })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.equal(embed.data.author.name, 'A team')
|
||||
assert.equal(embed.data.author.url, undefined)
|
||||
})
|
||||
|
||||
test('clamp treats whitespace-only as absent, which is what keeps an empty description off the embed', async () => {
|
||||
assert.equal(teamNotify.clamp(' ', 100), null)
|
||||
assert.equal(teamNotify.clamp('ok', 100), 'ok')
|
||||
})
|
||||
364
bot/test/teamVoice.test.js
Normal file
364
bot/test/teamVoice.test.js
Normal file
@@ -0,0 +1,364 @@
|
||||
// ── The bot's half of Team voice channels (TEAMS.md §7.3, phase 9) ────────
|
||||
//
|
||||
// Nothing here talks to Discord. `fakeGuild` records the calls, and the
|
||||
// assertions are about the four things this side genuinely owns — the ones the
|
||||
// site cannot decide because it cannot see the guild:
|
||||
//
|
||||
// 1. **The overwrite set.** @everyone denied, the Team's role allowed, each
|
||||
// configured staff role allowed — and a staff role the operator has since
|
||||
// deleted is FILTERED, because Discord rejects the whole set for one bad id
|
||||
// and that would take the Team's own grant down with it.
|
||||
// 2. **The membership diff is bounded and the remainder is reported.** Each
|
||||
// grant is its own API call; an unbounded first pass on a large guild
|
||||
// outlives its own timeout, which is the one failure that leaves the site
|
||||
// not knowing what was applied.
|
||||
// 3. **A member who linked Discord but never joined the guild is skipped
|
||||
// silently.** That is §2.6 hop 3 without hop 4 — an ordinary state, not an
|
||||
// error, and certainly not a hundred log lines.
|
||||
// 4. **A missing target is success.** A teardown that finds its channel already
|
||||
// deleted has reached the desired end state; a sync that finds one deleted
|
||||
// simply creates it again.
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { ChannelType, PermissionFlagsBits } = require('discord.js')
|
||||
const teamVoice = require('../src/discord/teamVoice')
|
||||
|
||||
const EVERYONE = 'guild-everyone'
|
||||
|
||||
function fakeMember(id, { canGrant = true } = {}) {
|
||||
const roles = new Set()
|
||||
return {
|
||||
id,
|
||||
roles: {
|
||||
cache: roles,
|
||||
add: async (role) => {
|
||||
if (!canGrant) throw new Error('Missing Permissions')
|
||||
roles.add(role.id)
|
||||
},
|
||||
remove: async (role) => { roles.delete(role.id) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function fakeGuild({
|
||||
members = [],
|
||||
roles = [],
|
||||
channels = [],
|
||||
botPermissions = [PermissionFlagsBits.ManageChannels, PermissionFlagsBits.ManageRoles],
|
||||
} = {}) {
|
||||
const memberMap = new Map(members.map((m) => [m.id, m]))
|
||||
const roleMap = new Map(roles.map((r) => [r.id, r]))
|
||||
const channelMap = new Map(channels.map((c) => [c.id, c]))
|
||||
const created = { roles: [], channels: [] }
|
||||
let nextId = 1000
|
||||
|
||||
const guild = {
|
||||
id: 'guild-1',
|
||||
created,
|
||||
roles: {
|
||||
everyone: { id: EVERYONE },
|
||||
cache: roleMap,
|
||||
fetch: async (id) => roleMap.get(id) || null,
|
||||
create: async (opts) => {
|
||||
const role = {
|
||||
id: String(nextId++),
|
||||
name: opts.name,
|
||||
members: [],
|
||||
setName: async (name) => { role.name = name },
|
||||
delete: async () => { roleMap.delete(role.id) },
|
||||
}
|
||||
roleMap.set(role.id, role)
|
||||
created.roles.push(opts)
|
||||
return role
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
cache: channelMap,
|
||||
fetch: async (id) => channelMap.get(id) || null,
|
||||
create: async (opts) => {
|
||||
const channel = {
|
||||
id: String(nextId++),
|
||||
name: opts.name,
|
||||
type: opts.type,
|
||||
parentId: opts.parent || null,
|
||||
overwrites: opts.permissionOverwrites || [],
|
||||
permissionOverwrites: {
|
||||
set: async (list) => { channel.overwrites = list },
|
||||
},
|
||||
setParent: async (parentId) => { channel.parentId = parentId },
|
||||
setName: async (name) => { channel.name = name },
|
||||
delete: async () => { channelMap.delete(channel.id) },
|
||||
}
|
||||
channelMap.set(channel.id, channel)
|
||||
created.channels.push(opts)
|
||||
return channel
|
||||
},
|
||||
},
|
||||
members: {
|
||||
me: { permissions: { has: (bit) => botPermissions.includes(bit) }, roles: { highest: { position: 7 } } },
|
||||
cache: memberMap,
|
||||
fetch: async () => memberMap,
|
||||
},
|
||||
}
|
||||
return guild
|
||||
}
|
||||
|
||||
const fakeClient = (guild) => ({ guilds: { fetch: async () => guild } })
|
||||
|
||||
const voiceChannel = (id, over = {}) => {
|
||||
const channel = {
|
||||
id,
|
||||
name: 'The Silver Hand',
|
||||
type: ChannelType.GuildVoice,
|
||||
parentId: '500',
|
||||
overwrites: [],
|
||||
permissionOverwrites: { set: async (list) => { channel.overwrites = list } },
|
||||
setParent: async (parentId) => { channel.parentId = parentId },
|
||||
setName: async (name) => { channel.name = name },
|
||||
delete: async () => {},
|
||||
...over,
|
||||
}
|
||||
return channel
|
||||
}
|
||||
|
||||
const category = (id = '500') => ({ id, type: ChannelType.GuildCategory })
|
||||
|
||||
const role = (id, name = 'The Silver Hand', members = []) => {
|
||||
const r = {
|
||||
id,
|
||||
name,
|
||||
members,
|
||||
setName: async (next) => { r.name = next },
|
||||
delete: async () => {},
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Preflight ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('preflight reports both permissions and the guild-wide role count', async () => {
|
||||
const guild = fakeGuild({ roles: [role('1'), role('2')] })
|
||||
const result = await teamVoice.preflight(fakeClient(guild), 'guild-1')
|
||||
assert.equal(result.can_manage_channels, true)
|
||||
assert.equal(result.can_manage_roles, true)
|
||||
// The GUILD's roles, not ours. The 250 cap is shared with everything the
|
||||
// operator made themselves, so counting only ours would promise headroom that
|
||||
// is not there.
|
||||
assert.equal(result.role_count, 2)
|
||||
assert.equal(result.bot_role_position, 7)
|
||||
})
|
||||
|
||||
test('preflight reports a missing permission rather than throwing', async () => {
|
||||
const guild = fakeGuild({ botPermissions: [PermissionFlagsBits.ManageChannels] })
|
||||
const result = await teamVoice.preflight(fakeClient(guild), 'guild-1')
|
||||
assert.equal(result.can_manage_channels, true)
|
||||
assert.equal(result.can_manage_roles, false)
|
||||
})
|
||||
|
||||
// ── Overwrites ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('the overwrite set denies @everyone and allows the Team role', () => {
|
||||
const guild = fakeGuild()
|
||||
const list = teamVoice.overwritesFor(guild, role('900'), [])
|
||||
assert.equal(list.length, 2)
|
||||
assert.equal(list[0].id, EVERYONE)
|
||||
assert.deepEqual(list[0].deny, teamVoice.ACCESS_BITS)
|
||||
assert.equal(list[1].id, '900')
|
||||
assert.deepEqual(list[1].allow, teamVoice.ACCESS_BITS)
|
||||
})
|
||||
|
||||
test('a configured staff role that still exists gets an allow', () => {
|
||||
const staff = role('777', 'Moderators')
|
||||
const guild = fakeGuild({ roles: [staff] })
|
||||
const list = teamVoice.overwritesFor(guild, role('900'), ['777'])
|
||||
assert.equal(list.length, 3)
|
||||
assert.equal(list[2].id, '777')
|
||||
})
|
||||
|
||||
test('a staff role deleted in Discord is skipped, not sent — it would void the whole set', () => {
|
||||
const guild = fakeGuild({ roles: [] })
|
||||
const list = teamVoice.overwritesFor(guild, role('900'), ['deleted-1'])
|
||||
assert.equal(list.length, 2)
|
||||
assert.ok(!list.some((o) => o.id === 'deleted-1'))
|
||||
})
|
||||
|
||||
// ── Ensure ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a missing category is created; an existing one is reused', async () => {
|
||||
const guild = fakeGuild()
|
||||
const made = await teamVoice.ensureCategory(guild, null)
|
||||
assert.equal(guild.created.channels.length, 1)
|
||||
assert.equal(guild.created.channels[0].type, ChannelType.GuildCategory)
|
||||
|
||||
const again = await teamVoice.ensureCategory(guild, made.id)
|
||||
assert.equal(again.id, made.id)
|
||||
assert.equal(guild.created.channels.length, 1)
|
||||
})
|
||||
|
||||
test('a category id pointing at something that is not a category makes a new one', async () => {
|
||||
const guild = fakeGuild({ channels: [voiceChannel('700')] })
|
||||
await teamVoice.ensureCategory(guild, '700')
|
||||
assert.equal(guild.created.channels.length, 1)
|
||||
})
|
||||
|
||||
test('the Team role is created not mentionable and not hoisted', async () => {
|
||||
const guild = fakeGuild()
|
||||
const { role: made, created } = await teamVoice.ensureRole(guild, null, 'The Silver Hand')
|
||||
assert.equal(created, true)
|
||||
assert.equal(made.name, 'The Silver Hand')
|
||||
// A Team with two hundred members must not become a way to ping them all, or a
|
||||
// second copy of the member list down the sidebar.
|
||||
assert.equal(guild.created.roles[0].mentionable, false)
|
||||
assert.equal(guild.created.roles[0].hoist, false)
|
||||
})
|
||||
|
||||
test('a renamed Team renames its role rather than making a second', async () => {
|
||||
const existing = role('900', 'Old Name')
|
||||
const guild = fakeGuild({ roles: [existing] })
|
||||
const { role: made, created } = await teamVoice.ensureRole(guild, '900', 'New Name')
|
||||
assert.equal(created, false)
|
||||
assert.equal(made.name, 'New Name')
|
||||
assert.equal(guild.created.roles.length, 0)
|
||||
})
|
||||
|
||||
test('a rename Discord refuses does not fail the pass — access matters more than a label', async () => {
|
||||
const existing = role('900', 'Old Name')
|
||||
existing.setName = async () => { throw new Error('rate limited') }
|
||||
const guild = fakeGuild({ roles: [existing] })
|
||||
const { role: made } = await teamVoice.ensureRole(guild, '900', 'New Name')
|
||||
assert.equal(made.id, '900')
|
||||
})
|
||||
|
||||
test('a channel a human deleted is simply created again', async () => {
|
||||
const guild = fakeGuild()
|
||||
const { channel, created } = await teamVoice.ensureChannel(guild, 'gone-1', {
|
||||
name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
|
||||
})
|
||||
assert.equal(created, true)
|
||||
assert.equal(channel.type, ChannelType.GuildVoice)
|
||||
assert.equal(channel.parentId, '500')
|
||||
})
|
||||
|
||||
test('an existing channel has its overwrites re-asserted every pass', async () => {
|
||||
const existing = voiceChannel('600')
|
||||
const guild = fakeGuild({ channels: [existing] })
|
||||
const { created } = await teamVoice.ensureChannel(guild, '600', {
|
||||
name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
|
||||
})
|
||||
assert.equal(created, false)
|
||||
// Re-setting rather than diffing is what repairs a channel somebody edited by
|
||||
// hand.
|
||||
assert.equal(existing.overwrites.length, 2)
|
||||
})
|
||||
|
||||
test('a channel that is no longer a voice channel is left alone and a new one made', async () => {
|
||||
const text = voiceChannel('600', { type: ChannelType.GuildText })
|
||||
const guild = fakeGuild({ channels: [text] })
|
||||
const { channel, created } = await teamVoice.ensureChannel(guild, '600', {
|
||||
name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
|
||||
})
|
||||
assert.equal(created, true)
|
||||
assert.notEqual(channel.id, '600')
|
||||
})
|
||||
|
||||
// ── Membership ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('the role is granted to the members the site named', async () => {
|
||||
const alice = fakeMember('a')
|
||||
const bob = fakeMember('b')
|
||||
const guild = fakeGuild({ members: [alice, bob] })
|
||||
const teamRole = role('900', 'The Silver Hand', [])
|
||||
|
||||
const result = await teamVoice.syncRoleMembers(guild, teamRole, ['a', 'b'], 50)
|
||||
assert.equal(result.added, 2)
|
||||
assert.equal(result.removed, 0)
|
||||
assert.equal(result.pending, 0)
|
||||
})
|
||||
|
||||
test('a member who left the Team has the role taken away', async () => {
|
||||
const alice = fakeMember('a')
|
||||
const bob = fakeMember('b')
|
||||
const guild = fakeGuild({ members: [alice, bob] })
|
||||
const teamRole = role('900', 'The Silver Hand', [alice, bob])
|
||||
|
||||
const result = await teamVoice.syncRoleMembers(guild, teamRole, ['a'], 50)
|
||||
assert.equal(result.added, 0)
|
||||
assert.equal(result.removed, 1)
|
||||
})
|
||||
|
||||
test('a member who linked Discord but never joined the guild is skipped without an error', async () => {
|
||||
const guild = fakeGuild({ members: [] })
|
||||
const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), ['not-in-guild'], 50)
|
||||
assert.equal(result.added, 0)
|
||||
assert.equal(result.pending, 0)
|
||||
})
|
||||
|
||||
test('the diff is bounded and the remainder is REPORTED, not dropped', async () => {
|
||||
const members = Array.from({ length: 10 }, (_, i) => fakeMember(`m${i}`))
|
||||
const guild = fakeGuild({ members })
|
||||
const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), members.map((m) => m.id), 4)
|
||||
assert.equal(result.added, 4)
|
||||
assert.equal(result.pending, 6)
|
||||
})
|
||||
|
||||
test('one member the bot cannot touch does not cost the other forty-nine', async () => {
|
||||
const ok1 = fakeMember('a')
|
||||
const nope = fakeMember('b', { canGrant: false })
|
||||
const ok2 = fakeMember('c')
|
||||
const guild = fakeGuild({ members: [ok1, nope, ok2] })
|
||||
|
||||
const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), ['a', 'b', 'c'], 50)
|
||||
assert.equal(result.added, 2)
|
||||
})
|
||||
|
||||
// ── Teardown ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('a teardown deletes the channel and the role together', async () => {
|
||||
const channel = voiceChannel('600')
|
||||
const teamRole = role('900')
|
||||
let deletedChannel = false
|
||||
let deletedRole = false
|
||||
channel.delete = async () => { deletedChannel = true }
|
||||
teamRole.delete = async () => { deletedRole = true }
|
||||
const guild = fakeGuild({ channels: [channel], roles: [teamRole] })
|
||||
|
||||
const result = await teamVoice.removeTeamVoice(fakeClient(guild), 'guild-1', { channelId: '600', roleId: '900' })
|
||||
assert.equal(deletedChannel, true)
|
||||
assert.equal(deletedRole, true)
|
||||
assert.equal(result.channel_deleted, true)
|
||||
assert.equal(result.role_deleted, true)
|
||||
})
|
||||
|
||||
test('a teardown whose target is already gone is success, not a failure to retry forever', async () => {
|
||||
const guild = fakeGuild({ channels: [], roles: [] })
|
||||
const result = await teamVoice.removeTeamVoice(fakeClient(guild), 'guild-1', { channelId: 'gone', roleId: 'gone' })
|
||||
assert.equal(result.channel_deleted, false)
|
||||
assert.equal(result.role_deleted, false)
|
||||
})
|
||||
|
||||
// ── The whole thing ────────────────────────────────────────────────────────
|
||||
|
||||
test('a first sync creates the category, the role and the channel, and grants the members', async () => {
|
||||
const alice = fakeMember('a')
|
||||
const guild = fakeGuild({ members: [alice] })
|
||||
|
||||
const result = await teamVoice.syncTeamVoice(fakeClient(guild), 'guild-1', {
|
||||
teamId: 1,
|
||||
name: 'The Silver Hand',
|
||||
categoryId: null,
|
||||
channelId: null,
|
||||
roleId: null,
|
||||
staffRoleIds: [],
|
||||
memberIds: ['a'],
|
||||
maxMemberOps: 50,
|
||||
})
|
||||
|
||||
assert.equal(result.created.channel, true)
|
||||
assert.equal(result.created.role, true)
|
||||
assert.ok(result.category_id)
|
||||
assert.ok(result.channel_id)
|
||||
assert.ok(result.role_id)
|
||||
assert.equal(result.members.added, 1)
|
||||
})
|
||||
@@ -42,19 +42,24 @@ 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'
|
||||
import PlayerRegister from './routes/player/PlayerRegister.jsx'
|
||||
import ForgotPassword from './routes/player/ForgotPassword.jsx'
|
||||
import ResetPassword from './routes/player/ResetPassword.jsx'
|
||||
import VerifyEmail from './routes/player/VerifyEmail.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 +167,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 +180,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
|
||||
@@ -196,7 +206,14 @@ export default function App() {
|
||||
<Route path="/account/register" element={<PlayerRegister />} />
|
||||
<Route path="/account/forgot" element={<ForgotPassword />} />
|
||||
<Route path="/account/reset/:token" element={<ResetPassword />} />
|
||||
{/* Opened from a mailbox, so public like the reset page above — the
|
||||
token is the proof, and confirming issues no session. */}
|
||||
<Route path="/account/verify-email/:token" element={<VerifyEmail />} />
|
||||
<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 +228,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
|
||||
|
||||
@@ -105,6 +105,36 @@ export const api = {
|
||||
revokeTrustedDevice: (id) =>
|
||||
req(`/auth/me/trusted-devices/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
revokeAllTrustedDevices: () => req('/auth/me/trusted-devices', { method: 'DELETE' }),
|
||||
// Self-service account security, role-agnostic under /auth/me/account. This is
|
||||
// the ONLY surface for it: the /admin/account/* and /player/account/* copies
|
||||
// were deleted (both were strictly smaller — neither carried recovery codes),
|
||||
// which is why recovery codes below already lived here while the rest did not.
|
||||
// The change endpoints re-issue the session cookie server-side, so the caller
|
||||
// stays signed in.
|
||||
myAccount: () => req('/auth/me/account'),
|
||||
changeUsername: (username) =>
|
||||
req('/auth/me/account/username', { method: 'PATCH', body: { username } }),
|
||||
changePassword: (newPassword, currentPassword) =>
|
||||
req('/auth/me/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
|
||||
// Email address (engagement Phase 1b). changeEmail STAGES the address — the
|
||||
// account keeps its current one until the emailed link is opened — so the UI
|
||||
// must show `email_pending` as pending, never as the address in force.
|
||||
changeEmail: (email, currentPassword) =>
|
||||
req('/auth/me/account/email', { method: 'PATCH', body: { email, currentPassword } }),
|
||||
resendEmailVerification: () => req('/auth/me/account/email/resend', { method: 'POST' }),
|
||||
cancelEmailChange: () => req('/auth/me/account/email/pending', { method: 'DELETE' }),
|
||||
// The confirm half is public and token-gated — it is reached from a mailbox,
|
||||
// often with no session, so it deliberately sits outside /auth/me.
|
||||
lookupEmailVerification: (token) => req(`/auth/email/verify/${encodeURIComponent(token)}`),
|
||||
confirmEmailVerification: (token) =>
|
||||
req(`/auth/email/verify/${encodeURIComponent(token)}`, { method: 'POST' }),
|
||||
totpSetup: () => req('/auth/me/account/totp/setup', { method: 'POST' }),
|
||||
totpEnable: (code) => req('/auth/me/account/totp/enable', { method: 'POST', body: { code } }),
|
||||
totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }),
|
||||
// Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link.
|
||||
myIdentities: () => req('/auth/me/account/identities'),
|
||||
unlinkIdentity: (provider) =>
|
||||
req(`/auth/me/account/identities/${encodeURIComponent(provider)}`, { method: 'DELETE' }),
|
||||
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
|
||||
// returned ONCE (password step-up for accounts that have a password).
|
||||
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
|
||||
@@ -133,6 +163,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
|
||||
@@ -220,6 +324,12 @@ export const api = {
|
||||
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||
// Accounts whose address was cleared when addresses became unique (Phase 1b).
|
||||
// They can still sign in but can receive no mail until they set a new one, so
|
||||
// they are the list an operator has to work through.
|
||||
emailDedupeReport: () => req('/admin/users/email-dedupe-report'),
|
||||
acknowledgeEmailDedupeReport: () =>
|
||||
req('/admin/users/email-dedupe-report/acknowledge', { method: 'POST' }),
|
||||
// A user's trusted devices + MFA reset (admin only).
|
||||
userTrustedDevices: (id) => req(`/admin/users/${id}/trusted-devices`),
|
||||
revokeUserTrustedDevice: (id, deviceId) =>
|
||||
@@ -247,8 +357,61 @@ export const api = {
|
||||
setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
|
||||
restartServer: () => req('/admin/modules/restart', { method: 'POST' }),
|
||||
|
||||
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
|
||||
// different depending on who calls them: for a moderator, unhide and
|
||||
// setTeamDisplayName file a request and the response says `pending: true`.
|
||||
// The caller does not choose — the server decides from the live role — so
|
||||
// there is deliberately no "asRequest" argument to get wrong.
|
||||
listTeams: () => req('/admin/teams'),
|
||||
getTeam: (id) => req(`/admin/teams/${id}`),
|
||||
resyncTeams: () => req('/admin/teams/resync', { method: 'POST' }),
|
||||
archiveTeam: (id, reason) => req(`/admin/teams/${id}/archive`, { method: 'POST', body: { reason } }),
|
||||
teamGrants: (id) => req(`/admin/teams/${id}/grants`),
|
||||
hideTeam: (id, reason) => req(`/admin/teams/${id}/hide`, { method: 'POST', body: { reason } }),
|
||||
unhideTeam: (id, reason) => req(`/admin/teams/${id}/unhide`, { method: 'POST', body: { reason } }),
|
||||
setTeamDisplayName: (id, displayName, reason) =>
|
||||
req(`/admin/teams/${id}/display-name`, { method: 'POST', body: { displayName, reason } }),
|
||||
setTeamLeaderOverride: (id, body) =>
|
||||
req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }),
|
||||
clearTeamLeaderOverride: (id, memberKey) =>
|
||||
req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }),
|
||||
teamForumSettings: () => req('/admin/teams/forum/settings'),
|
||||
// The notification bridge (TEAMS.md §7.2). Admin-only server-side, so a
|
||||
// moderator's admin panel never renders the panel that calls these.
|
||||
teamIntegrations: () => req('/admin/teams/integrations'),
|
||||
saveTeamIntegration: (body) => req('/admin/teams/integrations', { method: 'PUT', body }),
|
||||
deleteTeamIntegration: (teamId) =>
|
||||
req(`/admin/teams/integrations/${teamId === null ? 'default' : teamId}`, { method: 'DELETE' }),
|
||||
// Voice channels (TEAMS.md §7.3). Admin-only server-side, like the bridge.
|
||||
teamVoice: () => req('/admin/teams/voice'),
|
||||
saveTeamVoice: (body) => req('/admin/teams/voice', { method: 'PUT', body }),
|
||||
teamVoicePass: () => req('/admin/teams/voice/sync', { method: 'POST' }),
|
||||
removeTeamVoice: (teamId) => req(`/admin/teams/voice/${teamId}`, { method: 'DELETE' }),
|
||||
teamForumUploads: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.deleted) qs.set('deleted', '1')
|
||||
return req(`/admin/teams/forum/uploads${withQs(qs.toString())}`)
|
||||
},
|
||||
teamForumModeration: (id) => req(`/admin/teams/${id}/forum/moderation`),
|
||||
teamReviewQueue: () => req('/admin/teams/review'),
|
||||
teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`),
|
||||
decideTeamRequest: (id, status, note) =>
|
||||
req(`/admin/teams/requests/${id}/decide`, { method: 'POST', body: { status, note } }),
|
||||
|
||||
// ----- moderation dashboard (admin + moderator) -----
|
||||
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||
// The content-report queue (TEAMS.md §5.6). Under moderation rather than
|
||||
// under Teams because a staffer working a queue should have one place to
|
||||
// work, and a report about a forum post is the same job as a report about
|
||||
// anything else — which is also why `targetType` is open-ended.
|
||||
contentReports: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.status) qs.set('status', opts.status)
|
||||
if (opts.teamId) qs.set('teamId', String(opts.teamId))
|
||||
return req(`/admin/moderation/reports${withQs(qs.toString())}`)
|
||||
},
|
||||
handleContentReport: (id, body) =>
|
||||
req(`/admin/moderation/reports/${id}/handle`, { method: 'POST', body }),
|
||||
modRecent: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.type) qs.set('type', params.type)
|
||||
@@ -308,16 +471,6 @@ export const api = {
|
||||
req(`/admin/moderation/appeals/${id}/resolve`, { method: 'POST', body: data }),
|
||||
getUserAppeals: (discordId) => req(`/admin/moderation/user/${discordId}/appeals`),
|
||||
|
||||
// ----- account security (self-service 2FA) -----
|
||||
getAccount: () => req('/admin/account'),
|
||||
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
||||
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
|
||||
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
|
||||
|
||||
// ----- linked SSO identities (self-service) -----
|
||||
linkedIdentities: () => req('/admin/account/identities'),
|
||||
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- auth providers / SSO config (admin only) -----
|
||||
listAuthProviders: () => req('/admin/auth/providers'),
|
||||
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
||||
@@ -328,29 +481,19 @@ export const api = {
|
||||
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
||||
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
||||
|
||||
// ----- Email delivery / Gmail OAuth2 (admin only) -----
|
||||
// ----- Email delivery (admin only) -----
|
||||
// The connect-flow call went with Gmail OAuth2 (ENGAGEMENT.md §1.2a); the
|
||||
// config response now carries the transport catalog the form renders from.
|
||||
getEmailConfig: () => req('/admin/email/config'),
|
||||
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
||||
emailConnectUrl: () => req('/admin/email/connect/start'),
|
||||
testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }),
|
||||
disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }),
|
||||
},
|
||||
|
||||
// ----- player self-service (role: 'player') -----
|
||||
// Mirrors the admin account methods but self-scoped under /player. The change
|
||||
// endpoints re-issue the session cookie server-side, so the caller stays signed in.
|
||||
// Account security is NOT here — it is role-agnostic and lives at the root of
|
||||
// this object, on /auth/me/account. What remains is genuinely player-scoped.
|
||||
player: {
|
||||
getAccount: () => req('/player/account'),
|
||||
changeUsername: (username) =>
|
||||
req('/player/account/username', { method: 'PATCH', body: { username } }),
|
||||
changePassword: (newPassword, currentPassword) =>
|
||||
req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
|
||||
totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }),
|
||||
totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }),
|
||||
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
|
||||
linkedIdentities: () => req('/player/account/identities'),
|
||||
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- moderation appeals (self-service) -----
|
||||
getMyAppeals: () => req('/player/appeals'),
|
||||
getEligibleAppeals: () => req('/player/appeals/eligible'),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
175
client/src/components/security/EmailAddressPanel.jsx
Normal file
175
client/src/components/security/EmailAddressPanel.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// Self-service email address (engagement Phase 1b). Shared by the player portal
|
||||
// and the admin account screen, the same way TrustedDevicesPanel and
|
||||
// RecoveryCodesPanel are — /auth/me/account is one surface for every role, so its
|
||||
// UI is one component too.
|
||||
//
|
||||
// The property this component exists to make visible: a requested address is
|
||||
// STAGED, not applied. The account keeps receiving mail — password resets
|
||||
// included — at the address it already has until the emailed link is opened. If
|
||||
// the UI let a pending address look like the address in force, someone who
|
||||
// mistyped would believe the change took and would only discover otherwise when
|
||||
// they could not recover their account.
|
||||
//
|
||||
// `hasPassword` decides whether the current-password field appears: an address is
|
||||
// where account recovery lands, so changing it is re-authenticated, with the same
|
||||
// carve-out the password form makes for an SSO-only account.
|
||||
export default function EmailAddressPanel({ account, reload, embedded = false }) {
|
||||
const hasPassword = account.has_password !== false
|
||||
const [email, setEmail] = useState('')
|
||||
const [current, setCurrent] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const pending = account.email_pending
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault()
|
||||
setMsg('')
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await api.changeEmail(email.trim(), hasPassword ? current : undefined)
|
||||
setEmail('')
|
||||
setCurrent('')
|
||||
// Report an unsent mail honestly. Saying "check your inbox" about a message
|
||||
// that was never sent turns a configuration problem into a user who waits.
|
||||
if (res.emailed === false) {
|
||||
setMsg(
|
||||
res.reason === 'NOT_CONFIGURED'
|
||||
? 'Address saved, but this site cannot send email right now. Ask an administrator, then use Resend.'
|
||||
: 'Address saved, but the confirmation email could not be sent. Try Resend in a moment.',
|
||||
)
|
||||
} else {
|
||||
setMsg(
|
||||
`Confirmation sent to ${res.email_pending}. Your current address stays in use until you open that link.`,
|
||||
)
|
||||
}
|
||||
await reload()
|
||||
} catch (err) {
|
||||
if (err.status === 429) setError('Too many confirmation emails. Try again later.')
|
||||
else setError(err.message || 'Could not change your email address.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function resend() {
|
||||
setMsg('')
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await api.resendEmailVerification()
|
||||
setMsg(
|
||||
res.emailed === false
|
||||
? 'Could not send the confirmation email.'
|
||||
: `Confirmation re-sent to ${res.email_pending}.`,
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not resend the confirmation email.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function discard() {
|
||||
setMsg('')
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.cancelEmailChange()
|
||||
setMsg('Pending address discarded.')
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not discard the pending address.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const wrap = embedded
|
||||
? {}
|
||||
: { marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }
|
||||
|
||||
return (
|
||||
<div style={wrap}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
Email address
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
{account.email ? (
|
||||
<>
|
||||
Currently <strong style={{ color: 'var(--head)' }}>{account.email}</strong>
|
||||
{account.email_verified ? ' (confirmed)' : ' (not yet confirmed)'}. This is where password-reset
|
||||
email is sent.
|
||||
</>
|
||||
) : (
|
||||
'You have no email address on file, so you cannot reset your password by email.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
{pending && (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
border: '1px solid var(--line-soft)',
|
||||
borderRadius: 6,
|
||||
padding: '10px 12px',
|
||||
marginBottom: 16,
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: 'var(--head)' }}>{pending}</strong> is waiting to be confirmed. It is not in
|
||||
use until you open the link in that email.
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||
<button type="button" onClick={resend} disabled={busy} className="btn btn-sq">
|
||||
Resend
|
||||
</button>
|
||||
<button type="button" onClick={discard} disabled={busy} className="btn btn-sq">
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={save} style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 320 }}>
|
||||
<label>
|
||||
<span className="field-label">{pending ? 'Use a different address' : 'New email address'}</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</label>
|
||||
{hasPassword && (
|
||||
<label>
|
||||
<span className="field-label">Current password</span>
|
||||
<input
|
||||
type="password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div>
|
||||
<button type="submit" disabled={busy || !email.trim()} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Send confirmation'}
|
||||
</button>
|
||||
</div>
|
||||
{(msg || error) && (
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.85rem', color: error ? '#e08a8a' : 'var(--muted)' }}>
|
||||
{error || msg}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</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(' · ')
|
||||
}
|
||||
103
client/src/lib/teamIntegrations.js
Normal file
103
client/src/lib/teamIntegrations.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// What Admin → Teams → Notification bridge decides (TEAMS.md §7.2, phase 8).
|
||||
//
|
||||
// The view is a form; these are the rules it applies, extracted for the same
|
||||
// reason `teamAdmin.js` is: the interesting parts are decisions — when the
|
||||
// acknowledgement dialog opens, and when a standing acknowledgement stops being
|
||||
// valid — and a decision embedded in JSX is one nothing can assert on.
|
||||
//
|
||||
// **The rules here MIRROR the server's and do not replace them.** The server
|
||||
// refuses to enable a members-only bridge without the acknowledgement (422)
|
||||
// whether or not this file ever ran. What is here is so the screen agrees with
|
||||
// that answer before making the round trip, rather than showing an operator a
|
||||
// save that fails for a reason the form did not mention.
|
||||
|
||||
// Wording an operator reads, per event id the server offers. Presentation, so it
|
||||
// lives on this side; the one bit that is policy — which events are members-only —
|
||||
// comes from the server with each event.
|
||||
export const EVENT_LABELS = {
|
||||
'team.member.joined': 'New members joined',
|
||||
'team.leadership.changed': 'Leadership changed',
|
||||
'team.forum.post': 'New forum post',
|
||||
'team.announcement': 'Announcement posted',
|
||||
}
|
||||
|
||||
export const eventLabel = (id) => EVENT_LABELS[id] || id
|
||||
|
||||
/** A row's identity in a list. `null` and `undefined` are both the default row. */
|
||||
export const rowKey = (row) =>
|
||||
(row.team_id === null || row.team_id === undefined ? 'default' : String(row.team_id))
|
||||
|
||||
export const isDefaultRow = (row) => row.team_id === null || row.team_id === undefined
|
||||
|
||||
export const blankDraft = (teamId = null) => ({
|
||||
teamId,
|
||||
events: [],
|
||||
channelRef: '',
|
||||
enabled: false,
|
||||
membersAck: false,
|
||||
})
|
||||
|
||||
export const draftFrom = (row) => ({
|
||||
teamId: row.team_id ?? null,
|
||||
events: row.events || [],
|
||||
channelRef: row.channel_ref || '',
|
||||
enabled: !!row.enabled,
|
||||
membersAck: !!row.members_ack,
|
||||
})
|
||||
|
||||
export function appliesToLabel(row, fallback = 'All Teams') {
|
||||
if (isDefaultRow(row)) return fallback
|
||||
return row.display_name_override || row.team_name || `Team #${row.team_id}`
|
||||
}
|
||||
|
||||
/** Toggle one event in a draft, preserving order of first selection. */
|
||||
export const toggleEvent = (draft, id) => ({
|
||||
...draft,
|
||||
events: draft.events.includes(id) ? draft.events.filter((e) => e !== id) : [...draft.events, id],
|
||||
})
|
||||
|
||||
/**
|
||||
* Repointing the row drops a standing acknowledgement, in the SAME place the
|
||||
* server does.
|
||||
*
|
||||
* Leaving the tick showing while the server has already decided to clear it is
|
||||
* the one way this screen could actively mislead: an operator repoints a row at a
|
||||
* public channel, sees "members-only destination confirmed" still ticked, and
|
||||
* believes the confirmation they gave for a private channel covers the new one.
|
||||
*/
|
||||
export function setChannel(draft, channelRef) {
|
||||
if (channelRef === draft.channelRef) return draft
|
||||
return { ...draft, channelRef, membersAck: false }
|
||||
}
|
||||
|
||||
/** Does this draft carry anything that would publish members-only text? */
|
||||
export const carriesMembersOnly = (draft, membersOnlyIds) =>
|
||||
draft.events.some((id) => membersOnlyIds.includes(id))
|
||||
|
||||
/**
|
||||
* Should saving stop and ask first?
|
||||
*
|
||||
* Only when ENABLING. A draft that carries forum events but is switched off is a
|
||||
* configuration being written, not a channel being published to — asking then
|
||||
* would make an operator confirm something they have not decided to do yet, which
|
||||
* is how a confirmation dialog becomes a thing people click through.
|
||||
*/
|
||||
export const needsAcknowledgement = (draft, membersOnlyIds) =>
|
||||
!!draft.enabled && carriesMembersOnly(draft, membersOnlyIds) && !draft.membersAck
|
||||
|
||||
/** The ids of every event the server flagged as members-only. */
|
||||
export const membersOnlyIdsOf = (events) => (events || []).filter((e) => e.membersOnly).map((e) => e.id)
|
||||
|
||||
/**
|
||||
* Which Teams may still be given an override, and whether the default is taken.
|
||||
*
|
||||
* Offering a Team that already has a row would only produce a save that silently
|
||||
* overwrote it, since the unique key is (platform, team).
|
||||
*/
|
||||
export function availableTargets(rows, teams) {
|
||||
const taken = new Set(rows.filter((r) => !isDefaultRow(r)).map((r) => r.team_id))
|
||||
return {
|
||||
hasDefault: rows.some(isDefaultRow),
|
||||
teams: (teams || []).filter((t) => t.status === 'active' && !taken.has(t.id)),
|
||||
}
|
||||
}
|
||||
112
client/src/lib/teamVoice.js
Normal file
112
client/src/lib/teamVoice.js
Normal file
@@ -0,0 +1,112 @@
|
||||
// What Admin → Teams → Voice channels decides (TEAMS.md §7.3, phase 9).
|
||||
//
|
||||
// Extracted for the reason `teamIntegrations.js` is: the interesting parts are
|
||||
// decisions — when the panel refuses to let voice be switched on, how close the
|
||||
// guild is to running out of roles, what a row's state actually means to the
|
||||
// person reading it — and a decision written inline in JSX is one nothing can
|
||||
// assert on.
|
||||
//
|
||||
// **These rules MIRROR the server's and do not replace them.** The server refuses
|
||||
// to enable voice while the bot cannot manage channels and roles (422) whether or
|
||||
// not this file ever ran, and the reconciler applies the threshold and the grace
|
||||
// window regardless of what the screen says. What is here is so the screen agrees
|
||||
// with those answers before making the round trip.
|
||||
|
||||
/** Wording for each state the server can report on a row. */
|
||||
export const STATE_LABELS = {
|
||||
none: 'Not provisioned',
|
||||
active: 'Active',
|
||||
pending_removal: 'Scheduled for removal',
|
||||
error: 'Error',
|
||||
}
|
||||
|
||||
export const stateLabel = (state) => STATE_LABELS[state] || state || 'Unknown'
|
||||
|
||||
/**
|
||||
* Is the panel allowed to offer the enable switch?
|
||||
*
|
||||
* The preflight answers three separate questions and they fail differently: the
|
||||
* bot is not connected at all, it is connected but missing a permission, or it
|
||||
* could not be reached. An operator can act on each of those and they need
|
||||
* different actions, so the reason is passed through rather than flattened to a
|
||||
* boolean.
|
||||
*/
|
||||
export function enableBlockedReason(preflight) {
|
||||
if (!preflight) return 'The bot’s status is unknown.'
|
||||
if (!preflight.connected) return preflight.reason || 'The Discord bot is not connected.'
|
||||
if (preflight.missingPermissions && preflight.missingPermissions.length > 0) {
|
||||
return `The bot is missing ${preflight.missingPermissions.join(' and ')} in this guild.`
|
||||
}
|
||||
if (!preflight.ready) return preflight.reason || 'The bot cannot manage channels and roles yet.'
|
||||
return null
|
||||
}
|
||||
|
||||
// Below this many free roles the panel starts saying so. Not a server rule and
|
||||
// deliberately not one: it is a warning, and the server's only hard behaviour is
|
||||
// to refuse the create that would exceed the cap.
|
||||
const HEADROOM_WARNING = 25
|
||||
|
||||
/**
|
||||
* How much room is left, and whether to say something about it.
|
||||
*
|
||||
* The 250-role cap is the ceiling this phase's shape brings with it. Access is a
|
||||
* per-Team role, so it is not "how big can a Team be" — the old overwrite design's
|
||||
* limit — but "how many Teams can have voice at all", and the difference matters
|
||||
* to an operator with sixty guilds on their shard. It is guild-wide and shared
|
||||
* with every role they created themselves, which is why the count comes from the
|
||||
* bot rather than from core's own rows.
|
||||
*/
|
||||
export function roleHeadroom(preflight) {
|
||||
if (!preflight || !preflight.roleCap) return null
|
||||
const used = Number(preflight.roleCount) || 0
|
||||
const cap = Number(preflight.roleCap)
|
||||
const free = Math.max(0, cap - used)
|
||||
return { used, cap, free, tight: free <= HEADROOM_WARNING, exhausted: free === 0 }
|
||||
}
|
||||
|
||||
/** How a row's grace window reads while it is running. */
|
||||
export function removalCountdown(row, now = new Date()) {
|
||||
if (!row || row.state !== 'pending_removal' || !row.removeAfter) return null
|
||||
const ms = new Date(row.removeAfter).getTime() - now.getTime()
|
||||
if (ms <= 0) return 'due for removal on the next pass'
|
||||
const days = Math.floor(ms / 86400000)
|
||||
if (days >= 1) return `in ${days} day${days === 1 ? '' : 's'}`
|
||||
const hours = Math.max(1, Math.round(ms / 3600000))
|
||||
return `in ${hours} hour${hours === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the staff-role field an operator types.
|
||||
*
|
||||
* Comma-separated ids, because that is what a person copying role ids out of
|
||||
* Discord ends up with. Validated rather than filtered, mirroring the server: a
|
||||
* quietly dropped id is a settings screen showing a save that did not happen.
|
||||
*/
|
||||
export function parseStaffRoles(text) {
|
||||
const parts = String(text || '')
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
const bad = parts.filter((part) => !/^[0-9]{5,32}$/.test(part))
|
||||
return { roles: parts, invalid: bad }
|
||||
}
|
||||
|
||||
export const formatStaffRoles = (roles) => (roles || []).join(', ')
|
||||
|
||||
/**
|
||||
* The sentence under the enable switch, which changes meaning with the state.
|
||||
*
|
||||
* "Off" is not "nothing is provisioned": switching voice off suspends the
|
||||
* reconciler in BOTH directions and leaves existing channels in place, which is
|
||||
* deliberate — a checkbox must not delete structure in somebody's guild — but it
|
||||
* is also surprising unless the screen says so.
|
||||
*/
|
||||
export function statusSummary(settings, rows) {
|
||||
const provisioned = (rows || []).filter((row) => row.channelRef).length
|
||||
if (!settings || !settings.enabled) {
|
||||
return provisioned > 0
|
||||
? `Off. ${provisioned} channel${provisioned === 1 ? '' : 's'} remain in Discord and are no longer being kept in step — remove them below if they are not wanted.`
|
||||
: 'Off. No channels are provisioned.'
|
||||
}
|
||||
return `On. Teams with at least ${settings.minMembers} member${settings.minMembers === 1 ? '' : 's'} get a voice channel and a role; ${provisioned} provisioned.`
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import { publishSharedDependencies } from './modules/shared.js'
|
||||
import { declareSlot } from './modules/registry.js'
|
||||
import { declareSlot, applyCoreFills, offerCoreFill } from './modules/registry.js'
|
||||
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
|
||||
import TeamForumPanel from './modules/TeamForumPanel.jsx'
|
||||
import TeamNotifyToggle from './modules/TeamNotifyToggle.jsx'
|
||||
import './styles/theme.css'
|
||||
|
||||
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
||||
@@ -18,8 +21,6 @@ publishSharedDependencies()
|
||||
// and namespace `uo`, so that the seam was exercised by real content from the
|
||||
// day it was built. That prediction paid out exactly as written: the extraction
|
||||
// deleted the registration and the hook it named, and SiteHeader was not touched.
|
||||
// There is nothing for core to register now — no core nav row carries a
|
||||
// `feature` — and the filter is a correct no-op until a module supplies one.
|
||||
|
||||
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
|
||||
//
|
||||
@@ -56,6 +57,49 @@ declareSlot('player.invite.accepted')
|
||||
// all three, and core's own fills had to go for it to be able to — the first
|
||||
// fill wins, and core registered first (§3.7).
|
||||
|
||||
// ── The inverted direction: core fills a MODULE's slot ─────────────────────
|
||||
//
|
||||
// Teams is a contract PRIMITIVE, not a surface (TEAMS.md Part 3). Core owns the
|
||||
// tables, the sync, the access rules and the activity feed; it does not own the
|
||||
// word for one — a UO shard says guild, and the module that comes after it will
|
||||
// say clan. So core publishes no Team page and no Team nav row, and the module
|
||||
// that owns the vocabulary owns the page.
|
||||
//
|
||||
// The activity feed is the one piece of that page core cannot hand over: only
|
||||
// core can resolve whether this viewer is inside the Team, and the public/members
|
||||
// split is a security boundary. So the module declares the place and core fills
|
||||
// it. Registered here, applied at mount — `applyCoreFills` runs after every
|
||||
// module chunk has evaluated, which is the only moment a module-declared slot
|
||||
// exists to be filled.
|
||||
//
|
||||
// **Core offers a CONTRIBUTION and never names a slot.** The module that owns the
|
||||
// page says where each of these goes, in its own vocabulary, by asking for one on
|
||||
// `declareModuleSlot`. Naming the slots here instead — which is how this was first
|
||||
// written — meant core's Team content reached exactly one module: any other game
|
||||
// declaring a place under its own id got an empty page and no error, because a
|
||||
// fill nobody asked for is deliberately not an error. It also put a module id
|
||||
// inside core, in string literals `scripts/checkModuleIdentifiers.js` masks by
|
||||
// construction and so could never have caught.
|
||||
//
|
||||
// Offering something nothing asks for is still not an error: a deployment with no
|
||||
// game module installed asks for none of these, which is the mirror of an
|
||||
// unfilled slot rendering nothing.
|
||||
offerCoreFill('team.activity', TeamActivityFeed)
|
||||
|
||||
// The forum is core's for the same reason and goes wherever the module asked for
|
||||
// it — a SECOND place, in module-uo's case, rather than joining the feed in the
|
||||
// first: a slot takes one component (first fill wins), and stacking two unrelated
|
||||
// panels into one contribution would make the module unable to place them
|
||||
// separately on its own page. It also keeps the two independent — a deployment
|
||||
// with the forum switched off renders the feed exactly as before.
|
||||
offerCoreFill('team.forum', TeamForumPanel)
|
||||
|
||||
// And the notification control. A third contribution rather than a corner of the
|
||||
// feed for the same reason there were two: this is an action on the page and the
|
||||
// other two are content in it, and only the module can say where each belongs on
|
||||
// a page it owns.
|
||||
offerCoreFill('team.notify', TeamNotifyToggle)
|
||||
|
||||
// Render on DOMContentLoaded rather than immediately, and that is the one line
|
||||
// of core's boot the module system changes.
|
||||
//
|
||||
@@ -82,6 +126,10 @@ declareSlot('player.invite.accepted')
|
||||
// static deferred script, so this branch is the genuine "the event has already
|
||||
// been and gone" case and not a wrong guess about our own timing.
|
||||
function mount() {
|
||||
// Every module chunk has evaluated by now, so any slot a module declared is
|
||||
// present and core's pending fills can land. Must happen before the first
|
||||
// render: `extensionFor` is read during render and there is no subscription.
|
||||
applyCoreFills()
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<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,120 @@ export function declareSlot(name) {
|
||||
slots.set(name, { Component: null, filledBy: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* The contributions core has for a module-declared slot.
|
||||
*
|
||||
* **Core offers a CONTRIBUTION, not a slot name, and that is the whole of why
|
||||
* this list exists.** The first cut of the inverted direction had core fill three
|
||||
* literal names — `uo.guild.detail` and its two siblings — which worked for
|
||||
* exactly one module and silently did nothing for any other: a second game
|
||||
* declaring `clan.detail` under its own id got an empty page and no error,
|
||||
* because "a fill for a slot nobody declared is not an error" is the rule that
|
||||
* makes an unknown name invisible. It also put a module identifier in core, in
|
||||
* three string literals `scripts/checkModuleIdentifiers.js` cannot see, since it
|
||||
* masks string bodies by construction.
|
||||
*
|
||||
* So the module says WHERE (its own slot, in its own vocabulary) and WHICH of
|
||||
* core's contributions goes there. Core never names a module id.
|
||||
*
|
||||
* Adding a member here is a **minor** MODULE_API bump. Requesting one that is not
|
||||
* here THROWS at the declaration, deliberately: unlike an unfilled slot, an
|
||||
* unknown contribution is always a typo or a version skew — core's list is fixed
|
||||
* at build time and a module's `coreApi` range has already been checked — and the
|
||||
* failure it would otherwise produce is a page that renders empty forever.
|
||||
*/
|
||||
export const CORE_CONTRIBUTIONS = Object.freeze({
|
||||
/** The Team activity feed. Core's because only core can resolve the public/members split on it. */
|
||||
'team.activity': true,
|
||||
/** The Team forum panel. Core's because membership and manual grants are core's rules. */
|
||||
'team.forum': true,
|
||||
/** The per-Team notification control. Core's because it resolves whether the viewer is in the Team. */
|
||||
'team.notify': true,
|
||||
})
|
||||
|
||||
/**
|
||||
* The INVERTED direction: a MODULE declares a slot and CORE fills it.
|
||||
*
|
||||
* Added for Teams (TEAMS.md Part 3). The original direction assumes core owns
|
||||
* the page and a module contributes to it, which is right for the footer and the
|
||||
* admin user detail. Teams is the other shape: **Teams is a contract primitive,
|
||||
* not a surface.** Core owns the tables, the sync, the access rules and the
|
||||
* activity feed; it does not own the vocabulary — a UO shard calls them guilds
|
||||
* and the next game will call them something else — so the PAGE is the module's
|
||||
* and the content core contributes to it is core's.
|
||||
*
|
||||
* Without this, core would have to publish a `/teams` page under a word it
|
||||
* invented, next to the module's own Guilds page saying the same thing twice.
|
||||
*
|
||||
* A module namespaces its slot under its own id (`uo.guild.detail`), which is
|
||||
* what stops two modules colliding and what makes the owner readable at the fill
|
||||
* site. The namespace is enforced rather than conventional.
|
||||
*
|
||||
* **`options.core` names which of core's contributions belongs in that place.**
|
||||
* It is optional — a module may declare a slot it fills itself, or one it keeps
|
||||
* empty for now — and it is the only thing that gets core's content into the
|
||||
* page. The place name stays the module's own word; the contribution is core's.
|
||||
*
|
||||
* **Ordering is why this is a separate call and not just `declareSlot` exposed
|
||||
* to modules.** Core's bundle evaluates BEFORE any module chunk (module scripts
|
||||
* are deferred and injected after core's), so at the moment core would like to
|
||||
* fill one of these, it does not exist yet. Core therefore offers its
|
||||
* contributions through `offerCoreFill` below, applied after every module chunk
|
||||
* has evaluated — see main.jsx.
|
||||
*/
|
||||
export function declareModuleSlot(id, name, options = {}) {
|
||||
if (!name.startsWith(`${id}.`)) {
|
||||
throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
|
||||
}
|
||||
if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`)
|
||||
const contribution = options.core ?? null
|
||||
if (contribution !== null && !Object.hasOwn(CORE_CONTRIBUTIONS, contribution)) {
|
||||
throw new Error(
|
||||
`declareModuleSlot: "${name}" asks for core contribution "${contribution}", which core does not ` +
|
||||
`offer. Known: ${Object.keys(CORE_CONTRIBUTIONS).join(', ')}.`,
|
||||
)
|
||||
}
|
||||
slots.set(name, { Component: null, filledBy: null, declaredBy: id, wants: contribution })
|
||||
}
|
||||
|
||||
// Core's pending contributions, applied once every module chunk has evaluated.
|
||||
// Kept as a list rather than applied eagerly because no module-declared slot
|
||||
// exists when core offers — see the ordering note above.
|
||||
const coreFills = []
|
||||
|
||||
/**
|
||||
* Core: "here is my <contribution>, for whichever module asked for it."
|
||||
*
|
||||
* Deliberately not an error when nothing asked. A deployment with no game module
|
||||
* installed asks for none of these, and core offering content for a page that
|
||||
* does not exist is the ordinary case rather than a misconfiguration — the mirror
|
||||
* of an unfilled slot rendering nothing.
|
||||
*
|
||||
* More than one slot may ask for the same contribution, and each gets it. Core
|
||||
* has no reason to care how many places a module wants its feed in, and refusing
|
||||
* the second would be core making a layout decision on a page it does not own.
|
||||
*/
|
||||
export function offerCoreFill(contribution, Component) {
|
||||
if (!Object.hasOwn(CORE_CONTRIBUTIONS, contribution)) {
|
||||
throw new Error(`offerCoreFill: "${contribution}" is not in CORE_CONTRIBUTIONS`)
|
||||
}
|
||||
if (typeof Component !== 'function') throw new Error(`offerCoreFill: ${contribution} is not a component`)
|
||||
coreFills.push([contribution, Component])
|
||||
}
|
||||
|
||||
/** Apply core's contributions. Called once from main.jsx, after module chunks have run. */
|
||||
export function applyCoreFills() {
|
||||
for (const [contribution, Component] of coreFills) {
|
||||
for (const entry of slots.values()) {
|
||||
if (entry.wants !== contribution) continue
|
||||
if (entry.filledBy) continue // a module already claimed it; first fill wins
|
||||
entry.Component = Component
|
||||
entry.filledBy = 'core'
|
||||
}
|
||||
}
|
||||
coreFills.length = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a declared slot with a component.
|
||||
*
|
||||
@@ -207,6 +321,7 @@ export function _reset() {
|
||||
nav[area].length = 0
|
||||
}
|
||||
providers.clear()
|
||||
coreFills.length = 0
|
||||
// Declarations go too, unlike the server's, where a slot is declared once at
|
||||
// require time by the router that owns it. Core declares its slots in
|
||||
// main.jsx — the one file no test loads — so on this side there is nothing
|
||||
@@ -224,6 +339,8 @@ export const registry = {
|
||||
registerNav,
|
||||
registerFeatureProvider,
|
||||
registerExtension,
|
||||
// The inverted direction (TEAMS.md Part 3): the module declares, core fills.
|
||||
declareModuleSlot,
|
||||
routesFor,
|
||||
navFor,
|
||||
featureProviderFor,
|
||||
|
||||
@@ -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 ninth member, for the INVERTED slot direction (TEAMS.md Part 3). A
|
||||
// module that declares a slot on its own page needs the same component core
|
||||
// renders its own with — the error boundary in particular, since the thing
|
||||
// being contained here is CORE's content failing inside the MODULE's page.
|
||||
// Shared rather than reimplemented for the reason the whole kit exists: two
|
||||
// boundaries with different behaviour would be two bugs.
|
||||
Slot,
|
||||
}
|
||||
|
||||
// The request PRIMITIVE, not the `api` object (§3.5): a module builds its own
|
||||
|
||||
@@ -11,6 +11,33 @@
|
||||
// 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.7.0 — the engagement contract (docs/website/ENGAGEMENT.md Phase 2). Nothing
|
||||
// on this half changed: every member the version adds is on the server's `api`
|
||||
// and `ctx` (registerEventTriggers, registerAudiences, ctx.events.emit,
|
||||
// ctx.inbox.push). 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. The web surfaces the engagement system needs (the rules and template
|
||||
// editors, the in-app inbox) land in Phases 4, 5 and 7 and will add to this half
|
||||
// then.
|
||||
// 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 +53,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.7.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,8 @@ const TITLES = {
|
||||
'/admin/hero': 'Hero Editor',
|
||||
'/admin/moderation': 'Moderation',
|
||||
'/admin/moderation/appeals': 'Appeals',
|
||||
'/admin/moderation/reports': 'Reports',
|
||||
'/admin/teams': 'Teams',
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/appearance': 'Appearance',
|
||||
'/admin/navigation': 'Navigation',
|
||||
|
||||
@@ -4,6 +4,7 @@ import ProviderIcon from '../../../components/ProviderIcon.jsx'
|
||||
import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisplay.jsx'
|
||||
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
|
||||
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Link/unlink external SSO identities to this account. Linking redirects through
|
||||
@@ -25,7 +26,7 @@ function LinkedAccounts() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [ids, avail] = await Promise.all([
|
||||
api.admin.linkedIdentities(),
|
||||
api.myIdentities(),
|
||||
api.authProviders().catch(() => []),
|
||||
])
|
||||
setLinked(ids)
|
||||
@@ -44,7 +45,7 @@ function LinkedAccounts() {
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
try {
|
||||
await api.admin.unlinkIdentity(provider)
|
||||
await api.unlinkIdentity(provider)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
@@ -134,7 +135,7 @@ export default function AccountAdmin() {
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setAccount(await api.admin.getAccount())
|
||||
setAccount(await api.myAccount())
|
||||
} catch {
|
||||
setError('Could not load your account.')
|
||||
} finally {
|
||||
@@ -154,7 +155,7 @@ export default function AccountAdmin() {
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
setSetup(await api.admin.totpSetup())
|
||||
setSetup(await api.totpSetup())
|
||||
setCode('')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not start setup.')
|
||||
@@ -168,7 +169,7 @@ export default function AccountAdmin() {
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.admin.totpEnable(code.trim())
|
||||
const res = await api.totpEnable(code.trim())
|
||||
setSetup(null)
|
||||
setCode('')
|
||||
setNewCodes(res?.recoveryCodes || null)
|
||||
@@ -186,7 +187,7 @@ export default function AccountAdmin() {
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.totpDisable(code.trim())
|
||||
await api.totpDisable(code.trim())
|
||||
setCode('')
|
||||
setMsg('Two-factor authentication has been disabled.')
|
||||
await load()
|
||||
@@ -322,6 +323,10 @@ export default function AccountAdmin() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* The self-service address, from the same component the player portal
|
||||
renders — /auth/me/account is one surface for every role. */}
|
||||
{account && <EmailAddressPanel account={account} reload={load} />}
|
||||
|
||||
<LinkedAccounts />
|
||||
</section>
|
||||
)
|
||||
|
||||
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)' }
|
||||
@@ -66,6 +66,34 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<section>
|
||||
{/* Operator warnings: things that are quietly not working and would
|
||||
otherwise be discovered by someone not receiving an email. The list is
|
||||
normally empty, which is why it sits above the fold rather than in a
|
||||
panel — see ENGAGEMENT.md §1.2a (G22). */}
|
||||
{(dash.warnings || []).map((w) => (
|
||||
<div
|
||||
key={w.code}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.86rem',
|
||||
lineHeight: 1.5,
|
||||
borderRadius: 10,
|
||||
padding: '12px 16px',
|
||||
marginBottom: 18,
|
||||
border: '1px solid #7a6440',
|
||||
background: 'rgba(224,176,112,0.08)',
|
||||
color: '#e0b070',
|
||||
}}
|
||||
>
|
||||
{w.message}
|
||||
{w.href && (
|
||||
<>
|
||||
{' '}
|
||||
<a href={w.href} style={{ color: '#e0b070', textDecoration: 'underline' }}>Open settings</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -2,11 +2,20 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
|
||||
// Email delivery panel (Gmail over OAuth2), rendered as a section on the Settings
|
||||
// page. Sending is authorized by an in-app "Connect Gmail" consent flow that
|
||||
// captures a refresh token server-side — the token is write-only over the API
|
||||
// (stored encrypted, never returned). Reuses the Google SSO OAuth client, so it
|
||||
// requires the Google provider to be configured on the Authentication page first.
|
||||
// Email delivery panel, rendered as a section on the Settings page. Sending goes
|
||||
// through a registered mail transport (SMTP today) whose credentials the operator
|
||||
// types here; they are stored encrypted server-side and are write-only over the
|
||||
// API — a secret field comes back as "set", never as its value.
|
||||
//
|
||||
// **The form is not written here.** The server ships each transport's declared
|
||||
// `credentialFields` with the config, and this renders them. That is the whole
|
||||
// point of the declaration (ENGAGEMENT.md §3.1): adding a transport must not mean
|
||||
// editing this file. So there is no `host`, `port` or `password` anywhere below —
|
||||
// only field kinds.
|
||||
//
|
||||
// The "Connect Gmail" button, its redirect banner and its six error strings went
|
||||
// with the OAuth2 flow (§1.2a). Gmail is still reachable, as an ordinary SMTP
|
||||
// relay with an app password — which the operator types in like any other host.
|
||||
|
||||
const STATUS_COLOR = {
|
||||
connected: '#7fd0a4',
|
||||
@@ -14,17 +23,6 @@ const STATUS_COLOR = {
|
||||
unconfigured: 'var(--muted)',
|
||||
}
|
||||
|
||||
// Human-friendly text for the ?email_error=<code> the callback may redirect with.
|
||||
const ERROR_TEXT = {
|
||||
denied: 'Google sign-in was cancelled or denied.',
|
||||
bad_state: 'The connect session expired. Please try again.',
|
||||
no_client: 'The Google OAuth client is not configured.',
|
||||
no_refresh_token:
|
||||
'Google did not return a refresh token. Remove this app under your Google Account → Security → Third-party access, then reconnect.',
|
||||
no_email: 'Could not read the Gmail address from Google.',
|
||||
error: 'Could not connect the Gmail account. Please try again.',
|
||||
}
|
||||
|
||||
function StatusPanel({ config }) {
|
||||
const color = STATUS_COLOR[config.status] || 'var(--muted)'
|
||||
return (
|
||||
@@ -52,60 +50,100 @@ function StatusPanel({ config }) {
|
||||
)
|
||||
}
|
||||
|
||||
// One declared credential field. A `secret` already held renders empty with a
|
||||
// "leave blank to keep" hint, matching the server's patch semantics: an empty
|
||||
// secret is omitted from the save, not written as a blank.
|
||||
function CredentialField({ field, value, isSet, onChange }) {
|
||||
const hint = [field.help, field.kind === 'secret' && isSet ? 'Currently set — leave blank to keep it.' : null]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
if (field.kind === 'boolean') {
|
||||
return (
|
||||
<label className="sans" style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<input type="checkbox" checked={Boolean(value)} onChange={(e) => onChange(e.target.checked)} style={{ marginTop: 3 }} />
|
||||
<span>
|
||||
{field.label}
|
||||
{hint && <span className="sans dim" style={{ display: 'block', fontSize: '0.78rem' }}>{hint}</span>}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">
|
||||
{field.label}
|
||||
{field.required ? '' : ' (optional)'}
|
||||
</span>
|
||||
<input
|
||||
type={field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="input"
|
||||
autoComplete={field.kind === 'secret' ? 'new-password' : 'off'}
|
||||
placeholder={field.placeholder || ''}
|
||||
/>
|
||||
{hint && <span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>{hint}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default function EmailDelivery() {
|
||||
const { siteTitle } = useSite()
|
||||
const [config, setConfig] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [transport, setTransport] = useState('smtp')
|
||||
const [senderEmail, setSenderEmail] = useState('')
|
||||
const [senderName, setSenderName] = useState('')
|
||||
const [replyTo, setReplyTo] = useState('')
|
||||
const [credential, setCredential] = useState({})
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [busy, setBusy] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [banner, setBanner] = useState(null) // fields kind ('ok' or 'err') and text
|
||||
|
||||
// Seed the credential inputs from the non-secret values the server returned,
|
||||
// falling back to each field's declared default. Secrets are never seeded —
|
||||
// the server does not send them and an empty box means "keep what you have".
|
||||
const seedCredential = useCallback((c, transportId) => {
|
||||
const def = (c.transports || []).find((t) => t.id === transportId)
|
||||
const next = {}
|
||||
for (const f of def?.credentialFields || []) {
|
||||
if (f.kind === 'secret') continue
|
||||
next[f.key] = c.credential?.[f.key] ?? (f.default === null ? '' : f.default)
|
||||
}
|
||||
return next
|
||||
}, [])
|
||||
|
||||
const load = useCallback(async (seedForm = false) => {
|
||||
try {
|
||||
const c = await api.admin.getEmailConfig()
|
||||
setConfig(c)
|
||||
if (seedForm) {
|
||||
setTransport(c.transport || 'smtp')
|
||||
setSenderEmail(c.senderEmail || '')
|
||||
setSenderName(c.senderName || '')
|
||||
setReplyTo(c.replyTo || '')
|
||||
setEnabled(c.enabled)
|
||||
setCredential(seedCredential(c, c.transport || 'smtp'))
|
||||
}
|
||||
return c
|
||||
} catch {
|
||||
setError('Could not load email settings.')
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
}, [seedCredential])
|
||||
|
||||
// On mount, surface the outcome of a just-completed connect redirect, strip the
|
||||
// query params so a refresh doesn't replay the banner, then load config.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.has('email_connected')) {
|
||||
setBanner({ kind: 'ok', text: 'Gmail account connected.' })
|
||||
} else if (params.has('email_error')) {
|
||||
setBanner({ kind: 'err', text: ERROR_TEXT[params.get('email_error')] || 'Could not connect email.' })
|
||||
}
|
||||
if (params.has('email_connected') || params.has('email_error')) {
|
||||
params.delete('email_connected')
|
||||
params.delete('email_error')
|
||||
const qs = params.toString()
|
||||
window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : ''))
|
||||
}
|
||||
load(true)
|
||||
}, [load])
|
||||
|
||||
async function connect() {
|
||||
setBusy('connect')
|
||||
setActionError('')
|
||||
try {
|
||||
const { url } = await api.admin.emailConnectUrl()
|
||||
window.location.href = url
|
||||
} catch (err) {
|
||||
setActionError(err.message || 'Could not start the connect flow.')
|
||||
setBusy('')
|
||||
}
|
||||
// Switching transport starts from the new one's declared defaults, because the
|
||||
// server does the same: a credential blob is never carried across transports.
|
||||
function changeTransport(id) {
|
||||
setTransport(id)
|
||||
setCredential(seedCredential(config, id))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
@@ -113,10 +151,19 @@ export default function EmailDelivery() {
|
||||
setMsg('')
|
||||
setActionError('')
|
||||
try {
|
||||
const saved = await api.admin.saveEmailConfig({ senderName, enabled })
|
||||
const saved = await api.admin.saveEmailConfig({ transport, senderEmail, senderName, replyTo, credential, enabled })
|
||||
setConfig(saved)
|
||||
setEnabled(saved.enabled)
|
||||
setCredential(seedCredential(saved, saved.transport))
|
||||
setMsg('Saved.')
|
||||
} catch (err) {
|
||||
// A refused enable comes back with the reverted config attached, so the
|
||||
// screen shows what is actually stored rather than the state that was
|
||||
// rejected.
|
||||
if (err.body?.config) {
|
||||
setConfig(err.body.config)
|
||||
setEnabled(err.body.config.enabled)
|
||||
}
|
||||
setActionError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
@@ -133,12 +180,13 @@ export default function EmailDelivery() {
|
||||
await load()
|
||||
} catch (err) {
|
||||
setActionError(err.message || 'Could not send the test email.')
|
||||
await load()
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
async function clearCredentials() {
|
||||
setBusy('disconnect')
|
||||
setMsg('')
|
||||
setActionError('')
|
||||
@@ -146,9 +194,11 @@ export default function EmailDelivery() {
|
||||
const c = await api.admin.disconnectEmail()
|
||||
setConfig(c)
|
||||
setEnabled(false)
|
||||
setMsg('Disconnected.')
|
||||
setSenderEmail('')
|
||||
setCredential(seedCredential(c, c.transport))
|
||||
setMsg('Credentials cleared.')
|
||||
} catch (err) {
|
||||
setActionError(err.message || 'Could not disconnect.')
|
||||
setActionError(err.message || 'Could not clear the credentials.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
@@ -157,84 +207,118 @@ export default function EmailDelivery() {
|
||||
if (error) return <p className="sans" style={{ color: '#d98b84' }}>{error}</p>
|
||||
if (!config) return null
|
||||
|
||||
const connected = config.hasRefreshToken
|
||||
const catalog = config.transports || []
|
||||
const selected = catalog.find((t) => t.id === transport)
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 16, marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 30 }}>
|
||||
<div>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Email delivery</h2>
|
||||
<p className="sans dim" style={{ margin: '6px 0 0', fontSize: '0.82rem' }}>
|
||||
Sends the contact form through Gmail over OAuth2, delivered to the
|
||||
<strong> Contact email</strong> above. Reuses the Google authentication
|
||||
client — configure that on the Authentication page first.
|
||||
Sends the contact form, invitations, password resets and team
|
||||
notifications. Contact-form mail is delivered to the
|
||||
<strong> Contact email</strong> above. Credentials are stored encrypted
|
||||
and never shown again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{banner && (
|
||||
{config.hadLegacyConnection && !config.hasCredential && (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.85rem',
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
border: `1px solid ${banner.kind === 'ok' ? '#3f6b52' : '#7a4440'}`,
|
||||
color: banner.kind === 'ok' ? '#7fd0a4' : '#d98b84',
|
||||
}}
|
||||
style={{ fontSize: '0.85rem', borderRadius: 8, padding: '10px 12px', border: '1px solid #7a6440', color: '#e0b070' }}
|
||||
>
|
||||
{banner.text}
|
||||
This deployment was connected with the old Gmail sign-in, which has been
|
||||
removed. <strong>No mail is being sent.</strong> Enter SMTP credentials
|
||||
below to restore it — for Gmail, use <code>smtp.gmail.com</code> port 587
|
||||
with an app password.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatusPanel config={config} />
|
||||
|
||||
{!config.googleConfigured && (
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: '#e0b070' }}>
|
||||
The Google authentication provider needs a client ID and secret before
|
||||
you can connect a Gmail account.
|
||||
</p>
|
||||
{catalog.length > 1 && (
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Transport</span>
|
||||
<select value={transport} onChange={(e) => changeTransport(e.target.value)} className="input">
|
||||
{catalog.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{!connected ? (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={connect} disabled={busy === 'connect' || !config.googleConfigured} className="btn btn-primary btn-sq">
|
||||
{busy === 'connect' ? 'Redirecting…' : 'Connect Gmail'}
|
||||
{selected?.help && (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>{selected.help}</p>
|
||||
)}
|
||||
|
||||
{(selected?.credentialFields || []).map((f) => (
|
||||
<CredentialField
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={credential[f.key]}
|
||||
isSet={Boolean(config.secretsSet?.[f.key])}
|
||||
onChange={(v) => setCredential((prev) => ({ ...prev, [f.key]: v }))}
|
||||
/>
|
||||
))}
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Send from</span>
|
||||
<input
|
||||
type="email"
|
||||
value={senderEmail}
|
||||
onChange={(e) => setSenderEmail(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder="noreply@example.com"
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
Must be an address this account is allowed to send as, or the relay will
|
||||
reject it. Use <strong>Send test</strong> to confirm.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">From display name (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={senderName}
|
||||
onChange={(e) => setSenderName(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder={siteTitle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Reply-To (optional)</span>
|
||||
<input
|
||||
type="email"
|
||||
value={replyTo}
|
||||
onChange={(e) => setReplyTo(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder="Leave blank to reply to the sending address"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
Enable email sending
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={save} disabled={busy === 'save'} className="btn btn-primary btn-sq">
|
||||
{busy === 'save' ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
<button onClick={sendTest} disabled={busy === 'test' || !config.hasCredential} className="pill">
|
||||
{busy === 'test' ? 'Sending…' : 'Send test'}
|
||||
</button>
|
||||
{config.hasCredential && (
|
||||
<button onClick={clearCredentials} disabled={busy === 'disconnect'} className="pill">
|
||||
Clear credentials
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
Enable email sending
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">From display name (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={senderName}
|
||||
onChange={(e) => setSenderName(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder={siteTitle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={save} disabled={busy === 'save'} className="btn btn-primary btn-sq">
|
||||
{busy === 'save' ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
<button onClick={sendTest} disabled={busy === 'test'} className="pill">
|
||||
{busy === 'test' ? 'Sending…' : 'Send test'}
|
||||
</button>
|
||||
<button onClick={connect} disabled={busy === 'connect'} className="pill">
|
||||
Reconnect
|
||||
</button>
|
||||
<button onClick={disconnect} disabled={busy === 'disconnect'} className="pill">
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ minHeight: 18 }}>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
292
client/src/routes/admin/views/TeamIntegrations.jsx
Normal file
292
client/src/routes/admin/views/TeamIntegrations.jsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
import {
|
||||
eventLabel, rowKey, isDefaultRow, blankDraft, draftFrom, appliesToLabel, toggleEvent,
|
||||
setChannel, needsAcknowledgement, membersOnlyIdsOf, availableTargets,
|
||||
} from '../../../lib/teamIntegrations.js'
|
||||
|
||||
// The Team notification bridge (TEAMS.md §7.2, phase 8).
|
||||
//
|
||||
// Named for the TEAM concern rather than for Discord, and placed under Teams
|
||||
// rather than in the Discord Bot panel, because phase 10 replaces "Discord" here
|
||||
// with whatever the capability registry declares. What changes then should be
|
||||
// what fills this panel, not where an operator goes to find it. Nothing below
|
||||
// hardcodes the word except the heading the server sends as `platform`.
|
||||
//
|
||||
// **The checkbox in the dialog is not the gate.** The server refuses to enable a
|
||||
// row carrying `team.forum.post` or `team.announcement` without the
|
||||
// acknowledgement, 422, whether or not this dialog was ever rendered — the same
|
||||
// division TeamForumSettings draws for image uploads. What is here is how the
|
||||
// gate is PRESENTED: the sentence an operator agrees to, and the fact that
|
||||
// agreeing is a deliberate act rather than a checkbox they tab past.
|
||||
|
||||
const PANEL = { padding: 22, marginBottom: 22, maxWidth: 760 }
|
||||
const HEADING = { margin: '0 0 6px', fontSize: '1.2rem', color: 'var(--head)' }
|
||||
|
||||
const ACK_TEXT = [
|
||||
'Forum posts and announcements are visible only to a Team’s members. This site cannot see who can'
|
||||
+ ' read a channel on another platform, so it cannot check that for you.',
|
||||
'By enabling these events you confirm that the destination channel is restricted to the members of'
|
||||
+ ' the Team whose posts it will carry.',
|
||||
]
|
||||
|
||||
export default function TeamIntegrations() {
|
||||
const [config, setConfig] = useState(null)
|
||||
const [teams, setTeams] = useState([])
|
||||
const [draft, setDraft] = useState(null)
|
||||
const [dialog, setDialog] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const [cfg, teamList] = await Promise.all([api.admin.teamIntegrations(), api.admin.listTeams()])
|
||||
setConfig(cfg)
|
||||
setTeams((teamList.teams || []).filter((t) => t.status === 'active'))
|
||||
} catch (err) {
|
||||
// A moderator never reaches this panel — the admin nav does not render it —
|
||||
// so a 403 here means the role changed underneath an open tab rather than a
|
||||
// routing mistake, and saying so beats "could not load".
|
||||
setError(err.status === 403 ? 'Only an admin can configure the notification bridge.' : (err.message || 'Could not load the bridge configuration.'))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Notification bridge</h2>
|
||||
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const membersOnlyIds = membersOnlyIdsOf(config.events)
|
||||
const { hasDefault, teams: available } = availableTargets(config.rows, teams)
|
||||
|
||||
async function persist(next) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
await api.admin.saveTeamIntegration({
|
||||
teamId: next.teamId,
|
||||
events: next.events,
|
||||
channelRef: next.channelRef.trim() || null,
|
||||
enabled: next.enabled,
|
||||
membersAck: next.membersAck,
|
||||
})
|
||||
setDraft(null)
|
||||
setDialog(null)
|
||||
setNotice('Saved.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save.')
|
||||
setDialog(null)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Enabling members-only events without a standing acknowledgement asks first.
|
||||
// Everything else — disabling, editing a channel, adding a roster event — saves
|
||||
// straight through.
|
||||
function save() {
|
||||
if (!draft) return
|
||||
if (needsAcknowledgement(draft, membersOnlyIds)) {
|
||||
setDialog(draft)
|
||||
return
|
||||
}
|
||||
persist(draft)
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.deleteTeamIntegration(row.team_id ?? null)
|
||||
setNotice('Removed.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not remove.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Notification bridge</h2>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
|
||||
Send Team notifications to a {config.platform} channel. Set a default that every Team uses, and
|
||||
override it for individual Teams. A message is sent once and not retried — the bridge is a
|
||||
courtesy, and nothing on the site depends on it arriving.
|
||||
</p>
|
||||
|
||||
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
|
||||
{notice && <p className="sans" style={{ color: '#7fd0a4', fontSize: '0.82rem' }}>{notice}</p>}
|
||||
|
||||
{config.rows.length === 0 && !draft && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem' }}>Nothing configured — no Team events leave the site.</p>
|
||||
)}
|
||||
|
||||
{config.rows.length > 0 && (
|
||||
<div className="panel-flat" style={{ overflowX: 'auto' }}>
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Applies to</th>
|
||||
<th className="adm-th">Events</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">State</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{config.rows.map((row) => (
|
||||
<tr key={rowKey(row)}>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>
|
||||
{appliesToLabel(row)}
|
||||
{isDefaultRow(row) && <span className="dim"> (default)</span>}
|
||||
</td>
|
||||
<td className="adm-td">
|
||||
{row.events.length === 0
|
||||
? <span className="dim">none</span>
|
||||
: row.events.map(eventLabel).join(', ')}
|
||||
</td>
|
||||
<td className="adm-td dim">{row.channel_ref || <span className="dim">unset</span>}</td>
|
||||
<td className="adm-td">
|
||||
{row.enabled ? 'Enabled' : 'Disabled'}
|
||||
{row.members_ack && (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 3 }}>
|
||||
members-only destination confirmed
|
||||
{row.members_ack_username ? ` by ${row.members_ack_username}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => setDraft(draftFrom(row))}>Edit</button>
|
||||
<button type="button" className="btn btn-ghost btn-sq" style={{ marginLeft: 8 }} disabled={busy} onClick={() => remove(row)}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!draft && (
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 14 }}>
|
||||
{!hasDefault && (
|
||||
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft(blankDraft(null))}>
|
||||
Set a default for all Teams
|
||||
</button>
|
||||
)}
|
||||
{available.length > 0 && (
|
||||
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft(blankDraft(available[0].id))}>
|
||||
Add a per-Team override
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft && (
|
||||
<div style={{ marginTop: 18, borderTop: '1px solid var(--line-soft)', paddingTop: 16 }}>
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Applies to</span>
|
||||
<select
|
||||
className="select"
|
||||
value={draft.teamId === null ? 'default' : String(draft.teamId)}
|
||||
onChange={(e) => setDraft({ ...draft, teamId: e.target.value === 'default' ? null : Number(e.target.value) })}
|
||||
>
|
||||
<option value="default">All Teams (default)</option>
|
||||
{teams.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.display_name_override || t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span className="field-label">Events to send</span>
|
||||
{config.events.map((event) => (
|
||||
<label key={event.id} style={{ display: 'block', marginTop: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.events.includes(event.id)}
|
||||
onChange={() => setDraft((d) => toggleEvent(d, event.id))}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<span className="sans" style={{ fontSize: '0.82rem' }}>{eventLabel(event.id)}</span>
|
||||
{event.membersOnly && (
|
||||
<span className="dim sans" style={{ fontSize: '0.72rem', marginLeft: 8 }}>members-only content</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
|
||||
<label style={{ display: 'block', marginTop: 14 }}>
|
||||
<span className="field-label">Channel id</span>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.channelRef}
|
||||
// Changing the channel drops a standing acknowledgement in the SAME
|
||||
// place the server does. Leaving the tick showing while the server
|
||||
// has already decided to clear it would let an operator repoint a row
|
||||
// at a public channel and believe the confirmation still covered it.
|
||||
onChange={(e) => setDraft((d) => setChannel(d, e.target.value))}
|
||||
placeholder="1024839201048392010"
|
||||
style={{ maxWidth: 280 }}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||||
Right-click a channel in {config.platform} and copy its id. Changing it asks you to confirm
|
||||
the new channel’s audience again.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginTop: 14 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.enabled}
|
||||
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<span className="field-label" style={{ display: 'inline' }}>Enabled</span>
|
||||
</label>
|
||||
|
||||
{draft.membersAck && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 10 }}>
|
||||
You have confirmed this channel is restricted to the Team’s members.{' '}
|
||||
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft({ ...draft, membersAck: false })}>
|
||||
Withdraw
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={save}>Save</button>
|
||||
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => { setDraft(null); setError('') }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialog && (
|
||||
<div style={{ marginTop: 18, border: '1px solid #e0b070', padding: 16, borderRadius: 'var(--radius-input)' }}>
|
||||
<h3 className="display" style={{ fontSize: '0.95rem', marginTop: 0 }}>Confirm the destination’s audience</h3>
|
||||
{ACK_TEXT.map((line) => (
|
||||
<p key={line} className="sans" style={{ fontSize: '0.8rem' }}>{line}</p>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() => persist({ ...dialog, membersAck: true })}
|
||||
>
|
||||
I confirm the channel is members-only
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => setDialog(null)}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
273
client/src/routes/admin/views/TeamVoice.jsx
Normal file
273
client/src/routes/admin/views/TeamVoice.jsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
import {
|
||||
stateLabel, enableBlockedReason, roleHeadroom, removalCountdown,
|
||||
parseStaffRoles, formatStaffRoles, statusSummary,
|
||||
} from '../../../lib/teamVoice.js'
|
||||
|
||||
// Team voice channels (TEAMS.md §7.3, phase 9).
|
||||
//
|
||||
// Named for the Team concern and placed under Teams beside the notification
|
||||
// bridge, for the reason that panel gives: phase 10 replaces "Discord" with
|
||||
// whatever the capability registry declares, and what should change then is what
|
||||
// fills this panel rather than where an operator goes to find it.
|
||||
//
|
||||
// **The preflight is the first thing on the page, not a diagnostic.** §7.3
|
||||
// assumed the bot could manage channels and roles; nothing in this project has
|
||||
// ever checked, because the operator invites the bot by hand and no invite URL
|
||||
// with a permission integer exists anywhere in the tree. An operator whose bot
|
||||
// lacks Manage Roles otherwise has a screen full of controls that cannot work,
|
||||
// and finds out one Team at a time from a column of identical errors.
|
||||
|
||||
const PANEL = { padding: 22, marginBottom: 22, maxWidth: 760 }
|
||||
const HEADING = { margin: '0 0 6px', fontSize: '1.2rem', color: 'var(--head)' }
|
||||
|
||||
export default function TeamVoice() {
|
||||
const [config, setConfig] = useState(null)
|
||||
const [draft, setDraft] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const cfg = await api.admin.teamVoice()
|
||||
setConfig(cfg)
|
||||
setDraft({
|
||||
enabled: cfg.settings.enabled,
|
||||
minMembers: cfg.settings.minMembers,
|
||||
graceDays: cfg.settings.graceDays,
|
||||
staffRoles: formatStaffRoles(cfg.settings.staffRoles),
|
||||
})
|
||||
} catch (err) {
|
||||
// A moderator never reaches this panel — the admin nav does not render it —
|
||||
// so a 403 means the role changed underneath an open tab.
|
||||
setError(err.status === 403
|
||||
? 'Only an admin can configure Team voice channels.'
|
||||
: (err.message || 'Could not load the voice configuration.'))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (!config || !draft) {
|
||||
return (
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Voice channels</h2>
|
||||
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const blocked = enableBlockedReason(config.preflight)
|
||||
const headroom = roleHeadroom(config.preflight)
|
||||
|
||||
async function save() {
|
||||
const { roles, invalid } = parseStaffRoles(draft.staffRoles)
|
||||
if (invalid.length > 0) {
|
||||
setError(`Not a role id: ${invalid.join(', ')}. Copy role ids from Discord with Developer Mode on.`)
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
await api.admin.saveTeamVoice({
|
||||
enabled: draft.enabled,
|
||||
minMembers: Number(draft.minMembers),
|
||||
graceDays: Number(draft.graceDays),
|
||||
staffRoles: roles,
|
||||
})
|
||||
setNotice('Saved.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function runPass() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
const result = await api.admin.teamVoicePass()
|
||||
// A pass that refused says why, and that is the useful answer far more often
|
||||
// than a count is — "stale projection" and "synced 0" look identical in a
|
||||
// summary and mean completely different things.
|
||||
setNotice(result.ran
|
||||
? `Synced ${result.synced}, created ${result.created}, scheduled ${result.scheduled}, removed ${result.removed}, failed ${result.failed}.`
|
||||
: `Nothing was done: ${result.reason}`)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not run a pass.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.removeTeamVoice(row.teamId)
|
||||
setNotice('Removed.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not remove.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Voice channels</h2>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
|
||||
Give each Team a {config.platform} voice channel of its own. Access is granted with a role per
|
||||
Team, so members of a Team can see and join their channel and nobody else can. Members need a
|
||||
linked {config.platform} account and must be in the guild.
|
||||
</p>
|
||||
|
||||
{blocked && (
|
||||
<p className="sans" style={{ color: '#e0b070', fontSize: '0.82rem' }}>
|
||||
{blocked} Voice channels cannot be switched on until that is fixed.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{headroom && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||||
{headroom.used} of {headroom.cap} {config.platform} roles used in this guild
|
||||
{headroom.exhausted
|
||||
? ' — no room for another Team.'
|
||||
: headroom.tight
|
||||
? ` — room for about ${headroom.free} more Teams.`
|
||||
: '.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
|
||||
{notice && <p className="sans" style={{ color: '#7fd0a4', fontSize: '0.82rem' }}>{notice}</p>}
|
||||
|
||||
<p className="sans" style={{ fontSize: '0.8rem' }}>{statusSummary(config.settings, config.rows)}</p>
|
||||
|
||||
<div style={{ marginTop: 14, borderTop: '1px solid var(--line-soft)', paddingTop: 16 }}>
|
||||
<label className="sans" style={{ display: 'block', marginBottom: 12, fontSize: '0.82rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.enabled}
|
||||
disabled={busy || (!!blocked && !draft.enabled)}
|
||||
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
|
||||
/>
|
||||
{' '}Provision voice channels for Teams
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Minimum members</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
value={draft.minMembers}
|
||||
disabled={busy}
|
||||
onChange={(e) => setDraft({ ...draft, minMembers: e.target.value })}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
|
||||
Every active member counts, whether or not they have linked an account.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Grace window (days)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
max="90"
|
||||
value={draft.graceDays}
|
||||
disabled={busy}
|
||||
onChange={(e) => setDraft({ ...draft, graceDays: e.target.value })}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
|
||||
How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the
|
||||
window keeps the same channel; zero removes it on the next pass.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Staff roles</span>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={draft.staffRoles}
|
||||
disabled={busy}
|
||||
placeholder="role id, role id"
|
||||
onChange={(e) => setDraft({ ...draft, staffRoles: e.target.value })}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
|
||||
Roles that can see and join every Team’s channel. Guild administrators already can, so this
|
||||
is for staff who are not administrators. Leave empty if there are none.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={save}>Save</button>
|
||||
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={runPass}>Sync now</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.rows.length > 0 && (
|
||||
<div className="panel-flat" style={{ marginTop: 18, overflowX: 'auto' }}>
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Team</th>
|
||||
<th className="adm-th">Members</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">State</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{config.rows.map((row) => (
|
||||
<tr key={row.teamId}>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>{row.teamName}</td>
|
||||
<td className="adm-td">{row.memberCount}</td>
|
||||
<td className="adm-td dim">
|
||||
{row.channelRef || <span className="dim">none</span>}
|
||||
</td>
|
||||
<td className="adm-td">
|
||||
{stateLabel(row.state)}
|
||||
{removalCountdown(row) && (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 3 }}>
|
||||
{removalCountdown(row)}
|
||||
</span>
|
||||
)}
|
||||
{row.lastError && (
|
||||
<span style={{ display: 'block', color: '#d98b84', fontSize: '0.78rem', marginTop: 3 }}>
|
||||
{row.lastError}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => remove(row)}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.lastPass && config.lastPass.at && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', marginTop: 10 }}>
|
||||
Last pass {new Date(config.lastPass.at).toLocaleString()}
|
||||
{config.lastPass.ran ? '' : ` — nothing was done: ${config.lastPass.reason}`}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
490
client/src/routes/admin/views/TeamsAdmin.jsx
Normal file
490
client/src/routes/admin/views/TeamsAdmin.jsx
Normal file
@@ -0,0 +1,490 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { dateTime } from '../../../lib/format.js'
|
||||
import {
|
||||
freshnessOf, statusOf, gateLabelFor, describeRequest, leadershipOf, GATED_NOTE,
|
||||
} from '../../../lib/teamAdmin.js'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import TeamIntegrations from './TeamIntegrations.jsx'
|
||||
import TeamVoice from './TeamVoice.jsx'
|
||||
|
||||
// Admin → Teams (docs/website/TEAMS.md §2.4, §2.8, §2.9).
|
||||
//
|
||||
// Three panels, in the order an operator needs them:
|
||||
//
|
||||
// 1. **Sync state**, verbatim, including the last error. The screen's first job
|
||||
// is to make "the shard has no Teams" and "core has not been able to ask for
|
||||
// two hours" impossible to confuse — they render almost identically
|
||||
// otherwise, and one is fine while the other is an outage.
|
||||
// 2. **The review queue** — Teams auto-hidden because their name matched the
|
||||
// impersonation list, each showing which term matched.
|
||||
// 3. **The approval queue** — what moderators have asked to publish.
|
||||
//
|
||||
// Everything that decides what a row SAYS lives in lib/teamAdmin.js, which is
|
||||
// plain JS and has tests; this file renders it.
|
||||
|
||||
// Tones map onto the badge modifiers the rest of the admin panel already uses,
|
||||
// rather than onto inline colours. `.badge` on its own carries no border or
|
||||
// background — those live on the modifier — so a bare `className="badge"` with an
|
||||
// inline `borderColor` renders borderless, which is what this screen used to do.
|
||||
const TONE_BADGE = { ok: 'badge-pub', warn: 'badge-moderator', bad: 'badge-ban', idle: 'badge-draft' }
|
||||
|
||||
// The same three tones as text, for the places a badge would be wrong (a verbatim
|
||||
// error line). House palette — the values every other admin view uses.
|
||||
const TONE_TEXT = { ok: '#7fd0a4', warn: '#e0b070', bad: '#d98b84', idle: 'var(--muted)' }
|
||||
|
||||
const PANEL = { padding: 22, marginBottom: 22 }
|
||||
const HEADING = { margin: '0 0 12px', fontSize: '1.2rem', color: 'var(--head)' }
|
||||
const KV_VALUE = { margin: 0, fontSize: '0.88rem', color: 'var(--text)' }
|
||||
const SCROLLER = { overflowX: 'auto' }
|
||||
const BLURB = { margin: '0 0 14px', color: 'var(--muted)', fontSize: '0.85rem', lineHeight: 1.6 }
|
||||
|
||||
function Pill({ tone, children }) {
|
||||
return <span className={`badge ${TONE_BADGE[tone] || 'badge-draft'}`}>{children}</span>
|
||||
}
|
||||
|
||||
// ── Sync state ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SyncPanel({ sync, syncState, onResync, busy }) {
|
||||
const freshness = freshnessOf(sync)
|
||||
return (
|
||||
<section className="panel" style={PANEL}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ ...HEADING, margin: 0 }}>Sync</h2>
|
||||
<Pill tone={freshness.tone}>{freshness.label}</Pill>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sq"
|
||||
onClick={onResync}
|
||||
disabled={busy || !sync.configured}
|
||||
>
|
||||
{busy ? 'Resyncing…' : 'Resync now'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.85rem' }}>{freshness.detail}</p>
|
||||
|
||||
{syncState && (
|
||||
<dl
|
||||
style={{
|
||||
display: 'grid', gridTemplateColumns: 'auto minmax(0, 1fr)', gap: '9px 20px',
|
||||
margin: '16px 0 0', alignItems: 'baseline',
|
||||
}}
|
||||
>
|
||||
<dt className="field-label" style={{ margin: 0 }}>Module</dt>
|
||||
<dd className="sans" style={KV_VALUE}>{syncState.moduleId}</dd>
|
||||
<dt className="field-label" style={{ margin: 0 }}>Last attempt</dt>
|
||||
<dd className="sans" style={KV_VALUE}>{dateTime(syncState.lastAttemptAt) || 'never'}</dd>
|
||||
<dt className="field-label" style={{ margin: 0 }}>Last success</dt>
|
||||
<dd className="sans" style={KV_VALUE}>{dateTime(syncState.lastSuccessAt) || 'never'}</dd>
|
||||
<dt className="field-label" style={{ margin: 0 }}>Consecutive failures</dt>
|
||||
<dd className="sans" style={KV_VALUE}>{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 className="field-label" style={{ margin: 0 }}>Last error</dt>
|
||||
<dd className="sans" style={{ ...KV_VALUE, color: TONE_TEXT.bad }}>{syncState.lastError}</dd>
|
||||
</>
|
||||
)}
|
||||
{syncState.pendingEmptySince && (
|
||||
<>
|
||||
<dt className="field-label" style={{ margin: 0 }}>Empty answer held</dt>
|
||||
<dd className="sans" style={KV_VALUE}>
|
||||
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={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Names to review</h2>
|
||||
<p className="sans" style={BLURB}>
|
||||
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>
|
||||
<div className="panel-flat" style={SCROLLER}>
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Name</th>
|
||||
<th className="adm-th">Matched</th>
|
||||
<th className="adm-th">Members</th>
|
||||
<th className="adm-th">Created</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>{row.name}</td>
|
||||
<td className="adm-td"><Pill tone="bad">{row.hidden_term}</Pill></td>
|
||||
<td className="adm-td">{row.member_count}</td>
|
||||
<td className="adm-td dim">{dateTime(row.created_at)}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() => onAct(row.id, 'unhide')}
|
||||
>
|
||||
{gateLabelFor(role, 'Publish')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The approval queue ─────────────────────────────────────────────────────
|
||||
|
||||
function RequestQueue({ rows, role, onDecide, busy }) {
|
||||
if (!rows.length) return null
|
||||
const canDecide = role === 'admin'
|
||||
return (
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Awaiting approval</h2>
|
||||
<p className="sans" style={BLURB}>
|
||||
{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>
|
||||
<div className="panel-flat" style={SCROLLER}>
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Request</th>
|
||||
<th className="adm-th">Requested</th>
|
||||
<th className="adm-th">Reason</th>
|
||||
{canDecide && <th className="adm-th" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>{describeRequest(row)}</td>
|
||||
<td className="adm-td dim">{dateTime(row.requested_at)}</td>
|
||||
<td className="adm-td dim">{row.reason ? `“${row.reason}”` : '—'}</td>
|
||||
{canDecide && (
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() => onDecide(row.id, 'approved')}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sq"
|
||||
style={{ marginLeft: 8 }}
|
||||
disabled={busy}
|
||||
onClick={() => onDecide(row.id, 'rejected')}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── One Team ───────────────────────────────────────────────────────────────
|
||||
|
||||
function TeamRow({ team, role, onAct, busy, onLedger }) {
|
||||
const status = statusOf(team)
|
||||
return (
|
||||
<tr>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>
|
||||
{team.displayName}
|
||||
{team.displayNameOverride && (
|
||||
<div className="dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
|
||||
shown instead of “{team.name}”
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td"><Pill tone={status.tone}>{status.label}</Pill></td>
|
||||
<td className="adm-td">{team.memberCount}</td>
|
||||
<td className="adm-td">{team.linkedCount}</td>
|
||||
<td className="adm-td">{team.onlineCount}</td>
|
||||
<td className="adm-td dim">{dateTime(team.rosterSyncedAt) || 'never'}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
{team.status === 'active' && (team.hidden
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() => onAct(team.id, 'unhide')}
|
||||
>
|
||||
{gateLabelFor(role, 'Publish')}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() => onAct(team.id, 'hide')}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sq"
|
||||
onClick={() => onLedger(team)}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
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" style={PANEL}>
|
||||
<header
|
||||
style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
gap: 14, flexWrap: 'wrap', marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<h2 className="display" style={{ ...HEADING, margin: 0 }}>Forum log — {team.displayName}</h2>
|
||||
<button type="button" className="btn btn-ghost btn-sq" onClick={onClose}>Close</button>
|
||||
</header>
|
||||
{error && <ErrorState message={error} />}
|
||||
{!rows && !error && <Loading />}
|
||||
{rows && rows.length === 0 && (
|
||||
<p className="sans" style={{ ...BLURB, margin: 0 }}>Nothing has been moderated in this forum.</p>
|
||||
)}
|
||||
{rows && rows.length > 0 && (
|
||||
<div className="panel-flat" style={SCROLLER}>
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">When</th>
|
||||
<th className="adm-th">Action</th>
|
||||
<th className="adm-th">Target</th>
|
||||
<th className="adm-th">By</th>
|
||||
<th className="adm-th">As</th>
|
||||
<th className="adm-th">Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="adm-td dim">{dateTime(r.created_at)}</td>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.action}</td>
|
||||
<td className="adm-td dim">{r.target_type} #{r.target_id}</td>
|
||||
<td className="adm-td">{r.actor_username || '—'}</td>
|
||||
<td className="adm-td">
|
||||
{/* The distinction the whole ledger exists to preserve. */}
|
||||
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>
|
||||
</td>
|
||||
<td className="adm-td dim">{r.reason || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
{/* No page <h1>: AdminLayout's topbar already titles the page, as it does for
|
||||
every other admin screen. This one used to render its own, which is why
|
||||
"Teams" appeared twice — once in Cinzel in the bar and once in the body
|
||||
in whatever the UA picked for an unstyled heading. */}
|
||||
{error && <ErrorState message={error} />}
|
||||
{notice && (
|
||||
<div className="note sans" style={{ fontSize: '0.85rem', marginBottom: 22 }}>{notice}</div>
|
||||
)}
|
||||
|
||||
{ledgerTeam && <ForumLedger team={ledgerTeam} onClose={() => setLedgerTeam(null)} />}
|
||||
|
||||
{/* Admin-only, matching the server (§7.2). Rendered for a moderator it would
|
||||
be a panel every action in fails 403 — the role gate is the server's, and
|
||||
this is only how the screen agrees with it. */}
|
||||
{role === 'admin' && <TeamIntegrations />}
|
||||
{role === 'admin' && <TeamVoice />}
|
||||
|
||||
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
|
||||
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
|
||||
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
|
||||
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>All Teams</h2>
|
||||
{!data.teams.length && (
|
||||
<p className="sans" style={{ ...BLURB, margin: 0 }}>
|
||||
{data.configured
|
||||
? 'No Teams in the projection yet.'
|
||||
: 'No installed module supplies Teams, so there is nothing to show.'}
|
||||
</p>
|
||||
)}
|
||||
{data.teams.length > 0 && (
|
||||
<div className="panel-flat" style={SCROLLER}>
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Name</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th">Members</th>
|
||||
<th className="adm-th">Linked</th>
|
||||
<th className="adm-th">Online</th>
|
||||
<th className="adm-th">Roster confirmed</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.teams.map((team) => (
|
||||
<TeamRow
|
||||
key={team.id}
|
||||
team={team}
|
||||
role={role}
|
||||
onAct={act}
|
||||
busy={busy}
|
||||
onLedger={setLedgerTeam}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { leadershipOf }
|
||||
@@ -4,6 +4,7 @@ import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay.jsx'
|
||||
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
|
||||
import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
@@ -21,7 +22,7 @@ function ChangeUsername({ account, onChanged }) {
|
||||
if (username.trim().length < 3) return setError('Username must be at least 3 characters.')
|
||||
setBusy(true)
|
||||
try {
|
||||
const { username: next } = await api.player.changeUsername(username.trim())
|
||||
const { username: next } = await api.changeUsername(username.trim())
|
||||
setMsg('Username updated.')
|
||||
await onChanged(next)
|
||||
} catch (err) {
|
||||
@@ -67,7 +68,7 @@ function ChangePassword({ account }) {
|
||||
if (hasPassword && !current) return setError('Enter your current password.')
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.player.changePassword(next, hasPassword ? current : undefined)
|
||||
await api.changePassword(next, hasPassword ? current : undefined)
|
||||
setMsg(hasPassword ? 'Password changed.' : 'Password set. You can now sign in with it.')
|
||||
setCurrent('')
|
||||
setNext('')
|
||||
@@ -124,7 +125,7 @@ function TwoFactor({ account, reload }) {
|
||||
async function begin() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
setSetup(await api.player.totpSetup())
|
||||
setSetup(await api.totpSetup())
|
||||
setCode('')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not start setup.')
|
||||
@@ -135,7 +136,7 @@ function TwoFactor({ account, reload }) {
|
||||
async function confirm() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
const res = await api.player.totpEnable(code.trim())
|
||||
const res = await api.totpEnable(code.trim())
|
||||
setSetup(null); setCode(''); setNewCodes(res?.recoveryCodes || null); setMsg('Two-factor is now enabled.')
|
||||
await reload()
|
||||
} catch (err) {
|
||||
@@ -147,7 +148,7 @@ function TwoFactor({ account, reload }) {
|
||||
async function disable() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
await api.player.totpDisable(code.trim())
|
||||
await api.totpDisable(code.trim())
|
||||
setCode(''); setMsg('Two-factor has been disabled.')
|
||||
await reload()
|
||||
} catch (err) {
|
||||
@@ -234,7 +235,7 @@ function LinkedAccounts() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [ids, avail] = await Promise.all([
|
||||
api.player.linkedIdentities(),
|
||||
api.myIdentities(),
|
||||
api.authProviders().catch(() => []),
|
||||
])
|
||||
setLinked(ids)
|
||||
@@ -251,7 +252,7 @@ function LinkedAccounts() {
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
try {
|
||||
await api.player.unlinkIdentity(provider)
|
||||
await api.unlinkIdentity(provider)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
@@ -397,7 +398,7 @@ export default function PlayerAccount() {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setAccount(await api.player.getAccount())
|
||||
setAccount(await api.myAccount())
|
||||
} catch {
|
||||
setError('Could not load your account.')
|
||||
} finally {
|
||||
@@ -423,6 +424,7 @@ export default function PlayerAccount() {
|
||||
{account.email ? ` · ${account.email}` : ''}
|
||||
</p>
|
||||
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
||||
<EmailAddressPanel account={account} reload={load} />
|
||||
<ChangePassword account={account} />
|
||||
<TwoFactor account={account} reload={load} />
|
||||
{account.totp_enabled && (
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
166
client/src/routes/player/VerifyEmail.jsx
Normal file
166
client/src/routes/player/VerifyEmail.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { api } from '../../api/client.js'
|
||||
import PlayerShell from './PlayerShell.jsx'
|
||||
|
||||
// Public, token-gated confirmation page (/account/verify-email/:token).
|
||||
//
|
||||
// Unauthenticated on purpose: the link arrives in a mailbox and is routinely
|
||||
// opened on a device with no session. That is safe because the token IS the
|
||||
// proof — opening it installs an address on the account it was minted for and
|
||||
// does nothing else. No session is issued here, deliberately: proving control of
|
||||
// a mailbox is not proving control of an account.
|
||||
//
|
||||
// Every failure the server can have — expired, already used, superseded by a
|
||||
// later request, or an address another account confirmed first — comes back as
|
||||
// the same 404. That is not laziness on the server's part; distinguishing them
|
||||
// would let anyone test which addresses have accounts. So this page says the same
|
||||
// thing for all of them, and must keep doing so.
|
||||
export default function VerifyEmail() {
|
||||
const { token } = useParams()
|
||||
|
||||
const [link, setLink] = useState(null) // { username, email } once validated
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api
|
||||
.lookupEmailVerification(token)
|
||||
.then((r) => active && setLink(r || {}))
|
||||
.catch(
|
||||
(err) =>
|
||||
active &&
|
||||
setLoadErr(
|
||||
err.status === 404
|
||||
? 'This confirmation link is invalid or has expired.'
|
||||
: 'Could not load this confirmation link.',
|
||||
),
|
||||
)
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [token])
|
||||
|
||||
async function onConfirm() {
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.confirmEmailVerification(token)
|
||||
setDone(true)
|
||||
} catch (err) {
|
||||
if (err.status === 404) setError('This confirmation link is no longer usable. Request a new one from your account page.')
|
||||
else if (err.status === 429) setError('Too many attempts. Please try again in a little while.')
|
||||
else setError('Could not confirm your address right now. Please try again later.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invalid link ───────────────────────────────────────────────────────────
|
||||
if (loadErr) {
|
||||
return (
|
||||
<PlayerShell subtitle="Confirm your email">
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>
|
||||
{loadErr}
|
||||
</p>
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
|
||||
<Link to="/account" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Go to your account
|
||||
</Link>
|
||||
</p>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
if (link === null) {
|
||||
return (
|
||||
<PlayerShell subtitle="Confirm your email">
|
||||
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}>
|
||||
<span className="spin" />
|
||||
</div>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Done ───────────────────────────────────────────────────────────────────
|
||||
if (done) {
|
||||
return (
|
||||
<PlayerShell subtitle="Email confirmed">
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>
|
||||
{link.email ? (
|
||||
<>
|
||||
<strong style={{ color: 'var(--head)' }}>{link.email}</strong> is now the address for
|
||||
{link.username ? (
|
||||
<>
|
||||
{' '}
|
||||
<strong style={{ color: 'var(--head)' }}>{link.username}</strong>
|
||||
</>
|
||||
) : (
|
||||
' your account'
|
||||
)}
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
'Your email address has been confirmed.'
|
||||
)}
|
||||
</p>
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', fontSize: '0.85rem', color: 'var(--dim)' }}>
|
||||
You have not been signed in — confirming an address does not sign you in.
|
||||
</p>
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
|
||||
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Confirm ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A button rather than confirming on load. A mail client or scanner that
|
||||
// pre-fetches links would otherwise spend the token before the person ever saw
|
||||
// it, and this token is single-use.
|
||||
return (
|
||||
<PlayerShell subtitle="Confirm your email">
|
||||
<p
|
||||
className="sans"
|
||||
style={{ marginTop: 0, marginBottom: 20, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}
|
||||
>
|
||||
Confirm that{' '}
|
||||
{link.email ? <strong style={{ color: 'var(--head)' }}>{link.email}</strong> : 'this address'} should be
|
||||
the contact and account-recovery address for
|
||||
{link.username ? (
|
||||
<>
|
||||
{' '}
|
||||
<strong style={{ color: 'var(--head)' }}>{link.username}</strong>
|
||||
</>
|
||||
) : (
|
||||
' this account'
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
className="btn btn-primary"
|
||||
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
|
||||
>
|
||||
{busy ? 'Confirming…' : 'Confirm this address'}
|
||||
</button>
|
||||
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', fontSize: '0.82rem', color: 'var(--dim)' }}>
|
||||
If you did not ask for this, close this page. Nothing changes and no account of yours is affected.
|
||||
</p>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
@@ -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,10 @@ import assert from 'node:assert/strict'
|
||||
import {
|
||||
registry,
|
||||
declareSlot,
|
||||
declareModuleSlot,
|
||||
offerCoreFill,
|
||||
CORE_CONTRIBUTIONS,
|
||||
applyCoreFills,
|
||||
registerExtension,
|
||||
extensionFor,
|
||||
registeredIds,
|
||||
@@ -92,3 +96,114 @@ test('declareSlot and extensionFor are not on the module-facing registry', () =>
|
||||
assert.equal(registry.extensionFor, undefined)
|
||||
assert.equal(typeof registry.registerExtension, 'function')
|
||||
})
|
||||
|
||||
// ── The INVERTED direction: the module declares, core fills ────────────────
|
||||
//
|
||||
// Added in 1.6.0 for Teams (TEAMS.md Part 3). Teams are a core primitive with no
|
||||
// core surface — core owns the tables and the activity feed, the module owns the
|
||||
// page and the word "guild" — so the content flows the other way for the first
|
||||
// time. The rules below are the ones that direction gets wrong.
|
||||
|
||||
const Feed = () => null
|
||||
|
||||
test('a module-declared slot must be namespaced under the declaring module', () => {
|
||||
// Enforced rather than conventional: this is the only thing keeping two
|
||||
// modules from claiming the same slot name.
|
||||
assert.throws(() => declareModuleSlot('uo', 'guild.detail'), /must be namespaced/)
|
||||
assert.doesNotThrow(() => declareModuleSlot('uo', 'uo.guild.detail'))
|
||||
})
|
||||
|
||||
test('core offers a contribution and the module says where it goes', () => {
|
||||
// The ordering that makes this two calls: core's bundle evaluates BEFORE any
|
||||
// module chunk, so at the moment core offers, no module-declared slot exists.
|
||||
offerCoreFill('team.activity', Feed)
|
||||
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
||||
assert.equal(extensionFor('uo.guild.detail'), null, 'not before the fills are applied')
|
||||
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), Feed)
|
||||
})
|
||||
|
||||
test('core names no slot, so a second game gets the same content in its own words', () => {
|
||||
// The defect this replaced: core used to fill three literal `uo.guild.*` names,
|
||||
// which reached exactly one module. Every other game declared a place under its
|
||||
// own id and got an empty page with no error, because a fill nobody declared is
|
||||
// deliberately not an error — the rule that makes an unknown name invisible.
|
||||
offerCoreFill('team.activity', Feed)
|
||||
declareModuleSlot('examplegame', 'examplegame.clan.detail', { core: 'team.activity' })
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('examplegame.clan.detail'), Feed)
|
||||
})
|
||||
|
||||
test('two modules can ask for the same contribution, and both get it', () => {
|
||||
// Core has no reason to care how many places want its feed, and refusing the
|
||||
// second would be core making a layout decision on a page it does not own.
|
||||
offerCoreFill('team.activity', Feed)
|
||||
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
||||
declareModuleSlot('uo', 'uo.guild.summary', { core: 'team.activity' })
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), Feed)
|
||||
assert.equal(extensionFor('uo.guild.summary'), Feed)
|
||||
})
|
||||
|
||||
test('a slot that asks for nothing stays empty', () => {
|
||||
// Optional on purpose: a module may declare a place it fills itself, or one it
|
||||
// is keeping for later. Neither is core's business.
|
||||
offerCoreFill('team.activity', Feed)
|
||||
declareModuleSlot('uo', 'uo.guild.detail')
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), null)
|
||||
})
|
||||
|
||||
test('asking for a contribution core does not offer THROWS', () => {
|
||||
// The asymmetry with an unfilled slot, and it is deliberate. An unknown
|
||||
// contribution is always a typo or a version skew — core's list is fixed at
|
||||
// build time and the module's coreApi range has already been checked — and the
|
||||
// alternative failure is a page that renders empty forever with nothing logged.
|
||||
assert.throws(
|
||||
() => declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activityfeed' }),
|
||||
/does not offer/,
|
||||
)
|
||||
assert.ok(CORE_CONTRIBUTIONS['team.activity'], 'the catalogue is exported so a test can name it')
|
||||
})
|
||||
|
||||
test('a contribution nothing asks for is not an error', () => {
|
||||
// No game module installed. Core offering content for a page that does not
|
||||
// exist is the ordinary case on any deployment, not a misconfiguration.
|
||||
offerCoreFill('team.forum', Feed)
|
||||
assert.doesNotThrow(() => applyCoreFills())
|
||||
})
|
||||
|
||||
test('a module that fills its own slot first keeps it', () => {
|
||||
const Own = () => null
|
||||
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
||||
registerExtension('uo', 'uo.guild.detail', Own)
|
||||
offerCoreFill('team.activity', Feed)
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), Own, 'first fill wins, as everywhere else')
|
||||
})
|
||||
|
||||
test('a module-declared slot cannot be declared twice', () => {
|
||||
declareModuleSlot('uo', 'uo.guild.detail')
|
||||
assert.throws(() => declareModuleSlot('uo', 'uo.guild.detail'), /already declared/)
|
||||
})
|
||||
|
||||
test('applying the fills twice does not re-fill or throw', () => {
|
||||
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
||||
offerCoreFill('team.activity', Feed)
|
||||
applyCoreFills()
|
||||
assert.doesNotThrow(() => applyCoreFills())
|
||||
assert.equal(extensionFor('uo.guild.detail'), Feed)
|
||||
})
|
||||
|
||||
test('a non-component contribution is refused at the call site, not at render', () => {
|
||||
assert.throws(() => offerCoreFill('team.activity', 'nope'), /is not a component/)
|
||||
})
|
||||
|
||||
test('_reset clears pending fills, so one test cannot leak into the next', () => {
|
||||
offerCoreFill('team.activity', Feed)
|
||||
_reset()
|
||||
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), null)
|
||||
})
|
||||
|
||||
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))
|
||||
})
|
||||
129
client/test/teamIntegrations.test.js
Normal file
129
client/test/teamIntegrations.test.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// What Admin → Teams → Notification bridge decides (client/src/lib/teamIntegrations.js).
|
||||
//
|
||||
// The test that earns this file: **repointing a row must not carry its
|
||||
// acknowledgement across.** That is the one way this screen could actively
|
||||
// mislead — an operator confirms a private channel, changes the id to a public
|
||||
// one, and the form still shows the confirmation as standing. The server clears
|
||||
// it either way, so the failure would be a screen that disagrees with the answer
|
||||
// it is about to get, which is worse than one that simply refuses.
|
||||
//
|
||||
// The rest is the boundary of the confirmation dialog: it must open when it
|
||||
// matters and stay shut when it does not, because a dialog that appears on saves
|
||||
// that did not need it is one people learn to click through.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
eventLabel, rowKey, isDefaultRow, blankDraft, draftFrom, appliesToLabel, toggleEvent,
|
||||
setChannel, carriesMembersOnly, needsAcknowledgement, membersOnlyIdsOf, availableTargets,
|
||||
} from '../src/lib/teamIntegrations.js'
|
||||
|
||||
const MEMBERS_ONLY = ['team.forum.post', 'team.announcement']
|
||||
const ROSTER = 'team.member.joined'
|
||||
const FORUM = 'team.forum.post'
|
||||
|
||||
const draft = (over = {}) => ({ ...blankDraft(null), ...over })
|
||||
|
||||
// ── The acknowledgement dies with its channel ──────────────────────────────
|
||||
|
||||
test('changing the channel drops a standing acknowledgement', () => {
|
||||
const before = draft({ channelRef: '111', membersAck: true, events: [FORUM], enabled: true })
|
||||
const after = setChannel(before, '222')
|
||||
assert.equal(after.membersAck, false)
|
||||
assert.equal(after.channelRef, '222')
|
||||
})
|
||||
|
||||
test('setting the SAME channel does not clear it — an unrelated re-render is not a repoint', () => {
|
||||
const before = draft({ channelRef: '111', membersAck: true })
|
||||
const after = setChannel(before, '111')
|
||||
assert.equal(after.membersAck, true)
|
||||
assert.equal(after, before, 'and the object is returned unchanged, so nothing re-renders')
|
||||
})
|
||||
|
||||
test('a repointed row needs the dialog again, which is the whole point of clearing it', () => {
|
||||
const before = draft({ channelRef: '111', membersAck: true, events: [FORUM], enabled: true })
|
||||
assert.equal(needsAcknowledgement(before, MEMBERS_ONLY), false)
|
||||
assert.equal(needsAcknowledgement(setChannel(before, '222'), MEMBERS_ONLY), true)
|
||||
})
|
||||
|
||||
// ── When the dialog opens ──────────────────────────────────────────────────
|
||||
|
||||
test('enabling a forum event without the tick asks first', () => {
|
||||
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: true }), MEMBERS_ONLY), true)
|
||||
})
|
||||
|
||||
test('a DISABLED draft carrying forum events does not ask — nothing is being published yet', () => {
|
||||
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: false }), MEMBERS_ONLY), false)
|
||||
})
|
||||
|
||||
test('a roster-only bridge never asks, however it is configured', () => {
|
||||
assert.equal(needsAcknowledgement(draft({ events: [ROSTER], enabled: true }), MEMBERS_ONLY), false)
|
||||
assert.equal(carriesMembersOnly(draft({ events: [ROSTER] }), MEMBERS_ONLY), false)
|
||||
})
|
||||
|
||||
test('an acknowledgement already given means no second dialog for an unrelated edit', () => {
|
||||
const d = draft({ events: [FORUM], enabled: true, membersAck: true, channelRef: '111' })
|
||||
const withRoster = toggleEvent(d, ROSTER)
|
||||
assert.equal(needsAcknowledgement(withRoster, MEMBERS_ONLY), false)
|
||||
})
|
||||
|
||||
test('the members-only set comes from the server, not from a list held here', () => {
|
||||
// The client must not decide what is members-only: a future stream added
|
||||
// server-side would silently escape a hardcoded client list.
|
||||
assert.deepEqual(
|
||||
membersOnlyIdsOf([{ id: ROSTER, membersOnly: false }, { id: FORUM, membersOnly: true }]),
|
||||
[FORUM],
|
||||
)
|
||||
// Told nothing is members-only, the dialog never opens — the server is the one
|
||||
// that would then refuse, which is the correct division.
|
||||
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: true }), []), false)
|
||||
})
|
||||
|
||||
// ── Events, rows and targets ───────────────────────────────────────────────
|
||||
|
||||
test('toggling adds then removes, and preserves selection order', () => {
|
||||
let d = draft()
|
||||
d = toggleEvent(d, FORUM)
|
||||
d = toggleEvent(d, ROSTER)
|
||||
assert.deepEqual(d.events, [FORUM, ROSTER])
|
||||
d = toggleEvent(d, FORUM)
|
||||
assert.deepEqual(d.events, [ROSTER])
|
||||
})
|
||||
|
||||
test('the default row is identified by a NULL team, and an undefined one counts too', () => {
|
||||
assert.equal(isDefaultRow({ team_id: null }), true)
|
||||
assert.equal(isDefaultRow({}), true)
|
||||
assert.equal(isDefaultRow({ team_id: 4 }), false)
|
||||
assert.equal(rowKey({ team_id: null }), 'default')
|
||||
assert.equal(rowKey({ team_id: 4 }), '4')
|
||||
})
|
||||
|
||||
test('a row is labelled by the staff override first, then the name, then its id', () => {
|
||||
assert.equal(appliesToLabel({ team_id: null }), 'All Teams')
|
||||
assert.equal(appliesToLabel({ team_id: 4, team_name: 'Real', display_name_override: 'Shown' }), 'Shown')
|
||||
assert.equal(appliesToLabel({ team_id: 4, team_name: 'Real' }), 'Real')
|
||||
assert.equal(appliesToLabel({ team_id: 4 }), 'Team #4')
|
||||
})
|
||||
|
||||
test('a Team that already has an override is not offered a second one', () => {
|
||||
const rows = [{ team_id: null }, { team_id: 2 }]
|
||||
const teams = [{ id: 1, status: 'active' }, { id: 2, status: 'active' }, { id: 3, status: 'archived' }]
|
||||
const { hasDefault, teams: available } = availableTargets(rows, teams)
|
||||
assert.equal(hasDefault, true)
|
||||
assert.deepEqual(available.map((t) => t.id), [1], 'the taken one and the archived one are both out')
|
||||
})
|
||||
|
||||
test('with no default configured, the default is still offered', () => {
|
||||
const { hasDefault } = availableTargets([{ team_id: 2 }], [])
|
||||
assert.equal(hasDefault, false)
|
||||
})
|
||||
|
||||
test('a row round-trips through the draft without changing what it means', () => {
|
||||
const row = { team_id: 4, events: [FORUM], channel_ref: '111', enabled: 1, members_ack: 1 }
|
||||
assert.deepEqual(draftFrom(row), { teamId: 4, events: [FORUM], channelRef: '111', enabled: true, membersAck: true })
|
||||
})
|
||||
|
||||
test('an unknown event id renders as itself rather than as blank', () => {
|
||||
assert.equal(eventLabel(FORUM), 'New forum post')
|
||||
assert.equal(eventLabel('team.something.new'), 'team.something.new')
|
||||
})
|
||||
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$/)
|
||||
})
|
||||
152
client/test/teamVoice.test.js
Normal file
152
client/test/teamVoice.test.js
Normal file
@@ -0,0 +1,152 @@
|
||||
// Admin → Teams → Voice channels, the decisions (TEAMS.md §7.3, phase 9).
|
||||
//
|
||||
// These mirror server rules and do not replace them: the server refuses to enable
|
||||
// voice while the bot cannot act, and the reconciler applies the threshold and the
|
||||
// grace window, whether or not this file ever ran. What is asserted here is that
|
||||
// the SCREEN agrees with those answers instead of offering a control that will
|
||||
// fail, or describing a state the deployment is not in.
|
||||
//
|
||||
// The one that matters most is `statusSummary`'s "off" branch. Switching voice off
|
||||
// suspends the reconciler in both directions and deliberately leaves existing
|
||||
// channels standing — a checkbox must not delete structure in somebody's guild —
|
||||
// and an operator who reads "off" as "nothing is provisioned" would never go
|
||||
// looking for the channels that are still there.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
stateLabel, enableBlockedReason, roleHeadroom, removalCountdown,
|
||||
parseStaffRoles, formatStaffRoles, statusSummary,
|
||||
} from '../src/lib/teamVoice.js'
|
||||
|
||||
test('every state the server can report has wording', () => {
|
||||
for (const state of ['none', 'active', 'pending_removal', 'error']) {
|
||||
assert.notEqual(stateLabel(state), state)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unknown state falls back to itself rather than rendering blank', () => {
|
||||
assert.equal(stateLabel('something-new'), 'something-new')
|
||||
})
|
||||
|
||||
// ── The enable gate ────────────────────────────────────────────────────────
|
||||
|
||||
test('a ready bot blocks nothing', () => {
|
||||
assert.equal(enableBlockedReason({ ready: true, connected: true, missingPermissions: [] }), null)
|
||||
})
|
||||
|
||||
test('a disconnected bot and a bot missing a permission read differently', () => {
|
||||
const disconnected = enableBlockedReason({ ready: false, connected: false, reason: 'the bot is not connected to Discord' })
|
||||
const missing = enableBlockedReason({ ready: false, connected: true, missingPermissions: ['Manage Roles'] })
|
||||
assert.match(disconnected, /not connected/)
|
||||
assert.match(missing, /Manage Roles/)
|
||||
// An operator fixes these in two completely different places, so collapsing
|
||||
// them into one message would send half of them to the wrong one.
|
||||
assert.notEqual(disconnected, missing)
|
||||
})
|
||||
|
||||
test('an absent preflight blocks rather than silently allowing', () => {
|
||||
assert.ok(enableBlockedReason(null))
|
||||
assert.ok(enableBlockedReason(undefined))
|
||||
})
|
||||
|
||||
// ── The role ceiling ───────────────────────────────────────────────────────
|
||||
|
||||
test('headroom is counted against the guild-wide cap', () => {
|
||||
const h = roleHeadroom({ roleCount: 200, roleCap: 250 })
|
||||
assert.equal(h.free, 50)
|
||||
assert.equal(h.tight, false)
|
||||
assert.equal(h.exhausted, false)
|
||||
})
|
||||
|
||||
test('a nearly full guild is flagged before the create fails, not after', () => {
|
||||
// The whole reason this is in the panel: access is a per-Team role, so the cap
|
||||
// limits how many TEAMS can have voice, and an operator with sixty guilds needs
|
||||
// to know that before the sixtieth silently errors.
|
||||
const h = roleHeadroom({ roleCount: 240, roleCap: 250 })
|
||||
assert.equal(h.tight, true)
|
||||
assert.equal(h.exhausted, false)
|
||||
})
|
||||
|
||||
test('a full guild is exhausted, and never reports negative headroom', () => {
|
||||
const h = roleHeadroom({ roleCount: 260, roleCap: 250 })
|
||||
assert.equal(h.free, 0)
|
||||
assert.equal(h.exhausted, true)
|
||||
})
|
||||
|
||||
test('no preflight means no claim about headroom', () => {
|
||||
assert.equal(roleHeadroom(null), null)
|
||||
assert.equal(roleHeadroom({}), null)
|
||||
})
|
||||
|
||||
// ── The grace window ───────────────────────────────────────────────────────
|
||||
|
||||
test('a row that is not scheduled has no countdown', () => {
|
||||
assert.equal(removalCountdown({ state: 'active', removeAfter: null }), null)
|
||||
})
|
||||
|
||||
test('a running window reads in days', () => {
|
||||
const now = new Date('2026-08-19T00:00:00Z')
|
||||
const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-24T00:00:00Z' }, now)
|
||||
assert.equal(text, 'in 5 days')
|
||||
})
|
||||
|
||||
test('under a day reads in hours rather than rounding to zero days', () => {
|
||||
const now = new Date('2026-08-19T00:00:00Z')
|
||||
const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-19T06:00:00Z' }, now)
|
||||
assert.equal(text, 'in 6 hours')
|
||||
})
|
||||
|
||||
test('an expired window says the next pass will act, not "in 0 days"', () => {
|
||||
const now = new Date('2026-08-19T00:00:00Z')
|
||||
const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-18T00:00:00Z' }, now)
|
||||
assert.match(text, /next pass/)
|
||||
})
|
||||
|
||||
// ── Staff roles ────────────────────────────────────────────────────────────
|
||||
|
||||
test('staff roles parse from the comma-separated ids a person actually pastes', () => {
|
||||
const { roles, invalid } = parseStaffRoles(' 123456789012345678 , 987654321098765432 ')
|
||||
assert.deepEqual(roles, ['123456789012345678', '987654321098765432'])
|
||||
assert.deepEqual(invalid, [])
|
||||
})
|
||||
|
||||
test('a typo is REPORTED, never quietly dropped', () => {
|
||||
const { invalid } = parseStaffRoles('123456789012345678, @Moderators')
|
||||
assert.deepEqual(invalid, ['@Moderators'])
|
||||
})
|
||||
|
||||
test('an empty field is a legitimate answer and not an error', () => {
|
||||
const { roles, invalid } = parseStaffRoles('')
|
||||
assert.deepEqual(roles, [])
|
||||
assert.deepEqual(invalid, [])
|
||||
})
|
||||
|
||||
test('roles round-trip through the field', () => {
|
||||
const { roles } = parseStaffRoles(formatStaffRoles(['111111111111111111', '222222222222222222']))
|
||||
assert.deepEqual(roles, ['111111111111111111', '222222222222222222'])
|
||||
})
|
||||
|
||||
// ── The status line ────────────────────────────────────────────────────────
|
||||
|
||||
test('off with channels still standing says so — the surprising case', () => {
|
||||
const text = statusSummary({ enabled: false }, [{ channelRef: '900' }, { channelRef: '901' }])
|
||||
assert.match(text, /^Off\./)
|
||||
assert.match(text, /2 channels remain/)
|
||||
})
|
||||
|
||||
test('off with nothing provisioned does not invent a warning', () => {
|
||||
const text = statusSummary({ enabled: false }, [])
|
||||
assert.match(text, /No channels are provisioned/)
|
||||
})
|
||||
|
||||
test('on states the threshold in the words the setting uses', () => {
|
||||
const text = statusSummary({ enabled: true, minMembers: 5 }, [{ channelRef: '900' }])
|
||||
assert.match(text, /at least 5 members/)
|
||||
assert.match(text, /1 provisioned/)
|
||||
})
|
||||
|
||||
test('a threshold of one is not pluralised', () => {
|
||||
assert.match(statusSummary({ enabled: true, minMembers: 1 }, []), /at least 1 member get/)
|
||||
})
|
||||
@@ -14,7 +14,8 @@
|
||||
"seed": "npm run seed --prefix server",
|
||||
"build": "npm run build --prefix client",
|
||||
"start": "npm start --prefix server",
|
||||
"check:modules": "node scripts/checkModuleIdentifiers.js"
|
||||
"check:modules": "node scripts/checkModuleIdentifiers.js",
|
||||
"check:hosts": "node scripts/checkNoExternalHosts.js"
|
||||
},
|
||||
"keywords": ["express", "mariadb", "react", "vite", "jwt"],
|
||||
"author": "whitlocktech",
|
||||
|
||||
185
scripts/checkNoExternalHosts.js
Normal file
185
scripts/checkNoExternalHosts.js
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §3.2 rule 4 — no phone-home in the engagement subsystem ────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §3.2 records a posture the codebase already has and this check
|
||||
// exists to keep: **no transport may ship a default host, endpoint, API base or
|
||||
// sender.** A transport with no operator configuration is `unconfigured` and its
|
||||
// channel is off — it never quietly falls back to a destination we chose.
|
||||
//
|
||||
// The rule is easy to hold and easy to break by accident, and the removed Gmail
|
||||
// transport is the proof of both: `smtp.gmail.com` and port 465 were literals in
|
||||
// `mailer.buildTransport()`, which made "which provider" a code edit and made the
|
||||
// deployment's mail depend on a host nobody configured. Deleting that literal is
|
||||
// what this check was written against, and it is the first thing it would have
|
||||
// caught.
|
||||
//
|
||||
// **It reads code, not prose.** A comment naming `smtp.gmail.com` as the
|
||||
// migration path for existing operators is exactly the documentation this phase
|
||||
// owes, and a check that forbade it would teach people to phrase around it. So
|
||||
// comments and the insides of ordinary strings are masked out; what is checked is
|
||||
// a HOSTNAME OR URL appearing as a string literal in the engagement trees. Same
|
||||
// design, and the same reasoning, as `checkModuleIdentifiers.js` — including
|
||||
// having its own test suite, because a check that silently stops checking is
|
||||
// worse than no check.
|
||||
//
|
||||
// Scope is the engagement subsystem plus the mail path it owns, not the whole
|
||||
// server: core legitimately talks to hosts an operator configured elsewhere
|
||||
// (ntfy, Discord, the sidecar), and those are not this rule's business.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
|
||||
// The trees the rule covers. `server/src/engagement/` is where transports and,
|
||||
// later, the rules engine live; `utils/mailer.js` is the one file outside it that
|
||||
// composes and sends mail.
|
||||
const TREES = [path.join(ROOT, 'server', 'src', 'engagement')]
|
||||
const FILES = [path.join(ROOT, 'server', 'src', 'utils', 'mailer.js')]
|
||||
|
||||
const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', '.git'])
|
||||
const CODE = new Set(['.js', '.jsx', '.mjs', '.cjs'])
|
||||
|
||||
// A URL, or a bare dotted hostname with a real TLD. The TLD length floor is what
|
||||
// keeps `emailConfig.model` and `foo.js` out of it — a two-plus-letter final
|
||||
// label after at least one dot, with no path characters, is a host.
|
||||
const URL_LITERAL = /\b(?:https?|smtps?):\/\/[^\s'"`]+/
|
||||
const HOSTNAME_LITERAL = /\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:com|net|org|io|dev|co|email|mail|cloud|app|us|eu)\b/i
|
||||
|
||||
// Hosts that are not destinations: the loopback family, and the RFC 2606 names
|
||||
// reserved for documentation. A placeholder in an admin form's help text is the
|
||||
// opposite of a phone-home — it shows the operator the SHAPE of a value they
|
||||
// must supply, and blanking it would make the form worse to hold the rule.
|
||||
const ALLOWED = [
|
||||
/^(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)$/i,
|
||||
/(?:^|\.)example\.(?:com|net|org)$/i,
|
||||
/(?:^|\.)(?:invalid|test|localhost)$/i,
|
||||
]
|
||||
|
||||
const isAllowed = (host) => ALLOWED.some((re) => re.test(host))
|
||||
|
||||
const hostOf = (literal) => {
|
||||
const withoutScheme = literal.replace(/^[a-z]+:\/\//i, '')
|
||||
return withoutScheme.split(/[/?#:]/)[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank comments and mask string bodies in one left-to-right pass, keeping every
|
||||
* offset aligned so reported line numbers stay honest.
|
||||
*
|
||||
* Lifted from `checkModuleIdentifiers.maskCode` deliberately rather than
|
||||
* imported: that file's masking is tuned to ITS four checks (it keeps quotes so a
|
||||
* route-path check can re-read the original at the same offsets), and coupling
|
||||
* two checks through a shared helper means a change made for one silently
|
||||
* re-scopes the other. Both are ~40 lines and both are tested.
|
||||
*/
|
||||
function maskComments(src) {
|
||||
const out = Array.from(src)
|
||||
const blank = (from, to) => {
|
||||
for (let i = from; i < to && i < out.length; i++) if (out[i] !== '\n') out[i] = ' '
|
||||
}
|
||||
let i = 0
|
||||
while (i < src.length) {
|
||||
const c = src[i]
|
||||
const next = src[i + 1]
|
||||
if (c === '/' && next === '/') {
|
||||
let j = i
|
||||
while (j < src.length && src[j] !== '\n') j++
|
||||
blank(i, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if (c === '/' && next === '*') {
|
||||
const end = src.indexOf('*/', i + 2)
|
||||
const j = end === -1 ? src.length : end + 2
|
||||
blank(i, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if (c === '"' || c === "'" || c === '`') {
|
||||
let j = i + 1
|
||||
while (j < src.length) {
|
||||
if (src[j] === '\\') { j += 2; continue }
|
||||
if (src[j] === c) break
|
||||
j++
|
||||
}
|
||||
// Keep the string body: it is what this check reads. Only the delimiters
|
||||
// matter for finding it, and comments are what has to go.
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
return out.join('')
|
||||
}
|
||||
|
||||
// Every string literal in the (comment-free) source, with its line number.
|
||||
const STRING = /(['"`])((?:\\.|(?!\1)[^\\])*)\1/g
|
||||
|
||||
function lineOf(src, index) {
|
||||
return src.slice(0, index).split('\n').length
|
||||
}
|
||||
|
||||
/** Check one file's contents. Returns [{ file, line, literal, host }]. */
|
||||
function checkFile(rel, src) {
|
||||
const hits = []
|
||||
const code = maskComments(src)
|
||||
for (const m of code.matchAll(STRING)) {
|
||||
const value = m[2]
|
||||
if (!value) continue
|
||||
const urlMatch = value.match(URL_LITERAL)
|
||||
const hostMatch = urlMatch ? null : value.match(HOSTNAME_LITERAL)
|
||||
const literal = urlMatch ? urlMatch[0] : hostMatch ? hostMatch[0] : null
|
||||
if (!literal) continue
|
||||
const host = hostOf(literal)
|
||||
if (isAllowed(host)) continue
|
||||
hits.push({ file: rel, line: lineOf(src, m.index), literal, host })
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
function walk(dir, out = []) {
|
||||
if (!fs.existsSync(dir)) return out
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) walk(full, out)
|
||||
else out.push(full)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function run() {
|
||||
const files = [...TREES.flatMap((t) => walk(t)), ...FILES.filter((f) => fs.existsSync(f))]
|
||||
const hits = []
|
||||
for (const file of files) {
|
||||
if (!CODE.has(path.extname(file))) continue
|
||||
const rel = path.relative(ROOT, file).split(path.sep).join('/')
|
||||
hits.push(...checkFile(rel, fs.readFileSync(file, 'utf8')))
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
module.exports = { run, checkFile, maskComments, isAllowed, hostOf }
|
||||
|
||||
if (require.main === module) {
|
||||
const hits = run()
|
||||
if (hits.length === 0) {
|
||||
console.log('OK — the engagement subsystem names no external host (ENGAGEMENT.md §3.2 rule 4).')
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(
|
||||
`\nThe engagement subsystem names ${hits.length} external host${hits.length === 1 ? '' : 's'} ` +
|
||||
'in code (ENGAGEMENT.md §3.2 rule 4). A destination belongs in operator-supplied ' +
|
||||
'configuration, never in a literal:\n',
|
||||
)
|
||||
for (const h of hits) {
|
||||
console.error(` ${h.file}:${h.line} "${h.literal}"`)
|
||||
}
|
||||
console.error(
|
||||
'\nIf this is help text or documentation rather than a destination, put it in a comment or ' +
|
||||
'use an example.com placeholder — the check masks comments and allows the reserved ' +
|
||||
'documentation names on purpose.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -80,10 +80,12 @@ TOTP_CHALLENGE_TTL=5m
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-admin-password
|
||||
|
||||
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not here.
|
||||
# It reuses the Google auth provider's OAuth client and stores an encrypted
|
||||
# refresh token in the DB. The contact recipient is the `contact_email` site
|
||||
# Email is configured in Admin → Settings → Email, not here: pick a mail
|
||||
# transport (SMTP) and enter its host, port and credentials, stored encrypted in
|
||||
# the DB. A relay is the recommended posture; smtp.gmail.com:587 with an app
|
||||
# password is the simplest. The contact recipient is the `contact_email` site
|
||||
# setting; while email is unconfigured the contact form falls back to a mailto: link.
|
||||
# Upgrading from the removed Gmail connect flow: see docs/website/UPGRADE_NOTES.md.
|
||||
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
|
||||
|
||||
@@ -21,11 +21,30 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
-- (validatePassword returns false).
|
||||
password_hash VARCHAR(72) NULL,
|
||||
role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin',
|
||||
-- Optional contact email (players). Not unique — SSO emails may repeat. Used
|
||||
-- only for display + a future self-serve reset. email_verified is wired now so
|
||||
-- an eventual SMTP verification flow needs no schema change.
|
||||
-- The account's ONE contact address, and the destination for password-reset
|
||||
-- mail. Unique since engagement Phase 1b — but the index is on email_norm
|
||||
-- below, never on this column, and the reason is not stylistic:
|
||||
--
|
||||
-- Every case-insensitive (_ci) collation this server offers is ALSO
|
||||
-- accent-insensitive, so a UNIQUE index on `email` would refuse
|
||||
-- jose@x.com once josé@x.com exists. Those are two different mailboxes.
|
||||
--
|
||||
-- LOWER() under a _bin collation folds case WITHOUT folding accents, which is
|
||||
-- exactly the equivalence a mail system uses. Keeping the fold in a generated
|
||||
-- column rather than in application code means it cannot be bypassed by a
|
||||
-- caller that forgets to normalize.
|
||||
email VARCHAR(255) NULL,
|
||||
-- The uniqueness key. STORED (not VIRTUAL) because a UNIQUE index over it must
|
||||
-- be materialized. Multiple NULLs are legal under a UNIQUE index, which is what
|
||||
-- lets the Phase 1b de-duplication null the losers without deleting an account.
|
||||
email_norm VARCHAR(255) COLLATE utf8mb4_bin AS (LOWER(email)) STORED,
|
||||
email_verified TINYINT(1) NOT NULL DEFAULT 0,
|
||||
-- An address the user has asked for but not yet proved. It does NOT displace
|
||||
-- `email` until the verification link is used, so a typo cannot silently
|
||||
-- redirect this account's password-reset mail. Deliberately NOT unique: a
|
||||
-- pending address reserves nothing, and two users may both be pending on one
|
||||
-- address — the second to verify loses, with the same generic failure.
|
||||
email_pending VARCHAR(255) NULL,
|
||||
-- Account lifecycle, independent of role: staff can disable/ban a player
|
||||
-- without changing their role. active = normal; disabled = admin-locked;
|
||||
-- banned = moderation ban; pending = reserved for future email-verify gating.
|
||||
@@ -38,7 +57,12 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
tokens_valid_after DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at DATETIME NULL,
|
||||
last_login_ip VARCHAR(45) NULL -- IPv6-capable, set on each login
|
||||
last_login_ip VARCHAR(45) NULL, -- IPv6-capable, set on each login
|
||||
-- One account per mailbox (engagement Phase 1b). On the generated column, not
|
||||
-- on `email` — see the note there. Upgraded databases get this in the migration
|
||||
-- block at the foot of this file, AFTER the de-duplication that makes it
|
||||
-- addable; adding it here too is what gives a FRESH install the same shape.
|
||||
UNIQUE KEY uq_users_email_norm (email_norm)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
@@ -327,19 +351,33 @@ CREATE TABLE IF NOT EXISTS bot_config (
|
||||
CONSTRAINT chk_bot_config_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Outbound email configuration (Gmail over OAuth2 / SMTP XOAUTH2). Singleton row
|
||||
-- (id = 1), mirroring bot_config: the DB only ever holds the AES-256-GCM-encrypted
|
||||
-- refresh token, never plaintext, and the client id/secret are NOT stored here —
|
||||
-- they are read live from the `google` auth_providers row. The refresh token is
|
||||
-- captured by the in-app "Connect Gmail" consent flow and is write-only over the
|
||||
-- admin API (never returned; responses expose only hasRefreshToken).
|
||||
-- Outbound email configuration. Singleton row (id = 1), mirroring bot_config: the
|
||||
-- DB only ever holds the AES-256-GCM-encrypted credential, never plaintext, and it
|
||||
-- is write-only over the admin API (never returned; responses expose only
|
||||
-- hasCredential and the non-secret fields the transport declares).
|
||||
--
|
||||
-- `transport` names a registered mail transport (server/src/engagement/transports).
|
||||
-- `credential_enc` is that transport's whole credential set as one encrypted JSON
|
||||
-- blob rather than a column per field, because the field list is the transport's to
|
||||
-- declare — SMTP wants host/port/secure/user/password, an API relay wants a domain
|
||||
-- and a key, and a column per union member would make adding a transport a schema
|
||||
-- change. ENGAGEMENT.md §3.1.
|
||||
--
|
||||
-- `provider` and `refresh_token_enc` are DEPRECATED and no longer read: they held
|
||||
-- the removed Gmail OAuth2 connection (ENGAGEMENT.md §1.2a). They are kept rather
|
||||
-- than dropped under the additive-only discipline, and `refresh_token_enc` earns
|
||||
-- its keep in the meantime as the marker for "this deployment had working mail
|
||||
-- before the upgrade" — which is what the admin dashboard warning reads.
|
||||
CREATE TABLE IF NOT EXISTS email_config (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2',
|
||||
provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2', -- DEPRECATED, unread
|
||||
transport VARCHAR(32) NOT NULL DEFAULT 'smtp',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
sender_email VARCHAR(255) NULL, -- connected Gmail address (from userinfo)
|
||||
sender_email VARCHAR(255) NULL, -- envelope From, operator-typed
|
||||
sender_name VARCHAR(120) NULL, -- optional From display name
|
||||
refresh_token_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed
|
||||
reply_to VARCHAR(255) NULL, -- optional Reply-To for sent mail
|
||||
credential_enc TEXT NULL, -- AES-256-GCM ciphertext (JSON), never exposed
|
||||
refresh_token_enc TEXT NULL, -- DEPRECATED, unread; see above
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'unconfigured',
|
||||
status_detail VARCHAR(500) NULL,
|
||||
last_verified_at DATETIME NULL,
|
||||
@@ -401,6 +439,54 @@ CREATE TABLE IF NOT EXISTS password_resets (
|
||||
INDEX idx_password_resets_status (status, expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Self-service email verification (engagement Phase 1b). The same shape as
|
||||
-- password_resets, deliberately: an opaque random token whose sha256 is all that
|
||||
-- is stored, single-use, short-lived. The design of record calls this link
|
||||
-- "signed"; every comparable flow in this codebase (user_invites,
|
||||
-- password_resets, mobile_refresh_tokens) uses a hashed random token instead, and
|
||||
-- matching them beats introducing a second token mechanism for one caller.
|
||||
--
|
||||
-- The address lives on the ROW, not just on the user: a token proves control of
|
||||
-- the address it was mailed to, so if the user changes their mind and requests a
|
||||
-- different address, the older token must not be able to confirm the newer one.
|
||||
CREATE TABLE IF NOT EXISTS email_verifications (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token
|
||||
user_id INT NOT NULL,
|
||||
email VARCHAR(255) NOT NULL, -- the address THIS token proves
|
||||
status ENUM('pending','used') NOT NULL DEFAULT 'pending',
|
||||
requested_ip VARCHAR(64) NULL, -- who asked (audit only)
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
used_at DATETIME NULL,
|
||||
CONSTRAINT fk_email_verifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_email_verifications_user (user_id),
|
||||
INDEX idx_email_verifications_status (status, expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Who lost an address to the Phase 1b de-duplication, and what they lost.
|
||||
--
|
||||
-- These accounts are exactly the ones an operator must contact: they can no
|
||||
-- longer receive password-reset or engagement mail until they set a new address.
|
||||
-- Written by the migration below in pure SQL (ensureSchema() reads this file
|
||||
-- statement-by-statement and there is no JS migration hook), surfaced as a
|
||||
-- dashboard warning until acknowledged.
|
||||
--
|
||||
-- No foreign key to users, on purpose: the same reasoning as posts.announce_job_id
|
||||
-- — a constraint re-added on every boot is a constraint that can fail a boot, and
|
||||
-- this table is a historical record rather than a live relation.
|
||||
CREATE TABLE IF NOT EXISTS email_dedupe_report (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
username VARCHAR(32) NOT NULL, -- captured at clear time
|
||||
lost_address VARCHAR(255) NOT NULL,
|
||||
cleared_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
acknowledged_at DATETIME NULL, -- set when an admin dismisses the warning
|
||||
-- Makes the migration's INSERT strictly idempotent: an account cleared once is
|
||||
-- never reported twice, however many times ensureSchema() runs.
|
||||
UNIQUE KEY uq_edr_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Push notifications (opt-in) ─────────────────────────────────────────────
|
||||
-- One row per registered push endpoint (Android/UnifiedPush v1; FCM later). The
|
||||
-- `endpoint` is the UnifiedPush distributor URL the app's ntfy topic was handed —
|
||||
@@ -845,6 +931,561 @@ 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;
|
||||
|
||||
-- ── The integration bridge's configuration (TEAMS.md §7.2, phase 8) ─────────
|
||||
--
|
||||
-- The SAME events as §6, delivered to a second consumer. Not a second pipeline:
|
||||
-- `utils/teamNotify.js` computes the recipient set once and hands the event to
|
||||
-- push, to email and now to this bridge.
|
||||
--
|
||||
-- `team_id NULL` is the deployment-wide default and a per-Team row overrides it,
|
||||
-- which is what §7.2 asks for — but its `PRIMARY KEY (platform, team_id)` cannot
|
||||
-- express it: MariaDB coerces every PRIMARY KEY column to NOT NULL, so the
|
||||
-- default row is unrepresentable and the whole override mechanism has no base
|
||||
-- case. Hence the surrogate key plus a generated `team_key`, the same trick
|
||||
-- `teams.active_key` and `content_reports.open_marker` use: IFNULL folds the
|
||||
-- default row onto 0, which no `teams.id` can be, so one default and one row per
|
||||
-- Team coexist under a single UNIQUE key. It also buys the foreign key the
|
||||
-- original DDL had no room for — without it, deleting a Team leaves its bridge
|
||||
-- config behind to be inherited by the next Team that lands on the id.
|
||||
--
|
||||
-- **`members_ack` is a precondition, not a preference.** Forum posts and
|
||||
-- announcements are members-only ALWAYS — there is no public forum thread, and
|
||||
-- §7.2's gate ("visibility is public, or the channel is configured for a
|
||||
-- members-only context") has no data source on either side: the streams carry no
|
||||
-- visibility and core cannot see a Discord channel's permissions. Only the
|
||||
-- operator can. So enabling a members-only event requires an explicit, attributed
|
||||
-- acknowledgement that the destination is restricted to that Team, recorded the
|
||||
-- way `teams_forum_uploads_ack` records the image-policy one. Changing the channel
|
||||
-- CLEARS it (see the model): an acknowledgement is about a destination, and it
|
||||
-- cannot survive the destination changing underneath it.
|
||||
CREATE TABLE IF NOT EXISTS team_integration_config (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
platform VARCHAR(32) NOT NULL, -- 'discord'; opaque here, phase 10 makes it a registry key
|
||||
team_id INT NULL, -- NULL = the deployment-wide default
|
||||
events JSON NOT NULL, -- ['team.announcement','team.forum.post']
|
||||
channel_ref VARCHAR(64) NULL, -- destination on that platform, opaque to core
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
-- The §7.2 gate, as an operator assertion with a name against it.
|
||||
members_ack TINYINT(1) NOT NULL DEFAULT 0,
|
||||
members_ack_by INT NULL,
|
||||
members_ack_at DATETIME NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
team_key INT AS (IFNULL(team_id, 0)) STORED,
|
||||
UNIQUE KEY uq_tic_platform_team (platform, team_key),
|
||||
CONSTRAINT fk_tic_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
-- SET NULL rather than CASCADE, for the same reason every other snapshot in
|
||||
-- this file is: deleting the admin's account must not silently un-acknowledge a
|
||||
-- policy and start withholding messages the deployment is configured to send.
|
||||
CONSTRAINT fk_tic_ack_by FOREIGN KEY (members_ack_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Per-Team external resources: the voice channel (TEAMS.md §7.3, phase 9) ─
|
||||
--
|
||||
-- One row per (Team, platform, resource). Today the only resource is 'voice',
|
||||
-- and the column exists because the NEXT one — a text channel, a Matrix room —
|
||||
-- is the same lifecycle with a different noun, and phase 10's capability
|
||||
-- registry needs somewhere to say which resources a platform declares.
|
||||
--
|
||||
-- **Access is a per-Team ROLE, not per-member overwrites.** §7.3 designed
|
||||
-- overwrites-by-default with escalation to a role above ~90 members; the org lead
|
||||
-- settled on roles always (2026-08-18). That deletes `voice_overwrite_max` and the
|
||||
-- mode transition, and it moves the ceiling: the binding limit is no longer ~100
|
||||
-- overwrites on one channel but Discord's guild-wide cap of 250 roles, which the
|
||||
-- admin panel surfaces rather than letting a create fail into `state='error'`.
|
||||
-- `role_ref` is therefore NOT the escalation artefact it was in §7.3 — it is the
|
||||
-- grant itself, and a row with a channel and no role is a broken row.
|
||||
--
|
||||
-- **Two external refs, two lifetimes, and the pair is why this is a table rather
|
||||
-- than two columns on `teams`.** A channel can be deleted in Discord while the
|
||||
-- role survives, and vice versa; the reconciler has to be able to say "the role is
|
||||
-- there, the channel is not" and repair one without touching the other.
|
||||
--
|
||||
-- `state` is core's belief about Discord, never Discord's own answer: the
|
||||
-- reconciler writes what it just did, and the next pass re-derives the truth. A
|
||||
-- Team dropping below the threshold goes to 'pending_removal' with `remove_after`
|
||||
-- set rather than being deleted at once (§7.3's grace window) — a Team hovering
|
||||
-- around the threshold would otherwise delete-and-recreate, changing the channel
|
||||
-- id and breaking every pinned link to it, and a voice channel holds no message
|
||||
-- history, so the window costs nothing to keep.
|
||||
CREATE TABLE IF NOT EXISTS team_integrations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
platform VARCHAR(32) NOT NULL, -- 'discord'; opaque here, a registry key in phase 10
|
||||
resource VARCHAR(32) NOT NULL, -- 'voice'
|
||||
external_ref VARCHAR(64) NULL, -- the channel id
|
||||
role_ref VARCHAR(64) NULL, -- the Team's own role; the grant itself, not an escalation
|
||||
state ENUM('none','active','pending_removal','error') NOT NULL DEFAULT 'none',
|
||||
remove_after DATETIME NULL, -- set with 'pending_removal'; the grace window's expiry
|
||||
last_error VARCHAR(500) NULL,
|
||||
synced_at DATETIME NULL, -- last pass that reached Discord and was believed
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_team_integration (team_id, platform, resource),
|
||||
-- Expiry is swept across every Team, so the index is on the pair the sweep
|
||||
-- filters by rather than on the Team the unique key already covers.
|
||||
INDEX idx_ti_pending (state, remove_after),
|
||||
CONSTRAINT fk_ti_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
-- 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 +1516,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;
|
||||
@@ -903,3 +1550,95 @@ ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME
|
||||
-- trust token. A boolean only — the token is returned over that app→server call
|
||||
-- and never persisted here (only its sha256 lands in trusted_devices).
|
||||
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;
|
||||
|
||||
-- ── Engagement Phase 1: Gmail OAuth2 removed, SMTP is the baseline ──────────
|
||||
-- (ENGAGEMENT.md §1.2a). Additive on an upgraded database: `transport` backfills
|
||||
-- to 'smtp' for every existing row, and `credential_enc` starts NULL — so an
|
||||
-- upgraded deployment is deliberately CREDENTIAL-LESS until its operator supplies
|
||||
-- SMTP settings. That is the whole point of the G22 warning below: nothing about
|
||||
-- this fails loudly, so something has to say it out loud.
|
||||
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS transport VARCHAR(32) NOT NULL DEFAULT 'smtp';
|
||||
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS credential_enc TEXT NULL;
|
||||
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS reply_to VARCHAR(255) NULL;
|
||||
|
||||
-- ── Engagement Phase 1b: one account per mailbox ───────────────────────────
|
||||
-- (ENGAGEMENT.md Phase 1b / §0.6.) ORDER IS LOAD-BEARING and every statement here
|
||||
-- is idempotent — after the first successful boot each one matches zero rows.
|
||||
--
|
||||
-- Why the generated column is added BEFORE the de-duplication rather than after:
|
||||
-- the de-dupe must group addresses exactly the way the index will, and it cannot
|
||||
-- do that with LOWER(email) = LOWER(email) in SQL, because that comparison uses
|
||||
-- the COLUMN's collation, which is accent-insensitive. Grouping on email_norm —
|
||||
-- the very column the UNIQUE index goes on — makes the two agree by construction
|
||||
-- instead of by a hand-matched COLLATE clause someone can get wrong later.
|
||||
-- (Tested: with the LOWER()=LOWER() form, jose@x.com was nulled as a "duplicate"
|
||||
-- of josé@x.com. They are different mailboxes.)
|
||||
|
||||
-- 1. An empty string is a value, not an absence, so two accounts holding '' would
|
||||
-- collide under the index and stop the boot. Unreachable through the current
|
||||
-- routes (isEmail() rejects ''), but this runs against databases whose history
|
||||
-- we do not control.
|
||||
UPDATE users SET email = NULL WHERE email = '';
|
||||
|
||||
-- 2. The pending-address column and the uniqueness key. No index yet — a UNIQUE
|
||||
-- index here, before step 3, is precisely the ALTER that fails and takes the
|
||||
-- site down with it (§0.6 finding 1).
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_pending VARCHAR(255) NULL;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_norm VARCHAR(255) COLLATE utf8mb4_bin AS (LOWER(email)) STORED;
|
||||
|
||||
-- 3. Record every account about to lose its address, BEFORE nulling it — the
|
||||
-- report is the only place the lost value survives. Oldest-wins (§7.1 Q1):
|
||||
-- the earliest-created account keeps the address, ties broken by id so the
|
||||
-- outcome is deterministic. Verified status deliberately does NOT arbitrate —
|
||||
-- SSO set email_verified from the mere presence of an address, so it is too
|
||||
-- weak a signal to decide who keeps a mailbox (§0.6 finding 3).
|
||||
INSERT IGNORE INTO email_dedupe_report (user_id, username, lost_address)
|
||||
SELECT l.id, l.username, l.email FROM (
|
||||
SELECT u.id, u.username, u.email FROM users u
|
||||
WHERE u.email_norm IS NOT NULL
|
||||
AND u.id <> (SELECT u2.id FROM users u2
|
||||
WHERE u2.email_norm = u.email_norm
|
||||
ORDER BY u2.created_at ASC, u2.id ASC LIMIT 1)
|
||||
) AS l;
|
||||
|
||||
-- 4. Clear the losers. NEVER deletes a row: multiple NULLs are legal under a
|
||||
-- UNIQUE index, so every account survives with its login intact and simply has
|
||||
-- no contact address until its owner sets one. The extra derived table is not
|
||||
-- decoration — MariaDB refuses a subquery on the table being updated (error
|
||||
-- 1093) without it.
|
||||
UPDATE users SET email = NULL, email_verified = 0
|
||||
WHERE id IN (SELECT id FROM (
|
||||
SELECT u.id FROM users u
|
||||
WHERE u.email_norm IS NOT NULL
|
||||
AND u.id <> (SELECT u2.id FROM users u2
|
||||
WHERE u2.email_norm = u.email_norm
|
||||
ORDER BY u2.created_at ASC, u2.id ASC LIMIT 1)
|
||||
) AS losers);
|
||||
|
||||
-- 5. Now the table can hold it.
|
||||
ALTER TABLE users ADD UNIQUE INDEX IF NOT EXISTS uq_users_email_norm (email_norm);
|
||||
|
||||
-- 6. The verification gate: may an UNVERIFIED address receive opt-in engagement
|
||||
-- mail? ON for a fresh install, OFF for an upgrade — the asymmetry is the G22
|
||||
-- lesson, not an oversight. Turning it on retroactively would silently stop
|
||||
-- mailing every existing opted-in user on upgrade day, which is exactly the
|
||||
-- kind of quiet breakage Phase 1 had to write a dashboard warning to undo.
|
||||
-- "Fresh" is read off the users table: a database with no users has no one to
|
||||
-- surprise. Both statements are INSERT IGNORE, so an operator who has since
|
||||
-- changed the value keeps theirs.
|
||||
INSERT IGNORE INTO settings (`key`, value)
|
||||
SELECT 'email_verification_required', 'on' FROM DUAL WHERE (SELECT COUNT(*) FROM users) = 0;
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('email_verification_required', 'off');
|
||||
|
||||
-- The status a Gmail-connected deployment carries is 'connected', and after the
|
||||
-- upgrade that is a lie: nothing can send. Correct it once, narrowly. The WHERE
|
||||
-- makes this idempotent and self-limiting — it matches only a row that still holds
|
||||
-- a Gmail refresh token AND has no replacement credential, so re-running it after
|
||||
-- the operator configures SMTP touches nothing, and it can never overwrite a real
|
||||
-- status recorded by a later send.
|
||||
UPDATE email_config
|
||||
SET status = 'unconfigured',
|
||||
status_detail = 'Gmail OAuth2 was removed. Configure SMTP credentials in Admin - Settings - Email.'
|
||||
WHERE refresh_token_enc IS NOT NULL
|
||||
AND credential_enc IS NULL
|
||||
AND status <> 'unconfigured';
|
||||
|
||||
211
server/engagement-triggers.json
Normal file
211
server/engagement-triggers.json
Normal file
@@ -0,0 +1,211 @@
|
||||
{
|
||||
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
|
||||
"moduleApiVersion": "1.7.0",
|
||||
"triggers": [
|
||||
{
|
||||
"id": "news.post",
|
||||
"owner": "core",
|
||||
"label": "News post published",
|
||||
"description": "A news / Five-on-Friday / newsletter post was published.",
|
||||
"kind": "event",
|
||||
"subjectKey": null,
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Five on Friday — the Yew invasion",
|
||||
"description": "The post title."
|
||||
},
|
||||
{
|
||||
"name": "excerpt",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Four new champion spawns, and the fate of the Yew moongate…",
|
||||
"description": "A plain-text summary, already stripped of markup."
|
||||
},
|
||||
{
|
||||
"name": "category",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Five on Friday",
|
||||
"description": "The post category, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "postUrl",
|
||||
"type": "url",
|
||||
"required": true,
|
||||
"example": "/news/five-on-friday-yew-invasion",
|
||||
"description": "Site-relative path to the post."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team.announcement",
|
||||
"owner": "core",
|
||||
"label": "Team — announcement",
|
||||
"description": "A leader posted an announcement in a Team.",
|
||||
"kind": "event",
|
||||
"subjectKey": "teamName",
|
||||
"audience": "members",
|
||||
"ceiling": "members",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "teamName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Silver Anvil",
|
||||
"description": "The Team the event is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "authorName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Marisol",
|
||||
"description": "Display name of the leader who posted."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Siege practice moved to Sunday",
|
||||
"description": "The announcement title."
|
||||
},
|
||||
{
|
||||
"name": "excerpt",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "We are moving practice to Sunday 8pm…",
|
||||
"description": "Plain-text excerpt of the announcement body."
|
||||
},
|
||||
{
|
||||
"name": "postUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/guilds/the-silver-anvil/forum/419",
|
||||
"description": "Site-relative path to the announcement."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team.forum.post",
|
||||
"owner": "core",
|
||||
"label": "Team — new forum post",
|
||||
"description": "A new thread or reply in a Team forum.",
|
||||
"kind": "event",
|
||||
"subjectKey": "teamName",
|
||||
"audience": "members",
|
||||
"ceiling": "members",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "teamName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Silver Anvil",
|
||||
"description": "The Team the event is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "authorName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Darrow",
|
||||
"description": "Display name of the poster."
|
||||
},
|
||||
{
|
||||
"name": "threadTitle",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Tuesday champ rotation",
|
||||
"description": "Title of the thread the post belongs to."
|
||||
},
|
||||
{
|
||||
"name": "excerpt",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Moving the Tuesday run an hour later…",
|
||||
"description": "Plain-text excerpt of the post body, already stripped of markup."
|
||||
},
|
||||
{
|
||||
"name": "postUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/guilds/the-silver-anvil/forum/412",
|
||||
"description": "Site-relative path to the post."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team.leadership.changed",
|
||||
"owner": "core",
|
||||
"label": "Team — leadership change",
|
||||
"description": "Leadership changed in a Team.",
|
||||
"kind": "event",
|
||||
"subjectKey": "teamName",
|
||||
"audience": "members",
|
||||
"ceiling": "members",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "teamName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Silver Anvil",
|
||||
"description": "The Team the event is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "leaderName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Marisol",
|
||||
"description": "Display name of the new leader."
|
||||
},
|
||||
{
|
||||
"name": "teamUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/guilds/the-silver-anvil",
|
||||
"description": "Site-relative path to the Team page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team.member.joined",
|
||||
"owner": "core",
|
||||
"label": "Team — new member",
|
||||
"description": "Someone joined a Team.",
|
||||
"kind": "event",
|
||||
"subjectKey": "teamName",
|
||||
"audience": "members",
|
||||
"ceiling": "members",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "teamName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Silver Anvil",
|
||||
"description": "The Team the event is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "memberName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Darrow",
|
||||
"description": "Display name of the member who joined."
|
||||
},
|
||||
{
|
||||
"name": "teamUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/guilds/the-silver-anvil",
|
||||
"description": "Site-relative path to the Team page. Absent when no module supplies a pageUrlTemplate."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"seed": "node db/seed.js",
|
||||
"swagger": "node swagger/swagger.js",
|
||||
"routes:manifest": "node scripts/routeManifest.js",
|
||||
"engagement:manifest": "node scripts/engagementManifest.js",
|
||||
"test": "node --test --require ./test/_setup.js"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -27,66 +27,6 @@
|
||||
"handlers": 1,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/account",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/account/identities",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/account/identities/:provider",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/disable",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/enable",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/setup",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/activity",
|
||||
@@ -199,7 +139,7 @@
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/email/config",
|
||||
"handlers": 5,
|
||||
"handlers": 9,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
@@ -207,24 +147,6 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/email/connect/callback",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/email/connect/start",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/email/disconnect",
|
||||
@@ -245,6 +167,24 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/audiences",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/triggers",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/invites",
|
||||
@@ -345,6 +285,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 +690,247 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/:id",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/archive",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/display-name",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/:id/forum/moderation",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/:id/grants",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/hide",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/leader-override",
|
||||
"handlers": 6,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/teams/:id/leader-override/:memberKey",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/unhide",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/forum/settings",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/forum/uploads",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/integrations",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/teams/integrations",
|
||||
"handlers": 8,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/teams/integrations/:teamId",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/requests",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/requests/:id/decide",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/resync",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/review",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/voice",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/teams/voice",
|
||||
"handlers": 7,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/teams/voice/:teamId",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/voice/sync",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/uploads",
|
||||
@@ -837,6 +1038,24 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/email-dedupe-report",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/users/email-dedupe-report/acknowledge",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki",
|
||||
@@ -979,6 +1198,24 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/email/verify/:token",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/email/verify/:token",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/invite/:token",
|
||||
@@ -1043,6 +1280,35 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/auth/me/account/email",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/account/email/pending",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/account/email/resend",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account/identities",
|
||||
@@ -1197,6 +1463,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",
|
||||
@@ -1377,88 +1663,6 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/account",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/account/identities",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/account/identities/:provider",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/account/password",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/disable",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/enable",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/setup",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/account/username",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/appeals",
|
||||
@@ -1499,6 +1703,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 +1916,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 +2041,22 @@
|
||||
"gates": [
|
||||
"requireInternalKey"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/internal/commands",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"requireInternalKey"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/internal/commands/dispatch",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"requireInternalKey"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,30 +17,6 @@
|
||||
"method": "GET",
|
||||
"path": "/api/health"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/account/identities"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/account/identities/:provider"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/disable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/enable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/setup"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/activity"
|
||||
@@ -89,14 +65,6 @@
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/email/config"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/email/connect/callback"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/email/connect/start"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/email/disconnect"
|
||||
@@ -105,6 +73,14 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/email/test"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/audiences"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/triggers"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/invites"
|
||||
@@ -145,6 +121,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 +277,98 @@
|
||||
"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/integrations"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/teams/integrations"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/teams/integrations/:teamId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/requests"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/requests/:id/decide"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/resync"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/review"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/voice"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/teams/voice"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/teams/voice/:teamId"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/voice/sync"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/uploads"
|
||||
@@ -333,6 +409,14 @@
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/trusted-devices/:deviceId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/email-dedupe-report"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/users/email-dedupe-report/acknowledge"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki"
|
||||
@@ -389,6 +473,14 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki/tags"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/email/verify/:token"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/email/verify/:token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/invite/:token"
|
||||
@@ -417,6 +509,18 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/auth/me/account/email"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/account/email/pending"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/account/email/resend"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account/identities"
|
||||
@@ -477,6 +581,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"
|
||||
@@ -557,38 +669,6 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/sso/totp"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/account/identities"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/account/identities/:provider"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/account/password"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/disable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/enable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/setup"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/account/username"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/appeals"
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
132
server/scripts/engagementManifest.js
Normal file
132
server/scripts/engagementManifest.js
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Engagement trigger manifest — the machine-readable freeze of core's event
|
||||
* contract (ENGAGEMENT.md §4.3, property 4).
|
||||
*
|
||||
* Why this exists: a trigger declaration is what a template interpolates and what
|
||||
* a rule is written against. Renaming a variable, changing its type, or widening
|
||||
* a ceiling breaks stored templates and stored rules — and does it silently, at
|
||||
* send time, in an email someone already received. `routes.manifest.json` freezes
|
||||
* the URL surface for exactly this reason and this is its twin: a generated
|
||||
* artifact committed to the repo, whose DIFF is the review signal. Changing a
|
||||
* declaration without regenerating is a red build; changing one deliberately puts
|
||||
* the change in front of a reviewer instead of letting it pass as a comment edit.
|
||||
*
|
||||
* **Core's only.** A module ships its own `engagement-triggers.json` in its
|
||||
* bundle, for the same reason it ships a prebuilt swagger fragment: core never
|
||||
* has its sources to analyse (MODULE_API.md §6.1a). So this loads
|
||||
* `config/coreTriggers.js` through the real `registerCore()` — the declarations
|
||||
* as VALIDATED, not as authored — which means a shape error is a failure here
|
||||
* rather than a surprise at boot.
|
||||
*
|
||||
* The `resolve` half of an audience cannot be frozen (it is a function over a
|
||||
* module's own store), so audiences are deliberately absent: what a manifest can
|
||||
* usefully freeze is the payload contract, and freezing half a declaration would
|
||||
* suggest the other half was checked.
|
||||
*
|
||||
* Usage:
|
||||
* npm run engagement:manifest # write server/engagement-triggers.json
|
||||
* npm run engagement:manifest -- --check # exit 1 if the committed file is stale
|
||||
*/
|
||||
|
||||
// registries.js -> config/coreStreams + utils/discordAnnounce, which reach
|
||||
// utils/db and build a mariadb pool at require time. Point it at a closed port
|
||||
// (the same trick routeManifest.js and the test suite use) so generating a
|
||||
// manifest never opens a connection or hangs on a missing database.
|
||||
process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1'
|
||||
process.env.DB_PORT = process.env.DB_PORT || '59999'
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const registries = require('../src/modules/registries')
|
||||
const db = require('../src/utils/db')
|
||||
const { MODULE_API_VERSION } = require('../src/modules/version')
|
||||
|
||||
const SERVER_ROOT = path.join(__dirname, '..')
|
||||
const MANIFEST_PATH = path.join(SERVER_ROOT, 'engagement-triggers.json')
|
||||
|
||||
const MANIFEST_COMMENT =
|
||||
'Generated event-trigger inventory - the authoritative freeze of CORE\'s engagement ' +
|
||||
'contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` ' +
|
||||
'in website/server. A renamed variable, a changed type or a widened ceiling breaks stored ' +
|
||||
'templates and rules, so the diff here is the review signal. A module ships its own copy ' +
|
||||
'in its bundle; this file never contains one.'
|
||||
|
||||
function build() {
|
||||
// Through registerCore(), not by reading the array: what a reviewer needs
|
||||
// frozen is what the registry ACCEPTED — defaults filled in, audience resolved
|
||||
// against the ceiling, variables normalised — because that is what the editor
|
||||
// will read and the emit path will check against.
|
||||
registries.registerCore()
|
||||
|
||||
const triggers = registries
|
||||
.allTriggers()
|
||||
.filter((t) => t.owner === 'core')
|
||||
// Sorted by id rather than left in registration order, like the route
|
||||
// manifest: reordering a declaration in the source is not a contract change
|
||||
// and must not produce a diff that looks like one.
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
owner: t.owner,
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
kind: t.kind,
|
||||
subjectKey: t.subjectKey,
|
||||
audience: t.audience,
|
||||
ceiling: t.ceiling,
|
||||
version: t.version,
|
||||
// Variables keep their DECLARED order. Here it is contract: it is the
|
||||
// order the template editor lists them in, and an author reading the
|
||||
// manifest should see what the editor will show.
|
||||
variables: t.variables.map((v) => ({
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
required: v.required,
|
||||
example: v.example,
|
||||
description: v.description,
|
||||
})),
|
||||
}))
|
||||
|
||||
return {
|
||||
_comment: MANIFEST_COMMENT,
|
||||
// The contract version these declarations are shaped by. A reader looking at
|
||||
// a stale manifest needs to know which API's rules produced it.
|
||||
moduleApiVersion: MODULE_API_VERSION,
|
||||
triggers,
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const check = process.argv.includes('--check')
|
||||
const next = `${JSON.stringify(build(), null, 2)}\n`
|
||||
|
||||
if (!check) {
|
||||
fs.writeFileSync(MANIFEST_PATH, next)
|
||||
process.stdout.write(`wrote ${path.relative(SERVER_ROOT, MANIFEST_PATH)}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
const current = fs.existsSync(MANIFEST_PATH) ? fs.readFileSync(MANIFEST_PATH, 'utf8') : ''
|
||||
if (current === next) {
|
||||
process.stdout.write('engagement-triggers.json is current\n')
|
||||
return
|
||||
}
|
||||
process.stderr.write(
|
||||
'engagement-triggers.json is stale.\n' +
|
||||
'A trigger declaration changed without the manifest being regenerated.\n' +
|
||||
'Run `npm run engagement:manifest` in website/server and commit the result —\n' +
|
||||
'the diff is what a reviewer reads to see the contract change.\n',
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main()
|
||||
// The mariadb pool never connects here, but it keeps the loop alive even
|
||||
// pointed at a dead port — the same exit routeManifest.js takes.
|
||||
db.close().finally(() => process.exit(process.exitCode || 0))
|
||||
}
|
||||
|
||||
module.exports = { build }
|
||||
@@ -37,7 +37,7 @@ class BaseProvider {
|
||||
}
|
||||
|
||||
// Complete an SSO redirect flow: exchange the callback code for a normalized
|
||||
// user profile ({ subject, email, name }).
|
||||
// user profile ({ subject, email, emailVerified, name }).
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async handleCallback(params) {
|
||||
throw new Error(`handleCallback() not implemented for provider '${this.id}'`)
|
||||
@@ -49,7 +49,7 @@ class BaseProvider {
|
||||
throw new Error(`getUserProfile() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
|
||||
// Normalize a raw external profile to { subject, email, name }.
|
||||
// Normalize a raw external profile to { subject, email, emailVerified, name }.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
mapUser(profile) {
|
||||
throw new Error(`mapUser() not implemented for provider '${this.id}'`)
|
||||
|
||||
@@ -23,7 +23,14 @@ class DiscordProvider extends OAuth2Provider {
|
||||
}
|
||||
normalizeProfile(p = {}) {
|
||||
// global_name is the new display name; fall back to the legacy username.
|
||||
return { subject: p.id, email: p.email || null, name: p.global_name || p.username || null }
|
||||
return {
|
||||
subject: p.id,
|
||||
email: p.email || null,
|
||||
// Discord spells the claim `verified` rather than `email_verified`, and it
|
||||
// means exactly this: the user confirmed the address with Discord.
|
||||
emailVerified: p.verified === true,
|
||||
name: p.global_name || p.username || null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ class GenericOidcProvider extends OAuth2Provider {
|
||||
return {
|
||||
subject: p.sub || p.id || p.user_id || p.uid || null,
|
||||
email: p.email || null,
|
||||
// The standard OIDC claim. An IdP that omits it has not asserted anything,
|
||||
// so the address stays unverified and the user proves it the ordinary way —
|
||||
// absent is treated as false, never as true.
|
||||
emailVerified: p.email_verified === true || p.email_verified === 'true',
|
||||
name: p.name || p.preferred_username || p.username || p.email || null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,15 @@ class GoogleProvider extends OAuth2Provider {
|
||||
return { access_type: 'online', prompt: 'select_account' }
|
||||
}
|
||||
normalizeProfile(p = {}) {
|
||||
return { subject: p.sub, email: p.email || null, name: p.name || p.email || null }
|
||||
return {
|
||||
subject: p.sub,
|
||||
email: p.email || null,
|
||||
// Google's OIDC userinfo carries the standard `email_verified` claim. Read
|
||||
// it rather than inferring verification from the mere presence of an
|
||||
// address, which is what this code used to do (ENGAGEMENT.md §0.6/1b).
|
||||
emailVerified: p.email_verified === true || p.email_verified === 'true',
|
||||
name: p.name || p.email || null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
147
server/src/config/coreTriggers.js
Normal file
147
server/src/config/coreTriggers.js
Normal file
@@ -0,0 +1,147 @@
|
||||
// ── Core's own engagement triggers ─────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.3 and Phase 2. The twin of config/coreStreams.js, and
|
||||
// deliberately the SAME FIVE IDS — that is the org lead's §7.2 decision, taken at
|
||||
// the start of this phase: **one namespace.** A trigger is not a second thing
|
||||
// standing next to a stream; it is a payload contract attached to an id that may
|
||||
// also carry a subscription toggle. `news.post` names one event, whether the
|
||||
// question being asked of it is "may I push this?" or "what may a template
|
||||
// interpolate?".
|
||||
//
|
||||
// What that buys, concretely: `notification_channel_prefs.stream_id` (§4.5) stays
|
||||
// single-keyed. Under two namespaces it would have needed a `kind` discriminator
|
||||
// in its primary key, and `news.post` would have named two different things
|
||||
// forever.
|
||||
//
|
||||
// What it costs is the rule enforced in registries.js: an id has ONE owner across
|
||||
// both facets, so a module cannot attach a payload contract to another module's
|
||||
// stream, and core cannot attach one to a module's. Core's five ids below are
|
||||
// already core's five streams, so all five are the same-owner upgrade case.
|
||||
//
|
||||
// **These declare; nothing here emits yet.** Phase 2 is the contract only — the
|
||||
// Team pipeline keeps its own hardcoded mail until Phase 6 migrates it onto the
|
||||
// engine, and this file is what it migrates ONTO. Registering the declarations a
|
||||
// phase early is the same decision registerCore() has always taken: a registry
|
||||
// whose first real exercise is a module is a registry that has already drifted.
|
||||
//
|
||||
// Every variable carries an `example`, and that is required rather than
|
||||
// decorative (§4.3 property 3). It is what lets the template editor preview and
|
||||
// test-send without a live game event, which is the reason template systems go
|
||||
// untested.
|
||||
|
||||
const TRIGGERS = [
|
||||
{
|
||||
id: 'news.post',
|
||||
label: 'News post published',
|
||||
description: 'A news / Five-on-Friday / newsletter post was published.',
|
||||
kind: 'event',
|
||||
// No subjectKey. The subject of a cooldown here is the USER, not the post —
|
||||
// "do not mail me about news more than once an hour" is the useful rule, and
|
||||
// keying it per post would make every cooldown a no-op. Compare the four
|
||||
// Team triggers below, where the Team genuinely is the subject.
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'title', type: 'string', required: true, example: 'Five on Friday — the Yew invasion',
|
||||
description: 'The post title.' },
|
||||
{ name: 'excerpt', type: 'string', required: false, example: 'Four new champion spawns, and the fate of the Yew moongate…',
|
||||
description: 'A plain-text summary, already stripped of markup.' },
|
||||
{ name: 'category', type: 'string', required: false, example: 'Five on Friday',
|
||||
description: 'The post category, when it has one.' },
|
||||
{ name: 'postUrl', type: 'url', required: true, example: '/news/five-on-friday-yew-invasion',
|
||||
description: 'Site-relative path to the post.' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── Teams (TEAMS.md Part 6) ─────────────────────────────────────────────
|
||||
//
|
||||
// All four ceiling at `members` and not one of them higher. Who may be told
|
||||
// about a Team event is the access resolver's answer and always has been
|
||||
// (coreStreams.js says the same thing about the push catalog); the ceiling is
|
||||
// that rule written where a RULE EDITOR has to obey it too. Without it an
|
||||
// operator could point a rule at `authenticated` and mail a private Team's
|
||||
// forum excerpt to the whole site.
|
||||
{
|
||||
id: 'team.member.joined',
|
||||
label: 'Team — new member',
|
||||
description: 'Someone joined a Team.',
|
||||
kind: 'event',
|
||||
subjectKey: 'teamName',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
|
||||
description: 'The Team the event is about. Also the cooldown subject.' },
|
||||
{ name: 'memberName', type: 'string', required: true, example: 'Darrow',
|
||||
description: 'Display name of the member who joined.' },
|
||||
{ name: 'teamUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil',
|
||||
description: 'Site-relative path to the Team page. Absent when no module supplies a pageUrlTemplate.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'team.leadership.changed',
|
||||
label: 'Team — leadership change',
|
||||
description: 'Leadership changed in a Team.',
|
||||
kind: 'event',
|
||||
subjectKey: 'teamName',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
|
||||
description: 'The Team the event is about. Also the cooldown subject.' },
|
||||
{ name: 'leaderName', type: 'string', required: true, example: 'Marisol',
|
||||
description: 'Display name of the new leader.' },
|
||||
{ name: 'teamUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil',
|
||||
description: 'Site-relative path to the Team page.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'team.forum.post',
|
||||
label: 'Team — new forum post',
|
||||
description: 'A new thread or reply in a Team forum.',
|
||||
kind: 'event',
|
||||
subjectKey: 'teamName',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
|
||||
description: 'The Team the event is about. Also the cooldown subject.' },
|
||||
{ name: 'authorName', type: 'string', required: true, example: 'Darrow',
|
||||
description: 'Display name of the poster.' },
|
||||
{ name: 'threadTitle', type: 'string', required: true, example: 'Tuesday champ rotation',
|
||||
description: 'Title of the thread the post belongs to.' },
|
||||
{ name: 'excerpt', type: 'string', required: false, example: 'Moving the Tuesday run an hour later…',
|
||||
description: 'Plain-text excerpt of the post body, already stripped of markup.' },
|
||||
{ name: 'postUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil/forum/412',
|
||||
description: 'Site-relative path to the post.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'team.announcement',
|
||||
label: 'Team — announcement',
|
||||
description: 'A leader posted an announcement in a Team.',
|
||||
kind: 'event',
|
||||
subjectKey: 'teamName',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
|
||||
description: 'The Team the event is about. Also the cooldown subject.' },
|
||||
{ name: 'authorName', type: 'string', required: true, example: 'Marisol',
|
||||
description: 'Display name of the leader who posted.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'Siege practice moved to Sunday',
|
||||
description: 'The announcement title.' },
|
||||
{ name: 'excerpt', type: 'string', required: false, example: 'We are moving practice to Sunday 8pm…',
|
||||
description: 'Plain-text excerpt of the announcement body.' },
|
||||
{ name: 'postUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil/forum/419',
|
||||
description: 'Site-relative path to the announcement.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { TRIGGERS }
|
||||
22
server/src/engagement/index.js
Normal file
22
server/src/engagement/index.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// ── The engagement subsystem — one door ────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 1. Today this is the mail transport registry and core's
|
||||
// own transports; the trigger registry, the rules engine and the delivery
|
||||
// channels arrive in later phases and hang here too.
|
||||
//
|
||||
// **Core's transports register through the same door a module's would**, and
|
||||
// they register HERE rather than at the bottom of the registry file. That keeps
|
||||
// the registry free of any knowledge of its registrants — the same reason
|
||||
// `registerCore()` is called from app.js rather than from inside
|
||||
// `modules/registries.js` (MODULE_API.md §7.6) — and it means requiring the
|
||||
// registry never has the side effect of populating it.
|
||||
//
|
||||
// Requiring this module is what makes `smtp` available. Everything that resolves
|
||||
// a transport goes through here, so there is exactly one place a transport can
|
||||
// come into existence.
|
||||
|
||||
require('./transports/smtp')
|
||||
|
||||
const transports = require('./transports')
|
||||
|
||||
module.exports = { transports }
|
||||
195
server/src/engagement/transports/index.js
Normal file
195
server/src/engagement/transports/index.js
Normal file
@@ -0,0 +1,195 @@
|
||||
// ── The mail transport registry ────────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §3.1, Phase 1. A **channel** is what kind of sink this is (email,
|
||||
// push, in-app); a **transport** is how one channel actually delivers. This file
|
||||
// is the second half only. The channel registry arrives with the engine that
|
||||
// consumes it — registering a channel nothing calls would be a shape frozen
|
||||
// before anything had tried to use it.
|
||||
//
|
||||
// What this replaces: `mailer.buildTransport()` had Gmail's host, port and
|
||||
// OAuth2 auth type as literals, so "which provider" was a code edit. Now the
|
||||
// stored `email_config.transport` names a registration, and the registration
|
||||
// declares its own credential fields — which drives the admin form, the encrypted
|
||||
// blob's shape and the validation, from one place.
|
||||
//
|
||||
// **`credentialFields` is the contract.** It is read by three consumers that
|
||||
// would otherwise drift: the admin form renders it, `sanitizeCredential()` below
|
||||
// filters a submitted body through it, and `describe()` tells the client which
|
||||
// values are secret so they are never sent back. Adding a field to a transport is
|
||||
// therefore one edit, not four.
|
||||
//
|
||||
// **No transport may carry a default host, endpoint or sender** (§3.2 rule 1). A
|
||||
// transport with no operator configuration is `unconfigured` and its channel is
|
||||
// off — it never falls back to somewhere we chose. `scripts/checkNoExternalHosts.js`
|
||||
// is the CI backstop for that rule; this file is where it would be broken first.
|
||||
//
|
||||
// Nothing here touches the database or the network at require time.
|
||||
|
||||
const log = require('../../utils/logger')('mailer')
|
||||
|
||||
// id → transport definition
|
||||
const transports = new Map()
|
||||
|
||||
// Field kinds the admin form knows how to render. `secret` is the only one that
|
||||
// changes behaviour server-side: it is write-only, so an unchanged value arrives
|
||||
// as '' and must be read from the stored credential rather than overwritten.
|
||||
const FIELD_KINDS = new Set(['text', 'number', 'secret', 'boolean'])
|
||||
|
||||
/**
|
||||
* Register a mail transport. Shape-checked at the call and collision-checked
|
||||
* here, the same validate-then-commit discipline `modules/registries.js` uses.
|
||||
*
|
||||
* @param {object} def
|
||||
* @param {string} def.id stable id stored in email_config.transport
|
||||
* @param {string} def.label human name for the admin form
|
||||
* @param {Array} def.credentialFields [{ key, label, kind, required, help, default }]
|
||||
* @param {Function} def.build (credential, config) → a nodemailer-shaped transport
|
||||
* @param {Function} def.isComplete (credential) → boolean; are the required fields present
|
||||
*/
|
||||
function registerMailTransport(def) {
|
||||
if (!def || typeof def !== 'object') throw new Error('registerMailTransport: definition required')
|
||||
const { id, label, credentialFields, build, isComplete } = def
|
||||
if (typeof id !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(id)) {
|
||||
throw new Error(`registerMailTransport: invalid id ${JSON.stringify(id)}`)
|
||||
}
|
||||
if (transports.has(id)) throw new Error(`registerMailTransport: ${id} is already registered`)
|
||||
if (typeof label !== 'string' || !label) throw new Error(`registerMailTransport(${id}): label required`)
|
||||
if (!Array.isArray(credentialFields) || credentialFields.length === 0) {
|
||||
throw new Error(`registerMailTransport(${id}): credentialFields required`)
|
||||
}
|
||||
for (const f of credentialFields) {
|
||||
if (!f || typeof f.key !== 'string' || !f.key) {
|
||||
throw new Error(`registerMailTransport(${id}): every credential field needs a key`)
|
||||
}
|
||||
if (!FIELD_KINDS.has(f.kind)) {
|
||||
throw new Error(`registerMailTransport(${id}): field ${f.key} has unknown kind ${f.kind}`)
|
||||
}
|
||||
}
|
||||
if (typeof build !== 'function') throw new Error(`registerMailTransport(${id}): build() required`)
|
||||
if (typeof isComplete !== 'function') throw new Error(`registerMailTransport(${id}): isComplete() required`)
|
||||
|
||||
transports.set(id, { ...def, credentialFields: credentialFields.map((f) => ({ ...f })) })
|
||||
return id
|
||||
}
|
||||
|
||||
/** The registered transport, or null. Callers must handle null — a stored id can
|
||||
* name a transport that no longer exists (a downgrade, a removed provider), and
|
||||
* that must degrade to "unconfigured", never throw at send time. */
|
||||
function get(id) {
|
||||
return transports.get(id) || null
|
||||
}
|
||||
|
||||
function has(id) {
|
||||
return transports.has(id)
|
||||
}
|
||||
|
||||
/** Every transport, as the admin form needs it: no functions, secrets flagged. */
|
||||
function describe() {
|
||||
return [...transports.values()].map((t) => ({
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
help: t.help || null,
|
||||
credentialFields: t.credentialFields.map((f) => ({
|
||||
key: f.key,
|
||||
label: f.label || f.key,
|
||||
kind: f.kind,
|
||||
required: Boolean(f.required),
|
||||
help: f.help || null,
|
||||
default: f.default === undefined ? null : f.default,
|
||||
placeholder: f.placeholder || null,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a submitted credential body down to the transport's declared fields,
|
||||
* coercing each to its declared kind. Anything not declared is dropped — the
|
||||
* blob that reaches `secretBox.encrypt` only ever holds fields a transport asked
|
||||
* for, so a client cannot smuggle extra keys into stored ciphertext.
|
||||
*
|
||||
* `secret` fields submitted empty are OMITTED rather than blanked, which is the
|
||||
* "leave the existing one alone" convention `botConfig.save`/`emailConfig.save`
|
||||
* already use; `mergeCredential()` is what puts the stored value back.
|
||||
*/
|
||||
function sanitizeCredential(id, body) {
|
||||
const t = get(id)
|
||||
if (!t) return {}
|
||||
const out = {}
|
||||
for (const f of t.credentialFields) {
|
||||
if (!(f.key in (body || {}))) continue
|
||||
const raw = body[f.key]
|
||||
if (f.kind === 'secret') {
|
||||
if (raw === undefined || raw === null || raw === '') continue
|
||||
out[f.key] = String(raw)
|
||||
} else if (f.kind === 'number') {
|
||||
const n = Number(raw)
|
||||
if (Number.isFinite(n)) out[f.key] = n
|
||||
} else if (f.kind === 'boolean') {
|
||||
out[f.key] = Boolean(raw)
|
||||
} else {
|
||||
out[f.key] = raw === null || raw === undefined ? '' : String(raw)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Stored credential + the submitted patch. Omitted secrets keep their stored value. */
|
||||
function mergeCredential(id, stored, patch) {
|
||||
return { ...(stored || {}), ...(patch || {}) }
|
||||
}
|
||||
|
||||
/** Non-secret fields only — safe to return over the admin API. */
|
||||
function publicCredential(id, credential) {
|
||||
const t = get(id)
|
||||
if (!t || !credential) return {}
|
||||
const out = {}
|
||||
for (const f of t.credentialFields) {
|
||||
if (f.kind === 'secret') continue
|
||||
if (credential[f.key] !== undefined) out[f.key] = credential[f.key]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Which declared secrets are actually held, so the form can say "set" without
|
||||
* ever returning the value. */
|
||||
function secretsPresent(id, credential) {
|
||||
const t = get(id)
|
||||
if (!t) return {}
|
||||
const out = {}
|
||||
for (const f of t.credentialFields) {
|
||||
if (f.kind !== 'secret') continue
|
||||
out[f.key] = Boolean(credential && credential[f.key])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Does this credential have everything its transport needs to send? */
|
||||
function isComplete(id, credential) {
|
||||
const t = get(id)
|
||||
if (!t) return false
|
||||
try {
|
||||
return Boolean(t.isComplete(credential || {}))
|
||||
} catch (err) {
|
||||
log.warn('transport isComplete threw', { transport: id, message: err.message })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only: the registry is module-level state and a suite that registers a
|
||||
// fake transport must be able to undo it.
|
||||
function _reset() {
|
||||
transports.clear()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerMailTransport,
|
||||
get,
|
||||
has,
|
||||
describe,
|
||||
sanitizeCredential,
|
||||
mergeCredential,
|
||||
publicCredential,
|
||||
secretsPresent,
|
||||
isComplete,
|
||||
_reset,
|
||||
}
|
||||
96
server/src/engagement/transports/smtp.js
Normal file
96
server/src/engagement/transports/smtp.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// ── SMTP — the baseline mail transport ─────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md decision 4: Gmail OAuth2 is removed, SMTP is the baseline. This
|
||||
// is the only registered transport, and it is deliberately plain SMTP rather than
|
||||
// anything provider-shaped — a relay (Mailgun, SES, Postmark), a self-hosted MTA
|
||||
// and Gmail-with-an-app-password are all reachable through these five fields, so
|
||||
// one transport covers all three postures §7.1 Q5 asks to document.
|
||||
//
|
||||
// **No defaults for host, port, user or sender.** §3.2 rule 1: a transport with
|
||||
// no operator configuration is unconfigured, never pointed at somewhere we chose.
|
||||
// `secure` gets a default because it is a protocol choice, not a destination — and
|
||||
// even that is only a form default, not a fallback applied to a stored blank.
|
||||
//
|
||||
// **`secure` is the field operators get wrong**, so its help text says which port
|
||||
// each setting means: `secure: true` is implicit TLS on 465, `secure: false` is
|
||||
// plaintext-then-STARTTLS on 587 (which nodemailer upgrades automatically). The
|
||||
// combination that silently fails is 587 with secure on — the handshake hangs
|
||||
// rather than erroring cleanly — which is exactly why "Send test" is the real
|
||||
// verification path now (§1.2a consequence 2).
|
||||
|
||||
const nodemailer = require('nodemailer')
|
||||
|
||||
const registry = require('./index')
|
||||
|
||||
const CREDENTIAL_FIELDS = [
|
||||
{
|
||||
key: 'host',
|
||||
label: 'SMTP host',
|
||||
kind: 'text',
|
||||
required: true,
|
||||
placeholder: 'smtp.example.com',
|
||||
help: 'Your relay or mail server. No default — nothing is sent until you set this.',
|
||||
},
|
||||
{
|
||||
key: 'port',
|
||||
label: 'Port',
|
||||
kind: 'number',
|
||||
required: true,
|
||||
default: 587,
|
||||
help: '587 for STARTTLS (most relays), 465 for implicit TLS, 25 for an unauthenticated local MTA.',
|
||||
},
|
||||
{
|
||||
key: 'secure',
|
||||
label: 'Implicit TLS',
|
||||
kind: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
help: 'On for port 465. Leave off for 587 — the connection still upgrades to TLS via STARTTLS.',
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: 'Username',
|
||||
kind: 'text',
|
||||
required: false,
|
||||
help: 'Leave blank for an unauthenticated local relay.',
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: 'Password / API key',
|
||||
kind: 'secret',
|
||||
required: false,
|
||||
help: 'Stored encrypted and never returned. For Gmail this is an app password, not the account password.',
|
||||
},
|
||||
]
|
||||
|
||||
// Authentication is optional (a local MTA on port 25 needs none), so the only
|
||||
// hard requirement is a destination. A username without a password is not
|
||||
// "complete" — that combination authenticates as nobody and fails at the server.
|
||||
function isComplete(credential) {
|
||||
const c = credential || {}
|
||||
if (!c.host || !Number(c.port)) return false
|
||||
if (c.user && !c.password) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function build(credential) {
|
||||
const c = credential || {}
|
||||
const options = {
|
||||
host: String(c.host),
|
||||
port: Number(c.port),
|
||||
secure: Boolean(c.secure),
|
||||
}
|
||||
if (c.user) options.auth = { user: String(c.user), pass: String(c.password || '') }
|
||||
return nodemailer.createTransport(options)
|
||||
}
|
||||
|
||||
registry.registerMailTransport({
|
||||
id: 'smtp',
|
||||
label: 'SMTP',
|
||||
help: 'Any SMTP relay or mail server. See the operator guide for the three supported postures.',
|
||||
credentialFields: CREDENTIAL_FIELDS,
|
||||
isComplete,
|
||||
build,
|
||||
})
|
||||
|
||||
module.exports = { CREDENTIAL_FIELDS, isComplete, build }
|
||||
@@ -118,6 +118,19 @@ const passwordResetConfirmLimiter = makeLimiter({
|
||||
message: 'Too many attempts. Please try again later.',
|
||||
})
|
||||
|
||||
// Email-verification confirmations (engagement Phase 1b). Same reasoning as the
|
||||
// password-reset confirm limiter: the token is 256-bit random, but an
|
||||
// unauthenticated token-bearing endpoint should not be free to hammer. The
|
||||
// REQUEST side is authenticated and limited separately — accountChangeLimiter per
|
||||
// IP, plus a per-user ceiling in the model, because the mail goes to an address
|
||||
// its recipient did not ask to hear from.
|
||||
const emailVerifyConfirmLimiter = makeLimiter({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 15,
|
||||
label: 'email-verify-confirm',
|
||||
message: 'Too many attempts. Please try again later.',
|
||||
})
|
||||
|
||||
// CSP violation reports. Unauthenticated by necessity (browsers send them with no
|
||||
// session), and every accepted report writes a log line — so an attacker who can get
|
||||
// a victim to load a page could otherwise use it as a log-flood amplifier. Generous
|
||||
@@ -149,5 +162,6 @@ module.exports = {
|
||||
mobileSsoExchangeLimiter,
|
||||
passwordResetRequestLimiter,
|
||||
passwordResetConfirmLimiter,
|
||||
emailVerifyConfirmLimiter,
|
||||
cspReportLimiter,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const singletonConfigDb = require('../singletonConfigDb')
|
||||
|
||||
const COLS =
|
||||
'id, provider, enabled, sender_email, sender_name, refresh_token_enc, status, status_detail, last_verified_at, updated_by, created_at, updated_at'
|
||||
'id, provider, transport, enabled, sender_email, sender_name, reply_to, credential_enc, refresh_token_enc, ' +
|
||||
'status, status_detail, last_verified_at, updated_by, created_at, updated_at'
|
||||
|
||||
// Singleton row (id = 1). See ../singletonConfigDb for the get/upsert contract.
|
||||
module.exports = singletonConfigDb('email_config', COLS)
|
||||
|
||||
@@ -1,30 +1,69 @@
|
||||
// Outbound email config store (Gmail OAuth2). Mirrors the botConfig model split:
|
||||
// the DB layer only ever sees ciphertext, and only getWithSecret() (used by the
|
||||
// mailer at send time) decrypts the refresh token. The admin-facing getSafe()
|
||||
// never includes it — callers see only `hasRefreshToken`.
|
||||
// Outbound email config store. Mirrors the botConfig model split: the DB layer
|
||||
// only ever sees ciphertext, and only getWithSecret() (used by the mailer at send
|
||||
// time) decrypts the credential. The admin-facing getSafe() never includes it —
|
||||
// callers see the non-secret fields plus which secrets are set.
|
||||
//
|
||||
// The credential is ONE encrypted JSON blob, not a column per field, because the
|
||||
// field list belongs to the transport (ENGAGEMENT.md §3.1). `credential_enc`
|
||||
// holds `{ host, port, secure, user, password }` for `smtp`; a future relay's
|
||||
// blob would hold different keys against the same column.
|
||||
//
|
||||
// `provider` and `refresh_token_enc` are the removed Gmail OAuth2 connection
|
||||
// (§1.2a). They are no longer read as configuration — `hadLegacyConnection`
|
||||
// exposes the token column's presence for one purpose only: telling an upgraded
|
||||
// deployment that its mail just stopped.
|
||||
|
||||
const db = require('./emailConfig.db')
|
||||
const secretBox = require('../../utils/secretBox')
|
||||
const { transports } = require('../../engagement')
|
||||
|
||||
function toSafe(row) {
|
||||
const DEFAULT_TRANSPORT = 'smtp'
|
||||
|
||||
// A stored blob that will not parse is treated as ABSENT, never as an error —
|
||||
// the same fail-safe rule utils/settingsJson.js applies. A deployment whose
|
||||
// SECRET_ENC_KEY was rotated must degrade to "unconfigured" and say so on the
|
||||
// admin screen, not 500 the settings page and the contact form with it.
|
||||
function readCredential(row) {
|
||||
if (!row || !row.credential_enc) return null
|
||||
try {
|
||||
const parsed = JSON.parse(secretBox.decrypt(row.credential_enc))
|
||||
return parsed && typeof parsed === 'object' ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function toSafe(row, credential) {
|
||||
const transport = (row && row.transport) || DEFAULT_TRANSPORT
|
||||
if (!row) {
|
||||
return {
|
||||
provider: 'gmail_oauth2',
|
||||
transport: DEFAULT_TRANSPORT,
|
||||
enabled: false,
|
||||
senderEmail: null,
|
||||
senderName: null,
|
||||
hasRefreshToken: false,
|
||||
replyTo: null,
|
||||
credential: {},
|
||||
secretsSet: transports.secretsPresent(DEFAULT_TRANSPORT, null),
|
||||
hasCredential: false,
|
||||
hadLegacyConnection: false,
|
||||
status: 'unconfigured',
|
||||
statusDetail: null,
|
||||
lastVerifiedAt: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider: row.provider || 'gmail_oauth2',
|
||||
transport,
|
||||
enabled: Boolean(row.enabled),
|
||||
senderEmail: row.sender_email || null,
|
||||
senderName: row.sender_name || null,
|
||||
hasRefreshToken: Boolean(row.refresh_token_enc),
|
||||
replyTo: row.reply_to || null,
|
||||
credential: transports.publicCredential(transport, credential),
|
||||
secretsSet: transports.secretsPresent(transport, credential),
|
||||
hasCredential: transports.isComplete(transport, credential),
|
||||
// Deliberately the raw column, not "is Gmail configured": nothing reads the
|
||||
// token any more. It answers "did this deployment have working mail before
|
||||
// the upgrade?", which is the G22 warning's whole condition.
|
||||
hadLegacyConnection: Boolean(row.refresh_token_enc),
|
||||
status: row.status || 'unconfigured',
|
||||
statusDetail: row.status_detail || null,
|
||||
lastVerifiedAt: row.last_verified_at || null,
|
||||
@@ -32,38 +71,53 @@ function toSafe(row) {
|
||||
}
|
||||
|
||||
async function getSafe() {
|
||||
return toSafe(await db.get())
|
||||
const row = await db.get()
|
||||
return toSafe(row, readCredential(row))
|
||||
}
|
||||
|
||||
// Decrypted refresh token included — server-side only (building the mailer's
|
||||
// OAuth2 transport). Returns null when no row exists yet.
|
||||
// Decrypted credential included — server-side only (building the transport at
|
||||
// send time). Returns null when no row exists yet.
|
||||
async function getWithSecret() {
|
||||
const row = await db.get()
|
||||
if (!row) return null
|
||||
return {
|
||||
...toSafe(row),
|
||||
refreshToken: row.refresh_token_enc ? secretBox.decrypt(row.refresh_token_enc) : null,
|
||||
}
|
||||
const credential = readCredential(row)
|
||||
return { ...toSafe(row, credential), credentialSecret: credential || {} }
|
||||
}
|
||||
|
||||
// Save admin-supplied / connect-flow config. `refreshToken` undefined or '' means
|
||||
// "leave the existing token unchanged" (same convention as botConfig.save).
|
||||
async function save({ senderEmail, senderName, refreshToken, enabled, status, statusDetail, updatedBy }) {
|
||||
// Save admin-supplied config. `credential` is a PATCH, merged over the stored
|
||||
// blob: a secret field submitted empty is omitted by the registry's sanitizer and
|
||||
// therefore keeps its stored value (same convention as botConfig.save).
|
||||
async function save({ transport, senderEmail, senderName, replyTo, credential, enabled, status, statusDetail, updatedBy }) {
|
||||
const row = await db.get()
|
||||
const fields = {}
|
||||
const nextTransport = transport !== undefined ? transport : (row && row.transport) || DEFAULT_TRANSPORT
|
||||
if (transport !== undefined) fields.transport = transport
|
||||
if (senderEmail !== undefined) fields.sender_email = senderEmail
|
||||
if (senderName !== undefined) fields.sender_name = senderName
|
||||
if (refreshToken) fields.refresh_token_enc = secretBox.encrypt(refreshToken)
|
||||
if (replyTo !== undefined) fields.reply_to = replyTo
|
||||
if (credential !== undefined) {
|
||||
// Changing transport starts from an empty credential rather than merging one
|
||||
// transport's fields into another's — an SMTP password left inside a relay's
|
||||
// blob is a stored secret nobody can see and nothing will ever use.
|
||||
const base = row && row.transport === nextTransport ? readCredential(row) : null
|
||||
const merged = transports.mergeCredential(nextTransport, base, transports.sanitizeCredential(nextTransport, credential))
|
||||
fields.credential_enc = Object.keys(merged).length ? secretBox.encrypt(JSON.stringify(merged)) : null
|
||||
}
|
||||
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
|
||||
if (status !== undefined) fields.status = status
|
||||
if (statusDetail !== undefined) fields.status_detail = statusDetail
|
||||
if (updatedBy !== undefined) fields.updated_by = updatedBy
|
||||
const row = await db.upsert(fields)
|
||||
return toSafe(row)
|
||||
const saved = await db.upsert(fields)
|
||||
return toSafe(saved, readCredential(saved))
|
||||
}
|
||||
|
||||
// Clear the stored credential and disable sending (admin "Disconnect").
|
||||
// Clear the stored credential and disable sending (admin "Clear credentials").
|
||||
// The legacy Gmail token goes too: this is the operator saying "there is no
|
||||
// mailbox here", and leaving the deprecated column set would keep the G22 warning
|
||||
// on screen for a deployment that has deliberately turned mail off.
|
||||
async function disconnect(updatedBy) {
|
||||
const row = await db.upsert({
|
||||
credential_enc: null,
|
||||
refresh_token_enc: null,
|
||||
sender_email: null,
|
||||
enabled: 0,
|
||||
@@ -72,7 +126,7 @@ async function disconnect(updatedBy) {
|
||||
last_verified_at: null,
|
||||
updated_by: updatedBy ?? null,
|
||||
})
|
||||
return toSafe(row)
|
||||
return toSafe(row, readCredential(row))
|
||||
}
|
||||
|
||||
// Record the outcome of the last send / verification so the admin panel has
|
||||
@@ -86,7 +140,7 @@ async function recordStatus({ status, statusDetail, lastVerifiedAt } = {}) {
|
||||
}
|
||||
if (Object.keys(fields).length === 0) return getSafe()
|
||||
const row = await db.upsert(fields)
|
||||
return toSafe(row)
|
||||
return toSafe(row, readCredential(row))
|
||||
}
|
||||
|
||||
module.exports = { getSafe, getWithSecret, save, disconnect, recordStatus }
|
||||
module.exports = { getSafe, getWithSecret, save, disconnect, recordStatus, DEFAULT_TRANSPORT }
|
||||
|
||||
22
server/src/model/emailDedupe/emailDedupe.db.js
Normal file
22
server/src/model/emailDedupe/emailDedupe.db.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS = 'id, user_id, username, lost_address, cleared_at, acknowledged_at'
|
||||
|
||||
// Accounts cleared by the Phase 1b de-duplication, newest first.
|
||||
async function list() {
|
||||
return query(`SELECT ${COLS} FROM email_dedupe_report ORDER BY cleared_at DESC, id DESC`)
|
||||
}
|
||||
|
||||
async function countUnacknowledged() {
|
||||
const rows = await query('SELECT COUNT(*) AS n FROM email_dedupe_report WHERE acknowledged_at IS NULL')
|
||||
return Number(rows[0] ? rows[0].n : 0)
|
||||
}
|
||||
|
||||
// Dismiss the whole report. Idempotent — an already-acknowledged row is skipped
|
||||
// so a second dismissal cannot rewrite when it happened.
|
||||
async function acknowledgeAll() {
|
||||
const res = await query('UPDATE email_dedupe_report SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL')
|
||||
return res.affectedRows || 0
|
||||
}
|
||||
|
||||
module.exports = { list, countUnacknowledged, acknowledgeAll }
|
||||
18
server/src/model/emailDedupe/emailDedupe.model.js
Normal file
18
server/src/model/emailDedupe/emailDedupe.model.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// The Phase 1b de-duplication report: who lost an email address when the UNIQUE
|
||||
// index went on, and what they lost.
|
||||
//
|
||||
// The rows are written by schema.sql's migration in pure SQL — ensureSchema()
|
||||
// executes that file statement-by-statement and there is no JS migration hook —
|
||||
// so this model only ever READS and acknowledges. Nothing here creates a row.
|
||||
//
|
||||
// It matters because these accounts are exactly the ones an operator must
|
||||
// contact: each can still log in, but has no contact address, so password-reset
|
||||
// and engagement mail have nowhere to go until its owner sets a new one.
|
||||
|
||||
const db = require('./emailDedupe.db')
|
||||
|
||||
const list = () => db.list()
|
||||
const countUnacknowledged = () => db.countUnacknowledged()
|
||||
const acknowledgeAll = () => db.acknowledgeAll()
|
||||
|
||||
module.exports = { list, countUnacknowledged, acknowledgeAll }
|
||||
52
server/src/model/emailVerifications/emailVerifications.db.js
Normal file
52
server/src/model/emailVerifications/emailVerifications.db.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS = 'id, token_hash, user_id, email, status, requested_ip, expires_at, created_at, used_at'
|
||||
|
||||
async function insert({ tokenHash, userId, email, requestedIp, expiresAt }) {
|
||||
const res = await query(
|
||||
`INSERT INTO email_verifications (token_hash, user_id, email, requested_ip, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[tokenHash, userId, email, requestedIp ?? null, expiresAt],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function findByTokenHash(tokenHash) {
|
||||
const rows = await query(`SELECT ${COLS} FROM email_verifications WHERE token_hash = ? LIMIT 1`, [tokenHash])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Mark used only if still pending (atomic guard against a double-use race).
|
||||
// Returns rows changed (1 = we won, 0 = already used).
|
||||
async function markUsed(id) {
|
||||
const res = await query(
|
||||
`UPDATE email_verifications SET status = 'used', used_at = NOW()
|
||||
WHERE id = ? AND status = 'pending'`,
|
||||
[id],
|
||||
)
|
||||
return res.affectedRows || 0
|
||||
}
|
||||
|
||||
// Retire every still-pending verification for a user. Called when a fresh request
|
||||
// supersedes older links and after a successful verification, so an address the
|
||||
// user changed their mind about can never be installed by an old email.
|
||||
async function invalidatePendingForUser(userId) {
|
||||
const res = await query(
|
||||
`UPDATE email_verifications SET status = 'used', used_at = NOW()
|
||||
WHERE user_id = ? AND status = 'pending'`,
|
||||
[userId],
|
||||
)
|
||||
return res.affectedRows || 0
|
||||
}
|
||||
|
||||
// How many verification mails this user has asked for since `since`. Backs the
|
||||
// per-user resend ceiling, which the IP rate limiter cannot provide on its own.
|
||||
async function countRecentForUser(userId, since) {
|
||||
const rows = await query(
|
||||
'SELECT COUNT(*) AS n FROM email_verifications WHERE user_id = ? AND created_at >= ?',
|
||||
[userId, since],
|
||||
)
|
||||
return Number(rows[0] ? rows[0].n : 0)
|
||||
}
|
||||
|
||||
module.exports = { insert, findByTokenHash, markUsed, invalidatePendingForUser, countRecentForUser }
|
||||
@@ -0,0 +1,76 @@
|
||||
// Self-service email verification (engagement Phase 1b). A user asks to set or
|
||||
// change their address; a tokened link goes to the address they typed, and only
|
||||
// opening that link installs it. The opaque token lives only in the emailed link —
|
||||
// the DB stores its sha256 — so a DB read never yields a usable link. Same shape
|
||||
// as password_resets and user_invites, deliberately: the design of record calls
|
||||
// this link "signed", but every comparable flow here uses a hashed random token,
|
||||
// and matching them beats adding a second token mechanism for one caller.
|
||||
//
|
||||
// The address is stored ON THE ROW rather than read from the user at confirm
|
||||
// time, because a token proves control of the address it was mailed to and
|
||||
// nothing else.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const db = require('./emailVerifications.db')
|
||||
|
||||
// A day, not an hour. Unlike a password reset this is not a live credential-reset
|
||||
// capability — the worst a leaked token does is attach an address its holder
|
||||
// already controls — and a verification mail is routinely opened on another
|
||||
// device, hours later.
|
||||
const DEFAULT_TTL_MINUTES = 24 * 60
|
||||
|
||||
// Per-user ceiling on verification sends, independent of the per-IP limiter: the
|
||||
// mail goes to an address the RECIPIENT did not choose to hear from, so an
|
||||
// attacker with one account must not be able to use it to pester a mailbox.
|
||||
const MAX_SENDS_PER_WINDOW = 5
|
||||
const SEND_WINDOW_MINUTES = 60
|
||||
|
||||
function hashToken(raw) {
|
||||
return crypto.createHash('sha256').update(String(raw)).digest('hex')
|
||||
}
|
||||
|
||||
// Create a verification for one user + address. Returns { id, token } — the
|
||||
// plaintext token is returned ONCE, for the link, and is never recoverable after.
|
||||
async function create({ userId, email, requestedIp, ttlMinutes = DEFAULT_TTL_MINUTES }) {
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000)
|
||||
const id = await db.insert({ tokenHash: hashToken(token), userId, email, requestedIp, expiresAt })
|
||||
return { id, token }
|
||||
}
|
||||
|
||||
// Resolve a pending, unexpired verification from its plaintext token, else null.
|
||||
// Returns the RAW row (incl. user_id and the address it proves).
|
||||
async function findValidByToken(token) {
|
||||
if (!token) return null
|
||||
const row = await db.findByTokenHash(hashToken(token))
|
||||
if (!row || row.status !== 'pending') return null
|
||||
if (new Date(row.expires_at).getTime() < Date.now()) return null
|
||||
return row
|
||||
}
|
||||
|
||||
// Atomically consume a pending verification (double-use-safe). True if this call
|
||||
// won the race.
|
||||
async function consume(id) {
|
||||
return (await db.markUsed(id)) === 1
|
||||
}
|
||||
|
||||
const invalidatePendingForUser = (userId) => db.invalidatePendingForUser(userId)
|
||||
|
||||
// True when this user has already asked for as many verification mails as the
|
||||
// window allows.
|
||||
async function sendQuotaExhausted(userId) {
|
||||
const since = new Date(Date.now() - SEND_WINDOW_MINUTES * 60 * 1000)
|
||||
return (await db.countRecentForUser(userId, since)) >= MAX_SENDS_PER_WINDOW
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
create,
|
||||
findValidByToken,
|
||||
consume,
|
||||
invalidatePendingForUser,
|
||||
sendQuotaExhausted,
|
||||
hashToken,
|
||||
DEFAULT_TTL_MINUTES,
|
||||
MAX_SENDS_PER_WINDOW,
|
||||
SEND_WINDOW_MINUTES,
|
||||
}
|
||||
@@ -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).
|
||||
@@ -55,6 +67,30 @@ function registrationFlags(mode) {
|
||||
}
|
||||
}
|
||||
|
||||
// Engagement Phase 1b — may an UNVERIFIED address receive opt-in engagement mail?
|
||||
// Stored as 'on'/'off'. Seeded by schema.sql ASYMMETRICALLY on purpose: 'on' for a
|
||||
// fresh install, 'off' for an upgrade. Turning it on retroactively would silently
|
||||
// stop mailing every already-opted-in user on the day the operator upgraded, which
|
||||
// is the G22 mistake — a safe default must not be applied backwards to a running
|
||||
// system without telling anyone.
|
||||
//
|
||||
// Nothing CONSUMES this yet: the engine that would honour it arrives in Phase 4
|
||||
// and the deliverability rules in Phase 9. It is seeded and editable here because
|
||||
// the fresh-vs-upgrade distinction is only knowable at the migration that adds it,
|
||||
// and reconstructing "was this install fresh?" later is guesswork.
|
||||
const EMAIL_VERIFICATION_KEY = 'email_verification_required'
|
||||
|
||||
// Fail-safe direction is 'off': an unreadable or missing value must not silently
|
||||
// suppress mail an operator believes is going out. The loud failure mode (mail
|
||||
// reaching an unverified address) is recoverable; the quiet one is not.
|
||||
async function isEmailVerificationRequired() {
|
||||
try {
|
||||
return String(await settingsDb.get(EMAIL_VERIFICATION_KEY)) === 'on'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Android App Links opt-in (M9 follow-up). When on, the shard auto-serves
|
||||
// /.well-known/assetlinks.json and the mobile SSO bridge additionally accepts the
|
||||
// self-origin https://<host>/mobile/callback redirect. Stored as the string
|
||||
@@ -244,6 +280,8 @@ module.exports = {
|
||||
REGISTRATION_KEY,
|
||||
REGISTRATION_MODES,
|
||||
getRegistrationMode,
|
||||
EMAIL_VERIFICATION_KEY,
|
||||
isEmailVerificationRequired,
|
||||
registrationFlags,
|
||||
MOBILE_APP_LINKS_KEY,
|
||||
isMobileAppLinksEnabled,
|
||||
|
||||
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,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user