Patchpass V1
Deploy / Build (push) Successful in 28s
Deploy / Test & Lint (push) Failing after 29s
Deploy / Build and Push Docker Image (push) Has been skipped

This commit is contained in:
Space-Banane
2026-07-18 20:14:44 +02:00
commit 7e05dd918c
101 changed files with 15183 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
import { env } from "./lib/env"; // must be first — loads dotenv and validates
import { Server } from "rjweb-server";
import { Runtime } from "@rjweb/runtime-node";
import { existsSync, statSync } from "node:fs";
import { join, normalize } from "path";
import { prisma } from "./lib/db";
import { logger } from "./lib/logger";
import { corsMiddleware, initCorsDomains } from "./lib/middlewares/cors";
import { mainMiddleware } from "./lib/middlewares/main";
import { authResolutionMiddleware } from "./lib/middlewares/auth";
import { makeResponse } from "./lib/response";
import { ERROR_MESSAGES } from "./lib/errors";
import { startSystemCrons } from "./lib/SystemCrons";
export const VERSION = {
type: "PatchPass API" as const,
major: 1,
minor: 0,
patch: 0,
toString() {
return `${this.major}.${this.minor}.${this.patch}`;
},
};
export const UI_URL = env.UI_URL;
export const REACT_APP_API_URL = env.REACT_APP_API_URL;
export const DOMAIN = env.DOMAIN;
export const PORT = env.PORT;
export { prisma };
const CORS_DOMAINS = env.CORS_URLS.split(",").map((s) => s.trim());
CORS_DOMAINS.push(UI_URL);
CORS_DOMAINS.push(REACT_APP_API_URL.replace(/\/+$/, ""));
initCorsDomains(CORS_DOMAINS);
if (env.NODE_ENV !== "test") {
logger.info(`PatchPass API reachable on ${env.PORT}; ${env.REACT_APP_API_URL}`);
}
const uiBuildPath = join(__dirname, "../../UI/build");
const uiIndexPath = join(uiBuildPath, "index.html");
const hasUiBuild = existsSync(uiBuildPath);
const hasUiIndex = existsSync(uiIndexPath);
export const server = new Server(
Runtime,
{
port: env.PORT,
bind: "0.0.0.0",
version: false,
performance: { lastModified: false, eTag: false },
logging: { warn: true, debug: false, error: true },
},
[
corsMiddleware.use({}),
mainMiddleware.use({}),
authResolutionMiddleware.use({}),
],
);
const loader = new server.FileLoader("/");
if (env.NODE_ENV !== "test") {
loader.load("./routes", { fileBasedRouting: false });
}
export const fileRouter = loader.export();
/** Resolve a request path to a real file inside the UI build, guarding traversal. */
function resolveStaticFile(urlPath: string): string | null {
if (!hasUiBuild) return null;
const decoded = decodeURIComponent(urlPath.split("?")[0]);
const candidate = normalize(join(uiBuildPath, decoded));
if (candidate !== uiBuildPath && !candidate.startsWith(uiBuildPath)) return null; // traversal guard
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
return null;
}
server.notFound(async (ctr) => {
const path = ctr.url.path;
if (path.startsWith("/api") || path.startsWith("/v1") || path === "/mcp") {
return makeResponse({
ctr,
content: { code: ERROR_MESSAGES.NOT_FOUND.code, message: ERROR_MESSAGES.NOT_FOUND.message },
});
}
// Serve a matching build file (assets, favicons, etc.) if present.
const file = resolveStaticFile(path);
if (file) return ctr.status(200, "OK").printFile(file, { addTypes: true });
// SPA fallback — hand any other route to the React app.
if (hasUiIndex) return ctr.status(200, "OK").printFile(uiIndexPath, { addTypes: true });
return makeResponse({
ctr,
content: { code: ERROR_MESSAGES.NOT_FOUND.code, message: ERROR_MESSAGES.NOT_FOUND.message },
});
});
server.error("httpRequest", async (ctr, error) => {
logger.error(error, "Unhandled HTTP request error");
return makeResponse({ ctr, content: { code: ERROR_MESSAGES.INTERNAL_SERVER_ERROR.code } });
});
if (env.NODE_ENV !== "test") {
server
.start()
.then(async (port) => {
await prisma.$connect();
logger.info({ port }, "PatchPass API running");
startSystemCrons({ prisma });
})
.catch((err) => logger.error(err, "Server failed to start"));
}
+165
View File
@@ -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 1128 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 1128 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 });
}
+26
View File
@@ -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");
}
}
+80
View File
@@ -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;
}
+115
View File
@@ -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");
}
+448
View File
@@ -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,
});
}
+42
View File
@@ -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(),
};
}
+79
View File
@@ -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 };
}
+90
View File
@@ -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 };
}
+46
View File
@@ -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;
}
+43
View File
@@ -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 };
}
+41
View File
@@ -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);
}
+123
View File
@@ -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;
}
+30
View File
@@ -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));
}
+12
View File
@@ -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 },
});
+51
View File
@@ -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;
+9
View File
@@ -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;
+14
View File
@@ -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 });
}
+68
View File
@@ -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}`);
}
}
+74
View File
@@ -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"],
},
};
+46
View File
@@ -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;
+24
View File
@@ -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]));
+185
View File
@@ -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 (1200 chars)" },
description: { type: "string", description: "Optional context for the reviewer" },
changes: CHANGES_SCHEMA,
expires_in: {
type: "integer",
description: "Seconds until the request expires (6043200; 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);
},
};
+45
View File
@@ -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();
+66
View File
@@ -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();
+32
View File
@@ -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();
+46
View File
@@ -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();
}
+17
View File
@@ -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";
+180
View File
@@ -0,0 +1,180 @@
import { Cookie } from "rjweb-server";
import * as bcrypt from "bcryptjs";
import { DOMAIN, fileRouter, prisma } from "../..";
import { SESSION_COOKIE } from "../../lib/static";
import { cookieDomain } from "../../lib/Authentication";
import { requireSession } from "../../lib/RouteAuth";
import { clampAutoDeleteDays, MIN_AUTO_DELETE_DAYS } from "../../lib/Limits";
import { recordAudit } from "../../lib/Audit";
import { serializeAgent } from "../../lib/AgentService";
import { serializeRequest } from "../../lib/ChangeRequestService";
import { createLogger } from "../../lib/logger";
const log = createLogger("account");
export = new fileRouter.Path("/")
// ── Update profile (display name) ──────────────────────────────────────────
.http("PATCH", "/api/account/profile", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const [data, error] = await ctr.bindBody((z) =>
z.object({ display_name: z.string().min(1).max(64) }),
);
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
await prisma.user.update({
where: { id: session.user.id },
data: { displayName: data.display_name.trim() },
});
return ctr.print({ status: "OK", message: "Profile updated" });
}),
)
// ── Change password ─────────────────────────────────────────────────────────
.http("POST", "/api/account/password", (http) =>
http
.ratelimit((limit) => limit.hits(5).window(60000).penalty(3000))
.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const [data, error] = await ctr.bindBody((z) =>
z.object({
current_password: z.string().min(1),
new_password: z.string().min(8).max(120),
}),
);
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
const match = await bcrypt.compare(data.current_password, session.user.password);
if (!match) {
return ctr
.status(ctr.$status.UNAUTHORIZED)
.print({ status: "FAILED", message: "Current password is incorrect" });
}
const hash = await bcrypt.hash(data.new_password, 10);
await prisma.user.update({ where: { id: session.user.id }, data: { password: hash } });
await recordAudit({ actorId: session.user.id, action: "password_changed" });
return ctr.print({ status: "OK", message: "Password changed" });
}),
)
// ── Auto-delete settings ────────────────────────────────────────────────────
.http("PATCH", "/api/account/settings", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const [data, error] = await ctr.bindBody((z) =>
z.object({
auto_delete_enabled: z.boolean().optional(),
auto_delete_days: z.number().int().min(MIN_AUTO_DELETE_DAYS).max(3650).optional(),
}),
);
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
const updated = await prisma.user.update({
where: { id: session.user.id },
data: {
...(data.auto_delete_enabled !== undefined
? { autoDeleteEnabled: data.auto_delete_enabled }
: {}),
...(data.auto_delete_days !== undefined
? { autoDeleteDays: clampAutoDeleteDays(data.auto_delete_days) }
: {}),
},
});
return ctr.print({
status: "OK",
data: {
auto_delete_enabled: updated.autoDeleteEnabled,
auto_delete_days: updated.autoDeleteDays,
},
});
}),
)
// ── GDPR: export all data ────────────────────────────────────────────────────
.http("GET", "/api/account/export", (http) =>
http
.ratelimit((limit) => limit.hits(3).window(60000).penalty(5000))
.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: {
agents: true,
changeRequests: { include: { agent: true } },
notifications: true,
},
});
if (!user) return;
const exportData = {
exported_at: new Date().toISOString(),
account: {
id: user.id,
username: user.username,
display_name: user.displayName,
role: user.role,
two_factor_enabled: user.totpEnabled,
auto_delete_enabled: user.autoDeleteEnabled,
auto_delete_days: user.autoDeleteDays,
created_at: user.createdAt.toISOString(),
},
agents: user.agents.map((a) => serializeAgent(a)),
change_requests: user.changeRequests.map((r) => serializeRequest(r)),
notifications: user.notifications.map((n) => ({
id: n.id,
type: n.type,
title: n.title,
message: n.message,
request_id: n.requestPublicId,
read: n.read,
created_at: n.createdAt.toISOString(),
})),
};
await recordAudit({ actorId: user.id, action: "data_exported" });
ctr.headers.set("Content-Type", "application/json");
ctr.headers.set(
"Content-Disposition",
`attachment; filename="patchpass-export-${user.username}.json"`,
);
return ctr.print(exportData);
}),
)
// ── GDPR: delete account (cascades to agents + requests + notifications) ──────
.http("DELETE", "/api/account", (http) =>
http
.ratelimit((limit) => limit.hits(3).window(60000).penalty(5000))
.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const [data, error] = await ctr.bindBody((z) =>
z.object({ password: z.string().min(1), confirm: z.literal("DELETE") }),
);
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
const match = await bcrypt.compare(data.password, session.user.password);
if (!match) {
return ctr
.status(ctr.$status.UNAUTHORIZED)
.print({ status: "FAILED", message: "Password is incorrect" });
}
// Guard: don't let the last remaining admin delete the platform's only admin.
if (session.user.role === "ADMIN") {
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
if (adminCount <= 1) {
return ctr.status(ctr.$status.CONFLICT).print({
status: "FAILED",
message:
"You are the only administrator. Promote another admin before deleting your account.",
});
}
}
await prisma.user.delete({ where: { id: session.user.id } });
log.info({ userId: session.user.id }, "Account deleted (GDPR)");
ctr.cookies.set(
SESSION_COOKIE,
new Cookie("", { domain: cookieDomain(DOMAIN), path: "/", expires: new Date(0) }),
);
return ctr.print({ status: "OK", message: "Your account and all data have been deleted" });
}),
);
+313
View File
@@ -0,0 +1,313 @@
import { fileRouter, prisma } from "../..";
import { requireAdmin } from "../../lib/RouteAuth";
import { getGlobalSettings, setRegistrationEnabled, setRequestsEnabled } from "../../lib/DataManager";
import { recordAudit } from "../../lib/Audit";
import { createNotification } from "../../lib/Notifications";
import { serializeAgent } from "../../lib/AgentService";
import { serializeRequest } from "../../lib/ChangeRequestService";
import { createLogger } from "../../lib/logger";
/* eslint-disable @typescript-eslint/no-explicit-any */
const log = createLogger("admin");
export = new fileRouter.Path("/")
// ── Users ────────────────────────────────────────────────────────────────
.http("GET", "/api/admin/users", (http) =>
http.onRequest(async (ctr) => {
const admin = requireAdmin(ctr);
if (!admin) return;
const users = await prisma.user.findMany({
orderBy: { createdAt: "asc" },
select: {
id: true,
username: true,
displayName: true,
role: true,
disabled: true,
totpEnabled: true,
createdAt: true,
_count: { select: { agents: true, changeRequests: true } },
},
});
return ctr.print({
status: "OK",
data: users.map((u) => ({
id: u.id,
username: u.username,
display_name: u.displayName,
role: u.role,
disabled: u.disabled,
totp_enabled: u.totpEnabled,
created_at: u.createdAt.toISOString(),
agent_count: u._count.agents,
request_count: u._count.changeRequests,
})),
});
}),
)
// Update a user: role and/or disabled.
.http("PATCH", "/api/admin/users/{id}", (http) =>
http.onRequest(async (ctr) => {
const admin = requireAdmin(ctr);
if (!admin) return;
const id = Number(ctr.params.get("id"));
if (Number.isNaN(id)) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ status: "FAILED", message: "Invalid id" });
}
const [data, error] = await ctr.bindBody((z) =>
z.object({
role: z.enum(["ADMIN", "USER"]).optional(),
disabled: z.boolean().optional(),
}),
);
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
const target = await prisma.user.findUnique({ where: { id } });
if (!target) {
return ctr.status(ctr.$status.NOT_FOUND).print({ status: "FAILED", message: "User not found" });
}
// Don't allow removing the last admin.
if (data.role === "USER" && target.role === "ADMIN") {
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
if (adminCount <= 1) {
return ctr
.status(ctr.$status.CONFLICT)
.print({ status: "FAILED", message: "Cannot demote the last administrator" });
}
}
const updated = await prisma.user.update({
where: { id },
data: {
...(data.role !== undefined ? { role: data.role } : {}),
...(data.disabled !== undefined ? { disabled: data.disabled } : {}),
},
});
await recordAudit({
actorId: admin.user.id,
action: "admin_user_updated",
targetType: "user",
targetId: String(id),
detail: JSON.stringify(data),
});
if (data.disabled !== undefined || data.role !== undefined) {
await createNotification({
userId: id,
type: "ADMIN_ACTION",
title: "Account changed by administrator",
message:
data.disabled === true
? "An administrator disabled your account"
: data.disabled === false
? "An administrator re-enabled your account"
: `An administrator set your role to ${data.role}`,
});
}
return ctr.print({
status: "OK",
data: { id: updated.id, role: updated.role, disabled: updated.disabled },
});
}),
)
// Delete a user (cascades).
.http("DELETE", "/api/admin/users/{id}", (http) =>
http.onRequest(async (ctr) => {
const admin = requireAdmin(ctr);
if (!admin) return;
const id = Number(ctr.params.get("id"));
if (Number.isNaN(id)) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ status: "FAILED", message: "Invalid id" });
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) {
return ctr.status(ctr.$status.NOT_FOUND).print({ status: "FAILED", message: "User not found" });
}
if (target.role === "ADMIN") {
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
if (adminCount <= 1) {
return ctr
.status(ctr.$status.CONFLICT)
.print({ status: "FAILED", message: "Cannot delete the last administrator" });
}
}
await prisma.user.delete({ where: { id } });
await recordAudit({
actorId: admin.user.id,
action: "admin_user_deleted",
targetType: "user",
targetId: String(id),
});
log.info({ adminId: admin.user.id, deletedUserId: id }, "Admin deleted user");
return ctr.print({ status: "OK", message: "User deleted" });
}),
)
// ── Global settings ──────────────────────────────────────────────────────
.http("GET", "/api/admin/settings", (http) =>
http.onRequest(async (ctr) => {
const admin = requireAdmin(ctr);
if (!admin) return;
return ctr.print({ status: "OK", data: await getGlobalSettings() });
}),
)
.http("PATCH", "/api/admin/settings", (http) =>
http.onRequest(async (ctr) => {
const admin = requireAdmin(ctr);
if (!admin) return;
const [data, error] = await ctr.bindBody((z) =>
z.object({
registration_enabled: z.boolean().optional(),
requests_enabled: z.boolean().optional(),
}),
);
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
if (data.registration_enabled !== undefined) await setRegistrationEnabled(data.registration_enabled);
if (data.requests_enabled !== undefined) await setRequestsEnabled(data.requests_enabled);
await recordAudit({
actorId: admin.user.id,
action: "admin_settings_updated",
detail: JSON.stringify(data),
});
return ctr.print({ status: "OK", data: await getGlobalSettings() });
}),
)
// ── Agents (platform-wide) ────────────────────────────────────────────────
.http("GET", "/api/admin/agents", (http) =>
http.onRequest(async (ctr) => {
const admin = requireAdmin(ctr);
if (!admin) return;
const agents = await prisma.agent.findMany({
orderBy: { createdAt: "asc" },
include: { owner: { select: { username: true, id: true } } },
});
return ctr.print({
status: "OK",
data: agents.map((a) => ({
...serializeAgent(a),
owner: { id: a.owner.id, username: a.owner.username },
})),
});
}),
)
.http("POST", "/api/admin/agents/{id}/disabled", (http) =>
http.onRequest(async (ctr) => {
const admin = requireAdmin(ctr);
if (!admin) return;
const id = Number(ctr.params.get("id"));
if (Number.isNaN(id)) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ status: "FAILED", message: "Invalid id" });
}
const [data, error] = await ctr.bindBody((z) => z.object({ disabled: z.boolean() }));
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
const agent = await prisma.agent.findUnique({ where: { id } });
if (!agent) {
return ctr.status(ctr.$status.NOT_FOUND).print({ status: "FAILED", message: "Agent not found" });
}
await prisma.agent.update({ where: { id }, data: { disabled: data.disabled } });
await recordAudit({
actorId: admin.user.id,
action: "admin_agent_disabled",
targetType: "agent",
targetId: String(id),
detail: JSON.stringify(data),
});
await createNotification({
userId: agent.ownerId,
type: "AGENT_DISABLED",
title: data.disabled ? "Agent disabled by administrator" : "Agent re-enabled by administrator",
message: `An administrator ${data.disabled ? "disabled" : "re-enabled"} your agent "${agent.name}"`,
});
return ctr.print({ status: "OK" });
}),
)
.http("DELETE", "/api/admin/agents/{id}", (http) =>
http.onRequest(async (ctr) => {
const admin = requireAdmin(ctr);
if (!admin) return;
const id = Number(ctr.params.get("id"));
if (Number.isNaN(id)) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ status: "FAILED", message: "Invalid id" });
}
const agent = await prisma.agent.findUnique({ where: { id } });
if (!agent) {
return ctr.status(ctr.$status.NOT_FOUND).print({ status: "FAILED", message: "Agent not found" });
}
await prisma.agent.delete({ where: { id } });
await recordAudit({
actorId: admin.user.id,
action: "admin_agent_deleted",
targetType: "agent",
targetId: String(id),
});
return ctr.print({ status: "OK", message: "Agent deleted" });
}),
)
// ── Audit logs (paginated) ────────────────────────────────────────────────
.http("GET", "/api/admin/audit-logs", (http) =>
http.onRequest(async (ctr) => {
const admin = requireAdmin(ctr);
if (!admin) return;
const page = Math.max(1, Number(ctr.queries.get("page") ?? "1") || 1);
const pageSize = Math.min(100, Math.max(1, Number(ctr.queries.get("page_size") ?? "30") || 30));
const [total, rows] = await Promise.all([
prisma.auditLog.count(),
prisma.auditLog.findMany({
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
include: { actor: { select: { username: true, id: true } } },
}),
]);
return ctr.print({
status: "OK",
data: {
page,
page_size: pageSize,
total,
logs: rows.map((l) => ({
id: l.id,
action: l.action,
detail: l.detail,
target_type: l.targetType,
target_id: l.targetId,
actor: l.actor ? { id: l.actor.id, username: l.actor.username } : null,
created_at: l.createdAt.toISOString(),
})),
},
});
}),
)
// ── View a user's request payload (explicit + audited) ────────────────────
.http("GET", "/api/admin/change-requests/{id}", (http) =>
http.onRequest(async (ctr) => {
const admin = requireAdmin(ctr);
if (!admin) return;
const publicId = ctr.params.get("id");
if (!publicId) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ status: "FAILED", message: "Missing id" });
}
const request = await prisma.changeRequest.findUnique({
where: { publicId },
include: { agent: true, user: { select: { id: true, username: true } } },
});
if (!request) {
return ctr.status(ctr.$status.NOT_FOUND).print({ status: "FAILED", message: "Not found" });
}
// Viewing another user's payload is a privileged action — always audited.
await recordAudit({
actorId: admin.user.id,
action: "admin_viewed_request_payload",
targetType: "change_request",
targetId: publicId,
detail: `Owner user ${request.user.id} (${request.user.username})`,
});
return ctr.print({
status: "OK",
data: {
...serializeRequest(request),
owner: { id: request.user.id, username: request.user.username },
agent: request.agent ? { id: request.agent.id, name: request.agent.name } : null,
},
});
}),
);
+139
View File
@@ -0,0 +1,139 @@
import { fileRouter } from "../..";
import { requireSession } from "../../lib/RouteAuth";
import {
createAgent,
deleteAgent,
listAgents,
regenerateApiKey,
setAgentDisabled,
updateAgent,
} from "../../lib/AgentService";
import { recordAudit } from "../../lib/Audit";
/* eslint-disable @typescript-eslint/no-explicit-any */
function reply(ctr: any, result: { ok: boolean; status?: number; message?: string; data?: unknown }) {
if (!result.ok) {
return ctr.status(result.status ?? 400).print({ status: "FAILED", message: result.message });
}
return ctr.print({ status: "OK", data: (result as any).data });
}
export = new fileRouter.Path("/")
// List agents
.http("GET", "/api/agents", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const agents = await listAgents(session.user);
return ctr.print({ status: "OK", data: agents });
}),
)
// Create agent
.http("POST", "/api/agents", (http) =>
http
.ratelimit((limit) => limit.hits(10).window(60000).penalty(2000))
.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const [data, error] = await ctr.bindBody((z) =>
z.object({
name: z.string().min(1).max(128),
description: z.string().max(500).nullable().optional(),
website: z.string().max(300).nullable().optional(),
icon_url: z.string().max(1000).nullable().optional(),
max_pending_requests: z.number().int().min(1).max(10).optional(),
}),
);
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
const result = await createAgent(session.user, data);
if (result.ok) {
await recordAudit({
actorId: session.user.id,
action: "agent_created",
targetType: "agent",
targetId: String((result.data as any).id),
});
}
return reply(ctr, result);
}),
)
// Update agent
.http("PATCH", "/api/agents/{id}", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const id = Number(ctr.params.get("id"));
if (Number.isNaN(id)) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ status: "FAILED", message: "Invalid agent id" });
}
const [data, error] = await ctr.bindBody((z) =>
z.object({
name: z.string().min(1).max(128).optional(),
description: z.string().max(500).nullable().optional(),
website: z.string().max(300).nullable().optional(),
icon_url: z.string().max(1000).nullable().optional(),
max_pending_requests: z.number().int().min(1).max(10).optional(),
}),
);
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
return reply(ctr, await updateAgent(session.user, id, data));
}),
)
// Regenerate API key
.http("POST", "/api/agents/{id}/regenerate-key", (http) =>
http
.ratelimit((limit) => limit.hits(10).window(60000).penalty(2000))
.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const id = Number(ctr.params.get("id"));
if (Number.isNaN(id)) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ status: "FAILED", message: "Invalid agent id" });
}
const result = await regenerateApiKey(session.user, id);
if (result.ok) {
await recordAudit({
actorId: session.user.id,
action: "agent_key_regenerated",
targetType: "agent",
targetId: String(id),
});
}
return reply(ctr, result);
}),
)
// Enable / disable agent
.http("POST", "/api/agents/{id}/disabled", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const id = Number(ctr.params.get("id"));
if (Number.isNaN(id)) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ status: "FAILED", message: "Invalid agent id" });
}
const [data, error] = await ctr.bindBody((z) => z.object({ disabled: z.boolean() }));
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
return reply(ctr, await setAgentDisabled(session.user, id, data.disabled));
}),
)
// Delete agent
.http("DELETE", "/api/agents/{id}", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const id = Number(ctr.params.get("id"));
if (Number.isNaN(id)) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ status: "FAILED", message: "Invalid agent id" });
}
const result = await deleteAgent(session.user, id);
if (result.ok) {
await recordAudit({
actorId: session.user.id,
action: "agent_deleted",
targetType: "agent",
targetId: String(id),
});
}
return reply(ctr, result);
}),
);
+242
View File
@@ -0,0 +1,242 @@
import { Cookie } from "rjweb-server";
import * as bcrypt from "bcryptjs";
import { authenticator } from "otplib";
import { DOMAIN, fileRouter, prisma } from "../..";
import { SESSION_COOKIE } from "../../lib/static";
import {
cookieDomain,
generateSessionHash,
normalizeUsername,
} from "../../lib/Authentication";
import { getRegistrationEnabled } from "../../lib/DataManager";
import { requireSession } from "../../lib/RouteAuth";
import { recordAudit } from "../../lib/Audit";
import { createLogger } from "../../lib/logger";
const log = createLogger("auth");
function setSessionCookie(ctr: any, hash: string) {
ctr.cookies.set(
SESSION_COOKIE,
new Cookie(hash, {
domain: cookieDomain(DOMAIN),
httpOnly: true,
path: "/",
sameSite: "lax",
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), // 30 days
}),
);
}
function publicUser(user: {
id: number;
username: string;
displayName: string;
role: string;
totpEnabled: boolean;
autoDeleteEnabled: boolean;
autoDeleteDays: number;
}) {
return {
id: user.id,
username: user.username,
display_name: user.displayName,
role: user.role,
totp_enabled: user.totpEnabled,
auto_delete_enabled: user.autoDeleteEnabled,
auto_delete_days: user.autoDeleteDays,
};
}
export = new fileRouter.Path("/")
// ── Register ──────────────────────────────────────────────────────────────
.http("POST", "/api/auth/register", (http) =>
http
.ratelimit((limit) => limit.hits(5).window(60000).penalty(2000))
.onRequest(async (ctr) => {
const [data, error] = await ctr.bindBody((z) =>
z.object({
username: z.string().min(3).max(32),
display_name: z.string().min(1).max(64).optional(),
password: z.string().min(8).max(120),
}),
);
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
if (!(await getRegistrationEnabled())) {
// First user always allowed so the platform can be bootstrapped.
const userCount = await prisma.user.count();
if (userCount > 0) {
return ctr
.status(ctr.$status.FORBIDDEN)
.print({ status: "FAILED", message: "Registration is disabled" });
}
}
const username = normalizeUsername(data.username);
if (!/^[a-z0-9_.-]+$/.test(username)) {
return ctr.status(ctr.$status.BAD_REQUEST).print({
status: "FAILED",
message: "Username may only contain letters, numbers, dots, dashes, underscores",
});
}
const existing = await prisma.user.findUnique({ where: { username } });
if (existing) {
return ctr
.status(ctr.$status.CONFLICT)
.print({ status: "FAILED", message: "Username already taken" });
}
const isFirstUser = (await prisma.user.count()) === 0;
const passwordHash = await bcrypt.hash(data.password, 10);
const hash = generateSessionHash(username);
const user = await prisma.user.create({
data: {
username,
displayName: (data.display_name || data.username).trim(),
password: passwordHash,
role: isFirstUser ? "ADMIN" : "USER",
sessions: { create: { hash } },
},
});
setSessionCookie(ctr, hash);
log.info({ userId: user.id, admin: isFirstUser }, "User registered");
return ctr.print({ status: "OK", message: "Welcome to PatchPass!", data: publicUser(user) });
}),
)
// ── Login ─────────────────────────────────────────────────────────────────
.http("POST", "/api/auth/login", (http) =>
http
.ratelimit((limit) => limit.hits(6).window(10000).penalty(0))
.onRequest(async (ctr) => {
const [data, error] = await ctr.bindBody((z) =>
z.object({
username: z.string().min(1).max(64),
password: z.string().min(1).max(120),
totp: z.string().min(6).max(6).optional(),
}),
);
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
const username = normalizeUsername(data.username);
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
return ctr
.status(ctr.$status.UNAUTHORIZED)
.print({ status: "FAILED", message: "Invalid credentials" });
}
if (user.disabled) {
return ctr
.status(ctr.$status.FORBIDDEN)
.print({ status: "FAILED", message: "This account has been disabled" });
}
const match = await bcrypt.compare(data.password, user.password);
if (!match) {
return ctr
.status(ctr.$status.UNAUTHORIZED)
.print({ status: "FAILED", message: "Invalid credentials" });
}
if (user.totpEnabled && user.totpSecret) {
if (!data.totp) {
return ctr
.status(ctr.$status.UNAUTHORIZED)
.print({ status: "FAILED", message: "2FA code required", data: { totp_required: true } });
}
const valid = authenticator.verify({ token: data.totp, secret: user.totpSecret });
if (!valid) {
return ctr
.status(ctr.$status.UNAUTHORIZED)
.print({ status: "FAILED", message: "Invalid 2FA code" });
}
}
const hash = generateSessionHash(username);
await prisma.session.create({ data: { userId: user.id, hash } });
setSessionCookie(ctr, hash);
return ctr.print({ status: "OK", message: "Welcome back!", data: publicUser(user) });
}),
)
// ── Logout ────────────────────────────────────────────────────────────────
.http("POST", "/api/auth/logout", (http) =>
http.onRequest(async (ctr) => {
const hash = ctr.cookies.get(SESSION_COOKIE);
if (hash) {
await prisma.session.deleteMany({ where: { hash } });
}
ctr.cookies.set(
SESSION_COOKIE,
new Cookie("", { domain: cookieDomain(DOMAIN), path: "/", expires: new Date(0) }),
);
return ctr.print({ status: "OK", message: "Logged out" });
}),
)
// ── Current user ──────────────────────────────────────────────────────────
.http("GET", "/api/auth/me", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
return ctr.print({ status: "OK", data: publicUser(session.user) });
}),
)
// ── 2FA: begin setup (returns secret + otpauth URL) ─────────────────────────
.http("POST", "/api/auth/2fa/setup", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
if (session.user.totpEnabled) {
return ctr
.status(ctr.$status.BAD_REQUEST)
.print({ status: "FAILED", message: "2FA is already enabled" });
}
const secret = authenticator.generateSecret();
await prisma.user.update({ where: { id: session.user.id }, data: { totpSecret: secret } });
const otpauth = authenticator.keyuri(session.user.username, "PatchPass", secret);
return ctr.print({ status: "OK", data: { secret, otpauth_url: otpauth } });
}),
)
// ── 2FA: confirm & enable ───────────────────────────────────────────────────
.http("POST", "/api/auth/2fa/enable", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const [data, error] = await ctr.bindBody((z) => z.object({ totp: z.string().min(6).max(6) }));
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
const fresh = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!fresh?.totpSecret) {
return ctr
.status(ctr.$status.BAD_REQUEST)
.print({ status: "FAILED", message: "Start 2FA setup first" });
}
if (!authenticator.verify({ token: data.totp, secret: fresh.totpSecret })) {
return ctr.status(ctr.$status.UNAUTHORIZED).print({ status: "FAILED", message: "Invalid code" });
}
await prisma.user.update({ where: { id: fresh.id }, data: { totpEnabled: true } });
await recordAudit({ actorId: fresh.id, action: "2fa_enabled" });
return ctr.print({ status: "OK", message: "Two-factor authentication enabled" });
}),
)
// ── 2FA: disable ────────────────────────────────────────────────────────────
.http("POST", "/api/auth/2fa/disable", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const [data, error] = await ctr.bindBody((z) => z.object({ password: z.string().min(1) }));
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
const match = await bcrypt.compare(data.password, session.user.password);
if (!match) {
return ctr.status(ctr.$status.UNAUTHORIZED).print({ status: "FAILED", message: "Invalid password" });
}
await prisma.user.update({
where: { id: session.user.id },
data: { totpEnabled: false, totpSecret: null },
});
await recordAudit({ actorId: session.user.id, action: "2fa_disabled" });
return ctr.print({ status: "OK", message: "Two-factor authentication disabled" });
}),
);
+143
View File
@@ -0,0 +1,143 @@
import { fileRouter, prisma } from "../..";
import { requireSession } from "../../lib/RouteAuth";
import {
DecisionKind,
decideChangeRequest,
materializeExpiry,
serializeRequest,
} from "../../lib/ChangeRequestService";
/* eslint-disable @typescript-eslint/no-explicit-any */
function withAgent(request: any, agent: any) {
return {
...serializeRequest(request),
agent: agent
? {
id: agent.id,
name: agent.name,
description: agent.description,
website: agent.website,
icon_url: agent.iconUrl,
disabled: agent.disabled,
}
: null,
};
}
export = new fileRouter.Path("/")
// List / history of requests owned by the human (paginated, filterable).
.http("GET", "/api/change-requests", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const page = Math.max(1, Number(ctr.queries.get("page") ?? "1") || 1);
const pageSize = Math.min(100, Math.max(1, Number(ctr.queries.get("page_size") ?? "20") || 20));
const stateFilter = ctr.queries.get("state");
const agentFilter = ctr.queries.get("agent_id");
const validStates = [
"PENDING",
"CHANGES_REQUESTED",
"APPROVED",
"REJECTED",
"EXPIRED",
"CONSUMED",
"CANCELLED",
];
const where: any = { userId: session.user.id };
if (stateFilter && validStates.includes(stateFilter)) where.state = stateFilter;
if (agentFilter && !Number.isNaN(Number(agentFilter))) where.agentId = Number(agentFilter);
const [total, rows] = await Promise.all([
prisma.changeRequest.count({ where }),
prisma.changeRequest.findMany({
where,
include: { agent: true },
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
// Materialize expiry for any rows past their deadline so the list is accurate.
const materialized = await Promise.all(rows.map((r) => materializeExpiry(r)));
return ctr.print({
status: "OK",
data: {
page,
page_size: pageSize,
total,
requests: materialized.map((r, i) => withAgent(r, rows[i].agent)),
},
});
}),
)
// Pending count summary (for dashboard badges).
.http("GET", "/api/change-requests/summary", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const grouped = await prisma.changeRequest.groupBy({
by: ["state"],
where: { userId: session.user.id },
_count: { _all: true },
});
const counts: Record<string, number> = {};
for (const g of grouped) counts[g.state] = g._count._all;
return ctr.print({ status: "OK", data: { counts } });
}),
)
// Get a single request (full detail).
.http("GET", "/api/change-requests/{id}", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const publicId = ctr.params.get("id");
if (!publicId) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ status: "FAILED", message: "Missing id" });
}
const existing = await prisma.changeRequest.findUnique({
where: { publicId },
include: { agent: true },
});
if (!existing || existing.userId !== session.user.id) {
return ctr.status(ctr.$status.NOT_FOUND).print({ status: "FAILED", message: "Not found" });
}
const request = await materializeExpiry(existing);
return ctr.print({ status: "OK", data: withAgent(request, existing.agent) });
}),
)
// Decide a request: approve / reject / request_changes.
.http("POST", "/api/change-requests/{id}/decision", (http) =>
http
.ratelimit((limit) => limit.hits(30).window(60000).penalty(1000))
.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const publicId = ctr.params.get("id");
if (!publicId) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ status: "FAILED", message: "Missing id" });
}
const [data, error] = await ctr.bindBody((z) =>
z.object({
decision: z.enum(["APPROVE", "REJECT", "REQUEST_CHANGES"]),
comment: z.string().max(500).nullable().optional(),
}),
);
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
const result = await decideChangeRequest(
session.user,
publicId,
data.decision as DecisionKind,
data.comment,
);
if (!result.ok) {
return ctr.status(result.status).print({ status: "FAILED", message: result.message });
}
return ctr.print({ status: "OK", data: result.data });
}),
);
+13
View File
@@ -0,0 +1,13 @@
import { fileRouter, VERSION } from "../..";
import { getGlobalSettings } from "../../lib/DataManager";
// Public, unauthenticated: lets the UI know whether registration is open etc.
export = new fileRouter.Path("/").http("GET", "/api/global", (http) =>
http.onRequest(async (ctr) => {
const settings = await getGlobalSettings();
return ctr.print({
status: "OK",
data: { version: VERSION.toString(), ...settings },
});
}),
);
+80
View File
@@ -0,0 +1,80 @@
import { fileRouter, prisma } from "../..";
import { requireSession } from "../../lib/RouteAuth";
/* eslint-disable @typescript-eslint/no-explicit-any */
function serialize(n: any) {
return {
id: n.id,
type: n.type,
title: n.title,
message: n.message,
request_id: n.requestPublicId,
read: n.read,
created_at: n.createdAt.toISOString(),
};
}
export = new fileRouter.Path("/")
// List notifications (paginated) + unread count.
.http("GET", "/api/notifications", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const page = Math.max(1, Number(ctr.queries.get("page") ?? "1") || 1);
const pageSize = Math.min(100, Math.max(1, Number(ctr.queries.get("page_size") ?? "30") || 30));
const unreadOnly = ctr.queries.get("unread") === "true";
const where: any = { userId: session.user.id, ...(unreadOnly ? { read: false } : {}) };
const [total, rows, unreadCount] = await Promise.all([
prisma.notification.count({ where }),
prisma.notification.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.notification.count({ where: { userId: session.user.id, read: false } }),
]);
return ctr.print({
status: "OK",
data: { page, page_size: pageSize, total, unread_count: unreadCount, notifications: rows.map(serialize) },
});
}),
)
// Mark one notification read.
.http("POST", "/api/notifications/{id}/read", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
const id = Number(ctr.params.get("id"));
if (Number.isNaN(id)) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ status: "FAILED", message: "Invalid id" });
}
await prisma.notification.updateMany({
where: { id, userId: session.user.id },
data: { read: true },
});
return ctr.print({ status: "OK" });
}),
)
// Mark all read.
.http("POST", "/api/notifications/read-all", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
await prisma.notification.updateMany({
where: { userId: session.user.id, read: false },
data: { read: true },
});
return ctr.print({ status: "OK" });
}),
)
// Clear (delete) all notifications.
.http("DELETE", "/api/notifications", (http) =>
http.onRequest(async (ctr) => {
const session = requireSession(ctr);
if (!session) return;
await prisma.notification.deleteMany({ where: { userId: session.user.id } });
return ctr.print({ status: "OK", message: "Notifications cleared" });
}),
);
+13
View File
@@ -0,0 +1,13 @@
import { fileRouter, VERSION } from "..";
export = new fileRouter.Path("/")
.http("GET", "/api/health", (http) =>
http.onRequest(async (ctr) => {
return ctr.print({ status: "OK", service: "PatchPass", version: VERSION.toString() });
}),
)
.http("GET", "/health", (http) =>
http.onRequest(async (ctr) => {
return ctr.print({ status: "OK" });
}),
);
+69
View File
@@ -0,0 +1,69 @@
import { fileRouter } from "..";
import { API_KEY_HEADER } from "../lib/static";
import { checkAuthentication } from "../lib/Authentication";
import { handleMcpRequest, McpRequest } from "../lib/mcp/mcp";
import { tools } from "../lib/mcp/tools";
/* eslint-disable @typescript-eslint/no-explicit-any */
export = new fileRouter.Path("/")
// Discovery — unauthenticated metadata about the MCP server.
.http("GET", "/mcp", (http) =>
http
.ratelimit((limit) => limit.hits(20).window(60000).penalty(2000))
.onRequest(async (ctr) => {
return ctr.print({
name: "patchpass",
version: "1.0.0",
description: "PatchPass MCP Server — request human approval for agent actions",
transport: "streamable-http",
authentication: { header: API_KEY_HEADER, type: "agent-api-key" },
tools: tools.map(({ name, description }) => ({ name, description })),
});
}),
)
// JSON-RPC endpoint — authenticated with the agent API key.
.http("POST", "/mcp", (http) =>
http
.ratelimit((limit) => limit.hits(120).window(60000).penalty(5000))
.onRequest(async (ctr) => {
const apiKey = ctr.headers.get(API_KEY_HEADER);
if (!apiKey) {
return ctr.status(ctr.$status.UNAUTHORIZED).print({
jsonrpc: "2.0",
id: null,
error: { code: -32001, message: `Authentication required: provide ${API_KEY_HEADER} header` },
});
}
const auth = await checkAuthentication(null, apiKey);
if (!auth.success || auth.method !== "agent") {
return ctr.status(ctr.$status.UNAUTHORIZED).print({
jsonrpc: "2.0",
id: null,
error: { code: -32001, message: auth.success ? "Agent key required" : auth.message },
});
}
const [body, bindErr] = await ctr.bindBody((z: any) =>
z.object({
jsonrpc: z.literal("2.0"),
id: z.union([z.string(), z.number(), z.null()]).optional(),
method: z.string(),
params: z.any().optional(),
}),
);
if (!body) {
return ctr.status(ctr.$status.BAD_REQUEST).print({
jsonrpc: "2.0",
id: null,
error: { code: -32700, message: `Parse error: ${bindErr}` },
});
}
const { status, body: responseBody } = await handleMcpRequest(body as McpRequest, {
agent: auth.agent,
});
return ctr.status(status).print(responseBody as any);
}),
);
+130
View File
@@ -0,0 +1,130 @@
import { fileRouter } from "../..";
import { requireAgent } from "../../lib/RouteAuth";
import {
cancelChangeRequest,
consumeApproval,
createChangeRequest,
getChangeRequestForAgent,
listChangeRequestsForAgent,
updateChangeRequest,
} from "../../lib/ChangeRequestService";
/* eslint-disable @typescript-eslint/no-explicit-any */
const changeSchema = (z: any) =>
z.discriminatedUnion("type", [
z.object({
type: z.literal("unified_diff"),
path: z.string().min(1).max(1024),
content: z.string().min(1).max(500_000),
}),
z.object({
type: z.literal("config"),
path: z.string().min(1).max(1024),
before: z.any().optional(),
after: z.any().optional(),
content_type: z.string().max(64).optional(),
}),
z.object({
type: z.literal("custom"),
label: z.string().min(1).max(256),
before: z.any().optional(),
after: z.any().optional(),
}),
]);
const createSchema = (z: any) =>
z.object({
title: z.string().min(1).max(200),
description: z.string().max(5000).nullable().optional(),
changes: z.array(changeSchema(z)).min(1).max(100),
expires_in: z.number().int().min(60).max(43200).nullable().optional(),
metadata: z.record(z.string(), z.any()).nullable().optional(),
});
function reply(ctr: any, result: { ok: boolean; status?: number; message?: string; data?: unknown }) {
if (!result.ok) {
return ctr.status(result.status ?? 400).print({ status: "FAILED", message: result.message });
}
return ctr.print({ status: "OK", data: (result as any).data });
}
function requestId(ctr: any): string | null {
const id = ctr.params.get("id");
if (!id) {
ctr.status(ctr.$status.BAD_REQUEST).print({ status: "FAILED", message: "Missing request id" });
return null;
}
return id;
}
export = new fileRouter.Path("/")
// Submit a change request.
.http("POST", "/v1/change-requests", (http) =>
http
.ratelimit((limit) => limit.hits(30).window(60000).penalty(2000))
.onRequest(async (ctr) => {
const auth = requireAgent(ctr);
if (!auth) return;
const [data, error] = await ctr.bindBody(createSchema);
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(String(error));
return reply(ctr, await createChangeRequest(auth.agent, data as any));
}),
)
// List this agent's requests.
.http("GET", "/v1/change-requests", (http) =>
http.onRequest(async (ctr) => {
const auth = requireAgent(ctr);
if (!auth) return;
const data = await listChangeRequestsForAgent(auth.agent, {
state: ctr.queries.get("state") ?? undefined,
page: Number(ctr.queries.get("page") ?? "1") || 1,
pageSize: Number(ctr.queries.get("page_size") ?? "20") || 20,
});
return ctr.print({ status: "OK", data });
}),
)
// Get a single request + receipt.
.http("GET", "/v1/change-requests/{id}", (http) =>
http.onRequest(async (ctr) => {
const auth = requireAgent(ctr);
if (!auth) return;
const id = requestId(ctr);
if (!id) return;
return reply(ctr, await getChangeRequestForAgent(auth.agent, id));
}),
)
// Update a request (only while PENDING or CHANGES_REQUESTED).
.http("PATCH", "/v1/change-requests/{id}", (http) =>
http
.ratelimit((limit) => limit.hits(30).window(60000).penalty(2000))
.onRequest(async (ctr) => {
const auth = requireAgent(ctr);
if (!auth) return;
const id = requestId(ctr);
if (!id) return;
const [data, error] = await ctr.bindBody(createSchema);
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print(String(error));
return reply(ctr, await updateChangeRequest(auth.agent, id, data as any));
}),
)
// Cancel a request before a decision.
.http("POST", "/v1/change-requests/{id}/cancel", (http) =>
http.onRequest(async (ctr) => {
const auth = requireAgent(ctr);
if (!auth) return;
const id = requestId(ctr);
if (!id) return;
return reply(ctr, await cancelChangeRequest(auth.agent, id));
}),
)
// Consume an approval (single-use).
.http("POST", "/v1/change-requests/{id}/consume", (http) =>
http.onRequest(async (ctr) => {
const auth = requireAgent(ctr);
if (!auth) return;
const id = requestId(ctr);
if (!id) return;
return reply(ctr, await consumeApproval(auth.agent, id));
}),
);
+32
View File
@@ -0,0 +1,32 @@
import { fileRouter } from "..";
import { checkAuthentication } from "../lib/Authentication";
import { SESSION_COOKIE } from "../lib/static";
import { getUserChannel } from "../lib/WsHub";
import { createLogger } from "../lib/logger";
const log = createLogger("WS");
/**
* Realtime notifications socket. The browser connects with its session cookie;
* on open we resolve the session and subscribe the socket to that user's channel.
* Unauthenticated sockets are closed immediately.
*/
export = new fileRouter.Path("/").ws("/api/ws/notifications", (ws) =>
ws
.onOpen(async (ctr) => {
const hash = ctr.cookies.get(SESSION_COOKIE);
const auth = await checkAuthentication(hash, null);
if (!auth.success || auth.method !== "session") {
ctr.close(1008, "Unauthorized");
return;
}
ctr.printChannel(getUserChannel(auth.user.id));
log.debug({ userId: auth.user.id }, "WS notification socket opened");
})
.onMessage(async (ctr) => {
// Client heartbeats — echo a pong so idle proxies keep the socket alive.
if (ctr.rawMessage("utf8") === "ping") {
ctr.print("text", JSON.stringify({ kind: "pong", at: new Date().toISOString() }));
}
}),
);
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import {
canonicalize,
computeContentHash,
normalizeChanges,
normalizeDiff,
} from "../lib/ChangeNormalization";
describe("normalizeDiff", () => {
it("converts CRLF and lone CR to LF", () => {
expect(normalizeDiff("a\r\nb\rc")).toBe("a\nb\nc\n");
});
it("collapses trailing newlines to exactly one", () => {
expect(normalizeDiff("line\n\n\n")).toBe("line\n");
});
it("returns empty string for whitespace-only content", () => {
expect(normalizeDiff("\n\n")).toBe("");
});
});
describe("normalizeChanges", () => {
it("normalizes diff content and fills config/custom defaults", () => {
const out = normalizeChanges([
{ type: "unified_diff", path: "a.txt", content: "x\r\n" },
{ type: "config", path: "k", after: 5 },
{ type: "custom", label: "L" },
]);
expect(out[0]).toEqual({ type: "unified_diff", path: "a.txt", content: "x\n" });
expect(out[1]).toMatchObject({ type: "config", path: "k", before: null, after: 5, content_type: null });
expect(out[2]).toMatchObject({ type: "custom", label: "L", before: null, after: null });
});
it("preserves the submitted order of changes", () => {
const out = normalizeChanges([
{ type: "custom", label: "first" },
{ type: "custom", label: "second" },
]);
expect(out.map((c) => (c as { label: string }).label)).toEqual(["first", "second"]);
});
});
describe("canonicalize", () => {
it("is independent of key insertion order", () => {
expect(canonicalize({ a: 1, b: 2 })).toBe(canonicalize({ b: 2, a: 1 }));
});
});
describe("computeContentHash", () => {
const base = {
title: "T",
description: "D",
changes: [{ type: "config" as const, path: "k", before: 1, after: 2, content_type: "integer" }],
};
it("is stable across equivalent inputs (CRLF vs LF, key order)", () => {
const a = computeContentHash({
title: "T",
description: "D",
changes: [{ type: "unified_diff", path: "f", content: "a\r\nb\r\n" }],
});
const b = computeContentHash({
title: "T",
description: "D",
changes: [{ type: "unified_diff", path: "f", content: "a\nb" }],
});
expect(a).toBe(b);
});
it("changes when the title changes", () => {
expect(computeContentHash(base)).not.toBe(computeContentHash({ ...base, title: "T2" }));
});
it("changes when a change value changes", () => {
const mutated = {
...base,
changes: [{ type: "config" as const, path: "k", before: 1, after: 3, content_type: "integer" }],
};
expect(computeContentHash(base)).not.toBe(computeContentHash(mutated));
});
it("is order-sensitive across changes", () => {
const reordered = {
...base,
changes: [
{ type: "custom" as const, label: "x", before: 1, after: 2 },
{ type: "custom" as const, label: "y", before: 3, after: 4 },
],
};
const swapped = {
...base,
changes: [
{ type: "custom" as const, label: "y", before: 3, after: 4 },
{ type: "custom" as const, label: "x", before: 1, after: 2 },
],
};
expect(computeContentHash(reordered)).not.toBe(computeContentHash(swapped));
});
});
@@ -0,0 +1,189 @@
import { describe, expect, it, beforeEach } from "vitest";
import {
cancelChangeRequest,
consumeApproval,
createChangeRequest,
decideChangeRequest,
getChangeRequestForAgent,
updateChangeRequest,
} from "../lib/ChangeRequestService";
import { verifyReceipt } from "../lib/Signing";
import { prisma } from "../lib/db";
import { createAgent, createUser, resetDb, sampleChanges } from "./helpers";
async function freshAgentAndOwner() {
const owner = await createUser();
const agent = await createAgent(owner.id);
return { owner, agent };
}
const input = { title: "Deploy", description: "d", changes: sampleChanges };
describe("Change request lifecycle", () => {
beforeEach(async () => {
await resetDb();
});
it("creates a PENDING request with a content hash and approval url", async () => {
const { agent } = await freshAgentAndOwner();
const res = await createChangeRequest(agent, input);
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.data.state).toBe("PENDING");
expect(res.data.content_hash).toHaveLength(64);
expect(res.data.approval_url).toContain(res.data.request_id);
expect(res.data.receipt).toBeNull();
});
it("approve → signed receipt that verifies and binds to the content hash", async () => {
const { owner, agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error("create failed");
const decided = await decideChangeRequest(owner, created.data.request_id, "APPROVE", "ok");
expect(decided.ok).toBe(true);
if (!decided.ok) return;
expect(decided.data.state).toBe("APPROVED");
const receipt = decided.data.receipt!;
expect(receipt).not.toBeNull();
expect(receipt.payload.content_hash).toBe(created.data.content_hash);
expect(verifyReceipt(receipt.payload, receipt.signature)).toBe(true);
});
it("consume marks CONSUMED once and refuses a second time", async () => {
const { owner, agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error();
await decideChangeRequest(owner, created.data.request_id, "APPROVE");
const first = await consumeApproval(agent, created.data.request_id);
expect(first.ok).toBe(true);
if (first.ok) expect(first.data.state).toBe("CONSUMED");
const second = await consumeApproval(agent, created.data.request_id);
expect(second.ok).toBe(false);
if (!second.ok) expect(second.status).toBe(409);
});
it("cannot consume a request that was not approved", async () => {
const { agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error();
const res = await consumeApproval(agent, created.data.request_id);
expect(res.ok).toBe(false);
});
it("reject is a terminal blocker; no receipt consumption possible", async () => {
const { owner, agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error();
const rejected = await decideChangeRequest(owner, created.data.request_id, "REJECT", "no");
expect(rejected.ok).toBe(true);
if (rejected.ok) expect(rejected.data.state).toBe("REJECTED");
const consumed = await consumeApproval(agent, created.data.request_id);
expect(consumed.ok).toBe(false);
});
it("request_changes requires a comment and enables agent update loop", async () => {
const { owner, agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error();
const noComment = await decideChangeRequest(owner, created.data.request_id, "REQUEST_CHANGES");
expect(noComment.ok).toBe(false);
const withComment = await decideChangeRequest(
owner,
created.data.request_id,
"REQUEST_CHANGES",
"please revise",
);
expect(withComment.ok).toBe(true);
if (withComment.ok) expect(withComment.data.state).toBe("CHANGES_REQUESTED");
const updated = await updateChangeRequest(agent, created.data.request_id, {
title: "Deploy v2",
changes: [{ type: "custom", label: "Command", before: "a", after: "c" }],
});
expect(updated.ok).toBe(true);
if (!updated.ok) return;
expect(updated.data.state).toBe("PENDING");
expect(updated.data.resubmitted).toBe(true);
expect(updated.data.update_count).toBe(1);
// content hash must change with new content
expect(updated.data.content_hash).not.toBe(created.data.content_hash);
});
it("no update is possible after approval", async () => {
const { owner, agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error();
await decideChangeRequest(owner, created.data.request_id, "APPROVE");
const res = await updateChangeRequest(agent, created.data.request_id, input);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.status).toBe(409);
});
it("no decision is possible after a terminal state", async () => {
const { owner, agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error();
await decideChangeRequest(owner, created.data.request_id, "APPROVE");
const again = await decideChangeRequest(owner, created.data.request_id, "REJECT");
expect(again.ok).toBe(false);
});
it("agent can cancel before a decision", async () => {
const { agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error();
const cancelled = await cancelChangeRequest(agent, created.data.request_id);
expect(cancelled.ok).toBe(true);
if (cancelled.ok) expect(cancelled.data.state).toBe("CANCELLED");
});
it("expired-on-read: a past-expiry PENDING request becomes EXPIRED when fetched", async () => {
const { agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error();
await prisma.changeRequest.update({
where: { publicId: created.data.request_id },
data: { expiresAt: new Date(Date.now() - 1000) },
});
const fetched = await getChangeRequestForAgent(agent, created.data.request_id);
expect(fetched.ok).toBe(true);
if (fetched.ok) expect(fetched.data.state).toBe("EXPIRED");
});
});
describe("Authorization", () => {
beforeEach(async () => {
await resetDb();
});
it("an agent cannot read another agent's request", async () => {
const a = await freshAgentAndOwner();
const b = await freshAgentAndOwner();
const created = await createChangeRequest(a.agent, input);
if (!created.ok) throw new Error();
const res = await getChangeRequestForAgent(b.agent, created.data.request_id);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.status).toBe(404);
});
it("a human cannot decide a request they do not own", async () => {
const a = await freshAgentAndOwner();
const otherHuman = await createUser();
const created = await createChangeRequest(a.agent, input);
if (!created.ok) throw new Error();
const res = await decideChangeRequest(otherHuman, created.data.request_id, "APPROVE");
expect(res.ok).toBe(false);
if (!res.ok) expect(res.status).toBe(404);
});
it("an agent cannot cancel another agent's request", async () => {
const a = await freshAgentAndOwner();
const b = await freshAgentAndOwner();
const created = await createChangeRequest(a.agent, input);
if (!created.ok) throw new Error();
const res = await cancelChangeRequest(b.agent, created.data.request_id);
expect(res.ok).toBe(false);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it, beforeEach } from "vitest";
import {
clampAutoDeleteDays,
clampExpirySeconds,
clampPendingLimit,
checkAgentCountLimit,
checkAgentHourlyLimit,
checkAgentPendingLimit,
DEFAULT_EXPIRY_SECONDS,
MAX_AGENTS_PER_USER,
MAX_EXPIRY_SECONDS,
MAX_REQUESTS_PER_HOUR,
} from "../lib/Limits";
import { prisma } from "../lib/db";
import { createAgent, createUser, resetDb } from "./helpers";
describe("Limits — pure clamps", () => {
it("clamps pending limit to 1..10", () => {
expect(clampPendingLimit(0)).toBe(1);
expect(clampPendingLimit(5)).toBe(5);
expect(clampPendingLimit(99)).toBe(10);
expect(clampPendingLimit(NaN)).toBe(5);
});
it("clamps expiry to bounds and defaults sensibly", () => {
expect(clampExpirySeconds(undefined)).toBe(DEFAULT_EXPIRY_SECONDS);
expect(clampExpirySeconds(10)).toBe(60);
expect(clampExpirySeconds(99999999)).toBe(MAX_EXPIRY_SECONDS);
expect(clampExpirySeconds(3600)).toBe(3600);
});
it("clamps auto-delete days to a 7-day minimum", () => {
expect(clampAutoDeleteDays(1)).toBe(7);
expect(clampAutoDeleteDays(30)).toBe(30);
});
});
describe("Limits — DB-backed", () => {
beforeEach(async () => {
await resetDb();
});
it("blocks agent creation past the per-user cap", async () => {
const user = await createUser();
for (let i = 0; i < MAX_AGENTS_PER_USER; i++) await createAgent(user.id);
const result = await checkAgentCountLimit(user.id);
expect(result.allowed).toBe(false);
});
it("allows agent creation below the cap", async () => {
const user = await createUser();
await createAgent(user.id);
expect((await checkAgentCountLimit(user.id)).allowed).toBe(true);
});
it("enforces the hourly request limit", async () => {
const user = await createUser();
const agent = await createAgent(user.id);
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
await prisma.changeRequest.create({
data: {
title: `r${i}`,
changes: [],
rawChanges: [],
contentHash: "h",
expiresAt: new Date(Date.now() + 60000),
agentId: agent.id,
userId: user.id,
},
});
}
expect((await checkAgentHourlyLimit(agent.id)).allowed).toBe(false);
});
it("does not count requests older than an hour toward the hourly limit", async () => {
const user = await createUser();
const agent = await createAgent(user.id);
const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
await prisma.changeRequest.create({
data: {
title: `r${i}`,
changes: [],
rawChanges: [],
contentHash: "h",
expiresAt: new Date(Date.now() + 60000),
agentId: agent.id,
userId: user.id,
createdAt: old,
},
});
}
expect((await checkAgentHourlyLimit(agent.id)).allowed).toBe(true);
});
it("enforces the pending limit only against PENDING requests", async () => {
const user = await createUser();
const agent = await createAgent(user.id, { maxPendingRequests: 2 });
const mk = (state: "PENDING" | "APPROVED") =>
prisma.changeRequest.create({
data: {
title: "r",
changes: [],
rawChanges: [],
contentHash: "h",
state,
expiresAt: new Date(Date.now() + 60000),
agentId: agent.id,
userId: user.id,
},
});
await mk("PENDING");
await mk("APPROVED"); // should not count
expect((await checkAgentPendingLimit(agent.id, 2)).allowed).toBe(true);
await mk("PENDING");
expect((await checkAgentPendingLimit(agent.id, 2)).allowed).toBe(false);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it, beforeEach } from "vitest";
import { validateIconUrl } from "../lib/IconValidation";
import { createChangeRequest } from "../lib/ChangeRequestService";
import { setRequestsEnabled } from "../lib/DataManager";
import { normalizeUsername } from "../lib/Authentication";
import { createAgent, createUser, resetDb, sampleChanges } from "./helpers";
describe("validateIconUrl (non-network branches)", () => {
it("rejects a non-URL string", async () => {
const res = await validateIconUrl("not a url");
expect(res.valid).toBe(false);
});
it("rejects non-http protocols", async () => {
const res = await validateIconUrl("ftp://example.com/x.png");
expect(res.valid).toBe(false);
});
});
describe("normalizeUsername", () => {
it("lowercases and trims for case-insensitive uniqueness", () => {
expect(normalizeUsername(" Alice ")).toBe("alice");
expect(normalizeUsername("BOB")).toBe("bob");
});
});
describe("requests_enabled global gate", () => {
beforeEach(async () => {
await resetDb();
});
it("blocks request creation when disabled and allows when enabled", async () => {
const owner = await createUser();
const agent = await createAgent(owner.id);
await setRequestsEnabled(false);
const blocked = await createChangeRequest(agent, {
title: "t",
changes: sampleChanges,
});
expect(blocked.ok).toBe(false);
if (!blocked.ok) expect(blocked.status).toBe(403);
await setRequestsEnabled(true);
const allowed = await createChangeRequest(agent, { title: "t", changes: sampleChanges });
expect(allowed.ok).toBe(true);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { ReceiptPayload, signReceipt, verifyReceipt } from "../lib/Signing";
const base: ReceiptPayload = {
request_id: "req-123",
decision: "APPROVED",
content_hash: "abc123",
approver_id: 7,
decided_at: "2026-01-01T00:00:00.000Z",
};
describe("Signing", () => {
it("produces a deterministic signature for the same payload", () => {
expect(signReceipt(base)).toBe(signReceipt({ ...base }));
});
it("verifies a genuine signature", () => {
expect(verifyReceipt(base, signReceipt(base))).toBe(true);
});
it("rejects a tampered content hash", () => {
const sig = signReceipt(base);
expect(verifyReceipt({ ...base, content_hash: "different" }, sig)).toBe(false);
});
it("rejects a decision swapped from APPROVED to REJECTED", () => {
const sig = signReceipt(base);
expect(verifyReceipt({ ...base, decision: "REJECTED" }, sig)).toBe(false);
});
it("rejects a signature bound to a different request", () => {
const sig = signReceipt(base);
expect(verifyReceipt({ ...base, request_id: "other" }, sig)).toBe(false);
});
it("rejects a malformed signature without throwing", () => {
expect(verifyReceipt(base, "not-hex")).toBe(false);
expect(verifyReceipt(base, "")).toBe(false);
});
});
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it, beforeEach } from "vitest";
import { autoDeleteRequests, expireRequests } from "../lib/SystemCrons";
import { prisma } from "../lib/db";
import { createAgent, createUser, resetDb } from "./helpers";
async function makeRequest(
agentId: number,
userId: number,
opts: { state?: any; expiresAt?: Date; createdAt?: Date } = {},
) {
return prisma.changeRequest.create({
data: {
title: "r",
changes: [],
rawChanges: [],
contentHash: "h",
state: opts.state ?? "PENDING",
expiresAt: opts.expiresAt ?? new Date(Date.now() + 60000),
agentId,
userId,
...(opts.createdAt ? { createdAt: opts.createdAt } : {}),
},
});
}
describe("expireRequests cron", () => {
beforeEach(async () => {
await resetDb();
});
it("expires only open requests past their deadline", async () => {
const user = await createUser();
const agent = await createAgent(user.id);
const past = new Date(Date.now() - 1000);
const overduePending = await makeRequest(agent.id, user.id, { state: "PENDING", expiresAt: past });
const overdueChanges = await makeRequest(agent.id, user.id, {
state: "CHANGES_REQUESTED",
expiresAt: past,
});
const futurePending = await makeRequest(agent.id, user.id, { state: "PENDING" });
const approvedPast = await makeRequest(agent.id, user.id, { state: "APPROVED", expiresAt: past });
const count = await expireRequests({ prisma });
expect(count).toBe(2);
expect((await prisma.changeRequest.findUnique({ where: { id: overduePending.id } }))!.state).toBe(
"EXPIRED",
);
expect((await prisma.changeRequest.findUnique({ where: { id: overdueChanges.id } }))!.state).toBe(
"EXPIRED",
);
expect((await prisma.changeRequest.findUnique({ where: { id: futurePending.id } }))!.state).toBe(
"PENDING",
);
// Terminal states are never touched by the expiry sweep.
expect((await prisma.changeRequest.findUnique({ where: { id: approvedPast.id } }))!.state).toBe(
"APPROVED",
);
});
it("creates a notification per expired request", async () => {
const user = await createUser();
const agent = await createAgent(user.id);
await makeRequest(agent.id, user.id, { state: "PENDING", expiresAt: new Date(Date.now() - 1000) });
await expireRequests({ prisma });
const notifs = await prisma.notification.findMany({ where: { userId: user.id } });
expect(notifs.some((n) => n.type === "REQUEST_EXPIRED")).toBe(true);
});
});
describe("autoDeleteRequests cron", () => {
beforeEach(async () => {
await resetDb();
});
it("deletes old requests only for opted-in users, respecting the window", async () => {
const optedIn = await prisma.user.update({
where: { id: (await createUser()).id },
data: { autoDeleteEnabled: true, autoDeleteDays: 7 },
});
const optedOut = await createUser();
const agentIn = await createAgent(optedIn.id);
const agentOut = await createAgent(optedOut.id);
const old = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
const recent = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000);
const oldForOptedIn = await makeRequest(agentIn.id, optedIn.id, {
state: "CONSUMED",
createdAt: old,
});
const recentForOptedIn = await makeRequest(agentIn.id, optedIn.id, {
state: "CONSUMED",
createdAt: recent,
});
const oldForOptedOut = await makeRequest(agentOut.id, optedOut.id, {
state: "CONSUMED",
createdAt: old,
});
const deleted = await autoDeleteRequests({ prisma });
expect(deleted).toBe(1);
expect(await prisma.changeRequest.findUnique({ where: { id: oldForOptedIn.id } })).toBeNull();
expect(await prisma.changeRequest.findUnique({ where: { id: recentForOptedIn.id } })).not.toBeNull();
expect(await prisma.changeRequest.findUnique({ where: { id: oldForOptedOut.id } })).not.toBeNull();
});
});
+18
View File
@@ -0,0 +1,18 @@
import { execSync } from "child_process";
import { join } from "path";
/**
* Vitest global setup — bring the test database schema up to date via Prisma
* migrations before any test runs. Runs once for the whole suite.
*/
export default function setup() {
const backendRoot = join(__dirname, "../..");
const dbUrl =
process.env.TEST_DATABASE_URL ||
"postgresql://patchpass:patchpass@localhost:5433/patchpass_test";
execSync("npx prisma migrate deploy", {
cwd: backendRoot,
env: { ...process.env, DATABASE_URL: dbUrl },
stdio: "inherit",
});
}
+47
View File
@@ -0,0 +1,47 @@
import { prisma } from "../lib/db";
import { generateAgentApiKey } from "../lib/Authentication";
/** Wipe all data between tests. Order respects FK constraints (cascade handles rest). */
export async function resetDb() {
await prisma.auditLog.deleteMany();
await prisma.notification.deleteMany();
await prisma.changeRequest.deleteMany();
await prisma.agent.deleteMany();
await prisma.session.deleteMany();
await prisma.user.deleteMany();
await prisma.globalSetting.deleteMany();
}
let userCounter = 0;
export async function createUser(overrides: Partial<{ username: string; role: "ADMIN" | "USER" }> = {}) {
userCounter += 1;
const username = overrides.username ?? `user${userCounter}_${Date.now()}`;
return prisma.user.create({
data: {
username,
displayName: username,
password: "x", // not exercised in service-level tests
role: overrides.role ?? "USER",
},
});
}
export async function createAgent(
ownerId: number,
overrides: Partial<{ name: string; maxPendingRequests: number; disabled: boolean }> = {},
) {
return prisma.agent.create({
data: {
name: overrides.name ?? "Test Agent",
apiKey: generateAgentApiKey(),
ownerId,
maxPendingRequests: overrides.maxPendingRequests ?? 5,
disabled: overrides.disabled ?? false,
},
});
}
export const sampleChanges = [
{ type: "custom" as const, label: "Command", before: "a", after: "b" },
];