import { Router } from 'express'; import { z } from 'zod'; import * as pendingRepo from '../../db/repos/pending_changes.js'; import * as pagesRepo from '../../db/repos/pages.js'; import * as projectsRepo from '../../db/repos/projects.js'; import * as tasksRepo from '../../db/repos/tasks.js'; import * as refsRepo from '../../db/repos/refs.js'; import * as resourcesRepo from '../../db/repos/resources.js'; import * as sourceDocsRepo from '../../db/repos/source_docs.js'; import * as audit from '../../db/repos/audit.js'; import { parsePagination } from '../pagination.js'; import { validate } from '../validate.js'; import { requireOwner } from '../cap.js'; import { NotFoundError, ConflictError, ValidationError, asyncWrap } from '../errors.js'; const REPOS = { page: pagesRepo, project: projectsRepo, task: tasksRepo, ref: refsRepo, resource: resourcesRepo, source_doc: sourceDocsRepo }; // Action dispatch for approving a pending_change. 'create'/'update'/'delete' // plus 'upsert' (the suggestion path from POST /api/refs/upsert). Dependency // mutations are owner-only and never reach pending_changes — see // docs/security-followups.md. export async function applyPendingChange(row, actor) { const repo = REPOS[row.entity_type]; if (!repo) throw new ValidationError(`unsupported entity_type for approval: ${row.entity_type}`); switch (row.action) { case 'create': { const created = await repo.create(row.payload, actor); return created.id; } case 'upsert': { if (typeof repo.upsertByExternal !== 'function') { throw new ValidationError(`entity_type '${row.entity_type}' does not support upsert`); } const row_ = await repo.upsertByExternal(row.payload, actor); return row_.id; } case 'update': { await repo.update(row.entity_id, row.payload, actor); return row.entity_id; } case 'delete': { await repo.del(row.entity_id, actor); return row.entity_id; } default: throw new ValidationError(`unsupported action for approval: ${row.action}`); } } const idParams = z.object({ id: z.string().uuid() }); export const router = Router(); router.use(requireOwner); router.get('/', asyncWrap(async (req, res) => { const { limit } = parsePagination(req); res.json(await pendingRepo.listPending({ limit })); }) ); router.post('/:id/approve', validate({ params: idParams }), asyncWrap(async (req, res) => { const row = await pendingRepo.getById(req.params.id); if (!row) throw new NotFoundError('pending change not found'); if (row.status !== 'pending') { throw new ConflictError(`pending change is already ${row.status}`); } const entity_id = await applyPendingChange(row, req.actor); const resolved = await pendingRepo.resolve(req.params.id, 'approved', req.actor?.id ?? 'owner'); await audit.recordAudit( req.actor, 'approve', row.entity_type, entity_id, null, { pending_id: row.id, original_agent_id: row.agent_id, action: row.action } ); res.json({ ...resolved, entity_id }); }) ); router.post('/:id/reject', validate({ params: idParams }), asyncWrap(async (req, res) => { const row = await pendingRepo.getById(req.params.id); if (!row) throw new NotFoundError('pending change not found'); if (row.status !== 'pending') { throw new ConflictError(`pending change is already ${row.status}`); } const resolved = await pendingRepo.resolve(req.params.id, 'rejected', req.actor?.id ?? 'owner'); await audit.recordAudit( req.actor, 'reject', row.entity_type, row.entity_id, null, { pending_id: row.id, original_agent_id: row.agent_id, action: row.action } ); res.json(resolved); }) );