// ── 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: `. * * 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, }