991 lines
33 KiB
TypeScript
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),
|
|
};
|
|
}
|