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
+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() };
}
};