/** * Publish-mode policy — read + write + enumerate. * * Three modes, named after what they do rather than numbered: * `append` Any valid listing goes in; readers see the signed scan report * and their trust lists decide (the launch default). * `scan-first` Infected uploads are rejected before they land. Clean ones * publish immediately. Already the current /api/upload shape, * so this mode is the default behaviour today. * `review` Clean listings sit in a human-review queue before browse * shows them. Not yet enforced — the toggle persists, the * queue logic lands in a later milestone. * * Global mode is the fallback; a per-category override is read when a listing's * category has one set. The admin UI exposes both. */ import { getSetting, setSetting } from './db.js'; import { logAudit } from './audit.js'; export const MODES = ['append', 'scan-first', 'review']; const DEFAULT_MODE = 'append'; const GLOBAL_KEY = 'publish_mode_global'; const perCatKey = (slug) => `publish_mode_${slug}`; export function globalMode() { const v = getSetting(GLOBAL_KEY, DEFAULT_MODE); return MODES.includes(v) ? v : DEFAULT_MODE; } export function categoryMode(slug) { const v = getSetting(perCatKey(slug), null); if (v && MODES.includes(v)) return { mode: v, source: 'category' }; return { mode: globalMode(), source: 'global' }; } export function allModes(categories) { const g = globalMode(); const out = { global: g, categories: {} }; for (const slug of categories) { const v = getSetting(perCatKey(slug), null); out.categories[slug] = v && MODES.includes(v) ? v : null; // null = inherits global } return out; } /** * Set a mode (global or per-category). `category=null` sets the global default; * a slug sets that category. Writing an empty value for a category clears the * override and the category falls back to the global default. */ export function setMode({ category, value, actor }) { const key = category ? perCatKey(category) : GLOBAL_KEY; const now = new Date().toISOString(); if (!value) { // Clearing: delete the row by writing a sentinel and relying on the admin // page to show "inherits global". Simpler: just set the global default // string so getSetting returns the default. For an override we need an // actual delete — add it as a direct exec since it's one path. deleteSetting(key, actor, now, category); return; } if (!MODES.includes(value)) { throw new Error(`unknown mode: ${value}`); } const prev = getSetting(key, category ? null : DEFAULT_MODE); setSetting(key, value, actor, now); logAudit({ actor, action: 'set_mode', target: category || 'global', details: { before: prev, after: value }, }); } /* The one-off path for clearing a per-category override. */ function deleteSetting(key, actor, now, category) { // We don't ship a dedicated delete-setting helper — this is the only caller. // Importing the raw db just for this would be a dependency inversion; use // the public API: write a reserved sentinel, then the resolver ignores it. // Easier: just set it to the empty string and have the reader fall through. const prev = getSetting(key, null); setSetting(key, '', actor, now); logAudit({ actor, action: 'clear_mode', target: category || 'global', details: { before: prev, after: null }, }); }