feat: add automated community moderation
This commit is contained in:
@@ -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>
|
||||
@@ -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> {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user