Add public agent profiles and community moderation

This commit is contained in:
JB
2026-07-27 15:38:27 -04:00
parent efb0ccdc7e
commit 6b76110b76
28 changed files with 2101 additions and 64 deletions
+5 -2
View File
@@ -33,12 +33,15 @@
{ 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; reviewKey?: 'data' | 'avatars' }> = [
const oversightSections: Array<{ href: string; label: string; permission?: string; permissions?: string[]; reviewKey?: 'data' | 'avatars' }> = [
{ 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/audit', label: 'Audit history', permission: 'audit.view' }
];
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission)));
const visibleOversightSections = $derived(oversightSections.filter((section) =>
section.permissions?.some(can) || (section.permission ? can(section.permission) : false)
));
const systemSections = [
{ href: '/isac/site-controls', label: 'Site controls', permission: 'site.settings.manage' },
{ href: '/isac/images', label: 'Image library', permission: 'images.manage' },
+1
View File
@@ -87,6 +87,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN entity_type='game_data' THEN 1 ELSE 0 END) AS game_data,
SUM(CASE WHEN entity_type='data_moderation' THEN 1 ELSE 0 END) AS data_moderation,
SUM(CASE WHEN entity_type='community_moderation' THEN 1 ELSE 0 END) AS community_moderation,
SUM(CASE WHEN entity_type IN ('user','user_access','user_moderation','identity') THEN 1 ELSE 0 END) AS users,
SUM(CASE WHEN entity_type IN ('role','role_management') THEN 1 ELSE 0 END) AS access,
SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration,
+1
View File
@@ -36,6 +36,7 @@
{value:'calculation_configuration',label:'CALCULATION RULES',count:data.summary.calculation_configuration,description:'Combat model revisions and calibration'},
{value:'ai_assistance',label:'AI ASSISTANCE',count:data.summary.ai_assistance,description:'Permission-gated mechanic drafting'},
{value:'data_moderation',label:'DATA MODERATION',count:data.summary.data_moderation,description:'Proposal lifecycle decisions'},
{value:'community_moderation',label:'COMMUNITY',count:data.summary.community_moderation,description:'Profile and comment moderation'},
{value:'user',label:'USER OPERATIONS',count:data.summary.users,description:'Access, moderation, identities'},
{value:'access_policy',label:'ACCESS POLICY',count:data.summary.access,description:'Role definition changes'}
,{value:'system_configuration',label:'SYSTEM CONFIG',count:data.summary.system_configuration,description:'Automated messages and options'}
+473
View File
@@ -0,0 +1,473 @@
import { avatarUrl } from '$lib/avatar';
import { recordIsacAudit } from '$lib/server/audit';
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { renderUserMarkdown } from '$lib/markdown';
import { createNotification } from '$lib/server/notifications';
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
import { error, fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
const COMMENT_PERMISSION = 'community.comments.moderate';
const PROFILE_PERMISSION = 'community.profiles.moderate';
const REMOVED_RETENTION_DAYS = 30;
const has = (permissions: string[], permission: string) =>
permissions.includes('*') || permissions.includes(permission);
const actionFailure = (status: number, message: string) => fail(status, {
communityError: message,
communityResultToken: randomUUID()
});
const requiredReason = (value: FormDataEntryValue | null) => {
const reason = String(value ?? '').normalize('NFKC').trim().slice(0, 500);
return reason.length >= 3 ? reason : null;
};
const targetHref = (targetType: string, targetId: unknown, slug: unknown) =>
targetType === 'profile'
? `/agents/${encodeURIComponent(String(targetId))}#profile-comments`
: `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
const parseAuditSnapshot = (value: unknown) => {
if (typeof value !== 'string') return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null;
} catch {
return null;
}
};
export const load: PageServerLoad = async ({ cookies, url }) => {
const operator = await getSessionUser(cookies);
if (!operator) error(401, 'Sign in required');
const granted = await getPermissions(operator);
const canModerateComments = has(granted, COMMENT_PERMISSION);
const canModerateProfiles = has(granted, PROFILE_PERMISSION);
if (!canModerateComments && !canModerateProfiles) error(403, 'Community Oversight permission required');
const requestedTab = url.searchParams.get('tab');
const tab = requestedTab === 'bios'
? 'bios'
: requestedTab === 'comments'
? 'comments'
: canModerateComments ? 'comments' : 'bios';
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 status = allowedStatuses.includes(String(url.searchParams.get('status')))
? String(url.searchParams.get('status'))
: 'visible';
const { search, pattern } = getRegistrySearch(url);
const database = await getDatabase();
const removedCutoff = new Date(Date.now() - REMOVED_RETENTION_DAYS * 86_400_000).toISOString();
const commentSummaryRows = canModerateComments
? await (await database.prepare(`SELECT status,COUNT(*) AS count FROM comments
WHERE status='visible' OR (status='removed' AND removed_at>=?)
GROUP BY status`)).all(removedCutoff)
: [];
const visibleBios = canModerateProfiles
? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE profile.is_public=1 AND COALESCE(moderation.bio_hidden,0)=0
AND length(trim(profile.bio_markdown))>0`)).get())?.count ?? 0)
: 0;
const hiddenBios = canModerateProfiles
? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM user_profiles profile
JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE moderation.bio_hidden=1 AND length(trim(profile.bio_markdown))>0`)).get())?.count ?? 0)
: 0;
const commentSummary = Object.fromEntries(
commentSummaryRows.map((row) => [String(row.status), Number(row.count)])
);
if (tab === 'comments') {
const sort = getRegistrySort(url, {
created: {
asc: 'comments.created_at ASC',
desc: 'comments.created_at DESC',
defaultDirection: 'desc'
},
updated: {
asc: 'comments.updated_at ASC',
desc: 'comments.updated_at DESC',
defaultDirection: 'desc'
},
author: {
asc: 'display_name COLLATE NOCASE ASC',
desc: 'display_name COLLATE NOCASE DESC'
}
}, 'created', 'desc');
const conditions: string[] = [];
const args: unknown[] = [];
if (status === 'all') {
conditions.push("(comments.status='visible' OR (comments.status='removed' AND comments.removed_at>=?))");
args.push(removedCutoff);
} else {
conditions.push('comments.status=?');
args.push(status);
}
if (status === 'removed') {
conditions.push('comments.removed_at>=?');
args.push(removedCutoff);
}
if (search) {
conditions.push(`(comments.body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(profile.display_name,users.username,'Deleted Agent') LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(builds.title,target_profile.display_name,target_user.username,'') LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
args.push(pattern, pattern, pattern);
}
const joins = `FROM comments
JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN users ON users.id=comments.author_id
LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
LEFT JOIN users target_user ON target_user.id=thread.target_id AND thread.target_type='profile'
LEFT JOIN user_profiles target_profile ON target_profile.user_id=target_user.id`;
const where = `WHERE ${conditions.join(' AND ')}`;
const total = Number((await (await database.prepare(
`SELECT COUNT(*) AS count ${joins} ${where}`
)).get(...args))?.count ?? 0);
const pagination = getPagination(url, total);
const rows = await (await database.prepare(`SELECT comments.id,comments.body_markdown,comments.status,
comments.created_at,comments.updated_at,comments.removal_reason,
comments.author_id,COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
thread.target_type,thread.target_id,builds.slug,
COALESCE(builds.title,target_profile.display_name,target_user.username,'Community content') AS target_title
${joins} ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`))
.all(...args, pagination.perPage, pagination.offset);
return {
tab,
status,
search,
summary: {
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
bios: { visible: visibleBios, hidden: hiddenBios }
},
items: rows.map((row) => ({
id: String(row.id),
bodyHtml: renderUserMarkdown(row.body_markdown),
status: String(row.status),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at),
moderationReason: row.removal_reason ? String(row.removal_reason) : null,
targetType: String(row.target_type),
targetTitle: String(row.target_title),
targetHref: targetHref(String(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,
sort: { key: sort.key, direction: sort.direction },
permissions: { comments: canModerateComments, profiles: canModerateProfiles }
};
}
const sort = getRegistrySort(url, {
updated: {
asc: 'profile.updated_at ASC',
desc: 'profile.updated_at DESC',
defaultDirection: 'desc'
},
agent: {
asc: 'display_name COLLATE NOCASE ASC',
desc: 'display_name COLLATE NOCASE DESC'
}
}, 'updated', 'desc');
const conditions = [
status === 'all'
? "((profile.is_public=1 AND COALESCE(moderation.bio_hidden,0)=0) OR moderation.bio_hidden=1)"
: status === 'visible'
? 'profile.is_public=1 AND COALESCE(moderation.bio_hidden,0)=0'
: 'moderation.bio_hidden=1',
'length(trim(profile.bio_markdown))>0'
];
const args: unknown[] = [];
if (search) {
conditions.push(`(profile.bio_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(profile.display_name,users.username) LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
args.push(pattern, pattern);
}
const where = `WHERE ${conditions.join(' AND ')}`;
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS count
FROM user_profiles profile JOIN users ON users.id=profile.user_id
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
${where}`)).get(...args))?.count ?? 0);
const pagination = getPagination(url, total);
const rows = await (await database.prepare(`SELECT profile.user_id,profile.bio_markdown,profile.is_public,
profile.updated_at,COALESCE(profile.display_name,users.username) AS display_name,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
moderation.reason AS moderation_reason
FROM user_profiles profile
JOIN users ON users.id=profile.user_id
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
LEFT JOIN profile_moderation moderation ON moderation.user_id=users.id
${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`))
.all(...args, pagination.perPage, pagination.offset);
return {
tab,
status,
search,
summary: {
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
bios: { visible: visibleBios, hidden: hiddenBios }
},
items: rows.map((row) => ({
id: String(row.user_id),
bodyHtml: renderUserMarkdown(row.bio_markdown),
status,
updatedAt: String(row.updated_at),
moderationReason: row.moderation_reason ? String(row.moderation_reason) : null,
agent: {
id: String(row.user_id),
displayName: String(row.display_name),
avatarUrl: avatarUrl(row.avatar_source, row.avatar_value, String(row.user_id), row.avatar_updated_at)
}
})),
pagination,
sort: { key: sort.key, direction: sort.direction },
permissions: { comments: canModerateComments, profiles: canModerateProfiles }
};
};
export const actions: Actions = {
removeComment: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), COMMENT_PERMISSION);
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const reason = requiredReason(form.get('reason'));
if (!id || !reason) return actionFailure(400, 'Choose a comment and provide a brief removal reason.');
const database = await getDatabase();
const comment = await (await database.prepare(`SELECT comments.*,thread.target_type,thread.target_id,builds.slug
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
WHERE comments.id=? AND comments.status='visible'`)).get(id);
if (!comment) return actionFailure(404, 'That visible comment no longer exists.');
const before = {
status: 'visible',
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
targetType: String(comment.target_type),
targetId: String(comment.target_id),
updatedAt: String(comment.updated_at)
};
const now = new Date().toISOString();
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, id);
if (!result.changes) return actionFailure(409, 'That comment changed before it could be removed.');
const after = { ...before, status: 'removed', removedAt: now, removedBy: operator.id, reason, updatedAt: now };
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: 'comment.removed',
entityId: id,
before,
after
});
await createNotification({
userId: String(comment.author_id),
category: 'isac',
source: 'ISAC COMMUNITY OVERSIGHT',
title: 'Comment removed',
body: `Community Oversight removed one of your comments. Reason: ${reason}`,
link: targetHref(String(comment.target_type), comment.target_id, comment.slug),
actions: [{
label: 'VIEW DISCUSSION',
href: targetHref(String(comment.target_type), comment.target_id, comment.slug)
}],
mandatory: true
});
return { communitySaved: true, communityResultToken: randomUUID() };
},
restoreComment: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), COMMENT_PERMISSION);
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const reason = requiredReason(form.get('reason'));
if (!id || !reason) return actionFailure(400, 'Choose a comment and provide a brief restoration reason.');
const database = await getDatabase();
const comment = await (await database.prepare(`SELECT * FROM comments WHERE id=? AND status='removed'`)).get(id);
if (!comment) return actionFailure(404, 'That removed comment no longer exists.');
const audit = await (await database.prepare(`SELECT after_json FROM audit_log
WHERE entity_type='community_moderation' AND entity_id=? AND action='comment.removed'
ORDER BY created_at DESC LIMIT 1`)).get(id);
const expected = parseAuditSnapshot(audit?.after_json);
if (
!expected
|| expected.status !== 'removed'
|| String(expected.removedAt ?? '') !== String(comment.removed_at ?? '')
|| String(expected.updatedAt ?? '') !== String(comment.updated_at ?? '')
) return actionFailure(409, 'This comment was not removed by Community Oversight or changed after removal.');
const before = {
status: 'removed',
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
removedAt: String(comment.removed_at),
removedBy: String(comment.removed_by),
removalReason: String(comment.removal_reason),
updatedAt: String(comment.updated_at)
};
const now = new Date().toISOString();
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='removed' AND updated_at=?`))
.run(now, id, comment.updated_at);
if (!result.changes) return actionFailure(409, 'That comment changed before it could be restored.');
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: 'comment.restored',
entityId: id,
before,
after: {
status: 'visible',
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
restorationReason: reason,
updatedAt: now
}
});
return { communitySaved: true, communityResultToken: randomUUID() };
},
hideBio: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), PROFILE_PERMISSION);
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const reason = requiredReason(form.get('reason'));
if (!id || !reason) return actionFailure(400, 'Choose a profile and provide a brief reason for hiding it.');
const database = await getDatabase();
const profile = await (await database.prepare(`SELECT profile.user_id,profile.bio_markdown,
profile.is_public,profile.bio_updated_at,profile.updated_at,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason,
moderation.moderated_at,moderation.moderated_by
FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE profile.user_id=? AND profile.is_public=1
AND COALESCE(moderation.bio_hidden,0)=0 AND length(trim(profile.bio_markdown))>0`)).get(id);
if (!profile) return actionFailure(404, 'That visible profile biography no longer exists.');
const before = {
bioMarkdown: String(profile.bio_markdown),
isPublic: true,
bioHidden: false,
bioUpdatedAt: profile.bio_updated_at ? String(profile.bio_updated_at) : null,
profileUpdatedAt: String(profile.updated_at),
moderation: profile.moderated_at ? {
reason: profile.reason ? String(profile.reason) : null,
moderatedAt: String(profile.moderated_at),
moderatedBy: profile.moderated_by ? String(profile.moderated_by) : null
} : null
};
const now = new Date().toISOString();
const result = 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
WHERE profile_moderation.bio_hidden=0`)).run(id, reason, now, operator.id);
if (!result.changes) return actionFailure(409, 'That profile changed before it could be hidden.');
const after = {
...before,
bioHidden: true,
reason,
moderatedAt: now,
moderatedBy: operator.id
};
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: 'profile.hidden',
entityId: id,
before,
after
});
await createNotification({
userId: id,
category: 'isac',
source: 'ISAC COMMUNITY OVERSIGHT',
title: 'Agent profile hidden',
body: `Community Oversight hid your public Agent profile. Your biography is preserved but is not publicly visible. Reason: ${reason}`,
link: '/account',
actions: [{ label: 'REVIEW PROFILE', href: '/account' }],
mandatory: true
});
return { communitySaved: true, communityResultToken: randomUUID() };
},
restoreBio: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), PROFILE_PERMISSION);
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const reason = requiredReason(form.get('reason'));
if (!id || !reason) return actionFailure(400, 'Choose a profile and provide a brief restoration reason.');
const database = await getDatabase();
const profile = await (await database.prepare(`SELECT profile.user_id,profile.bio_markdown,
profile.is_public,profile.bio_updated_at,profile.updated_at,
moderation.bio_hidden,moderation.reason,moderation.moderated_at,moderation.moderated_by
FROM user_profiles profile
JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE profile.user_id=? AND moderation.bio_hidden=1
AND length(trim(profile.bio_markdown))>0`)).get(id);
if (!profile) return actionFailure(404, 'That hidden profile biography no longer exists.');
const audit = await (await database.prepare(`SELECT after_json FROM audit_log
WHERE entity_type='community_moderation' AND entity_id=? AND action='profile.hidden'
ORDER BY created_at DESC LIMIT 1`)).get(id);
const expected = parseAuditSnapshot(audit?.after_json);
if (
!expected
|| expected.bioHidden !== true
|| String(expected.bioMarkdown ?? '') !== String(profile.bio_markdown)
|| String(expected.bioUpdatedAt ?? '') !== String(profile.bio_updated_at ?? '')
|| String(expected.profileUpdatedAt ?? '') !== String(profile.updated_at)
|| String(expected.moderatedAt ?? '') !== String(profile.moderated_at)
|| String(expected.moderatedBy ?? '') !== String(profile.moderated_by)
) return actionFailure(409, 'This profile was not hidden by Community Oversight or changed after it was hidden.');
const before = {
bioMarkdown: String(profile.bio_markdown),
isPublic: Boolean(profile.is_public),
bioHidden: true,
bioUpdatedAt: profile.bio_updated_at ? String(profile.bio_updated_at) : null,
profileUpdatedAt: String(profile.updated_at),
reason: String(profile.reason),
moderatedAt: String(profile.moderated_at),
moderatedBy: String(profile.moderated_by)
};
const now = new Date().toISOString();
const result = await (await database.prepare(`UPDATE profile_moderation
SET bio_hidden=0,reason=?,moderated_at=?,moderated_by=?
WHERE user_id=? AND bio_hidden=1 AND moderated_at=?`))
.run(reason, now, operator.id, id, profile.moderated_at);
if (!result.changes) return actionFailure(409, 'That profile changed before it could be restored.');
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: 'profile.restored',
entityId: id,
before,
after: {
...before,
bioHidden: false,
restorationReason: reason,
moderatedAt: now,
moderatedBy: operator.id
}
});
return { communitySaved: true, communityResultToken: randomUUID() };
}
};
+270
View File
@@ -0,0 +1,270 @@
<script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
type AgentSummary = { id: string; displayName: string; avatarUrl: string };
type CommunityComment = {
id: string;
bodyHtml: string;
status: 'visible' | 'removed';
createdAt: string;
updatedAt: string;
moderationReason: string | null;
targetType: string;
targetTitle: string;
targetHref: string;
author: AgentSummary;
};
type CommunityBio = {
id: string;
bodyHtml: string;
status: 'visible' | 'hidden';
updatedAt: string;
moderationReason: string | null;
agent: AgentSummary;
};
type CommunityItem = CommunityComment | CommunityBio;
type ModerationAction = 'removeComment' | 'restoreComment' | 'hideBio' | 'restoreBio';
type PageData = {
tab: 'comments' | 'bios';
status: string;
search: string;
summary: {
comments: { visible: number; removed: number };
bios: { visible: number; hidden: number };
};
items: CommunityItem[];
pagination: any;
permissions: { comments: boolean; profiles: boolean };
};
let { data, form }: { data: PageData; form?: Record<string, any> | null } = $props();
let reviewing = $state<CommunityItem | null>(null);
let moderationAction = $state<ModerationAction | null>(null);
let moderationReason = $state('');
let confirming = $state(false);
let handledResultToken = $state('');
const canModerateComments = $derived(data.permissions.comments);
const canModerateProfiles = $derived(data.permissions.profiles);
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;
const date = (value: string) => {
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf())
? 'DATE UNKNOWN'
: new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(parsed);
};
const actionRoute = $derived(
moderationAction === 'removeComment' ? '?/removeComment'
: moderationAction === 'restoreComment' ? '?/restoreComment'
: moderationAction === 'hideBio' ? '?/hideBio'
: '?/restoreBio'
);
const actionLabel = $derived(
moderationAction === 'removeComment' ? 'REMOVE COMMENT'
: moderationAction === 'restoreComment' ? 'RESTORE COMMENT'
: moderationAction === 'hideBio' ? 'HIDE PROFILE BIO'
: 'RESTORE PROFILE BIO'
);
const actionConsequence = $derived(
moderationAction === 'removeComment'
? 'The comment will no longer appear on its public target or the author’s public activity.'
: moderationAction === 'restoreComment'
? 'The comment will return to its public target and the author’s public activity.'
: moderationAction === 'hideBio'
? 'The current bio will be hidden from the Agent’s public profile.'
: 'The bio will return to the Agent’s public profile.'
);
const statusItems = $derived(
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' },
{ value: 'removed', label: 'REMOVED', count: data.summary.comments.removed, description: 'Comments removed by Oversight' }
]
: [
{ value: 'all', label: 'ALL', count: data.summary.bios.visible + data.summary.bios.hidden, description: 'All public and moderated bios' },
{ value: 'visible', label: 'VISIBLE', count: data.summary.bios.visible, description: 'Bios currently visible on Agent profiles' },
{ value: 'hidden', label: 'HIDDEN', count: data.summary.bios.hidden, description: 'Bios hidden by Oversight' }
]
);
function openModeration(item: CommunityItem) {
reviewing = item;
moderationAction = isComment(item)
? item.status === 'removed' ? 'restoreComment' : 'removeComment'
: item.status === 'hidden' ? 'restoreBio' : 'hideBio';
moderationReason = '';
confirming = false;
}
function closeModeration() {
reviewing = null;
moderationAction = null;
moderationReason = '';
confirming = false;
}
$effect(() => {
const token = String(form?.communityResultToken ?? '');
if (!token || token === handledResultToken) return;
handledResultToken = token;
if (form?.communitySaved) closeModeration();
});
</script>
<svelte:head><title>Community Oversight — ISAC</title></svelte:head>
<svelte:window onkeydown={(event) => {
if (event.key === 'Escape' && reviewing) closeModeration();
}}/>
<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>
</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>
</div>
</div>
<nav class="community-tabs" aria-label="Community oversight channels">
{#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>
</a>
{/if}
{#if canModerateProfiles}
<a class:active={data.tab === 'bios'} aria-current={data.tab === 'bios' ? 'page' : undefined} href="/isac/community?tab=bios&status=all">
<span>PROFILE BIOS</span><b>{data.summary.bios.visible + data.summary.bios.hidden}</b>
</a>
{/if}
</nav>
<KpiFilters parameter="status" active={data.status} items={statusItems}/>
<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>
</form>
<RegistryPagination pagination={data.pagination}/>
<div class="community-registry">
{#each data.items as item}
<article class:moderated={item.status === 'removed' || item.status === 'hidden'}>
<header>
<AgentIdentityLink agent={itemAgent(item)} meta={isComment(item) ? 'COMMENT AUTHOR' : 'PROFILE OWNER'} />
<div class="community-state">
<span>{date(itemTimestamp(item))}</span>
<b class:visible={item.status === 'visible'}>{item.status.toUpperCase()}</b>
</div>
</header>
{#if isComment(item)}
<div class="community-target">
<span>{item.targetType.toUpperCase()}</span>
<a href={item.targetHref}>{item.targetTitle} →</a>
</div>
{:else}
<div class="community-target"><span>PUBLIC PROFILE BIO</span><a href={`/agents/${encodeURIComponent(item.agent.id)}`}>VIEW AGENT PROFILE →</a></div>
{/if}
<div class="community-markdown-preview">
<MarkdownContent html={item.bodyHtml} compact emptyMessage={isComment(item) ? 'Comment body unavailable.' : 'No bio content.'}/>
</div>
{#if item.moderationReason}
<div class="moderation-evidence"><span>LAST OVERSIGHT REASON</span><p>{item.moderationReason}</p></div>
{/if}
<footer>
<span>{isComment(item) ? `COMMENT ${item.id.slice(0,8).toUpperCase()}` : `AGENT ${item.id.slice(0,8).toUpperCase()}`}</span>
{#if (isComment(item) && canModerateComments) || (!isComment(item) && canModerateProfiles)}
<button class={item.status === 'visible' ? 'danger' : 'secondary'} onclick={() => openModeration(item)}>
{item.status === 'visible' ? isComment(item) ? 'REMOVE COMMENT →' : 'HIDE BIO →' : isComment(item) ? 'RESTORE COMMENT →' : 'RESTORE BIO →'}
</button>
{/if}
</footer>
</article>
{: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>
</div>
{/each}
</div>
{#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">
<div class="editor-title">
<div><p class="eyebrow">COMMUNITY CONTROL / {reviewing.status.toUpperCase()}</p><h3 id="community-review-title">{actionLabel}</h3></div>
<button aria-label="Close community moderation review" onclick={closeModeration}>×</button>
</div>
<div class="community-review-identity">
<AgentIdentityLink agent={itemAgent(reviewing)} meta={isComment(reviewing) ? 'COMMENT AUTHOR' : 'PROFILE OWNER'} />
<span>{date(itemTimestamp(reviewing))}</span>
</div>
{#if isComment(reviewing)}
<div class="community-target review-target"><span>{reviewing.targetType.toUpperCase()}</span><a href={reviewing.targetHref}>{reviewing.targetTitle} →</a></div>
{/if}
<div class="community-review-content"><MarkdownContent html={reviewing.bodyHtml} emptyMessage="No content is available for review."/></div>
{#if reviewing.moderationReason}<div class="moderation-evidence"><span>PREVIOUS OVERSIGHT REASON</span><p>{reviewing.moderationReason}</p></div>{/if}
{#if !confirming}
<label class="field moderation-reason">
OVERSIGHT REASON — REQUIRED
<textarea bind:value={moderationReason} maxlength="500" required placeholder="Record a specific policy, safety, or restoration reason…"></textarea>
</label>
{#if form?.communityError}<p class="error">{form.communityError}</p>{/if}
<div class="moderation-warning" class:rejecting={moderationAction === 'removeComment' || moderationAction === 'hideBio'}>
<b>!</b><span>{actionConsequence} The original content and this reason remain available to authorized Oversight.</span>
</div>
<div class="form-actions">
<button type="button" class="secondary" onclick={closeModeration}>CANCEL</button>
<button type="button" class="primary" disabled={!moderationReason.trim()} onclick={() => confirming = true}>REVIEW ACTION →</button>
</div>
{:else}
{@const reviewingId = reviewing.id}
<div class="community-final-confirm" role="alert">
<div><span>FINAL CONFIRMATION</span><strong>{actionLabel}?</strong><p>{actionConsequence}</p></div>
<dl><dt>OVERSIGHT REASON</dt><dd>{moderationReason}</dd></dl>
</div>
<PendingForm action={actionRoute}>
{#snippet children(pending)}
<input type="hidden" name="id" value={reviewingId}/>
<input type="hidden" name="reason" value={moderationReason}/>
{#if form?.communityError}<p class="error">{form.communityError}</p>{/if}
<div class="form-actions">
<button type="button" class="secondary" disabled={pending} onclick={() => confirming = false}>BACK</button>
<button class={moderationAction === 'removeComment' || moderationAction === 'hideBio' ? 'danger' : 'primary'} disabled={pending} aria-busy={pending}>
{pending ? 'PROCESSING…' : `CONFIRM ${actionLabel} →`}
</button>
</div>
{/snippet}
</PendingForm>
{/if}
</div>
</div>
{/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 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}
.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}
@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(prefers-reduced-motion:reduce){.community-tabs a,.community-registry button{transition:none}}
</style>
+2 -2
View File
@@ -26,7 +26,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
deleted_accounts.deleted_at,
(SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at,
(SELECT COUNT(*) FROM builds WHERE owner_id=users.id) AS build_count,
(SELECT COUNT(*) FROM comments WHERE user_id=users.id) AS comment_count
(SELECT COUNT(*) FROM comments WHERE author_id=users.id) AS comment_count
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
@@ -76,7 +76,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at,
(SELECT provider FROM login_events WHERE user_id=users.id AND succeeded=1 ORDER BY created_at DESC LIMIT 1) AS last_login_provider,
(SELECT COUNT(*) FROM builds WHERE owner_id=users.id) AS build_count,
(SELECT COUNT(*) FROM comments WHERE user_id=users.id) AS comment_count
(SELECT COUNT(*) FROM comments WHERE author_id=users.id) AS comment_count
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
+14 -4
View File
@@ -30,12 +30,17 @@ export const load: PageServerLoad = async ({ cookies, params, url }) => {
const loginArgs: unknown[] = [params.id, loginPattern, loginPattern, loginPattern, loginPattern, loginPattern, ...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : [])];
const buildWhere = `owner_id=? AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR slug LIKE ? ESCAPE '\\' COLLATE NOCASE)${buildVisibility ? ' AND visibility=?' : ''}`;
const buildArgs: unknown[] = [params.id, pattern(buildQuery), pattern(buildQuery), ...(buildVisibility ? [buildVisibility] : [])];
const commentWhere = `comments.user_id=? AND (comments.body LIKE ? ESCAPE '\\' COLLATE NOCASE OR builds.title LIKE ? ESCAPE '\\' COLLATE NOCASE)`;
const commentWhere = `comments.author_id=? AND (comments.body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(builds.title,target_profiles.display_name,'Agent profile') LIKE ? ESCAPE '\\' COLLATE NOCASE)`;
const commentArgs: unknown[] = [params.id, pattern(commentQuery), pattern(commentQuery)];
const [loginCount, buildCount, commentCount] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS count FROM login_events WHERE ${loginWhere}`)).get(...loginArgs),
(await database.prepare(`SELECT COUNT(*) AS count FROM builds WHERE ${buildWhere}`)).get(...buildArgs),
(await database.prepare(`SELECT COUNT(*) AS count FROM comments JOIN builds ON builds.id=comments.build_id WHERE ${commentWhere}`)).get(...commentArgs)
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
JOIN comment_threads ON comment_threads.id=comments.thread_id
LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id
LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_id
WHERE ${commentWhere}`)).get(...commentArgs)
]);
const loginPagination = getPagination(url, Number(loginCount?.count ?? 0), 'login');
const buildPagination = getPagination(url, Number(buildCount?.count ?? 0), 'build');
@@ -67,8 +72,13 @@ export const load: PageServerLoad = async ({ cookies, params, url }) => {
FROM login_events LEFT JOIN sessions ON sessions.id=login_events.session_id
WHERE ${loginWhere} ORDER BY login_events.created_at DESC LIMIT ? OFFSET ?`)).all(...loginArgs, loginPagination.perPage, loginPagination.offset),
(await database.prepare(`SELECT id,title,slug,visibility,updated_at FROM builds WHERE ${buildWhere} ORDER BY updated_at DESC LIMIT ? OFFSET ?`)).all(...buildArgs, buildPagination.perPage, buildPagination.offset),
(await database.prepare(`SELECT comments.id,comments.body,comments.created_at,builds.title,builds.slug
FROM comments JOIN builds ON builds.id=comments.build_id WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)).all(...commentArgs, commentPagination.perPage, commentPagination.offset)
(await database.prepare(`SELECT comments.id,comments.body_markdown AS body,comments.created_at,
COALESCE(builds.title,target_profiles.display_name,'Agent profile') AS title,builds.slug
FROM comments
JOIN comment_threads ON comment_threads.id=comments.thread_id
LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id
LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_id
WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)).all(...commentArgs, commentPagination.perPage, commentPagination.offset)
,getAuthenticationMethods(database, params.id)
,(await database.prepare('SELECT email,verified_at,updated_at FROM user_emails WHERE user_id=?')).all(params.id)
,(await database.prepare('SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC')).all(params.id)