Improve moderation controls and term management

This commit is contained in:
JB
2026-07-27 16:44:53 -04:00
parent 27cbeca0ed
commit fe4acec191
8 changed files with 1065 additions and 362 deletions
@@ -0,0 +1,74 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
id,
kicker,
title,
status,
statusTone = 'default',
open = $bindable(true),
class: className = '',
children
}: {
id: string;
kicker: string;
title: string;
status?: string;
statusTone?: 'default' | 'success' | 'danger';
open?: boolean;
class?: string;
children: Snippet;
} = $props();
</script>
<section class={`collapsible-section ${className}`} aria-labelledby={`${id}-heading`}>
<h3 id={`${id}-heading`}>
<button
type="button"
aria-expanded={open}
aria-controls={`${id}-content`}
onclick={() => open = !open}
>
<span class="collapsible-section-identity">
<small>{kicker}</small>
<b>{title}</b>
</span>
{#if status}<strong class:success={statusTone === 'success'} class:danger={statusTone === 'danger'}>{status}</strong>{/if}
<span class="collapsible-section-action">
<span>{open ? 'COLLAPSE' : 'EXPAND'}</span>
<i aria-hidden="true">{open ? '−' : '+'}</i>
</span>
</button>
</h3>
<div id={`${id}-content`} class="collapsible-section-content" hidden={!open}>
{@render children()}
</div>
</section>
<style>
.collapsible-section{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}
h3{margin:0}
h3>button{width:100%;min-height:58px;display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:12px;padding:9px 11px;border:0;background:#0d1011;color:var(--shd-text);text-align:left;cursor:pointer}
h3>button:hover{background:#141819}
h3>button:active{filter:brightness(.88)}
h3>button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}
.collapsible-section-identity{min-width:0;display:grid;gap:2px}
.collapsible-section-identity small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
.collapsible-section-identity b{overflow:hidden;font-size:var(--shd-type-body);letter-spacing:.5px;text-overflow:ellipsis;white-space:nowrap}
h3>button>strong{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.7px;white-space:nowrap}
h3>button>strong.success{color:var(--shd-success)}
h3>button>strong.danger{color:var(--shd-danger)}
.collapsible-section-action{display:flex;align-items:center;gap:8px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.collapsible-section-action i{width:28px;height:28px;display:grid;place-items:center;border:1px solid var(--shd-border-strong);font-size:16px;font-style:normal}
.collapsible-section-content{padding:11px;border-top:1px solid var(--shd-border)}
.collapsible-section-content[hidden]{display:none}
@media(max-width:650px){
h3>button{min-height:64px;grid-template-columns:minmax(0,1fr) auto;gap:8px}
h3>button>strong{grid-column:1;grid-row:2}
.collapsible-section-action{grid-column:2;grid-row:1/3}
.collapsible-section-action>span{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.collapsible-section-content{padding:9px}
}
@media(prefers-reduced-motion:reduce){h3>button{transition:none}}
</style>
@@ -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,
+31
View File
@@ -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<string>();
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');
}
+410 -98
View File
@@ -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<string, string>();
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();
+262 -255
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import { untrack } from 'svelte';
@@ -45,6 +47,13 @@
enabled: string;
note: string;
};
type BulkModerationTermDraft = {
listType: 'allow' | 'ban';
terms: string;
scopes: string[];
enabled: string;
note: string;
};
const settingValue = (key: string) => Number(data.settings[key]) ? '1' : '0';
const moderationSettingValue = (key: string) => Number(data.moderationSettings[key]) ? '1' : '0';
const parseScopes = (value: unknown) => {
@@ -65,19 +74,33 @@
enabled: '1',
note: ''
});
const blankBulkModerationTerms = (): BulkModerationTermDraft => ({
listType: 'allow',
terms: '',
scopes: ['comment', 'profile_bio', 'guide'],
enabled: '1',
note: ''
});
let comingSoonEnabled = $state(settingValue('coming_soon_enabled'));
let accountCreationEnabled = $state(settingValue('account_creation_enabled'));
let sectionValues = $state<Record<string, string>>(Object.fromEntries(
sectionDefinitions.map((section) => [section.key, settingValue(`${section.key}_enabled`)])
));
let reviewing = $state(false);
let reviewingModerationPolicy = $state(false);
let launchPanelOpen = $state(true);
let accountPanelOpen = $state(true);
let availabilityPanelOpen = $state(true);
let screeningPanelOpen = $state(true);
let publicationPanelOpen = $state(true);
let coveragePanelOpen = $state(true);
let limitsPanelOpen = $state(true);
let thresholdsPanelOpen = $state(true);
let termsPanelOpen = $state(true);
let archivingModerationTerm = $state<any>(null);
let editingModerationTerm = $state<ModerationTermDraft | null>(null);
let bulkAddingModerationTerms = $state<BulkModerationTermDraft | null>(null);
let synchronizedAt = $state('');
let moderationSynchronizedAt = $state('');
let handledResultToken = $state('');
let handledModerationResultToken = $state('');
let automatedEnabled = $state(moderationSettingValue('automated_enabled'));
let autoPublishClean = $state(moderationSettingValue('auto_publish_clean'));
@@ -136,11 +159,6 @@
synchronizedAt = updatedAt;
synchronize();
});
$effect(() => {
if (!form?.siteSettingsSaved || !form.resultToken || form.resultToken === handledResultToken) return;
handledResultToken = form.resultToken;
reviewing = false;
});
$effect(() => {
const syncKey = `${data.moderationSettings.updated_at}:${data.moderationSettings.policy_version}`;
if (syncKey === moderationSynchronizedAt) return;
@@ -151,71 +169,45 @@
const token = String(form?.moderationResultToken ?? '');
if (!token || token === handledModerationResultToken) return;
handledModerationResultToken = token;
if (form?.moderationPolicySaved) reviewingModerationPolicy = false;
if (form?.moderationTermSaved) editingModerationTerm = null;
if (form?.moderationTermSaved) {
editingModerationTerm = editingModerationTerm?.id ? null : blankModerationTerm();
}
if (form?.bulkModerationTermsSaved) bulkAddingModerationTerms = blankBulkModerationTerms();
if (form?.moderationTermArchived) archivingModerationTerm = null;
});
const currentValue = (key: string) => Number(data.settings[key]) ? 'ENABLED' : 'DISABLED';
const proposedValue = (value: string) => value === '1' ? 'ENABLED' : 'DISABLED';
const changes = $derived.by(() => {
const rows = [
{
label: 'LAUNCH GATE',
before: Number(data.settings.coming_soon_enabled) ? 'COMING SOON' : 'PUBLIC LAUNCH',
after: comingSoonEnabled === '1' ? 'COMING SOON' : 'PUBLIC LAUNCH'
},
{
label: 'ACCOUNT CREATION',
before: currentValue('account_creation_enabled'),
after: proposedValue(accountCreationEnabled)
},
...sectionDefinitions.map((section) => ({
label: section.label,
before: currentValue(`${section.key}_enabled`),
after: proposedValue(sectionValues[section.key] ?? '0')
}))
];
return rows.filter((row) => row.before !== row.after);
});
const moderationChanges = $derived.by(() => {
const settingsRows = [
{ label: 'AUTOMATED MODERATION', before: Number(data.moderationSettings.automated_enabled) ? 'ENABLED' : 'DISABLED', after: automatedEnabled === '1' ? 'ENABLED' : 'DISABLED' },
{ label: 'CLEAN CONTENT', before: Number(data.moderationSettings.auto_publish_clean) ? 'AUTO-PUBLISH' : 'HOLD', after: autoPublishClean === '1' ? 'AUTO-PUBLISH' : 'HOLD' },
{ label: 'PROVIDER ERROR', before: Number(data.moderationSettings.hold_on_provider_error) ? 'HOLD' : 'PUBLISH', after: holdOnProviderError === '1' ? 'HOLD' : 'PUBLISH' },
{ label: 'COMMENTS', before: Number(data.moderationSettings.scan_comments) ? 'SCAN' : 'BYPASS', after: scanComments === '1' ? 'SCAN' : 'BYPASS' },
{ label: 'PROFILE BIOS', before: Number(data.moderationSettings.scan_profiles) ? 'SCAN' : 'BYPASS', after: scanProfiles === '1' ? 'SCAN' : 'BYPASS' },
{ label: 'GUIDES', before: Number(data.moderationSettings.scan_guides) ? 'SCAN' : 'BYPASS', after: scanGuides === '1' ? 'SCAN' : 'BYPASS' },
{ label: 'AVATARS', before: Number(data.moderationSettings.scan_avatars) ? 'SCAN' : 'BYPASS', after: scanAvatars === '1' ? 'SCAN' : 'BYPASS' },
{ label: 'PII DETECTION', before: Number(data.moderationSettings.detect_pii) ? 'ENABLED' : 'DISABLED', after: detectPii === '1' ? 'ENABLED' : 'DISABLED' },
{ label: 'PROVIDER MODEL', before: String(data.moderationSettings.provider_model), after: providerModel },
{ label: 'MAXIMUM LINKS', before: String(data.moderationSettings.max_links), after: maxLinks },
{ label: 'DUPLICATE WINDOW', before: `${data.moderationSettings.duplicate_window_seconds} SEC`, after: `${duplicateWindowSeconds} SEC` }
];
const thresholdRows = data.moderationThresholds.flatMap((threshold: any) => {
const category = String(threshold.category);
const draft = thresholdDrafts[category];
if (!draft) return [];
return [
{
label: `${categoryLabel(category)} STATE`,
before: Number(threshold.enabled) ? 'ENABLED' : 'DISABLED',
after: draft.enabled === '1' ? 'ENABLED' : 'DISABLED'
},
{
label: `${categoryLabel(category)} REVIEW`,
before: String(threshold.review_threshold),
after: draft.review
},
{
label: `${categoryLabel(category)} PRIORITY`,
before: String(threshold.priority_threshold),
after: draft.priority
}
];
});
return [...settingsRows, ...thresholdRows].filter((row) => row.before !== row.after);
});
const launchChanged = $derived(comingSoonEnabled !== settingValue('coming_soon_enabled'));
const accountChanged = $derived(accountCreationEnabled !== settingValue('account_creation_enabled'));
const availabilityChanged = $derived(sectionDefinitions.some((section) =>
sectionValues[section.key] !== settingValue(`${section.key}_enabled`)
));
const screeningChanged = $derived(
automatedEnabled !== moderationSettingValue('automated_enabled')
|| providerModel !== String(data.moderationSettings.provider_model)
);
const publicationChanged = $derived(
autoPublishClean !== moderationSettingValue('auto_publish_clean')
|| holdOnProviderError !== moderationSettingValue('hold_on_provider_error')
);
const coverageChanged = $derived(
scanComments !== moderationSettingValue('scan_comments')
|| scanProfiles !== moderationSettingValue('scan_profiles')
|| scanGuides !== moderationSettingValue('scan_guides')
|| scanAvatars !== moderationSettingValue('scan_avatars')
);
const limitsChanged = $derived(
detectPii !== moderationSettingValue('detect_pii')
|| maxLinks !== String(data.moderationSettings.max_links)
|| duplicateWindowSeconds !== String(data.moderationSettings.duplicate_window_seconds)
);
const thresholdsChanged = $derived(data.moderationThresholds.some((threshold: any) => {
const draft = thresholdDrafts[String(threshold.category)];
return draft && (
draft.enabled !== (Number(threshold.enabled) ? '1' : '0')
|| draft.review !== String(threshold.review_threshold)
|| draft.priority !== String(threshold.priority_threshold)
);
}));
const editModerationTerm = (term: any) => {
editingModerationTerm = {
id: String(term.id),
@@ -227,6 +219,15 @@
note: String(term.note ?? '')
};
};
const bulkModerationTermCount = $derived.by(() => {
if (!bulkAddingModerationTerms) return 0;
return new Set(
bulkAddingModerationTerms.terms.normalize('NFKC')
.split(/[\r\n,;]+/u)
.map((term) => term.trim().replace(/\s+/g, ' ').toLocaleLowerCase())
.filter(Boolean)
).size;
});
const date = (value: unknown) => value
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value)))
: 'INITIAL CONFIGURATION';
@@ -235,9 +236,8 @@
<svelte:head><title>Site controls — ISAC</title></svelte:head>
<svelte:window onkeydown={(event) => {
if (event.key !== 'Escape') return;
reviewing = false;
reviewingModerationPolicy = false;
archivingModerationTerm = null;
bulkAddingModerationTerms = null;
}}/>
<div class="module-toolbar site-control-toolbar">
@@ -254,39 +254,54 @@
</div>
<div class="site-control-grid">
<section class="site-control-panel launch-control">
<header><div><span>01 / LAUNCH GATE</span><h3>PUBLIC NETWORK STATE</h3></div><b class:restricted={comingSoonEnabled === '1'}>{comingSoonEnabled === '1' ? 'GATED' : 'LIVE'}</b></header>
<p>The Coming Soon gate takes priority over individual sections for signed-out visitors. Signed-in agents bypass the launch gate, but section availability still applies.</p>
<SegmentedChoice legend="LAUNCH STATE" name="launchStateDraft" options={launchOptions} bind:value={comingSoonEnabled}/>
</section>
<CollapsibleSection id="launch-gate" class="site-control-panel launch-control" kicker="01 / LAUNCH GATE" title="PUBLIC NETWORK STATE" status={comingSoonEnabled === '1' ? 'GATED' : 'LIVE'} statusTone={comingSoonEnabled === '1' ? 'danger' : 'success'} bind:open={launchPanelOpen}>
<PendingForm action="?/saveLaunchGate" successMessage="Public network state saved.">
{#snippet children(pending)}
<p>The Coming Soon gate takes priority over individual sections for signed-out visitors. Signed-in agents bypass the launch gate, but section availability still applies.</p>
<SegmentedChoice legend="LAUNCH STATE" name="comingSoonEnabled" options={launchOptions} bind:value={comingSoonEnabled}/>
{#if form?.launchGateError}<p class="error" role="alert">{form.launchGateError}</p>{/if}
<div class="section-save-row">
<a class="secondary" href="/coming-soon">PREVIEW PAGE ↗</a>
<button class="primary" disabled={pending || !launchChanged} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE NETWORK STATE'}</button>
</div>
{/snippet}
</PendingForm>
</CollapsibleSection>
<section class="site-control-panel account-control">
<header><div><span>02 / IDENTITY INTAKE</span><h3>ACCOUNT CREATION</h3></div><b class:restricted={accountCreationEnabled === '0'}>{accountCreationEnabled === '1' ? 'OPEN' : 'CLOSED'}</b></header>
<p>Controls creation of new accounts across supported registration methods. Existing agents retain their configured sign-in methods.</p>
<SegmentedChoice legend="REGISTRATION STATE" name="accountCreationDraft" options={binaryOptions} bind:value={accountCreationEnabled}/>
</section>
<CollapsibleSection id="account-creation" class="site-control-panel account-control" kicker="02 / IDENTITY INTAKE" title="ACCOUNT CREATION" status={accountCreationEnabled === '1' ? 'OPEN' : 'CLOSED'} statusTone={accountCreationEnabled === '1' ? 'success' : 'danger'} bind:open={accountPanelOpen}>
<PendingForm action="?/saveAccountCreation" successMessage="Account creation state saved.">
{#snippet children(pending)}
<p>Controls creation of new accounts across supported registration methods. Existing agents retain their configured sign-in methods.</p>
<SegmentedChoice legend="REGISTRATION STATE" name="accountCreationEnabled" options={binaryOptions} bind:value={accountCreationEnabled}/>
{#if form?.accountCreationError}<p class="error" role="alert">{form.accountCreationError}</p>{/if}
<div class="section-save-row">
<span>{accountChanged ? 'UNSAVED CHANGE' : 'SYNCHRONIZED'}</span>
<button class="primary" disabled={pending || !accountChanged} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE ACCOUNT STATE'}</button>
</div>
{/snippet}
</PendingForm>
</CollapsibleSection>
<section class="site-control-panel public-sections">
<header><div><span>03 / PUBLIC SECTIONS</span><h3>SECTION AVAILABILITY</h3></div><b>{sectionDefinitions.filter((section) => sectionValues[section.key] === '1').length} / {sectionDefinitions.length} ONLINE</b></header>
<p>Section controls apply after the Launch Gate. Disabled sections disappear from navigation and reject direct access and mutations for every agent.</p>
<div class="section-control-list">
{#each sectionDefinitions as section}
<article>
<div><strong>{section.label}</strong><small>{section.description}</small></div>
<SegmentedChoice legend={`${section.label} STATE`} name={`${section.key}StateDraft`} options={binaryOptions} bind:value={sectionValues[section.key]}/>
</article>
{/each}
</div>
</section>
</div>
{#if form?.siteSettingsError}<p class="error">{form.siteSettingsError}</p>{/if}
<div class="site-control-actions">
<div><span>CHANGE SET</span><b>{changes.length ? `${changes.length} PENDING` : 'SYNCHRONIZED'}</b><small>UPDATED BY {data.settings.updated_by_name ?? 'SYSTEM BOOTSTRAP'}</small></div>
<div class="site-control-action-buttons">
<a class="secondary" href="/coming-soon">PREVIEW PAGE ↗</a>
<button class="primary" disabled={!changes.length} onclick={() => reviewing = true}>REVIEW CHANGES →</button>
</div>
<CollapsibleSection id="section-availability" class="site-control-panel public-sections" kicker="03 / PUBLIC SECTIONS" title="SECTION AVAILABILITY" status={`${sectionDefinitions.filter((section) => sectionValues[section.key] === '1').length} / ${sectionDefinitions.length} ONLINE`} statusTone="success" bind:open={availabilityPanelOpen}>
<PendingForm action="?/saveSectionAvailability" successMessage="Section availability saved.">
{#snippet children(pending)}
<p>Section controls apply after the Launch Gate. Disabled sections disappear from navigation and reject direct access and mutations for every agent.</p>
<div class="section-control-list">
{#each sectionDefinitions as section}
<article>
<div><strong>{section.label}</strong><small>{section.description}</small></div>
<SegmentedChoice legend={`${section.label} STATE`} name={`${section.key}Enabled`} options={binaryOptions} bind:value={sectionValues[section.key]}/>
</article>
{/each}
</div>
{#if form?.sectionAvailabilityError}<p class="error" role="alert">{form.sectionAvailabilityError}</p>{/if}
<div class="section-save-row">
<span>{availabilityChanged ? 'UNSAVED CHANGES' : 'SYNCHRONIZED'}</span>
<button class="primary" disabled={pending || !availabilityChanged} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE SECTION STATES'}</button>
</div>
{/snippet}
</PendingForm>
</CollapsibleSection>
</div>
<section class="community-safety" aria-labelledby="community-safety-heading">
@@ -311,79 +326,141 @@
{/if}
<div class="moderation-policy-grid">
<section class="moderation-policy-panel moderation-master">
<header><div><span>AUTOMATION GATE</span><h3>SCREENING PIPELINE</h3></div><b class:restricted={automatedEnabled === '0'}>{automatedEnabled === '1' ? 'ACTIVE' : 'DISABLED'}</b></header>
<p>When enabled, selected content enters automated screening before publication. Reviewers receive exceptions, not a queue of clean submissions.</p>
<SegmentedChoice legend="AUTOMATED MODERATION" name="moderationEnabledDraft" options={binaryOptions} bind:value={automatedEnabled}/>
<SegmentedChoice legend="PROVIDER MODEL" name="providerModelDraft" options={moderationModelOptions} bind:value={providerModel}/>
</section>
<CollapsibleSection id="screening-pipeline" class="moderation-policy-panel moderation-master" kicker="AUTOMATION GATE" title="SCREENING PIPELINE" status={automatedEnabled === '1' ? 'ACTIVE' : 'DISABLED'} statusTone={automatedEnabled === '1' ? 'success' : 'danger'} bind:open={screeningPanelOpen}>
<PendingForm action="?/saveModerationScreening" successMessage="Screening pipeline saved.">
{#snippet children(pending)}
<p>When enabled, selected content enters automated screening before publication. Reviewers receive exceptions, not a queue of clean submissions.</p>
<SegmentedChoice legend="AUTOMATED MODERATION" name="automatedEnabled" options={binaryOptions} bind:value={automatedEnabled}/>
<SegmentedChoice legend="PROVIDER MODEL" name="providerModel" options={moderationModelOptions} bind:value={providerModel}/>
{#if form?.moderationScreeningError}<p class="error" role="alert">{form.moderationScreeningError}</p>{/if}
<div class="section-save-row"><span>{screeningChanged ? 'UNSAVED CHANGES' : 'SYNCHRONIZED'}</span><button class="primary" disabled={pending || !screeningChanged} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE SCREENING'}</button></div>
{/snippet}
</PendingForm>
</CollapsibleSection>
<section class="moderation-policy-panel moderation-publication">
<header><div><span>PUBLICATION POLICY</span><h3>VISIBILITY DECISIONS</h3></div><b>{autoPublishClean === '1' ? 'EXCEPTION QUEUE' : 'REVIEW ALL'}</b></header>
<p>Clean content can publish immediately. Flagged, uncertain, or failed checks remain hidden for Community Oversight.</p>
<SegmentedChoice legend="CLEAN CONTENT" name="autoPublishCleanDraft" options={[{value:'1',label:'AUTO-PUBLISH'},{value:'0',label:'HOLD FOR REVIEW'}]} bind:value={autoPublishClean}/>
<SegmentedChoice legend="PROVIDER FAILURE" name="holdOnProviderErrorDraft" options={[{value:'1',label:'HOLD CONTENT'},{value:'0',label:'ALLOW CONTENT'}]} bind:value={holdOnProviderError}/>
</section>
<CollapsibleSection id="visibility-decisions" class="moderation-policy-panel moderation-publication" kicker="PUBLICATION POLICY" title="VISIBILITY DECISIONS" status={autoPublishClean === '1' ? 'EXCEPTION QUEUE' : 'REVIEW ALL'} bind:open={publicationPanelOpen}>
<PendingForm action="?/saveModerationPublication" successMessage="Visibility decisions saved.">
{#snippet children(pending)}
<p>Clean content can publish immediately. Flagged, uncertain, or failed checks remain hidden for Community Oversight.</p>
<SegmentedChoice legend="CLEAN CONTENT" name="autoPublishClean" options={[{value:'1',label:'AUTO-PUBLISH'},{value:'0',label:'HOLD FOR REVIEW'}]} bind:value={autoPublishClean}/>
<SegmentedChoice legend="PROVIDER FAILURE" name="holdOnProviderError" options={[{value:'1',label:'HOLD CONTENT'},{value:'0',label:'ALLOW CONTENT'}]} bind:value={holdOnProviderError}/>
{#if form?.moderationPublicationError}<p class="error" role="alert">{form.moderationPublicationError}</p>{/if}
<div class="section-save-row"><span>{publicationChanged ? 'UNSAVED CHANGES' : 'SYNCHRONIZED'}</span><button class="primary" disabled={pending || !publicationChanged} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE VISIBILITY'}</button></div>
{/snippet}
</PendingForm>
</CollapsibleSection>
<section class="moderation-policy-panel moderation-scopes">
<header><div><span>CONTENT COVERAGE</span><h3>SCREENED CHANNELS</h3></div><b>{[scanComments,scanProfiles,scanGuides,scanAvatars].filter((value) => value === '1').length} / 4 ACTIVE</b></header>
<p>Disable a channel only when its submission flow should bypass automated checks.</p>
<div class="moderation-scope-list">
<div><span><b>COMMENTS</b><small>Build, guide, and profile discussion</small></span><SegmentedChoice legend="COMMENTS" name="scanCommentsDraft" options={binaryOptions} bind:value={scanComments}/></div>
<div><span><b>PROFILE BIOS</b><small>Public Agent biography revisions</small></span><SegmentedChoice legend="PROFILE BIOS" name="scanProfilesDraft" options={binaryOptions} bind:value={scanProfiles}/></div>
<div><span><b>GUIDES</b><small>Long-form community field manuals</small></span><SegmentedChoice legend="GUIDES" name="scanGuidesDraft" options={binaryOptions} bind:value={scanGuides}/></div>
<div><span><b>AVATARS</b><small>Uploaded custom Agent portraits</small></span><SegmentedChoice legend="AVATARS" name="scanAvatarsDraft" options={binaryOptions} bind:value={scanAvatars}/></div>
</div>
</section>
<section class="moderation-policy-panel moderation-local-rules">
<header><div><span>DETERMINISTIC CHECKS</span><h3>LOCAL SAFETY LIMITS</h3></div><b>{detectPii === '1' ? 'PII ACTIVE' : 'PII DISABLED'}</b></header>
<p>Local rules supplement provider categories with privacy, link, duplicate, and site-specific terminology checks.</p>
<SegmentedChoice legend="PII DETECTION" name="detectPiiDraft" options={binaryOptions} bind:value={detectPii}/>
<div class="moderation-limit-grid">
<label class="field">MAXIMUM LINKS<input type="number" min="0" max="20" step="1" bind:value={maxLinks}/><small>0–20 links per submission</small></label>
<label class="field">DUPLICATE WINDOW · SECONDS<input type="number" min="0" max="3600" step="1" bind:value={duplicateWindowSeconds}/><small>0 disables duplicate timing</small></label>
</div>
</section>
</div>
<section class="moderation-threshold-panel">
<header>
<div><span>PROVIDER CATEGORY MATRIX</span><h3>REVIEW & PRIORITY THRESHOLDS</h3></div>
<p>Review scores hold content for Oversight. Priority scores must be equal to or higher than review scores and move urgent exceptions to the front of the queue.</p>
</header>
<div class="moderation-threshold-table">
<div class="moderation-threshold-row heading"><span>CATEGORY</span><span>STATE</span><span>REVIEW · 0–1</span><span>PRIORITY · 0–1</span></div>
{#each data.moderationThresholds as threshold}
{@const category = String(threshold.category)}
{@const draft = thresholdDrafts[category]}
{#if draft}
<div class="moderation-threshold-row">
<div><strong>{categoryLabel(category)}</strong><small>{draft.enabled === '1' ? 'AUTOMATED DECISIONS ENABLED' : 'CATEGORY IGNORED'}</small></div>
<SegmentedChoice legend={`${categoryLabel(category)} STATE`} name={`${category}EnabledDraft`} options={binaryOptions} bind:value={draft.enabled}/>
<label class="field"><span class="sr-only">{categoryLabel(category)} review threshold</span><input type="number" min="0" max="1" step="0.01" bind:value={draft.review}/></label>
<label class="field"><span class="sr-only">{categoryLabel(category)} priority threshold</span><input type="number" min="0" max="1" step="0.01" bind:value={draft.priority}/></label>
<CollapsibleSection id="screened-channels" class="moderation-policy-panel moderation-scopes" kicker="CONTENT COVERAGE" title="SCREENED CHANNELS" status={`${[scanComments,scanProfiles,scanGuides,scanAvatars].filter((value) => value === '1').length} / 4 ACTIVE`} statusTone="success" bind:open={coveragePanelOpen}>
<PendingForm action="?/saveModerationCoverage" successMessage="Screened channels saved.">
{#snippet children(pending)}
<p>Disable a channel only when its submission flow should bypass automated checks.</p>
<div class="moderation-scope-list">
<div><span><b>COMMENTS</b><small>Build, guide, and profile discussion</small></span><SegmentedChoice legend="COMMENTS" name="scanComments" options={binaryOptions} bind:value={scanComments}/></div>
<div><span><b>PROFILE BIOS</b><small>Public Agent biography revisions</small></span><SegmentedChoice legend="PROFILE BIOS" name="scanProfiles" options={binaryOptions} bind:value={scanProfiles}/></div>
<div><span><b>GUIDES</b><small>Long-form community field manuals</small></span><SegmentedChoice legend="GUIDES" name="scanGuides" options={binaryOptions} bind:value={scanGuides}/></div>
<div><span><b>AVATARS</b><small>Uploaded custom Agent portraits</small></span><SegmentedChoice legend="AVATARS" name="scanAvatars" options={binaryOptions} bind:value={scanAvatars}/></div>
</div>
{/if}
{/each}
</div>
</section>
{#if form?.moderationCoverageError}<p class="error" role="alert">{form.moderationCoverageError}</p>{/if}
<div class="section-save-row"><span>{coverageChanged ? 'UNSAVED CHANGES' : 'SYNCHRONIZED'}</span><button class="primary" disabled={pending || !coverageChanged} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE CHANNELS'}</button></div>
{/snippet}
</PendingForm>
</CollapsibleSection>
{#if form?.moderationPolicyError}<p class="error moderation-policy-error" role="alert">{form.moderationPolicyError}</p>{/if}
<div class="moderation-policy-actions">
<div>
<span>POLICY CHANGE SET</span>
<b>{moderationChanges.length ? `${moderationChanges.length} PENDING` : 'SYNCHRONIZED'}</b>
<small>UPDATED BY {data.moderationSettings.updated_by_name ?? 'SYSTEM BOOTSTRAP'} · {date(data.moderationSettings.updated_at)}</small>
</div>
<button class="primary" disabled={!moderationChanges.length} onclick={() => reviewingModerationPolicy = true}>REVIEW POLICY →</button>
<CollapsibleSection id="local-safety-limits" class="moderation-policy-panel moderation-local-rules" kicker="DETERMINISTIC CHECKS" title="LOCAL SAFETY LIMITS" status={detectPii === '1' ? 'PII ACTIVE' : 'PII DISABLED'} statusTone={detectPii === '1' ? 'success' : 'danger'} bind:open={limitsPanelOpen}>
<PendingForm action="?/saveModerationLimits" successMessage="Local safety limits saved.">
{#snippet children(pending)}
<p>Local rules supplement provider categories with privacy, link, duplicate, and site-specific terminology checks.</p>
<SegmentedChoice legend="PII DETECTION" name="detectPii" options={binaryOptions} bind:value={detectPii}/>
<div class="moderation-limit-grid">
<label class="field">MAXIMUM LINKS<input name="maxLinks" type="number" min="0" max="20" step="1" bind:value={maxLinks}/><small>0–20 links per submission</small></label>
<label class="field">DUPLICATE WINDOW · SECONDS<input name="duplicateWindowSeconds" type="number" min="0" max="3600" step="1" bind:value={duplicateWindowSeconds}/><small>0 disables duplicate timing</small></label>
</div>
{#if form?.moderationLimitsError}<p class="error" role="alert">{form.moderationLimitsError}</p>{/if}
<div class="section-save-row"><span>{limitsChanged ? 'UNSAVED CHANGES' : 'SYNCHRONIZED'}</span><button class="primary" disabled={pending || !limitsChanged} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE LOCAL LIMITS'}</button></div>
{/snippet}
</PendingForm>
</CollapsibleSection>
</div>
<CollapsibleSection id="moderation-thresholds" class="moderation-threshold-panel" kicker="PROVIDER CATEGORY MATRIX" title="REVIEW & PRIORITY THRESHOLDS" status={`${data.moderationThresholds.length} CATEGORIES`} bind:open={thresholdsPanelOpen}>
<PendingForm action="?/saveModerationThresholds" successMessage="Review and priority thresholds saved.">
{#snippet children(pending)}
<p class="moderation-threshold-description">Review scores hold content for Oversight. Priority scores must be equal to or higher than review scores and move urgent exceptions to the front of the queue.</p>
<div class="moderation-threshold-table">
<div class="moderation-threshold-row heading"><span>CATEGORY</span><span>STATE</span><span>REVIEW · 0–1</span><span>PRIORITY · 0–1</span></div>
{#each data.moderationThresholds as threshold}
{@const category = String(threshold.category)}
{@const draft = thresholdDrafts[category]}
{#if draft}
<div class="moderation-threshold-row">
<div><strong>{categoryLabel(category)}</strong><small>{draft.enabled === '1' ? 'AUTOMATED DECISIONS ENABLED' : 'CATEGORY IGNORED'}</small></div>
<SegmentedChoice legend={`${categoryLabel(category)} STATE`} name={`thresholdEnabled:${category}`} options={binaryOptions} bind:value={draft.enabled}/>
<label class="field"><span class="sr-only">{categoryLabel(category)} review threshold</span><input name={`reviewThreshold:${category}`} type="number" min="0" max="1" step="0.01" bind:value={draft.review}/></label>
<label class="field"><span class="sr-only">{categoryLabel(category)} priority threshold</span><input name={`priorityThreshold:${category}`} type="number" min="0" max="1" step="0.01" bind:value={draft.priority}/></label>
</div>
{/if}
{/each}
</div>
{#if form?.moderationThresholdsError}<p class="error" role="alert">{form.moderationThresholdsError}</p>{/if}
<div class="section-save-row"><span>{thresholdsChanged ? 'UNSAVED CHANGES' : 'SYNCHRONIZED'}</span><button class="primary" disabled={pending || !thresholdsChanged} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE THRESHOLDS'}</button></div>
{/snippet}
</PendingForm>
</CollapsibleSection>
<CollapsibleSection id="moderation-terms" class="moderation-term-section" kicker="DETERMINISTIC RULE REGISTRY" title="ALLOWED & BANNED TERMS" status={`${data.moderationTermPagination.total} RULE${Number(data.moderationTermPagination.total) === 1 ? '' : 'S'}`} bind:open={termsPanelOpen}>
<div class="moderation-rule-toolbar">
<div><span>DETERMINISTIC RULE REGISTRY</span><h3>ALLOWED & BANNED TERMS</h3><p>Normalized rules run alongside provider checks and can target comments, profile bios, guides, or any combination.</p></div>
<button class="primary" disabled={Boolean(editingModerationTerm)} onclick={() => editingModerationTerm = blankModerationTerm()}>ADD RULE +</button>
<p>Normalized rules run alongside provider checks and can target comments, profile bios, guides, or any combination.</p>
<div class="moderation-rule-toolbar-actions">
<button class="secondary" disabled={Boolean(editingModerationTerm || bulkAddingModerationTerms)} onclick={() => bulkAddingModerationTerms = blankBulkModerationTerms()}>BULK ADD</button>
<button class="primary" disabled={Boolean(editingModerationTerm || bulkAddingModerationTerms)} onclick={() => editingModerationTerm = blankModerationTerm()}>ADD RULE +</button>
</div>
</div>
<RegistryPagination pagination={data.moderationTermPagination} namespace="terms" search={data.moderationTermSearch} searchPlaceholder="SEARCH TERM, TYPE, OR NOTE"/>
{#if bulkAddingModerationTerms}
{@const bulkDraft = bulkAddingModerationTerms}
<section class="dashboard-editor horizontal-editor moderation-term-editor moderation-term-bulk-editor" aria-label="Bulk add moderation terms">
<div class="editor-title">
<div><p class="eyebrow">BULK DETERMINISTIC RULES</p><h3>PASTE TERM LIST</h3></div>
<button type="button" aria-label="Close bulk moderation term editor" onclick={() => bulkAddingModerationTerms = null}>×</button>
</div>
<PendingForm action="?/bulkAddModerationTerms" successMessage="Moderation terms added.">
{#snippet children(pending)}
<div class="moderation-term-bulk-grid">
<label class="field moderation-term-bulk-values">
<span>TERMS & PHRASES</span>
<textarea name="terms" rows="9" maxlength="50000" required bind:value={bulkDraft.terms} placeholder={'weapon\nkill\nmachine gun\nassault rifle'}></textarea>
<small>Enter one term or phrase per line. Commas and semicolons also work. Word matching is exact, so add forms such as kill, kills, killed, and killing separately.</small>
</label>
<div class="moderation-term-bulk-options">
<SegmentedChoice legend="RULE EFFECT" name="listType" options={termListOptions} bind:value={bulkDraft.listType}/>
<SegmentedChoice legend="RULE STATE" name="enabled" options={binaryOptions} bind:value={bulkDraft.enabled}/>
<SegmentedChoice legend="CONTENT SCOPES" name="scopes" options={termScopeOptions} mode="checkbox" bind:selected={bulkDraft.scopes}/>
<label class="field">SHARED OPERATIONAL NOTE<textarea name="note" rows="3" maxlength="500" bind:value={bulkDraft.note} placeholder="Optional reason or policy reference for this batch"></textarea></label>
</div>
</div>
<div class="moderation-term-bulk-summary" aria-live="polite">
<span>IMPORT PREVIEW</span>
<strong class:restricted={bulkModerationTermCount > 250}>{bulkModerationTermCount} UNIQUE {bulkModerationTermCount === 1 ? 'ENTRY' : 'ENTRIES'}</strong>
<small>{bulkDraft.listType === 'allow' ? 'ALLOW' : 'BAN'} · {bulkDraft.scopes.length} {bulkDraft.scopes.length === 1 ? 'SCOPE' : 'SCOPES'} · LIMIT 250</small>
</div>
{#if form?.bulkModerationTermsError}<p class="error" role="alert">{form.bulkModerationTermsError}</p>{/if}
{#if form?.bulkModerationTermsSaved}
<p class="moderation-term-bulk-success" role="status">
{form.bulkModerationTermsAdded} {Number(form.bulkModerationTermsAdded) === 1 ? 'rule' : 'rules'} added.
{#if Number(form.bulkModerationTermsSkipped)} {form.bulkModerationTermsSkipped} duplicate {Number(form.bulkModerationTermsSkipped) === 1 ? 'entry was' : 'entries were'} skipped.{/if}
</p>
{/if}
<div class="form-actions">
<button type="button" class="secondary" disabled={pending} onclick={() => bulkAddingModerationTerms = null}>CLOSE</button>
<button class="primary" disabled={pending || !bulkModerationTermCount || bulkModerationTermCount > 250 || !bulkDraft.scopes.length} aria-busy={pending}>{pending ? 'ADDING TERMS…' : `ADD ${bulkModerationTermCount} ${bulkModerationTermCount === 1 ? 'RULE' : 'RULES'} →`}</button>
</div>
{/snippet}
</PendingForm>
</section>
{/if}
{#if editingModerationTerm}
{@const termDraft = editingModerationTerm}
<section class="dashboard-editor horizontal-editor moderation-term-editor" aria-label={editingModerationTerm.id ? `Edit ${editingModerationTerm.value}` : 'Add moderation rule'}>
@@ -421,92 +498,21 @@
<span class:restricted={!Number(term.enabled)}>{Number(term.enabled) ? 'ENABLED' : 'ARCHIVED'}</span>
<span><b>{date(term.updated_at)}</b><small>{term.updated_by_name ?? 'SYSTEM'}</small></span>
<div class="moderation-rule-actions">
<button class="secondary" disabled={Boolean(editingModerationTerm)} onclick={() => editModerationTerm(term)}>EDIT</button>
{#if Number(term.enabled)}<button class="danger" disabled={Boolean(editingModerationTerm)} onclick={() => archivingModerationTerm = term}>ARCHIVE</button>{/if}
<button class="secondary" disabled={Boolean(editingModerationTerm || bulkAddingModerationTerms)} onclick={() => editModerationTerm(term)}>EDIT</button>
{#if Number(term.enabled)}<button class="danger" disabled={Boolean(editingModerationTerm || bulkAddingModerationTerms)} onclick={() => archivingModerationTerm = term}>ARCHIVE</button>{/if}
</div>
</div>
{:else}
<div class="moderation-rule-empty"><strong>NO DETERMINISTIC TERMS CONFIGURED</strong><span>Provider thresholds and local structural checks remain active. Add a term only for a specific SHD.build policy need.</span></div>
{#if data.moderationTermSearch}
<div class="moderation-rule-empty"><strong>NO TERMS MATCH THIS SEARCH</strong><span>Clear the search or try a term, rule type, match mode, or operational note.</span></div>
{:else}
<div class="moderation-rule-empty"><strong>NO DETERMINISTIC TERMS CONFIGURED</strong><span>Provider thresholds and local structural checks remain active. Add a term only for a specific SHD.build policy need.</span></div>
{/if}
{/each}
</div>
</CollapsibleSection>
</section>
{#if reviewing}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal site-control-review" role="dialog" aria-modal="true" aria-labelledby="site-control-review-title">
<div class="editor-title">
<div><p class="eyebrow">CONFIRM SITE ACCESS PROFILE</p><h3 id="site-control-review-title">{changes.length} OPERATIONAL CHANGE{changes.length === 1 ? '' : 'S'}</h3></div>
<button type="button" aria-label="Close site control review" onclick={() => reviewing = false}>×</button>
</div>
<p>These settings change which public SHD.build services agents can reach. Review the full change set before deployment.</p>
<div class="site-control-change-list">
{#each changes as change}
<div><strong>{change.label}</strong><span>{change.before}</span><b>→</b><span class:restricted={change.after === 'DISABLED' || change.after === 'COMING SOON'}>{change.after}</span></div>
{/each}
</div>
<PendingForm action="?/save" successMessage="Site access settings updated.">
{#snippet children(pending)}
<input type="hidden" name="confirmation" value="apply-site-access-settings"/>
<input type="hidden" name="comingSoonEnabled" value={comingSoonEnabled}/>
<input type="hidden" name="accountCreationEnabled" value={accountCreationEnabled}/>
{#each sectionDefinitions as section}<input type="hidden" name={`${section.key}Enabled`} value={sectionValues[section.key]}/>{/each}
<div class="form-actions">
<button type="button" class="secondary" onclick={() => reviewing = false} disabled={pending}>CANCEL</button>
<button class="primary" disabled={pending || !changes.length} aria-busy={pending}>{pending ? 'APPLYING PROFILE…' : 'CONFIRM SITE CONTROLS →'}</button>
</div>
{/snippet}
</PendingForm>
</div>
</div>
{/if}
{#if reviewingModerationPolicy}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal moderation-policy-review" role="dialog" aria-modal="true" aria-labelledby="moderation-policy-review-title">
<div class="editor-title">
<div><p class="eyebrow">CONFIRM COMMUNITY SAFETY POLICY</p><h3 id="moderation-policy-review-title">{moderationChanges.length} POLICY CHANGE{moderationChanges.length === 1 ? '' : 'S'}</h3></div>
<button type="button" aria-label="Close moderation policy review" onclick={() => reviewingModerationPolicy = false}>×</button>
</div>
<p>These settings control which submissions are scanned, published, or held for Community Oversight. Applying them creates policy version {Number(data.moderationSettings.policy_version) + 1}.</p>
<div class="site-control-change-list moderation-change-list">
{#each moderationChanges as change}
<div><strong>{change.label}</strong><span>{change.before}</span><b>→</b><span class:restricted={['DISABLED','HOLD','BYPASS'].includes(change.after)}>{change.after}</span></div>
{/each}
</div>
<PendingForm action="?/saveModerationPolicy" successMessage="Community moderation policy updated.">
{#snippet children(pending)}
<input type="hidden" name="confirmation" value="apply-community-moderation-policy"/>
<input type="hidden" name="automatedEnabled" value={automatedEnabled}/>
<input type="hidden" name="autoPublishClean" value={autoPublishClean}/>
<input type="hidden" name="holdOnProviderError" value={holdOnProviderError}/>
<input type="hidden" name="scanComments" value={scanComments}/>
<input type="hidden" name="scanProfiles" value={scanProfiles}/>
<input type="hidden" name="scanGuides" value={scanGuides}/>
<input type="hidden" name="scanAvatars" value={scanAvatars}/>
<input type="hidden" name="detectPii" value={detectPii}/>
<input type="hidden" name="providerModel" value={providerModel}/>
<input type="hidden" name="maxLinks" value={maxLinks}/>
<input type="hidden" name="duplicateWindowSeconds" value={duplicateWindowSeconds}/>
{#each data.moderationThresholds as threshold}
{@const category = String(threshold.category)}
{@const draft = thresholdDrafts[category]}
{#if draft}
<input type="hidden" name={`thresholdEnabled:${category}`} value={draft.enabled}/>
<input type="hidden" name={`reviewThreshold:${category}`} value={draft.review}/>
<input type="hidden" name={`priorityThreshold:${category}`} value={draft.priority}/>
{/if}
{/each}
{#if form?.moderationPolicyError}<p class="error" role="alert">{form.moderationPolicyError}</p>{/if}
<div class="form-actions">
<button type="button" class="secondary" disabled={pending} onclick={() => reviewingModerationPolicy = false}>CANCEL</button>
<button class="primary" disabled={pending || !moderationChanges.length} aria-busy={pending}>{pending ? 'APPLYING POLICY…' : 'CONFIRM POLICY →'}</button>
</div>
{/snippet}
</PendingForm>
</div>
</div>
{/if}
{#if archivingModerationTerm}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal moderation-archive-review" role="dialog" aria-modal="true" aria-labelledby="moderation-archive-title">
@@ -533,15 +539,16 @@
{/if}
<style>
.site-control-toolbar{align-items:stretch}.site-control-state{display:grid;align-content:center;min-width:230px;padding:10px 14px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:var(--shd-surface)}.site-control-state span,.site-control-state small,.site-control-panel header span,.site-control-actions span,.site-control-actions small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.site-control-state strong{color:var(--shd-success);font-size:21px}.site-control-state strong.restricted,.site-control-panel b.restricted,.site-control-change-list span.restricted{color:var(--shd-danger)}.site-control-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.site-control-panel{display:grid;align-content:start;gap:10px;padding:12px;border:1px solid var(--shd-border);background:var(--shd-surface)}.site-control-panel>header{display:flex;justify-content:space-between;gap:12px;align-items:start;padding-bottom:9px;border-bottom:1px solid var(--shd-border)}.site-control-panel h3,.site-control-panel p{margin:0}.site-control-panel>p{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.site-control-panel header b{color:var(--shd-success);font-size:var(--shd-type-supporting);letter-spacing:.8px}.public-sections{grid-column:1/-1}.section-control-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.section-control-list article{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,.8fr);gap:10px;align-items:center;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.section-control-list article>div{display:grid;gap:2px}.section-control-list small{color:var(--shd-text-muted)}.site-control-actions{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:9px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.site-control-actions>div:first-child{display:grid}.site-control-action-buttons{display:flex;gap:7px}.site-control-action-buttons>a{display:flex;align-items:center}.site-control-actions b{color:var(--shd-orange)}.site-control-review>p{margin:0;padding:12px;color:var(--shd-text-muted)}.site-control-change-list{display:grid;gap:4px;padding:0 12px 12px}.site-control-change-list>div{display:grid;grid-template-columns:minmax(140px,1fr) minmax(110px,.7fr) 28px minmax(110px,.7fr);gap:7px;align-items:center;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.site-control-change-list>div>b{text-align:center;color:var(--shd-orange)}.site-control-change-list span:last-child{font-weight:900}.site-control-review .form-actions{padding:12px;border-top:1px solid var(--shd-border)}
.community-safety{display:grid;gap:8px;margin-top:20px;padding-top:18px;border-top:1px solid var(--shd-orange)}.community-safety-head{display:flex;align-items:stretch;justify-content:space-between;gap:12px}.community-safety-head>div:first-child{max-width:72ch}.community-safety-head h2,.community-safety-head p,.moderation-policy-panel h3,.moderation-policy-panel p,.moderation-threshold-panel h3,.moderation-threshold-panel p,.moderation-rule-toolbar h3,.moderation-rule-toolbar p{margin:0}.community-safety-head>div:first-child>p:last-child,.moderation-policy-panel>p,.moderation-threshold-panel>header>p,.moderation-rule-toolbar p{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}.moderation-network-state{min-width:270px;display:grid;align-content:center;padding:10px 14px;border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-network-state span,.moderation-network-state small,.moderation-policy-panel header span,.moderation-threshold-panel header span,.moderation-policy-actions span,.moderation-policy-actions small,.moderation-rule-toolbar span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.moderation-network-state strong{color:var(--shd-success);font-size:21px}.moderation-network-state strong.restricted,.moderation-policy-panel header b.restricted,.moderation-rule-row>span.restricted{color:var(--shd-danger)}.moderation-provider-warning{display:flex;align-items:center;gap:10px;padding:9px 11px;border:1px solid var(--shd-danger);background:#1b1111}.moderation-provider-warning b{flex:0 0 auto;color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.moderation-provider-warning span{color:#d9aaa0;font-size:var(--shd-type-supporting)}
.moderation-policy-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.moderation-policy-panel{min-width:0;display:grid;align-content:start;gap:10px;padding:12px;border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-policy-panel>header{display:flex;align-items:start;justify-content:space-between;gap:12px;padding-bottom:9px;border-bottom:1px solid var(--shd-border)}.moderation-policy-panel header span{display:block}.moderation-policy-panel header b{color:var(--shd-success);font-size:var(--shd-type-supporting);letter-spacing:.8px}.moderation-master :global(.segmented-choice),.moderation-publication :global(.segmented-choice),.moderation-local-rules :global(.segmented-choice){margin:0}.moderation-scope-list{display:grid;gap:5px}.moderation-scope-list>div{display:grid;grid-template-columns:minmax(0,1fr) minmax(190px,.7fr);align-items:center;gap:10px;padding:8px;border:1px solid var(--shd-border-soft);background:#0d1011}.moderation-scope-list>div>span{min-width:0;display:grid;gap:2px}.moderation-scope-list small{color:var(--shd-text-muted);overflow-wrap:anywhere}.moderation-scope-list :global(.segmented-choice){margin:0}.moderation-limit-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.moderation-limit-grid .field{margin:0}.moderation-limit-grid small{color:var(--shd-text-muted)}
.moderation-threshold-panel{border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-threshold-panel>header{display:grid;grid-template-columns:minmax(220px,.7fr) minmax(0,1.3fr);gap:16px;padding:12px;border-bottom:1px solid var(--shd-border)}.moderation-threshold-table{overflow-x:auto}.moderation-threshold-row{min-width:760px;display:grid;grid-template-columns:minmax(220px,1.2fr) minmax(210px,1fr) minmax(120px,.55fr) minmax(120px,.55fr);align-items:center;gap:8px;padding:7px 9px;border-top:1px solid var(--shd-border-soft)}.moderation-threshold-row:first-child{border-top:0}.moderation-threshold-row.heading{min-height:34px;background:#0d1011;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.moderation-threshold-row>div:first-child{min-width:0;display:grid;gap:2px}.moderation-threshold-row>div:first-child strong{overflow-wrap:anywhere}.moderation-threshold-row>div:first-child small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.moderation-threshold-row :global(.segmented-choice),.moderation-threshold-row .field{margin:0}.moderation-threshold-row .field input{margin:0}.moderation-policy-error{margin:0}.moderation-policy-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-policy-actions>div{display:grid}.moderation-policy-actions b{color:var(--shd-orange)}
.moderation-rule-toolbar{display:flex;align-items:end;justify-content:space-between;gap:12px;margin-top:12px;padding-bottom:10px;border-bottom:1px solid var(--shd-border)}.moderation-rule-toolbar>div{max-width:72ch}.moderation-term-editor{margin:0}.moderation-term-grid{display:grid;grid-template-columns:minmax(240px,1.25fr) repeat(3,minmax(170px,.65fr));gap:8px;padding:10px}.moderation-term-grid>.moderation-term-value{grid-row:span 2}.moderation-term-grid>:global(.segmented-choice){align-self:end;margin:0}.moderation-term-grid>:global(.segmented-choice):nth-of-type(4){grid-column:2/4}.moderation-term-note{grid-column:4;grid-row:2}.moderation-term-grid .field{margin:0}.moderation-term-grid small{color:var(--shd-text-muted)}.moderation-term-editor :global(form>.error){margin:0 10px}.moderation-term-editor :global(.form-actions){padding:10px;border-top:1px solid var(--shd-border)}
.site-control-toolbar{align-items:stretch}.site-control-state{display:grid;align-content:center;min-width:230px;padding:10px 14px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:var(--shd-surface)}.site-control-state span,.site-control-state small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.site-control-state strong{color:var(--shd-success);font-size:var(--shd-type-body)}.site-control-state strong.restricted{color:var(--shd-danger)}.site-control-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}:global(.site-control-panel){min-width:0}:global(.site-control-panel) p{margin:0 0 10px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}:global(.public-sections){grid-column:1/-1}.section-control-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.section-control-list article{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,.8fr);gap:10px;align-items:center;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.section-control-list article>div{display:grid;gap:2px}.section-control-list small{color:var(--shd-text-muted)}.section-save-row{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--shd-border-soft)}.section-save-row>span{margin-right:auto;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.section-save-row>a{display:flex;align-items:center}.section-save-row .primary{min-width:170px}
.community-safety{display:grid;gap:8px;margin-top:20px;padding-top:18px;border-top:1px solid var(--shd-orange)}.community-safety-head{display:flex;align-items:stretch;justify-content:space-between;gap:12px}.community-safety-head>div:first-child{max-width:72ch}.community-safety-head h2,.community-safety-head p,:global(.moderation-policy-panel) p,:global(.moderation-threshold-panel) p,.moderation-rule-toolbar p{margin:0}.community-safety-head>div:first-child>p:last-child,:global(.moderation-policy-panel) p,.moderation-threshold-description,.moderation-rule-toolbar p{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}.moderation-network-state{min-width:270px;display:grid;align-content:center;padding:10px 14px;border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-network-state span,.moderation-network-state small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.moderation-network-state strong{color:var(--shd-success);font-size:var(--shd-type-body)}.moderation-network-state strong.restricted,.moderation-rule-row>span.restricted{color:var(--shd-danger)}.moderation-provider-warning{display:flex;align-items:center;gap:10px;padding:9px 11px;border:1px solid var(--shd-danger);background:color-mix(in srgb,var(--shd-danger) 10%,var(--shd-surface))}.moderation-provider-warning b{flex:0 0 auto;color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.moderation-provider-warning span{color:var(--shd-danger);font-size:var(--shd-type-supporting)}
.moderation-policy-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}:global(.moderation-policy-panel){min-width:0}:global(.moderation-policy-panel) p{margin-bottom:10px}:global(.moderation-master) :global(.segmented-choice),:global(.moderation-publication) :global(.segmented-choice),:global(.moderation-local-rules) :global(.segmented-choice){margin:0}.moderation-scope-list{display:grid;gap:5px}.moderation-scope-list>div{display:grid;grid-template-columns:minmax(0,1fr) minmax(190px,.7fr);align-items:center;gap:10px;padding:8px;border:1px solid var(--shd-border-soft);background:#0d1011}.moderation-scope-list>div>span{min-width:0;display:grid;gap:2px}.moderation-scope-list small{color:var(--shd-text-muted);overflow-wrap:anywhere}.moderation-scope-list :global(.segmented-choice){margin:0}.moderation-limit-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.moderation-limit-grid .field{margin:0}.moderation-limit-grid small{color:var(--shd-text-muted)}
.moderation-threshold-description{padding-bottom:10px}.moderation-threshold-table{overflow-x:auto}.moderation-threshold-row{min-width:760px;display:grid;grid-template-columns:minmax(220px,1.2fr) minmax(210px,1fr) minmax(120px,.55fr) minmax(120px,.55fr);align-items:center;gap:8px;padding:7px 9px;border-top:1px solid var(--shd-border-soft)}.moderation-threshold-row:first-child{border-top:0}.moderation-threshold-row.heading{min-height:34px;background:#0d1011;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.moderation-threshold-row>div:first-child{min-width:0;display:grid;gap:2px}.moderation-threshold-row>div:first-child strong{overflow-wrap:anywhere}.moderation-threshold-row>div:first-child small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.moderation-threshold-row :global(.segmented-choice),.moderation-threshold-row .field{margin:0}.moderation-threshold-row .field input{margin:0}
.moderation-rule-toolbar{display:flex;align-items:end;justify-content:space-between;gap:12px;padding-bottom:10px}.moderation-rule-toolbar>p{max-width:72ch}.moderation-rule-toolbar-actions{display:flex;gap:7px}:global(.moderation-term-section) :global(.registry-pagination){margin:0 0 8px}.moderation-term-editor{margin:0}.moderation-term-grid{display:grid;grid-template-columns:minmax(240px,1.25fr) repeat(3,minmax(170px,.65fr));gap:8px;padding:10px}.moderation-term-grid>.moderation-term-value{grid-row:span 2}.moderation-term-grid>:global(.segmented-choice){align-self:end;margin:0}.moderation-term-grid>:global(.segmented-choice):nth-of-type(4){grid-column:2/4}.moderation-term-note{grid-column:4;grid-row:2}.moderation-term-grid .field{margin:0}.moderation-term-grid small{color:var(--shd-text-muted)}.moderation-term-editor :global(form>.error){margin:0 10px}.moderation-term-editor :global(.form-actions){padding:10px;border-top:1px solid var(--shd-border)}
.moderation-term-bulk-grid{display:grid;grid-template-columns:minmax(300px,1.2fr) minmax(360px,.8fr);gap:10px;padding:10px}.moderation-term-bulk-grid .field{margin:0}.moderation-term-bulk-values textarea{min-height:244px;resize:vertical}.moderation-term-bulk-values small{color:var(--shd-text-muted);line-height:1.45}.moderation-term-bulk-options{display:grid;align-content:start;gap:8px}.moderation-term-bulk-options :global(.segmented-choice){margin:0}.moderation-term-bulk-summary{display:grid;grid-template-columns:minmax(140px,.5fr) minmax(180px,.8fr) minmax(0,1.2fr);align-items:center;gap:8px;margin:0 10px 10px;padding:9px 10px;border:1px solid var(--shd-border);background:#0d1011}.moderation-term-bulk-summary span,.moderation-term-bulk-summary small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}.moderation-term-bulk-summary strong{color:var(--shd-success)}.moderation-term-bulk-summary strong.restricted{color:var(--shd-danger)}.moderation-term-bulk-success{margin:0 10px 10px;padding:8px 10px;border:1px solid var(--shd-success);color:var(--shd-success);font-size:var(--shd-type-supporting);font-weight:900}
.moderation-rule-registry{border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-rule-row{display:grid;grid-template-columns:minmax(230px,1.25fr) minmax(180px,.9fr) minmax(90px,.45fr) minmax(160px,.7fr) minmax(150px,.55fr);align-items:center;gap:9px;padding:9px;border-top:1px solid var(--shd-border-soft)}.moderation-rule-row:first-child{border-top:0}.moderation-rule-row.heading{min-height:34px;background:#0d1011;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.moderation-rule-row.archived{opacity:.65}.moderation-rule-row>div:first-child,.moderation-rule-row>span:nth-child(4){min-width:0;display:grid;gap:3px}.moderation-rule-row>div:first-child small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);overflow-wrap:anywhere}.moderation-rule-row>span{font-size:var(--shd-type-telemetry);font-weight:900}.moderation-rule-row>span:nth-child(4) small{color:var(--shd-text-muted);font-weight:400}.moderation-rule-scopes{display:flex;flex-wrap:wrap;gap:4px}.moderation-rule-scopes b{padding:3px 5px;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.5px}.moderation-rule-actions{display:grid;grid-template-columns:1fr 1fr;gap:5px}.moderation-rule-actions button{min-height:36px;padding:0 8px}.moderation-rule-empty{min-height:130px;display:grid;place-content:center;gap:5px;padding:20px;text-align:center}.moderation-rule-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.moderation-rule-empty span{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}
.moderation-policy-review{width:min(930px,calc(100vw - 28px));max-height:calc(100vh - 28px);overflow:auto}.moderation-policy-review>p{margin:0;padding:12px;color:var(--shd-text-muted)}.moderation-change-list{max-height:48vh;overflow:auto}.moderation-policy-review :global(.form-actions),.moderation-archive-review :global(.form-actions){padding:12px;border-top:1px solid var(--shd-border)}.moderation-policy-review :global(form>.error),.moderation-archive-review :global(form>.error){margin:0 12px 12px}.moderation-archive-review{width:min(620px,calc(100vw - 28px))}.moderation-archive-consequence{display:grid;gap:5px;padding:14px}.moderation-archive-consequence b{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.moderation-archive-consequence p{margin:0;color:var(--shd-text-muted);line-height:1.45}
.moderation-archive-review :global(.form-actions){padding:12px;border-top:1px solid var(--shd-border)}.moderation-archive-review :global(form>.error){margin:0 12px 12px}.moderation-archive-review{width:min(620px,calc(100vw - 28px))}.moderation-archive-consequence{display:grid;gap:5px;padding:14px}.moderation-archive-consequence b{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.moderation-archive-consequence p{margin:0;color:var(--shd-text-muted);line-height:1.45}
@media(max-width:1100px){.moderation-term-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.moderation-term-grid>.moderation-term-value{grid-row:auto}.moderation-term-grid>:global(.segmented-choice):nth-of-type(4),.moderation-term-note{grid-column:auto;grid-row:auto}.moderation-rule-registry{overflow-x:auto}.moderation-rule-row{min-width:900px}}
@media(max-width:900px){.site-control-grid,.moderation-policy-grid{grid-template-columns:1fr}.public-sections{grid-column:auto}.section-control-list{grid-template-columns:1fr}.community-safety-head{display:grid}.moderation-network-state{min-width:0}.moderation-threshold-panel>header{grid-template-columns:1fr}.moderation-provider-warning{align-items:start;flex-direction:column}}
@media(max-width:650px){.site-control-state{min-width:0}.section-control-list article,.moderation-scope-list>div,.moderation-limit-grid,.moderation-term-grid{grid-template-columns:1fr}.site-control-actions,.moderation-policy-actions,.moderation-rule-toolbar{display:grid}.site-control-action-buttons{display:grid;grid-template-columns:1fr}.site-control-action-buttons>*,.moderation-policy-actions>button,.moderation-rule-toolbar>button{width:100%;justify-content:center}.site-control-change-list>div{grid-template-columns:1fr auto 1fr}.site-control-change-list strong{grid-column:1/-1}.site-control-change-list span:last-child{text-align:right}.moderation-scope-list :global(.segmented-choice span),.moderation-policy-panel :global(.segmented-choice span){min-height:44px}.moderation-policy-review :global(.form-actions),.moderation-archive-review :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}.moderation-term-note{grid-column:auto}.moderation-term-editor :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}}
@media(prefers-reduced-motion:reduce){.moderation-rule-row,.moderation-policy-panel button,.moderation-rule-toolbar button{transition:none}}
@media(max-width:900px){.site-control-grid,.moderation-policy-grid,.moderation-term-bulk-grid{grid-template-columns:1fr}:global(.public-sections){grid-column:auto}.section-control-list{grid-template-columns:1fr}.community-safety-head{display:grid}.moderation-network-state{min-width:0}.moderation-provider-warning{align-items:start;flex-direction:column}}
@media(max-width:650px){.site-control-state{min-width:0}.section-control-list article,.moderation-scope-list>div,.moderation-limit-grid,.moderation-term-grid{grid-template-columns:1fr}.moderation-rule-toolbar,.section-save-row{display:grid}.moderation-rule-toolbar-actions{display:grid;grid-template-columns:1fr}.moderation-rule-toolbar-actions>button,.section-save-row>*{width:100%;justify-content:center}.section-save-row>span{text-align:center}.moderation-scope-list :global(.segmented-choice span),:global(.moderation-policy-panel) :global(.segmented-choice span),.moderation-term-bulk-options :global(.segmented-choice span){min-height:44px}.moderation-term-bulk-options :global(.segmented-choice.multi>div){height:auto;grid-template-columns:1fr}.moderation-archive-review :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}.moderation-term-note{grid-column:auto}.moderation-term-editor :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}}
@media(prefers-reduced-motion:reduce){.moderation-rule-row,:global(.moderation-policy-panel) button,.moderation-rule-toolbar button{transition:none}}
</style>
@@ -0,0 +1,223 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
audits: [] as Array<Record<string, unknown>>,
executed: [] as string[],
inserts: [] as unknown[][],
invalidatePolicy: vi.fn(),
invalidateSiteAccess: vi.fn(),
siteSettings: {} as Record<string, unknown>,
moderationSettings: {} as Record<string, unknown>
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => ({ id: 'operator-1' }))
}));
vi.mock('$lib/server/auth/authorization', () => ({
requirePermission: vi.fn(async (user) => user)
}));
vi.mock('$lib/server/audit', () => ({
recordIsacAudit: vi.fn(async (event) => {
mocks.audits.push(event);
})
}));
vi.mock('$lib/server/site-access', () => ({
invalidateSiteAccessSettings: mocks.invalidateSiteAccess
}));
vi.mock('$lib/server/community-moderation', async (importOriginal) => {
const actual = await importOriginal<typeof import('$lib/server/community-moderation')>();
return { ...actual, invalidateCommunityModerationPolicy: mocks.invalidatePolicy };
});
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => ({
exec: async (statement: string) => {
mocks.executed.push(statement);
},
prepare: (statement: string) => ({
all: async () => statement.includes('FROM community_moderation_terms') ? [] : [],
get: async () => {
if (statement.includes('FROM site_launch_settings')) return { ...mocks.siteSettings };
if (statement.includes('FROM community_moderation_settings')) return { ...mocks.moderationSettings };
return null;
},
run: async (...values: unknown[]) => {
if (statement.includes('INSERT INTO community_moderation_terms')) mocks.inserts.push(values);
if (statement.includes('SET coming_soon_enabled=')) {
mocks.siteSettings.coming_soon_enabled = values[0];
mocks.siteSettings.updated_at = values[1];
mocks.siteSettings.updated_by = values[2];
}
if (statement.includes('SET\n\t\t\tautomated_enabled=')) {
mocks.moderationSettings.automated_enabled = values[0];
mocks.moderationSettings.provider_model = values[1];
mocks.moderationSettings.policy_version = Number(mocks.moderationSettings.policy_version) + 1;
mocks.moderationSettings.updated_at = values[2];
mocks.moderationSettings.updated_by = values[3];
}
return { changes: 1 };
}
})
}))
}));
import { actions } from './+page.server';
describe('bulk moderation term action', () => {
beforeEach(() => {
mocks.audits.length = 0;
mocks.executed.length = 0;
mocks.inserts.length = 0;
mocks.invalidatePolicy.mockClear();
mocks.invalidateSiteAccess.mockClear();
mocks.siteSettings = {
id: 'primary',
coming_soon_enabled: 1,
account_creation_enabled: 1,
updated_at: '2026-07-01T00:00:00.000Z',
updated_by: null
};
mocks.moderationSettings = {
id: 'primary',
automated_enabled: 1,
provider_model: 'omni-moderation-latest',
policy_version: 4,
updated_at: '2026-07-01T00:00:00.000Z',
updated_by: null
};
});
it('creates and audits each normalized rule in one transaction', async () => {
const form = new FormData();
form.set('listType', 'allow');
form.set('enabled', '1');
form.append('scopes', 'comment');
form.append('scopes', 'guide');
form.set('terms', 'Kill\nmachine gun\nkill');
form.set('note', 'Common game vocabulary');
const result = await actions.bulkAddModerationTerms?.({
cookies: {} as never,
request: new Request('http://localhost/isac/site-controls?/bulkAddModerationTerms', {
method: 'POST',
body: form
})
} as never);
expect(result).toMatchObject({
bulkModerationTermsSaved: true,
bulkModerationTermsAdded: 2,
bulkModerationTermsSkipped: 1
});
expect(mocks.executed).toEqual(['BEGIN IMMEDIATE', 'COMMIT']);
expect(mocks.inserts).toHaveLength(2);
expect(mocks.inserts.map((values) => values.slice(1, 5))).toEqual([
['allow', 'word', 'Kill', 'kill'],
['allow', 'phrase', 'machine gun', 'machine gun']
]);
expect(mocks.audits).toHaveLength(2);
expect(mocks.audits[0]).toMatchObject({
actorId: 'operator-1',
category: 'system_configuration',
action: 'moderation.term_created',
entityId: expect.any(String),
before: null,
after: {
listKind: 'allow',
matchMode: 'word',
value: 'Kill',
normalizedValue: 'kill',
scopes: ['comment', 'guide'],
enabled: 1,
note: 'Common game vocabulary'
}
});
expect(mocks.invalidatePolicy).toHaveBeenCalledOnce();
});
it('saves and audits the launch gate without changing unrelated site controls', async () => {
const form = new FormData();
form.set('comingSoonEnabled', '0');
const result = await actions.saveLaunchGate?.({
cookies: {} as never,
request: new Request('http://localhost/isac/site-controls?/saveLaunchGate', {
method: 'POST',
body: form
})
} as never);
expect(result).toMatchObject({ launchGateSaved: true });
expect(mocks.siteSettings).toMatchObject({
coming_soon_enabled: 0,
account_creation_enabled: 1,
updated_by: 'operator-1'
});
expect(mocks.invalidateSiteAccess).toHaveBeenCalledOnce();
expect(mocks.audits).toEqual([
expect.objectContaining({
actorId: 'operator-1',
category: 'system_configuration',
action: 'site_access.launch_gate_updated',
entityId: 'primary',
before: expect.objectContaining({ coming_soon_enabled: 1 }),
after: expect.objectContaining({ coming_soon_enabled: 0 })
})
]);
});
it('saves one moderation panel, increments policy version, and audits its evidence', async () => {
const form = new FormData();
form.set('automatedEnabled', '0');
form.set('providerModel', 'omni-moderation-2024-09-26');
const result = await actions.saveModerationScreening?.({
cookies: {} as never,
request: new Request('http://localhost/isac/site-controls?/saveModerationScreening', {
method: 'POST',
body: form
})
} as never);
expect(result).toMatchObject({ moderationScreeningSaved: true });
expect(mocks.moderationSettings).toMatchObject({
automated_enabled: 0,
provider_model: 'omni-moderation-2024-09-26',
policy_version: 5,
updated_by: 'operator-1'
});
expect(mocks.invalidatePolicy).toHaveBeenCalledOnce();
expect(mocks.audits).toEqual([
expect.objectContaining({
actorId: 'operator-1',
category: 'system_configuration',
action: 'moderation.screening_updated',
entityId: 'primary',
before: expect.objectContaining({ policy_version: 4 }),
after: expect.objectContaining({ policy_version: 5 })
})
]);
});
it('rejects an invalid panel value without mutation, audit, or cache invalidation', async () => {
const form = new FormData();
form.set('automatedEnabled', 'sometimes');
form.set('providerModel', 'omni-moderation-latest');
const result = await actions.saveModerationScreening?.({
cookies: {} as never,
request: new Request('http://localhost/isac/site-controls?/saveModerationScreening', {
method: 'POST',
body: form
})
} as never);
expect(result).toMatchObject({ status: 400 });
expect(mocks.moderationSettings).toMatchObject({ policy_version: 4 });
expect(mocks.audits).toHaveLength(0);
expect(mocks.invalidatePolicy).not.toHaveBeenCalled();
});
});