Phase 5's server half — TEAMS.md §5.1's "5b". The schema for all of it landed in
phase 4, so this adds 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.
* `teams_forum_edit_window_minutes` (0…1440, default 15) joins the forum's
settings. It fails closed to ZERO rather than to its default, which 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 quoting it or a moderator
about to act on a report, so the safe answer during a DB fault is "nobody may
edit for the next minute". A stale uploads acknowledgement freezes this key
too — it is a forum setting.
* Thread creation splits its authority BY TYPE, which is what phase 4's comment
said would happen here rather than widening the leader gate. An announcement
stays leader-authored; a discussion is open to every participant, and
"participant" includes a granted non-member with no game identity — path 3
doing its job. `type` still defaults to `announcement`, so a phase-4 client
keeps meaning what it meant.
* Replies refuse three ways with deliberately different codes: 404 for absent or
hidden, 400 for an announcement (which takes no replies by TYPE, not by being
closed), and 409 for locked — well-formed request, refusing state. Locked
refuses staff too; they hold `unlock`, and unlock/post/relock reaches the same
place leaving three ledger rows that say so.
* The edit window is evaluated on the server twice, on purpose. The read path
stamps every post with `canEdit`/`editableUntil` so the client knows whether to
draw the control; the write re-derives it from `created_at` before allowing
anything. A time-bounded permission must not take its clock from the party it
bounds. Staff are not time-bounded, and a staff edit of someone else's words
writes `activity_log` while a member fixing their own typo does not (§5.3).
* Post moderation shares the thread ledger via `target_type='post'`, so
"everything moderated in this Team" stays one query. `pin`/`lock` are refused
by name rather than as unknown actions — they describe a thread's place in a
list and its openness to replies, neither of which a post has. Counters are
RECOMPUTED after each action rather than nudged, because hide → unhide → hide
is a cycle a delta gets wrong the first time a step is retried.
Two fixes to phase 4 code this work reached: `softDeleteUploadsForPost` bound its
two arguments in the wrong order (never fired — nothing called it until post
deletion did), and it had no inverse, so `delete` → `restore` would have returned
a post's words and silently lost its pictures a retention window later.
Co-Authored-By: Claude <noreply@anthropic.com>
198 lines
7.9 KiB
JavaScript
198 lines
7.9 KiB
JavaScript
// ── 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,
|
|
}
|