Files
shd.build/src/lib/server/data-moderation.ts
T

991 lines
33 KiB
TypeScript

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';
import { getContributorMode } from '$lib/server/contributor-mode';
import { notifyOversightReview } from '$lib/server/oversight-notifications';
import type { Database } from '@tursodatabase/database';
import { GameDataIconValidationError } from '$lib/server/game-data-icons';
import { ManagedImageValidationError } from '$lib/server/managed-images';
import { MediaMutationJournal } from '$lib/server/media-journal';
import {
readTalentFormMedia,
saveTalentFromFormInTransaction,
TalentNotFoundError,
type SaveTalentInTransactionResult,
} from '../../routes/isac/talents/talent-save';
import { MechanicSourceFingerprintConflictError } from '$lib/server/mechanic-authored-sources';
import {
parseProtocolForm,
ProtocolFormValidationError,
ProtocolNotFoundError,
saveProtocolInTransaction,
} from '../../routes/isac/protocols/protocol-save';
import {
_saveSkillChipInTransaction,
parseSkillChipForm,
SkillChipFormValidationError,
SkillChipNotFoundError,
} from '../../routes/isac/skillchips/skillchip-save';
import {
parseSpecializationCombatEffectForm,
parseSpecializationNodeForm,
saveSpecializationCombatEffectInTransaction,
saveSpecializationNodeInTransaction,
SpecializationFormValidationError,
} from '../../routes/isac/specializations/specialization-save';
export const DATA_MODULES = [
'configuration',
'calculations',
'specializations',
'attributes',
'talents',
'brands',
'gear',
'weapons',
'weaponmods',
'gearmods',
'protocols',
'skillchips',
] as const;
export type DataModule = (typeof DATA_MODULES)[number];
const proposalResultKeys: Partial<
Record<DataModule, Record<string, { saved: string; mode?: string }>>
> = {
configuration: { saveCoreRanges: { saved: 'configurationSaved' } },
calculations: {
saveRevision: { saved: 'revisionSaved', mode: 'revisionMode' },
saveCalibration: { saved: 'calibrationSaved', mode: 'calibrationMode' },
},
attributes: { save: { saved: 'saved', mode: 'editorMode' } },
brands: { save: { saved: 'brandSaved', mode: 'brandMode' } },
gear: {
save: { saved: 'gearSaved', mode: 'gearMode' },
saveAugmentProfile: { saved: 'augmentProfileSaved' },
},
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
weaponmods: {
save: { saved: 'weaponModSaved', mode: 'weaponModMode' },
saveSlotIcon: { saved: 'weaponModSlotIconSaved' },
saveSecondaryPool: {
saved: 'weaponModSecondaryPoolSaved',
mode: 'weaponModSecondaryPoolMode',
},
},
gearmods: {
save: { saved: 'gearModSaved', mode: 'gearModMode' },
saveSecondaryPool: {
saved: 'gearModSecondaryPoolSaved',
mode: 'gearModSecondaryPoolMode',
},
},
talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } },
protocols: {
save: { saved: 'protocolSaved', mode: 'protocolMode' },
saveProfile: { saved: 'profileSaved' },
},
skillchips: {
save: { saved: 'skillChipSaved', mode: 'skillChipMode' },
savePrimaryProfile: { saved: 'skillChipPrimaryProfileSaved' },
saveSecondaryPool: { saved: 'skillChipSecondaryPoolSaved' },
},
specializations: {
saveProgression: { saved: 'progressionSaved' },
saveSpecialization: {
saved: 'specializationSaved',
mode: 'specializationMode',
},
saveFocus: { saved: 'focusSaved', mode: 'focusMode' },
saveAbility: { saved: 'abilitySaved', mode: 'abilityMode' },
saveCombatEffect: { saved: 'combatEffectSaved', mode: 'combatEffectMode' },
saveFocusAbilities: { saved: 'focusAbilitiesSaved' },
saveNode: { saved: 'nodeSaved', mode: 'nodeMode' },
},
};
export function proposalActionResult(
module: DataModule,
action: string,
edit: boolean,
proposalId: string,
) {
const keys = proposalResultKeys[module]?.[action];
return keys
? {
[keys.saved]: true,
...(keys.mode ? { [keys.mode]: edit ? 'edit' : 'add' } : {}),
proposalId,
resultToken: randomUUID(),
}
: { proposalQueued: true, proposalId, resultToken: randomUUID() };
}
export const MAX_PROPOSAL_ENTRIES = 300;
export const MAX_PROPOSAL_TEXT_ENTRY_CHARS = 100_000;
export const MAX_PROPOSAL_TEXT_BYTES = 2 * 1024 * 1024;
export const MAX_PROPOSAL_FILE_BYTES = 5 * 1024 * 1024;
export const MAX_PROPOSAL_FILE_BYTES_TOTAL = 12 * 1024 * 1024;
const MAX_PROPOSAL_FILE_BASE64_CHARS =
4 * Math.ceil(MAX_PROPOSAL_FILE_BYTES / 3);
export const MAX_PROPOSAL_FIELD_NAME_CHARS = 200;
export const MAX_PROPOSAL_FILE_NAME_CHARS = 255;
export const MAX_PROPOSAL_FILE_TYPE_CHARS = 255;
const PROPOSAL_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u;
const PROPOSAL_BASE64_PATTERN =
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
export type SerializedEntry =
| { name: string; kind: 'text'; value: string }
| {
name: string;
kind: 'file';
value: string;
fileName: string;
type: string;
};
export class ProposalPayloadValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ProposalPayloadValidationError';
}
}
export type PendingDefinition = {
module: DataModule;
action: string;
kind: string;
fields?: string[];
};
function validateProposalFieldName(name: string) {
if (
Array.from(name).length > MAX_PROPOSAL_FIELD_NAME_CHARS ||
PROPOSAL_CONTROL_CHARACTERS.test(name)
)
throw new ProposalPayloadValidationError(
`Proposal field names must be at most ${MAX_PROPOSAL_FIELD_NAME_CHARS} characters and contain no control characters.`,
);
}
function validateProposalFileMetadata(fileName: string, type: string) {
if (
Array.from(fileName).length > MAX_PROPOSAL_FILE_NAME_CHARS ||
PROPOSAL_CONTROL_CHARACTERS.test(fileName)
)
throw new ProposalPayloadValidationError(
`Proposal file names must be at most ${MAX_PROPOSAL_FILE_NAME_CHARS} characters and contain no control characters.`,
);
if (
Array.from(type).length > MAX_PROPOSAL_FILE_TYPE_CHARS ||
PROPOSAL_CONTROL_CHARACTERS.test(type)
)
throw new ProposalPayloadValidationError(
`Proposal file types must be at most ${MAX_PROPOSAL_FILE_TYPE_CHARS} characters and contain no control characters.`,
);
}
function decodeSerializedProposalFile(value: string) {
if (value.length > MAX_PROPOSAL_FILE_BASE64_CHARS)
throw new ProposalPayloadValidationError('Proposal file data is invalid.');
if (!PROPOSAL_BASE64_PATTERN.test(value))
throw new ProposalPayloadValidationError('Proposal file data is invalid.');
const bytes = Buffer.from(value, 'base64');
if (!bytes.length || bytes.toString('base64') !== value)
throw new ProposalPayloadValidationError('Proposal file data is invalid.');
return bytes;
}
export function validateSerializedProposalEntries(entries: SerializedEntry[]) {
if (!Array.isArray(entries))
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
if (entries.length > MAX_PROPOSAL_ENTRIES)
throw new ProposalPayloadValidationError(
`A proposal may contain at most ${MAX_PROPOSAL_ENTRIES} entries.`,
);
let textBytes = 0;
let fileBytes = 0;
for (const entry of entries) {
if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string')
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
validateProposalFieldName(entry.name);
if (entry.kind === 'text') {
if (typeof entry.value !== 'string')
throw new ProposalPayloadValidationError(
'Proposal entries are invalid.',
);
if (Array.from(entry.value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS)
throw new ProposalPayloadValidationError(
`Proposal text entries must be at most ${MAX_PROPOSAL_TEXT_ENTRY_CHARS} characters.`,
);
textBytes += Buffer.byteLength(entry.value, 'utf8');
if (textBytes > MAX_PROPOSAL_TEXT_BYTES)
throw new ProposalPayloadValidationError(
'The aggregate proposal text exceeds the safe size limit.',
);
continue;
}
if (entry.kind !== 'file')
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
if (
typeof entry.value !== 'string' ||
typeof entry.fileName !== 'string' ||
typeof entry.type !== 'string'
)
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
validateProposalFileMetadata(entry.fileName, entry.type);
const bytes = decodeSerializedProposalFile(entry.value);
if (bytes.length > MAX_PROPOSAL_FILE_BYTES)
throw new ProposalPayloadValidationError(
'Proposal files exceed the safe per-file size limit.',
);
fileBytes += bytes.length;
if (fileBytes > MAX_PROPOSAL_FILE_BYTES_TOTAL)
throw new ProposalPayloadValidationError(
'The aggregate proposal files exceed the safe size limit.',
);
}
}
export async function serializeProposal(
form: FormData,
): Promise<SerializedEntry[]> {
const entries: SerializedEntry[] = [];
let entryCount = 0;
let textBytes = 0;
let fileBytes = 0;
for (const [name, value] of form.entries()) {
entryCount += 1;
if (entryCount > MAX_PROPOSAL_ENTRIES)
throw new ProposalPayloadValidationError(
`A proposal may contain at most ${MAX_PROPOSAL_ENTRIES} entries.`,
);
validateProposalFieldName(name);
if (typeof value === 'string') {
if (Array.from(value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS)
throw new ProposalPayloadValidationError(
`Proposal text entries must be at most ${MAX_PROPOSAL_TEXT_ENTRY_CHARS} characters.`,
);
textBytes += Buffer.byteLength(value, 'utf8');
if (textBytes > MAX_PROPOSAL_TEXT_BYTES)
throw new ProposalPayloadValidationError(
'The aggregate proposal text exceeds the safe size limit.',
);
entries.push({ name, kind: 'text', value });
continue;
}
validateProposalFileMetadata(value.name, value.type);
if (value.size === 0) continue;
if (value.size > MAX_PROPOSAL_FILE_BYTES)
throw new ProposalPayloadValidationError(
'Proposal files exceed the safe per-file size limit.',
);
if (fileBytes + value.size > MAX_PROPOSAL_FILE_BYTES_TOTAL)
throw new ProposalPayloadValidationError(
'The aggregate proposal files exceed the safe size limit.',
);
const bytes = Buffer.from(await value.arrayBuffer());
if (bytes.length > MAX_PROPOSAL_FILE_BYTES)
throw new ProposalPayloadValidationError(
'Proposal files exceed the safe per-file size limit.',
);
fileBytes += bytes.length;
if (fileBytes > MAX_PROPOSAL_FILE_BYTES_TOTAL)
throw new ProposalPayloadValidationError(
'The aggregate proposal files exceed the safe size limit.',
);
entries.push({
name,
kind: 'file',
value: bytes.toString('base64'),
fileName: value.name,
type: value.type,
});
}
return entries;
}
export function parseSerializedProposalEntries(
payload: string,
): SerializedEntry[] {
let parsed: unknown;
try {
parsed = JSON.parse(payload);
validateSerializedProposalEntries(parsed as SerializedEntry[]);
} catch (error) {
if (error instanceof ProposalPayloadValidationError) throw error;
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
}
return parsed as SerializedEntry[];
}
export function deserializeProposal(payload: string): FormData {
const entries = parseSerializedProposalEntries(payload);
const form = new FormData();
for (const entry of entries) {
if (entry.kind === 'text') form.append(entry.name, entry.value);
else
form.append(
entry.name,
new Blob([decodeSerializedProposalFile(entry.value)], {
type: entry.type,
}),
entry.fileName,
);
}
return form;
}
export class ProposalNotPendingError extends Error {
constructor() {
super('That pending proposal no longer exists.');
this.name = 'ProposalNotPendingError';
}
}
export class ProposalDependenciesBlockedError extends Error {
readonly dependencies: string;
constructor(dependencies: string) {
super(`Approve this proposal's dependencies first: ${dependencies}.`);
this.name = 'ProposalDependenciesBlockedError';
this.dependencies = dependencies;
}
}
export class ProposalDependencyCycleError extends Error {
readonly proposalId: string;
readonly dependencyId: string;
constructor(proposalId: string, dependencyId: string) {
super(
`Adding dependency ${proposalId} -> ${dependencyId} would create a cycle.`,
);
this.name = 'ProposalDependencyCycleError';
this.proposalId = proposalId;
this.dependencyId = dependencyId;
}
}
export class ProposalCanonicalValidationError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'ProposalCanonicalValidationError';
this.status = status;
}
}
const safeValidationMessage = (error: unknown) => {
const message = error instanceof Error ? error.message : '';
return message && message.length <= 500
? message.replace(/[\u0000-\u001f\u007f]/gu, ' ').trim()
: 'The proposal did not pass current canonical validation.';
};
function canonicalFailure(
error: unknown,
): ProposalCanonicalValidationError | null {
if (error instanceof ProposalCanonicalValidationError)
return new ProposalCanonicalValidationError(
error.status,
safeValidationMessage(error),
);
if (error instanceof ProposalPayloadValidationError)
return new ProposalCanonicalValidationError(
400,
'The stored proposal payload is invalid.',
);
if (error instanceof MechanicSourceFingerprintConflictError)
return new ProposalCanonicalValidationError(
409,
safeValidationMessage(error),
);
if (
error instanceof GameDataIconValidationError ||
error instanceof ManagedImageValidationError
)
return new ProposalCanonicalValidationError(
400,
safeValidationMessage(error),
);
if (
error instanceof ProtocolFormValidationError ||
error instanceof SkillChipFormValidationError ||
error instanceof SpecializationFormValidationError
)
return new ProposalCanonicalValidationError(
error.status,
safeValidationMessage(error),
);
if (
error instanceof ProtocolNotFoundError ||
error instanceof TalentNotFoundError ||
error instanceof SkillChipNotFoundError
)
return new ProposalCanonicalValidationError(
404,
safeValidationMessage(error),
);
if (
error instanceof Error &&
/no authored source|identity does not match|cannot be moved|does not belong to this node|submitted more than once|reused an existing ID|no longer exists/i.test(
error.message,
)
)
return new ProposalCanonicalValidationError(
409,
safeValidationMessage(error),
);
return null;
}
const SPECIALIZATION_NOT_FOUND_ERRORS = new Set([
'The specialization tree node no longer exists.',
'The specialization combat effect no longer exists.',
]);
const SPECIALIZATION_CONFLICT_ERRORS = new Set([
'The node identity does not match the supplied record.',
'A saved tree node cannot be moved to another focus.',
'A combat-effect attachment was submitted more than once.',
'A submitted combat-effect attachment does not belong to this node.',
'A new combat-effect attachment reused an existing ID.',
'The combat-effect identity does not match the supplied record.',
'A saved combat effect cannot be moved to another specialization.',
]);
function classifySpecializationTransactionError(error: unknown) {
if (error instanceof SpecializationFormValidationError) return error;
if (error instanceof MechanicSourceFingerprintConflictError) return error;
if (!(error instanceof Error)) return null;
const message = error.message.trim();
if (SPECIALIZATION_NOT_FOUND_ERRORS.has(message))
return new SpecializationFormValidationError(
404,
safeValidationMessage(error),
);
if (SPECIALIZATION_CONFLICT_ERRORS.has(message))
return new SpecializationFormValidationError(
409,
safeValidationMessage(error),
);
const isKnownValidationMessage =
message === 'Every conditional effect requires an authored description.' ||
message ===
'The authored mechanic owner does not match its attachment.' ||
message === 'One or more conditional effects are unavailable for this specialization.' ||
message === 'One or more authored description Attributes are unavailable.' ||
message.startsWith('The authored template ') ||
message.startsWith('Clause ') ||
message.startsWith('Conditional mechanic ');
return isKnownValidationMessage
? new SpecializationFormValidationError(400, safeValidationMessage(error))
: null;
}
function enforcePhase2ProposalIdentity(
proposal: Record<string, unknown>,
form: FormData,
) {
const entityId = String(proposal.entity_id ?? '').trim();
if (entityId) {
form.set('id', entityId);
form.delete('_proposedEntityId');
return;
}
const draftEntityId = String(proposal.draft_entity_id ?? '').trim();
if (!draftEntityId)
throw new ProposalCanonicalValidationError(
400,
'The proposal does not have a stored draft identity.',
);
form.delete('id');
form.set('_proposedEntityId', draftEntityId);
}
async function replayPhase2Owner(
database: Database,
proposal: Record<string, unknown>,
reviewerId: string,
form: FormData,
proposalId: string,
journal: MediaMutationJournal,
) {
const mutationContext = { origin: 'proposal' as const, proposalId };
switch (`${String(proposal.module)}:${String(proposal.action)}`) {
case 'talents:saveTalent': {
const media = await readTalentFormMedia(form);
const result: SaveTalentInTransactionResult =
await saveTalentFromFormInTransaction(database, {
form,
operatorId: reviewerId,
proposedEntityId:
String(proposal.draft_entity_id ?? '').trim() || undefined,
...media,
mutationContext,
journal,
});
if (!result.ok)
throw new ProposalCanonicalValidationError(
result.status,
result.errors[0]?.message ?? 'The talent could not be saved.',
);
return { entityId: result.talentId, mode: result.talentMode };
}
case 'protocols:save': {
const input = await parseProtocolForm(
database,
form,
reviewerId,
mutationContext,
);
const result = await saveProtocolInTransaction(database, {
...input,
journal,
});
return { entityId: result.protocolId, mode: result.mode };
}
case 'skillchips:save': {
const input = await parseSkillChipForm(
database,
form,
reviewerId,
mutationContext,
);
const result = await _saveSkillChipInTransaction(database, {
...input,
journal,
});
return { entityId: result.setId, mode: result.mode };
}
case 'specializations:saveNode': {
const input = await parseSpecializationNodeForm(
database,
form,
reviewerId,
mutationContext,
);
try {
const result = await saveSpecializationNodeInTransaction(database, {
...input,
journal,
});
return {
entityId: input.nodeId,
mode: input.id ? 'edit' : 'add',
result,
};
} catch (error) {
throw classifySpecializationTransactionError(error) ?? error;
}
}
case 'specializations:saveCombatEffect': {
const input = await parseSpecializationCombatEffectForm(
database,
form,
reviewerId,
mutationContext,
);
try {
const result = await saveSpecializationCombatEffectInTransaction(
database,
input,
);
return {
entityId: input.effectId,
mode: input.id ? 'edit' : 'add',
result,
};
} catch (error) {
throw classifySpecializationTransactionError(error) ?? error;
}
}
default:
throw new Error('Unsupported Phase 2 proposal action.');
}
}
export type Phase2ApprovalResult = {
proposal: Record<string, unknown>;
entityId: string;
mode: string;
};
/** Runs one Phase 2 owner replay and proposal decision in one immediate tx. */
export async function approvePhase2Proposal(
database: Database,
reviewerId: string,
proposalId: string,
note: string | null,
): Promise<Phase2ApprovalResult> {
const journal = new MediaMutationJournal();
try {
const operation = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const proposal = await (
await tx.prepare(
"SELECT * FROM data_change_proposals WHERE id=? AND status='pending'",
)
).get(proposalId);
if (!proposal) throw new ProposalNotPendingError();
const claimed = await (
await tx.prepare(
"UPDATE data_change_proposals SET status='approved' WHERE id=? AND status='pending'",
)
).run(proposalId);
if (!Number(claimed?.changes ?? 0)) throw new ProposalNotPendingError();
const unresolved = await (
await tx.prepare(`SELECT dependency.id,dependency.summary,dependency.status
FROM data_change_proposal_dependencies link
JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id
WHERE link.proposal_id=? AND dependency.status<>'approved'`)
).all(proposalId);
if (unresolved.length) {
const names = unresolved
.map((item) => `${item.summary} (${item.status})`)
.join(', ');
throw new ProposalDependenciesBlockedError(names);
}
let result: { entityId: string; mode: string };
try {
const form = deserializeProposal(String(proposal.payload_json));
enforcePhase2ProposalIdentity(proposal as Record<string, unknown>, form);
result = await replayPhase2Owner(
tx,
proposal,
reviewerId,
form,
proposalId,
journal,
);
} catch (error) {
const failure = canonicalFailure(error);
if (failure) throw failure;
throw error;
}
const now = new Date().toISOString();
const updated = await (
await tx.prepare(
"UPDATE data_change_proposals SET applied_entity_id=?,reviewed_by=?,review_note=?,reviewed_at=?,applied_at=? WHERE id=? AND status='approved'",
)
).run(result.entityId, reviewerId, note || null, now, now, proposalId);
if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError();
await recordIsacAudit({
actorId: reviewerId,
category: 'data_moderation',
action: 'proposal.approved',
entityId: proposalId,
before: { status: 'pending' },
after: {
status: 'approved',
module: proposal.module,
action: proposal.action,
proposedBy: proposal.proposed_by,
appliedEntityId: result.entityId,
},
database: tx,
});
return { proposal, entityId: result.entityId, mode: result.mode };
});
const result = await operation.immediate();
journal.clear();
return result;
} catch (error) {
await journal.rollback(error);
throw error;
}
}
export async function failPhase2Proposal(
database: Database,
reviewerId: string,
proposalId: string,
message: string,
) {
const operation = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const proposal = await (
await tx.prepare(
"SELECT * FROM data_change_proposals WHERE id=? AND status='pending'",
)
).get(proposalId);
if (!proposal) throw new ProposalNotPendingError();
const now = new Date().toISOString();
const updated = await (
await tx.prepare(
"UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=? AND status='pending'",
)
).run(reviewerId, message, now, proposalId);
if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError();
await recordIsacAudit({
actorId: reviewerId,
category: 'data_moderation',
action: 'proposal.failed',
entityId: proposalId,
before: { status: 'pending' },
after: { status: 'failed', messagePresent: Boolean(message) },
database: tx,
});
return proposal as Record<string, unknown>;
});
return operation.immediate();
}
export async function refreshProposalDependencies(
proposalId: string,
payload: string,
suppliedDatabase?: Database,
) {
const textValues = serializedTextValues(payload);
// A malformed persisted payload cannot provide reliable dependency evidence.
// Leave existing links intact rather than mutating state during recovery.
if (!textValues) return [];
const database = suppliedDatabase ?? (await getDatabase());
const text = textValues.map((entry) => entry.value).join('\n');
const candidates = await (
await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
WHERE id<>? AND status='pending' AND draft_entity_id IS NOT NULL`)
).all(proposalId);
const dependencies = candidates.filter((candidate) =>
text.includes(String(candidate.draft_entity_id)),
);
const existingLinks = await (
await database.prepare(
'SELECT proposal_id,depends_on_proposal_id FROM data_change_proposal_dependencies',
)
).all();
const links = new Map<string, string[]>();
for (const link of existingLinks) {
const proposalLinks = links.get(String(link.proposal_id)) ?? [];
proposalLinks.push(String(link.depends_on_proposal_id));
links.set(String(link.proposal_id), proposalLinks);
}
const reachesProposal = (start: string) => {
const visited = new Set<string>();
const toVisit = [start];
while (toVisit.length) {
const current = toVisit.pop()!;
if (current === proposalId) return true;
if (visited.has(current)) continue;
visited.add(current);
toVisit.push(...(links.get(current) ?? []));
}
return false;
};
for (const dependency of dependencies) {
const dependencyId = String(dependency.id);
if (reachesProposal(dependencyId))
throw new ProposalDependencyCycleError(proposalId, dependencyId);
}
await (
await database.prepare(
'DELETE FROM data_change_proposal_dependencies WHERE proposal_id=?',
)
).run(proposalId);
for (const dependency of dependencies) {
await (
await database.prepare(
'INSERT INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)',
)
).run(proposalId, dependency.id);
}
return dependencies.map((dependency) => String(dependency.id));
}
const serializedTextValues = (
payload: string,
): Array<Extract<SerializedEntry, { kind: 'text' }>> | null => {
try {
return parseSerializedProposalEntries(payload).filter(
(entry): entry is Extract<SerializedEntry, { kind: 'text' }> =>
entry.kind === 'text',
);
} catch (error) {
if (error instanceof ProposalPayloadValidationError) return null;
throw error;
}
};
export async function getPendingEntities(
_userId: string,
definitions: PendingDefinition[],
): Promise<
Array<{
id: string;
proposalId: string;
pending: true;
active: number;
kind: string;
name: string;
proposerName: string;
[key: string]: unknown;
}>
> {
if (!definitions.length) return [];
const database = await getDatabase();
const clauses = definitions
.map(() => '(proposal.module=? AND proposal.action=?)')
.join(' OR ');
const args = definitions.flatMap(({ module, action }) => [module, action]);
const rows = await (
await database.prepare(`SELECT proposal.id,proposal.module,proposal.action,proposal.draft_entity_id,proposal.payload_json,proposal.created_at,
COALESCE(profile.display_name,proposer.username) AS proposer_name
FROM data_change_proposals proposal
JOIN users proposer ON proposer.id=proposal.proposed_by
LEFT JOIN user_profiles profile ON profile.user_id=proposer.id
WHERE proposal.status='pending' AND proposal.entity_id IS NULL AND (${clauses})
ORDER BY proposal.created_at DESC`)
).all(...args);
return rows.map((row) => {
const definition = definitions.find(
(item) => item.module === row.module && item.action === row.action,
)!;
const values = serializedTextValues(String(row.payload_json));
const unusable = values === null;
const safeValues = values ?? [];
const field = (name: string) =>
safeValues.find((entry) => entry.name === name)?.value ?? '';
const fieldValue = (name: string) => {
const matches = safeValues
.filter((entry) => entry.name === name)
.map((entry) => entry.value);
return matches.length > 1 ? matches : (matches[0] ?? '');
};
return {
id: String(row.draft_entity_id ?? row.id),
proposalId: String(row.id),
pending: true,
active: 1,
...(unusable ? { unusable: true } : {}),
kind: definition.kind,
name: unusable ? 'UNUSABLE PROPOSAL' : field('name'),
proposerName: String(row.proposer_name),
...Object.fromEntries(
(definition.fields ?? []).map((name) => [
name,
unusable ? '' : fieldValue(name),
]),
),
};
});
}
export async function queueDataProposal(
user: AuthUser,
module: DataModule,
action: string,
form: FormData,
) {
const id = randomUUID();
const contributorNote = String(form.get('contributorNote') ?? '')
.normalize('NFKC')
.trim();
if (
contributorNote.length > 1_000 ||
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(contributorNote)
) {
throw new Error(
'Contributor rationale must contain at most 1,000 visible characters.',
);
}
form.delete('contributorNote');
const entityId = String(form.get('id') ?? '').trim() || null;
const draftEntityId = entityId ? null : randomUUID();
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 serializeProposal(form));
const database = await getDatabase();
const createProposal = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
await (
await tx.prepare(`INSERT INTO data_change_proposals
(id,module,action,entity_id,draft_entity_id,summary,contributor_note,payload_json,status,proposed_by,created_at)
VALUES(?,?,?,?,?,?,?,?,'pending',?,?)`)
).run(
id,
module,
action,
entityId,
draftEntityId,
summary,
contributorNote || null,
payload,
user.id,
now,
);
const text = (serializedTextValues(payload) ?? [])
.map((entry) => entry.value)
.join('\n');
const candidates = await (
await tx.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
WHERE status='pending' AND draft_entity_id IS NOT NULL`)
).all();
const dependencies = candidates.filter(
(candidate) =>
candidate.id !== id && text.includes(String(candidate.draft_entity_id)),
);
for (const dependency of dependencies) {
await (
await tx.prepare(
'INSERT OR IGNORE INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)',
)
).run(id, dependency.id);
}
await recordIsacAudit({
actorId: user.id,
category: 'data_moderation',
action: 'proposal.created',
entityId: id,
before: null,
after: {
module,
action,
entityId,
draftEntityId,
dependencies: dependencies.map((item) => item.id),
summary,
contributorNotePresent: Boolean(contributorNote),
},
database: tx,
});
});
await createProposal.immediate();
await notifyOversightReview({
category: 'data_changes',
itemId: id,
title: 'Data change awaiting review',
summary: `${summary} was submitted to the Oversight queue and is ready for review.`,
link: `/isac/data-moderation?search=${encodeURIComponent(name || entityId || id)}`,
}).catch((error) =>
console.error('Unable to notify Data Oversight reviewers', error),
);
return { id, entityId, draftEntityId };
}
export async function shouldModerateDataMutation(user: AuthUser | null) {
const permissions = await getPermissions(user);
const eligibleToPropose =
permissions.includes('*') || permissions.includes('data.propose');
const contributorMode =
user && eligibleToPropose ? await getContributorMode(user.id) : null;
return {
canManage: permissions.includes('*') || permissions.includes('data.manage'),
canPropose: eligibleToPropose,
contributorEnabled: Boolean(contributorMode?.enabled),
};
}