Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,11 @@ export async function PATCH(request: Request, context: Params) {
return handleApiError(error);
}
}

export async function DELETE(request: Request, context: Params) {
try {
assertSameOriginMutatingRequest(request);
const { projectId, approvalId } = await readParams(context);
const approval = await deleteApproval(projectId, approvalId);
const approval = await deleteApproval(projectId, approvalId, await readJson(request));
return approval ? ok({ deleted: true }) : notFound("Approval not found.");
} catch (error) {
return handleApiError(error);
Expand Down
24 changes: 21 additions & 3 deletions src/app/api/projects/[projectId]/approvals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,33 @@ export async function GET(request: Request, context: Params) {
try {
const { projectId } = await readParams(context);
const params = new URL(request.url).searchParams;
const approvals = await listApprovals(projectId, {
limit: readLimit(params.get("limit"), DEFAULT_APPROVAL_LIST_LIMIT, MAX_APPROVAL_LIST_LIMIT),
});
const { active, history } = partitionApprovalHistory(approvals);
return ok({
approvals: await listApprovals(projectId, {
limit: readLimit(params.get("limit"), DEFAULT_APPROVAL_LIST_LIMIT, MAX_APPROVAL_LIST_LIMIT),
}),
approvals,
activeApprovals: active,
approvalHistory: history,
});
} catch (error) {
return handleApiError(error);
}
}
function partitionApprovalHistory(approvals: Awaited<ReturnType<typeof listApprovals>>) {
const now = Date.now();
const active = approvals.filter((approval) => {
if (approval.status === "pending") return true;
if (approval.status !== "approved" || approval.metadata?.consumedAt) return false;
const expiresAt = approval.metadata?.expiresAt;
return typeof expiresAt !== "string" || Date.parse(expiresAt) > now;
});
const activeIds = new Set(active.map((approval) => approval.id));
return {
active,
history: approvals.filter((approval) => !activeIds.has(approval.id)),
};
}

function readLimit(value: string | null, fallback: number, max: number) {
if (!value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,28 @@ export async function PATCH(request: Request, context: Params) {
if (action !== "revoke") {
return notFound(`Unsupported authorization action: ${action}.`);
}
const authorization = await revokeProjectAuthorization(projectId, authorizationId);
const toolRunId = readOptionalString(body, "toolRunId");
const authorization = await revokeProjectAuthorization(projectId, authorizationId, {
actor: readRequiredString(body, "actor"),
reason: readRequiredString(body, "reason"),
...(toolRunId ? { toolRunId } : {}),
});
return authorization ? ok({ authorization }) : notFound("Authorization not found.");
} catch (error) {
return handleApiError(error);
}
}

function readOptionalString(body: Record<string, unknown>, key: string) {
const value = body[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

function readRequiredString(body: Record<string, unknown>, key: string) {
const value = readOptionalString(body, key);
if (!value) throw new Error(`Authorization revocation ${key} is required.`);
return value;
}

function readOptionalRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
Expand Down Expand Up @@ -96,7 +106,13 @@ export async function DELETE(request: Request, context: Params) {
try {
assertSameOriginMutatingRequest(request);
const { projectId, authorizationId } = await readParams(context);
const authorization = await deleteProjectAuthorization(projectId, authorizationId);
const body = (await readJson(request)) as Record<string, unknown>;
if (body.confirmDraftDeletion !== true) {
throw new Error("Draft deletion confirmation must be true.");
}
const authorization = await deleteProjectAuthorization(projectId, authorizationId, {
confirmDraftDeletion: true,
});
return authorization ? ok({ deleted: true }) : notFound("Authorization not found.");
} catch (error) {
return handleApiError(error);
Expand Down
35 changes: 28 additions & 7 deletions src/app/api/projects/[projectId]/authorizations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,40 @@ export async function GET(request: Request, context: Params) {
try {
const { projectId } = await readParams(context);
const params = new URL(request.url).searchParams;
const authorizations = await listProjectAuthorizations(projectId, {
limit: readLimit(
params.get("limit"),
DEFAULT_AUTHORIZATION_LIST_LIMIT,
MAX_AUTHORIZATION_LIST_LIMIT,
),
});
const { active, history } = partitionAuthorizationHistory(authorizations);
return ok({
authorizations: await listProjectAuthorizations(projectId, {
limit: readLimit(
params.get("limit"),
DEFAULT_AUTHORIZATION_LIST_LIMIT,
MAX_AUTHORIZATION_LIST_LIMIT,
),
}),
authorizations,
activeAuthorizations: active,
authorizationHistory: history,
});
} catch (error) {
return handleApiError(error);
}
}
function partitionAuthorizationHistory(
authorizations: Awaited<ReturnType<typeof listProjectAuthorizations>>,
) {
const now = Date.now();
const active = authorizations.filter((authorization) => {
if (authorization.status === "draft" || authorization.status === "requested") return true;
if (authorization.status !== "approved" || authorization.revokedAt || authorization.consumedAt) {
return false;
}
return !authorization.expiresAt || Date.parse(authorization.expiresAt) > now;
});
const activeIds = new Set(active.map((authorization) => authorization.id));
return {
active,
history: authorizations.filter((authorization) => !activeIds.has(authorization.id)),
};
}

function readLimit(value: string | null, fallback: number, max: number) {
if (!value) {
Expand Down
91 changes: 87 additions & 4 deletions src/server/chat/projectAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { buildModelConfigUri } from "./model-config-uri";
import type {
Approval,
ApprovalCreateInput,
ApprovalDraftDeletionInput,
ApprovalListOptions,
ApprovalUpdateInput,
ArtifactCreateInput,
Expand Down Expand Up @@ -64,7 +65,6 @@ type ProjectRow = {
created_at: string;
updated_at: string;
};

type ApprovalRow = {
id: string;
project_id: string;
Expand Down Expand Up @@ -1171,6 +1171,45 @@ class DbChatStore implements ChatStore {
async updateApproval(projectId: string, approvalId: string, input: ApprovalUpdateInput) {
const approval = await withProjectDb(async (db) => {
const status = input.status ? normalizeApprovalStatus(input.status) : null;
const existing = await db.query<{
status: ApprovalRow["status"];
consumed_at: string | null;
}>(
`SELECT status, consumed_at FROM approvals WHERE project_id = $1 AND id = $2`,
[projectId, approvalId],
);
const current = existing.rows[0];
if (!current) return null;
const currentStatus = normalizeApprovalStatus(current.status);
if (
currentStatus !== "pending" &&
!(currentStatus === "approved" && status === "cancelled")
) {
throw new Error(
"A decided approval must be immutable; an approved decision may only be cancelled.",
);
}
if (status === "cancelled" && !input.cancellation) {
throw new Error("Approval cancellation actor and reason are required.");
}
if (input.cancellation?.toolRunId) {
const linked = await db.query<{ id: string }>(
`SELECT id FROM tool_runs WHERE project_id = $1 AND id = $2`,
[projectId, input.cancellation.toolRunId],
);
if (!linked.rows[0]) {
throw new Error("Approval cancellation Tool Run must belong to the same project.");
}
}
const cancellation = input.cancellation
? {
actor: input.cancellation.actor,
reason: input.cancellation.reason,
cancelledAt: new Date().toISOString(),
previousStatus: currentStatus,
...(input.cancellation.toolRunId ? { toolRunId: input.cancellation.toolRunId } : {}),
}
: undefined;
const requestPatch = {
...(input.title ? { title: input.title } : {}),
...(input.description ? { description: input.description } : {}),
Expand All @@ -1185,20 +1224,30 @@ class DbChatStore implements ChatStore {
decided_at = CASE WHEN $1 IS NULL THEN decided_at ELSE now() END,
updated_at = now()
WHERE project_id = $6 AND id = $7
AND (
(status = 'pending' AND consumed_at IS NULL)
OR (status = 'approved' AND consumed_at IS NULL AND $1 = 'cancelled')
)
RETURNING id, project_id, thread_id, kind, source, reason, status, request, evidence,
decision_scope, target_binding, single_use, consumed_at, expires_at, metadata,
created_at, updated_at`,
[
status,
input.title ?? null,
JSON.stringify(requestPatch),
JSON.stringify(input.metadata ?? {}),
JSON.stringify({
...(input.metadata ?? {}),
...(cancellation ? { cancellation } : {}),
}),
JSON.stringify(status ? { status } : {}),
projectId,
approvalId,
],
);
return result.rows[0] ? mapApproval(result.rows[0]) : null;
if (result.rows[0]) return mapApproval(result.rows[0]);
throw new Error(
"The approval changed while this decision was being recorded; terminal decisions are immutable.",
);
});
if (approval) {
const threadId =
Expand All @@ -1211,11 +1260,45 @@ class DbChatStore implements ChatStore {
return approval;
}

async deleteApproval(projectId: string, approvalId: string) {
async deleteApproval(
projectId: string,
approvalId: string,
_input: ApprovalDraftDeletionInput,
) {
const approval = await withProjectDb(async (db) => {
const existing = await db.query<
ApprovalRow & {
tool_run_id?: string | null;
decided_at?: string | null;
}
>(
`SELECT id, project_id, thread_id, kind, source, reason, status, request, evidence,
decision_scope, target_binding, single_use, consumed_at, expires_at, metadata,
tool_run_id, decided_at, created_at, updated_at
FROM approvals WHERE project_id = $1 AND id = $2`,
[projectId, approvalId],
);
const draft = existing.rows[0];
if (!draft) return null;
if (
draft.status !== "pending" ||
(draft.kind ?? "manual") !== "manual" ||
draft.decided_at ||
draft.consumed_at ||
draft.tool_run_id
) {
throw new Error(
"Approval must be an undecided, unlinked manual draft before it can be deleted.",
);
}
const result = await db.query<ApprovalRow>(
`DELETE FROM approvals
WHERE project_id = $1 AND id = $2
AND status = 'pending'
AND kind = 'manual'
AND decided_at IS NULL
AND consumed_at IS NULL
AND tool_run_id IS NULL
RETURNING id, project_id, reason, status, request, metadata, created_at, updated_at`,
[projectId, approvalId],
);
Expand Down
26 changes: 23 additions & 3 deletions src/server/chat/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { getProjectStore } from "./projectAdapter";
import type {
ApprovalCreateInput,
ApprovalDraftDeletionInput,
ApprovalListOptions,
ApprovalStatus,
ApprovalUpdateInput,
Expand All @@ -50,7 +51,6 @@ const requiredString = (body: Record<string, unknown>, key: string) => {
const value = body[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
};

const optionalString = (body: Record<string, unknown>, key: string) => {
const value = body[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
Expand Down Expand Up @@ -612,6 +612,19 @@ export const parseApprovalUpdate = (body: unknown): ApprovalUpdateInput => {
if (Object.hasOwn(body, "metadata")) {
update.metadata = optionalMetadata(body);
}
if (update.status === "cancelled") {
const actor = requiredString(body, "actor");
const reason = requiredString(body, "reason");
if (!actor || !reason) {
throw new Error("Approval cancellation actor and reason are required.");
}
const toolRunId = optionalString(body, "toolRunId");
update.cancellation = {
actor,
reason,
...(toolRunId ? { toolRunId } : {}),
};
}
if (
!update.status &&
!update.title &&
Expand All @@ -624,6 +637,13 @@ export const parseApprovalUpdate = (body: unknown): ApprovalUpdateInput => {
return update;
};

export const parseApprovalDraftDeletion = (body: unknown): ApprovalDraftDeletionInput => {
if (!isRecord(body) || body.confirmDraftDeletion !== true) {
throw new Error("Draft deletion confirmation must be true.");
}
return { confirmDraftDeletion: true };
};

export const parseModelConfigUpdate = (body: unknown): Partial<ModelConfig> => {
if (!isRecord(body)) {
throw new Error("Request body must be an object.");
Expand Down Expand Up @@ -859,9 +879,9 @@ export const updateApproval = async (projectId: string, approvalId: string, body
return store.updateApproval(projectId, approvalId, parseApprovalUpdate(body));
};

export const deleteApproval = async (projectId: string, approvalId: string) => {
export const deleteApproval = async (projectId: string, approvalId: string, body: unknown) => {
const store = await getProjectStore();
return store.deleteApproval(projectId, approvalId);
return store.deleteApproval(projectId, approvalId, parseApprovalDraftDeletion(body));
};

export const listArtifacts = async (projectId: string, options: ArtifactListOptions = {}) => {
Expand Down
16 changes: 14 additions & 2 deletions src/server/chat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ export type McpConnection = {
status?: "unknown" | "ready" | "warning" | "error";
statusMessage?: string;
};

export type ProjectSettings = {
egressProfile?: EgressProfile;
agencyLevel?: AgencyLevel;
Expand Down Expand Up @@ -269,6 +268,15 @@ export type ApprovalUpdateInput = {
description?: string;
status?: ApprovalStatus;
metadata?: Record<string, unknown>;
cancellation?: {
actor: string;
reason: string;
toolRunId?: string;
};
};

export type ApprovalDraftDeletionInput = {
confirmDraftDeletion: true;
};

export type ArtifactCreateInput = {
Expand Down Expand Up @@ -479,7 +487,11 @@ export type ChatStore = SecurityResearchTurnStore & {
approvalId: string,
input: ApprovalUpdateInput,
): Promise<Approval | null>;
deleteApproval(projectId: string, approvalId: string): Promise<Approval | null>;
deleteApproval(
projectId: string,
approvalId: string,
input: ApprovalDraftDeletionInput,
): Promise<Approval | null>;
listArtifacts(projectId: string, options?: ArtifactListOptions): Promise<ArtifactMetadata[]>;
createArtifact(projectId: string, input: ArtifactCreateInput): Promise<ArtifactMetadata>;
createToolRun?(projectId: string, input: ToolRunCreateInput): Promise<ToolRunRecord>;
Expand Down
Loading
Loading