mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
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>
266 lines
9.5 KiB
TypeScript
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;
|
|
}
|