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
+24
View File
@@ -0,0 +1,24 @@
module.exports = {
parser: "@typescript-eslint/parser",
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: __dirname,
},
plugins: ["@typescript-eslint"],
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
root: true,
env: {
node: true,
es2022: true,
},
rules: {
"no-console": "error",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-require-imports": "error",
},
ignorePatterns: ["dist/", "node_modules/"],
};
+46
View File
@@ -0,0 +1,46 @@
{
"name": "patchpass-backend",
"version": "1.0.0",
"description": "Backend for PatchPass — a human approval layer for AI agents.",
"private": true,
"license": "MIT-0",
"dependencies": {
"@prisma/adapter-pg": "^7.4.0",
"@prisma/client": "^7.4.0",
"@rjweb/runtime-node": "^1.1.1",
"@rjweb/utils": "^1.12.29",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.19.15",
"bcryptjs": "^2.4.3",
"dotenv": "17.2.3",
"esbuild": "^0.25.12",
"file-type": "^19.6.0",
"otplib": "^12.0.1",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"pg": "^8.13.1",
"prisma": "^7.4.0",
"rjweb-server": "^9.8.6",
"typescript": "^5.9.3",
"vitest": "^4.1.2",
"zod": "^4.4.3"
},
"scripts": {
"migrate": "prisma migrate dev",
"migrate:deploy": "prisma migrate deploy",
"generate": "prisma generate",
"build": "rimraf dist && tsc",
"start": "cd dist && node index.js",
"prod": "pnpm build && pnpm start",
"dev": "rimraf dist && esbuild \"src/**/*.ts\" --platform=node --sourcemap --ignore-annotations --format=cjs --target=es2022 --outdir=dist && cd dist && node index.js",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src --ext .ts"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.62.1",
"@typescript-eslint/parser": "^8.62.1",
"eslint": "^8.57.1",
"rimraf": "^5.0.10"
}
}
+3918
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
allowBuilds:
'@prisma/client': true
'@prisma/engines': true
bufferutil: true
esbuild: true
prisma: true
+12
View File
@@ -0,0 +1,12 @@
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});
@@ -0,0 +1,182 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER');
-- CreateEnum
CREATE TYPE "RequestState" AS ENUM ('PENDING', 'CHANGES_REQUESTED', 'APPROVED', 'REJECTED', 'EXPIRED', 'CONSUMED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "NotificationType" AS ENUM ('REQUEST_CREATED', 'REQUEST_UPDATED', 'REQUEST_APPROVED', 'REQUEST_REJECTED', 'CHANGES_REQUESTED', 'REQUEST_CONSUMED', 'REQUEST_CANCELLED', 'REQUEST_EXPIRED', 'AGENT_DISABLED', 'ADMIN_ACTION', 'SETTINGS_CHANGED');
-- CreateEnum
CREATE TYPE "GlobalSettingType" AS ENUM ('registration_enabled', 'requests_enabled');
-- CreateTable
CREATE TABLE "GlobalSetting" (
"type" "GlobalSettingType" NOT NULL,
"value" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GlobalSetting_pkey" PRIMARY KEY ("type")
);
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"username" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'USER',
"totpSecret" TEXT,
"totpEnabled" BOOLEAN NOT NULL DEFAULT false,
"disabled" BOOLEAN NOT NULL DEFAULT false,
"autoDeleteEnabled" BOOLEAN NOT NULL DEFAULT false,
"autoDeleteDays" INTEGER NOT NULL DEFAULT 30,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" SERIAL NOT NULL,
"hash" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Agent" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"website" TEXT,
"iconUrl" TEXT,
"apiKey" TEXT NOT NULL,
"disabled" BOOLEAN NOT NULL DEFAULT false,
"maxPendingRequests" INTEGER NOT NULL DEFAULT 5,
"ownerId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Agent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChangeRequest" (
"id" SERIAL NOT NULL,
"publicId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"changes" JSONB NOT NULL,
"rawChanges" JSONB NOT NULL,
"metadata" JSONB,
"contentHash" TEXT NOT NULL,
"state" "RequestState" NOT NULL DEFAULT 'PENDING',
"expiresAt" TIMESTAMP(3) NOT NULL,
"comment" TEXT,
"decidedAt" TIMESTAMP(3),
"approverId" INTEGER,
"signature" TEXT,
"receiptIssuedAt" TIMESTAMP(3),
"consumedAt" TIMESTAMP(3),
"cancelledAt" TIMESTAMP(3),
"updateCount" INTEGER NOT NULL DEFAULT 0,
"resubmitted" BOOLEAN NOT NULL DEFAULT false,
"lastAgentUpdateAt" TIMESTAMP(3),
"agentId" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ChangeRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Notification" (
"id" SERIAL NOT NULL,
"type" "NotificationType" NOT NULL,
"title" TEXT NOT NULL,
"message" TEXT NOT NULL,
"requestPublicId" TEXT,
"read" BOOLEAN NOT NULL DEFAULT false,
"userId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" SERIAL NOT NULL,
"action" TEXT NOT NULL,
"detail" TEXT,
"targetType" TEXT,
"targetId" TEXT,
"actorId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "Session_hash_key" ON "Session"("hash");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Agent_apiKey_key" ON "Agent"("apiKey");
-- CreateIndex
CREATE INDEX "Agent_ownerId_idx" ON "Agent"("ownerId");
-- CreateIndex
CREATE UNIQUE INDEX "ChangeRequest_publicId_key" ON "ChangeRequest"("publicId");
-- CreateIndex
CREATE INDEX "ChangeRequest_userId_state_idx" ON "ChangeRequest"("userId", "state");
-- CreateIndex
CREATE INDEX "ChangeRequest_agentId_state_idx" ON "ChangeRequest"("agentId", "state");
-- CreateIndex
CREATE INDEX "ChangeRequest_createdAt_idx" ON "ChangeRequest"("createdAt");
-- CreateIndex
CREATE INDEX "Notification_userId_read_idx" ON "Notification"("userId", "read");
-- CreateIndex
CREATE INDEX "Notification_userId_createdAt_idx" ON "Notification"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_actorId_idx" ON "AuditLog"("actorId");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChangeRequest" ADD CONSTRAINT "ChangeRequest_approverId_fkey" FOREIGN KEY ("approverId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChangeRequest" ADD CONSTRAINT "ChangeRequest_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChangeRequest" ADD CONSTRAINT "ChangeRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+214
View File
@@ -0,0 +1,214 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
// ─────────────────────────────────────────────────────────────────────────────
// Enums
// ─────────────────────────────────────────────────────────────────────────────
enum UserRole {
ADMIN
USER
}
enum RequestState {
PENDING
CHANGES_REQUESTED
APPROVED
REJECTED
EXPIRED
CONSUMED
CANCELLED
}
enum NotificationType {
REQUEST_CREATED
REQUEST_UPDATED
REQUEST_APPROVED
REQUEST_REJECTED
CHANGES_REQUESTED
REQUEST_CONSUMED
REQUEST_CANCELLED
REQUEST_EXPIRED
AGENT_DISABLED
ADMIN_ACTION
SETTINGS_CHANGED
}
enum GlobalSettingType {
registration_enabled
requests_enabled
}
// ─────────────────────────────────────────────────────────────────────────────
// Models
// ─────────────────────────────────────────────────────────────────────────────
/// A global (admin-only) platform setting, keyed by type.
model GlobalSetting {
type GlobalSettingType @id
value String
updatedAt DateTime @updatedAt
}
/// A human account. The first registered user becomes ADMIN.
model User {
id Int @id @default(autoincrement())
/// Normalized (lowercased) username — enforces case-insensitive uniqueness.
username String @unique
/// Original-cased display handle shown in the UI.
displayName String
password String
role UserRole @default(USER)
// Optional TOTP two-factor auth
totpSecret String?
totpEnabled Boolean @default(false)
// Admin can disable a human account entirely
disabled Boolean @default(false)
// Auto-delete of old change requests (opt-in, min 7 days, default 30)
autoDeleteEnabled Boolean @default(false)
autoDeleteDays Int @default(30)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
agents Agent[]
changeRequests ChangeRequest[] @relation("Owner")
decisions ChangeRequest[] @relation("Approver")
notifications Notification[]
auditLogs AuditLog[] @relation("AuditActor")
}
/// A browser session for a human account (cookie-based auth).
model Session {
id Int @id @default(autoincrement())
hash String @unique
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([userId])
}
/// An AGENT account. Fully managed by a human owner. Holds exactly one API key.
model Agent {
id Int @id @default(autoincrement())
name String
description String?
website String?
iconUrl String?
/// The single API key the agent authenticates with.
apiKey String @unique
disabled Boolean @default(false)
/// Max simultaneous pending requests (1..10, human-configurable, default 5).
maxPendingRequests Int @default(5)
ownerId Int
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
changeRequests ChangeRequest[]
@@index([ownerId])
}
/// A structured change request submitted by an agent for human approval.
model ChangeRequest {
id Int @id @default(autoincrement())
/// Public UUID (v4) exposed to agents as request_id.
publicId String @unique @default(uuid())
title String
description String?
/// Normalized changes array (diffs normalized, canonical ordering).
changes Json
/// Original changes exactly as submitted (audit trail).
rawChanges Json
metadata Json?
/// SHA-256 hash over the canonical (title + description + normalized changes).
contentHash String
state RequestState @default(PENDING)
expiresAt DateTime
// Decision fields
comment String?
decidedAt DateTime?
approverId Int?
approver User? @relation("Approver", fields: [approverId], references: [id], onDelete: SetNull)
/// HMAC-SHA256 signature of the approval receipt (platform-signed decision).
signature String?
receiptIssuedAt DateTime?
consumedAt DateTime?
cancelledAt DateTime?
// Update tracking for the CHANGES_REQUESTED → resubmit loop
updateCount Int @default(0)
/// True after an agent resubmits following a CHANGES_REQUESTED decision.
resubmitted Boolean @default(false)
lastAgentUpdateAt DateTime?
agentId Int
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
// The human who owns/reviews this request (the agent's owner at creation time).
userId Int
user User @relation("Owner", fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, state])
@@index([agentId, state])
@@index([createdAt])
}
/// An in-app notification for a human account.
model Notification {
id Int @id @default(autoincrement())
type NotificationType
title String
message String
/// Optional link to a change request (publicId).
requestPublicId String?
read Boolean @default(false)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([userId, read])
@@index([userId, createdAt])
}
/// An audit log entry. Records privileged/admin actions and payload views.
model AuditLog {
id Int @id @default(autoincrement())
action String
/// Human-readable detail / JSON string of context.
detail String?
/// Optional target references.
targetType String?
targetId String?
actorId Int?
actor User? @relation("AuditActor", fields: [actorId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@index([createdAt])
@@index([actorId])
}
+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" },
];
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"strict": true,
"module": "Node16",
"moduleResolution": "node16",
"lib": ["ES2022"],
"pretty": true,
"alwaysStrict": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"declaration": false,
"esModuleInterop": true,
"target": "ES2022",
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"removeComments": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts"]
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
import { config as dotenvConfig } from "dotenv";
import { join } from "path";
dotenvConfig({ path: join(__dirname, ".env") });
// Tests run against a dedicated database so they never touch dev data.
process.env.NODE_ENV = "test";
process.env.DATABASE_URL =
process.env.TEST_DATABASE_URL ||
"postgresql://patchpass:patchpass@localhost:5433/patchpass_test";
// Deterministic secret so signature assertions are stable.
process.env.INSTANCE_SECRET =
process.env.INSTANCE_SECRET || "test_instance_secret_for_signing_receipts_0001";
process.env.LOG_LEVEL = process.env.LOG_LEVEL || "silent";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/tests/**/*.test.ts"],
exclude: ["**/node_modules/**", "**/dist/**"],
globalSetup: ["src/tests/globalSetup.ts"],
fileParallelism: false,
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
},
},
});