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