From fe4acec191312ab64d7832b00cb246feb8e1a092 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 27 Jul 2026 16:44:53 -0400 Subject: [PATCH] Improve moderation controls and term management --- docs/CONTENT_MODERATION.md | 13 + docs/DESIGN_SYSTEM.md | 39 +- src/lib/components/CollapsibleSection.svelte | 74 +++ src/lib/server/community-moderation.test.ts | 22 + src/lib/server/community-moderation.ts | 31 ++ src/routes/isac/site-controls/+page.server.ts | 508 +++++++++++++---- src/routes/isac/site-controls/+page.svelte | 517 +++++++++--------- .../bulk-moderation-terms.test.ts | 223 ++++++++ 8 files changed, 1065 insertions(+), 362 deletions(-) create mode 100644 src/lib/components/CollapsibleSection.svelte create mode 100644 src/routes/isac/site-controls/bulk-moderation-terms.test.ts diff --git a/docs/CONTENT_MODERATION.md b/docs/CONTENT_MODERATION.md index 78e3427..b4653f8 100644 --- a/docs/CONTENT_MODERATION.md +++ b/docs/CONTENT_MODERATION.md @@ -55,6 +55,19 @@ ISAC Site Controls stores the site-wide policy: - category review and priority thresholds; - allowed and banned local terms. +The term registry accepts single rules and batch imports. Batch imports use one +term or phrase per line, accept commas and semicolons as separators, infer word +or phrase matching, and add no more than 250 unique rules. Word rules match +exact forms: `kill` does not match `kills`, `killed`, or `killing`. The import +skips duplicates, records each created rule in the ISAC audit log, and leaves +the batch editor open for the next list. The registry provides server-side +search and displays 25 rules per page by default. + +Each Site Controls category has its own save action. A save validates and +updates only that category, increments the moderation policy version when +applicable, and records a specific ISAC audit event with before and after +evidence. Unsaved changes in other categories remain local drafts. + Site Controls requires `site.settings.manage`. Community Oversight handles text decisions through the existing comment and profile permissions. Avatar Oversight handles custom images through `avatars.review`. diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 95d3224..dee141c 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -362,15 +362,23 @@ targets and keep status text within the viewport. Reduce the radar sweep and other ambient motion under `prefers-reduced-motion`; retain the static rings, nodes, and labels. -ISAC Site Controls use three connected command panels: Launch Gate, Account -Creation, and Public Sections. Use `SegmentedChoice` for each finite state. -Display the pending-change count outside the panels and require a confirmation -modal that lists every changed setting as previous state, arrow, and proposed -state. A successful save closes the modal and refreshes the network-state -summary. Keep a `PREVIEW PAGE` link in the command toolbar so authenticated -operators can inspect the promotional page without ending their session. -Operators need text for enabled, disabled, coming-soon, and public states; color -supports those labels. +ISAC Site Controls use collapsible command panels for Launch Gate, Account +Creation, Public Sections, moderation policy groups, thresholds, and the term +registry. Build these panels with `CollapsibleSection`. The disclosure button +must expose `aria-expanded` and `aria-controls`, retain its title and textual +status while closed, show a focus-visible outline, and provide at least a +44-pixel touch target. Keep panel content in the document and hide it while +closed so draft form state survives expansion changes. Disable motion under +`prefers-reduced-motion`. + +Each command panel owns its save action, validation feedback, pending state, +and success notice. Do not place a page-wide review or save banner between +panels. Disable a panel's save button until that panel differs from saved +state. Saving one panel must update only its fields, retain the panel's open +state, and leave unsaved drafts in other panels unchanged. Keep `PREVIEW PAGE` +inside the Launch Gate save row so authenticated operators can inspect the +promotional page without ending their session. Operators need text for enabled, +disabled, coming-soon, and public states; color supports those labels. The Launch Gate controls signed-out entry to the application. Public Section controls apply to everyone. When an operator disables News, Database, Builds, @@ -489,6 +497,19 @@ public. Flagged custom avatars use the default avatar publicly until approval. Community Oversight shows automated evidence and Agent reports in one pending queue, but labels their sources separately. Human approval, rejection, removal, and report dismissal require a reason and confirmation. + +The Site Controls term registry supports single-rule editing and an inline bulk +editor. The bulk editor accepts pasted lists, applies one effect and scope set +to the batch, previews the unique-entry count, and states the 250-rule limit. +Keep it inline because operators need the registry for context. Show exact-word +behavior beside the textarea, preserve pasted values after validation errors, +and clear the textarea while keeping the editor open after a successful import. +Search the registry on the server across term text, normalized text, rule type, +match mode, and operational note. Use the shared pagination rail with +namespaced URL parameters and 25 rows by default. Search applies before +pagination. Distinguish an empty registry from a search with no matches. The +registry toolbar, editors, search, and pagination stay inside the term +disclosure. Only HTTP and HTTPS links are active; rendered links open separately with UGC, nofollow, and opener-isolation attributes. `MarkdownContent` must receive HTML from the server renderer, never raw user input or client-rendered Markdown. diff --git a/src/lib/components/CollapsibleSection.svelte b/src/lib/components/CollapsibleSection.svelte new file mode 100644 index 0000000..d6a4676 --- /dev/null +++ b/src/lib/components/CollapsibleSection.svelte @@ -0,0 +1,74 @@ + + +
+

