Files
PaperClipAI/server/src/routes/decision-training.ts
T
DottaandPaperclip 3a726e676f feat: enforce private task permissions across execution and data (#10633)
Enforce private task and project access across direct reads, search, execution, files, plugins, live delivery, and sharing mutations. Preserve downward-only sharing, current responsible-user authorization, and audited emergency access. Bind historical draft assets with migration 0314.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-07 07:06:40 -05:00

266 lines
9.5 KiB
TypeScript

import { Router, type Request, type Response } from "express";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { authorizationService, issueReadSqlCondition } from "../services/authorization.js";
import { validate } from "../middleware/validate.js";
import { decisionTrainingService, logActivity } from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo, hasCompanyAccess } from "./authz.js";
const sourceKindSchema = z.enum(["interaction", "approval", "execution_decision"]);
const exampleIdSchema = z.string().guid();
const createSchema = z.object({
sourceKind: sourceKindSchema,
sourceId: z.string().guid(),
issueId: z.string().guid(),
notes: z.string().max(100_000).default(""),
}).strict();
const updateSchema = z.object({ notes: z.string().max(100_000) }).strict();
const previewSchema = z.object({
sourceKind: sourceKindSchema,
sourceId: z.string().guid(),
issueId: z.string().guid(),
}).strict();
function requireHumanUser(req: Request, res: Response) {
if (req.actor.type !== "board") {
res.status(403).json({ error: "Decision training writes require a human user" });
return null;
}
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return null;
}
return req.actor.userId;
}
function parseExampleId(req: Request, res: Response) {
const parsed = exampleIdSchema.safeParse(req.params.id);
if (!parsed.success) {
res.status(404).json({ error: "Decision training example not found" });
return null;
}
return parsed.data;
}
function requireExampleOwner(res: Response, userId: string, createdByUserId: string) {
if (userId !== createdByUserId) {
res.status(403).json({ error: "Only the example author can change decision training examples" });
return false;
}
return true;
}
export function decisionTrainingRoutes(db: Db) {
const router = Router();
const svc = decisionTrainingService(db);
async function canRead(req: Request, companyId: string, issueId: string) {
return (await authorizationService(db).decide({ actor: req.actor, action: "issue:read", resource: { type: "issue", companyId, issueId } })).allowed;
}
async function requireRead(req: Request, res: Response, companyId: string, issueId: string) {
if (await canRead(req, companyId, issueId)) return true;
res.status(404).json({ error: "Decision training source not found" });
return false;
}
router.post(
"/companies/:companyId/decision-training",
validate(createSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const userId = requireHumanUser(req, res);
if (!userId) return;
if (!(await requireRead(req, res, companyId, req.body.issueId))) return;
const example = await svc.create({
companyId,
sourceKind: req.body.sourceKind,
sourceId: req.body.sourceId,
issueId: req.body.issueId,
notes: req.body.notes,
createdByUserId: userId,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "decision_training.created",
entityType: "decision_training_example",
entityId: example.id,
details: { sourceKind: example.sourceKind, sourceId: example.sourceId, issueId: example.issueId },
});
res.status(201).json(example);
},
);
// Read-only snapshot preview for the create drawer. Same authz as a write
// (humans only) since it exposes the same captured decision state, but it
// never persists anything.
router.post(
"/companies/:companyId/decision-training/preview",
validate(previewSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const userId = requireHumanUser(req, res);
if (!userId) return;
if (!(await requireRead(req, res, companyId, req.body.issueId))) return;
const preview = await svc.preview({
companyId,
sourceKind: req.body.sourceKind,
sourceId: req.body.sourceId,
issueId: req.body.issueId,
});
res.json({
cutoffAt: preview.cutoffAt.toISOString(),
decisionOutcome: preview.decisionOutcome,
snapshot: preview.snapshot,
});
},
);
router.get("/companies/:companyId/decision-training", async (req, res) => {
const companyId = req.params.companyId as string;
assertBoard(req);
assertCompanyAccess(req, companyId);
const parsed = z.object({
project: z.string().guid().optional(),
kind: sourceKindSchema.optional(),
author: z.string().optional(),
q: z.string().trim().max(500).optional(),
}).safeParse(req.query);
if (!parsed.success) {
res.status(400).json({ error: "Invalid decision training query", details: parsed.error.flatten() });
return;
}
res.json(await svc.list(companyId, {
readCondition: await issueReadSqlCondition(db, req.actor),
projectId: parsed.data.project,
kind: parsed.data.kind,
author: parsed.data.author,
q: parsed.data.q,
}));
});
router.get("/companies/:companyId/decision-training/export.jsonl", async (req, res) => {
const companyId = req.params.companyId as string;
assertBoard(req);
assertCompanyAccess(req, companyId);
const rows = await svc.list(companyId, { readCondition: await issueReadSqlCondition(db, req.actor) });
const body = rows
.map(({ example }) => JSON.stringify({
retentionPolicy: example.retentionPolicy,
state: example.snapshot,
label: { outcome: example.decisionOutcome, notes: example.notes },
}))
.join("\n");
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "decision_training.exported",
entityType: "decision_training_export",
entityId: companyId,
details: { exampleCount: rows.length, exampleIds: rows.map(({ example }) => example.id) },
});
res.type("application/x-ndjson").send(body ? `${body}\n` : "");
});
router.get("/decision-training/:id", async (req, res) => {
assertBoard(req);
const exampleId = parseExampleId(req, res);
if (!exampleId) return;
const example = await svc.getById(exampleId);
if (!example || !hasCompanyAccess(req, example.companyId) || !(await canRead(req, example.companyId, example.issueId))) {
res.status(404).json({ error: "Decision training example not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: example.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "decision_training.read",
entityType: "decision_training_example",
entityId: example.id,
details: { sourceKind: example.sourceKind, sourceId: example.sourceId, issueId: example.issueId },
});
res.json(example);
});
router.patch("/decision-training/:id", validate(updateSchema), async (req, res) => {
const exampleId = parseExampleId(req, res);
if (!exampleId) return;
const existing = await svc.getById(exampleId);
if (!existing || !hasCompanyAccess(req, existing.companyId)) {
res.status(404).json({ error: "Decision training example not found" });
return;
}
const userId = requireHumanUser(req, res);
if (!userId) return;
if (!requireExampleOwner(res, userId, existing.createdByUserId)) return;
const notesChanged = req.body.notes !== existing.notes;
const updated = await svc.updateNotes(existing.id, userId, req.body.notes);
if (!updated) {
res.status(404).json({ error: "Decision training example not found" });
return;
}
if (notesChanged) {
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "decision_training.notes_updated",
entityType: "decision_training_example",
entityId: updated.id,
details: { issueId: updated.issueId },
});
}
res.json(updated);
});
router.delete("/decision-training/:id", async (req, res) => {
const exampleId = parseExampleId(req, res);
if (!exampleId) return;
const existing = await svc.getById(exampleId);
if (!existing || !hasCompanyAccess(req, existing.companyId)) {
res.status(404).json({ error: "Decision training example not found" });
return;
}
const userId = requireHumanUser(req, res);
if (!userId) return;
if (!requireExampleOwner(res, userId, existing.createdByUserId)) return;
const deleted = await svc.delete(existing.id);
if (deleted.length === 0) {
res.status(404).json({ error: "Decision training example not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "decision_training.deleted",
entityType: "decision_training_example",
entityId: existing.id,
details: { issueId: existing.issueId, deletedByUserId: userId },
});
res.status(204).send();
});
return router;
}