feat(isac): add moderated data contributions

This commit is contained in:
JB
2026-07-25 13:08:05 -04:00
parent 6c982f7ed5
commit bb01f9b69f
18 changed files with 359 additions and 23 deletions
+9
View File
@@ -31,6 +31,15 @@ export async function requirePermission(user: AuthUser | null, permission: strin
return user;
}
export async function requireDataView(user: AuthUser | null): Promise<AuthUser> {
if (!user) error(401, 'Sign in required');
const permissions = await getPermissions(user);
if (!permissions.includes('*') && !['data.view', 'data.manage', 'data.propose', 'data.approve'].some((permission) => permissions.includes(permission))) {
error(403, 'ISAC data access required');
}
return user;
}
export function requireAdmin(user: AuthUser | null): asserts user is AuthUser {
if (!user) error(401, 'Sign in required');
if (user.role !== 'admin') error(403, 'Administrator permission required');
+66
View File
@@ -0,0 +1,66 @@
import { randomUUID } from 'node:crypto';
import { getDatabase } from '$lib/server/database';
import { getPermissions } from '$lib/server/auth/authorization';
import type { AuthUser } from '$lib/server/auth/session';
import { recordIsacAudit } from '$lib/server/audit';
export const DATA_MODULES = ['stats', 'talents', 'brands', 'gear', 'weapons', 'protocols'] as const;
export type DataModule = typeof DATA_MODULES[number];
type SerializedEntry =
| { name: string; kind: 'text'; value: string }
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
async function serialize(form: FormData): Promise<SerializedEntry[]> {
const entries: SerializedEntry[] = [];
for (const [name, value] of form.entries()) {
if (typeof value === 'string') entries.push({ name, kind: 'text', value });
else if (value.size > 0) entries.push({
name,
kind: 'file',
value: Buffer.from(await value.arrayBuffer()).toString('base64'),
fileName: value.name,
type: value.type
});
}
return entries;
}
export function deserializeProposal(payload: string): FormData {
const form = new FormData();
for (const entry of JSON.parse(payload) as SerializedEntry[]) {
if (entry.kind === 'text') form.append(entry.name, entry.value);
else form.append(entry.name, new Blob([Buffer.from(entry.value, 'base64')], { type: entry.type }), entry.fileName);
}
return form;
}
export async function queueDataProposal(user: AuthUser, module: DataModule, action: string, form: FormData) {
const id = randomUUID();
const entityId = String(form.get('id') ?? '').trim() || null;
const name = String(form.get('name') ?? '').trim();
const summary = `${action.replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || entityId || 'NEW RECORD'}`;
const now = new Date().toISOString();
const payload = JSON.stringify(await serialize(form));
const database = await getDatabase();
await (await database.prepare(`INSERT INTO data_change_proposals
(id,module,action,entity_id,summary,payload_json,status,proposed_by,created_at)
VALUES(?,?,?,?,?,?,'pending',?,?)`)).run(id, module, action, entityId, summary, payload, user.id, now);
await recordIsacAudit({
actorId: user.id,
category: 'data_moderation',
action: 'proposal.created',
entityId: id,
before: null,
after: { module, action, entityId, summary }
});
return id;
}
export async function shouldModerateDataMutation(user: AuthUser | null) {
const permissions = await getPermissions(user);
return {
canManage: permissions.includes('*') || permissions.includes('data.manage'),
canPropose: permissions.includes('*') || permissions.includes('data.propose')
};
}