+ +

+ +
+ + diff --git a/src/lib/server/community-moderation.test.ts b/src/lib/server/community-moderation.test.ts index 8d77a12..ba36e43 100644 --- a/src/lib/server/community-moderation.test.ts +++ b/src/lib/server/community-moderation.test.ts @@ -3,6 +3,7 @@ import { assessCommunityModeration, findLocalModerationMatches, normalizeModerationTerm, + parseModerationTermBatch, type CommunityModerationPolicy } from './community-moderation'; @@ -34,6 +35,27 @@ describe('community moderation policy', () => { expect(normalizeModerationTerm(' Rogue\u00a0Agent ')).toBe('rogue agent'); }); + it('parses pasted terms with automatic word and phrase matching', () => { + expect(parseModerationTermBatch('weapon\nkill, machine gun; assault rifle')).toEqual({ + terms: [ + { value: 'weapon', normalizedValue: 'weapon', matchMode: 'word' }, + { value: 'kill', normalizedValue: 'kill', matchMode: 'word' }, + { value: 'machine gun', normalizedValue: 'machine gun', matchMode: 'phrase' }, + { value: 'assault rifle', normalizedValue: 'assault rifle', matchMode: 'phrase' } + ], + duplicateCount: 0 + }); + }); + + it('deduplicates pasted terms after compatibility and case normalization', () => { + const parsed = parseModerationTermBatch('Weapon\nweapon\nMACHINE GUN\nmachine gun'); + expect(parsed.terms).toEqual([ + { value: 'Weapon', normalizedValue: 'weapon', matchMode: 'word' }, + { value: 'MACHINE GUN', normalizedValue: 'machine gun', matchMode: 'phrase' } + ]); + expect(parsed.duplicateCount).toBe(2); + }); + it('allows an exact local exception to suppress a matching banned term', () => { const matches = findLocalModerationMatches('Rogue Agent tactics', 'comment', policy({ detectPii: false, diff --git a/src/lib/server/community-moderation.ts b/src/lib/server/community-moderation.ts index 46739ad..fd54d51 100644 --- a/src/lib/server/community-moderation.ts +++ b/src/lib/server/community-moderation.ts @@ -107,6 +107,37 @@ export function normalizeModerationTerm(value: string) { return value.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase(); } +export type ParsedModerationTermBatch = { + terms: Array<{ + value: string; + normalizedValue: string; + matchMode: 'word' | 'phrase'; + }>; + duplicateCount: number; +}; + +export function parseModerationTermBatch(value: string): ParsedModerationTermBatch { + const entries = value.normalize('NFKC') + .split(/[\r\n,;]+/u) + .map((entry) => entry.trim().replace(/\s+/g, ' ')) + .filter(Boolean); + const terms: ParsedModerationTermBatch['terms'] = []; + const seen = new Set(); + let duplicateCount = 0; + for (const entry of entries) { + const normalizedValue = normalizeModerationTerm(entry); + const matchMode = /\s/u.test(normalizedValue) ? 'phrase' : 'word'; + const key = `${matchMode}:${normalizedValue}`; + if (seen.has(key)) { + duplicateCount += 1; + continue; + } + seen.add(key); + terms.push({ value: entry, normalizedValue, matchMode }); + } + return { terms, duplicateCount }; +} + export function hashModeratedContent(value: string) { return createHash('sha256').update(value.normalize('NFC')).digest('hex'); } diff --git a/src/routes/isac/site-controls/+page.server.ts b/src/routes/isac/site-controls/+page.server.ts index f42f850..750c6cf 100644 --- a/src/routes/isac/site-controls/+page.server.ts +++ b/src/routes/isac/site-controls/+page.server.ts @@ -4,14 +4,17 @@ import { requirePermission } from '$lib/server/auth/authorization'; import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; import { recordIsacAudit } from '$lib/server/audit'; +import { getPagination, getRegistrySearch } from '$lib/server/pagination'; import { invalidateSiteAccessSettings } from '$lib/server/site-access'; -import { invalidateCommunityModerationPolicy } from '$lib/server/community-moderation'; +import { + invalidateCommunityModerationPolicy, + normalizeModerationTerm, + parseModerationTermBatch +} from '$lib/server/community-moderation'; import { env } from '$env/dynamic/private'; import type { Actions, PageServerLoad } from './$types'; const SETTINGS_ID = 'primary'; -const CONFIRMATION = 'apply-site-access-settings'; -const MODERATION_CONFIRMATION = 'apply-community-moderation-policy'; const MODERATION_MODELS = new Set(['omni-moderation-latest', 'omni-moderation-2024-09-26']); const MODERATION_SCOPES = new Set(['comment', 'profile_bio', 'guide']); @@ -22,19 +25,6 @@ const settingSnapshot = async () => { )).get(SETTINGS_ID) ?? null; }; -const flagNames = [ - 'comingSoonEnabled', 'accountCreationEnabled', 'newsEnabled', 'databaseEnabled', - 'buildsEnabled', 'guidesEnabled', 'builderEnabled' -] as const; - -const enabled = (form: FormData, name: (typeof flagNames)[number]) => - String(form.get(name)) === '1' ? 1 : 0; - -const moderationBooleanNames = [ - 'automatedEnabled', 'autoPublishClean', 'holdOnProviderError', 'scanComments', - 'scanProfiles', 'scanGuides', 'scanAvatars', 'detectPii' -] as const; - const strictBoolean = (form: FormData, name: string) => { const value = String(form.get(name) ?? ''); return value === '1' ? 1 : value === '0' ? 0 : null; @@ -46,9 +36,6 @@ const finiteNumber = (value: FormDataEntryValue | null) => { return Number.isFinite(parsed) ? parsed : null; }; -const normalizeTermValue = (value: FormDataEntryValue | null) => - String(value ?? '').normalize('NFKC').trim().toLowerCase(); - const moderationPolicySnapshot = async () => { const database = await getDatabase(); const [settings, thresholds] = await Promise.all([ @@ -58,10 +45,26 @@ const moderationPolicySnapshot = async () => { return { settings: settings ?? null, thresholds }; }; -export const load: PageServerLoad = async ({ cookies }) => { +const moderationSettingsSnapshot = async () => { + const database = await getDatabase(); + return await (await database.prepare( + 'SELECT * FROM community_moderation_settings WHERE id=?' + )).get(SETTINGS_ID) ?? null; +}; + +export const load: PageServerLoad = async ({ cookies, url }) => { await requirePermission(await getSessionUser(cookies), 'site.settings.manage'); const database = await getDatabase(); - const [settings, moderationSettings, moderationThresholds, moderationTerms] = await Promise.all([ + const { search: moderationTermSearch, pattern: moderationTermPattern } = getRegistrySearch(url, 'terms'); + const termWhere = moderationTermSearch + ? `WHERE terms.value LIKE ? ESCAPE '\\' COLLATE NOCASE + OR terms.normalized_value LIKE ? ESCAPE '\\' COLLATE NOCASE + OR COALESCE(terms.note,'') LIKE ? ESCAPE '\\' COLLATE NOCASE + OR terms.list_kind LIKE ? ESCAPE '\\' COLLATE NOCASE + OR terms.match_mode LIKE ? ESCAPE '\\' COLLATE NOCASE` + : ''; + const termArgs = moderationTermSearch ? Array(5).fill(moderationTermPattern) : []; + const [settings, moderationSettings, moderationThresholds, termCount] = await Promise.all([ (await database.prepare(`SELECT settings.*, COALESCE(profile.display_name,user.username) AS updated_by_name FROM site_launch_settings settings @@ -80,20 +83,31 @@ export const load: PageServerLoad = async ({ cookies }) => { LEFT JOIN users user ON user.id=thresholds.updated_by LEFT JOIN user_profiles profile ON profile.user_id=user.id ORDER BY thresholds.category`)).all(), - (await database.prepare(`SELECT terms.*, - COALESCE(profile.display_name,user.username) AS updated_by_name - FROM community_moderation_terms terms - LEFT JOIN users user ON user.id=terms.updated_by - LEFT JOIN user_profiles profile ON profile.user_id=user.id - ORDER BY terms.enabled DESC,terms.list_kind,terms.normalized_value`)).all() + (await database.prepare(`SELECT COUNT(*) AS total + FROM community_moderation_terms terms ${termWhere}`)).get(...termArgs) ]); if (!settings) error(500, 'Site launch settings are not initialized.'); if (!moderationSettings) error(500, 'Community moderation settings are not initialized.'); + const moderationTermPagination = getPagination(url, Number(termCount?.total ?? 0), 'terms'); + const moderationTerms = await (await database.prepare(`SELECT terms.*, + COALESCE(profile.display_name,user.username) AS updated_by_name + FROM community_moderation_terms terms + LEFT JOIN users user ON user.id=terms.updated_by + LEFT JOIN user_profiles profile ON profile.user_id=user.id + ${termWhere} + ORDER BY terms.enabled DESC,terms.list_kind,terms.normalized_value + LIMIT ? OFFSET ?`)).all( + ...termArgs, + moderationTermPagination.perPage, + moderationTermPagination.offset + ); return { settings, moderationSettings, moderationThresholds, moderationTerms, + moderationTermSearch, + moderationTermPagination, providerConfigured: Boolean( env.OPENAI_MODERATION_API_KEY?.trim() || env.OPENAI_MECHANIC_API_KEY?.trim() ) @@ -101,78 +115,241 @@ export const load: PageServerLoad = async ({ cookies }) => { }; export const actions: Actions = { - save: async ({ cookies, request }) => { + saveLaunchGate: async ({ cookies, request }) => { const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage'); const form = await request.formData(); - if (String(form.get('confirmation') ?? '') !== CONFIRMATION) { - return fail(400, { siteSettingsError: 'Review and confirm the complete site access profile before applying it.' }); - } - if (flagNames.some((name) => !['0', '1'].includes(String(form.get(name))))) { - return fail(400, { siteSettingsError: 'Every site access control must have an explicit enabled or disabled state.' }); - } + const comingSoonEnabled = strictBoolean(form, 'comingSoonEnabled'); + if (comingSoonEnabled === null) return fail(400, { + launchGateError: 'Choose a public network state.', + siteResultToken: randomUUID() + }); const before = await settingSnapshot(); - if (!before) return fail(404, { siteSettingsError: 'Site launch settings are not initialized.' }); - const next = { - comingSoonEnabled: enabled(form, 'comingSoonEnabled'), - accountCreationEnabled: enabled(form, 'accountCreationEnabled'), - newsEnabled: enabled(form, 'newsEnabled'), - databaseEnabled: enabled(form, 'databaseEnabled'), - buildsEnabled: enabled(form, 'buildsEnabled'), - guidesEnabled: enabled(form, 'guidesEnabled'), - builderEnabled: enabled(form, 'builderEnabled') - }; + if (!before) return fail(404, { + launchGateError: 'Site launch settings are not initialized.', + siteResultToken: randomUUID() + }); const database = await getDatabase(); const updatedAt = new Date().toISOString(); - await (await database.prepare(`UPDATE site_launch_settings SET - coming_soon_enabled=?,account_creation_enabled=?,news_enabled=?,database_enabled=?, - builds_enabled=?,guides_enabled=?,builder_enabled=?,updated_at=?,updated_by=? - WHERE id=?`)).run( - next.comingSoonEnabled, next.accountCreationEnabled, next.newsEnabled, next.databaseEnabled, - next.buildsEnabled, next.guidesEnabled, next.builderEnabled, updatedAt, operator.id, SETTINGS_ID - ); + await (await database.prepare(`UPDATE site_launch_settings + SET coming_soon_enabled=?,updated_at=?,updated_by=? WHERE id=?`)) + .run(comingSoonEnabled, updatedAt, operator.id, SETTINGS_ID); invalidateSiteAccessSettings(); const after = await settingSnapshot(); await recordIsacAudit({ actorId: operator.id, category: 'system_configuration', - action: 'site_access.updated', + action: 'site_access.launch_gate_updated', entityId: SETTINGS_ID, before, after }); - return { siteSettingsSaved: true, resultToken: randomUUID() }; + return { launchGateSaved: true, siteResultToken: randomUUID() }; }, - saveModerationPolicy: async ({ cookies, request }) => { + saveAccountCreation: async ({ cookies, request }) => { const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage'); const form = await request.formData(); - if (String(form.get('confirmation') ?? '') !== MODERATION_CONFIRMATION) { - return fail(400, { - moderationPolicyError: 'Review and confirm the complete community moderation policy before applying it.', - moderationResultToken: randomUUID() - }); - } - const booleanValues = Object.fromEntries( - moderationBooleanNames.map((name) => [name, strictBoolean(form, name)]) - ) as Record<(typeof moderationBooleanNames)[number], number | null>; - if (moderationBooleanNames.some((name) => booleanValues[name] === null)) { - return fail(400, { - moderationPolicyError: 'Every moderation control must have an explicit enabled or disabled state.', - moderationResultToken: randomUUID() - }); - } + const accountCreationEnabled = strictBoolean(form, 'accountCreationEnabled'); + if (accountCreationEnabled === null) return fail(400, { + accountCreationError: 'Choose an account creation state.', + siteResultToken: randomUUID() + }); + const before = await settingSnapshot(); + if (!before) return fail(404, { + accountCreationError: 'Site launch settings are not initialized.', + siteResultToken: randomUUID() + }); + const database = await getDatabase(); + await (await database.prepare(`UPDATE site_launch_settings + SET account_creation_enabled=?,updated_at=?,updated_by=? WHERE id=?`)) + .run(accountCreationEnabled, new Date().toISOString(), operator.id, SETTINGS_ID); + invalidateSiteAccessSettings(); + const after = await settingSnapshot(); + await recordIsacAudit({ + actorId: operator.id, + category: 'system_configuration', + action: 'site_access.account_creation_updated', + entityId: SETTINGS_ID, + before, + after + }); + return { accountCreationSaved: true, siteResultToken: randomUUID() }; + }, + + saveSectionAvailability: async ({ cookies, request }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage'); + const form = await request.formData(); + const fields = ['newsEnabled', 'databaseEnabled', 'buildsEnabled', 'guidesEnabled', 'builderEnabled'] as const; + const values = Object.fromEntries(fields.map((name) => [name, strictBoolean(form, name)])) as + Record<(typeof fields)[number], number | null>; + if (fields.some((name) => values[name] === null)) return fail(400, { + sectionAvailabilityError: 'Every public section must have an explicit enabled or disabled state.', + siteResultToken: randomUUID() + }); + const before = await settingSnapshot(); + if (!before) return fail(404, { + sectionAvailabilityError: 'Site launch settings are not initialized.', + siteResultToken: randomUUID() + }); + const database = await getDatabase(); + await (await database.prepare(`UPDATE site_launch_settings SET + news_enabled=?,database_enabled=?,builds_enabled=?,guides_enabled=?,builder_enabled=?, + updated_at=?,updated_by=? WHERE id=?`)).run( + values.newsEnabled, + values.databaseEnabled, + values.buildsEnabled, + values.guidesEnabled, + values.builderEnabled, + new Date().toISOString(), + operator.id, + SETTINGS_ID + ); + invalidateSiteAccessSettings(); + const after = await settingSnapshot(); + await recordIsacAudit({ + actorId: operator.id, + category: 'system_configuration', + action: 'site_access.section_availability_updated', + entityId: SETTINGS_ID, + before, + after + }); + return { sectionAvailabilitySaved: true, siteResultToken: randomUUID() }; + }, + + saveModerationScreening: async ({ cookies, request }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage'); + const form = await request.formData(); + const automatedEnabled = strictBoolean(form, 'automatedEnabled'); const providerModel = String(form.get('providerModel') ?? '').trim(); + if (automatedEnabled === null) return fail(400, { + moderationScreeningError: 'Choose whether automated moderation is enabled.', + moderationResultToken: randomUUID() + }); if (!MODERATION_MODELS.has(providerModel)) { return fail(400, { - moderationPolicyError: 'Choose a supported OpenAI moderation model.', + moderationScreeningError: 'Choose a supported OpenAI moderation model.', moderationResultToken: randomUUID() }); } + const before = await moderationSettingsSnapshot(); + if (!before) return fail(404, { + moderationScreeningError: 'Community moderation settings are not initialized.', + moderationResultToken: randomUUID() + }); + const database = await getDatabase(); + await (await database.prepare(`UPDATE community_moderation_settings SET + automated_enabled=?,provider_model=?,policy_version=policy_version+1, + updated_at=?,updated_by=? WHERE id=?`)).run( + automatedEnabled, + providerModel, + new Date().toISOString(), + operator.id, + SETTINGS_ID + ); + const after = await moderationSettingsSnapshot(); + await recordIsacAudit({ + actorId: operator.id, + category: 'system_configuration', + action: 'moderation.screening_updated', + entityId: SETTINGS_ID, + before, + after + }); + invalidateCommunityModerationPolicy(); + return { moderationScreeningSaved: true, moderationResultToken: randomUUID() }; + }, + + saveModerationPublication: async ({ cookies, request }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage'); + const form = await request.formData(); + const autoPublishClean = strictBoolean(form, 'autoPublishClean'); + const holdOnProviderError = strictBoolean(form, 'holdOnProviderError'); + if (autoPublishClean === null || holdOnProviderError === null) return fail(400, { + moderationPublicationError: 'Choose an explicit clean-content and provider-failure decision.', + moderationResultToken: randomUUID() + }); + const before = await moderationSettingsSnapshot(); + if (!before) return fail(404, { + moderationPublicationError: 'Community moderation settings are not initialized.', + moderationResultToken: randomUUID() + }); + const database = await getDatabase(); + await (await database.prepare(`UPDATE community_moderation_settings SET + auto_publish_clean=?,hold_on_provider_error=?,policy_version=policy_version+1, + updated_at=?,updated_by=? WHERE id=?`)).run( + autoPublishClean, + holdOnProviderError, + new Date().toISOString(), + operator.id, + SETTINGS_ID + ); + const after = await moderationSettingsSnapshot(); + await recordIsacAudit({ + actorId: operator.id, + category: 'system_configuration', + action: 'moderation.publication_updated', + entityId: SETTINGS_ID, + before, + after + }); + invalidateCommunityModerationPolicy(); + return { moderationPublicationSaved: true, moderationResultToken: randomUUID() }; + }, + + saveModerationCoverage: async ({ cookies, request }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage'); + const form = await request.formData(); + const fields = ['scanComments', 'scanProfiles', 'scanGuides', 'scanAvatars'] as const; + const values = Object.fromEntries(fields.map((name) => [name, strictBoolean(form, name)])) as + Record<(typeof fields)[number], number | null>; + if (fields.some((name) => values[name] === null)) return fail(400, { + moderationCoverageError: 'Every content channel must have an explicit enabled or disabled state.', + moderationResultToken: randomUUID() + }); + const before = await moderationSettingsSnapshot(); + if (!before) return fail(404, { + moderationCoverageError: 'Community moderation settings are not initialized.', + moderationResultToken: randomUUID() + }); + const database = await getDatabase(); + await (await database.prepare(`UPDATE community_moderation_settings SET + scan_comments=?,scan_profiles=?,scan_guides=?,scan_avatars=?, + policy_version=policy_version+1,updated_at=?,updated_by=? WHERE id=?`)).run( + values.scanComments, + values.scanProfiles, + values.scanGuides, + values.scanAvatars, + new Date().toISOString(), + operator.id, + SETTINGS_ID + ); + const after = await moderationSettingsSnapshot(); + await recordIsacAudit({ + actorId: operator.id, + category: 'system_configuration', + action: 'moderation.coverage_updated', + entityId: SETTINGS_ID, + before, + after + }); + invalidateCommunityModerationPolicy(); + return { moderationCoverageSaved: true, moderationResultToken: randomUUID() }; + }, + + saveModerationLimits: async ({ cookies, request }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage'); + const form = await request.formData(); + const detectPii = strictBoolean(form, 'detectPii'); const maxLinks = finiteNumber(form.get('maxLinks')); const duplicateWindowSeconds = finiteNumber(form.get('duplicateWindowSeconds')); + if (detectPii === null) return fail(400, { + moderationLimitsError: 'Choose whether local PII detection is enabled.', + moderationResultToken: randomUUID() + }); if (!Number.isInteger(maxLinks) || maxLinks === null || maxLinks < 0 || maxLinks > 20) { return fail(400, { - moderationPolicyError: 'Maximum links must be a whole number from 0 through 20.', + moderationLimitsError: 'Maximum links must be a whole number from 0 through 20.', moderationResultToken: randomUUID() }); } @@ -183,18 +360,49 @@ export const actions: Actions = { || duplicateWindowSeconds > 3_600 ) { return fail(400, { - moderationPolicyError: 'Duplicate detection must use a whole-number window from 0 through 3,600 seconds.', + moderationLimitsError: 'Duplicate detection must use a whole-number window from 0 through 3,600 seconds.', moderationResultToken: randomUUID() }); } + const before = await moderationSettingsSnapshot(); + if (!before) return fail(404, { + moderationLimitsError: 'Community moderation settings are not initialized.', + moderationResultToken: randomUUID() + }); + const database = await getDatabase(); + await (await database.prepare(`UPDATE community_moderation_settings SET + detect_pii=?,max_links=?,duplicate_window_seconds=?,policy_version=policy_version+1, + updated_at=?,updated_by=? WHERE id=?`)).run( + detectPii, + maxLinks, + duplicateWindowSeconds, + new Date().toISOString(), + operator.id, + SETTINGS_ID + ); + const after = await moderationSettingsSnapshot(); + await recordIsacAudit({ + actorId: operator.id, + category: 'system_configuration', + action: 'moderation.local_limits_updated', + entityId: SETTINGS_ID, + before, + after + }); + invalidateCommunityModerationPolicy(); + return { moderationLimitsSaved: true, moderationResultToken: randomUUID() }; + }, + saveModerationThresholds: async ({ cookies, request }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage'); + const form = await request.formData(); const database = await getDatabase(); const thresholdRows = await (await database.prepare( 'SELECT category FROM community_moderation_thresholds ORDER BY category' )).all(); if (!thresholdRows.length) { return fail(409, { - moderationPolicyError: 'Moderation categories are not initialized.', + moderationThresholdsError: 'Moderation categories are not initialized.', moderationResultToken: randomUUID() }); } @@ -220,7 +428,7 @@ export const actions: Actions = { || priorityThreshold < reviewThreshold ) { return fail(400, { - moderationPolicyError: `Review the ${category} thresholds. Values must be between 0 and 1, and priority must be at least the review threshold.`, + moderationThresholdsError: `Review the ${category} thresholds. Values must be between 0 and 1, and priority must be at least the review threshold.`, moderationResultToken: randomUUID() }); } @@ -232,25 +440,8 @@ export const actions: Actions = { await database.exec('BEGIN IMMEDIATE'); try { await (await database.prepare(`UPDATE community_moderation_settings SET - automated_enabled=?,auto_publish_clean=?,hold_on_provider_error=?, - scan_comments=?,scan_profiles=?,scan_guides=?,scan_avatars=?,provider_model=?,detect_pii=?, - max_links=?,duplicate_window_seconds=?,policy_version=policy_version+1, - updated_at=?,updated_by=? WHERE id=?`)).run( - booleanValues.automatedEnabled, - booleanValues.autoPublishClean, - booleanValues.holdOnProviderError, - booleanValues.scanComments, - booleanValues.scanProfiles, - booleanValues.scanGuides, - booleanValues.scanAvatars, - providerModel, - booleanValues.detectPii, - maxLinks, - duplicateWindowSeconds, - now, - operator.id, - SETTINGS_ID - ); + policy_version=policy_version+1,updated_at=?,updated_by=? WHERE id=?`)) + .run(now, operator.id, SETTINGS_ID); for (const threshold of thresholds) { await (await database.prepare(`UPDATE community_moderation_thresholds SET enabled=?,review_threshold=?,priority_threshold=?,updated_at=?,updated_by=? @@ -267,7 +458,7 @@ export const actions: Actions = { await recordIsacAudit({ actorId: operator.id, category: 'system_configuration', - action: 'moderation.policy_updated', + action: 'moderation.thresholds_updated', entityId: SETTINGS_ID, before, after @@ -279,7 +470,7 @@ export const actions: Actions = { } invalidateCommunityModerationPolicy(); return { - moderationPolicySaved: true, + moderationThresholdsSaved: true, moderationResultToken: randomUUID() }; }, @@ -291,7 +482,7 @@ export const actions: Actions = { const listType = String(form.get('listType') ?? ''); const matchMode = String(form.get('matchMode') ?? ''); const value = String(form.get('value') ?? '').normalize('NFKC').trim(); - const normalizedValue = normalizeTermValue(form.get('value')); + const normalizedValue = normalizeModerationTerm(String(form.get('value') ?? '')); const requestedScopes = [...new Set(form.getAll('scopes').map(String))]; const scopes = [...MODERATION_SCOPES].filter((scope) => requestedScopes.includes(scope)); const termEnabled = strictBoolean(form, 'enabled'); @@ -319,8 +510,8 @@ export const actions: Actions = { moderationResultToken: randomUUID() }); const conflicts = await (await database.prepare(`SELECT id,scopes_json FROM community_moderation_terms - WHERE normalized_value=? AND match_mode=? AND id<>?`)) - .all(normalizedValue, matchMode, id || ''); + WHERE normalized_value=? AND match_mode=? AND list_kind=? AND id<>?`)) + .all(normalizedValue, matchMode, listType, id || ''); const scopeConflict = conflicts.some((candidate) => { try { const candidateScopes = JSON.parse(String(candidate.scopes_json ?? '[]')); @@ -385,6 +576,127 @@ export const actions: Actions = { }; }, + bulkAddModerationTerms: async ({ cookies, request }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage'); + const form = await request.formData(); + const listType = String(form.get('listType') ?? ''); + const requestedScopes = [...new Set(form.getAll('scopes').map(String))]; + const scopes = [...MODERATION_SCOPES].filter((scope) => requestedScopes.includes(scope)); + const termEnabled = strictBoolean(form, 'enabled'); + const note = String(form.get('note') ?? '').normalize('NFKC').trim(); + const rawTerms = String(form.get('terms') ?? ''); + const failure = (message: string, status = 400) => fail(status, { + bulkModerationTermsError: message, + moderationResultToken: randomUUID() + }); + if (!['allow', 'ban'].includes(listType)) { + return failure('Choose whether the imported terms allow or ban matching content.'); + } + if (!requestedScopes.length || requestedScopes.some((scope) => !MODERATION_SCOPES.has(scope))) { + return failure('Choose at least one valid content scope.'); + } + if (termEnabled === null) return failure('Choose whether the imported rules are enabled or disabled.'); + if (Array.from(note).length > 500) return failure('Operational notes may contain up to 500 characters.'); + if (Array.from(rawTerms).length > 50_000) { + return failure('The pasted list is too large. Import no more than 250 terms at a time.'); + } + + const parsed = parseModerationTermBatch(rawTerms); + if (!parsed.terms.length) return failure('Paste at least one term or phrase.'); + if (parsed.terms.length > 250) return failure('Import no more than 250 unique terms at a time.'); + const invalidTerm = parsed.terms.find((term) => + !term.normalizedValue || Array.from(term.normalizedValue).length > 200 + ); + if (invalidTerm) { + return failure(`Review “${invalidTerm.value.slice(0, 60)}”. Each term or phrase may contain up to 200 characters.`); + } + + const database = await getDatabase(); + const now = new Date().toISOString(); + const placeholders = parsed.terms.map(() => '?').join(','); + await database.exec('BEGIN IMMEDIATE'); + try { + const existingTerms = await (await database.prepare(`SELECT id,list_kind,normalized_value,match_mode,scopes_json + FROM community_moderation_terms WHERE normalized_value IN (${placeholders})`)) + .all(...parsed.terms.map((term) => term.normalizedValue)); + const conflicts = new Map(); + for (const existing of existingTerms) { + if (String(existing.list_kind) !== listType) continue; + let existingScopes: unknown; + try { + existingScopes = JSON.parse(String(existing.scopes_json ?? '[]')); + } catch { + existingScopes = null; + } + if (!Array.isArray(existingScopes) || existingScopes.some((scope) => scopes.includes(String(scope)))) { + conflicts.set(`${existing.match_mode}:${existing.normalized_value}`, String(existing.id)); + } + } + + const created = parsed.terms.filter((term) => + !conflicts.has(`${term.matchMode}:${term.normalizedValue}`) + ); + if (!created.length) { + await database.exec('ROLLBACK'); + return failure('No terms were added. Every pasted term already has a rule in one or more selected scopes.', 409); + } + + for (const term of created) { + const termId = randomUUID(); + await (await database.prepare(`INSERT INTO community_moderation_terms + (id,list_kind,match_mode,value,normalized_value,scopes_json,enabled,note,created_at,created_by,updated_at,updated_by) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`)).run( + termId, + listType, + term.matchMode, + term.value, + term.normalizedValue, + JSON.stringify(scopes), + termEnabled, + note || null, + now, + operator.id, + now, + operator.id + ); + const createdRow = { + id: termId, + listKind: listType, + matchMode: term.matchMode, + value: term.value, + normalizedValue: term.normalizedValue, + scopes, + enabled: termEnabled, + note: note || null + }; + await recordIsacAudit({ + actorId: operator.id, + category: 'system_configuration', + action: 'moderation.term_created', + entityId: termId, + before: null, + after: createdRow + }); + } + const existingConflictCount = parsed.terms.length - created.length; + await database.exec('COMMIT'); + invalidateCommunityModerationPolicy(); + return { + bulkModerationTermsSaved: true, + bulkModerationTermsAdded: created.length, + bulkModerationTermsSkipped: existingConflictCount + parsed.duplicateCount, + moderationResultToken: randomUUID() + }; + } catch (cause) { + try { + await database.exec('ROLLBACK'); + } catch { + // Preserve the original mutation failure. + } + throw cause; + } + }, + archiveModerationTerm: async ({ cookies, request }) => { const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage'); const id = String((await request.formData()).get('id') ?? '').trim(); diff --git a/src/routes/isac/site-controls/+page.svelte b/src/routes/isac/site-controls/+page.svelte index 82097f2..88f956a 100644 --- a/src/routes/isac/site-controls/+page.svelte +++ b/src/routes/isac/site-controls/+page.svelte @@ -1,5 +1,7 @@