feat: add automated community moderation

This commit is contained in:
JB
2026-07-27 16:09:56 -04:00
parent 6b76110b76
commit 27cbeca0ed
24 changed files with 3241 additions and 83 deletions
+24 -2
View File
@@ -15,8 +15,11 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
const canProposeData = permissions.includes('*') || permissions.includes('data.propose');
const canApproveData = permissions.includes('*') || permissions.includes('data.approve');
const canReviewAvatars = permissions.includes('*') || permissions.includes('avatars.review');
const canReviewCommunity = permissions.includes('*')
|| permissions.includes('community.comments.moderate')
|| permissions.includes('community.profiles.moderate');
const contributorMode = !canManageData && canProposeData ? await getContributorMode(user.id) : null;
const database = canSeeData || canReviewAvatars ? await getDatabase() : null;
const database = canSeeData || canReviewAvatars || canReviewCommunity ? await getDatabase() : null;
const pendingRows = canSeeData
? await (await database!.prepare("SELECT module,COUNT(*) AS count FROM data_change_proposals WHERE status='pending' GROUP BY module")).all()
: [];
@@ -24,6 +27,24 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
const pendingAvatarReviews = canReviewAvatars
? Number((await (await database!.prepare("SELECT COUNT(*) AS count FROM avatar_reviews WHERE status='pending'")).get())?.count ?? 0)
: 0;
const canReviewComments = permissions.includes('*') || permissions.includes('community.comments.moderate');
const canReviewProfiles = permissions.includes('*') || permissions.includes('community.profiles.moderate');
const communityReviewTypes = [
...(canReviewComments ? ['comment', 'guide'] : []),
...(canReviewProfiles ? ['profile_bio'] : [])
];
const communityTypePlaceholders = communityReviewTypes.map(() => '?').join(',');
const pendingCommunityReviews = canReviewCommunity && communityReviewTypes.length
? Number((await (await database!.prepare(`SELECT
(SELECT COUNT(*) FROM content_moderation_scans scan
WHERE scan.content_type IN (${communityTypePlaceholders})
AND scan.status IN ('review','priority_review','error')
AND NOT EXISTS(SELECT 1 FROM content_moderation_reviews review WHERE review.scan_id=scan.id))
+
(SELECT COUNT(*) FROM user_content_reports
WHERE content_type IN (${communityTypePlaceholders}) AND status='pending') AS count`))
.get(...communityReviewTypes, ...communityReviewTypes))?.count ?? 0)
: 0;
const configuredBuildNumber = process.env.SHD_BUILD_NUMBER?.trim() || '0';
const buildNumber = /^\d+$/.test(configuredBuildNumber) ? configuredBuildNumber : '0';
return {
@@ -33,7 +54,8 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
pendingProposals: Object.fromEntries(pendingRows.map((row) => [String(row.module), Number(row.count)])),
oversightReviewCounts: {
data: canApproveData ? pendingProposalTotal : 0,
avatars: pendingAvatarReviews
avatars: pendingAvatarReviews,
community: pendingCommunityReviews
}
};
};
+2 -2
View File
@@ -33,10 +33,10 @@
{ href: '/isac/roles', label: 'Roles', permission: 'users.roles' }
];
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
const oversightSections: Array<{ href: string; label: string; permission?: string; permissions?: string[]; reviewKey?: 'data' | 'avatars' }> = [
const oversightSections: Array<{ href: string; label: string; permission?: string; permissions?: string[]; reviewKey?: 'data' | 'avatars' | 'community' }> = [
{ href: '/isac/data-moderation', label: 'Data Oversight', permission: 'data.approve', reviewKey: 'data' },
{ href: '/isac/avatar-oversight', label: 'Avatar Oversight', permission: 'avatars.review', reviewKey: 'avatars' },
{ href: '/isac/community', label: 'Community Oversight', permissions: ['community.comments.moderate', 'community.profiles.moderate'] },
{ href: '/isac/community', label: 'Community Oversight', permissions: ['community.comments.moderate', 'community.profiles.moderate'], reviewKey: 'community' },
{ href: '/isac/audit', label: 'Audit history', permission: 'audit.view' }
];
const visibleOversightSections = $derived(oversightSections.filter((section) =>
@@ -56,6 +56,9 @@ export const actions: Actions = {
const now = new Date().toISOString();
await (await database.prepare("UPDATE avatar_reviews SET status='approved',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=? AND status='pending'"))
.run(now, reviewer.id, note || null, id);
await (await database.prepare(`UPDATE user_content_reports SET status='dismissed',reviewed_at=?,
reviewed_by=?,resolution_reason=? WHERE content_type='avatar' AND content_id=? AND status='pending'`))
.run(now, reviewer.id, note || 'Oversight approved the current custom avatar.', review.user_id);
await recordIsacAudit({ actorId: reviewer.id, category: 'user_moderation', action: 'avatar.approved', entityId: String(review.user_id), before: { reviewId: id, status: 'pending' }, after: { reviewId: id, status: 'approved', note: note || null } });
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
},
@@ -77,6 +80,9 @@ export const actions: Actions = {
const now = new Date().toISOString();
await (await database.prepare("UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=?"))
.run(now, reviewer.id, note, id);
await (await database.prepare(`UPDATE user_content_reports SET status='resolved',reviewed_at=?,
reviewed_by=?,resolution_reason=? WHERE content_type='avatar' AND content_id=? AND status='pending'`))
.run(now, reviewer.id, note, review.user_id);
await recordIsacAudit({ actorId: reviewer.id, category: 'user_moderation', action: 'avatar.removed', entityId: String(review.user_id), before, after: { reviewId: id, status: 'removed', source: 'default', note } });
await createNotification({ userId: String(review.user_id), category: 'isac', source: 'ISAC AVATAR OVERSIGHT', title: 'Custom avatar removed', body: `Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: ${note}`, link: '/account#avatar', mandatory: true });
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
+681 -12
View File
@@ -44,6 +44,116 @@ const parseAuditSnapshot = (value: unknown) => {
}
};
type QueueContentType = 'comment' | 'profile_bio' | 'guide';
type QueueSource = 'scan' | 'report';
const contentPermission = (contentType: QueueContentType) =>
contentType === 'profile_bio' ? PROFILE_PERMISSION : COMMENT_PERMISSION;
const parseJsonObject = (value: unknown) => {
if (typeof value !== 'string') return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {};
} catch {
return {};
}
};
const parseJsonArray = (value: unknown) => {
if (typeof value !== 'string') return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const queueTargetHref = (
contentType: QueueContentType,
contentId: unknown,
targetType: unknown,
targetId: unknown,
slug: unknown
) => {
if (contentType === 'profile_bio') return `/agents/${encodeURIComponent(String(contentId))}`;
if (contentType === 'guide') {
return slug
? `/builds/${encodeURIComponent(String(slug))}`
: `/builds/${encodeURIComponent(String(contentId))}`;
}
return targetHref(String(targetType), targetId, slug);
};
const queueActionFailure = (status: number, message: string) => fail(status, {
communityQueueError: message,
communityResultToken: randomUUID()
});
const allowedQueueTypes = (canModerateComments: boolean, canModerateProfiles: boolean) => [
...(canModerateComments ? ['comment', 'guide'] as QueueContentType[] : []),
...(canModerateProfiles ? ['profile_bio'] as QueueContentType[] : [])
];
const assertQueuePermission = async (
operator: NonNullable<Awaited<ReturnType<typeof getSessionUser>>>,
contentType: QueueContentType
) => {
const granted = await getPermissions(operator);
if (!has(granted, contentPermission(contentType))) {
error(403, contentType === 'profile_bio'
? 'Profile moderation permission required'
: 'Comment moderation permission required');
}
};
const scanQueueRecord = async (database: Awaited<ReturnType<typeof getDatabase>>, id: string) =>
(await database.prepare(`SELECT scan.*,revision.body_markdown AS revision_body,
comments.body_markdown AS comment_body,comments.status AS comment_status,
comments.thread_id,thread.target_type,thread.target_id,target_build.slug,target_build.title AS target_title,
target_build.guide_body AS current_guide_body,target_build.visibility AS current_guide_visibility,
target_build.owner_id AS guide_owner_id,
profile.bio_markdown AS current_bio,profile.bio_updated_at AS current_bio_updated_at,
profile.updated_at AS profile_updated_at,COALESCE(profile.display_name,users.username) AS display_name
FROM content_moderation_scans scan
LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
LEFT JOIN moderated_content_revisions revision ON revision.id=scan.revision_id
LEFT JOIN comments ON scan.content_type='comment' AND comments.id=scan.content_id
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN builds target_build ON
(scan.content_type='guide' AND target_build.id=scan.content_id)
OR (scan.content_type='comment' AND thread.target_type IN ('build','guide')
AND target_build.id=thread.target_id)
LEFT JOIN user_profiles profile ON profile.user_id=scan.author_id
LEFT JOIN users ON users.id=scan.author_id
WHERE scan.id=? AND scan.content_type IN ('comment','profile_bio','guide')
AND scan.status IN ('review','priority_review','error') AND review.id IS NULL`)).get(id);
const reportQueueRecord = async (database: Awaited<ReturnType<typeof getDatabase>>, id: string) =>
(await database.prepare(`SELECT report.*,
comments.body_markdown AS comment_body,comments.status AS comment_status,
comments.author_id AS comment_author_id,comments.thread_id,
thread.target_type,thread.target_id,target_build.slug,target_build.title AS target_title,
target_build.guide_body AS current_guide_body,target_build.visibility AS current_guide_visibility,
target_build.owner_id AS guide_owner_id,
profile.bio_markdown AS current_bio,profile.is_public AS profile_is_public,
COALESCE(moderation.bio_hidden,0) AS bio_hidden
FROM user_content_reports report
LEFT JOIN comments ON report.content_type='comment' AND comments.id=report.content_id
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN builds target_build ON
(report.content_type='guide' AND target_build.id=report.content_id)
OR (report.content_type='comment' AND thread.target_type IN ('build','guide')
AND target_build.id=thread.target_id)
LEFT JOIN user_profiles profile ON report.content_type='profile_bio'
AND profile.user_id=report.content_id
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE report.id=? AND report.status='pending'
AND report.content_type IN ('comment','profile_bio','guide')`)).get(id);
export const load: PageServerLoad = async ({ cookies, url }) => {
const operator = await getSessionUser(cookies);
if (!operator) error(401, 'Sign in required');
@@ -53,18 +163,24 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
if (!canModerateComments && !canModerateProfiles) error(403, 'Community Oversight permission required');
const requestedTab = url.searchParams.get('tab');
const tab = requestedTab === 'bios'
const tab = requestedTab === 'queue'
? 'queue'
: requestedTab === 'bios'
? 'bios'
: requestedTab === 'comments'
? 'comments'
: canModerateComments ? 'comments' : 'bios';
: 'queue';
if (tab === 'comments' && !canModerateComments) error(403, 'Comment moderation permission required');
if (tab === 'bios' && !canModerateProfiles) error(403, 'Profile moderation permission required');
const allowedStatuses = tab === 'comments' ? ['all', 'visible', 'removed'] : ['all', 'visible', 'hidden'];
const allowedStatuses = tab === 'queue'
? ['all', 'priority', 'automated', 'reported']
: tab === 'comments'
? ['all', 'visible', 'removed']
: ['all', 'visible', 'hidden'];
const status = allowedStatuses.includes(String(url.searchParams.get('status')))
? String(url.searchParams.get('status'))
: 'visible';
: tab === 'queue' ? 'all' : 'visible';
const { search, pattern } = getRegistrySearch(url);
const database = await getDatabase();
const removedCutoff = new Date(Date.now() - REMOVED_RETENTION_DAYS * 86_400_000).toISOString();
@@ -88,6 +204,207 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const commentSummary = Object.fromEntries(
commentSummaryRows.map((row) => [String(row.status), Number(row.count)])
);
const queueTypes = allowedQueueTypes(canModerateComments, canModerateProfiles);
const queueTypePlaceholders = queueTypes.map(() => '?').join(',');
const [scanQueueSummary, reportQueueSummary] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN status='priority_review' THEN 1 ELSE 0 END) AS priority,
SUM(CASE WHEN status='error' THEN 1 ELSE 0 END) AS errors
FROM content_moderation_scans scan
WHERE scan.content_type IN (${queueTypePlaceholders})
AND scan.status IN ('review','priority_review','error')
AND NOT EXISTS(
SELECT 1 FROM content_moderation_reviews review WHERE review.scan_id=scan.id
)`)).get(...queueTypes),
(await database.prepare(`SELECT COUNT(*) AS total
FROM user_content_reports
WHERE content_type IN (${queueTypePlaceholders}) AND status='pending'`)).get(...queueTypes)
]);
const queueSummary = {
automated: Number(scanQueueSummary?.total ?? 0),
reported: Number(reportQueueSummary?.total ?? 0),
priority: Number(scanQueueSummary?.priority ?? 0) + Number(scanQueueSummary?.errors ?? 0)
};
const summary = {
queue: { ...queueSummary, total: queueSummary.automated + queueSummary.reported },
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
bios: { visible: visibleBios, hidden: hiddenBios }
};
if (tab === 'queue') {
const scanQueue = `SELECT
scan.id AS queue_id,'scan' AS item_source,scan.content_type,scan.content_id,
scan.author_id,COALESCE(author_profile.display_name,author.username,'Deleted Agent') AS display_name,
author_avatar.source AS avatar_source,author_avatar.value AS avatar_value,
author_avatar.updated_at AS avatar_updated_at,
COALESCE(revision.body_markdown,comments.body_markdown,target_build.guide_body,'') AS body_markdown,
scan.created_at AS queued_at,scan.status AS scan_status,scan.risk_level,scan.reason,
NULL AS category,scan.provider,scan.model,scan.provider_request_id,
scan.scores_json,scan.flags_json,scan.rule_matches_json,NULL AS reporter_name,
NULL AS details,thread.target_type,thread.target_id,
CASE
WHEN scan.content_type='profile_bio'
THEN COALESCE(author_profile.display_name,author.username,'Agent profile')
WHEN scan.content_type='guide'
THEN COALESCE(target_build.title,'Build guide')
ELSE COALESCE(target_build.title,target_profile.display_name,target_user.username,'Community content')
END AS target_title,
target_build.slug
FROM content_moderation_scans scan
LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
LEFT JOIN moderated_content_revisions revision ON revision.id=scan.revision_id
LEFT JOIN comments ON scan.content_type='comment' AND comments.id=scan.content_id
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN builds target_build ON
(scan.content_type='guide' AND target_build.id=scan.content_id)
OR (scan.content_type='comment' AND thread.target_type IN ('build','guide')
AND target_build.id=thread.target_id)
LEFT JOIN users target_user ON scan.content_type='comment'
AND thread.target_type='profile' AND target_user.id=thread.target_id
LEFT JOIN user_profiles target_profile ON target_profile.user_id=target_user.id
LEFT JOIN users author ON author.id=scan.author_id
LEFT JOIN user_profiles author_profile ON author_profile.user_id=author.id
LEFT JOIN user_avatars author_avatar ON author_avatar.user_id=author.id
WHERE scan.content_type IN (${queueTypePlaceholders})
AND scan.status IN ('review','priority_review','error') AND review.id IS NULL`;
const reportQueue = `SELECT
report.id AS queue_id,'report' AS item_source,report.content_type,report.content_id,
COALESCE(comments.author_id,profile.user_id,target_build.owner_id) AS author_id,
COALESCE(author_profile.display_name,author.username,'Deleted Agent') AS display_name,
author_avatar.source AS avatar_source,author_avatar.value AS avatar_value,
author_avatar.updated_at AS avatar_updated_at,
COALESCE(comments.body_markdown,profile.bio_markdown,target_build.guide_body,'') AS body_markdown,
report.created_at AS queued_at,NULL AS scan_status,'reported' AS risk_level,
COALESCE(report.details,'A community member requested Oversight review.') AS reason,
report.category,NULL AS provider,NULL AS model,NULL AS provider_request_id,
'{}' AS scores_json,'{}' AS flags_json,'[]' AS rule_matches_json,
COALESCE(reporter_profile.display_name,reporter.username,'Community member') AS reporter_name,
report.details,thread.target_type,thread.target_id,
CASE
WHEN report.content_type='profile_bio'
THEN COALESCE(profile.display_name,author.username,'Agent profile')
WHEN report.content_type='guide'
THEN COALESCE(target_build.title,'Build guide')
ELSE COALESCE(target_build.title,target_profile.display_name,target_user.username,'Community content')
END AS target_title,
target_build.slug
FROM user_content_reports report
LEFT JOIN comments ON report.content_type='comment' AND comments.id=report.content_id
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN user_profiles profile ON report.content_type='profile_bio'
AND profile.user_id=report.content_id
LEFT JOIN builds target_build ON
(report.content_type='guide' AND target_build.id=report.content_id)
OR (report.content_type='comment' AND thread.target_type IN ('build','guide')
AND target_build.id=thread.target_id)
LEFT JOIN users target_user ON report.content_type='comment'
AND thread.target_type='profile' AND target_user.id=thread.target_id
LEFT JOIN user_profiles target_profile ON target_profile.user_id=target_user.id
LEFT JOIN users author ON author.id=COALESCE(comments.author_id,profile.user_id,target_build.owner_id)
LEFT JOIN user_profiles author_profile ON author_profile.user_id=author.id
LEFT JOIN user_avatars author_avatar ON author_avatar.user_id=author.id
LEFT JOIN users reporter ON reporter.id=report.reporter_id
LEFT JOIN user_profiles reporter_profile ON reporter_profile.user_id=reporter.id
WHERE report.content_type IN (${queueTypePlaceholders}) AND report.status='pending'`;
const union = status === 'automated'
? scanQueue
: status === 'reported'
? reportQueue
: `${scanQueue} UNION ALL ${reportQueue}`;
const args: unknown[] = status === 'automated' || status === 'reported'
? [...queueTypes]
: [...queueTypes, ...queueTypes];
const conditions: string[] = [];
if (status === 'priority') {
conditions.push("item_source='scan' AND scan_status IN ('priority_review','error')");
}
if (search) {
conditions.push(`(body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
OR display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR target_title LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(reason,'') LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(category,'') LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
args.push(pattern, pattern, pattern, pattern, pattern);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const total = Number((await (await database.prepare(
`WITH queue_items AS (${union}) SELECT COUNT(*) AS count FROM queue_items ${where}`
)).get(...args))?.count ?? 0);
const pagination = getPagination(url, total);
const rows = await (await database.prepare(`WITH queue_items AS (${union})
SELECT * FROM queue_items ${where}
ORDER BY CASE
WHEN scan_status='priority_review' THEN 0
WHEN scan_status='error' THEN 1
WHEN item_source='report' THEN 2
ELSE 3
END,queued_at DESC LIMIT ? OFFSET ?`))
.all(...args, pagination.perPage, pagination.offset);
return {
tab,
status,
search,
summary,
items: rows.map((row) => {
const contentType = String(row.content_type) as QueueContentType;
const scores = parseJsonObject(row.scores_json);
const flags = parseJsonObject(row.flags_json);
return {
id: String(row.queue_id),
source: String(row.item_source) as QueueSource,
contentType,
contentId: String(row.content_id),
bodyHtml: renderUserMarkdown(row.body_markdown),
queuedAt: String(row.queued_at),
scanStatus: row.scan_status ? String(row.scan_status) : null,
riskLevel: String(row.risk_level),
reason: String(row.reason),
category: row.category ? String(row.category) : null,
provider: row.provider ? String(row.provider) : null,
model: row.model ? String(row.model) : null,
providerRequestId: row.provider_request_id ? String(row.provider_request_id) : null,
reporterName: row.reporter_name ? String(row.reporter_name) : null,
scores: Object.entries(scores)
.map(([category, score]) => ({ category, score: Number(score) }))
.filter((entry) => Number.isFinite(entry.score) && entry.score > 0)
.sort((left, right) => right.score - left.score)
.slice(0, 5),
flags: Object.entries(flags)
.filter(([, flagged]) => Boolean(flagged))
.map(([category]) => category),
ruleMatches: parseJsonArray(row.rule_matches_json).flatMap((match) => {
if (!match || typeof match !== 'object' || Array.isArray(match)) return [];
const record = match as Record<string, unknown>;
return [{
label: String(record.label ?? record.kind ?? 'LOCAL RULE'),
excerpt: String(record.excerpt ?? '')
}];
}),
targetType: contentType === 'comment' ? String(row.target_type ?? 'content') : contentType,
targetTitle: String(row.target_title),
targetHref: queueTargetHref(
contentType,
row.content_id,
row.target_type,
row.target_id,
row.slug
),
author: {
id: String(row.author_id ?? ''),
displayName: String(row.display_name),
avatarUrl: avatarUrl(
row.avatar_source,
row.avatar_value,
String(row.author_id ?? ''),
row.avatar_updated_at
)
}
};
}),
pagination,
permissions: { comments: canModerateComments, profiles: canModerateProfiles }
};
}
if (tab === 'comments') {
const sort = getRegistrySort(url, {
@@ -150,10 +467,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
tab,
status,
search,
summary: {
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
bios: { visible: visibleBios, hidden: hiddenBios }
},
summary,
items: rows.map((row) => ({
id: String(row.id),
bodyHtml: renderUserMarkdown(row.body_markdown),
@@ -221,10 +535,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
tab,
status,
search,
summary: {
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
bios: { visible: visibleBios, hidden: hiddenBios }
},
summary,
items: rows.map((row) => ({
id: String(row.user_id),
bodyHtml: renderUserMarkdown(row.bio_markdown),
@@ -244,6 +555,364 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
};
export const actions: Actions = {
approveQueue: async ({ cookies, request }) => {
const operator = await getSessionUser(cookies);
if (!operator) return queueActionFailure(401, 'Sign in to review community content.');
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const source = String(form.get('source') ?? '') as QueueSource;
const reason = requiredReason(form.get('reason'));
if (!id || !['scan', 'report'].includes(source) || !reason) {
return queueActionFailure(400, 'Choose a queue item and provide a brief approval reason.');
}
const database = await getDatabase();
if (source === 'report') {
const report = await reportQueueRecord(database, id);
if (!report) return queueActionFailure(404, 'That user report is no longer pending.');
const contentType = String(report.content_type) as QueueContentType;
await assertQueuePermission(operator, contentType);
const now = new Date().toISOString();
await database.exec('BEGIN IMMEDIATE');
try {
const result = await (await database.prepare(`UPDATE user_content_reports
SET status='dismissed',reviewed_at=?,reviewed_by=?,resolution_reason=?
WHERE id=? AND status='pending'`)).run(now, operator.id, reason, id);
if (!result.changes) {
await database.exec('ROLLBACK');
return queueActionFailure(409, 'That report changed before it could be dismissed.');
}
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: 'content_report.dismissed',
entityId: id,
before: {
status: 'pending',
contentType,
contentId: String(report.content_id),
category: String(report.category),
reporterId: String(report.reporter_id)
},
after: {
status: 'dismissed',
reason,
reviewedAt: now,
reviewedBy: operator.id
}
});
await database.exec('COMMIT');
} catch (cause) {
await database.exec('ROLLBACK');
throw cause;
}
return { communitySaved: true, communityResultToken: randomUUID() };
}
const scan = await scanQueueRecord(database, id);
if (!scan) return queueActionFailure(404, 'That automated review is no longer pending.');
const contentType = String(scan.content_type) as QueueContentType;
await assertQueuePermission(operator, contentType);
const body = contentType === 'comment' ? String(scan.comment_body ?? '') : String(scan.revision_body ?? '');
if (!body.trim()) return queueActionFailure(409, 'The queued content is no longer available.');
const now = new Date().toISOString();
const reviewId = randomUUID();
let targetOwnerId: string | null = null;
await database.exec('BEGIN IMMEDIATE');
try {
const claim = await (await database.prepare(`INSERT INTO content_moderation_reviews
(id,scan_id,reviewer_id,decision,reason,created_at)
SELECT ?,?,?,'approved',?,?
WHERE NOT EXISTS(SELECT 1 FROM content_moderation_reviews WHERE scan_id=?)`))
.run(reviewId, id, operator.id, reason, now, id);
if (!claim.changes) {
await database.exec('ROLLBACK');
return queueActionFailure(409, 'Another reviewer already handled this scan.');
}
if (contentType === 'comment') {
const result = await (await database.prepare(`UPDATE comments
SET status='visible',removed_at=NULL,removed_by=NULL,removal_reason=NULL,updated_at=?
WHERE id=? AND status='pending_review'`)).run(now, scan.content_id);
if (!result.changes) throw new Error('The pending comment changed before approval.');
targetOwnerId = String(scan.target_type) === 'profile'
? String(scan.target_id)
: scan.guide_owner_id ? String(scan.guide_owner_id) : null;
} else if (contentType === 'profile_bio') {
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
VALUES(?,?,?,1,?,?)
ON CONFLICT(user_id) DO UPDATE SET
bio_markdown=excluded.bio_markdown,
bio_updated_at=excluded.bio_updated_at,
updated_at=excluded.updated_at`))
.run(scan.content_id, String(scan.display_name ?? 'Agent'), body, now, now);
await (await database.prepare(`UPDATE profile_moderation SET bio_hidden=0,
reason=?,moderated_at=?,moderated_by=? WHERE user_id=?`))
.run(reason, now, operator.id, scan.content_id);
} else {
const result = await (await database.prepare(`UPDATE builds
SET guide_body=?,updated_at=? WHERE id=? AND owner_id=?`))
.run(body, now, scan.content_id, scan.author_id);
if (!result.changes) throw new Error('The queued guide changed before approval.');
}
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: `${contentType}.review_approved`,
entityId: String(scan.content_id),
before: {
scanId: id,
status: String(scan.status),
riskLevel: String(scan.risk_level),
reason: String(scan.reason),
policyVersion: Number(scan.policy_version),
provider: String(scan.provider),
model: String(scan.model),
currentStatus: contentType === 'comment'
? String(scan.comment_status)
: contentType === 'profile_bio' ? 'revision_pending' : 'revision_pending'
},
after: {
scanId: id,
reviewId,
decision: 'approved',
reason,
reviewedAt: now,
reviewedBy: operator.id,
published: true
}
});
await database.exec('COMMIT');
} catch (cause) {
await database.exec('ROLLBACK');
if (cause instanceof Error && cause.message.includes('changed before approval')) {
return queueActionFailure(409, cause.message);
}
throw cause;
}
const authorId = String(scan.author_id);
const link = queueTargetHref(
contentType,
scan.content_id,
scan.target_type,
scan.target_id,
scan.slug
);
await createNotification({
userId: authorId,
category: 'isac',
source: 'ISAC COMMUNITY OVERSIGHT',
title: contentType === 'comment'
? 'Comment approved'
: contentType === 'profile_bio' ? 'Agent bio approved' : 'Guide approved',
body: `Community Oversight approved your ${contentType === 'profile_bio' ? 'Agent bio' : contentType}. Reason: ${reason}`,
link,
actions: [{ label: 'VIEW CONTENT', href: link }],
mandatory: true
});
if (contentType === 'comment' && targetOwnerId && targetOwnerId !== authorId) {
await createNotification({
userId: targetOwnerId,
category: 'comments',
source: 'agent',
title: 'New comment',
body: `${String(scan.display_name ?? 'An Agent')} posted a comment on your ${String(scan.target_type) === 'profile' ? 'Agent profile' : 'build'}.`,
link,
actions: [{ label: 'VIEW COMMENT', href: link }]
});
}
return { communitySaved: true, communityResultToken: randomUUID() };
},
rejectQueue: async ({ cookies, request }) => {
const operator = await getSessionUser(cookies);
if (!operator) return queueActionFailure(401, 'Sign in to review community content.');
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const source = String(form.get('source') ?? '') as QueueSource;
const reason = requiredReason(form.get('reason'));
if (!id || !['scan', 'report'].includes(source) || !reason) {
return queueActionFailure(400, 'Choose a queue item and provide a brief rejection reason.');
}
const database = await getDatabase();
if (source === 'report') {
const report = await reportQueueRecord(database, id);
if (!report) return queueActionFailure(404, 'That user report is no longer pending.');
const contentType = String(report.content_type) as QueueContentType;
await assertQueuePermission(operator, contentType);
const authorId = contentType === 'comment'
? String(report.comment_author_id ?? '')
: contentType === 'profile_bio'
? String(report.content_id)
: String(report.guide_owner_id ?? '');
if (!authorId) return queueActionFailure(409, 'The reported content author is no longer available.');
const now = new Date().toISOString();
const before = {
reportId: id,
contentType,
contentId: String(report.content_id),
reportCategory: String(report.category),
reporterId: String(report.reporter_id),
contentStatus: contentType === 'comment'
? String(report.comment_status ?? 'missing')
: contentType === 'profile_bio'
? Boolean(report.bio_hidden) ? 'hidden' : 'visible'
: String(report.current_guide_visibility ?? 'missing')
};
await database.exec('BEGIN IMMEDIATE');
try {
if (contentType === 'comment') {
const result = await (await database.prepare(`UPDATE comments SET status='removed',
removed_at=?,removed_by=?,removal_reason=?,updated_at=?
WHERE id=? AND status='visible'`))
.run(now, operator.id, reason, now, report.content_id);
if (!result.changes) throw new Error('The reported comment is no longer visible.');
} else if (contentType === 'profile_bio') {
if (!String(report.current_bio ?? '').trim() || Boolean(report.bio_hidden)) {
throw new Error('The reported biography is no longer visible.');
}
await (await database.prepare(`INSERT INTO profile_moderation
(user_id,bio_hidden,reason,moderated_at,moderated_by) VALUES(?,1,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET bio_hidden=1,reason=excluded.reason,
moderated_at=excluded.moderated_at,moderated_by=excluded.moderated_by`))
.run(report.content_id, reason, now, operator.id);
} else {
if (!String(report.current_guide_body ?? '').trim() || report.current_guide_visibility === 'private') {
throw new Error('The reported guide is no longer publicly available.');
}
const result = await (await database.prepare(`UPDATE builds
SET visibility='private',updated_at=? WHERE id=? AND visibility<>'private'`))
.run(now, report.content_id);
if (!result.changes) throw new Error('The reported guide changed before it could be hidden.');
}
const resolved = await (await database.prepare(`UPDATE user_content_reports
SET status='resolved',reviewed_at=?,reviewed_by=?,resolution_reason=?
WHERE content_type=? AND content_id=? AND status='pending'`))
.run(now, operator.id, reason, contentType, report.content_id);
if (!resolved.changes) throw new Error('The pending reports changed before resolution.');
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: contentType === 'comment'
? 'comment.report_removed'
: contentType === 'profile_bio' ? 'profile_bio.report_hidden' : 'guide.report_hidden',
entityId: String(report.content_id),
before,
after: {
status: contentType === 'comment' ? 'removed' : 'hidden',
reason,
reviewedAt: now,
reviewedBy: operator.id,
resolvedReports: Number(resolved.changes)
}
});
await database.exec('COMMIT');
} catch (cause) {
await database.exec('ROLLBACK');
if (cause instanceof Error && (
cause.message.includes('no longer')
|| cause.message.includes('changed before')
)) return queueActionFailure(409, cause.message);
throw cause;
}
const link = queueTargetHref(
contentType,
report.content_id,
report.target_type,
report.target_id,
report.slug
);
await createNotification({
userId: authorId,
category: 'isac',
source: 'ISAC COMMUNITY OVERSIGHT',
title: contentType === 'comment'
? 'Comment removed'
: contentType === 'profile_bio' ? 'Agent bio hidden' : 'Guide hidden',
body: `Community Oversight ${contentType === 'comment' ? 'removed' : 'hid'} your ${contentType === 'profile_bio' ? 'Agent bio' : contentType}. Reason: ${reason}`,
link: contentType === 'profile_bio' ? '/account' : link,
actions: [{
label: 'REVIEW CONTENT',
href: contentType === 'profile_bio' ? '/account' : link
}],
mandatory: true
});
return { communitySaved: true, communityResultToken: randomUUID() };
}
const scan = await scanQueueRecord(database, id);
if (!scan) return queueActionFailure(404, 'That automated review is no longer pending.');
const contentType = String(scan.content_type) as QueueContentType;
await assertQueuePermission(operator, contentType);
const now = new Date().toISOString();
const reviewId = randomUUID();
await database.exec('BEGIN IMMEDIATE');
try {
const claim = await (await database.prepare(`INSERT INTO content_moderation_reviews
(id,scan_id,reviewer_id,decision,reason,created_at)
SELECT ?,?,?,'rejected',?,?
WHERE NOT EXISTS(SELECT 1 FROM content_moderation_reviews WHERE scan_id=?)`))
.run(reviewId, id, operator.id, reason, now, id);
if (!claim.changes) {
await database.exec('ROLLBACK');
return queueActionFailure(409, 'Another reviewer already handled this scan.');
}
if (contentType === 'comment') {
const result = await (await database.prepare(`UPDATE comments SET status='removed',
removed_at=?,removed_by=?,removal_reason=?,updated_at=?
WHERE id=? AND status='pending_review'`))
.run(now, operator.id, reason, now, scan.content_id);
if (!result.changes) throw new Error('The pending comment changed before rejection.');
}
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: `${contentType}.review_rejected`,
entityId: String(scan.content_id),
before: {
scanId: id,
status: String(scan.status),
riskLevel: String(scan.risk_level),
reason: String(scan.reason),
policyVersion: Number(scan.policy_version),
provider: String(scan.provider),
model: String(scan.model)
},
after: {
scanId: id,
reviewId,
decision: 'rejected',
reason,
reviewedAt: now,
reviewedBy: operator.id,
published: false
}
});
await database.exec('COMMIT');
} catch (cause) {
await database.exec('ROLLBACK');
if (cause instanceof Error && cause.message.includes('changed before rejection')) {
return queueActionFailure(409, cause.message);
}
throw cause;
}
await createNotification({
userId: String(scan.author_id),
category: 'isac',
source: 'ISAC COMMUNITY OVERSIGHT',
title: contentType === 'comment'
? 'Comment rejected'
: contentType === 'profile_bio' ? 'Agent bio revision rejected' : 'Guide revision rejected',
body: `Community Oversight rejected your ${contentType === 'profile_bio' ? 'Agent bio revision' : contentType}. Reason: ${reason}`,
link: '/account',
actions: [{ label: 'REVIEW ACCOUNT', href: '/account' }],
mandatory: true
});
return { communitySaved: true, communityResultToken: randomUUID() };
},
removeComment: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), COMMENT_PERMISSION);
const form = await request.formData();
+212 -14
View File
@@ -26,17 +26,41 @@
moderationReason: string | null;
agent: AgentSummary;
};
type QueueItem = {
id: string;
source: 'scan' | 'report';
contentType: 'comment' | 'profile_bio' | 'guide';
contentId: string;
bodyHtml: string;
queuedAt: string;
scanStatus: string | null;
riskLevel: string;
reason: string;
category: string | null;
provider: string | null;
model: string | null;
providerRequestId: string | null;
reporterName: string | null;
scores: Array<{ category: string; score: number }>;
flags: string[];
ruleMatches: Array<{ label: string; excerpt: string }>;
targetType: string;
targetTitle: string;
targetHref: string;
author: AgentSummary;
};
type CommunityItem = CommunityComment | CommunityBio;
type ModerationAction = 'removeComment' | 'restoreComment' | 'hideBio' | 'restoreBio';
type PageData = {
tab: 'comments' | 'bios';
tab: 'queue' | 'comments' | 'bios';
status: string;
search: string;
summary: {
queue: { total: number; automated: number; reported: number; priority: number };
comments: { visible: number; removed: number };
bios: { visible: number; hidden: number };
};
items: CommunityItem[];
items: Array<CommunityItem | QueueItem>;
pagination: any;
permissions: { comments: boolean; profiles: boolean };
};
@@ -46,10 +70,15 @@
let moderationAction = $state<ModerationAction | null>(null);
let moderationReason = $state('');
let confirming = $state(false);
let queueReviewing = $state<QueueItem | null>(null);
let queueDecision = $state<'approve' | 'reject'>('approve');
let queueReason = $state('');
let queueConfirming = $state(false);
let handledResultToken = $state('');
const canModerateComments = $derived(data.permissions.comments);
const canModerateProfiles = $derived(data.permissions.profiles);
const isQueueItem = (item: CommunityItem | QueueItem): item is QueueItem => 'source' in item;
const isComment = (item: CommunityItem): item is CommunityComment => 'author' in item;
const itemAgent = (item: CommunityItem) => isComment(item) ? item.author : item.agent;
const itemTimestamp = (item: CommunityItem) => isComment(item) ? item.createdAt : item.updatedAt;
@@ -81,7 +110,14 @@
: 'The bio will return to the Agent’s public profile.'
);
const statusItems = $derived(
data.tab === 'comments'
data.tab === 'queue'
? [
{ value: 'all', label: 'PENDING', count: data.summary.queue.total, description: 'All items requiring human review' },
{ value: 'priority', label: 'PRIORITY', count: data.summary.queue.priority, description: 'High-risk or failed automated scans' },
{ value: 'automated', label: 'AUTOMATED', count: data.summary.queue.automated, description: 'Items routed here by moderation scans' },
{ value: 'reported', label: 'USER REPORTS', count: data.summary.queue.reported, description: 'Content flagged by community members' }
]
: data.tab === 'comments'
? [
{ value: 'all', label: 'ALL', count: data.summary.comments.visible + data.summary.comments.removed, description: 'All public and moderated comments' },
{ value: 'visible', label: 'VISIBLE', count: data.summary.comments.visible, description: 'Comments currently visible to the community' },
@@ -93,6 +129,22 @@
{ value: 'hidden', label: 'HIDDEN', count: data.summary.bios.hidden, description: 'Bios hidden by Oversight' }
]
);
const queueActionLabel = $derived(
queueDecision === 'approve'
? queueReviewing?.source === 'report' ? 'KEEP CONTENT' : queueReviewing?.contentType === 'comment' ? 'APPROVE & PUBLISH' : 'APPROVE REVISION'
: queueReviewing?.source === 'report'
? queueReviewing?.contentType === 'comment' ? 'REMOVE COMMENT' : `HIDE ${queueReviewing?.contentType === 'profile_bio' ? 'BIO' : 'GUIDE'}`
: queueReviewing?.contentType === 'comment' ? 'REJECT & REMOVE' : 'REJECT REVISION'
);
const queueConsequence = $derived(
queueDecision === 'approve'
? queueReviewing?.source === 'report'
? 'This report will be dismissed. Other reports for the same content will remain pending.'
: 'This scan will be approved and the queued content will become public.'
: queueReviewing?.source === 'report'
? 'The content will be hidden or removed, and all pending reports for it will be resolved.'
: 'This scan will be rejected. The queued revision will not be published.'
);
function openModeration(item: CommunityItem) {
reviewing = item;
@@ -110,33 +162,53 @@
confirming = false;
}
function openQueueReview(item: QueueItem) {
queueReviewing = item;
queueDecision = 'approve';
queueReason = '';
queueConfirming = false;
}
function closeQueueReview() {
queueReviewing = null;
queueReason = '';
queueConfirming = false;
}
$effect(() => {
const token = String(form?.communityResultToken ?? '');
if (!token || token === handledResultToken) return;
handledResultToken = token;
if (form?.communitySaved) closeModeration();
if (form?.communitySaved) {
closeModeration();
closeQueueReview();
}
});
</script>
<svelte:head><title>Community Oversight — ISAC</title></svelte:head>
<svelte:window onkeydown={(event) => {
if (event.key === 'Escape' && reviewing) closeModeration();
if (event.key === 'Escape' && queueReviewing) closeQueueReview();
}}/>
<div class="module-toolbar moderation-toolbar">
<div>
<p class="eyebrow">COMMUNITY OVERSIGHT / PUBLIC CONTENT CONTROL</p>
<h2>COMMUNITY OVERSIGHT</h2>
<p>Review public discussion and Agent bios. Moderation preserves evidence, requires a reason, and remains reversible.</p>
<p>Resolve automated safety flags and community reports, then inspect the retained comment and biography history when needed.</p>
</div>
<div class="queue-readout">
<span>{data.tab === 'comments' ? 'VISIBLE COMMENTS' : 'VISIBLE BIOS'}</span>
<strong>{data.tab === 'comments' ? data.summary.comments.visible : data.summary.bios.visible}</strong>
<small>PUBLIC RECORDS</small>
<span>{data.tab === 'queue' ? 'PENDING REVIEW' : data.tab === 'comments' ? 'VISIBLE COMMENTS' : 'VISIBLE BIOS'}</span>
<strong>{data.tab === 'queue' ? data.summary.queue.total : data.tab === 'comments' ? data.summary.comments.visible : data.summary.bios.visible}</strong>
<small>{data.tab === 'queue' ? 'EXCEPTIONS ONLY' : 'PUBLIC RECORDS'}</small>
</div>
</div>
<nav class="community-tabs" aria-label="Community oversight channels">
<a class:active={data.tab === 'queue'} aria-current={data.tab === 'queue' ? 'page' : undefined} href="/isac/community?tab=queue&status=all">
<span>REVIEW QUEUE</span><b>{data.summary.queue.total}</b>
</a>
{#if canModerateComments}
<a class:active={data.tab === 'comments'} aria-current={data.tab === 'comments' ? 'page' : undefined} href="/isac/community?tab=comments&status=all">
<span>COMMENTS</span><b>{data.summary.comments.visible + data.summary.comments.removed}</b>
@@ -154,14 +226,45 @@
<form class="audit-filter community-filter">
<input type="hidden" name="tab" value={data.tab}/>
<input type="hidden" name="status" value={data.status}/>
<input name="search" type="search" value={data.search} placeholder={data.tab === 'comments' ? 'AUTHOR, TARGET, OR COMMENT TEXT' : 'AGENT OR BIO TEXT'}/>
<button class="secondary">SEARCH COMMUNITY →</button>
<input name="search" type="search" value={data.search} placeholder={data.tab === 'queue' ? 'AUTHOR, TARGET, CATEGORY, OR CONTENT' : data.tab === 'comments' ? 'AUTHOR, TARGET, OR COMMENT TEXT' : 'AGENT OR BIO TEXT'}/>
<button class="secondary">{data.tab === 'queue' ? 'SEARCH QUEUE →' : 'SEARCH COMMUNITY →'}</button>
</form>
<RegistryPagination pagination={data.pagination}/>
<div class="community-registry">
{#each data.items as item}
{#if isQueueItem(item)}
<article class="queue-item" class:priority={item.riskLevel === 'high' || item.scanStatus === 'error'}>
<header>
<AgentIdentityLink agent={item.author} meta="CONTENT AUTHOR" />
<div class="community-state">
<span>{date(item.queuedAt)}</span>
<b class:visible={item.source === 'report'}>{item.source === 'scan' ? 'AUTOMATED SCAN' : 'USER REPORT'}</b>
</div>
</header>
<div class="community-target">
<span>{item.contentType.replace('_', ' ').toUpperCase()}</span>
<a href={item.targetHref}>{item.targetTitle} →</a>
</div>
<div class="queue-evidence">
<div class="queue-signal">
<span class:priority={item.riskLevel === 'high'}>{item.riskLevel.toUpperCase()}</span>
<strong>{item.category ? item.category.replace('_', ' ').toUpperCase() : item.scanStatus?.replace('_', ' ').toUpperCase()}</strong>
</div>
<p>{item.reason}</p>
{#if item.reporterName}<small>REPORTED BY {item.reporterName}</small>{/if}
{#if item.provider}<small>{item.provider.toUpperCase()} / {item.model ?? 'MODEL UNKNOWN'}</small>{/if}
</div>
<div class="community-markdown-preview">
<MarkdownContent html={item.bodyHtml} compact emptyMessage="The queued content is no longer available."/>
</div>
<footer>
<span>{item.source === 'scan' ? `SCAN ${item.id.slice(0,8).toUpperCase()}` : `REPORT ${item.id.slice(0,8).toUpperCase()}`}</span>
<button class="secondary" onclick={() => openQueueReview(item)}>REVIEW EVIDENCE →</button>
</footer>
</article>
{:else}
<article class:moderated={item.status === 'removed' || item.status === 'hidden'}>
<header>
<AgentIdentityLink agent={itemAgent(item)} meta={isComment(item) ? 'COMMENT AUTHOR' : 'PROFILE OWNER'} />
@@ -193,14 +296,106 @@
{/if}
</footer>
</article>
{/if}
{:else}
<div class="empty-dashboard">
<strong>NO MATCHING {data.tab === 'comments' ? 'COMMENTS' : 'PROFILE BIOS'}</strong>
<span>Adjust the status or search filters to inspect another part of the community record.</span>
<strong>NO MATCHING {data.tab === 'queue' ? 'REVIEW EXCEPTIONS' : data.tab === 'comments' ? 'COMMENTS' : 'PROFILE BIOS'}</strong>
<span>{data.tab === 'queue' ? 'Automated checks have not routed any matching content to human Oversight.' : 'Adjust the status or search filters to inspect another part of the community record.'}</span>
</div>
{/each}
</div>
{#if queueReviewing}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal community-review-modal" role="dialog" aria-modal="true" aria-labelledby="queue-review-title">
<div class="editor-title">
<div>
<p class="eyebrow">{queueReviewing.source === 'scan' ? 'AUTOMATED SAFETY FLAG' : 'COMMUNITY REPORT'} / {queueReviewing.contentType.replace('_', ' ').toUpperCase()}</p>
<h3 id="queue-review-title">REVIEW CONTENT EXCEPTION</h3>
</div>
<button aria-label="Close content review" onclick={closeQueueReview}>×</button>
</div>
<div class="community-review-identity">
<AgentIdentityLink agent={queueReviewing.author} meta="CONTENT AUTHOR" />
<span>{date(queueReviewing.queuedAt)}</span>
</div>
<div class="community-target review-target">
<span>{queueReviewing.contentType.replace('_', ' ').toUpperCase()}</span>
<a href={queueReviewing.targetHref}>{queueReviewing.targetTitle} →</a>
</div>
<div class="queue-review-evidence">
<div>
<span>SOURCE</span><strong>{queueReviewing.source === 'scan' ? 'AUTOMATED SCAN' : 'USER REPORT'}</strong>
</div>
<div>
<span>RISK / CATEGORY</span><strong>{queueReviewing.riskLevel.toUpperCase()} · {(queueReviewing.category ?? queueReviewing.scanStatus ?? 'REVIEW').replace('_', ' ').toUpperCase()}</strong>
</div>
{#if queueReviewing.provider}
<div><span>PROVIDER / MODEL</span><strong>{queueReviewing.provider.toUpperCase()} · {queueReviewing.model ?? 'UNKNOWN'}</strong></div>
{/if}
{#if queueReviewing.providerRequestId}
<div><span>REQUEST ID</span><strong>{queueReviewing.providerRequestId}</strong></div>
{/if}
{#if queueReviewing.reporterName}
<div><span>REPORTER</span><strong>{queueReviewing.reporterName}</strong></div>
{/if}
</div>
<div class="moderation-evidence queue-reason-evidence"><span>ROUTING REASON</span><p>{queueReviewing.reason}</p></div>
{#if queueReviewing.flags.length || queueReviewing.ruleMatches.length || queueReviewing.scores.length}
<div class="queue-signals">
{#if queueReviewing.flags.length}<div><span>FLAGGED CATEGORIES</span><p>{queueReviewing.flags.join(' · ')}</p></div>{/if}
{#if queueReviewing.ruleMatches.length}<div><span>LOCAL RULES</span>{#each queueReviewing.ruleMatches as match}<p><b>{match.label}</b>{match.excerpt ? ` — ${match.excerpt}` : ''}</p>{/each}</div>{/if}
{#if queueReviewing.scores.length}<div><span>TOP PROVIDER SCORES</span>{#each queueReviewing.scores as score}<p><b>{score.category}</b> {(score.score * 100).toFixed(1)}%</p>{/each}</div>{/if}
</div>
{/if}
<div class="community-review-content"><MarkdownContent html={queueReviewing.bodyHtml} emptyMessage="The queued content is no longer available."/></div>
<div class="decision-tabs queue-decision-tabs" role="group" aria-label="Moderation decision">
<button type="button" class:active={queueDecision === 'approve'} aria-pressed={queueDecision === 'approve'} onclick={() => { queueDecision = 'approve'; queueConfirming = false; }}>
<b>✓</b><span>{queueReviewing.source === 'report' ? 'KEEP' : 'APPROVE'}<small>{queueReviewing.source === 'report' ? 'DISMISS THIS REPORT' : 'PUBLISH QUEUED CONTENT'}</small></span>
</button>
<button type="button" class:active={queueDecision === 'reject'} aria-pressed={queueDecision === 'reject'} onclick={() => { queueDecision = 'reject'; queueConfirming = false; }}>
<b>×</b><span>{queueReviewing.source === 'report' ? 'REMOVE' : 'REJECT'}<small>{queueReviewing.source === 'report' ? 'RESOLVE ALL REPORTS' : 'KEEP CONTENT HIDDEN'}</small></span>
</button>
</div>
{#if !queueConfirming}
<label class="field moderation-reason">
OVERSIGHT REASON — REQUIRED
<textarea bind:value={queueReason} minlength="3" maxlength="500" required placeholder="Record the evidence and policy basis for this decision…"></textarea>
</label>
{#if form?.communityQueueError}<p class="error queue-error">{form.communityQueueError}</p>{/if}
<div class="moderation-warning" class:rejecting={queueDecision === 'reject'}>
<b>!</b><span>{queueConsequence} The review decision and reason will be added to the audit history.</span>
</div>
<div class="form-actions">
<button type="button" class="secondary" onclick={closeQueueReview}>CANCEL</button>
<button type="button" class="primary" disabled={queueReason.trim().length < 3} onclick={() => queueConfirming = true}>REVIEW ACTION →</button>
</div>
{:else}
{@const queueReviewingId = queueReviewing.id}
{@const queueReviewingSource = queueReviewing.source}
<div class="community-final-confirm" role="alert">
<div><span>FINAL CONFIRMATION</span><strong>{queueActionLabel}?</strong><p>{queueConsequence}</p></div>
<dl><dt>OVERSIGHT REASON</dt><dd>{queueReason}</dd></dl>
</div>
<PendingForm action={queueDecision === 'approve' ? '?/approveQueue' : '?/rejectQueue'}>
{#snippet children(pending)}
<input type="hidden" name="id" value={queueReviewingId}/>
<input type="hidden" name="source" value={queueReviewingSource}/>
<input type="hidden" name="reason" value={queueReason}/>
{#if form?.communityQueueError}<p class="error">{form.communityQueueError}</p>{/if}
<div class="form-actions">
<button type="button" class="secondary" disabled={pending} onclick={() => queueConfirming = false}>BACK</button>
<button class={queueDecision === 'reject' ? 'danger' : 'primary'} disabled={pending} aria-busy={pending}>
{pending ? 'PROCESSING…' : `CONFIRM ${queueActionLabel} →`}
</button>
</div>
{/snippet}
</PendingForm>
{/if}
</div>
</div>
{/if}
{#if reviewing && moderationAction}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal community-review-modal" role="dialog" aria-modal="true" aria-labelledby="community-review-title">
@@ -256,15 +451,18 @@
{/if}
<style>
.community-tabs{height:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0 0 8px;border:1px solid var(--shd-border);background:#0d1011}
.community-tabs{height:auto;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));margin:0 0 8px;border:1px solid var(--shd-border);background:#0d1011}
.community-tabs a{min-height:46px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;padding:8px 11px;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;color:var(--shd-text-muted)}.community-tabs a:first-child{border-left:0}.community-tabs a span,.community-tabs a b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.community-tabs a b{color:var(--shd-text-dim)}.community-tabs a:hover,.community-tabs a:focus-visible{background:var(--shd-surface-active);color:var(--shd-text)}.community-tabs a.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.community-tabs a.active b{color:var(--shd-orange)}
.community-filter{grid-template-columns:minmax(0,1fr) auto}.community-registry{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:7px}.community-registry>article{min-width:0;display:grid;grid-template-rows:auto auto minmax(100px,1fr) auto;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface)}.community-registry>article.moderated{border-left-color:var(--shd-danger)}
.community-registry article>header{min-height:55px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.community-registry article>header :global(.agent-identity){flex:1}.community-state{display:grid;justify-items:end;gap:4px}.community-state span,.community-state b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-state span{color:var(--shd-text-muted)}.community-state b{padding:3px 5px;border:1px solid var(--shd-danger);color:var(--shd-danger)}.community-state b.visible{border-color:var(--shd-success);color:var(--shd-success)}
.community-target{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 10px;border-bottom:1px solid var(--shd-border-soft);background:#0d1011}.community-target span,.community-target a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-target span{color:var(--shd-text-muted)}.community-target a{overflow:hidden;color:var(--shd-orange);text-overflow:ellipsis;white-space:nowrap}.community-markdown-preview{max-height:150px;overflow:hidden;padding:11px;position:relative}.community-markdown-preview:after{position:absolute;right:0;bottom:0;left:0;height:25px;background:linear-gradient(transparent,var(--shd-surface));content:"";pointer-events:none}
.community-registry>article.queue-item{grid-template-rows:auto auto auto minmax(100px,1fr) auto;border-left:1px solid var(--shd-info)}.community-registry>article.queue-item.priority{border-color:var(--shd-danger)}
.queue-evidence{display:grid;gap:5px;padding:9px 10px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.queue-evidence p{margin:0;color:var(--shd-text);line-height:1.4;overflow-wrap:anywhere}.queue-evidence small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;overflow-wrap:anywhere}.queue-signal{display:flex;align-items:center;gap:7px}.queue-signal span,.queue-signal strong{padding:3px 5px;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.queue-signal span.priority{border-color:var(--shd-danger);color:var(--shd-danger)}
.moderation-evidence{display:grid;gap:4px;margin:0 10px 9px;padding:8px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-danger);background:#1b1111}.moderation-evidence span{color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.moderation-evidence p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.community-registry article>footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border-top:1px solid var(--shd-border);background:var(--shd-surface-raised)}.community-registry article>footer>span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-registry article>footer button{min-height:36px;padding:0 10px}
.community-review-modal{width:min(900px,calc(100vw - 28px));max-height:calc(100vh - 28px);overflow:auto}.community-review-identity{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px;border-bottom:1px solid var(--shd-border)}.community-review-identity :global(.agent-identity){flex:1}.community-review-identity>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.review-target{border-bottom:1px solid var(--shd-border)}.community-review-content{max-height:330px;overflow:auto;padding:14px;border-bottom:1px solid var(--shd-border);background:#0d1011}.community-review-content :global(.markdown-content){max-width:none}.moderation-reason{margin:10px}.moderation-reason textarea{min-height:90px}.community-final-confirm{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,.65fr);gap:9px;margin:10px;padding:10px;border:1px solid var(--shd-orange);background:#1b160d}.community-final-confirm>div{display:grid;gap:4px}.community-final-confirm span,.community-final-confirm dt{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.community-final-confirm p,.community-final-confirm dd{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.community-final-confirm dl{display:grid;align-content:start;gap:5px;margin:0;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.community-review-modal>.moderation-warning,.community-review-modal>.form-actions,.community-review-modal :global(form > .form-actions),.community-review-modal :global(form > .error){margin-right:10px;margin-left:10px}
.queue-review-evidence{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border-bottom:1px solid var(--shd-border)}.queue-review-evidence>div{min-width:0;display:grid;gap:3px;padding:8px 10px;border-right:1px solid var(--shd-border);border-top:1px solid var(--shd-border-soft)}.queue-review-evidence span,.queue-signals span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.queue-review-evidence strong{overflow-wrap:anywhere}.queue-reason-evidence{margin-top:10px}.queue-signals{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;margin:0 10px 10px;border:1px solid var(--shd-border);background:var(--shd-border)}.queue-signals>div{min-width:0;padding:8px;background:#0d1011}.queue-signals p{margin:4px 0 0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);overflow-wrap:anywhere}.queue-decision-tabs{margin:10px}.queue-error{margin:0 10px}
@media(max-width:900px){.community-registry{grid-template-columns:1fr}}
@media(max-width:650px){.community-tabs{grid-template-columns:1fr}.community-tabs a{min-height:52px;border-top:1px solid var(--shd-border);border-left:0}.community-tabs a:first-child{border-top:0}.community-filter{grid-template-columns:1fr}.community-filter button{min-height:44px}.community-registry article>header,.community-registry article>footer,.community-review-identity,.community-target{align-items:stretch;flex-direction:column}.community-state{justify-items:start}.community-target a{white-space:normal}.community-registry article>footer button{width:100%;min-height:44px}.community-final-confirm{grid-template-columns:1fr}.community-review-modal>.form-actions,.community-review-modal :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}.community-review-modal button{min-height:44px}}
@media(max-width:650px){.community-tabs{grid-template-columns:1fr}.community-tabs a{min-height:52px;border-top:1px solid var(--shd-border);border-left:0}.community-tabs a:first-child{border-top:0}.community-filter{grid-template-columns:1fr}.community-filter button{min-height:44px}.community-registry article>header,.community-registry article>footer,.community-review-identity,.community-target{align-items:stretch;flex-direction:column}.community-state{justify-items:start}.community-target a{white-space:normal}.community-registry article>footer button{width:100%;min-height:44px}.community-final-confirm,.queue-review-evidence,.queue-signals{grid-template-columns:1fr}.community-review-modal>.form-actions,.community-review-modal :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}.community-review-modal button{min-height:44px}}
@media(prefers-reduced-motion:reduce){.community-tabs a,.community-registry button{transition:none}}
</style>
+350 -7
View File
@@ -5,10 +5,15 @@ import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit';
import { invalidateSiteAccessSettings } from '$lib/server/site-access';
import { invalidateCommunityModerationPolicy } 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']);
const settingSnapshot = async () => {
const database = await getDatabase();
@@ -25,17 +30,74 @@ const flagNames = [
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;
};
const finiteNumber = (value: FormDataEntryValue | null) => {
if (value === null || String(value).trim() === '') return null;
const parsed = Number(value);
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([
(await database.prepare('SELECT * FROM community_moderation_settings WHERE id=?')).get(SETTINGS_ID),
(await database.prepare('SELECT * FROM community_moderation_thresholds ORDER BY category')).all()
]);
return { settings: settings ?? null, thresholds };
};
export const load: PageServerLoad = async ({ cookies }) => {
await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
const database = await getDatabase();
const settings = await (await database.prepare(`SELECT settings.*,
COALESCE(profile.display_name,user.username) AS updated_by_name
FROM site_launch_settings settings
LEFT JOIN users user ON user.id=settings.updated_by
LEFT JOIN user_profiles profile ON profile.user_id=user.id
WHERE settings.id=?`)).get(SETTINGS_ID);
const [settings, moderationSettings, moderationThresholds, moderationTerms] = await Promise.all([
(await database.prepare(`SELECT settings.*,
COALESCE(profile.display_name,user.username) AS updated_by_name
FROM site_launch_settings settings
LEFT JOIN users user ON user.id=settings.updated_by
LEFT JOIN user_profiles profile ON profile.user_id=user.id
WHERE settings.id=?`)).get(SETTINGS_ID),
(await database.prepare(`SELECT settings.*,
COALESCE(profile.display_name,user.username) AS updated_by_name
FROM community_moderation_settings settings
LEFT JOIN users user ON user.id=settings.updated_by
LEFT JOIN user_profiles profile ON profile.user_id=user.id
WHERE settings.id=?`)).get(SETTINGS_ID),
(await database.prepare(`SELECT thresholds.*,
COALESCE(profile.display_name,user.username) AS updated_by_name
FROM community_moderation_thresholds thresholds
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()
]);
if (!settings) error(500, 'Site launch settings are not initialized.');
return { settings };
if (!moderationSettings) error(500, 'Community moderation settings are not initialized.');
return {
settings,
moderationSettings,
moderationThresholds,
moderationTerms,
providerConfigured: Boolean(
env.OPENAI_MODERATION_API_KEY?.trim() || env.OPENAI_MECHANIC_API_KEY?.trim()
)
};
};
export const actions: Actions = {
@@ -79,5 +141,286 @@ export const actions: Actions = {
after
});
return { siteSettingsSaved: true, resultToken: randomUUID() };
},
saveModerationPolicy: 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 providerModel = String(form.get('providerModel') ?? '').trim();
if (!MODERATION_MODELS.has(providerModel)) {
return fail(400, {
moderationPolicyError: 'Choose a supported OpenAI moderation model.',
moderationResultToken: randomUUID()
});
}
const maxLinks = finiteNumber(form.get('maxLinks'));
const duplicateWindowSeconds = finiteNumber(form.get('duplicateWindowSeconds'));
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.',
moderationResultToken: randomUUID()
});
}
if (
!Number.isInteger(duplicateWindowSeconds)
|| duplicateWindowSeconds === null
|| duplicateWindowSeconds < 0
|| duplicateWindowSeconds > 3_600
) {
return fail(400, {
moderationPolicyError: 'Duplicate detection must use a whole-number window from 0 through 3,600 seconds.',
moderationResultToken: randomUUID()
});
}
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.',
moderationResultToken: randomUUID()
});
}
const thresholds: Array<{
category: string;
enabled: number;
reviewThreshold: number;
priorityThreshold: number;
}> = [];
for (const row of thresholdRows) {
const category = String(row.category);
const thresholdEnabled = strictBoolean(form, `thresholdEnabled:${category}`);
const reviewThreshold = finiteNumber(form.get(`reviewThreshold:${category}`));
const priorityThreshold = finiteNumber(form.get(`priorityThreshold:${category}`));
if (
thresholdEnabled === null
|| reviewThreshold === null
|| priorityThreshold === null
|| reviewThreshold < 0
|| reviewThreshold > 1
|| priorityThreshold < 0
|| priorityThreshold > 1
|| 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.`,
moderationResultToken: randomUUID()
});
}
thresholds.push({ category, enabled: thresholdEnabled, reviewThreshold, priorityThreshold });
}
const before = await moderationPolicySnapshot();
const now = new Date().toISOString();
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
);
for (const threshold of thresholds) {
await (await database.prepare(`UPDATE community_moderation_thresholds SET
enabled=?,review_threshold=?,priority_threshold=?,updated_at=?,updated_by=?
WHERE category=?`)).run(
threshold.enabled,
threshold.reviewThreshold,
threshold.priorityThreshold,
now,
operator.id,
threshold.category
);
}
const after = await moderationPolicySnapshot();
await recordIsacAudit({
actorId: operator.id,
category: 'system_configuration',
action: 'moderation.policy_updated',
entityId: SETTINGS_ID,
before,
after
});
await database.exec('COMMIT');
} catch (cause) {
await database.exec('ROLLBACK');
throw cause;
}
invalidateCommunityModerationPolicy();
return {
moderationPolicySaved: true,
moderationResultToken: randomUUID()
};
},
saveModerationTerm: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
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 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 failure = (message: string) => fail(400, {
moderationTermError: message,
moderationResultToken: randomUUID()
});
if (!['allow', 'ban'].includes(listType)) return failure('Choose whether this rule allows or bans matching content.');
if (!['word', 'phrase'].includes(matchMode)) return failure('Choose word or phrase matching.');
if (!normalizedValue || Array.from(normalizedValue).length > 200) return failure('Rule text must contain 1–200 characters.');
if (matchMode === 'word' && /\s/u.test(normalizedValue)) return failure('Word rules cannot contain whitespace. Use phrase matching instead.');
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 rule is enabled or disabled.');
if (Array.from(note).length > 500) return failure('Operational notes may contain up to 500 characters.');
const database = await getDatabase();
const existing = id
? await (await database.prepare('SELECT * FROM community_moderation_terms WHERE id=?')).get(id)
: null;
if (id && !existing) return fail(404, {
moderationTermError: 'That moderation rule no longer exists.',
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 || '');
const scopeConflict = conflicts.some((candidate) => {
try {
const candidateScopes = JSON.parse(String(candidate.scopes_json ?? '[]'));
return Array.isArray(candidateScopes) && candidateScopes.some((scope) => scopes.includes(String(scope)));
} catch {
return true;
}
});
if (scopeConflict) return fail(409, {
moderationTermError: 'A rule with the same normalized text and overlapping content scope already exists.',
moderationResultToken: randomUUID()
});
const termId = id || randomUUID();
const now = new Date().toISOString();
if (existing) {
await (await database.prepare(`UPDATE community_moderation_terms SET
list_kind=?,match_mode=?,value=?,normalized_value=?,scopes_json=?,enabled=?,
note=?,updated_at=?,updated_by=? WHERE id=?`)).run(
listType,
matchMode,
value,
normalizedValue,
JSON.stringify(scopes),
termEnabled,
note || null,
now,
operator.id,
termId
);
} else {
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,
matchMode,
value,
normalizedValue,
JSON.stringify(scopes),
termEnabled,
note || null,
now,
operator.id,
now,
operator.id
);
}
const after = await (await database.prepare('SELECT * FROM community_moderation_terms WHERE id=?')).get(termId);
await recordIsacAudit({
actorId: operator.id,
category: 'system_configuration',
action: existing ? 'moderation.term_updated' : 'moderation.term_created',
entityId: termId,
before: existing,
after: after ?? null
});
invalidateCommunityModerationPolicy();
return {
moderationTermSaved: true,
moderationResultToken: randomUUID()
};
},
archiveModerationTerm: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
const id = String((await request.formData()).get('id') ?? '').trim();
if (!id) return fail(400, {
moderationArchiveError: 'Choose a moderation rule to archive.',
moderationResultToken: randomUUID()
});
const database = await getDatabase();
const before = await (await database.prepare(
'SELECT * FROM community_moderation_terms WHERE id=? AND enabled=1'
)).get(id);
if (!before) return fail(404, {
moderationArchiveError: 'That enabled moderation rule no longer exists.',
moderationResultToken: randomUUID()
});
const now = new Date().toISOString();
const result = await (await database.prepare(`UPDATE community_moderation_terms
SET enabled=0,updated_at=?,updated_by=? WHERE id=? AND enabled=1`))
.run(now, operator.id, id);
if (!result.changes) return fail(409, {
moderationArchiveError: 'That moderation rule changed before it could be archived.',
moderationResultToken: randomUUID()
});
const after = await (await database.prepare('SELECT * FROM community_moderation_terms WHERE id=?')).get(id);
await recordIsacAudit({
actorId: operator.id,
category: 'system_configuration',
action: 'moderation.term_archived',
entityId: id,
before,
after: after ?? null
});
invalidateCommunityModerationPolicy();
return {
moderationTermArchived: true,
moderationResultToken: randomUUID()
};
}
};
+386 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import { untrack } from 'svelte';
let { data, form } = $props();
const binaryOptions = [
@@ -18,7 +19,52 @@
{ key: 'guides', label: 'GUIDES', description: 'Long-form field manuals and build walkthroughs.' },
{ key: 'builder', label: 'BUILDER', description: 'Loadout creation, calculation, and account saves.' }
] as const;
const moderationModelOptions = [
{ value: 'omni-moderation-latest', label: 'LATEST' },
{ value: 'omni-moderation-2024-09-26', label: 'PINNED 2024-09-26' }
];
const termListOptions = [
{ value: 'ban', label: 'BAN' },
{ value: 'allow', label: 'ALLOW' }
];
const termMatchOptions = [
{ value: 'word', label: 'WORD' },
{ value: 'phrase', label: 'PHRASE' }
];
const termScopeOptions = [
{ value: 'comment', label: 'COMMENTS' },
{ value: 'profile_bio', label: 'PROFILE BIOS' },
{ value: 'guide', label: 'GUIDES' }
];
type ModerationTermDraft = {
id: string;
listType: 'allow' | 'ban';
matchMode: 'word' | 'phrase';
value: 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) => {
try {
const scopes = JSON.parse(String(value ?? '[]'));
return Array.isArray(scopes) ? scopes.map(String) : [];
} catch {
return [];
}
};
const categoryLabel = (value: unknown) => String(value).replaceAll('_', ' ').replaceAll('/', ' / ').toUpperCase();
const blankModerationTerm = (): ModerationTermDraft => ({
id: '',
listType: 'ban',
matchMode: 'word',
value: '',
scopes: ['comment', 'profile_bio', 'guide'],
enabled: '1',
note: ''
});
let comingSoonEnabled = $state(settingValue('coming_soon_enabled'));
let accountCreationEnabled = $state(settingValue('account_creation_enabled'));
@@ -26,8 +72,34 @@
sectionDefinitions.map((section) => [section.key, settingValue(`${section.key}_enabled`)])
));
let reviewing = $state(false);
let reviewingModerationPolicy = $state(false);
let archivingModerationTerm = $state<any>(null);
let editingModerationTerm = $state<ModerationTermDraft | 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'));
let holdOnProviderError = $state(moderationSettingValue('hold_on_provider_error'));
let scanComments = $state(moderationSettingValue('scan_comments'));
let scanProfiles = $state(moderationSettingValue('scan_profiles'));
let scanGuides = $state(moderationSettingValue('scan_guides'));
let scanAvatars = $state(moderationSettingValue('scan_avatars'));
let detectPii = $state(moderationSettingValue('detect_pii'));
let providerModel = $state(untrack(() => String(data.moderationSettings.provider_model)));
let maxLinks = $state(untrack(() => String(data.moderationSettings.max_links)));
let duplicateWindowSeconds = $state(untrack(() => String(data.moderationSettings.duplicate_window_seconds)));
let thresholdDrafts = $state<Record<string, { enabled: string; review: string; priority: string }>>(
untrack(() => Object.fromEntries(data.moderationThresholds.map((threshold: any) => [
String(threshold.category),
{
enabled: Number(threshold.enabled) ? '1' : '0',
review: String(threshold.review_threshold),
priority: String(threshold.priority_threshold)
}
])))
);
const synchronize = () => {
comingSoonEnabled = settingValue('coming_soon_enabled');
@@ -37,6 +109,27 @@
settingValue(`${section.key}_enabled`)
]));
};
const synchronizeModeration = () => {
automatedEnabled = moderationSettingValue('automated_enabled');
autoPublishClean = moderationSettingValue('auto_publish_clean');
holdOnProviderError = moderationSettingValue('hold_on_provider_error');
scanComments = moderationSettingValue('scan_comments');
scanProfiles = moderationSettingValue('scan_profiles');
scanGuides = moderationSettingValue('scan_guides');
scanAvatars = moderationSettingValue('scan_avatars');
detectPii = moderationSettingValue('detect_pii');
providerModel = String(data.moderationSettings.provider_model);
maxLinks = String(data.moderationSettings.max_links);
duplicateWindowSeconds = String(data.moderationSettings.duplicate_window_seconds);
thresholdDrafts = Object.fromEntries(data.moderationThresholds.map((threshold: any) => [
String(threshold.category),
{
enabled: Number(threshold.enabled) ? '1' : '0',
review: String(threshold.review_threshold),
priority: String(threshold.priority_threshold)
}
]));
};
$effect(() => {
const updatedAt = String(data.settings.updated_at);
if (updatedAt === synchronizedAt) return;
@@ -48,6 +141,20 @@
handledResultToken = form.resultToken;
reviewing = false;
});
$effect(() => {
const syncKey = `${data.moderationSettings.updated_at}:${data.moderationSettings.policy_version}`;
if (syncKey === moderationSynchronizedAt) return;
moderationSynchronizedAt = syncKey;
synchronizeModeration();
});
$effect(() => {
const token = String(form?.moderationResultToken ?? '');
if (!token || token === handledModerationResultToken) return;
handledModerationResultToken = token;
if (form?.moderationPolicySaved) reviewingModerationPolicy = false;
if (form?.moderationTermSaved) editingModerationTerm = null;
if (form?.moderationTermArchived) archivingModerationTerm = null;
});
const currentValue = (key: string) => Number(data.settings[key]) ? 'ENABLED' : 'DISABLED';
const proposedValue = (value: string) => value === '1' ? 'ENABLED' : 'DISABLED';
@@ -71,12 +178,67 @@
];
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 editModerationTerm = (term: any) => {
editingModerationTerm = {
id: String(term.id),
listType: String(term.list_kind) as 'allow' | 'ban',
matchMode: String(term.match_mode) as 'word' | 'phrase',
value: String(term.value),
scopes: parseScopes(term.scopes_json),
enabled: Number(term.enabled) ? '1' : '0',
note: String(term.note ?? '')
};
};
const date = (value: unknown) => value
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value)))
: 'INITIAL CONFIGURATION';
</script>
<svelte:head><title>Site controls — ISAC</title></svelte:head>
<svelte:window onkeydown={(event) => {
if (event.key !== 'Escape') return;
reviewing = false;
reviewingModerationPolicy = false;
archivingModerationTerm = null;
}}/>
<div class="module-toolbar site-control-toolbar">
<div>
@@ -127,6 +289,148 @@
</div>
</div>
<section class="community-safety" aria-labelledby="community-safety-heading">
<header class="community-safety-head">
<div>
<p class="eyebrow">SYSTEM CONFIGURATION / COMMUNITY SAFETY</p>
<h2 id="community-safety-heading">COMMUNITY SAFETY</h2>
<p>Configure automated screening, exception thresholds, and deterministic allow or ban rules for user-submitted content.</p>
</div>
<div class="moderation-network-state">
<span>MODERATION PROVIDER</span>
<strong class:restricted={!data.providerConfigured}>{data.providerConfigured ? 'CONFIGURED' : 'KEY REQUIRED'}</strong>
<small>POLICY VERSION {data.moderationSettings.policy_version} · {automatedEnabled === '1' ? 'AUTOMATION ENABLED' : 'MANUAL REVIEW ONLY'}</small>
</div>
</header>
{#if !data.providerConfigured}
<div class="moderation-provider-warning" role="status">
<b>PROVIDER NOT CONFIGURED</b>
<span>Automated provider checks cannot run until an OpenAI moderation key is configured. Hold-on-error remains the safe publication policy.</span>
</div>
{/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>
<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>
<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>
</div>
{/if}
{/each}
</div>
</section>
{#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>
</div>
<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>
</div>
{#if editingModerationTerm}
{@const termDraft = editingModerationTerm}
<section class="dashboard-editor horizontal-editor moderation-term-editor" aria-label={editingModerationTerm.id ? `Edit ${editingModerationTerm.value}` : 'Add moderation rule'}>
<div class="editor-title">
<div><p class="eyebrow">{editingModerationTerm.id ? 'EDIT DETERMINISTIC RULE' : 'NEW DETERMINISTIC RULE'}</p><h3>{editingModerationTerm.id ? editingModerationTerm.value : 'DEFINE TERM'}</h3></div>
<button type="button" aria-label="Close moderation rule editor" onclick={() => editingModerationTerm = null}>×</button>
</div>
<PendingForm action="?/saveModerationTerm">
{#snippet children(pending)}
<input type="hidden" name="id" value={termDraft.id}/>
<div class="moderation-term-grid">
<label class="field moderation-term-value">WORD OR PHRASE<input name="value" bind:value={termDraft.value} maxlength="200" required/><small>Stored with NFKC normalization and lowercase matching.</small></label>
<SegmentedChoice legend="RULE EFFECT" name="listType" options={termListOptions} bind:value={termDraft.listType}/>
<SegmentedChoice legend="MATCH MODE" name="matchMode" options={termMatchOptions} bind:value={termDraft.matchMode}/>
<SegmentedChoice legend="RULE STATE" name="enabled" options={binaryOptions} bind:value={termDraft.enabled}/>
<SegmentedChoice legend="CONTENT SCOPES" name="scopes" options={termScopeOptions} mode="checkbox" bind:selected={termDraft.scopes}/>
<label class="field moderation-term-note">OPERATIONAL NOTE<textarea name="note" rows="3" maxlength="500" bind:value={termDraft.note} placeholder="Optional reason, exception context, or policy reference"></textarea></label>
</div>
{#if form?.moderationTermError}<p class="error" role="alert">{form.moderationTermError}</p>{/if}
<div class="form-actions">
<button type="button" class="secondary" disabled={pending} onclick={() => editingModerationTerm = null}>CANCEL</button>
<button class="primary" disabled={pending || !termDraft.value.trim() || !termDraft.scopes.length} aria-busy={pending}>{pending ? 'SAVING RULE…' : 'SAVE RULE →'}</button>
</div>
{/snippet}
</PendingForm>
</section>
{/if}
<div class="moderation-rule-registry">
<div class="moderation-rule-row heading"><span>RULE</span><span>SCOPES</span><span>STATE</span><span>UPDATED</span><span></span></div>
{#each data.moderationTerms as term}
<div class="moderation-rule-row" class:archived={!Number(term.enabled)}>
<div><strong>{term.value}</strong><small>{String(term.list_kind).toUpperCase()} · {String(term.match_mode).toUpperCase()}{term.note ? ` · ${term.note}` : ''}</small></div>
<div class="moderation-rule-scopes">{#each parseScopes(term.scopes_json) as scope}<b>{categoryLabel(scope)}</b>{/each}</div>
<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}
</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>
{/each}
</div>
</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">
@@ -156,8 +460,88 @@
</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">
<div class="editor-title">
<div><p class="eyebrow">ARCHIVE DETERMINISTIC RULE</p><h3 id="moderation-archive-title">{archivingModerationTerm.value}</h3></div>
<button type="button" aria-label="Close archive confirmation" onclick={() => archivingModerationTerm = null}>×</button>
</div>
<div class="moderation-archive-consequence">
<b>RULE WILL STOP MATCHING NEW CONTENT</b>
<p>Existing moderation decisions and audit evidence remain unchanged. The archived rule stays in this registry and can be edited and enabled again.</p>
</div>
<PendingForm action="?/archiveModerationTerm" successMessage="Moderation rule archived.">
{#snippet children(pending)}
<input type="hidden" name="id" value={archivingModerationTerm.id}/>
{#if form?.moderationArchiveError}<p class="error" role="alert">{form.moderationArchiveError}</p>{/if}
<div class="form-actions">
<button type="button" class="secondary" disabled={pending} onclick={() => archivingModerationTerm = null}>CANCEL</button>
<button class="danger" disabled={pending} aria-busy={pending}>{pending ? 'ARCHIVING…' : 'CONFIRM ARCHIVE →'}</button>
</div>
{/snippet}
</PendingForm>
</div>
</div>
{/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)}
@media(max-width:900px){.site-control-grid{grid-template-columns:1fr}.public-sections{grid-column:auto}.section-control-list{grid-template-columns:1fr}}
@media(max-width:650px){.site-control-state{min-width:0}.section-control-list article{grid-template-columns:1fr}.site-control-actions{display:grid}.site-control-action-buttons{display:grid;grid-template-columns:1fr}.site-control-action-buttons>*{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}}
.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)}
.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}
@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}}
</style>