Patchpass V1
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import type { Agent, User } from "@prisma/client";
|
||||
import { prisma } from "./db";
|
||||
import { generateAgentApiKey } from "./Authentication";
|
||||
import { validateIconUrl } from "./IconValidation";
|
||||
import { checkAgentCountLimit, clampPendingLimit } from "./Limits";
|
||||
import { ServiceResult } from "./ChangeRequestService";
|
||||
|
||||
const ok = <T>(data: T): ServiceResult<T> => ({ ok: true, data });
|
||||
const err = (status: number, message: string): ServiceResult<never> => ({
|
||||
ok: false,
|
||||
status,
|
||||
message,
|
||||
});
|
||||
|
||||
export function serializeAgent(agent: Agent, opts: { includeKey?: boolean } = {}) {
|
||||
return {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
website: agent.website,
|
||||
icon_url: agent.iconUrl,
|
||||
disabled: agent.disabled,
|
||||
max_pending_requests: agent.maxPendingRequests,
|
||||
created_at: agent.createdAt.toISOString(),
|
||||
updated_at: agent.updatedAt.toISOString(),
|
||||
...(opts.includeKey ? { api_key: agent.apiKey } : { api_key_masked: maskKey(agent.apiKey) }),
|
||||
};
|
||||
}
|
||||
|
||||
function maskKey(key: string): string {
|
||||
if (key.length <= 12) return key;
|
||||
return key.slice(0, 12) + "…" + key.slice(-4);
|
||||
}
|
||||
|
||||
export type AgentInput = {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
website?: string | null;
|
||||
icon_url?: string | null;
|
||||
max_pending_requests?: number;
|
||||
};
|
||||
|
||||
async function validateInputIcon(iconUrl: string | null | undefined): Promise<ServiceResult<null>> {
|
||||
if (!iconUrl) return ok(null);
|
||||
const result = await validateIconUrl(iconUrl);
|
||||
if (!result.valid) return err(400, result.reason);
|
||||
return ok(null);
|
||||
}
|
||||
|
||||
export async function listAgents(user: User) {
|
||||
const agents = await prisma.agent.findMany({
|
||||
where: { ownerId: user.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
// Attach pending counts so the UI can show queue pressure.
|
||||
const withCounts = await Promise.all(
|
||||
agents.map(async (agent) => {
|
||||
const pending = await prisma.changeRequest.count({
|
||||
where: { agentId: agent.id, state: "PENDING" },
|
||||
});
|
||||
return { ...serializeAgent(agent), pending_count: pending };
|
||||
}),
|
||||
);
|
||||
return withCounts;
|
||||
}
|
||||
|
||||
export async function createAgent(user: User, input: AgentInput): Promise<ServiceResult<unknown>> {
|
||||
const name = input.name?.trim();
|
||||
if (!name || name.length < 1 || name.length > 128) {
|
||||
return err(400, "Agent name must be 1–128 characters");
|
||||
}
|
||||
|
||||
const limit = await checkAgentCountLimit(user.id);
|
||||
if (!limit.allowed) return err(409, limit.reason);
|
||||
|
||||
const iconCheck = await validateInputIcon(input.icon_url);
|
||||
if (!iconCheck.ok) return iconCheck;
|
||||
|
||||
const agent = await prisma.agent.create({
|
||||
data: {
|
||||
name,
|
||||
description: input.description?.trim() || null,
|
||||
website: input.website?.trim() || null,
|
||||
iconUrl: input.icon_url?.trim() || null,
|
||||
apiKey: generateAgentApiKey(),
|
||||
maxPendingRequests: clampPendingLimit(
|
||||
input.max_pending_requests ?? 5,
|
||||
),
|
||||
ownerId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Return the full key exactly once, on creation.
|
||||
return ok(serializeAgent(agent, { includeKey: true }));
|
||||
}
|
||||
|
||||
export async function updateAgent(
|
||||
user: User,
|
||||
agentId: number,
|
||||
input: AgentInput,
|
||||
): Promise<ServiceResult<unknown>> {
|
||||
const agent = await prisma.agent.findFirst({ where: { id: agentId, ownerId: user.id } });
|
||||
if (!agent) return err(404, "Agent not found");
|
||||
|
||||
if (input.name !== undefined) {
|
||||
const name = input.name.trim();
|
||||
if (name.length < 1 || name.length > 128) {
|
||||
return err(400, "Agent name must be 1–128 characters");
|
||||
}
|
||||
}
|
||||
|
||||
if (input.icon_url) {
|
||||
const iconCheck = await validateInputIcon(input.icon_url);
|
||||
if (!iconCheck.ok) return iconCheck;
|
||||
}
|
||||
|
||||
const updated = await prisma.agent.update({
|
||||
where: { id: agent.id },
|
||||
data: {
|
||||
...(input.name !== undefined ? { name: input.name.trim() } : {}),
|
||||
...(input.description !== undefined ? { description: input.description?.trim() || null } : {}),
|
||||
...(input.website !== undefined ? { website: input.website?.trim() || null } : {}),
|
||||
...(input.icon_url !== undefined ? { iconUrl: input.icon_url?.trim() || null } : {}),
|
||||
...(input.max_pending_requests !== undefined
|
||||
? { maxPendingRequests: clampPendingLimit(input.max_pending_requests) }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
return ok(serializeAgent(updated));
|
||||
}
|
||||
|
||||
export async function regenerateApiKey(
|
||||
user: User,
|
||||
agentId: number,
|
||||
): Promise<ServiceResult<unknown>> {
|
||||
const agent = await prisma.agent.findFirst({ where: { id: agentId, ownerId: user.id } });
|
||||
if (!agent) return err(404, "Agent not found");
|
||||
const updated = await prisma.agent.update({
|
||||
where: { id: agent.id },
|
||||
data: { apiKey: generateAgentApiKey() },
|
||||
});
|
||||
return ok(serializeAgent(updated, { includeKey: true }));
|
||||
}
|
||||
|
||||
export async function setAgentDisabled(
|
||||
user: User,
|
||||
agentId: number,
|
||||
disabled: boolean,
|
||||
): Promise<ServiceResult<unknown>> {
|
||||
const agent = await prisma.agent.findFirst({ where: { id: agentId, ownerId: user.id } });
|
||||
if (!agent) return err(404, "Agent not found");
|
||||
const updated = await prisma.agent.update({
|
||||
where: { id: agent.id },
|
||||
data: { disabled },
|
||||
});
|
||||
return ok(serializeAgent(updated));
|
||||
}
|
||||
|
||||
export async function deleteAgent(user: User, agentId: number): Promise<ServiceResult<unknown>> {
|
||||
const agent = await prisma.agent.findFirst({ where: { id: agentId, ownerId: user.id } });
|
||||
if (!agent) return err(404, "Agent not found");
|
||||
await prisma.agent.delete({ where: { id: agent.id } });
|
||||
return ok({ deleted: true });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { prisma } from "./db";
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("AUDIT");
|
||||
|
||||
export async function recordAudit(input: {
|
||||
actorId: number | null;
|
||||
action: string;
|
||||
detail?: string | null;
|
||||
targetType?: string | null;
|
||||
targetId?: string | null;
|
||||
}) {
|
||||
try {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
actorId: input.actorId,
|
||||
action: input.action,
|
||||
detail: input.detail ?? null,
|
||||
targetType: input.targetType ?? null,
|
||||
targetId: input.targetId ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn({ err, action: input.action }, "Failed to write audit log");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import type { Agent, Session, User } from "@prisma/client";
|
||||
import { prisma } from "./db";
|
||||
import { AGENT_KEY_PREFIX } from "./static";
|
||||
|
||||
export type AuthState =
|
||||
| { success: true; method: "session"; user: User; session: Session }
|
||||
| { success: true; method: "agent"; agent: Agent; owner: User }
|
||||
| { success: false; message: string; method: "none" };
|
||||
|
||||
/**
|
||||
* Resolve authentication from a human session cookie hash and/or an agent API key.
|
||||
* Sessions win over API keys when both are present. Disabled humans and disabled
|
||||
* agents (or agents whose owner is disabled) are rejected.
|
||||
*/
|
||||
export async function checkAuthentication(
|
||||
sessionHash: string | null | undefined,
|
||||
apiKey: string | null | undefined,
|
||||
): Promise<AuthState> {
|
||||
if (!sessionHash && !apiKey) {
|
||||
return { success: false, message: "No authentication data provided", method: "none" };
|
||||
}
|
||||
|
||||
if (sessionHash) {
|
||||
const session = await prisma.session.findFirst({
|
||||
where: { hash: sessionHash },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!session) {
|
||||
return { success: false, message: "Invalid session", method: "none" };
|
||||
}
|
||||
if (session.user.disabled) {
|
||||
return { success: false, message: "This account has been disabled", method: "none" };
|
||||
}
|
||||
return { success: true, method: "session", user: session.user, session };
|
||||
}
|
||||
|
||||
// API key path
|
||||
const agent = await prisma.agent.findFirst({
|
||||
where: { apiKey: apiKey! },
|
||||
include: { owner: true },
|
||||
});
|
||||
if (!agent) {
|
||||
return { success: false, message: "Invalid API key", method: "none" };
|
||||
}
|
||||
if (agent.disabled) {
|
||||
return { success: false, message: "This agent has been disabled", method: "none" };
|
||||
}
|
||||
if (agent.owner.disabled) {
|
||||
return { success: false, message: "The owning account has been disabled", method: "none" };
|
||||
}
|
||||
return { success: true, method: "agent", agent, owner: agent.owner };
|
||||
}
|
||||
|
||||
/** Generate a fresh, prefixed agent API key. */
|
||||
export function generateAgentApiKey(): string {
|
||||
return AGENT_KEY_PREFIX + randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
/** Generate a session hash for a human login. */
|
||||
export function generateSessionHash(username: string): string {
|
||||
return createHash("sha256")
|
||||
.update(`${Date.now()}+${username}+${randomBytes(16).toString("hex")}`)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/** Normalize a username for case-insensitive storage & lookup. */
|
||||
export function normalizeUsername(username: string): string {
|
||||
return username.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Compute the cookie domain scope from the configured DOMAIN. */
|
||||
export function cookieDomain(domain: string): string {
|
||||
if (domain === "localhost") return "localhost";
|
||||
if (domain.split(".").length > 2) {
|
||||
// Subdomain deployment: scope to the registrable parent domain.
|
||||
return domain.split(".").slice(1).join(".");
|
||||
}
|
||||
return "." + domain;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createHash } from "crypto";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Change payload types
|
||||
//
|
||||
// Agents submit a `changes` array. Each entry is one of three shapes:
|
||||
// - unified_diff : a git-style unified diff applied with `patch -p1`
|
||||
// - config : a keyed config value change (before -> after) with a type
|
||||
// - custom : an arbitrary labelled before/after value
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type UnifiedDiffChange = {
|
||||
type: "unified_diff";
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ConfigChange = {
|
||||
type: "config";
|
||||
path: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
content_type?: string;
|
||||
};
|
||||
|
||||
export type CustomChange = {
|
||||
type: "custom";
|
||||
label: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
};
|
||||
|
||||
export type Change = UnifiedDiffChange | ConfigChange | CustomChange;
|
||||
|
||||
/**
|
||||
* Normalize a single unified diff:
|
||||
* - Convert CRLF / lone CR line endings to LF
|
||||
* - Strip trailing whitespace from the very end, then guarantee exactly one
|
||||
* trailing newline so hashes are stable regardless of how the agent framed it.
|
||||
*/
|
||||
export function normalizeDiff(content: string): string {
|
||||
const lf = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const trimmedEnd = lf.replace(/\n+$/, "");
|
||||
return trimmedEnd.length > 0 ? `${trimmedEnd}\n` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the full changes array. Diffs get line-ending normalization; config
|
||||
* and custom entries are returned in a canonical key order. The relative order of
|
||||
* entries as submitted is preserved (it is semantically meaningful).
|
||||
*/
|
||||
export function normalizeChanges(changes: Change[]): Change[] {
|
||||
return changes.map((change) => {
|
||||
if (change.type === "unified_diff") {
|
||||
return {
|
||||
type: "unified_diff" as const,
|
||||
path: change.path,
|
||||
content: normalizeDiff(change.content),
|
||||
};
|
||||
}
|
||||
if (change.type === "config") {
|
||||
return {
|
||||
type: "config" as const,
|
||||
path: change.path,
|
||||
before: change.before ?? null,
|
||||
after: change.after ?? null,
|
||||
content_type: change.content_type ?? null,
|
||||
} as ConfigChange;
|
||||
}
|
||||
return {
|
||||
type: "custom" as const,
|
||||
label: change.label,
|
||||
before: change.before ?? null,
|
||||
after: change.after ?? null,
|
||||
} as CustomChange;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a canonical, key-sorted JSON string for a value. Used so the content
|
||||
* hash is independent of key insertion order in the incoming JSON.
|
||||
*/
|
||||
export function canonicalize(value: unknown): string {
|
||||
return JSON.stringify(sortKeys(value));
|
||||
}
|
||||
|
||||
function sortKeys(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sortKeys);
|
||||
if (value && typeof value === "object") {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(obj).sort()) {
|
||||
out[key] = sortKeys(obj[key]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the content hash that binds an approval receipt to the exact reviewed
|
||||
* content. Any change to title, description, or the normalized changes changes it.
|
||||
*/
|
||||
export function computeContentHash(input: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
changes: Change[];
|
||||
}): string {
|
||||
const canonical = canonicalize({
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
changes: normalizeChanges(input.changes),
|
||||
});
|
||||
return createHash("sha256").update(canonical).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import type { Agent, ChangeRequest, User } from "@prisma/client";
|
||||
import { prisma } from "./db";
|
||||
import { env } from "./env";
|
||||
import { Change, computeContentHash, normalizeChanges } from "./ChangeNormalization";
|
||||
import { ReceiptPayload, signReceipt } from "./Signing";
|
||||
import {
|
||||
checkAgentHourlyLimit,
|
||||
checkAgentPendingLimit,
|
||||
clampExpirySeconds,
|
||||
} from "./Limits";
|
||||
import { getRequestsEnabled } from "./DataManager";
|
||||
import { createNotification } from "./Notifications";
|
||||
import { publishToUser } from "./WsHub";
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("CHANGE_REQ");
|
||||
|
||||
// ── Result helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type ServiceOk<T> = { ok: true; data: T };
|
||||
export type ServiceErr = { ok: false; status: number; message: string };
|
||||
export type ServiceResult<T> = ServiceOk<T> | ServiceErr;
|
||||
|
||||
const ok = <T>(data: T): ServiceOk<T> => ({ ok: true, data });
|
||||
const err = (status: number, message: string): ServiceErr => ({ ok: false, status, message });
|
||||
|
||||
// ── Serialization ────────────────────────────────────────────────────────────────
|
||||
|
||||
function approvalUrl(publicId: string): string {
|
||||
return `${env.UI_URL.replace(/\/+$/, "")}/requests/${publicId}`;
|
||||
}
|
||||
|
||||
/** The platform-signed receipt for a decided request (APPROVED/REJECTED/CONSUMED). */
|
||||
export function buildReceipt(request: ChangeRequest) {
|
||||
const decidedState =
|
||||
request.state === "CONSUMED" ? "APPROVED" : (request.state as string);
|
||||
if (
|
||||
(decidedState !== "APPROVED" && decidedState !== "REJECTED") ||
|
||||
!request.signature ||
|
||||
!request.decidedAt ||
|
||||
request.approverId === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const payload: ReceiptPayload = {
|
||||
request_id: request.publicId,
|
||||
decision: decidedState as "APPROVED" | "REJECTED",
|
||||
content_hash: request.contentHash,
|
||||
approver_id: request.approverId,
|
||||
decided_at: request.decidedAt.toISOString(),
|
||||
};
|
||||
return {
|
||||
payload,
|
||||
signature: request.signature,
|
||||
algorithm: "HMAC-SHA256",
|
||||
consumed: request.state === "CONSUMED",
|
||||
consumed_at: request.consumedAt ? request.consumedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeRequest(request: ChangeRequest & { agent?: Agent }) {
|
||||
const receipt = buildReceipt(request);
|
||||
return {
|
||||
request_id: request.publicId,
|
||||
title: request.title,
|
||||
description: request.description,
|
||||
changes: request.changes,
|
||||
metadata: request.metadata ?? null,
|
||||
content_hash: request.contentHash,
|
||||
state: request.state,
|
||||
comment: request.comment,
|
||||
expires_at: request.expiresAt.toISOString(),
|
||||
created_at: request.createdAt.toISOString(),
|
||||
updated_at: request.updatedAt.toISOString(),
|
||||
decided_at: request.decidedAt ? request.decidedAt.toISOString() : null,
|
||||
consumed_at: request.consumedAt ? request.consumedAt.toISOString() : null,
|
||||
cancelled_at: request.cancelledAt ? request.cancelledAt.toISOString() : null,
|
||||
update_count: request.updateCount,
|
||||
resubmitted: request.resubmitted,
|
||||
approval_url: approvalUrl(request.publicId),
|
||||
receipt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Expiry materialization ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lazily transition an open (PENDING / CHANGES_REQUESTED) request to EXPIRED when
|
||||
* it is read past its expiry. Keeps reads correct even between cron sweeps.
|
||||
*/
|
||||
export async function materializeExpiry<T extends ChangeRequest>(request: T): Promise<T> {
|
||||
const open = request.state === "PENDING" || request.state === "CHANGES_REQUESTED";
|
||||
if (open && request.expiresAt.getTime() <= Date.now()) {
|
||||
const updated = await prisma.changeRequest.update({
|
||||
where: { id: request.id },
|
||||
data: { state: "EXPIRED" },
|
||||
});
|
||||
return { ...request, ...updated };
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
// ── Agent actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CreateRequestInput = {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
changes: Change[];
|
||||
expires_in?: number | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export async function createChangeRequest(
|
||||
agent: Agent,
|
||||
input: CreateRequestInput,
|
||||
): Promise<ServiceResult<ReturnType<typeof serializeRequest>>> {
|
||||
if (!(await getRequestsEnabled())) {
|
||||
return err(403, "Request submission is currently disabled by the administrator");
|
||||
}
|
||||
|
||||
if (!input.title || input.title.trim().length === 0) {
|
||||
return err(400, "title is required");
|
||||
}
|
||||
if (!Array.isArray(input.changes) || input.changes.length === 0) {
|
||||
return err(400, "changes must be a non-empty array");
|
||||
}
|
||||
|
||||
const hourly = await checkAgentHourlyLimit(agent.id);
|
||||
if (!hourly.allowed) return err(429, hourly.reason);
|
||||
|
||||
const pending = await checkAgentPendingLimit(agent.id, agent.maxPendingRequests);
|
||||
if (!pending.allowed) return err(429, pending.reason);
|
||||
|
||||
const normalized = normalizeChanges(input.changes);
|
||||
const contentHash = computeContentHash({
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
changes: input.changes,
|
||||
});
|
||||
const expiresInSeconds = clampExpirySeconds(input.expires_in ?? undefined);
|
||||
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000);
|
||||
|
||||
const created = await prisma.changeRequest.create({
|
||||
data: {
|
||||
title: input.title.trim(),
|
||||
description: input.description?.trim() || null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
changes: normalized as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
rawChanges: input.changes as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
metadata: (input.metadata ?? undefined) as any,
|
||||
contentHash,
|
||||
expiresAt,
|
||||
state: "PENDING",
|
||||
agentId: agent.id,
|
||||
userId: agent.ownerId,
|
||||
},
|
||||
});
|
||||
|
||||
await createNotification({
|
||||
userId: agent.ownerId,
|
||||
type: "REQUEST_CREATED",
|
||||
title: "New change request",
|
||||
message: `${agent.name} submitted "${created.title}" for review`,
|
||||
requestPublicId: created.publicId,
|
||||
});
|
||||
|
||||
log.info({ requestId: created.publicId, agentId: agent.id }, "Change request created");
|
||||
return ok(serializeRequest(created));
|
||||
}
|
||||
|
||||
export async function updateChangeRequest(
|
||||
agent: Agent,
|
||||
publicId: string,
|
||||
input: CreateRequestInput,
|
||||
): Promise<ServiceResult<ReturnType<typeof serializeRequest>>> {
|
||||
const existing = await prisma.changeRequest.findUnique({ where: { publicId } });
|
||||
if (!existing || existing.agentId !== agent.id) {
|
||||
return err(404, "Change request not found");
|
||||
}
|
||||
const request = await materializeExpiry(existing);
|
||||
|
||||
// Updates are only possible before a final decision.
|
||||
if (request.state !== "CHANGES_REQUESTED" && request.state !== "PENDING") {
|
||||
return err(
|
||||
409,
|
||||
`Cannot update a request in state ${request.state}; only PENDING or CHANGES_REQUESTED requests may be updated`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!input.title || input.title.trim().length === 0) {
|
||||
return err(400, "title is required");
|
||||
}
|
||||
if (!Array.isArray(input.changes) || input.changes.length === 0) {
|
||||
return err(400, "changes must be a non-empty array");
|
||||
}
|
||||
|
||||
const normalized = normalizeChanges(input.changes);
|
||||
const contentHash = computeContentHash({
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
changes: input.changes,
|
||||
});
|
||||
|
||||
const updated = await prisma.changeRequest.update({
|
||||
where: { id: request.id },
|
||||
data: {
|
||||
title: input.title.trim(),
|
||||
description: input.description?.trim() || null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
changes: normalized as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
rawChanges: input.changes as any,
|
||||
...(input.metadata !== undefined
|
||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ metadata: (input.metadata ?? undefined) as any }
|
||||
: {}),
|
||||
contentHash,
|
||||
state: "PENDING",
|
||||
resubmitted: true,
|
||||
updateCount: { increment: 1 },
|
||||
lastAgentUpdateAt: new Date(),
|
||||
// A fresh decision must be made on the new content.
|
||||
decidedAt: null,
|
||||
approverId: null,
|
||||
signature: null,
|
||||
receiptIssuedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
await createNotification({
|
||||
userId: request.userId,
|
||||
type: "REQUEST_UPDATED",
|
||||
title: "Change request updated",
|
||||
message: `${agent.name} updated "${updated.title}" after your requested changes`,
|
||||
requestPublicId: updated.publicId,
|
||||
});
|
||||
|
||||
log.info({ requestId: updated.publicId, agentId: agent.id }, "Change request updated");
|
||||
return ok(serializeRequest(updated));
|
||||
}
|
||||
|
||||
export async function getChangeRequestForAgent(
|
||||
agent: Agent,
|
||||
publicId: string,
|
||||
): Promise<ServiceResult<ReturnType<typeof serializeRequest>>> {
|
||||
const existing = await prisma.changeRequest.findUnique({ where: { publicId } });
|
||||
if (!existing || existing.agentId !== agent.id) {
|
||||
return err(404, "Change request not found");
|
||||
}
|
||||
const request = await materializeExpiry(existing);
|
||||
return ok(serializeRequest(request));
|
||||
}
|
||||
|
||||
export async function cancelChangeRequest(
|
||||
agent: Agent,
|
||||
publicId: string,
|
||||
): Promise<ServiceResult<ReturnType<typeof serializeRequest>>> {
|
||||
const existing = await prisma.changeRequest.findUnique({ where: { publicId } });
|
||||
if (!existing || existing.agentId !== agent.id) {
|
||||
return err(404, "Change request not found");
|
||||
}
|
||||
const request = await materializeExpiry(existing);
|
||||
|
||||
if (request.state !== "PENDING" && request.state !== "CHANGES_REQUESTED") {
|
||||
return err(409, `Cannot cancel a request in state ${request.state}`);
|
||||
}
|
||||
|
||||
const updated = await prisma.changeRequest.update({
|
||||
where: { id: request.id },
|
||||
data: { state: "CANCELLED", cancelledAt: new Date() },
|
||||
});
|
||||
|
||||
await createNotification({
|
||||
userId: request.userId,
|
||||
type: "REQUEST_CANCELLED",
|
||||
title: "Change request cancelled",
|
||||
message: `${agent.name} cancelled "${updated.title}"`,
|
||||
requestPublicId: updated.publicId,
|
||||
});
|
||||
|
||||
return ok(serializeRequest(updated));
|
||||
}
|
||||
|
||||
export async function consumeApproval(
|
||||
agent: Agent,
|
||||
publicId: string,
|
||||
): Promise<ServiceResult<ReturnType<typeof serializeRequest>>> {
|
||||
const existing = await prisma.changeRequest.findUnique({ where: { publicId } });
|
||||
if (!existing || existing.agentId !== agent.id) {
|
||||
return err(404, "Change request not found");
|
||||
}
|
||||
const request = await materializeExpiry(existing);
|
||||
|
||||
if (request.state === "CONSUMED") {
|
||||
return err(409, "This approval has already been consumed");
|
||||
}
|
||||
if (request.state !== "APPROVED") {
|
||||
return err(409, `Only APPROVED requests can be consumed (current state: ${request.state})`);
|
||||
}
|
||||
|
||||
const updated = await prisma.changeRequest.update({
|
||||
where: { id: request.id },
|
||||
data: { state: "CONSUMED", consumedAt: new Date() },
|
||||
});
|
||||
|
||||
await createNotification({
|
||||
userId: request.userId,
|
||||
type: "REQUEST_CONSUMED",
|
||||
title: "Approval consumed",
|
||||
message: `${agent.name} consumed the approval for "${updated.title}" and is proceeding`,
|
||||
requestPublicId: updated.publicId,
|
||||
});
|
||||
|
||||
return ok(serializeRequest(updated));
|
||||
}
|
||||
|
||||
export async function listChangeRequestsForAgent(
|
||||
agent: Agent,
|
||||
opts: { state?: string; page?: number; pageSize?: number },
|
||||
) {
|
||||
const page = Math.max(1, opts.page ?? 1);
|
||||
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 20));
|
||||
const where = {
|
||||
agentId: agent.id,
|
||||
...(opts.state ? { state: opts.state as ChangeRequest["state"] } : {}),
|
||||
};
|
||||
const [total, rows] = await Promise.all([
|
||||
prisma.changeRequest.count({ where }),
|
||||
prisma.changeRequest.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
requests: rows.map((r) => serializeRequest(r)),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Human decision ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type DecisionKind = "APPROVE" | "REJECT" | "REQUEST_CHANGES";
|
||||
|
||||
export async function decideChangeRequest(
|
||||
user: User,
|
||||
publicId: string,
|
||||
decision: DecisionKind,
|
||||
comment?: string | null,
|
||||
): Promise<ServiceResult<ReturnType<typeof serializeRequest>>> {
|
||||
const existing = await prisma.changeRequest.findUnique({ where: { publicId } });
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return err(404, "Change request not found");
|
||||
}
|
||||
const request = await materializeExpiry(existing);
|
||||
|
||||
if (request.state !== "PENDING") {
|
||||
return err(
|
||||
409,
|
||||
`Only PENDING requests can be decided (current state: ${request.state})`,
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedComment = comment?.trim() || null;
|
||||
if (trimmedComment && trimmedComment.length > 500) {
|
||||
return err(400, "Comment must be 500 characters or fewer");
|
||||
}
|
||||
|
||||
const decidedAt = new Date();
|
||||
|
||||
if (decision === "REQUEST_CHANGES") {
|
||||
if (!trimmedComment) {
|
||||
return err(400, "A comment is required when requesting changes");
|
||||
}
|
||||
const updated = await prisma.changeRequest.update({
|
||||
where: { id: request.id },
|
||||
data: {
|
||||
state: "CHANGES_REQUESTED",
|
||||
comment: trimmedComment,
|
||||
decidedAt,
|
||||
approverId: user.id,
|
||||
resubmitted: false,
|
||||
},
|
||||
});
|
||||
await notifyOwnerDecision(updated, "CHANGES_REQUESTED");
|
||||
return ok(serializeRequest(updated));
|
||||
}
|
||||
|
||||
// APPROVE / REJECT — sign the platform receipt binding decision to content.
|
||||
const receiptDecision = decision === "APPROVE" ? "APPROVED" : "REJECTED";
|
||||
const signature = signReceipt({
|
||||
request_id: request.publicId,
|
||||
decision: receiptDecision,
|
||||
content_hash: request.contentHash,
|
||||
approver_id: user.id,
|
||||
decided_at: decidedAt.toISOString(),
|
||||
});
|
||||
|
||||
const updated = await prisma.changeRequest.update({
|
||||
where: { id: request.id },
|
||||
data: {
|
||||
state: receiptDecision,
|
||||
comment: trimmedComment,
|
||||
decidedAt,
|
||||
approverId: user.id,
|
||||
signature,
|
||||
receiptIssuedAt: decidedAt,
|
||||
},
|
||||
});
|
||||
|
||||
await notifyOwnerDecision(updated, receiptDecision);
|
||||
return ok(serializeRequest(updated));
|
||||
}
|
||||
|
||||
async function notifyOwnerDecision(request: ChangeRequest, decision: string) {
|
||||
const map: Record<
|
||||
string,
|
||||
{ type: Parameters<typeof createNotification>[0]["type"]; title: string; verb: string }
|
||||
> = {
|
||||
APPROVED: { type: "REQUEST_APPROVED", title: "Request approved", verb: "approved" },
|
||||
REJECTED: { type: "REQUEST_REJECTED", title: "Request rejected", verb: "rejected" },
|
||||
CHANGES_REQUESTED: {
|
||||
type: "CHANGES_REQUESTED",
|
||||
title: "Changes requested",
|
||||
verb: "requested changes on",
|
||||
},
|
||||
};
|
||||
const entry = map[decision];
|
||||
if (!entry) return;
|
||||
await createNotification({
|
||||
userId: request.userId,
|
||||
type: entry.type,
|
||||
title: entry.title,
|
||||
message: `You ${entry.verb} "${request.title}"`,
|
||||
requestPublicId: request.publicId,
|
||||
});
|
||||
// Nudge all of the human's open tabs to refresh this request.
|
||||
await publishToUser(request.userId, {
|
||||
kind: "request_event",
|
||||
event: decision,
|
||||
requestPublicId: request.publicId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { GlobalSettingType } from "@prisma/client";
|
||||
import { prisma } from "./db";
|
||||
|
||||
async function getSetting(type: GlobalSettingType): Promise<string | null> {
|
||||
const row = await prisma.globalSetting.findUnique({ where: { type } });
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
async function setSetting(type: GlobalSettingType, value: string): Promise<void> {
|
||||
await prisma.globalSetting.upsert({
|
||||
where: { type },
|
||||
create: { type, value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRegistrationEnabled(): Promise<boolean> {
|
||||
const val = await getSetting("registration_enabled");
|
||||
if (val !== null) return val === "true";
|
||||
return true; // registration is enabled by default
|
||||
}
|
||||
|
||||
export async function setRegistrationEnabled(enabled: boolean): Promise<void> {
|
||||
await setSetting("registration_enabled", String(enabled));
|
||||
}
|
||||
|
||||
export async function getRequestsEnabled(): Promise<boolean> {
|
||||
const val = await getSetting("requests_enabled");
|
||||
if (val !== null) return val === "true";
|
||||
return true; // request submission is enabled by default
|
||||
}
|
||||
|
||||
export async function setRequestsEnabled(enabled: boolean): Promise<void> {
|
||||
await setSetting("requests_enabled", String(enabled));
|
||||
}
|
||||
|
||||
export async function getGlobalSettings() {
|
||||
return {
|
||||
registration_enabled: await getRegistrationEnabled(),
|
||||
requests_enabled: await getRequestsEnabled(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
const MAX_ICON_BYTES = 1024 * 1024; // 1 MB
|
||||
const SUPPORTED_MIME = new Set(["image/jpeg", "image/png", "image/gif"]);
|
||||
const FETCH_TIMEOUT_MS = 8000;
|
||||
|
||||
export type IconValidationResult =
|
||||
| { valid: true; mime: string; bytes: number }
|
||||
| { valid: false; reason: string };
|
||||
|
||||
/**
|
||||
* Verify that an icon URL points to a real, supported, appropriately-sized image.
|
||||
* The image is fetched ONCE and NOT cached/persisted — only validated. Formats
|
||||
* other than jpg/png/gif and images over 1 MB are rejected.
|
||||
*/
|
||||
export async function validateIconUrl(url: string): Promise<IconValidationResult> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return { valid: false, reason: "Icon URL is not a valid URL" };
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return { valid: false, reason: "Icon URL must use http or https" };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(parsed.toString(), {
|
||||
method: "GET",
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "image/*" },
|
||||
});
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
return { valid: false, reason: "Could not fetch the icon URL" };
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
return { valid: false, reason: `Icon URL returned HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
// Reject early if the server advertises an oversized body.
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength && Number(contentLength) > MAX_ICON_BYTES) {
|
||||
return { valid: false, reason: "Icon image exceeds the 1 MB limit" };
|
||||
}
|
||||
|
||||
let arrayBuffer: ArrayBuffer;
|
||||
try {
|
||||
arrayBuffer = await response.arrayBuffer();
|
||||
} catch {
|
||||
return { valid: false, reason: "Failed to read the icon image data" };
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
if (buffer.byteLength > MAX_ICON_BYTES) {
|
||||
return { valid: false, reason: "Icon image exceeds the 1 MB limit" };
|
||||
}
|
||||
if (buffer.byteLength === 0) {
|
||||
return { valid: false, reason: "Icon URL returned an empty body" };
|
||||
}
|
||||
|
||||
// Detect the true format from the bytes, not the advertised content-type.
|
||||
// file-type is ESM-only; use a dynamic import from this CommonJS build.
|
||||
const { fileTypeFromBuffer } = await import("file-type");
|
||||
const detected = await fileTypeFromBuffer(buffer);
|
||||
if (!detected || !SUPPORTED_MIME.has(detected.mime)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: "Icon must be a JPG, PNG, or GIF image",
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, mime: detected.mime, bytes: buffer.byteLength };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { prisma } from "./db";
|
||||
|
||||
// ── Platform limits ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Max change requests an agent may create per rolling hour. */
|
||||
export const MAX_REQUESTS_PER_HOUR = 15;
|
||||
|
||||
/** Max agents a single human account may own. */
|
||||
export const MAX_AGENTS_PER_USER = 5;
|
||||
|
||||
/** Pending-request cap bounds (human-configurable per agent). */
|
||||
export const MIN_PENDING_LIMIT = 1;
|
||||
export const MAX_PENDING_LIMIT = 10;
|
||||
export const DEFAULT_PENDING_LIMIT = 5;
|
||||
|
||||
/** Request expiry bounds, in seconds. */
|
||||
export const DEFAULT_EXPIRY_SECONDS = 30 * 60; // 30 minutes
|
||||
export const MIN_EXPIRY_SECONDS = 60; // 1 minute
|
||||
export const MAX_EXPIRY_SECONDS = 12 * 60 * 60; // 12 hours
|
||||
|
||||
/** Auto-delete retention bounds, in days. */
|
||||
export const MIN_AUTO_DELETE_DAYS = 7;
|
||||
export const DEFAULT_AUTO_DELETE_DAYS = 30;
|
||||
|
||||
// ── Pure clamps ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function clampPendingLimit(value: number): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_PENDING_LIMIT;
|
||||
return Math.min(MAX_PENDING_LIMIT, Math.max(MIN_PENDING_LIMIT, Math.trunc(value)));
|
||||
}
|
||||
|
||||
export function clampExpirySeconds(value: number | undefined | null): number {
|
||||
if (value === undefined || value === null || !Number.isFinite(value)) {
|
||||
return DEFAULT_EXPIRY_SECONDS;
|
||||
}
|
||||
return Math.min(MAX_EXPIRY_SECONDS, Math.max(MIN_EXPIRY_SECONDS, Math.trunc(value)));
|
||||
}
|
||||
|
||||
export function clampAutoDeleteDays(value: number): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_AUTO_DELETE_DAYS;
|
||||
return Math.max(MIN_AUTO_DELETE_DAYS, Math.trunc(value));
|
||||
}
|
||||
|
||||
// ── Limit checks (DB-backed) ────────────────────────────────────────────────────
|
||||
|
||||
export type LimitResult = { allowed: true } | { allowed: false; reason: string };
|
||||
|
||||
/** An agent may create at most MAX_REQUESTS_PER_HOUR requests per rolling hour. */
|
||||
export async function checkAgentHourlyLimit(agentId: number): Promise<LimitResult> {
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const count = await prisma.changeRequest.count({
|
||||
where: { agentId, createdAt: { gte: oneHourAgo } },
|
||||
});
|
||||
if (count >= MAX_REQUESTS_PER_HOUR) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Rate limit reached: an agent may create at most ${MAX_REQUESTS_PER_HOUR} requests per hour`,
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/** An agent may have at most `maxPendingRequests` requests in PENDING state. */
|
||||
export async function checkAgentPendingLimit(
|
||||
agentId: number,
|
||||
maxPendingRequests: number,
|
||||
): Promise<LimitResult> {
|
||||
const count = await prisma.changeRequest.count({
|
||||
where: { agentId, state: "PENDING" },
|
||||
});
|
||||
if (count >= maxPendingRequests) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Pending limit reached: this agent may have at most ${maxPendingRequests} pending requests at a time`,
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/** A human may own at most MAX_AGENTS_PER_USER agents. */
|
||||
export async function checkAgentCountLimit(userId: number): Promise<LimitResult> {
|
||||
const count = await prisma.agent.count({ where: { ownerId: userId } });
|
||||
if (count >= MAX_AGENTS_PER_USER) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Agent limit reached: a human may own at most ${MAX_AGENTS_PER_USER} agents`,
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NotificationType } from "@prisma/client";
|
||||
import { prisma } from "./db";
|
||||
import { publishToUser } from "./WsHub";
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("NOTIFY");
|
||||
|
||||
export type CreateNotificationInput = {
|
||||
userId: number;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
requestPublicId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist a notification and push it to the user's live websocket channel.
|
||||
* Notifications always persist (retrievable via REST) — the websocket delivery
|
||||
* is best-effort on top of that.
|
||||
*/
|
||||
export async function createNotification(input: CreateNotificationInput) {
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
requestPublicId: input.requestPublicId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const unreadCount = await prisma.notification.count({
|
||||
where: { userId: input.userId, read: false },
|
||||
});
|
||||
await publishToUser(input.userId, {
|
||||
kind: "notification",
|
||||
notification,
|
||||
unreadCount,
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn({ err, userId: input.userId }, "Failed to push realtime notification");
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Agent, Session, User } from "@prisma/client";
|
||||
import type { AuthState } from "./Authentication";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
function getAuth(ctr: any): AuthState {
|
||||
if (typeof ctr.getAuth === "function") return ctr.getAuth();
|
||||
return { success: false, message: "Auth not resolved", method: "none" };
|
||||
}
|
||||
|
||||
/** Require a logged-in human. Returns the user/session or null (after replying 401). */
|
||||
export function requireSession(ctr: any): { user: User; session: Session } | null {
|
||||
const auth = getAuth(ctr);
|
||||
if (!auth.success || auth.method !== "session") {
|
||||
ctr.status(401).print({ status: "FAILED", message: "You must be logged in" });
|
||||
return null;
|
||||
}
|
||||
return { user: auth.user, session: auth.session };
|
||||
}
|
||||
|
||||
/** Require a valid agent API key. Returns the agent/owner or null (after replying 401). */
|
||||
export function requireAgent(ctr: any): { agent: Agent; owner: User } | null {
|
||||
const auth = getAuth(ctr);
|
||||
if (!auth.success || auth.method !== "agent") {
|
||||
ctr.status(401).print({
|
||||
status: "FAILED",
|
||||
message: "A valid agent API key is required (x-api-key header)",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { agent: auth.agent, owner: auth.owner };
|
||||
}
|
||||
|
||||
/** Require an admin human. Returns the user or null (after replying 401/403). */
|
||||
export function requireAdmin(ctr: any): { user: User } | null {
|
||||
const session = requireSession(ctr);
|
||||
if (!session) return null;
|
||||
if (session.user.role !== "ADMIN") {
|
||||
ctr.status(403).print({ status: "FAILED", message: "Administrator access required" });
|
||||
return null;
|
||||
}
|
||||
return { user: session.user };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* The canonical, platform-signed decision receipt. Agents verify this through the
|
||||
* API before applying changes: the signature is an HMAC-SHA256 over the canonical
|
||||
* receipt fields keyed by INSTANCE_SECRET, so a decision cannot be forged or
|
||||
* replayed against a different request or different content.
|
||||
*/
|
||||
export type ReceiptPayload = {
|
||||
request_id: string;
|
||||
decision: "APPROVED" | "REJECTED";
|
||||
content_hash: string;
|
||||
approver_id: number;
|
||||
decided_at: string; // ISO 8601, UTC
|
||||
};
|
||||
|
||||
function canonicalReceipt(p: ReceiptPayload): string {
|
||||
// Fixed field order — never reorder, the signature depends on it.
|
||||
return [
|
||||
`request_id=${p.request_id}`,
|
||||
`decision=${p.decision}`,
|
||||
`content_hash=${p.content_hash}`,
|
||||
`approver_id=${p.approver_id}`,
|
||||
`decided_at=${p.decided_at}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function signReceipt(payload: ReceiptPayload): string {
|
||||
return createHmac("sha256", env.INSTANCE_SECRET)
|
||||
.update(canonicalReceipt(payload))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function verifyReceipt(payload: ReceiptPayload, signature: string): boolean {
|
||||
const expected = signReceipt(payload);
|
||||
const a = Buffer.from(expected, "hex");
|
||||
const b = Buffer.from(signature, "hex");
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { createLogger } from "./logger";
|
||||
import { createNotification } from "./Notifications";
|
||||
|
||||
const log = createLogger("SYSTEM_CRONS");
|
||||
|
||||
type SystemCronDependencies = { prisma: PrismaClient };
|
||||
|
||||
type ScheduledJob = {
|
||||
name: string;
|
||||
intervalMs: number;
|
||||
runOnStart?: boolean;
|
||||
run: () => Promise<void>;
|
||||
};
|
||||
|
||||
type SystemCronHandle = { stop: () => void };
|
||||
|
||||
function scheduleJob(job: ScheduledJob): SystemCronHandle {
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const tick = async () => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
await job.run();
|
||||
} catch (error) {
|
||||
log.error({ err: error, job: job.name }, "System cron failed");
|
||||
}
|
||||
if (!stopped) {
|
||||
timer = setTimeout(() => void tick(), job.intervalMs);
|
||||
}
|
||||
};
|
||||
|
||||
if (job.runOnStart) void tick();
|
||||
else timer = setTimeout(() => void tick(), job.intervalMs);
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function startSystemCrons(deps: SystemCronDependencies): SystemCronHandle {
|
||||
const jobs: ScheduledJob[] = [
|
||||
{
|
||||
name: "expire-requests",
|
||||
intervalMs: 30 * 1000,
|
||||
runOnStart: true,
|
||||
run: async () => {
|
||||
await expireRequests(deps);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "auto-delete-requests",
|
||||
intervalMs: 60 * 60 * 1000, // hourly
|
||||
runOnStart: true,
|
||||
run: async () => {
|
||||
await autoDeleteRequests(deps);
|
||||
},
|
||||
},
|
||||
];
|
||||
const handles = jobs.map(scheduleJob);
|
||||
return { stop: () => handles.forEach((h) => h.stop()) };
|
||||
}
|
||||
|
||||
/** Transition open requests past their expiry to EXPIRED and notify their owners. */
|
||||
export async function expireRequests({ prisma }: SystemCronDependencies): Promise<number> {
|
||||
const now = new Date();
|
||||
const expired = await prisma.changeRequest.findMany({
|
||||
where: {
|
||||
state: { in: ["PENDING", "CHANGES_REQUESTED"] },
|
||||
expiresAt: { lt: now },
|
||||
},
|
||||
select: { id: true, publicId: true, title: true, userId: true },
|
||||
});
|
||||
if (expired.length === 0) return 0;
|
||||
|
||||
await prisma.changeRequest.updateMany({
|
||||
where: { id: { in: expired.map((r) => r.id) } },
|
||||
data: { state: "EXPIRED" },
|
||||
});
|
||||
|
||||
for (const r of expired) {
|
||||
await createNotification({
|
||||
userId: r.userId,
|
||||
type: "REQUEST_EXPIRED",
|
||||
title: "Change request expired",
|
||||
message: `"${r.title}" expired before it was reviewed`,
|
||||
requestPublicId: r.publicId,
|
||||
});
|
||||
}
|
||||
|
||||
log.info({ count: expired.length }, "Expired stale change requests");
|
||||
return expired.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete change requests older than each opted-in user's retention window.
|
||||
* Disabled by default per user; retention is clamped to a 7-day minimum.
|
||||
*/
|
||||
export async function autoDeleteRequests({ prisma }: SystemCronDependencies): Promise<number> {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { autoDeleteEnabled: true },
|
||||
select: { id: true, autoDeleteDays: true },
|
||||
});
|
||||
if (users.length === 0) return 0;
|
||||
|
||||
let totalDeleted = 0;
|
||||
for (const user of users) {
|
||||
const days = Math.max(7, user.autoDeleteDays);
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
const result = await prisma.changeRequest.deleteMany({
|
||||
where: { userId: user.id, createdAt: { lt: cutoff } },
|
||||
});
|
||||
totalDeleted += result.count;
|
||||
}
|
||||
if (totalDeleted > 0) {
|
||||
log.info({ count: totalDeleted }, "Auto-deleted old change requests");
|
||||
}
|
||||
return totalDeleted;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Channel } from "rjweb-server";
|
||||
|
||||
/**
|
||||
* Real-time hub. One RJWEB Channel per human account; the websocket endpoint
|
||||
* subscribes each authenticated socket to its owner's channel via
|
||||
* `ctr.printChannel(getUserChannel(userId))`. Any server-side event for that
|
||||
* user (new notification, request state change) is published to the channel and
|
||||
* fanned out to every open socket.
|
||||
*/
|
||||
const userChannels = new Map<number, Channel<string>>();
|
||||
|
||||
export function getUserChannel(userId: number): Channel<string> {
|
||||
let channel = userChannels.get(userId);
|
||||
if (!channel) {
|
||||
channel = new Channel<string>();
|
||||
userChannels.set(userId, channel);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
export type RealtimeEvent =
|
||||
| { kind: "notification"; notification: unknown; unreadCount: number }
|
||||
| { kind: "request_event"; event: string; requestPublicId: string }
|
||||
| { kind: "ping"; at: string };
|
||||
|
||||
export async function publishToUser(userId: number, event: RealtimeEvent): Promise<void> {
|
||||
const channel = userChannels.get(userId);
|
||||
if (!channel) return; // nobody connected — nothing to push
|
||||
await channel.send("text", JSON.stringify(event));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { env } from "./env";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
|
||||
const adapter = new PrismaPg({ connectionString: env.DATABASE_URL });
|
||||
|
||||
export const prisma = new PrismaClient({
|
||||
adapter,
|
||||
log: ["info", "error", "warn"],
|
||||
errorFormat: "pretty",
|
||||
transactionOptions: { timeout: 30000, maxWait: 20000 },
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import dotenv from "dotenv";
|
||||
import { join } from "path";
|
||||
import { z } from "zod";
|
||||
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
dotenv.config({ path: join(__dirname, "../../.env") });
|
||||
}
|
||||
|
||||
const baseSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
DATABASE_URL: z
|
||||
.string()
|
||||
.default("postgresql://patchpass:patchpass@localhost:5433/patchpass"),
|
||||
PORT: z.coerce.number().int().positive(),
|
||||
UI_URL: z.string().min(1),
|
||||
REACT_APP_API_URL: z.string().min(1),
|
||||
DOMAIN: z.string().min(1),
|
||||
CORS_URLS: z.string().min(1),
|
||||
INSTANCE_SECRET: z.string().default("default_insecure_secret_please_set"),
|
||||
RATELIMIT: z.coerce.number().int().nonnegative().default(0),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
REQUEST_DEBUGGING: z
|
||||
.enum(["true", "false"])
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
RESPONSE_DEBUGGING: z
|
||||
.enum(["true", "false"])
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
});
|
||||
|
||||
// In test mode the server never starts, so production-required vars get safe defaults.
|
||||
const testSchema = baseSchema.extend({
|
||||
PORT: z.coerce.number().int().positive().default(3000),
|
||||
UI_URL: z.string().default("http://localhost:3000"),
|
||||
REACT_APP_API_URL: z.string().default("http://localhost:3000"),
|
||||
DOMAIN: z.string().default("localhost"),
|
||||
CORS_URLS: z.string().default("http://localhost:3000"),
|
||||
});
|
||||
|
||||
const isTest = process.env.NODE_ENV === "test";
|
||||
const result = (isTest ? testSchema : baseSchema).safeParse(process.env);
|
||||
|
||||
if (!result.success) {
|
||||
const formatted = result.error.issues
|
||||
.map((i) => ` ${i.path.join(".")}: ${i.message}`)
|
||||
.join("\n");
|
||||
throw new Error(`Invalid environment variables:\n${formatted}`);
|
||||
}
|
||||
|
||||
export const env = result.data;
|
||||
@@ -0,0 +1,9 @@
|
||||
export const ERROR_MESSAGES = {
|
||||
UNAUTHORIZED: { code: 401, message: "You are not authorized to access this resource." },
|
||||
FORBIDDEN: { code: 403, message: "You do not have permission to access this resource." },
|
||||
NOT_FOUND: { code: 404, message: "The requested resource was not found." },
|
||||
INTERNAL_SERVER_ERROR: { code: 500, message: "An unexpected server error has occurred." },
|
||||
BAD_REQUEST: { code: 400, message: "The request was invalid or malformed." },
|
||||
CONFLICT: { code: 409, message: "The request conflicts with the current state of the resource." },
|
||||
TOO_MANY_REQUESTS: { code: 429, message: "Too many requests. Please slow down." },
|
||||
} as const;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { env } from "./env";
|
||||
import pino from "pino";
|
||||
|
||||
export const logger = pino({
|
||||
level: env.LOG_LEVEL,
|
||||
transport:
|
||||
env.NODE_ENV !== "production"
|
||||
? { target: "pino-pretty", options: { colorize: true } }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export function createLogger(component: string) {
|
||||
return logger.child({ component });
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Agent } from "@prisma/client";
|
||||
import { tools, toolMap } from "./tools";
|
||||
|
||||
type JsonRpcId = string | number | null | undefined;
|
||||
|
||||
function ok(id: JsonRpcId, result: unknown) {
|
||||
return { status: 200, body: { jsonrpc: "2.0" as const, id: id ?? null, result } };
|
||||
}
|
||||
|
||||
function rpcErr(id: JsonRpcId, code: number, message: string) {
|
||||
return { status: 200, body: { jsonrpc: "2.0" as const, id: id ?? null, error: { code, message } } };
|
||||
}
|
||||
|
||||
export type McpRequest = {
|
||||
jsonrpc: "2.0";
|
||||
id?: JsonRpcId;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
export async function handleMcpRequest(
|
||||
req: McpRequest,
|
||||
ctx: { agent: Agent },
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
const { id, method, params } = req;
|
||||
|
||||
// Notifications — acknowledge with no body.
|
||||
if (method.startsWith("notifications/") || method === "initialized") {
|
||||
return { status: 202, body: {} };
|
||||
}
|
||||
|
||||
switch (method) {
|
||||
case "initialize":
|
||||
return ok(id, {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: "patchpass", version: "1.0.0" },
|
||||
instructions:
|
||||
"PatchPass is a human approval layer. Call get_docs first. Before taking consequential actions, call create_request and wait for approval, then consume_approval before proceeding.",
|
||||
});
|
||||
|
||||
case "ping":
|
||||
return ok(id, {});
|
||||
|
||||
case "tools/list":
|
||||
return ok(id, {
|
||||
tools: tools.map(({ name, description, inputSchema }) => ({
|
||||
name,
|
||||
description,
|
||||
inputSchema,
|
||||
})),
|
||||
});
|
||||
|
||||
case "tools/call": {
|
||||
const p = params as { name?: string; arguments?: Record<string, unknown> } | undefined;
|
||||
const toolName = p?.name;
|
||||
const args = (p?.arguments ?? {}) as Record<string, unknown>;
|
||||
if (!toolName) return rpcErr(id, -32602, "Invalid params: missing name");
|
||||
const tool = toolMap.get(toolName);
|
||||
if (!tool) return rpcErr(id, -32601, `Unknown tool: ${toolName}`);
|
||||
const result = await tool.handler(args, ctx);
|
||||
return ok(id, result);
|
||||
}
|
||||
|
||||
default:
|
||||
return rpcErr(id, -32601, `Method not found: ${method}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Agent } from "@prisma/client";
|
||||
|
||||
export type McpToolResult = {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
export type ToolContext = {
|
||||
agent: Agent;
|
||||
};
|
||||
|
||||
export type ToolHandler = (
|
||||
args: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
) => Promise<McpToolResult>;
|
||||
|
||||
export type McpToolDef = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
handler: ToolHandler;
|
||||
};
|
||||
|
||||
export function text(s: string): McpToolResult {
|
||||
return { content: [{ type: "text", text: s }] };
|
||||
}
|
||||
|
||||
export function json(v: unknown): McpToolResult {
|
||||
return text(JSON.stringify(v, null, 2));
|
||||
}
|
||||
|
||||
export function errResult(message: string): McpToolResult {
|
||||
return { content: [{ type: "text", text: message }], isError: true };
|
||||
}
|
||||
|
||||
/** Bridge a ChangeRequestService result to an MCP tool result. */
|
||||
export function fromService(result: {
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
}): McpToolResult {
|
||||
if (!result.ok) return errResult(result.message ?? "Request failed");
|
||||
return json(result.data);
|
||||
}
|
||||
|
||||
// Shared JSON-schema fragment for the `changes` array so every tool documents it
|
||||
// identically. Agents are told to use correct types so humans see rich diffs.
|
||||
export const CHANGES_SCHEMA = {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
description:
|
||||
"List of proposed changes. Each item is one of three types: unified_diff (git-style diff applied with `patch -p1`), config (a keyed before/after value with a content_type), or custom (an arbitrary labelled before/after).",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", enum: ["unified_diff", "config", "custom"] },
|
||||
path: {
|
||||
type: "string",
|
||||
description: "File path (unified_diff) or config key path (config)",
|
||||
},
|
||||
content: { type: "string", description: "The unified diff text (unified_diff only)" },
|
||||
label: { type: "string", description: "Human-readable label (custom only)" },
|
||||
before: { description: "Value before the change (config/custom)" },
|
||||
after: { description: "Value after the change (config/custom)" },
|
||||
content_type: {
|
||||
type: "string",
|
||||
description:
|
||||
"Type hint for config values, e.g. 'integer', 'string', 'boolean' — so the human sees 30 -> 60, not \"30\" -> \"60\"",
|
||||
},
|
||||
},
|
||||
required: ["type"],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { McpToolDef, text } from "../shared";
|
||||
|
||||
const DOCS = `PatchPass — human approval layer for AI agents.
|
||||
|
||||
WORKFLOW
|
||||
1. Before taking any consequential action, call create_request with a clear title,
|
||||
description, and a structured list of changes. You receive a request_id and an
|
||||
approval_url. Share the approval_url with your human if helpful.
|
||||
2. Poll get_request(request_id) until state is no longer PENDING.
|
||||
- APPROVED → you may proceed. First call consume_approval(request_id).
|
||||
- REJECTED → do NOT proceed. This is a hard blocker; a new request is required.
|
||||
- CHANGES_REQUESTED → read the 'comment', revise, and call update_request.
|
||||
- EXPIRED / CANCELLED → the request is dead; start over if still needed.
|
||||
3. consume_approval(request_id) marks the approval as used (single-use) and returns
|
||||
the platform-signed receipt. Only proceed after a successful consume.
|
||||
|
||||
CHANGE TYPES
|
||||
- unified_diff: { type, path, content } — a git-style diff (patch -p1). Preferred
|
||||
for code/file edits.
|
||||
- config: { type, path, before, after, content_type } — a keyed value change. Use the
|
||||
correct content_type ('integer', 'boolean', 'string', ...) so the human sees
|
||||
"30 -> 60" or "50% increase", not stringified values.
|
||||
- custom: { type, label, before, after } — any other before/after pair.
|
||||
|
||||
RECEIPTS
|
||||
A decided request carries a 'receipt' with the decision, content_hash, approver_id,
|
||||
decided_at, and an HMAC-SHA256 'signature'. The content_hash binds the decision to the
|
||||
exact reviewed content: if you change anything after approval, the approval no longer
|
||||
applies — submit a new request.
|
||||
|
||||
LIMITS
|
||||
- Max 15 requests per hour per agent.
|
||||
- Limited simultaneous PENDING requests (configured by your human, default 5).
|
||||
- Requests expire (default 30 min, up to 12 h via expires_in seconds).`;
|
||||
|
||||
const tool: McpToolDef = {
|
||||
name: "get_docs",
|
||||
description:
|
||||
"Read PatchPass conventions: the request/approval workflow, change types, receipts, and limits. Call this first.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
async handler() {
|
||||
return text(DOCS);
|
||||
},
|
||||
};
|
||||
|
||||
export default tool;
|
||||
@@ -0,0 +1,24 @@
|
||||
import getDocs from "./docs";
|
||||
import {
|
||||
cancelRequest,
|
||||
consumeApprovalTool,
|
||||
createRequest,
|
||||
getRequest,
|
||||
listRequests,
|
||||
updateRequest,
|
||||
} from "./requests";
|
||||
import { McpToolDef } from "../shared";
|
||||
|
||||
export type { McpToolResult, ToolContext, ToolHandler, McpToolDef } from "../shared";
|
||||
|
||||
export const tools: McpToolDef[] = [
|
||||
getDocs,
|
||||
createRequest,
|
||||
updateRequest,
|
||||
getRequest,
|
||||
listRequests,
|
||||
cancelRequest,
|
||||
consumeApprovalTool,
|
||||
];
|
||||
|
||||
export const toolMap = new Map<string, McpToolDef>(tools.map((t) => [t.name, t]));
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
cancelChangeRequest,
|
||||
consumeApproval,
|
||||
createChangeRequest,
|
||||
getChangeRequestForAgent,
|
||||
listChangeRequestsForAgent,
|
||||
updateChangeRequest,
|
||||
CreateRequestInput,
|
||||
} from "../../ChangeRequestService";
|
||||
import { Change } from "../../ChangeNormalization";
|
||||
import { CHANGES_SCHEMA, McpToolDef, errResult, fromService, json } from "../shared";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
function coerceChanges(raw: unknown): Change[] | { error: string } {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return { error: "changes must be a non-empty array" };
|
||||
}
|
||||
const out: Change[] = [];
|
||||
for (const [i, item] of raw.entries()) {
|
||||
if (!item || typeof item !== "object") return { error: `changes[${i}] must be an object` };
|
||||
const c = item as any;
|
||||
if (c.type === "unified_diff") {
|
||||
if (typeof c.path !== "string" || typeof c.content !== "string") {
|
||||
return { error: `changes[${i}] (unified_diff) needs string 'path' and 'content'` };
|
||||
}
|
||||
out.push({ type: "unified_diff", path: c.path, content: c.content });
|
||||
} else if (c.type === "config") {
|
||||
if (typeof c.path !== "string") return { error: `changes[${i}] (config) needs a string 'path'` };
|
||||
out.push({
|
||||
type: "config",
|
||||
path: c.path,
|
||||
before: c.before,
|
||||
after: c.after,
|
||||
content_type: typeof c.content_type === "string" ? c.content_type : undefined,
|
||||
});
|
||||
} else if (c.type === "custom") {
|
||||
if (typeof c.label !== "string") return { error: `changes[${i}] (custom) needs a string 'label'` };
|
||||
out.push({ type: "custom", label: c.label, before: c.before, after: c.after });
|
||||
} else {
|
||||
return { error: `changes[${i}] has invalid type '${c.type}'` };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildInput(args: Record<string, unknown>): CreateRequestInput | { error: string } {
|
||||
const title = typeof args.title === "string" ? args.title : undefined;
|
||||
if (!title) return { error: "title is required" };
|
||||
const changes = coerceChanges(args.changes);
|
||||
if ("error" in changes) return { error: changes.error };
|
||||
return {
|
||||
title,
|
||||
description: typeof args.description === "string" ? args.description : null,
|
||||
changes,
|
||||
expires_in: typeof args.expires_in === "number" ? args.expires_in : undefined,
|
||||
metadata:
|
||||
args.metadata && typeof args.metadata === "object"
|
||||
? (args.metadata as Record<string, unknown>)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const commonProps = {
|
||||
title: { type: "string", description: "Short, human-readable title (1–200 chars)" },
|
||||
description: { type: "string", description: "Optional context for the reviewer" },
|
||||
changes: CHANGES_SCHEMA,
|
||||
expires_in: {
|
||||
type: "integer",
|
||||
description: "Seconds until the request expires (60–43200; default 1800 = 30 min)",
|
||||
},
|
||||
metadata: {
|
||||
type: "object",
|
||||
description: "Optional free-form context, e.g. { repository, environment }",
|
||||
},
|
||||
};
|
||||
|
||||
export const createRequest: McpToolDef = {
|
||||
name: "create_request",
|
||||
description:
|
||||
"Submit a change request for human approval. Returns request_id, approval_url, and state (PENDING). Poll get_request until decided.",
|
||||
inputSchema: { type: "object", required: ["title", "changes"], properties: commonProps },
|
||||
async handler(args, { agent }) {
|
||||
const input = buildInput(args);
|
||||
if ("error" in input) return errResult(input.error);
|
||||
return fromService(await createChangeRequest(agent, input));
|
||||
},
|
||||
};
|
||||
|
||||
export const updateRequest: McpToolDef = {
|
||||
name: "update_request",
|
||||
description:
|
||||
"Update a PENDING or CHANGES_REQUESTED request with revised content (e.g. after the human requested changes). Resets the request to PENDING for re-review.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["request_id", "title", "changes"],
|
||||
properties: { request_id: { type: "string" }, ...commonProps },
|
||||
},
|
||||
async handler(args, { agent }) {
|
||||
const requestId = typeof args.request_id === "string" ? args.request_id : undefined;
|
||||
if (!requestId) return errResult("request_id is required");
|
||||
const input = buildInput(args);
|
||||
if ("error" in input) return errResult(input.error);
|
||||
return fromService(await updateChangeRequest(agent, requestId, input));
|
||||
},
|
||||
};
|
||||
|
||||
export const getRequest: McpToolDef = {
|
||||
name: "get_request",
|
||||
description:
|
||||
"Fetch a request by id: its current state, comment, and (once decided) the platform-signed receipt.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["request_id"],
|
||||
properties: { request_id: { type: "string" } },
|
||||
},
|
||||
async handler(args, { agent }) {
|
||||
const requestId = typeof args.request_id === "string" ? args.request_id : undefined;
|
||||
if (!requestId) return errResult("request_id is required");
|
||||
return fromService(await getChangeRequestForAgent(agent, requestId));
|
||||
},
|
||||
};
|
||||
|
||||
export const cancelRequest: McpToolDef = {
|
||||
name: "cancel_request",
|
||||
description: "Cancel your own request before a decision is made (PENDING or CHANGES_REQUESTED).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["request_id"],
|
||||
properties: { request_id: { type: "string" } },
|
||||
},
|
||||
async handler(args, { agent }) {
|
||||
const requestId = typeof args.request_id === "string" ? args.request_id : undefined;
|
||||
if (!requestId) return errResult("request_id is required");
|
||||
return fromService(await cancelChangeRequest(agent, requestId));
|
||||
},
|
||||
};
|
||||
|
||||
export const consumeApprovalTool: McpToolDef = {
|
||||
name: "consume_approval",
|
||||
description:
|
||||
"Consume an APPROVED request (single-use) before applying changes. Returns the signed receipt. Fails if not APPROVED or already consumed.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["request_id"],
|
||||
properties: { request_id: { type: "string" } },
|
||||
},
|
||||
async handler(args, { agent }) {
|
||||
const requestId = typeof args.request_id === "string" ? args.request_id : undefined;
|
||||
if (!requestId) return errResult("request_id is required");
|
||||
return fromService(await consumeApproval(agent, requestId));
|
||||
},
|
||||
};
|
||||
|
||||
export const listRequests: McpToolDef = {
|
||||
name: "list_requests",
|
||||
description: "List your recent change requests, optionally filtered by state.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
state: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"PENDING",
|
||||
"CHANGES_REQUESTED",
|
||||
"APPROVED",
|
||||
"REJECTED",
|
||||
"EXPIRED",
|
||||
"CONSUMED",
|
||||
"CANCELLED",
|
||||
],
|
||||
},
|
||||
page: { type: "integer" },
|
||||
page_size: { type: "integer" },
|
||||
},
|
||||
},
|
||||
async handler(args, { agent }) {
|
||||
const data = await listChangeRequestsForAgent(agent, {
|
||||
state: typeof args.state === "string" ? args.state : undefined,
|
||||
page: typeof args.page === "number" ? args.page : 1,
|
||||
pageSize: typeof args.page_size === "number" ? args.page_size : 20,
|
||||
});
|
||||
return json(data);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Middleware } from "rjweb-server";
|
||||
import { checkAuthentication, AuthState } from "../Authentication";
|
||||
import { SESSION_COOKIE, API_KEY_HEADER } from "../static";
|
||||
import { createLogger } from "../logger";
|
||||
|
||||
const log = createLogger("AUTH");
|
||||
|
||||
type AuthContext = {
|
||||
auth?: AuthState;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves auth for every request and exposes `ctr.getAuth()`. Does NOT reject —
|
||||
* route handlers decide whether a session or agent key is required. This keeps
|
||||
* anonymous routes (login, register, health) working while giving every handler a
|
||||
* uniform way to read the caller's identity.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export const authResolutionMiddleware = new Middleware<{}, AuthContext>(
|
||||
"Auth Resolution Middleware",
|
||||
"1.0.0",
|
||||
)
|
||||
.load(() => {
|
||||
log.info("Auth resolution middleware loaded");
|
||||
})
|
||||
.httpRequest(async (_config, _server, context, ctr) => {
|
||||
const cookieToken = ctr.cookies.get(SESSION_COOKIE);
|
||||
const apiKeyToken = ctr.headers.get(API_KEY_HEADER);
|
||||
const result = await checkAuthentication(cookieToken, apiKeyToken);
|
||||
const data = context.data(authResolutionMiddleware);
|
||||
data.auth = result;
|
||||
})
|
||||
.httpRequestContext(
|
||||
(_config, Original) =>
|
||||
class extends Original {
|
||||
getAuth(): AuthState {
|
||||
const data = this.context.data(authResolutionMiddleware);
|
||||
if (!data.auth) {
|
||||
return { success: false, message: "Auth not resolved", method: "none" };
|
||||
}
|
||||
return data.auth;
|
||||
}
|
||||
},
|
||||
)
|
||||
.export();
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Middleware } from "rjweb-server";
|
||||
import { createLogger } from "../logger";
|
||||
import { env } from "../env";
|
||||
|
||||
const corsLog = createLogger("CORS");
|
||||
|
||||
const CORS_DOMAINS: string[] = [];
|
||||
|
||||
export function initCorsDomains(domains: string[]) {
|
||||
CORS_DOMAINS.length = 0;
|
||||
CORS_DOMAINS.push(...domains.filter(Boolean));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export const corsMiddleware = new Middleware<{}, {}>("Custom CORS", "1.0.0")
|
||||
.load(() => {
|
||||
corsLog.info("Custom CORS loaded");
|
||||
})
|
||||
.httpRequest(async (_config, _server, _context, ctr, end) => {
|
||||
if (env.RATELIMIT === 0) {
|
||||
ctr.skipRateLimit();
|
||||
}
|
||||
|
||||
if (ctr.url.path === "/api/openapi.json") {
|
||||
ctr.headers.set("Content-Type", "application/json");
|
||||
ctr.headers.set("Access-Control-Allow-Origin", "*");
|
||||
return;
|
||||
}
|
||||
|
||||
const origin = ctr.headers.get("origin");
|
||||
|
||||
if (origin && !CORS_DOMAINS.includes(origin)) {
|
||||
// Agent/API traffic (no browser origin) is unaffected; only browser
|
||||
// requests from disallowed origins are blocked.
|
||||
corsLog.warn({ origin }, "CORS denied");
|
||||
return end(
|
||||
ctr.status(ctr.$status.FORBIDDEN).print({
|
||||
status: "FAILED",
|
||||
message: "CORS policy: this origin is not allowed",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const allowedHeaders =
|
||||
ctr.headers.get("access-control-request-headers") || "content-type, x-api-key";
|
||||
const allowedMethods = "GET, POST, PUT, DELETE, OPTIONS, PATCH";
|
||||
|
||||
if (origin) {
|
||||
if (ctr.url.method === "OPTIONS") {
|
||||
ctr.headers.set("Access-Control-Max-Age", "86400");
|
||||
ctr.headers.set("Content-Length", "0");
|
||||
ctr.headers.set("Access-Control-Allow-Origin", origin);
|
||||
ctr.headers.set("Access-Control-Allow-Methods", allowedMethods);
|
||||
ctr.headers.set("Vary", "Origin");
|
||||
ctr.headers.set("Access-Control-Allow-Headers", allowedHeaders);
|
||||
ctr.headers.set("Access-Control-Allow-Credentials", "true");
|
||||
return end(ctr.status(ctr.$status.NO_CONTENT).print(""));
|
||||
}
|
||||
ctr.headers.set("Access-Control-Allow-Origin", origin);
|
||||
ctr.headers.set("Vary", "Origin");
|
||||
ctr.headers.set("Access-Control-Allow-Methods", allowedMethods);
|
||||
ctr.headers.set("Access-Control-Allow-Headers", allowedHeaders);
|
||||
ctr.headers.set("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
})
|
||||
.export();
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Middleware } from "rjweb-server";
|
||||
import { createLogger } from "../logger";
|
||||
import { env } from "../env";
|
||||
import { IGNORE_PATHS, INJECT_HEADERS } from "../static";
|
||||
|
||||
const log = createLogger("HTTP");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export const mainMiddleware = new Middleware<{}, {}>("Main Middleware", "1.0.0")
|
||||
.load(() => {
|
||||
log.info("Main middleware loaded");
|
||||
})
|
||||
.httpRequest(async (_config, _server, _context, ctr) => {
|
||||
if (env.REQUEST_DEBUGGING && !IGNORE_PATHS.some((p) => ctr.url.href.startsWith(p))) {
|
||||
log.info(
|
||||
{ method: ctr.url.method, url: ctr.url.href, ip: ctr.client.ip.usual() },
|
||||
"Received request",
|
||||
);
|
||||
}
|
||||
for (const [header, value] of Object.entries(INJECT_HEADERS)) {
|
||||
ctr.headers.set(header, value);
|
||||
}
|
||||
})
|
||||
.httpRequestFinish(async (_config, _server, _context, ctr, ms) => {
|
||||
if (env.RESPONSE_DEBUGGING && !IGNORE_PATHS.some((p) => ctr.url.href.startsWith(p))) {
|
||||
log.info(
|
||||
{ method: ctr.url.method, url: ctr.url.href, duration: ms.toFixed(2) },
|
||||
"Sent response",
|
||||
);
|
||||
}
|
||||
})
|
||||
.export();
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ERROR_MESSAGES } from "./errors";
|
||||
|
||||
type ResponseContent =
|
||||
| { code: number; message?: string; data?: unknown }
|
||||
| { status: number; message?: string; data?: unknown };
|
||||
|
||||
function resolve(content: ResponseContent) {
|
||||
const code = "code" in content ? content.code : content.status;
|
||||
const message = code >= 500 ? ERROR_MESSAGES.INTERNAL_SERVER_ERROR.message : content.message;
|
||||
return { code, message };
|
||||
}
|
||||
|
||||
function buildBody(code: number, message: string | undefined, data: unknown) {
|
||||
if (code >= 400) {
|
||||
return { status: "FAILED", message };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "OK",
|
||||
...(message !== undefined ? { message } : {}),
|
||||
...(data !== undefined ? { data } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function makeResponse({ ctr, content }: { ctr: any; content: ResponseContent }) {
|
||||
const { code, message } = resolve(content);
|
||||
const data = "data" in content ? content.data : undefined;
|
||||
return ctr.status(code).print(buildBody(code, message, data));
|
||||
}
|
||||
|
||||
export async function endResponse({
|
||||
ctr,
|
||||
end,
|
||||
content,
|
||||
}: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ctr: any;
|
||||
end: () => void;
|
||||
content: ResponseContent;
|
||||
}) {
|
||||
const { code, message } = resolve(content);
|
||||
const data = "data" in content ? content.data : undefined;
|
||||
ctr.status(code).print(buildBody(code, message, data));
|
||||
end();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export const IGNORE_PATHS: string[] = ["/api/openapi.json", "/api/health", "/health"];
|
||||
|
||||
export const INJECT_HEADERS: Record<string, string> = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
};
|
||||
|
||||
// API key prefix used for all agent API keys.
|
||||
export const AGENT_KEY_PREFIX = "pp_agent_";
|
||||
|
||||
// Cookie name for human browser sessions.
|
||||
export const SESSION_COOKIE = "patchpass_session";
|
||||
|
||||
// Header agents use to present their API key.
|
||||
export const API_KEY_HEADER = "x-api-key";
|
||||
Reference in New Issue
Block a user