ACCOUNT_DELETION.md phase 2 (core): a member deletes their own account on
the website, and the admin route runs the same procedure.
- DELETE /api/v1/auth/me/account: typed username, password always (AD7),
and a 2FA or recovery code when 2FA is on. A wrong password or code is
400, not 401 (AD12), so the app's refresh-on-401 never replays it.
409 password_required and last_admin; the demo's shared logins are
locked.
- auth/accountDeletion.js: module hooks first, then one transaction that
reserves a staff username (reserved_usernames, AD9) and deletes the row,
then a Teams reconcile, the audit entry (no username) and a notice to
the old address (new protected template auth.account-deleted).
- MODULE_API 1.20.0: api.onUserDeleting({ userId, deletedBy }), before the
delete, 10 s limit, cannot veto (AD10, AD11).
- utils/displayAuthor.js: a deleted account's username snapshots read
"[deleted player]" to members and the stored name, marked, to staff
(AD5, AD6). Used by the forum, the Reports queue, the guest list and
the Team digest. A deleted account drops off Forum guests (AD13).
- Reserved names refuse at every username write, answered as "taken".
- Web: Delete my account on the player and staff Account pages; staff
views mark "(deleted account)".
- OpenAPI regenerated; route manifest and engagement manifest regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
251 lines
11 KiB
JavaScript
251 lines
11 KiB
JavaScript
// ── The demo lock (docs/website/DEMO_MODE.md §3) ───────────────────────────
|
|
//
|
|
// With DEMO_MODE on, app.js mounts four middlewares from this file, and with it
|
|
// off none of them is mounted at all, so a real shard runs none of this code.
|
|
//
|
|
// • `refuseMultipart` — any request with a multipart body, anywhere, before a
|
|
// router sees it (§3.3 "Uploads — locked hard"). An upload route added later
|
|
// is locked without anyone remembering to list it, and a module cannot open one.
|
|
// • `lock` — each write request against core's list below plus every module's
|
|
// declared `demoLocks`, at `/api/v1`, ahead of every router.
|
|
// • `refuseDataUri` — any write whose JSON body carries a data: URI in any
|
|
// string (DM25): a pasted image is an upload that did not use multipart.
|
|
// • `noindex` + `robots` — nothing a visitor writes reaches a search engine.
|
|
//
|
|
// **The server is the lock (DM10).** Everything here is decided from the request
|
|
// alone, so `curl`, an old app build and a hand-edited page are refused exactly
|
|
// like a click. The client explains a refusal; it never enforces one.
|
|
//
|
|
// A refusal is always the same answer, so the web client and the app can each
|
|
// recognise it by `code` and show one sentence for it.
|
|
|
|
const { compile, match } = require('./paths')
|
|
const { isDemoUsername } = require('./mode')
|
|
|
|
const log = require('../utils/logger')('demo')
|
|
|
|
const CODE = 'DEMO_LOCKED'
|
|
const MESSAGE = 'This is turned off on the demo site.'
|
|
const WRITES = ['POST', 'PUT', 'PATCH', 'DELETE']
|
|
|
|
function refuse(res, extra = {}) {
|
|
return res.status(403).json({ message: MESSAGE, code: CODE, ...extra })
|
|
}
|
|
|
|
// ── Conditions ─────────────────────────────────────────────────────────────
|
|
// A few rows lock a route only for some requests. Each condition is asked only
|
|
// after the method and path have matched, so the DB is touched on a handful of
|
|
// rare admin and self-service writes and on nothing else.
|
|
|
|
// Required lazily: this file is required by app.js, and the users model would
|
|
// otherwise make the DB pool a startup dependency of the lock.
|
|
/* eslint-disable global-require */
|
|
const users = () => require('../model/users/users.model')
|
|
const sessions = () => require('../auth/session.service')
|
|
/* eslint-enable global-require */
|
|
|
|
async function isDemoUserId(id) {
|
|
const n = Number(id)
|
|
if (!Number.isInteger(n) || n < 1) return false
|
|
const user = await users().getById(n)
|
|
return Boolean(user && isDemoUsername(user.username))
|
|
}
|
|
|
|
// `PUT /admin/users/:id` and its kin: an admin acting on one of the shared logins.
|
|
const targetIsDemo = (req, params) => isDemoUserId(params.id)
|
|
|
|
// `/auth/me/*`: the caller signed in as one of the shared logins. The identity is
|
|
// read the way requireAuth reads it, and the account row decides — not the
|
|
// username claim in the token. No session is not a refusal: the route's own
|
|
// requireAuth answers that with its usual 401.
|
|
async function callerIsDemo(req) {
|
|
const session = sessions().validateSession(req)
|
|
return Boolean(session && (await isDemoUserId(session.userId)))
|
|
}
|
|
|
|
// `PUT /admin/settings` stays open (DM2), but that one route writes arbitrary
|
|
// keys, and a few keys are another door into something Tier A locks: `site_mode`
|
|
// is what `PUT /admin/site-mode` writes, `module_source_hosts` what the Modules
|
|
// screen's sources do, `teams_voice_*` the voice settings. `demo_*` is the
|
|
// banner and the published logins, which the seed owns. `hero_layout` is the
|
|
// live homepage hero (DM21): the editor's draft (`hero_layout_draft`) and its
|
|
// Preview stay open, its Publish is refused.
|
|
const PROTECTED_SETTINGS = [/^site_mode/, /^module_source_hosts$/, /^teams_voice_/, /^demo_/, /^hero_layout$/]
|
|
|
|
function protectedKeys(body) {
|
|
if (!body || typeof body !== 'object' || Array.isArray(body)) return []
|
|
return Object.keys(body).filter((key) => PROTECTED_SETTINGS.some((re) => re.test(key)))
|
|
}
|
|
|
|
// ── Core's list (§3.3) ─────────────────────────────────────────────────────
|
|
// Paths are relative to /api/v1 and written as the routers declare them;
|
|
// test/demoLocks.test.js holds every row against routes.manifest.json, so a
|
|
// row that stops naming a real route fails the build rather than reading as a lock.
|
|
|
|
const CORE_LOCKS = [
|
|
// Tier A — site configuration.
|
|
{ methods: ['PUT'], path: '/admin/site-mode' },
|
|
{ methods: WRITES, path: '/admin/modules/*' },
|
|
{ methods: WRITES, path: '/admin/auth/providers/*' },
|
|
{ methods: ['PUT'], path: '/admin/email/config' },
|
|
{ methods: ['POST'], path: '/admin/email/test' },
|
|
{ methods: ['POST'], path: '/admin/email/disconnect' },
|
|
{ methods: ['PUT'], path: '/admin/discord-bot/config' },
|
|
{ methods: WRITES, path: '/admin/teams/voice/*' },
|
|
{ methods: ['PUT'], path: '/admin/settings', when: (req) => protectedKeys(req.body).length > 0 },
|
|
|
|
// Tier A — the shared logins, by an admin.
|
|
{ methods: ['PUT', 'DELETE'], path: '/admin/users/:id', when: targetIsDemo },
|
|
{ methods: ['DELETE'], path: '/admin/users/:id/trusted-devices', when: targetIsDemo },
|
|
{ methods: ['DELETE'], path: '/admin/users/:id/trusted-devices/:deviceId', when: targetIsDemo },
|
|
{ methods: ['POST'], path: '/admin/users/:id/mfa/reset', when: targetIsDemo },
|
|
|
|
// Tier A — the shared logins, by whoever is signed in as one.
|
|
{ methods: ['PATCH'], path: '/auth/me/account/username', when: callerIsDemo },
|
|
{ methods: ['PATCH'], path: '/auth/me/account/password', when: callerIsDemo },
|
|
{ methods: ['PATCH'], path: '/auth/me/account/email', when: callerIsDemo },
|
|
{ methods: ['POST'], path: '/auth/me/account/totp/setup', when: callerIsDemo },
|
|
{ methods: ['POST'], path: '/auth/me/account/totp/enable', when: callerIsDemo },
|
|
{ methods: ['POST'], path: '/auth/me/account/totp/disable', when: callerIsDemo },
|
|
{ methods: ['POST'], path: '/auth/me/account/recovery-codes/generate', when: callerIsDemo },
|
|
{ methods: ['DELETE'], path: '/auth/me/account/identities/:provider', when: callerIsDemo },
|
|
// ACCOUNT_DELETION.md §4.3: the screen stays visible on the demo, because
|
|
// Play's reviewer must be able to find it, and the shared logins cannot be
|
|
// deleted. An account an admin created on the demo can be.
|
|
{ methods: ['DELETE'], path: '/auth/me/account', when: callerIsDemo },
|
|
// DM17: the whole route, because a mobile access token carries no refresh-row
|
|
// id and "a session that is not the caller's" cannot be decided. Signing out
|
|
// (POST /auth/mobile/logout) revokes the caller's own row and stays open.
|
|
{ methods: ['DELETE'], path: '/auth/me/sessions/:id', when: callerIsDemo },
|
|
|
|
// Tier A — credentials for everyone.
|
|
{ methods: ['POST'], path: '/auth/password/forgot' },
|
|
{ methods: ['POST'], path: '/auth/password/reset/:token' },
|
|
// DM16: settings are open, so `player_registration` can be switched on; the
|
|
// route is what keeps registration closed (§4).
|
|
{ methods: ['POST'], path: '/auth/register' },
|
|
|
|
// Tier C's one exception: the contact form mails a person.
|
|
{ methods: ['POST'], path: '/public/contact' },
|
|
|
|
// Uploads. `refuseMultipart` already stops every one of these; they are
|
|
// listed too so the table in §3.3 and this file say the same thing, and so a
|
|
// non-multipart body sent to an upload route is refused the same way.
|
|
{ methods: ['POST'], path: '/admin/uploads' },
|
|
{ methods: ['POST'], path: '/admin/posts/upload' },
|
|
{ methods: ['POST'], path: '/admin/settings/brand-asset/:slot' },
|
|
{ methods: ['POST'], path: '/player/teams/:slug/forum/uploads' },
|
|
].map((rule) => ({ ...rule, source: 'core', compiled: compile(rule.path) }))
|
|
|
|
/**
|
|
* Build the lock middleware.
|
|
*
|
|
* @param {() => object[]} moduleLocks returns the installed modules' compiled
|
|
* locks — asked per request, because the loader runs after app.js mounts this.
|
|
*/
|
|
function createLock(moduleLocks = () => []) {
|
|
return async function lock(req, res, next) {
|
|
if (!WRITES.includes(req.method)) return next()
|
|
const rules = CORE_LOCKS.concat(moduleLocks())
|
|
for (const rule of rules) {
|
|
if (!rule.methods.includes(req.method)) continue
|
|
const params = match(rule.compiled, req.path)
|
|
if (!params) continue
|
|
if (rule.when) {
|
|
let applies
|
|
try {
|
|
applies = await rule.when(req, params)
|
|
} catch (err) {
|
|
// Fail closed: a lock that cannot decide is a lock.
|
|
log.warn('lock condition failed; refusing', { path: rule.path, message: err.message })
|
|
applies = true
|
|
}
|
|
if (!applies) continue
|
|
}
|
|
log.info('refused', { method: req.method, path: req.originalUrl, rule: rule.path, source: rule.source })
|
|
const keys = rule.path === '/admin/settings' ? { keys: protectedKeys(req.body) } : {}
|
|
return refuse(res, keys)
|
|
}
|
|
return next()
|
|
}
|
|
}
|
|
|
|
// Any multipart content type, not only form-data: nothing on the demo takes one,
|
|
// and a narrower test is one more spelling to get wrong. The header alone
|
|
// decides, so a multipart request with no body is refused too.
|
|
function refuseMultipart(req, res, next) {
|
|
const type = String(req.headers['content-type'] || '').trim().toLowerCase()
|
|
if (!type.startsWith('multipart/')) return next()
|
|
log.info('refused upload', { method: req.method, path: req.originalUrl })
|
|
return refuse(res)
|
|
}
|
|
|
|
// DM25: an image pasted into rich text travels inside the JSON body as a data:
|
|
// URI, which is an upload by another name. Any string anywhere in a write's
|
|
// body is checked, so a field added later, a setting or a module's route is
|
|
// covered without being listed, as with multipart.
|
|
//
|
|
// A data: URI is `data:` (not the tail of a word like "metadata:") followed by
|
|
// a comma, a `;`, or a type/subtype and then a comma. Prose such as
|
|
// "Data: none, yet" does not match. Before testing, HTML character references
|
|
// are decoded and tabs and newlines removed, because a browser does both when
|
|
// it reads an attribute: `data:` and `da<newline>ta:` are still data: URIs.
|
|
const DATA_URI = /(?<![a-z0-9+.-])data:\s*(?:[,;]|[a-z]+\/[a-z0-9.+-]+[^,<>"']{0,256},)/i
|
|
|
|
function browserReading(s) {
|
|
return s
|
|
.replace(/&#x([0-9a-f]+);?/gi, (m, hex) => safeChar(parseInt(hex, 16), m))
|
|
.replace(/&#(\d+);?/g, (m, dec) => safeChar(parseInt(dec, 10), m))
|
|
.replace(/:/gi, ':')
|
|
.replace(/&(?:tab|newline);/gi, '')
|
|
.replace(/[\t\n\r]/g, '')
|
|
}
|
|
|
|
function safeChar(code, original) {
|
|
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : original
|
|
}
|
|
|
|
function hasDataUri(body) {
|
|
const stack = [body]
|
|
while (stack.length) {
|
|
const value = stack.pop()
|
|
if (typeof value === 'string') {
|
|
if (value.length > 5 && DATA_URI.test(browserReading(value))) return true
|
|
} else if (value && typeof value === 'object') {
|
|
for (const v of Object.values(value)) stack.push(v)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
function refuseDataUri(req, res, next) {
|
|
if (!WRITES.includes(req.method) || !hasDataUri(req.body)) return next()
|
|
log.info('refused data: URI', { method: req.method, path: req.originalUrl })
|
|
return refuse(res)
|
|
}
|
|
|
|
function noindex(req, res, next) {
|
|
res.set('X-Robots-Tag', 'noindex, nofollow')
|
|
next()
|
|
}
|
|
|
|
function robots(req, res) {
|
|
res.type('text/plain').send('User-agent: *\nDisallow: /\n')
|
|
}
|
|
|
|
module.exports = {
|
|
CODE,
|
|
MESSAGE,
|
|
WRITES,
|
|
CORE_LOCKS,
|
|
PROTECTED_SETTINGS,
|
|
createLock,
|
|
refuseMultipart,
|
|
refuseDataUri,
|
|
hasDataUri,
|
|
noindex,
|
|
robots,
|
|
refuse,
|
|
protectedKeys,
|
|
}
|