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
+63
View File
@@ -0,0 +1,63 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
let {
contentType,
contentId,
label = 'REPORT',
result
}: {
contentType: 'comment' | 'profile_bio' | 'guide' | 'avatar';
contentId: string;
label?: string;
result?: Record<string, unknown> | null;
} = $props();
const categories = [
{ value: 'spam', label: 'SPAM OR PROMOTION' },
{ value: 'harassment', label: 'HARASSMENT OR BULLYING' },
{ value: 'hate', label: 'HATEFUL CONTENT' },
{ value: 'sexual', label: 'SEXUAL CONTENT' },
{ value: 'violence', label: 'VIOLENCE OR THREATS' },
{ value: 'self_harm', label: 'SELF-HARM' },
{ value: 'privacy', label: 'PRIVATE INFORMATION' },
{ value: 'other', label: 'OTHER POLICY CONCERN' }
];
const matchesResult = $derived(
String(result?.userReportContentId ?? '') === contentId
&& String(result?.userReportContentType ?? '') === contentType
);
</script>
<details class="content-report">
<summary>{label}</summary>
<div class="content-report-panel">
<strong>REQUEST OVERSIGHT REVIEW</strong>
<p>Send this content to Community Oversight. A report does not remove it automatically.</p>
<PendingForm action="?/reportContent" toast={false}>
{#snippet children(pending)}
<input type="hidden" name="contentType" value={contentType}/>
<input type="hidden" name="contentId" value={contentId}/>
<label class="field">CONCERN
<select name="category" required>
{#each categories as category}<option value={category.value}>{category.label}</option>{/each}
</select>
</label>
<label class="field">DETAILS — OPTIONAL
<textarea name="details" maxlength="500" rows="3" placeholder="Give Oversight useful context without repeating sensitive information."></textarea>
</label>
{#if matchesResult && result?.userReportError}<p class="error">{String(result.userReportError)}</p>{/if}
{#if matchesResult && result?.userReportSaved}<p class="success">Report sent to Community Oversight.</p>{/if}
<div class="content-report-actions">
<button class="primary" disabled={pending} aria-busy={pending}>{pending ? 'SENDING…' : 'SEND REPORT →'}</button>
</div>
{/snippet}
</PendingForm>
</div>
</details>
<style>
.content-report{position:relative}.content-report summary{list-style:none;min-height:32px;display:inline-flex;align-items:center;padding:0 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}.content-report summary::-webkit-details-marker{display:none}.content-report summary:hover,.content-report summary:focus-visible{border-color:var(--shd-danger);color:var(--shd-danger)}.content-report[open] summary{border-color:var(--shd-orange);color:var(--shd-orange)}.content-report-panel{position:absolute;z-index:8;right:0;width:min(380px,calc(100vw - 32px));display:grid;gap:8px;padding:10px;border:1px solid var(--shd-border-strong);background:var(--shd-surface);box-shadow:0 18px 46px #000a}.content-report-panel>strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.content-report-panel>p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.content-report-panel :global(form fieldset){display:grid;gap:8px}.content-report-panel :global(.field){margin:0}.content-report-panel textarea{min-height:80px}.content-report-actions{display:flex;justify-content:flex-end}.content-report-actions button{min-height:40px}
@media(max-width:650px){.content-report-panel{position:fixed;right:16px;bottom:16px;left:16px;width:auto}.content-report summary{min-height:44px}.content-report-actions button{width:100%;min-height:44px}}
@media(prefers-reduced-motion:reduce){.content-report summary{transition:none}}
</style>
@@ -0,0 +1,17 @@
<aside class="moderation-disclosure" aria-label="Automated safety review">
<span aria-hidden="true">i</span>
<p>
This submission may receive automated safety review.
<a href="/terms#privacy">TERMS & PRIVACY →</a>
</p>
</aside>
<style>
.moderation-disclosure{display:flex;align-items:flex-start;gap:8px;margin:8px 0;padding:8px 9px;border:1px solid var(--shd-border-soft);background:var(--shd-surface);color:var(--shd-text-muted)}
.moderation-disclosure>span{flex:0 0 20px;display:grid;place-items:center;width:20px;height:20px;border:1px solid var(--shd-border-strong);color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}
.moderation-disclosure p{margin:0;font-size:var(--shd-type-telemetry);line-height:1.5}
.moderation-disclosure a{display:inline-block;margin-left:4px;color:var(--shd-orange);font-weight:900;letter-spacing:.4px}
.moderation-disclosure a:hover{text-decoration:underline}
.moderation-disclosure a:focus-visible{outline:2px solid var(--shd-orange);outline-offset:3px}
@media(max-width:650px){.moderation-disclosure{padding:10px}.moderation-disclosure a{display:block;min-height:44px;margin:4px 0 0;padding-top:8px}}
</style>
+47 -5
View File
@@ -49,7 +49,12 @@ export async function processAvatarUpload(file: File): Promise<Buffer> {
}
}
export async function saveUploadedAvatar(database: Database, userId: string, output: Buffer): Promise<string> {
export async function saveUploadedAvatar(
database: Database,
userId: string,
output: Buffer,
options: { queueReview: boolean } = { queueReview: true }
): Promise<{ uploadedAt: string; reviewId: string | null }> {
const key = storageKey(userId);
const directory = avatarStorageDirectory();
await mkdir(directory, { recursive: true });
@@ -61,18 +66,55 @@ export async function saveUploadedAvatar(database: Database, userId: string, out
.run(userId, 'upload', key, output.length, now);
await (await database.prepare("UPDATE avatar_reviews SET status='superseded',reviewed_at=? WHERE user_id=? AND status='pending'"))
.run(now, userId);
let reviewId: string | null = null;
if (options.queueReview) {
reviewId = await queueCurrentAvatarReview(
database,
userId,
{
origin: 'automated',
summary: 'Automated safety checks flagged a custom avatar for Oversight review.'
}
);
}
return { uploadedAt: now, reviewId };
}
export async function queueCurrentAvatarReview(
database: Database,
userId: string,
options: {
origin: 'automated' | 'user_report' | 'legacy';
summary?: string;
} = { origin: 'user_report' }
): Promise<string | null> {
const avatar = await (await database.prepare(`SELECT source,value,byte_size,updated_at
FROM user_avatars WHERE user_id=?`)).get(userId);
if (avatar?.source !== 'upload' || typeof avatar.value !== 'string') return null;
const existing = await (await database.prepare(
"SELECT id FROM avatar_reviews WHERE user_id=? AND avatar_updated_at=? AND status='pending'"
)).get(userId, avatar.updated_at);
if (existing?.id) return String(existing.id);
const reviewId = randomUUID();
await (await database.prepare(`INSERT INTO avatar_reviews
(id,user_id,avatar_updated_at,storage_key,byte_size,status,submitted_at)
VALUES(?,?,?,?,?,'pending',?)`)).run(reviewId, userId, now, key, output.length, now);
(id,user_id,avatar_updated_at,storage_key,byte_size,status,review_origin,submitted_at)
VALUES(?,?,?,?,?,'pending',?,?)`)).run(
reviewId,
userId,
avatar.updated_at,
avatar.value,
avatar.byte_size,
options.origin,
new Date().toISOString()
);
await notifyOversightReview({
category: 'avatars',
itemId: reviewId,
title: 'Avatar awaiting review',
summary: 'A new custom agent avatar was uploaded and is ready for Oversight review.',
summary: options.summary ?? 'An Agent report requested Oversight review of a custom avatar.',
link: '/isac/avatar-oversight?status=pending'
}).catch((error) => console.error('Unable to notify Avatar Oversight reviewers', error));
return now;
return reviewId;
}
export async function selectPresetAvatar(database: Database, userId: string, preset: string): Promise<void> {
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import {
assessCommunityModeration,
findLocalModerationMatches,
normalizeModerationTerm,
type CommunityModerationPolicy
} from './community-moderation';
const policy = (overrides: Partial<CommunityModerationPolicy> = {}): CommunityModerationPolicy => ({
automatedEnabled: true,
autoPublishClean: true,
holdOnProviderError: true,
scanComments: true,
scanProfiles: true,
scanGuides: true,
scanAvatars: true,
providerModel: 'omni-moderation-latest',
detectPii: true,
maxLinks: 1,
duplicateWindowSeconds: 300,
policyVersion: 1,
thresholds: [{
category: 'harassment',
enabled: true,
reviewThreshold: 0.5,
priorityThreshold: 0.85
}],
terms: [],
...overrides
});
describe('community moderation policy', () => {
it('normalizes configured terms consistently', () => {
expect(normalizeModerationTerm(' Rogue\u00a0Agent ')).toBe('rogue agent');
});
it('allows an exact local exception to suppress a matching banned term', () => {
const matches = findLocalModerationMatches('Rogue Agent tactics', 'comment', policy({
detectPii: false,
terms: [
{ id: 'ban', listType: 'ban', matchMode: 'phrase', value: 'Rogue Agent', normalizedValue: 'rogue agent', scopes: ['comment'], enabled: true },
{ id: 'allow', listType: 'allow', matchMode: 'phrase', value: 'Rogue Agent', normalizedValue: 'rogue agent', scopes: ['comment'], enabled: true }
]
}));
expect(matches).toEqual([]);
});
it('finds scoped banned terms, PII, and excessive links', () => {
const matches = findLocalModerationMatches(
'Contact test@example.com about forbidden plans at https://one.test and https://two.test',
'comment',
policy({
terms: [{
id: 'ban',
listType: 'ban',
matchMode: 'word',
value: 'forbidden',
normalizedValue: 'forbidden',
scopes: ['comment'],
enabled: true
}]
})
);
expect(matches.map((match) => match.kind)).toEqual(['banned_term', 'pii', 'link_limit']);
});
it('routes priority scores ahead of ordinary review scores', () => {
expect(assessCommunityModeration({
policy: policy(),
contentType: 'comment',
providerFlagged: true,
categoryScores: { harassment: 0.9 },
ruleMatches: []
}).decision).toBe('priority_review');
});
it('holds provider failures under the default fail-closed policy', () => {
expect(assessCommunityModeration({
policy: policy(),
contentType: 'profile_bio',
providerFlagged: false,
categoryScores: {},
ruleMatches: [],
providerError: 'timeout'
}).decision).toBe('error');
});
it('publishes clean content only when auto publication is enabled', () => {
expect(assessCommunityModeration({
policy: policy(),
contentType: 'comment',
providerFlagged: false,
categoryScores: { harassment: 0.01 },
ruleMatches: []
}).decision).toBe('approved');
expect(assessCommunityModeration({
policy: policy({ autoPublishClean: false }),
contentType: 'comment',
providerFlagged: false,
categoryScores: { harassment: 0.01 },
ruleMatches: []
}).decision).toBe('review');
});
it('publishes locally clean content when provider scanning is disabled', () => {
expect(assessCommunityModeration({
policy: policy({ automatedEnabled: false }),
contentType: 'comment',
providerFlagged: false,
categoryScores: {},
ruleMatches: []
}).decision).toBe('approved');
});
});
+611
View File
@@ -0,0 +1,611 @@
import { env } from '$env/dynamic/private';
import { getDatabase } from '$lib/server/database';
import { createHash, randomUUID } from 'node:crypto';
import OpenAI from 'openai';
export const MODERATION_CATEGORIES = [
'harassment',
'harassment/threatening',
'hate',
'hate/threatening',
'illicit',
'illicit/violent',
'self-harm',
'self-harm/intent',
'self-harm/instructions',
'sexual',
'sexual/minors',
'violence',
'violence/graphic'
] as const;
export type ModerationCategory = (typeof MODERATION_CATEGORIES)[number];
export type ModeratedContentType = 'comment' | 'profile_bio' | 'guide' | 'avatar';
export type ModerationDecision = 'approved' | 'review' | 'priority_review' | 'error';
export type ModerationThreshold = {
category: ModerationCategory;
enabled: boolean;
reviewThreshold: number;
priorityThreshold: number;
};
export type ModerationTerm = {
id: string;
listType: 'allow' | 'ban';
matchMode: 'word' | 'phrase';
value: string;
normalizedValue: string;
scopes: ModeratedContentType[];
enabled: boolean;
};
export type CommunityModerationPolicy = {
automatedEnabled: boolean;
autoPublishClean: boolean;
holdOnProviderError: boolean;
scanComments: boolean;
scanProfiles: boolean;
scanGuides: boolean;
scanAvatars: boolean;
providerModel: 'omni-moderation-latest' | 'omni-moderation-2024-09-26';
detectPii: boolean;
maxLinks: number;
duplicateWindowSeconds: number;
policyVersion: number;
thresholds: ModerationThreshold[];
terms: ModerationTerm[];
};
export type LocalModerationMatch = {
kind: 'banned_term' | 'pii' | 'link_limit';
label: string;
excerpt: string;
termId?: string;
};
export type CommunityModerationResult = {
contentHash: string;
decision: ModerationDecision;
riskLevel: 'low' | 'medium' | 'high';
reason: string;
policyVersion: number;
provider: 'openai' | 'local';
model: string;
requestId: string | null;
categoryScores: Partial<Record<ModerationCategory, number>>;
categoryFlags: Partial<Record<ModerationCategory, boolean>>;
ruleMatches: LocalModerationMatch[];
error: string | null;
};
const DEFAULT_POLICY: CommunityModerationPolicy = {
automatedEnabled: false,
autoPublishClean: true,
holdOnProviderError: true,
scanComments: true,
scanProfiles: true,
scanGuides: true,
scanAvatars: true,
providerModel: 'omni-moderation-latest',
detectPii: true,
maxLinks: 3,
duplicateWindowSeconds: 300,
policyVersion: 1,
thresholds: [],
terms: []
};
let cachedPolicy: { value: CommunityModerationPolicy; expiresAt: number } | null = null;
let client: OpenAI | null = null;
export function invalidateCommunityModerationPolicy() {
cachedPolicy = null;
}
export function normalizeModerationTerm(value: string) {
return value.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase();
}
export function hashModeratedContent(value: string) {
return createHash('sha256').update(value.normalize('NFC')).digest('hex');
}
function parseScopes(value: unknown): ModeratedContentType[] {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (!Array.isArray(parsed)) return [];
return parsed.filter((scope): scope is ModeratedContentType =>
scope === 'comment' || scope === 'profile_bio' || scope === 'guide' || scope === 'avatar'
);
} catch {
return [];
}
}
function boundedNumber(value: unknown, fallback: number, min: number, max: number) {
const number = Number(value);
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
}
export async function getCommunityModerationPolicy(): Promise<CommunityModerationPolicy> {
if (cachedPolicy && cachedPolicy.expiresAt > Date.now()) return cachedPolicy.value;
const database = await getDatabase();
const [settings, thresholdRows, termRows] = await Promise.all([
(await database.prepare("SELECT * FROM community_moderation_settings WHERE id='primary'")).get(),
(await database.prepare('SELECT * FROM community_moderation_thresholds ORDER BY category')).all(),
(await database.prepare('SELECT * FROM community_moderation_terms WHERE enabled=1 ORDER BY list_kind,value')).all()
]);
const model = String(settings?.provider_model ?? DEFAULT_POLICY.providerModel);
const value: CommunityModerationPolicy = {
automatedEnabled: Boolean(settings?.automated_enabled),
autoPublishClean: settings?.auto_publish_clean === undefined
? DEFAULT_POLICY.autoPublishClean
: Boolean(settings.auto_publish_clean),
holdOnProviderError: settings?.hold_on_provider_error === undefined
? DEFAULT_POLICY.holdOnProviderError
: Boolean(settings.hold_on_provider_error),
scanComments: settings?.scan_comments === undefined ? true : Boolean(settings.scan_comments),
scanProfiles: settings?.scan_profiles === undefined ? true : Boolean(settings.scan_profiles),
scanGuides: settings?.scan_guides === undefined ? true : Boolean(settings.scan_guides),
scanAvatars: settings?.scan_avatars === undefined ? true : Boolean(settings.scan_avatars),
providerModel: model === 'omni-moderation-2024-09-26'
? model
: 'omni-moderation-latest',
detectPii: settings?.detect_pii === undefined ? true : Boolean(settings.detect_pii),
maxLinks: boundedNumber(settings?.max_links, DEFAULT_POLICY.maxLinks, 0, 20),
duplicateWindowSeconds: boundedNumber(
settings?.duplicate_window_seconds,
DEFAULT_POLICY.duplicateWindowSeconds,
0,
3_600
),
policyVersion: Math.max(1, Number(settings?.policy_version ?? 1)),
thresholds: thresholdRows.flatMap((row) => {
const category = String(row.category);
if (!MODERATION_CATEGORIES.includes(category as ModerationCategory)) return [];
return [{
category: category as ModerationCategory,
enabled: Boolean(row.enabled),
reviewThreshold: boundedNumber(row.review_threshold, 0.5, 0, 1),
priorityThreshold: boundedNumber(row.priority_threshold, 0.85, 0, 1)
}];
}),
terms: termRows.flatMap((row) => {
const listType = String(row.list_kind);
const matchMode = String(row.match_mode);
if (!['allow', 'ban'].includes(listType) || !['word', 'phrase'].includes(matchMode)) return [];
return [{
id: String(row.id),
listType: listType as 'allow' | 'ban',
matchMode: matchMode as 'word' | 'phrase',
value: String(row.value),
normalizedValue: String(row.normalized_value),
scopes: parseScopes(row.scopes_json),
enabled: Boolean(row.enabled)
}];
})
};
cachedPolicy = { value, expiresAt: Date.now() + 30_000 };
return value;
}
function scopeEnabled(policy: CommunityModerationPolicy, contentType: ModeratedContentType) {
if (contentType === 'comment') return policy.scanComments;
if (contentType === 'profile_bio') return policy.scanProfiles;
if (contentType === 'guide') return policy.scanGuides;
return policy.scanAvatars;
}
function wordPattern(value: string) {
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(^|[^\\p{L}\\p{N}_])${escaped}(?=$|[^\\p{L}\\p{N}_])`, 'iu');
}
function matchesTerm(text: string, term: ModerationTerm) {
return term.matchMode === 'word'
? wordPattern(term.normalizedValue).test(text)
: text.includes(term.normalizedValue);
}
function excerpt(value: string, limit = 80) {
const compact = value.replace(/\s+/g, ' ').trim();
return compact.length > limit ? `${compact.slice(0, limit - 1)}…` : compact;
}
const piiPatterns = [
{ label: 'EMAIL ADDRESS', pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/iu },
{ label: 'PHONE NUMBER', pattern: /(?:\+?\d[\d\s().-]{7,}\d)/u },
{ label: 'IP ADDRESS', pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/u }
] as const;
const linkPattern = /\bhttps?:\/\/[^\s<>()]+/giu;
export function findLocalModerationMatches(
text: string,
contentType: ModeratedContentType,
policy: Pick<CommunityModerationPolicy, 'terms' | 'detectPii' | 'maxLinks'>
): LocalModerationMatch[] {
const normalized = normalizeModerationTerm(text);
const scoped = policy.terms.filter((term) =>
term.enabled && (term.scopes.length === 0 || term.scopes.includes(contentType))
);
const allowKeys = new Set(scoped.filter((term) => term.listType === 'allow' && matchesTerm(normalized, term))
.map((term) => `${term.matchMode}:${term.normalizedValue}`));
const matches: LocalModerationMatch[] = scoped
.filter((term) =>
term.listType === 'ban'
&& !allowKeys.has(`${term.matchMode}:${term.normalizedValue}`)
&& matchesTerm(normalized, term)
)
.map((term) => ({
kind: 'banned_term',
label: `BANNED ${term.matchMode.toLocaleUpperCase()}`,
excerpt: excerpt(term.value),
termId: term.id
}));
if (policy.detectPii) {
for (const definition of piiPatterns) {
const match = text.match(definition.pattern);
if (match?.[0]) matches.push({
kind: 'pii',
label: definition.label,
excerpt: excerpt(match[0])
});
}
}
const links = [...text.matchAll(linkPattern)];
if (links.length > policy.maxLinks) matches.push({
kind: 'link_limit',
label: 'LINK LIMIT',
excerpt: `${links.length} links submitted; policy allows ${policy.maxLinks}`
});
return matches;
}
export function assessCommunityModeration(input: {
policy: CommunityModerationPolicy;
contentType: ModeratedContentType;
providerFlagged: boolean;
categoryScores: Partial<Record<ModerationCategory, number>>;
categoryFlags?: Partial<Record<ModerationCategory, boolean>>;
ruleMatches: LocalModerationMatch[];
providerError?: string | null;
}): Pick<CommunityModerationResult, 'decision' | 'riskLevel' | 'reason'> {
if (!input.policy.automatedEnabled || !scopeEnabled(input.policy, input.contentType)) {
if (input.ruleMatches.some((match) => match.kind === 'banned_term')) {
return { decision: 'priority_review', riskLevel: 'high', reason: 'A configured banned term requires priority review.' };
}
if (input.ruleMatches.length) {
return { decision: 'review', riskLevel: 'medium', reason: 'A deterministic safety rule requires review.' };
}
return { decision: 'approved', riskLevel: 'low', reason: 'Automated provider scanning is disabled and no local rule was triggered.' };
}
if (input.providerError) {
return input.policy.holdOnProviderError
? { decision: 'error', riskLevel: 'medium', reason: 'The moderation provider could not classify this content.' }
: { decision: 'approved', riskLevel: 'low', reason: 'Provider errors are configured to allow publication.' };
}
if (input.ruleMatches.some((match) => match.kind === 'banned_term')) {
return { decision: 'priority_review', riskLevel: 'high', reason: 'A configured banned term requires priority review.' };
}
if (input.ruleMatches.length) {
return { decision: 'review', riskLevel: 'medium', reason: 'A deterministic safety rule requires review.' };
}
const enabledThresholds = input.policy.thresholds.filter((threshold) => threshold.enabled);
if (enabledThresholds.some((threshold) =>
Number(input.categoryScores[threshold.category] ?? 0) >= threshold.priorityThreshold
)) {
return { decision: 'priority_review', riskLevel: 'high', reason: 'A provider category exceeded its priority threshold.' };
}
if (input.providerFlagged || enabledThresholds.some((threshold) =>
Number(input.categoryScores[threshold.category] ?? 0) >= threshold.reviewThreshold
)) {
return { decision: 'review', riskLevel: 'medium', reason: 'The moderation provider flagged this content for review.' };
}
return input.policy.autoPublishClean
? { decision: 'approved', riskLevel: 'low', reason: 'No configured moderation rule was triggered.' }
: { decision: 'review', riskLevel: 'medium', reason: 'Clean content requires human approval under the current policy.' };
}
function moderationClient() {
const apiKey = env.OPENAI_MODERATION_API_KEY?.trim() || env.OPENAI_MECHANIC_API_KEY?.trim();
if (!apiKey) return null;
if (!client) client = new OpenAI({ apiKey, timeout: 15_000, maxRetries: 1 });
return client;
}
export async function moderateCommunityText(input: {
contentType: ModeratedContentType;
text: string;
policy?: CommunityModerationPolicy;
}): Promise<CommunityModerationResult> {
const policy = input.policy ?? await getCommunityModerationPolicy();
const contentHash = hashModeratedContent(input.text);
const ruleMatches = findLocalModerationMatches(input.text, input.contentType, policy);
if (!policy.automatedEnabled || !scopeEnabled(policy, input.contentType)) {
const assessment = assessCommunityModeration({
policy,
contentType: input.contentType,
providerFlagged: false,
categoryScores: {},
ruleMatches
});
return {
...assessment,
contentHash,
policyVersion: policy.policyVersion,
provider: 'local',
model: 'local-rules-v1',
requestId: null,
categoryScores: {},
categoryFlags: {},
ruleMatches,
error: null
};
}
const openai = moderationClient();
if (!openai) {
const error = 'OpenAI moderation is not configured.';
const assessment = assessCommunityModeration({
policy,
contentType: input.contentType,
providerFlagged: false,
categoryScores: {},
ruleMatches,
providerError: error
});
return {
...assessment,
contentHash,
policyVersion: policy.policyVersion,
provider: 'openai',
model: policy.providerModel,
requestId: null,
categoryScores: {},
categoryFlags: {},
ruleMatches,
error
};
}
try {
const response = await openai.moderations.create({
model: policy.providerModel,
input: input.text
});
const result = response.results[0];
const scores = Object.fromEntries(MODERATION_CATEGORIES.map((category) => [
category,
Number(result?.category_scores[category] ?? 0)
])) as Record<ModerationCategory, number>;
const flags = Object.fromEntries(MODERATION_CATEGORIES.map((category) => [
category,
Boolean(result?.categories[category])
])) as Record<ModerationCategory, boolean>;
const assessment = assessCommunityModeration({
policy,
contentType: input.contentType,
providerFlagged: Boolean(result?.flagged),
categoryScores: scores,
categoryFlags: flags,
ruleMatches
});
return {
...assessment,
contentHash,
policyVersion: policy.policyVersion,
provider: 'openai',
model: String(response.model),
requestId: String((response as typeof response & { _request_id?: string })._request_id ?? response.id),
categoryScores: scores,
categoryFlags: flags,
ruleMatches,
error: null
};
} catch (error) {
const message = error instanceof Error ? error.message.slice(0, 500) : 'Moderation provider request failed.';
const assessment = assessCommunityModeration({
policy,
contentType: input.contentType,
providerFlagged: false,
categoryScores: {},
ruleMatches,
providerError: message
});
return {
...assessment,
contentHash,
policyVersion: policy.policyVersion,
provider: 'openai',
model: policy.providerModel,
requestId: null,
categoryScores: {},
categoryFlags: {},
ruleMatches,
error: message
};
}
}
export async function moderateCommunityImage(input: {
contentType: 'avatar';
image: Buffer;
policy?: CommunityModerationPolicy;
}): Promise<CommunityModerationResult> {
const policy = input.policy ?? await getCommunityModerationPolicy();
const contentHash = createHash('sha256').update(input.image).digest('hex');
if (!policy.automatedEnabled || !scopeEnabled(policy, input.contentType)) {
return {
contentHash,
decision: 'approved',
riskLevel: 'low',
reason: 'Automated image scanning is disabled.',
policyVersion: policy.policyVersion,
provider: 'local',
model: 'local-rules-v1',
requestId: null,
categoryScores: {},
categoryFlags: {},
ruleMatches: [],
error: null
};
}
const openai = moderationClient();
if (!openai) {
const error = 'OpenAI moderation is not configured.';
const assessment = assessCommunityModeration({
policy,
contentType: input.contentType,
providerFlagged: false,
categoryScores: {},
ruleMatches: [],
providerError: error
});
return {
...assessment,
contentHash,
policyVersion: policy.policyVersion,
provider: 'openai',
model: policy.providerModel,
requestId: null,
categoryScores: {},
categoryFlags: {},
ruleMatches: [],
error
};
}
try {
const response = await openai.moderations.create({
model: policy.providerModel,
input: [{
type: 'image_url',
image_url: { url: `data:image/webp;base64,${input.image.toString('base64')}` }
}]
});
const result = response.results[0];
const scores = Object.fromEntries(MODERATION_CATEGORIES.map((category) => [
category,
Number(result?.category_scores[category] ?? 0)
])) as Record<ModerationCategory, number>;
const flags = Object.fromEntries(MODERATION_CATEGORIES.map((category) => [
category,
Boolean(result?.categories[category])
])) as Record<ModerationCategory, boolean>;
const assessment = assessCommunityModeration({
policy,
contentType: input.contentType,
providerFlagged: Boolean(result?.flagged),
categoryScores: scores,
categoryFlags: flags,
ruleMatches: []
});
return {
...assessment,
contentHash,
policyVersion: policy.policyVersion,
provider: 'openai',
model: String(response.model),
requestId: String((response as typeof response & { _request_id?: string })._request_id ?? response.id),
categoryScores: scores,
categoryFlags: flags,
ruleMatches: [],
error: null
};
} catch (error) {
const message = error instanceof Error ? error.message.slice(0, 500) : 'Moderation provider request failed.';
const assessment = assessCommunityModeration({
policy,
contentType: input.contentType,
providerFlagged: false,
categoryScores: {},
ruleMatches: [],
providerError: message
});
return {
...assessment,
contentHash,
policyVersion: policy.policyVersion,
provider: 'openai',
model: policy.providerModel,
requestId: null,
categoryScores: {},
categoryFlags: {},
ruleMatches: [],
error: message
};
}
}
export async function recordCommunityModerationScan(input: {
contentType: ModeratedContentType;
contentId: string;
revisionId?: string | null;
authorId: string;
result: CommunityModerationResult;
}) {
const database = await getDatabase();
const id = randomUUID();
const now = new Date().toISOString();
const result = await (await database.prepare(`INSERT OR IGNORE INTO content_moderation_scans
(id,content_type,content_id,revision_id,author_id,content_hash,policy_version,
provider,model,provider_request_id,status,risk_level,reason,scores_json,
flags_json,rule_matches_json,error_code,error_message,created_at,completed_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
id,
input.contentType,
input.contentId,
input.revisionId ?? null,
input.authorId,
input.result.contentHash,
input.result.policyVersion,
input.result.provider,
input.result.model,
input.result.requestId,
input.result.decision,
input.result.riskLevel,
input.result.reason,
JSON.stringify(input.result.categoryScores),
JSON.stringify(input.result.categoryFlags),
JSON.stringify(input.result.ruleMatches),
input.result.error ? 'provider_error' : null,
input.result.error,
now,
now
);
if (result.changes) return id;
const existing = await (await database.prepare(`SELECT id FROM content_moderation_scans
WHERE content_type=? AND content_id=? AND content_hash=? AND policy_version=?
AND provider=? AND model=?`)).get(
input.contentType,
input.contentId,
input.result.contentHash,
input.result.policyVersion,
input.result.provider,
input.result.model
);
return String(existing?.id ?? id);
}
export async function createUserContentReport(input: {
contentType: ModeratedContentType;
contentId: string;
reporterId: string;
category: 'spam' | 'harassment' | 'hate' | 'sexual' | 'violence' | 'self_harm' | 'privacy' | 'other';
details?: string | null;
}) {
const database = await getDatabase();
const id = randomUUID();
const createdAt = new Date().toISOString();
const result = await (await database.prepare(`INSERT OR IGNORE INTO user_content_reports
(id,content_type,content_id,reporter_id,category,details,status,created_at)
VALUES(?,?,?,?,?,?,'pending',?)`)).run(
id,
input.contentType,
input.contentId,
input.reporterId,
input.category,
input.details?.trim() || null,
createdAt
);
return result.changes ? { id, createdAt } : null;
}
+69 -14
View File
@@ -11,12 +11,17 @@ import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/se
import { recordIsacAudit } from '$lib/server/audit';
import { normalizeUserMarkdown, renderUserMarkdown } from '$lib/markdown';
import { randomUUID } from 'node:crypto';
import {
moderateCommunityImage,
moderateCommunityText,
recordCommunityModerationScan
} from '$lib/server/community-moderation';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread, profile] = await Promise.all([
const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread, profile, pendingBioRevision] = await Promise.all([
(await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id),
(await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id),
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id),
@@ -38,7 +43,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason AS moderation_reason
FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE profile.user_id=?`)).get(user.id)
WHERE profile.user_id=?`)).get(user.id),
(await database.prepare(`SELECT revision.body_markdown,revision.created_at,scan.status,scan.reason
FROM moderated_content_revisions revision
JOIN content_moderation_scans scan ON scan.revision_id=revision.id
LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
WHERE revision.content_type='profile_bio' AND revision.content_id=?
AND scan.status IN ('review','priority_review','error') AND review.id IS NULL
ORDER BY revision.created_at DESC LIMIT 1`)).get(user.id)
]);
const oauthErrorCode = url.searchParams.get('oauth_error');
const oauthErrors: Record<string, { title: string; message: string }> = {
@@ -64,7 +76,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
return {
user: {
...user,
bioMarkdown: String(profile?.bio_markdown ?? ''),
bioMarkdown: String(pendingBioRevision?.body_markdown ?? profile?.bio_markdown ?? ''),
profileIsPublic: profile?.is_public === undefined ? true : Boolean(profile.is_public)
},
account: { builds, guides, favorites, ratings, notifications, notificationUnread: Number(notificationUnread?.count ?? 0) },
@@ -96,6 +108,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
isPublic: Boolean(profile?.is_public),
bioHidden: Boolean(profile?.bio_hidden),
moderationReason: profile?.moderation_reason ? String(profile.moderation_reason) : null,
bioReviewPending: Boolean(pendingBioRevision),
bioReviewReason: pendingBioRevision?.reason ? String(pendingBioRevision.reason) : null,
pendingBioSubmittedAt: pendingBioRevision?.created_at ? String(pendingBioRevision.created_at) : null,
bioUpdatedAt: profile?.bio_updated_at ? String(profile.bio_updated_at) : null,
href: `/agents/${encodeURIComponent(user.id)}`
}
@@ -116,16 +131,37 @@ export const actions: Actions = {
}
const now = new Date().toISOString();
const database = await getDatabase();
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(user.id, user.displayName, bio, now, now);
const moderation = await moderateCommunityText({ contentType: 'profile_bio', text: bio });
const candidateRevisionId = randomUUID();
await (await database.prepare(`INSERT OR IGNORE INTO moderated_content_revisions
(id,content_type,content_id,submitted_by,body_markdown,content_hash,created_at)
VALUES(?,'profile_bio',?,?,?,?,?)`))
.run(candidateRevisionId, user.id, user.id, bio, moderation.contentHash, now);
const storedRevision = await (await database.prepare(`SELECT id
FROM moderated_content_revisions
WHERE content_type='profile_bio' AND content_id=? AND content_hash=?`))
.get(user.id, moderation.contentHash);
const revisionId = String(storedRevision?.id ?? candidateRevisionId);
if (moderation.decision === 'approved') {
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(user.id, user.displayName, bio, now, now);
}
await recordCommunityModerationScan({
contentType: 'profile_bio',
contentId: user.id,
revisionId,
authorId: user.id,
result: moderation
});
return {
bioSaved: true,
bioReviewQueued: moderation.decision !== 'approved',
bioMarkdown: bio,
bioResultToken: randomUUID()
};
@@ -227,10 +263,29 @@ export const actions: Actions = {
if (!(file instanceof File)) return fail(400, { avatarError: 'Choose an image to upload.' });
try {
const output = await processAvatarUpload(file);
const moderation = await moderateCommunityImage({ contentType: 'avatar', image: output });
const database = await getDatabase();
const uploadedAt = await saveUploadedAvatar(database, user.id, output);
await recordIsacAudit({ actorId: user.id, category: 'user_moderation', action: 'avatar.submitted', entityId: user.id, after: { status: 'pending', uploadedAt, byteSize: output.length } });
return { avatarChanged: true, avatarReviewQueued: true };
const queueReview = moderation.decision !== 'approved';
const { uploadedAt, reviewId } = await saveUploadedAvatar(database, user.id, output, { queueReview });
await recordCommunityModerationScan({
contentType: 'avatar',
contentId: user.id,
authorId: user.id,
result: moderation
});
await recordIsacAudit({
actorId: user.id,
category: 'user_moderation',
action: 'avatar.submitted',
entityId: user.id,
after: {
status: queueReview ? 'pending_review' : 'automatically_approved',
uploadedAt,
byteSize: output.length,
reviewId
}
});
return { avatarChanged: true, avatarReviewQueued: queueReview };
} catch (error) {
if (error instanceof AvatarValidationError) return fail(400, { avatarError: error.message });
throw error;
+15 -4
View File
@@ -3,6 +3,7 @@
import PendingForm from '$lib/components/PendingForm.svelte';
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
import { toast } from '$lib/toast';
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
@@ -60,8 +61,13 @@
if (!token || token === handledBioToken) return;
handledBioToken = token;
if (form?.bioSaved) {
bioDraft = String(user.bioMarkdown ?? bioDraft);
toast.success('Profile bio saved', 'Your public Agent profile has been updated.');
bioDraft = String(form.bioMarkdown ?? user.bioMarkdown ?? bioDraft);
toast.success(
form?.bioReviewQueued ? 'Profile bio received' : 'Profile bio saved',
form?.bioReviewQueued
? 'Automated checks sent this revision to Oversight. Your last approved bio remains public.'
: 'Your public Agent profile has been updated.'
);
} else if (form?.bioError) {
toast.error('Profile bio not saved', String(form.bioError));
}
@@ -154,8 +160,9 @@
<p class="eyebrow">CUSTOM AVATAR</p>
<ImageUploadInput name="avatar" accept="image/jpeg,image/png,image/webp,image/avif" required emptyLabel="NO AVATAR SELECTED" browseLabel="CHOOSE AVATAR"/>
<small class="identity-note">JPEG, PNG, WebP, or AVIF · 5 MiB maximum · cropped to a 256 px square.</small>
<ModerationDisclosure/>
{#if form?.avatarError}<p class="error">{form.avatarError}</p>{/if}
{#if form?.avatarChanged}<p class="success">{form?.avatarReviewQueued ? 'Avatar active now · queued for Oversight review.' : 'Avatar updated.'}</p>{/if}
{#if form?.avatarChanged}<p class="success">{form?.avatarReviewQueued ? 'Avatar received and held for Oversight review. The default portrait remains public.' : 'Avatar updated.'}</p>{/if}
<button class="secondary console-action">UPLOAD CUSTOM AVATAR →</button>
</PendingForm>
{/if}
@@ -167,6 +174,9 @@
{#if data.profile.bioHidden}
<div class="account-alert error-alert" role="status"><b>BIO HIDDEN BY OVERSIGHT</b><span>{data.profile.moderationReason ?? 'Review the ISAC notification in your inbox for details.'} You may edit your bio, but it will remain hidden until Oversight restores it.</span></div>
{/if}
{#if data.profile.bioReviewPending}
<div class="account-alert" role="status"><b>BIO REVISION AWAITING REVIEW</b><span>{data.profile.bioReviewReason ?? 'Automated safety checks requested human review.'} Your last approved biography remains public while Oversight reviews this revision.</span></div>
{/if}
<PendingForm action="?/saveBio">
<MarkdownEditor
name="bio"
@@ -176,8 +186,9 @@
rows={10}
bind:value={bioDraft}
/>
<ModerationDisclosure/>
{#if form?.bioError}<p class="error">{form.bioError}</p>{/if}
{#if form?.bioSaved}<p class="success">Public bio updated.</p>{/if}
{#if form?.bioSaved}<p class="success">{form?.bioReviewQueued ? 'Bio revision queued for Oversight review.' : 'Public bio updated.'}</p>{/if}
<button class="primary console-action">SAVE PUBLIC BIO →</button>
</PendingForm>
<div class="profile-visibility-control">
+96 -7
View File
@@ -3,13 +3,19 @@ import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { normalizeUserMarkdown, renderUserMarkdown } from '$lib/markdown';
import { createNotification } from '$lib/server/notifications';
import {
getCommunityModerationPolicy,
moderateCommunityText,
createUserContentReport,
recordCommunityModerationScan
} from '$lib/server/community-moderation';
import { error, fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { queueCurrentAvatarReview } from '$lib/server/avatars';
import type { Actions, PageServerLoad } from './$types';
const COMMENT_MAX_CHARACTERS = 2_000;
const RAPID_COMMENT_SECONDS = 10;
const DUPLICATE_COMMENT_SECONDS = 300;
const unavailable = (row: Record<string, unknown> | null | undefined) => {
const banned = typeof row?.banned_until === 'string' && new Date(row.banned_until) > new Date();
@@ -163,11 +169,13 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
comment.avatar_updated_at
)
},
canDelete: viewer?.id === String(comment.author_id)
canDelete: viewer?.id === String(comment.author_id),
canReport: Boolean(viewer) && viewer?.id !== String(comment.author_id)
})),
viewer: {
id: viewer?.id ?? null,
canComment: Boolean(viewer) && Boolean(agent.is_public),
canReportProfile: Boolean(viewer) && !isSelf,
isSelf
}
};
@@ -187,6 +195,7 @@ export const actions: Actions = {
});
}
const database = await getDatabase();
const moderationPolicy = await getCommunityModerationPolicy();
const now = new Date();
const rows = await (await database.prepare(`SELECT users.id,
COALESCE(profile.display_name,users.username) AS display_name,profile.is_public,
@@ -207,7 +216,7 @@ export const actions: Actions = {
const recent = await (await database.prepare(`SELECT comments.body_markdown,comments.created_at
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.author_id=? AND comments.status='visible'
WHERE comments.author_id=? AND comments.status IN ('visible','pending_review')
AND thread.target_type='profile' AND thread.target_id=?
ORDER BY comments.created_at DESC LIMIT 1`)).get(commenter.id, params.id);
if (typeof recent?.created_at === 'string') {
@@ -215,11 +224,17 @@ export const actions: Actions = {
if (elapsed < RAPID_COMMENT_SECONDS) {
return fail(429, { profileCommentError: 'Wait a few seconds before posting again.' });
}
if (elapsed < DUPLICATE_COMMENT_SECONDS && recent.body_markdown === body) {
if (elapsed < moderationPolicy.duplicateWindowSeconds && recent.body_markdown === body) {
return fail(409, { profileCommentError: 'That comment was already posted.' });
}
}
const moderation = await moderateCommunityText({
contentType: 'comment',
text: body,
policy: moderationPolicy
});
const commentStatus = moderation.decision === 'approved' ? 'visible' : 'pending_review';
const createdAt = now.toISOString();
const proposedThreadId = randomUUID();
await (await database.prepare(`INSERT OR IGNORE INTO comment_threads
@@ -232,9 +247,15 @@ export const actions: Actions = {
const commentId = randomUUID();
await (await database.prepare(`INSERT INTO comments
(id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at)
VALUES(?,?,?,NULL,?,'visible',?,?)`))
.run(commentId, thread.id, commenter.id, body, createdAt, createdAt);
if (commenter.id !== params.id) {
VALUES(?,?,?,NULL,?,?,?,?)`))
.run(commentId, thread.id, commenter.id, body, commentStatus, createdAt, createdAt);
await recordCommunityModerationScan({
contentType: 'comment',
contentId: commentId,
authorId: commenter.id,
result: moderation
});
if (commentStatus === 'visible' && commenter.id !== params.id) {
await createNotification({
userId: params.id,
category: 'comments',
@@ -250,6 +271,7 @@ export const actions: Actions = {
}
return {
profileCommentSaved: true,
profileCommentReviewQueued: commentStatus === 'pending_review',
profileCommentId: commentId,
profileCommentResultToken: randomUUID()
};
@@ -278,5 +300,72 @@ export const actions: Actions = {
profileCommentId: commentId,
profileCommentResultToken: randomUUID()
};
},
reportContent: async ({ params, cookies, request }) => {
const reporter = await getSessionUser(cookies);
const form = await request.formData();
const contentType = String(form.get('contentType') ?? '');
const contentId = String(form.get('contentId') ?? '').trim();
const category = String(form.get('category') ?? '');
const details = String(form.get('details') ?? '').normalize('NFKC').trim();
const failure = (status: number, message: string) => fail(status, {
userReportError: message,
userReportContentId: contentId,
userReportContentType: contentType,
userReportResultToken: randomUUID()
});
if (!reporter) return failure(401, 'Sign in to request an Oversight review.');
if (!['comment', 'profile_bio', 'avatar'].includes(contentType)) {
return failure(400, 'Choose reportable content.');
}
if (!['spam', 'harassment', 'hate', 'sexual', 'violence', 'self_harm', 'privacy', 'other'].includes(category)) {
return failure(400, 'Choose the policy concern that best fits this report.');
}
if (details.length > 500 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(details)) {
return failure(400, 'Report details may contain up to 500 visible characters.');
}
const database = await getDatabase();
if (contentType === 'comment') {
const comment = await (await database.prepare(`SELECT comments.author_id
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.id=? AND comments.status='visible'
AND thread.target_type='profile' AND thread.target_id=?`)).get(contentId, params.id);
if (!comment) return failure(404, 'That comment is no longer available.');
if (String(comment.author_id) === reporter.id) return failure(400, 'You cannot report your own comment.');
} else {
if (contentId !== params.id) return failure(400, 'That profile content does not match this Agent.');
if (reporter.id === params.id) return failure(400, 'You cannot report your own profile content.');
const profile = await (await database.prepare(`SELECT profile.bio_markdown,profile.is_public,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,avatar.source AS avatar_source
FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
LEFT JOIN user_avatars avatar ON avatar.user_id=profile.user_id
WHERE profile.user_id=?`)).get(params.id);
if (!profile?.is_public) return failure(404, 'That profile is no longer available.');
if (contentType === 'profile_bio' && (profile.bio_hidden || !String(profile.bio_markdown ?? '').trim())) {
return failure(404, 'That biography is no longer available.');
}
if (contentType === 'avatar' && profile.avatar_source !== 'upload') {
return failure(400, 'Preset SHD avatars are managed site assets and do not need an Agent report.');
}
}
const report = await createUserContentReport({
contentType: contentType as 'comment' | 'profile_bio' | 'avatar',
contentId,
reporterId: reporter.id,
category: category as 'spam' | 'harassment' | 'hate' | 'sexual' | 'violence' | 'self_harm' | 'privacy' | 'other',
details
});
if (!report) return failure(409, 'You already have a pending report for this content.');
if (contentType === 'avatar') {
await queueCurrentAvatarReview(database, params.id, { origin: 'user_report' });
}
return {
userReportSaved: true,
userReportContentId: contentId,
userReportContentType: contentType,
userReportResultToken: randomUUID()
};
}
};
+12 -3
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import ContentReport from '$lib/components/ContentReport.svelte';
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
@@ -32,6 +34,7 @@
createdAt: string;
author: { id: string; displayName: string; avatarUrl: string };
canDelete: boolean;
canReport: boolean;
};
type PageData = {
agent: PublicAgent;
@@ -40,7 +43,7 @@
guides: PublicContent[];
authoredComments: AuthoredComment[];
profileComments: ProfileComment[];
viewer: { id: string | null; canComment: boolean; isSelf: boolean };
viewer: { id: string | null; canComment: boolean; canReportProfile: boolean; isSelf: boolean };
};
let { data, form }: { data: PageData; form?: Record<string, any> | null } = $props();
@@ -75,6 +78,7 @@
<header class="agent-command">
<AgentIdentityLink agent={data.agent} size="large" meta="SHD.BUILD AGENT PROFILE" />
<div class="agent-command-actions">
{#if data.viewer.canReportProfile}<ContentReport contentType="avatar" contentId={data.agent.id} label="REPORT AVATAR" result={form}/>{/if}
{#if data.viewer.isSelf}<a class="secondary" href="/account">EDIT MY PROFILE →</a>{/if}
</div>
</header>
@@ -89,7 +93,7 @@
<div class="agent-profile-grid">
<div class="agent-primary">
<section class="profile-section agent-bio">
<header><div><p class="eyebrow">FIELD DOSSIER</p><h1>ABOUT {data.agent.displayName.toUpperCase()}</h1></div><span>PUBLIC BIO</span></header>
<header><div><p class="eyebrow">FIELD DOSSIER</p><h1>ABOUT {data.agent.displayName.toUpperCase()}</h1></div><div class="profile-header-actions"><span>PUBLIC BIO</span>{#if data.viewer.canReportProfile && data.agent.bioHtml}<ContentReport contentType="profile_bio" contentId={data.agent.id} label="REPORT BIO" result={form}/>{/if}</div></header>
<div class="bio-content">
<MarkdownContent html={data.agent.bioHtml} emptyMessage="This Agent has not published a field bio yet." />
</div>
@@ -167,8 +171,11 @@
required
bind:value={profileCommentDraft}
/>
<ModerationDisclosure/>
{#if form?.profileCommentError || form?.commentError}<p class="error">{form?.profileCommentError ?? form?.commentError}</p>{/if}
{#if form?.profileCommentSaved || form?.commentSaved}<p class="success">Profile message transmitted.</p>{/if}
{#if form?.profileCommentSaved || form?.commentSaved}
<p class="success">{form?.profileCommentReviewQueued ? 'Profile message received and held for Oversight review.' : 'Profile message transmitted.'}</p>
{/if}
<button class="primary" disabled={!profileCommentDraft.trim()}>POST MESSAGE →</button>
</PendingForm>
{:else if !data.viewer.id}
@@ -185,6 +192,7 @@
{#if comment.canDelete}
<button type="button" class="comment-remove" aria-expanded={deletingCommentId === comment.id} onclick={() => deletingCommentId = comment.id}>REMOVE</button>
{/if}
{#if comment.canReport}<ContentReport contentType="comment" contentId={comment.id} result={form}/>{/if}
</header>
<MarkdownContent html={comment.bodyHtml} compact />
{#if deletingCommentId === comment.id}
@@ -223,6 +231,7 @@
.public-empty{grid-column:1/-1;min-height:150px;display:grid;place-content:center;gap:6px;padding:20px;text-align:center}.public-empty span{color:var(--shd-orange);font-size:28px}.public-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:1px}.public-empty p{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.public-empty.compact{min-height:110px}
.authored-comment-list{display:grid;gap:7px;padding:7px}.authored-comment-list>article{display:grid;gap:9px;padding:10px;border:1px solid var(--shd-border);background:#0d1011}.authored-comment-list article>header{display:flex;justify-content:space-between;gap:10px}.authored-comment-list article>header>div{display:grid;gap:3px}.authored-comment-list article header span,.authored-comment-list article header small,.authored-comment-list article>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.authored-comment-list article header span,.authored-comment-list article>a{color:var(--shd-orange)}.authored-comment-list article header small{color:var(--shd-text-muted)}.authored-comment-list article>a{justify-self:end}
.profile-comms{align-self:start;position:sticky;top:88px}:global(form.profile-comment-form){display:grid;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border)}:global(form.profile-comment-form > button){min-height:40px}.profile-comment-list{display:grid}.profile-comment-list>article{display:grid;gap:9px;padding:10px;border-bottom:1px solid var(--shd-border)}.profile-comment-list>article:last-child{border-bottom:0}.profile-comment-list article>header{display:flex;align-items:center;justify-content:space-between;gap:8px}.profile-comment-list article>header :global(.agent-identity){flex:1}.comment-remove{min-height:32px;padding:0 7px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}.comment-remove:hover,.comment-remove:focus-visible{background:var(--shd-danger);color:var(--shd-bg)}.comment-delete-confirm{display:grid;gap:8px;padding:8px;border:1px solid var(--shd-danger);background:#1b1111}.comment-delete-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}.comment-delete-confirm button{min-height:38px;padding:0 10px}.comment-access-note{display:grid;gap:7px;padding:12px;border-bottom:1px solid var(--shd-border)}.comment-access-note strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.comment-access-note p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-access-note a{justify-self:start}
.profile-header-actions{display:flex;align-items:center;gap:7px}.profile-header-actions>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
@media(max-width:1000px){.agent-profile-grid{grid-template-columns:1fr}.profile-comms{position:static}.agent-telemetry{grid-template-columns:repeat(2,minmax(0,1fr))}.agent-telemetry>div:nth-child(3){border-left:0}.agent-telemetry>div:nth-child(n+3){border-top:1px solid var(--shd-border)}}
@media(max-width:650px){.agent-command{align-items:stretch;flex-direction:column}.agent-command-actions a{width:100%;min-height:44px;justify-content:center}.agent-telemetry{grid-template-columns:1fr}.agent-telemetry>div{border-top:1px solid var(--shd-border);border-left:0}.agent-telemetry>div:first-child{border-top:0}.public-content-grid{grid-template-columns:1fr}.profile-section>header{align-items:flex-start;flex-direction:column}:global(form.profile-comment-form > button),.comment-delete-confirm button{min-height:44px}.comment-delete-confirm>div{display:grid;grid-template-columns:1fr 1fr}}
@media(prefers-reduced-motion:reduce){.public-content-card,.comment-remove{transition:none}}
+16 -4
View File
@@ -1,16 +1,28 @@
import { getDatabase } from '$lib/server/database';
import { readUploadedAvatar } from '$lib/server/avatars';
import { error } from '@sveltejs/kit';
import { getSessionUser } from '$lib/server/auth/session';
import { hasPermission } from '$lib/server/auth/authorization';
import { error, redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, cookies }) => {
if (!/^[0-9a-f-]{36}$/i.test(params.id)) error(404, 'Avatar not found');
const image = await readUploadedAvatar(await getDatabase(), params.id);
const database = await getDatabase();
const held = await (await database.prepare(`SELECT review.id
FROM avatar_reviews review
JOIN user_avatars avatar
ON avatar.user_id=review.user_id AND avatar.updated_at=review.avatar_updated_at
WHERE review.user_id=? AND review.status='pending' AND review.review_origin='automated'
LIMIT 1`)).get(params.id);
if (held && !await hasPermission(await getSessionUser(cookies), 'avatars.review')) {
redirect(307, '/avatars/default/field-agent.webp');
}
const image = await readUploadedAvatar(database, params.id);
if (!image) error(404, 'Avatar not found');
return new Response(new Uint8Array(image), {
headers: {
'content-type': 'image/webp',
'cache-control': 'public, max-age=31536000, immutable',
'cache-control': held ? 'private, no-store' : 'public, max-age=31536000, immutable',
'x-content-type-options': 'nosniff'
}
});
+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>
+37 -5
View File
@@ -11,7 +11,7 @@
<p class="eyebrow"><span></span> SHD NETWORK POLICY</p>
<h1>TERMS & <em>PRIVACY.</em></h1>
<p>
Effective July 25, 2026. These terms set the rules for using SHD.build and explain what
Effective July 27, 2026. These terms set the rules for using SHD.build and explain what
information we process to operate and secure the service.
</p>
</header>
@@ -76,6 +76,12 @@
or presents a safety, privacy, legal, or abuse risk. Removing content does not
remove related moderation, audit, or legal records when SHD.build must retain them.
</p>
<p>
SHD.build may screen user content with local rules and OpenAI’s moderation API
before or after publication. Automated classification can publish clean content or
hold flagged content for Community Oversight; an authorized human makes the final
decision on held or reported content.
</p>
</div>
</section>
<section id="conduct">
@@ -140,6 +146,31 @@
notification category, and email delivery defaults to off. OAuth and email
providers process information under their own policies.
</p>
<p>
When automated content screening is enabled, SHD.build sends the submitted text
or custom avatar image to OpenAI’s API for safety classification. SHD.build does
not include your password, authentication tokens, or private sign-in credentials
in that moderation request. We retain the resulting category scores, flags,
request reference, content hash, local-rule matches, and review outcome as needed
to operate moderation, investigate abuse, and document decisions.
</p>
<p>
After SHD.build sends content to OpenAI, OpenAI controls its processing under
its then-current service terms, privacy policy, data controls, and SHD.build’s
OpenAI account settings. Those rules may permit retention, human safety review,
or use for model improvement, including when data sharing has been enabled.
OpenAI currently states that API data is not used to train or improve its models
unless the API customer opts in, and that default abuse-monitoring logs may be
retained for up to 30 days, subject to stated exceptions. SHD.build cannot
guarantee that OpenAI’s terms or practices will remain unchanged. Review
OpenAI’s
<a href="https://developers.openai.com/api/docs/guides/your-data#default-usage-policies-by-endpoint" rel="external"
>API data controls</a
>,
<a href="https://openai.com/policies/services-agreement/" rel="external">Services Agreement</a>,
and <a href="https://openai.com/policies/privacy-policy/" rel="external">privacy policy</a>
for the current rules governing API content.
</p>
</div>
</section>
<section id="cookies">
@@ -231,10 +262,11 @@
<div>
<h2>THIRD-PARTY SERVICES</h2>
<p>
SHD.build may connect to Discord, email delivery services, and future identity
providers. Each provider applies its own terms. Links to other sites do not imply
endorsement. When you choose a linked sign-in method, you authorize SHD.build to
request the minimum provider data needed for that method.
SHD.build may connect to OpenAI for content safety classification, Discord and
future identity providers for sign-in, and email services for message delivery.
Each provider applies its own terms. Links to other sites do not imply endorsement.
When you choose a linked sign-in method, you authorize SHD.build to request the
minimum provider data needed for that method.
</p>
</div>
</section>