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
141 lines
5.1 KiB
JavaScript
141 lines
5.1 KiB
JavaScript
const fs = require('fs')
|
|
const path = require('path')
|
|
const mariadb = require('mariadb')
|
|
require('dotenv').config()
|
|
|
|
const log = require('./logger')('db')
|
|
const { splitStatements } = require('./sqlStatements')
|
|
|
|
const pool = mariadb.createPool({
|
|
host: process.env.DB_HOST || '127.0.0.1',
|
|
port: Number(process.env.DB_PORT) || 3306,
|
|
user: process.env.DB_USER || 'root',
|
|
password: process.env.DB_PASSWORD || '',
|
|
database: process.env.DB_NAME || 'runic_gateway',
|
|
connectionLimit: 5,
|
|
// Return plain JS numbers, never BigInt — keeps JSON responses clean.
|
|
insertIdAsNumber: true,
|
|
bigIntAsNumber: true,
|
|
decimalAsNumber: true,
|
|
// The driver defaults to 'local' — silently serializing bound JS Date
|
|
// params using the HOST MACHINE's local offset instead of the DB session's
|
|
// timezone (discovered via the Discord bot's temp_roles.expires_at coming
|
|
// back hours off in dev). 'auto' negotiates the actual session timezone so
|
|
// Date round-trips correctly regardless of host TZ — affects any write of
|
|
// a JS Date param, e.g. botConfig.model.js's last_connected_at.
|
|
timezone: 'auto',
|
|
})
|
|
|
|
/**
|
|
* Run a parameterized query and release the connection.
|
|
* @param {string} sql
|
|
* @param {Array} [params]
|
|
*/
|
|
async function query(sql, params) {
|
|
const conn = await pool.getConnection()
|
|
try {
|
|
return await conn.query(sql, params)
|
|
} finally {
|
|
conn.release()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run `fn(conn)` inside one transaction on one connection: committed when it
|
|
* resolves, rolled back when it throws (and the error rethrown).
|
|
*
|
|
* Every statement that must stand or fall together goes through the `conn` it is
|
|
* handed, never through `query()` above, which takes a different connection from
|
|
* the pool and so sits outside the transaction.
|
|
*
|
|
* @template T
|
|
* @param {(conn: import('mariadb').PoolConnection) => Promise<T>} fn
|
|
* @returns {Promise<T>}
|
|
*/
|
|
async function transaction(fn) {
|
|
const conn = await pool.getConnection()
|
|
try {
|
|
await conn.beginTransaction()
|
|
const out = await fn(conn)
|
|
await conn.commit()
|
|
return out
|
|
} catch (err) {
|
|
try {
|
|
await conn.rollback()
|
|
} catch (rollbackErr) {
|
|
log.error('rollback failed', { message: rollbackErr.message })
|
|
}
|
|
throw err
|
|
} finally {
|
|
conn.release()
|
|
}
|
|
}
|
|
|
|
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'db', 'schema.sql')
|
|
|
|
/**
|
|
* Create tables if they do not exist. Idempotent. Retries while the DB is still
|
|
* coming up (important under docker-compose even with a healthcheck).
|
|
*
|
|
* Once core's schema is in place, every installed module's schema fragment is
|
|
* replayed after it (MODULE_API.md §2.6). That step is deliberately OUTSIDE the
|
|
* retry loop: a fragment that throws is that module's failure, not a signal the
|
|
* database is still coming up, and retrying core's whole schema nine more times
|
|
* because one module shipped bad SQL would turn a 503'd module into a two-minute
|
|
* boot. It is also why this file knows nothing about modules beyond the one call
|
|
* below — the discovery, splitting and per-module failure handling all live in
|
|
* modules/schema.js, required lazily so that requiring the pool never drags the
|
|
* loader in with it.
|
|
*
|
|
* `replayModules: false` is for a caller that has not scanned the volume YET and
|
|
* intends to. server.js is the one: since slice 3 it resolves the declared
|
|
* module set before requiring app.js, which puts core's schema *before* the scan
|
|
* — so it replays the fragments itself, in the one place that knows the scan has
|
|
* happened. Left true everywhere else, so the ordinary caller cannot get module
|
|
* tables by accident and lose them by refactor.
|
|
*/
|
|
async function ensureSchema({ retries = 10, delayMs = 2000, replayModules = true } = {}) {
|
|
await ensureCoreSchema({ retries, delayMs })
|
|
// eslint-disable-next-line global-require
|
|
if (replayModules) await require('../modules/schema').replayFragments()
|
|
}
|
|
|
|
/** Core's own schema.sql, with the wait-for-the-database retry. */
|
|
async function ensureCoreSchema({ retries, delayMs }) {
|
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
try {
|
|
const conn = await pool.getConnection()
|
|
try {
|
|
const sql = fs.readFileSync(SCHEMA_PATH, 'utf8')
|
|
for (const statement of splitStatements(sql)) {
|
|
await conn.query(statement)
|
|
}
|
|
log.info('schema ensured')
|
|
return
|
|
} finally {
|
|
conn.release()
|
|
}
|
|
} catch (err) {
|
|
if (attempt === retries) throw err
|
|
log.warn(`database not ready, retrying (attempt ${attempt}/${retries})`, {
|
|
code: err.code || err.message,
|
|
})
|
|
await new Promise((r) => setTimeout(r, delayMs))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Idempotent: `pool.end()` throws "pool is already closed" on a second call, and
|
|
// closing twice is normal rather than exceptional — a SIGINT followed by a
|
|
// SIGTERM reaches the shutdown handler twice, and the test harness closes the
|
|
// pool for every file on top of the suites that close it themselves. A teardown
|
|
// that fails because it had already succeeded is noise.
|
|
let closed = false
|
|
async function close() {
|
|
if (closed) return
|
|
closed = true
|
|
await pool.end()
|
|
}
|
|
|
|
module.exports = { pool, query, transaction, ensureSchema, close }
|