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
+8
View File
@@ -0,0 +1,8 @@
**/node_modules
**/dist
**/build
**/.env
**/*.log
Backend/.data
.git
UI/public/styles.css
+141
View File
@@ -0,0 +1,141 @@
name: Deploy
on:
push:
branches: ["main", "dev"]
pull_request:
branches: ["main", "dev"]
permissions:
contents: read
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
version: 11.5.2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Install dependencies
run: |
cd Backend && pnpm install
cd ../UI && pnpm install
- name: Generate Prisma client
run: cd Backend && pnpm generate
env:
DATABASE_URL: postgresql://x:x@localhost/x
- name: Build backend
run: cd Backend && pnpm run build
- name: Build UI
run: cd UI && pnpm run build
test-and-lint:
name: Test & Lint
needs: build
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: patchpass
POSTGRES_PASSWORD: patchpass
POSTGRES_DB: patchpass
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U patchpass"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
TEST_DATABASE_URL: postgresql://patchpass:patchpass@localhost:5432/patchpass
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
version: 11.5.2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Install dependencies
run: |
cd Backend && pnpm install
cd ../UI && pnpm install
- name: Generate Prisma client
run: cd Backend && pnpm generate
env:
DATABASE_URL: ${{ env.TEST_DATABASE_URL }}
- name: Backend lint
run: cd Backend && pnpm lint
- name: UI lint
run: cd UI && pnpm lint
- name: Backend tests
run: cd Backend && pnpm test
push-image:
name: Build and Push Docker Image
needs: [build, test-and-lint]
runs-on: ubuntu-latest
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Buildx
uses: docker/setup-buildx-action@v3
- name: Generate image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: registry.reversed.dev/patchpass/core
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=prod,enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}
type=sha,format=long
labels: |
org.opencontainers.image.title=PatchPass
org.opencontainers.image.description=A human approval layer for AI agents
org.opencontainers.image.vendor=space
- name: Log in to Harbor
uses: docker/login-action@v3
with:
registry: registry.reversed.dev
username: ${{ secrets.HARBOR_USERNAME }}
password: ${{ secrets.HARBOR_PASSWORD }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
provenance: false
sbom: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
build/
.env
*.log
.DS_Store
Backend/.data/
Backend/prisma/*.db
UI/public/styles.css
coverage/
+147
View File
@@ -0,0 +1,147 @@
# PatchPass Agent Guidelines
## What is PatchPass?
**A human approval layer for AI agents.** Agents submit structured change requests (code diffs, config updates, custom values); humans review and approve them in the browser; agents then verify the platform-signed decision through the API before applying anything. Packaged as a single `docker compose` stack.
---
## Repo layout
```
Backend/ Node.js + TypeScript API (rjweb-server)
src/
index.ts server bootstrap + static UI serving + notFound SPA fallback
lib/ service layer + shared utilities
ChangeRequestService.ts the request lifecycle — REST and MCP both call this
AgentService.ts agent CRUD (human-owned)
Authentication.ts session (human) + API key (agent) resolution
Signing.ts HMAC-SHA256 approval receipts
ChangeNormalization.ts diff normalization + content hashing
Limits.ts rate/pending/agent-count limits
Notifications.ts, WsHub.ts realtime notifications
SystemCrons.ts expiry + auto-delete jobs
DataManager.ts global (admin) settings
mcp/ MCP JSON-RPC server + tools
middlewares/, RouteAuth.ts
routes/
api/ human-facing REST (auth, account, agents, change-requests, notifications, admin, global)
v1/ agent-facing REST (/v1/change-requests)
mcp.ts MCP endpoint (/mcp)
ws.ts WebSocket (/api/ws/notifications)
tests/ Vitest integration tests
prisma/schema.prisma
UI/ React + TypeScript + Tailwind (Vite), builds to UI/build
Dockerfile
docker-compose.yml
.gitea/workflows/deploy.yml
```
---
## Stack
| Layer | Technology |
|---|---|
| Backend | Node.js + TypeScript (strict), rjweb-server |
| ORM | Prisma 7 (`@prisma/adapter-pg`) |
| Database | PostgreSQL |
| Frontend | React + Tailwind CSS (Vite) |
| Package manager | pnpm (Backend and UI are separate pnpm workspaces) |
| Backend tests | Vitest |
---
## Development workflow
### Branch strategy
```
feature/<name> → dev → main
```
### Running locally
```bash
# Backend
cd Backend
pnpm dev # esbuild + node, http://localhost:5000
# UI (separate terminal)
cd UI
pnpm dev # vite on :3000, proxies /api and /v1 to :5000
```
### Before committing
1. **Backend lint:** `cd Backend && pnpm lint`
2. **Backend tests:** `cd Backend && pnpm test`
3. **UI lint:** `cd UI && pnpm lint`
4. **UI build check:** `cd UI && pnpm build`
Never commit code that fails lint or tests.
---
## Key rules
### The service layer is the single source of truth
`ChangeRequestService.ts` contains ALL request lifecycle logic and authorization. The REST routes (`routes/v1`, `routes/api`) and the MCP tools (`lib/mcp/tools`) are thin adapters that call it. **Do not duplicate authorization or business logic** in a route or tool — add it to the service.
### Request state machine
States: `PENDING`, `CHANGES_REQUESTED`, `APPROVED`, `REJECTED`, `EXPIRED`, `CONSUMED`, `CANCELLED`.
- Decisions are only allowed on `PENDING`.
- Agent updates are only allowed on `PENDING` / `CHANGES_REQUESTED`.
- `REQUEST_CHANGES` requires a comment (≤500 chars) and resets to `PENDING` on agent resubmit (`resubmitted=true`).
- No mutation after `APPROVED`, `REJECTED`, `EXPIRED`, `CONSUMED`, or `CANCELLED`.
- Approvals are single-use: `consume` moves `APPROVED → CONSUMED`.
### Signing
Approve/reject decisions are signed with HMAC-SHA256 over a fixed canonical field order (see `Signing.ts`). The signature binds the decision to `content_hash`. Never reorder the canonical fields — it breaks every existing signature.
### Content hashing
`computeContentHash` hashes `{title, description, normalizedChanges}` with key-sorted canonical JSON and LF-normalized diffs. Equivalent inputs (CRLF vs LF, key order) must hash identically; any semantic change must change the hash. Covered by tests.
### Limits
`Limits.ts` centralizes: 15 requests/agent/hour, 5 agents/human, 110 pending/agent (default 5), expiry 60s12h (default 30 min), auto-delete ≥7 days. Enforce through these helpers, not ad hoc.
### Auth
- Humans: session cookie (`patchpass_session`), bcrypt passwords, optional TOTP 2FA.
- Agents: one API key each, `x-api-key` header.
Resolution is in `Authentication.ts`; routes use `requireSession` / `requireAgent` / `requireAdmin` from `RouteAuth.ts`. Disabled humans/agents are rejected.
### Admin
First registered user is `ADMIN`. Global settings (`registration_enabled`, `requests_enabled`) live in `DataManager.ts`. Admin viewing another user's request payload is explicit and audited (`recordAudit`). Never remove the last admin.
### UTC & pagination
All timestamps are UTC ISO strings at the API boundary. History and admin logs are paginated.
---
## Database migrations MANDATORY rules
> **Never use `prisma db push` or `prisma db pull` to evolve the schema.**
When you change `prisma/schema.prisma`:
1. Create a migration: `cd Backend && pnpm migrate` (`prisma migrate dev`), give a short slug.
2. Commit the generated `prisma/migrations/<timestamp>_<name>/migration.sql` with the schema change.
3. Never hand-edit an applied `migration.sql` — create a new migration.
4. Production/CI: `pnpm migrate:deploy`.
Prisma 7: the datasource URL lives in `prisma.config.ts` via `env("DATABASE_URL")`, not in `schema.prisma`. The client uses the `PrismaPg` adapter — do not remove it. Run `pnpm generate` for TS types without a migration.
---
## General coding rules
- TypeScript strict mode is on. Avoid `any` without reason (it is a lint warning).
- Do not use `console.*` in the Backend. Use `createLogger(component)` from `lib/logger.ts`.
- UI state uses React built-ins (Context + hooks) — no external state library.
- Follow existing conventions: route handlers in `routes/`, shared logic in `lib/`, MCP tools in `lib/mcp/tools/`.
---
## Keeping CLAUDE.md and AGENTS.md in sync MANDATORY
`CLAUDE.md` (Claude Code) and `AGENTS.md` (all other AI agents) must always reflect the same rules. Any rule added, removed, or amended in one must be mirrored in the other **in the same commit**. If they drift, fix the gap before continuing.
+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"],
},
},
});
+147
View File
@@ -0,0 +1,147 @@
# PatchPass Claude Code Guidelines
## What is PatchPass?
**A human approval layer for AI agents.** Agents submit structured change requests (code diffs, config updates, custom values); humans review and approve them in the browser; agents then verify the platform-signed decision through the API before applying anything. Packaged as a single `docker compose` stack.
---
## Repo layout
```
Backend/ Node.js + TypeScript API (rjweb-server)
src/
index.ts server bootstrap + static UI serving + notFound SPA fallback
lib/ service layer + shared utilities
ChangeRequestService.ts the request lifecycle — REST and MCP both call this
AgentService.ts agent CRUD (human-owned)
Authentication.ts session (human) + API key (agent) resolution
Signing.ts HMAC-SHA256 approval receipts
ChangeNormalization.ts diff normalization + content hashing
Limits.ts rate/pending/agent-count limits
Notifications.ts, WsHub.ts realtime notifications
SystemCrons.ts expiry + auto-delete jobs
DataManager.ts global (admin) settings
mcp/ MCP JSON-RPC server + tools
middlewares/, RouteAuth.ts
routes/
api/ human-facing REST (auth, account, agents, change-requests, notifications, admin, global)
v1/ agent-facing REST (/v1/change-requests)
mcp.ts MCP endpoint (/mcp)
ws.ts WebSocket (/api/ws/notifications)
tests/ Vitest integration tests
prisma/schema.prisma
UI/ React + TypeScript + Tailwind (Vite), builds to UI/build
Dockerfile
docker-compose.yml
.gitea/workflows/deploy.yml
```
---
## Stack
| Layer | Technology |
|---|---|
| Backend | Node.js + TypeScript (strict), rjweb-server |
| ORM | Prisma 7 (`@prisma/adapter-pg`) |
| Database | PostgreSQL |
| Frontend | React + Tailwind CSS (Vite) |
| Package manager | pnpm (Backend and UI are separate pnpm workspaces) |
| Backend tests | Vitest |
---
## Development workflow
### Branch strategy
```
feature/<name> → dev → main
```
### Running locally
```bash
# Backend
cd Backend
pnpm dev # esbuild + node, http://localhost:5000
# UI (separate terminal)
cd UI
pnpm dev # vite on :3000, proxies /api and /v1 to :5000
```
### Before committing
1. **Backend lint:** `cd Backend && pnpm lint`
2. **Backend tests:** `cd Backend && pnpm test`
3. **UI lint:** `cd UI && pnpm lint`
4. **UI build check:** `cd UI && pnpm build`
Never commit code that fails lint or tests.
---
## Key rules
### The service layer is the single source of truth
`ChangeRequestService.ts` contains ALL request lifecycle logic and authorization. The REST routes (`routes/v1`, `routes/api`) and the MCP tools (`lib/mcp/tools`) are thin adapters that call it. **Do not duplicate authorization or business logic** in a route or tool — add it to the service.
### Request state machine
States: `PENDING`, `CHANGES_REQUESTED`, `APPROVED`, `REJECTED`, `EXPIRED`, `CONSUMED`, `CANCELLED`.
- Decisions are only allowed on `PENDING`.
- Agent updates are only allowed on `PENDING` / `CHANGES_REQUESTED`.
- `REQUEST_CHANGES` requires a comment (≤500 chars) and resets to `PENDING` on agent resubmit (`resubmitted=true`).
- No mutation after `APPROVED`, `REJECTED`, `EXPIRED`, `CONSUMED`, or `CANCELLED`.
- Approvals are single-use: `consume` moves `APPROVED → CONSUMED`.
### Signing
Approve/reject decisions are signed with HMAC-SHA256 over a fixed canonical field order (see `Signing.ts`). The signature binds the decision to `content_hash`. Never reorder the canonical fields — it breaks every existing signature.
### Content hashing
`computeContentHash` hashes `{title, description, normalizedChanges}` with key-sorted canonical JSON and LF-normalized diffs. Equivalent inputs (CRLF vs LF, key order) must hash identically; any semantic change must change the hash. Covered by tests.
### Limits
`Limits.ts` centralizes: 15 requests/agent/hour, 5 agents/human, 110 pending/agent (default 5), expiry 60s12h (default 30 min), auto-delete ≥7 days. Enforce through these helpers, not ad hoc.
### Auth
- Humans: session cookie (`patchpass_session`), bcrypt passwords, optional TOTP 2FA.
- Agents: one API key each, `x-api-key` header.
Resolution is in `Authentication.ts`; routes use `requireSession` / `requireAgent` / `requireAdmin` from `RouteAuth.ts`. Disabled humans/agents are rejected.
### Admin
First registered user is `ADMIN`. Global settings (`registration_enabled`, `requests_enabled`) live in `DataManager.ts`. Admin viewing another user's request payload is explicit and audited (`recordAudit`). Never remove the last admin.
### UTC & pagination
All timestamps are UTC ISO strings at the API boundary. History and admin logs are paginated.
---
## Database migrations MANDATORY rules
> **Never use `prisma db push` or `prisma db pull` to evolve the schema.**
When you change `prisma/schema.prisma`:
1. Create a migration: `cd Backend && pnpm migrate` (`prisma migrate dev`), give a short slug.
2. Commit the generated `prisma/migrations/<timestamp>_<name>/migration.sql` with the schema change.
3. Never hand-edit an applied `migration.sql` — create a new migration.
4. Production/CI: `pnpm migrate:deploy`.
Prisma 7: the datasource URL lives in `prisma.config.ts` via `env("DATABASE_URL")`, not in `schema.prisma`. The client uses the `PrismaPg` adapter — do not remove it. Run `pnpm generate` for TS types without a migration.
---
## General coding rules
- TypeScript strict mode is on. Avoid `any` without reason (it is a lint warning).
- Do not use `console.*` in the Backend. Use `createLogger(component)` from `lib/logger.ts`.
- UI state uses React built-ins (Context + hooks) — no external state library.
- Follow existing conventions: route handlers in `routes/`, shared logic in `lib/`, MCP tools in `lib/mcp/tools/`.
---
## Keeping CLAUDE.md and AGENTS.md in sync MANDATORY
`CLAUDE.md` (Claude Code) and `AGENTS.md` (all other AI agents) must always reflect the same rules. Any rule added, removed, or amended in one must be mirrored in the other **in the same commit**. If they drift, fix the gap before continuing.
+46
View File
@@ -0,0 +1,46 @@
FROM node:24-bookworm-slim AS build
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY Backend/package.json Backend/pnpm-workspace.yaml ./Backend/
COPY UI/package.json UI/pnpm-workspace.yaml ./UI/
# Install in separate layers to keep peak memory low.
RUN cd Backend && pnpm install
RUN cd UI && pnpm install
COPY . .
# Build the frontend, then generate the Prisma client and compile the backend.
RUN cd UI && pnpm run build
RUN cd Backend && DATABASE_URL=postgresql://x:x@localhost/x pnpm run generate && pnpm run build
FROM node:24-bookworm-slim AS runtime
ENV NODE_ENV=production
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY Backend/package.json Backend/pnpm-workspace.yaml Backend/prisma.config.ts ./Backend/
COPY Backend/prisma ./Backend/prisma
RUN cd Backend && pnpm install --prod && DATABASE_URL=postgresql://x:x@localhost/x pnpm run generate
COPY --from=build /app/Backend/dist ./Backend/dist
COPY --from=build /app/UI/build ./UI/build
EXPOSE 5000
# Apply migrations on boot, then start the API (which also serves the UI build).
CMD ["sh", "-c", "cd /app/Backend && pnpm migrate:deploy && cd dist && node index.js"]
+150
View File
@@ -0,0 +1,150 @@
# PatchPass
**A human approval layer for AI agents.**
> Review changes. Approve intent. Let agents proceed.
Agents submit structured change requests containing code diffs, configuration updates, or custom values. Humans review and approve them in the browser, then agents verify the platform-signed decision through the API before applying anything.
---
## How it works
```
Agent PatchPass Human
│ create_request ───────────► │ │
│ (diffs/config/custom) │ normalize · hash · store │
│ ◄─── request_id + URL ────────│ ──── notify (WS) ──────────► │ reviews in browser
│ │ │ APPROVE / REJECT / REQUEST_CHANGES
│ get_request ──────────────► │ ◄──── signed decision ──────│
│ ◄─── state + receipt ─────────│ │
│ consume_approval ─────────► │ mark CONSUMED (single-use) │
│ ◄─── receipt (HMAC) ──────────│ │
│ apply changes │ │
```
- **Decisions are platform-signed.** Approvals and rejections carry an HMAC-SHA256 receipt bound to the exact reviewed content (`content_hash`). If the agent changes anything after approval, the receipt no longer matches.
- **Approvals are single-use.** An agent must `consume` an approval before proceeding; it cannot be replayed.
- **Two ways in for agents:** a REST API (`/v1/...`) and an MCP server (`/mcp`), both authenticated with a per-agent API key and backed by the same service layer.
## Request states
`PENDING` · `CHANGES_REQUESTED` · `APPROVED` · `REJECTED` · `EXPIRED` · `CONSUMED` · `CANCELLED`
No update is possible after approval, rejection, expiry, consumption, or cancellation. A `REQUEST_CHANGES` decision requires a comment and returns the request to the agent, which can `update_request` to resubmit (highlighted in the UI as **UPDATED**).
## Limits
| Limit | Value |
|---|---|
| Requests per agent per hour | 15 |
| Agents per human | 5 |
| Simultaneous pending requests per agent | 110 (human-configured, default 5) |
| Request expiry | default 30 min, max 12 h |
## Tech stack
| Layer | Technology |
|---|---|
| Backend | Node.js + TypeScript, [rjweb-server](https://server.rjweb.dev) |
| ORM | Prisma 7 (`@prisma/adapter-pg`) |
| Database | PostgreSQL |
| Frontend | React + TypeScript + Tailwind CSS (Vite), served by the backend |
| Realtime | rjweb WebSocket channels |
| Tests | Vitest |
## Repository layout
```
Backend/ Node + TypeScript API (rjweb-server) + Prisma
src/
index.ts server bootstrap + static UI serving
lib/ service layer, auth, signing, limits, crons, MCP tools
routes/ REST (/api, /v1), MCP (/mcp), WebSocket (/api/ws)
tests/ Vitest integration tests
prisma/schema.prisma
UI/ React + Tailwind frontend (builds to UI/build)
Dockerfile multi-stage build (UI + backend)
docker-compose.yml backend + postgres
.gitea/workflows/ CI: build · lint · test · push image
```
## Running locally
Prerequisites: Node 24+, pnpm, and Postgres (or Docker).
```bash
# 1. Start Postgres (Docker)
docker run -d --name patchpass-db \
-e POSTGRES_USER=patchpass -e POSTGRES_PASSWORD=patchpass -e POSTGRES_DB=patchpass \
-p 5433:5432 postgres:16-alpine
# 2. Backend
cd Backend
cp ../example.env .env # then edit DATABASE_URL / INSTANCE_SECRET
pnpm install
pnpm generate
pnpm migrate # apply migrations
pnpm dev # http://localhost:5000
# 3. UI (separate terminal, for hot-reload dev)
cd UI
pnpm install
pnpm dev # http://localhost:3000 (proxies /api to :5000)
```
For a production-style run, `cd UI && pnpm build` — the backend then serves `UI/build` at `/`.
### Tests
```bash
cd Backend
pnpm test # spins up against the test database (see vitest.config.ts)
```
## Deploying
```bash
cp example.env .env # set DATABASE_URL=...@db:5432/..., a strong INSTANCE_SECRET
docker compose up -d --build
```
The backend applies migrations on boot and serves the UI. The first account to register becomes the **admin**.
## Connecting an agent
Create an agent in the UI, then use its API key.
**OpenClaw** — add to `~/.openclaw/openclaw.json`:
```json
{
"mcp": {
"servers": {
"patchpass": {
"transport": "streamable-http",
"url": "https://your-host/mcp",
"headers": { "x-api-key": "pp_agent_..." }
}
}
}
}
```
**REST**:
```bash
curl -X POST https://your-host/v1/change-requests \
-H "x-api-key: pp_agent_..." -H "Content-Type: application/json" \
-d '{"title":"Bump timeout","changes":[{"type":"config","path":"timeout","before":30,"after":60,"content_type":"integer"}]}'
```
Tell your agent: *before taking any consequential action, call `create_request`, wait for approval, then `consume_approval` before proceeding.*
## Privacy & data
Humans can export all their data as JSON and delete their account (cascading to agents, requests, and notifications) from **Settings**. Agent icon URLs are fetched once for validation and never stored. See the in-app Privacy Policy and Terms of Service.
## License
MIT-0
+17
View File
@@ -0,0 +1,17 @@
module.exports = {
root: true,
env: { browser: true, es2022: true },
parser: "@typescript-eslint/parser",
parserOptions: { ecmaVersion: "latest", sourceType: "module", ecmaFeatures: { jsx: true } },
plugins: ["@typescript-eslint", "react-hooks"],
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
settings: {},
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"no-empty": ["error", { allowEmptyCatch: true }],
},
ignorePatterns: ["build/", "node_modules/", "vite.config.ts"],
};
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%E2%9C%85%3C/text%3E%3C/svg%3E" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="PatchPass — a human approval layer for AI agents." />
<title>PatchPass</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "patchpass-ui",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"tailwind:build": "echo \"tailwind is bundled by vite\"",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.0",
"react-toastify": "^10.0.6"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.14",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^8.57.1",
"eslint-plugin-react-hooks": "^5.1.0",
"tailwindcss": "^4.1.14",
"typescript": "^5.9.3",
"vite": "^6.0.7"
}
}
+2503
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
'@tailwindcss/oxide': true
esbuild: true
+150
View File
@@ -0,0 +1,150 @@
import type {
Agent,
AdminUser,
AuditLog,
ChangeRequest,
GlobalSettings,
Notification,
RequestState,
User,
} from "./types";
export class ApiError extends Error {
status: number;
data?: unknown;
constructor(message: string, status: number, data?: unknown) {
super(message);
this.status = status;
this.data = data;
}
}
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
credentials: "include",
headers:
opts.body && !(opts.headers && "Content-Type" in opts.headers)
? { "Content-Type": "application/json", ...(opts.headers || {}) }
: opts.headers,
...opts,
});
let body: any = null;
try {
body = await res.json();
} catch {
/* no body */
}
if (!res.ok || body?.status === "FAILED") {
throw new ApiError(body?.message || `Request failed (${res.status})`, res.status, body?.data);
}
return (body?.data ?? body) as T;
}
// ── Auth ──────────────────────────────────────────────────────────────────────
export const auth = {
me: () => api<User>("/api/auth/me"),
login: (username: string, password: string, totp?: string) =>
api<User>("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password, totp }) }),
register: (username: string, display_name: string, password: string) =>
api<User>("/api/auth/register", {
method: "POST",
body: JSON.stringify({ username, display_name, password }),
}),
logout: () => api("/api/auth/logout", { method: "POST" }),
setup2fa: () => api<{ secret: string; otpauth_url: string }>("/api/auth/2fa/setup", { method: "POST" }),
enable2fa: (totp: string) => api("/api/auth/2fa/enable", { method: "POST", body: JSON.stringify({ totp }) }),
disable2fa: (password: string) =>
api("/api/auth/2fa/disable", { method: "POST", body: JSON.stringify({ password }) }),
};
// ── Account ────────────────────────────────────────────────────────────────────
export const account = {
updateProfile: (display_name: string) =>
api("/api/account/profile", { method: "PATCH", body: JSON.stringify({ display_name }) }),
changePassword: (current_password: string, new_password: string) =>
api("/api/account/password", {
method: "POST",
body: JSON.stringify({ current_password, new_password }),
}),
updateSettings: (settings: { auto_delete_enabled?: boolean; auto_delete_days?: number }) =>
api<{ auto_delete_enabled: boolean; auto_delete_days: number }>("/api/account/settings", {
method: "PATCH",
body: JSON.stringify(settings),
}),
deleteAccount: (password: string) =>
api("/api/account", { method: "DELETE", body: JSON.stringify({ password, confirm: "DELETE" }) }),
exportUrl: "/api/account/export",
};
// ── Agents ─────────────────────────────────────────────────────────────────────
export const agents = {
list: () => api<Agent[]>("/api/agents"),
create: (input: Partial<Agent>) => api<Agent>("/api/agents", { method: "POST", body: JSON.stringify(input) }),
update: (id: number, input: Partial<Agent>) =>
api<Agent>(`/api/agents/${id}`, { method: "PATCH", body: JSON.stringify(input) }),
regenerateKey: (id: number) => api<Agent>(`/api/agents/${id}/regenerate-key`, { method: "POST" }),
setDisabled: (id: number, disabled: boolean) =>
api<Agent>(`/api/agents/${id}/disabled`, { method: "POST", body: JSON.stringify({ disabled }) }),
remove: (id: number) => api(`/api/agents/${id}`, { method: "DELETE" }),
};
// ── Change requests (human) ──────────────────────────────────────────────────────
export const requests = {
list: (params: { page?: number; page_size?: number; state?: string; agent_id?: number } = {}) => {
const q = new URLSearchParams();
if (params.page) q.set("page", String(params.page));
if (params.page_size) q.set("page_size", String(params.page_size));
if (params.state) q.set("state", params.state);
if (params.agent_id) q.set("agent_id", String(params.agent_id));
return api<{ page: number; page_size: number; total: number; requests: ChangeRequest[] }>(
`/api/change-requests?${q.toString()}`,
);
},
summary: () => api<{ counts: Record<RequestState, number> }>("/api/change-requests/summary"),
get: (id: string) => api<ChangeRequest>(`/api/change-requests/${id}`),
decide: (id: string, decision: "APPROVE" | "REJECT" | "REQUEST_CHANGES", comment?: string) =>
api<ChangeRequest>(`/api/change-requests/${id}/decision`, {
method: "POST",
body: JSON.stringify({ decision, comment }),
}),
};
// ── Notifications ────────────────────────────────────────────────────────────────
export const notifications = {
list: (params: { page?: number; page_size?: number; unread?: boolean } = {}) => {
const q = new URLSearchParams();
if (params.page) q.set("page", String(params.page));
if (params.page_size) q.set("page_size", String(params.page_size));
if (params.unread) q.set("unread", "true");
return api<{ total: number; unread_count: number; notifications: Notification[] }>(
`/api/notifications?${q.toString()}`,
);
},
markRead: (id: number) => api(`/api/notifications/${id}/read`, { method: "POST" }),
markAllRead: () => api("/api/notifications/read-all", { method: "POST" }),
clear: () => api("/api/notifications", { method: "DELETE" }),
};
// ── Global ───────────────────────────────────────────────────────────────────────
export const global = {
get: () => api<GlobalSettings>("/api/global"),
};
// ── Admin ─────────────────────────────────────────────────────────────────────────
export const admin = {
users: () => api<AdminUser[]>("/api/admin/users"),
updateUser: (id: number, input: { role?: "ADMIN" | "USER"; disabled?: boolean }) =>
api(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(input) }),
deleteUser: (id: number) => api(`/api/admin/users/${id}`, { method: "DELETE" }),
settings: () => api<GlobalSettings>("/api/admin/settings"),
updateSettings: (input: { registration_enabled?: boolean; requests_enabled?: boolean }) =>
api<GlobalSettings>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(input) }),
agents: () => api<(Agent & { owner: { id: number; username: string } })[]>("/api/admin/agents"),
setAgentDisabled: (id: number, disabled: boolean) =>
api(`/api/admin/agents/${id}/disabled`, { method: "POST", body: JSON.stringify({ disabled }) }),
deleteAgent: (id: number) => api(`/api/admin/agents/${id}`, { method: "DELETE" }),
auditLogs: (page = 1) =>
api<{ total: number; page: number; page_size: number; logs: AuditLog[] }>(
`/api/admin/audit-logs?page=${page}`,
),
};
+125
View File
@@ -0,0 +1,125 @@
export type RequestState =
| "PENDING"
| "CHANGES_REQUESTED"
| "APPROVED"
| "REJECTED"
| "EXPIRED"
| "CONSUMED"
| "CANCELLED";
export type User = {
id: number;
username: string;
display_name: string;
role: "ADMIN" | "USER";
totp_enabled: boolean;
auto_delete_enabled: boolean;
auto_delete_days: number;
};
export type Agent = {
id: number;
name: string;
description: string | null;
website: string | null;
icon_url: string | null;
disabled: boolean;
max_pending_requests: number;
created_at: string;
updated_at: string;
api_key?: string;
api_key_masked?: string;
pending_count?: number;
};
export type UnifiedDiffChange = { type: "unified_diff"; path: string; content: string };
export type ConfigChange = {
type: "config";
path: string;
before?: unknown;
after?: unknown;
content_type?: string | null;
};
export type CustomChange = { type: "custom"; label: string; before?: unknown; after?: unknown };
export type Change = UnifiedDiffChange | ConfigChange | CustomChange;
export type Receipt = {
payload: {
request_id: string;
decision: "APPROVED" | "REJECTED";
content_hash: string;
approver_id: number;
decided_at: string;
};
signature: string;
algorithm: string;
consumed: boolean;
consumed_at: string | null;
};
export type ChangeRequest = {
request_id: string;
title: string;
description: string | null;
changes: Change[];
metadata: Record<string, unknown> | null;
content_hash: string;
state: RequestState;
comment: string | null;
expires_at: string;
created_at: string;
updated_at: string;
decided_at: string | null;
consumed_at: string | null;
cancelled_at: string | null;
update_count: number;
resubmitted: boolean;
approval_url: string;
receipt: Receipt | null;
agent?: {
id: number;
name: string;
description: string | null;
website: string | null;
icon_url: string | null;
disabled: boolean;
} | null;
};
export type Notification = {
id: number;
type: string;
title: string;
message: string;
request_id: string | null;
read: boolean;
created_at: string;
};
export type GlobalSettings = {
version?: string;
registration_enabled: boolean;
requests_enabled: boolean;
};
export type AdminUser = {
id: number;
username: string;
display_name: string;
role: "ADMIN" | "USER";
disabled: boolean;
totp_enabled: boolean;
created_at: string;
agent_count: number;
request_count: number;
};
export type AuditLog = {
id: number;
action: string;
detail: string | null;
target_type: string | null;
target_id: string | null;
actor: { id: number; username: string } | null;
created_at: string;
};
+40
View File
@@ -0,0 +1,40 @@
import { useState } from "react";
export function AgentAvatar({
name,
iconUrl,
size = 36,
}: {
name: string;
iconUrl?: string | null;
size?: number;
}) {
const [errored, setErrored] = useState(false);
const initials = name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? "")
.join("");
if (iconUrl && !errored) {
return (
<img
src={iconUrl}
alt={name}
width={size}
height={size}
onError={() => setErrored(true)}
className="rounded-lg border border-border object-cover"
style={{ width: size, height: size }}
/>
);
}
return (
<div
className="flex items-center justify-center rounded-lg border border-border bg-primary-dim/40 font-semibold text-primary"
style={{ width: size, height: size, fontSize: size * 0.4 }}
>
{initials || "?"}
</div>
);
}
+250
View File
@@ -0,0 +1,250 @@
import { useState } from "react";
import { toast } from "react-toastify";
import { agents as agentsApi } from "../api/client";
import type { Agent } from "../api/types";
import { Modal } from "./Modal";
import { Button, Input, Textarea } from "./ui";
import { CodeBlock } from "./CodeBlock";
// ── Create / edit form ───────────────────────────────────────────────────────────
export function AgentFormModal({
open,
onClose,
agent,
onSaved,
}: {
open: boolean;
onClose: () => void;
agent?: Agent | null;
onSaved: (agent: Agent, created: boolean) => void;
}) {
const editing = !!agent;
const [name, setName] = useState(agent?.name ?? "");
const [description, setDescription] = useState(agent?.description ?? "");
const [website, setWebsite] = useState(agent?.website ?? "");
const [iconUrl, setIconUrl] = useState(agent?.icon_url ?? "");
const [maxPending, setMaxPending] = useState(agent?.max_pending_requests ?? 5);
const [loading, setLoading] = useState(false);
const submit = async () => {
if (!name.trim()) return toast.error("Name is required");
setLoading(true);
try {
const payload = {
name: name.trim(),
description: description.trim() || null,
website: website.trim() || null,
icon_url: iconUrl.trim() || null,
max_pending_requests: maxPending,
};
const saved = editing
? await agentsApi.update(agent!.id, payload)
: await agentsApi.create(payload);
toast.success(editing ? "Agent updated" : "Agent created");
onSaved(saved, !editing);
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed");
} finally {
setLoading(false);
}
};
return (
<Modal
open={open}
onClose={onClose}
title={editing ? "Edit agent" : "New agent"}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={submit} loading={loading}>
{editing ? "Save" : "Create agent"}
</Button>
</>
}
>
<div className="space-y-4">
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
<Textarea
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
<Input
label="Website (optional)"
value={website}
onChange={(e) => setWebsite(e.target.value)}
placeholder="https://…"
/>
<Input
label="Icon URL (optional)"
value={iconUrl}
onChange={(e) => setIconUrl(e.target.value)}
placeholder="https://…/icon.png"
hint="Verified on save. Must be a JPG, PNG, or GIF under 1 MB."
/>
<div>
<div className="mb-1 flex items-center justify-between text-sm">
<span className="text-muted">Max pending requests</span>
<span className="font-medium text-text">{maxPending}</span>
</div>
<input
type="range"
min={1}
max={10}
value={maxPending}
onChange={(e) => setMaxPending(Number(e.target.value))}
className="w-full accent-[var(--color-primary)]"
/>
<p className="mt-1 text-xs text-faint">
How many requests this agent may have awaiting review at once (110).
</p>
</div>
</div>
</Modal>
);
}
// ── Connect (config + key) ─────────────────────────────────────────────────────────
type Tab = "openclaw" | "mcp" | "rest";
export function ConnectModal({
open,
onClose,
agent,
revealedKey,
}: {
open: boolean;
onClose: () => void;
agent: Agent;
revealedKey?: string | null;
}) {
const [tab, setTab] = useState<Tab>("openclaw");
const origin = window.location.origin;
const key = revealedKey ?? "YOUR_API_KEY";
const hasKey = !!revealedKey;
const openclawConfig = JSON.stringify(
{
mcp: {
servers: {
patchpass: {
transport: "streamable-http",
url: `${origin}/mcp`,
headers: { "x-api-key": key },
},
},
},
},
null,
2,
);
const mcpConfig = JSON.stringify(
{
mcpServers: {
patchpass: {
type: "streamable-http",
url: `${origin}/mcp`,
headers: { "x-api-key": key },
},
},
},
null,
2,
);
const restExample = `# Submit a change request
curl -X POST ${origin}/v1/change-requests \\
-H "x-api-key: ${key}" \\
-H "Content-Type: application/json" \\
-d '{
"title": "Update deployment",
"changes": [
{ "type": "config", "path": "timeout", "before": 30, "after": 60, "content_type": "integer" }
]
}'
# Poll for the decision
curl ${origin}/v1/change-requests/{request_id} -H "x-api-key: ${key}"
# Consume the approval before proceeding
curl -X POST ${origin}/v1/change-requests/{request_id}/consume -H "x-api-key: ${key}"`;
const tabs: { id: Tab; label: string }[] = [
{ id: "openclaw", label: "OpenClaw" },
{ id: "mcp", label: "MCP (generic)" },
{ id: "rest", label: "REST API" },
];
return (
<Modal open={open} onClose={onClose} title={`Connect "${agent.name}"`} wide footer={<Button onClick={onClose}>Done</Button>}>
<div className="space-y-4">
{hasKey ? (
<div className="rounded-lg border border-pending/40 bg-pending/10 p-3">
<p className="text-xs font-semibold text-pending">Save this API key now it won't be shown again.</p>
<div className="mt-2">
<CodeBlock code={revealedKey!} />
</div>
</div>
) : (
<p className="text-sm text-muted">
The API key is only shown once, at creation or after regeneration. Configs below use a
placeholder substitute your saved key.
</p>
)}
<div className="flex gap-1 border-b border-border">
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`px-3 py-2 text-sm font-medium transition ${
tab === t.id ? "border-b-2 border-primary text-text" : "text-muted hover:text-text"
}`}
>
{t.label}
</button>
))}
</div>
{tab === "openclaw" && (
<div className="space-y-2">
<p className="text-sm text-muted">
Add this to <code className="text-text">~/.openclaw/openclaw.json</code>, then tell your
agent to request approval via PatchPass before taking action.
</p>
<CodeBlock code={openclawConfig} language="json" />
</div>
)}
{tab === "mcp" && (
<div className="space-y-2">
<p className="text-sm text-muted">
Generic streamable-HTTP MCP client config (Claude Desktop, Cursor, etc.).
</p>
<CodeBlock code={mcpConfig} language="json" />
</div>
)}
{tab === "rest" && (
<div className="space-y-2">
<p className="text-sm text-muted">Or call the REST API directly with the agent key.</p>
<CodeBlock code={restExample} language="bash" />
</div>
)}
<div className="rounded-lg border border-border bg-surface-raised/40 p-3 text-xs text-muted">
<p className="mb-1 font-semibold text-text">Recommended agent instruction</p>
Before taking any consequential action (deploys, config changes, code edits), call
<code className="mx-1 text-text">create_request</code>, wait for approval, then
<code className="mx-1 text-text">consume_approval</code> before proceeding.
</div>
</div>
</Modal>
);
}
+100
View File
@@ -0,0 +1,100 @@
import type { Change } from "../api/types";
function DiffView({ content }: { content: string }) {
const lines = content.split("\n");
return (
<pre className="overflow-x-auto rounded-lg border border-border bg-bg p-3 text-xs leading-relaxed">
<code>
{lines.map((line, i) => {
let cls = "";
if (line.startsWith("+++") || line.startsWith("---")) cls = "diff-meta";
else if (line.startsWith("@@")) cls = "diff-hunk";
else if (line.startsWith("+")) cls = "diff-add";
else if (line.startsWith("-")) cls = "diff-del";
else if (line.startsWith("diff ") || line.startsWith("index ")) cls = "diff-meta";
return (
<div key={i} className={`whitespace-pre px-1 ${cls}`}>
{line || " "}
</div>
);
})}
</code>
</pre>
);
}
function fmtValue(v: unknown): string {
if (v === null || v === undefined) return "∅";
if (typeof v === "object") return JSON.stringify(v);
return String(v);
}
function percentDelta(before: unknown, after: unknown): string | null {
const b = Number(before);
const a = Number(after);
if (!Number.isFinite(b) || !Number.isFinite(a) || b === 0) return null;
const pct = Math.round(((a - b) / Math.abs(b)) * 100);
if (pct === 0) return null;
return `${pct > 0 ? "+" : ""}${pct}%`;
}
function BeforeAfter({ before, after, contentType }: { before: unknown; after: unknown; contentType?: string | null }) {
const delta =
contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null;
return (
<div className="flex flex-wrap items-center gap-2 text-sm">
<code className="diff-del rounded px-2 py-0.5 font-mono">{fmtValue(before)}</code>
<span className="text-faint"></span>
<code className="diff-add rounded px-2 py-0.5 font-mono">{fmtValue(after)}</code>
{delta && <span className="rounded bg-primary-dim/40 px-2 py-0.5 text-xs text-primary">{delta}</span>}
{contentType && <span className="text-xs text-faint">({contentType})</span>}
</div>
);
}
function typeChip(label: string, color: string) {
return <span className={`rounded px-2 py-0.5 text-xs font-medium ${color}`}>{label}</span>;
}
export function ChangeCard({ change, index }: { change: Change; index: number }) {
return (
<div className="rounded-xl border border-border bg-surface-raised/50 p-4">
<div className="mb-3 flex items-center gap-2">
<span className="text-xs font-mono text-faint">#{index + 1}</span>
{change.type === "unified_diff" && (
<>
{typeChip("diff", "bg-primary-dim/40 text-primary")}
<code className="text-sm text-text">{change.path}</code>
</>
)}
{change.type === "config" && (
<>
{typeChip("config", "bg-accent/15 text-accent")}
<code className="text-sm text-text">{change.path}</code>
</>
)}
{change.type === "custom" && (
<>
{typeChip("custom", "bg-changes/15 text-changes")}
<span className="text-sm text-text">{change.label}</span>
</>
)}
</div>
{change.type === "unified_diff" && <DiffView content={change.content} />}
{change.type === "config" && (
<BeforeAfter before={change.before} after={change.after} contentType={change.content_type} />
)}
{change.type === "custom" && <BeforeAfter before={change.before} after={change.after} />}
</div>
);
}
export function ChangeList({ changes }: { changes: Change[] }) {
return (
<div className="space-y-3">
{changes.map((c, i) => (
<ChangeCard key={i} change={c} index={i} />
))}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { useState } from "react";
export function CodeBlock({ code, language }: { code: string; language?: string }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* ignore */
}
};
return (
<div className="relative">
{language && (
<span className="absolute left-3 top-2 text-[10px] uppercase tracking-wide text-faint">
{language}
</span>
)}
<button
onClick={copy}
className="absolute right-2 top-2 rounded-md border border-border-strong bg-surface px-2 py-1 text-xs text-muted hover:text-text"
>
{copied ? "Copied!" : "Copy"}
</button>
<pre className={`overflow-x-auto rounded-lg border border-border bg-bg p-3 ${language ? "pt-7" : ""} text-xs leading-relaxed text-text`}>
<code>{code}</code>
</pre>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { requests } from "../api/client";
import type { ChangeRequest } from "../api/types";
import { Modal } from "./Modal";
import { Button } from "./ui";
type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES";
const MAX = 500;
const meta: Record<Decision, { title: string; verb: string; variant: "success" | "danger" | "primary"; blurb: string; commentRequired: boolean }> = {
APPROVE: {
title: "Approve request",
verb: "Approve",
variant: "success",
blurb: "The agent will be allowed to proceed. A platform-signed receipt will be issued.",
commentRequired: false,
},
REJECT: {
title: "Reject request",
verb: "Reject",
variant: "danger",
blurb: "This is a hard blocker. The agent must submit a new request to try again.",
commentRequired: false,
},
REQUEST_CHANGES: {
title: "Request changes",
verb: "Request changes",
variant: "primary",
blurb: "The agent will see your notes and can update the request for re-review.",
commentRequired: true,
},
};
export function DecisionModal({
request,
decision,
onClose,
onDone,
}: {
request: ChangeRequest;
decision: Decision | null;
onClose: () => void;
onDone: (updated: ChangeRequest) => void;
}) {
const [comment, setComment] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
setComment("");
}, [decision]);
if (!decision) return null;
const m = meta[decision];
const tooLong = comment.length > MAX;
const missingRequired = m.commentRequired && comment.trim().length === 0;
const submit = async () => {
if (tooLong || missingRequired) return;
setLoading(true);
try {
const updated = await requests.decide(request.request_id, decision, comment.trim() || undefined);
toast.success(`${m.verb}d`);
onDone(updated);
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed");
} finally {
setLoading(false);
}
};
return (
<Modal
open={!!decision}
onClose={onClose}
title={m.title}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant={m.variant} onClick={submit} loading={loading} disabled={tooLong || missingRequired}>
{m.verb}
</Button>
</>
}
>
<div className="space-y-4">
<div className="rounded-lg border border-border bg-surface-raised/50 p-3">
<p className="text-sm font-medium text-text">{request.title}</p>
<p className="mt-1 text-xs text-muted">
{request.changes.length} change{request.changes.length === 1 ? "" : "s"} ·{" "}
{request.agent?.name}
</p>
</div>
<p className="text-sm text-muted">{m.blurb}</p>
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-sm text-muted">
Comment {m.commentRequired ? <span className="text-changes">(required)</span> : "(optional)"}
</span>
<span className={`text-xs ${tooLong ? "text-rejected" : comment.length > MAX * 0.8 ? "text-pending" : "text-faint"}`}>
{comment.length}/{MAX}
</span>
</div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
autoFocus
placeholder={m.commentRequired ? "Explain what needs to change…" : "Add an optional note…"}
className={`w-full rounded-lg border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${
tooLong ? "border-rejected" : "border-border-strong"
}`}
/>
{tooLong && <p className="mt-1 text-xs text-rejected">Comment is too long (max {MAX}).</p>}
{missingRequired && (
<p className="mt-1 text-xs text-changes">A comment is required when requesting changes.</p>
)}
</div>
</div>
</Modal>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { ReactNode, useState } from "react";
import { Link, NavLink, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { NotificationBell } from "./NotificationBell";
const navItems = [
{ to: "/", label: "Dashboard", end: true },
{ to: "/requests", label: "Requests" },
{ to: "/agents", label: "Agents" },
{ to: "/settings", label: "Settings" },
];
export function Layout({ children }: { children: ReactNode }) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="min-h-screen">
<header className="sticky top-0 z-30 border-b border-border bg-bg/80 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<div className="flex items-center gap-6">
<Link to="/" className="flex items-center gap-2">
<span className="text-xl"></span>
<span className="text-lg font-bold tracking-tight">
Patch<span className="text-primary">Pass</span>
</span>
</Link>
<nav className="hidden items-center gap-1 md:flex">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`rounded-lg px-3 py-1.5 text-sm font-medium transition ${
isActive ? "bg-surface-raised text-text" : "text-muted hover:text-text"
}`
}
>
{item.label}
</NavLink>
))}
{user?.role === "ADMIN" && (
<NavLink
to="/admin"
className={({ isActive }) =>
`rounded-lg px-3 py-1.5 text-sm font-medium transition ${
isActive ? "bg-surface-raised text-accent" : "text-accent/70 hover:text-accent"
}`
}
>
Admin
</NavLink>
)}
</nav>
</div>
<div className="flex items-center gap-2">
<NotificationBell />
<div className="relative">
<button
onClick={() => setMenuOpen((o) => !o)}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-surface-raised"
>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-primary-dim/50 text-xs font-semibold text-primary">
{user?.display_name?.[0]?.toUpperCase()}
</span>
<span className="hidden text-text sm:inline">{user?.display_name}</span>
</button>
{menuOpen && (
<div
className="animate-fade-in absolute right-0 mt-2 w-44 rounded-xl border border-border-strong bg-surface py-1 shadow-2xl"
onMouseLeave={() => setMenuOpen(false)}
>
<Link
to="/settings"
className="block px-4 py-2 text-sm text-muted hover:bg-surface-raised hover:text-text"
onClick={() => setMenuOpen(false)}
>
Settings
</Link>
<Link
to="/notifications"
className="block px-4 py-2 text-sm text-muted hover:bg-surface-raised hover:text-text md:hidden"
onClick={() => setMenuOpen(false)}
>
Notifications
</Link>
<button
onClick={async () => {
await logout();
navigate("/login");
}}
className="block w-full px-4 py-2 text-left text-sm text-rejected hover:bg-surface-raised"
>
Log out
</button>
</div>
)}
</div>
</div>
</div>
{/* mobile nav */}
<nav className="flex items-center gap-1 overflow-x-auto border-t border-border px-4 py-2 md:hidden">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium ${
isActive ? "bg-surface-raised text-text" : "text-muted"
}`
}
>
{item.label}
</NavLink>
))}
{user?.role === "ADMIN" && (
<NavLink to="/admin" className="whitespace-nowrap rounded-lg px-3 py-1.5 text-sm text-accent">
Admin
</NavLink>
)}
</nav>
</header>
<main className="mx-auto max-w-6xl px-4 py-8">{children}</main>
<footer className="mx-auto max-w-6xl px-4 py-8 text-center text-xs text-faint">
<div className="flex flex-wrap items-center justify-center gap-3">
<Link to="/privacy" className="hover:text-muted">
Privacy Policy
</Link>
<span>·</span>
<Link to="/terms" className="hover:text-muted">
Terms of Service
</Link>
<span>·</span>
<span>PatchPass review changes, approve intent, let agents proceed.</span>
</div>
</footer>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { ReactNode, useEffect } from "react";
export function Modal({
open,
onClose,
title,
children,
footer,
wide,
}: {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
wide?: boolean;
}) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div
className={`animate-fade-in relative z-10 w-full ${wide ? "max-w-3xl" : "max-w-lg"} rounded-2xl border border-border-strong bg-surface shadow-2xl`}
>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<h2 className="text-lg font-semibold text-text">{title}</h2>
<button
onClick={onClose}
className="rounded-lg p-1 text-muted hover:bg-surface-raised hover:text-text"
aria-label="Close"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="max-h-[70vh] overflow-y-auto px-5 py-4">{children}</div>
{footer && <div className="flex justify-end gap-2 border-t border-border px-5 py-4">{footer}</div>}
</div>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNotifications } from "../context/NotificationsContext";
import { relativeTime } from "../utils";
export function NotificationBell() {
const { items, unreadCount, markRead, markAllRead, clear } = useNotifications();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((o) => !o)}
className="relative rounded-lg p-2 text-muted transition hover:bg-surface-raised hover:text-text"
aria-label="Notifications"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path
d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 01-3.4 0"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-rejected px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{open && (
<div className="animate-fade-in absolute right-0 z-40 mt-2 w-80 rounded-xl border border-border-strong bg-surface shadow-2xl">
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<span className="text-sm font-semibold">Notifications</span>
<div className="flex gap-2 text-xs">
<button className="text-muted hover:text-text" onClick={() => markAllRead()}>
Mark all read
</button>
<button className="text-muted hover:text-rejected" onClick={() => clear()}>
Clear
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{items.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted">No notifications yet</p>
)}
{items.map((n) => (
<button
key={n.id}
onClick={() => {
markRead(n.id);
setOpen(false);
if (n.request_id) navigate(`/requests/${n.request_id}`);
}}
className={`flex w-full flex-col items-start gap-0.5 border-b border-border/60 px-4 py-3 text-left transition hover:bg-surface-raised ${
n.read ? "opacity-60" : ""
}`}
>
<div className="flex w-full items-center gap-2">
{!n.read && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />}
<span className="text-sm font-medium text-text">{n.title}</span>
</div>
<span className="text-xs text-muted">{n.message}</span>
<span className="text-[11px] text-faint">{relativeTime(n.created_at)}</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { RequestState } from "../api/types";
const config: Record<RequestState, { label: string; cls: string; dot: string }> = {
PENDING: { label: "Pending", cls: "bg-pending/15 text-pending border-pending/30", dot: "bg-pending" },
CHANGES_REQUESTED: {
label: "Changes requested",
cls: "bg-changes/15 text-changes border-changes/30",
dot: "bg-changes",
},
APPROVED: { label: "Approved", cls: "bg-approved/15 text-approved border-approved/30", dot: "bg-approved" },
REJECTED: { label: "Rejected", cls: "bg-rejected/15 text-rejected border-rejected/30", dot: "bg-rejected" },
EXPIRED: { label: "Expired", cls: "bg-expired/15 text-expired border-expired/30", dot: "bg-expired" },
CONSUMED: { label: "Consumed", cls: "bg-consumed/15 text-consumed border-consumed/30", dot: "bg-consumed" },
CANCELLED: { label: "Cancelled", cls: "bg-cancelled/15 text-cancelled border-cancelled/30", dot: "bg-cancelled" },
};
export function StateBadge({ state, className = "" }: { state: RequestState; className?: string }) {
const c = config[state];
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${c.cls} ${className}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${c.dot}`} />
{c.label}
</span>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "react";
type Variant = "primary" | "secondary" | "ghost" | "danger" | "success";
const variants: Record<Variant, string> = {
primary: "bg-primary text-white hover:brightness-110 border border-transparent",
secondary: "bg-surface-raised text-text hover:bg-border border border-border-strong",
ghost: "bg-transparent text-muted hover:text-text hover:bg-surface-raised border border-transparent",
danger: "bg-rejected/90 text-white hover:bg-rejected border border-transparent",
success: "bg-approved/90 text-[#06231a] hover:bg-approved border border-transparent font-semibold",
};
export function Button({
variant = "primary",
loading,
children,
className = "",
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant; loading?: boolean }) {
return (
<button
{...props}
disabled={props.disabled || loading}
className={`inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition disabled:opacity-50 disabled:cursor-not-allowed ${variants[variant]} ${className}`}
>
{loading && <Spinner className="h-4 w-4" />}
{children}
</button>
);
}
export function Spinner({ className = "h-5 w-5" }: { className?: string }) {
return (
<svg className={`animate-spin ${className}`} viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-90" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
);
}
export function Card({
children,
className = "",
onClick,
}: {
children: ReactNode;
className?: string;
onClick?: () => void;
}) {
return (
<div onClick={onClick} className={`rounded-xl border border-border bg-surface ${className}`}>
{children}
</div>
);
}
export function Input({ label, hint, className = "", ...props }: InputHTMLAttributes<HTMLInputElement> & { label?: string; hint?: string }) {
return (
<label className="block">
{label && <span className="mb-1 block text-sm text-muted">{label}</span>}
<input
{...props}
className={`w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${className}`}
/>
{hint && <span className="mt-1 block text-xs text-faint">{hint}</span>}
</label>
);
}
export function Textarea({ label, className = "", ...props }: TextareaHTMLAttributes<HTMLTextAreaElement> & { label?: string }) {
return (
<label className="block">
{label && <span className="mb-1 block text-sm text-muted">{label}</span>}
<textarea
{...props}
className={`w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${className}`}
/>
</label>
);
}
export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className="inline-flex items-center gap-3"
>
<span
className={`relative h-6 w-11 rounded-full transition ${checked ? "bg-primary" : "bg-border-strong"}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all ${checked ? "left-[22px]" : "left-0.5"}`}
/>
</span>
{label && <span className="text-sm text-text">{label}</span>}
</button>
);
}
export function EmptyState({ title, subtitle, icon }: { title: string; subtitle?: string; icon?: string }) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-16 text-center">
{icon && <div className="mb-3 text-4xl opacity-70">{icon}</div>}
<p className="text-text font-medium">{title}</p>
{subtitle && <p className="mt-1 max-w-md text-sm text-muted">{subtitle}</p>}
</div>
);
}
export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: string; actions?: ReactNode }) {
return (
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight text-text">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>}
</div>
{actions && <div className="flex gap-2">{actions}</div>}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { createContext, useCallback, useContext, useEffect, useState, ReactNode } from "react";
import { auth as authApi, global as globalApi } from "../api/client";
import type { GlobalSettings, User } from "../api/types";
type AuthContextValue = {
user: User | null;
loading: boolean;
settings: GlobalSettings | null;
setUser: (u: User | null) => void;
refresh: () => Promise<void>;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue>({
user: null,
loading: true,
settings: null,
setUser: () => {},
refresh: async () => {},
logout: async () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [settings, setSettings] = useState<GlobalSettings | null>(null);
const refresh = useCallback(async () => {
try {
const me = await authApi.me();
setUser(me);
} catch {
setUser(null);
}
}, []);
useEffect(() => {
(async () => {
globalApi.get().then(setSettings).catch(() => {});
await refresh();
setLoading(false);
})();
}, [refresh]);
const logout = useCallback(async () => {
await authApi.logout().catch(() => {});
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ user, loading, settings, setUser, refresh, logout }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
+118
View File
@@ -0,0 +1,118 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, ReactNode } from "react";
import { toast } from "react-toastify";
import { notifications as notifApi } from "../api/client";
import type { Notification } from "../api/types";
import { useAuth } from "./AuthContext";
type NotificationsContextValue = {
items: Notification[];
unreadCount: number;
reload: () => Promise<void>;
markRead: (id: number) => Promise<void>;
markAllRead: () => Promise<void>;
clear: () => Promise<void>;
/** Bumps whenever a realtime request event arrives, so lists can refresh. */
requestEventTick: number;
};
const NotificationsContext = createContext<NotificationsContextValue>({
items: [],
unreadCount: 0,
reload: async () => {},
markRead: async () => {},
markAllRead: async () => {},
clear: async () => {},
requestEventTick: 0,
});
export function NotificationsProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const [items, setItems] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [requestEventTick, setRequestEventTick] = useState(0);
const wsRef = useRef<WebSocket | null>(null);
const reload = useCallback(async () => {
if (!user) return;
try {
const data = await notifApi.list({ page_size: 30 });
setItems(data.notifications);
setUnreadCount(data.unread_count);
} catch {
/* ignore */
}
}, [user]);
const markRead = useCallback(async (id: number) => {
await notifApi.markRead(id).catch(() => {});
setItems((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)));
setUnreadCount((c) => Math.max(0, c - 1));
}, []);
const markAllRead = useCallback(async () => {
await notifApi.markAllRead().catch(() => {});
setItems((prev) => prev.map((n) => ({ ...n, read: true })));
setUnreadCount(0);
}, []);
const clear = useCallback(async () => {
await notifApi.clear().catch(() => {});
setItems([]);
setUnreadCount(0);
}, []);
useEffect(() => {
if (!user) {
setItems([]);
setUnreadCount(0);
return;
}
reload();
// Realtime websocket
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${window.location.host}/api/ws/notifications`);
wsRef.current = ws;
let heartbeat: ReturnType<typeof setInterval> | null = null;
ws.onopen = () => {
heartbeat = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send("ping");
}, 25000);
};
ws.onmessage = (evt) => {
try {
const event = JSON.parse(evt.data);
if (event.kind === "notification") {
setItems((prev) => [event.notification, ...prev].slice(0, 50));
if (typeof event.unreadCount === "number") setUnreadCount(event.unreadCount);
else setUnreadCount((c) => c + 1);
toast.info(event.notification.title, { autoClose: 4000 });
setRequestEventTick((t) => t + 1);
} else if (event.kind === "request_event") {
setRequestEventTick((t) => t + 1);
}
} catch {
/* ignore */
}
};
ws.onclose = () => {
if (heartbeat) clearInterval(heartbeat);
};
return () => {
if (heartbeat) clearInterval(heartbeat);
ws.close();
};
}, [user, reload]);
return (
<NotificationsContext.Provider
value={{ items, unreadCount, reload, markRead, markAllRead, clear, requestEventTick }}
>
{children}
</NotificationsContext.Provider>
);
}
export const useNotifications = () => useContext(NotificationsContext);
+94
View File
@@ -0,0 +1,94 @@
@import "tailwindcss";
/* PatchPass design tokens (Tailwind v4 @theme) */
@theme {
--color-bg: #0a0e17;
--color-surface: #111726;
--color-surface-raised: #1a2234;
--color-border: #232c40;
--color-border-strong: #313c56;
--color-text: #e6ebf5;
--color-muted: #8b96ad;
--color-faint: #5c6780;
--color-primary: #6d8bff;
--color-primary-dim: #3a4a86;
--color-accent: #5be0c8;
--color-pending: #f4b740;
--color-approved: #3ecf8e;
--color-rejected: #ff6b6b;
--color-changes: #b985ff;
--color-expired: #7a8296;
--color-consumed: #5be0c8;
--color-cancelled: #7a8296;
}
html,
body,
#root {
background-color: var(--color-bg);
color: var(--color-text);
min-height: 100%;
margin: 0;
}
body {
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial,
sans-serif;
-webkit-font-smoothing: antialiased;
}
button:hover,
a:hover {
cursor: pointer;
}
* {
scrollbar-color: var(--color-border-strong) transparent;
}
/* Diff syntax coloring */
.diff-add {
background-color: rgba(62, 207, 142, 0.13);
color: #9ef0c4;
}
.diff-del {
background-color: rgba(255, 107, 107, 0.13);
color: #ffb0b0;
}
.diff-hunk {
color: var(--color-primary);
}
.diff-meta {
color: var(--color-faint);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.18s ease-out;
}
@keyframes pulseRing {
0% {
box-shadow: 0 0 0 0 rgba(185, 133, 255, 0.5);
}
70% {
box-shadow: 0 0 0 8px rgba(185, 133, 255, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(185, 133, 255, 0);
}
}
.animate-pulse-ring {
animation: pulseRing 1.8s ease-out infinite;
}
+73
View File
@@ -0,0 +1,73 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import "./index.css";
import { AuthProvider, useAuth } from "./context/AuthContext";
import { NotificationsProvider } from "./context/NotificationsContext";
import { Layout } from "./components/Layout";
import { Spinner } from "./components/ui";
import { LoginPage } from "./pages/Login";
import { RegisterPage } from "./pages/Register";
import { DashboardPage } from "./pages/Dashboard";
import { RequestsPage } from "./pages/Requests";
import { RequestDetailPage } from "./pages/RequestDetail";
import { AgentsPage } from "./pages/Agents";
import { NotificationsPage } from "./pages/Notifications";
import { SettingsPage } from "./pages/Settings";
import { AdminPage } from "./pages/Admin";
import { PrivacyPage, TermsPage } from "./pages/Legal";
function FullScreenLoader() {
return (
<div className="flex min-h-screen items-center justify-center text-primary">
<Spinner className="h-8 w-8" />
</div>
);
}
function Protected({ children, adminOnly }: { children: React.ReactNode; adminOnly?: boolean }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <FullScreenLoader />;
if (!user) return <Navigate to="/login" replace state={{ from: location }} />;
if (adminOnly && user.role !== "ADMIN") return <Navigate to="/" replace />;
return (
<NotificationsProvider>
<Layout>{children}</Layout>
</NotificationsProvider>
);
}
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/terms" element={<TermsPage />} />
<Route path="/" element={<Protected><DashboardPage /></Protected>} />
<Route path="/requests" element={<Protected><RequestsPage /></Protected>} />
<Route path="/requests/:id" element={<Protected><RequestDetailPage /></Protected>} />
<Route path="/agents" element={<Protected><AgentsPage /></Protected>} />
<Route path="/notifications" element={<Protected><NotificationsPage /></Protected>} />
<Route path="/settings" element={<Protected><SettingsPage /></Protected>} />
<Route path="/admin" element={<Protected adminOnly><AdminPage /></Protected>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
<ToastContainer position="bottom-right" theme="dark" newestOnTop />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
);
+288
View File
@@ -0,0 +1,288 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "react-toastify";
import { admin } from "../api/client";
import type { AdminUser, AuditLog, Agent, GlobalSettings } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { Button, Card, PageHeader, Spinner, Toggle } from "../components/ui";
import { relativeTime } from "../utils";
type Tab = "users" | "agents" | "settings" | "audit";
export function AdminPage() {
const [tab, setTab] = useState<Tab>("users");
const tabs: { id: Tab; label: string }[] = [
{ id: "users", label: "Users" },
{ id: "agents", label: "Agents" },
{ id: "settings", label: "Global settings" },
{ id: "audit", label: "Audit logs" },
];
return (
<div>
<PageHeader title="Admin" subtitle="Platform administration." />
<div className="mb-6 flex gap-1 border-b border-border">
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`px-4 py-2 text-sm font-medium transition ${
tab === t.id ? "border-b-2 border-accent text-text" : "text-muted hover:text-text"
}`}
>
{t.label}
</button>
))}
</div>
{tab === "users" && <UsersTab />}
{tab === "agents" && <AgentsTab />}
{tab === "settings" && <SettingsTab />}
{tab === "audit" && <AuditTab />}
</div>
);
}
function UsersTab() {
const { user: me } = useAuth();
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setUsers(await admin.users());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const setRole = async (u: AdminUser, role: "ADMIN" | "USER") => {
try {
await admin.updateUser(u.id, { role });
toast.success("Role updated");
load();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
}
};
const toggleDisabled = async (u: AdminUser) => {
try {
await admin.updateUser(u.id, { disabled: !u.disabled });
load();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
}
};
const remove = async (u: AdminUser) => {
if (!confirm(`Delete user "${u.username}" and all their data?`)) return;
try {
await admin.deleteUser(u.id);
toast.success("User deleted");
load();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
}
};
if (loading) return <Loader />;
return (
<div className="space-y-2">
{users.map((u) => (
<Card key={u.id} className="flex flex-wrap items-center gap-3 p-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-text">{u.display_name}</span>
<span className="text-xs text-faint">@{u.username}</span>
{u.role === "ADMIN" && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-semibold text-accent">
ADMIN
</span>
)}
{u.disabled && (
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
DISABLED
</span>
)}
</div>
<p className="text-xs text-muted">
{u.agent_count} agents · {u.request_count} requests · joined {relativeTime(u.created_at)}
</p>
</div>
{u.id !== me?.id && (
<div className="flex gap-2">
<Button variant="ghost" onClick={() => setRole(u, u.role === "ADMIN" ? "USER" : "ADMIN")}>
{u.role === "ADMIN" ? "Demote" : "Promote"}
</Button>
<Button variant="ghost" onClick={() => toggleDisabled(u)}>
{u.disabled ? "Enable" : "Disable"}
</Button>
<Button variant="ghost" className="text-rejected" onClick={() => remove(u)}>
Delete
</Button>
</div>
)}
</Card>
))}
</div>
);
}
function AgentsTab() {
const [agents, setAgents] = useState<(Agent & { owner: { id: number; username: string } })[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setAgents(await admin.agents());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const toggle = async (a: Agent) => {
try {
await admin.setAgentDisabled(a.id, !a.disabled);
load();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
}
};
const remove = async (a: Agent) => {
if (!confirm(`Delete agent "${a.name}"?`)) return;
try {
await admin.deleteAgent(a.id);
toast.success("Agent deleted");
load();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
}
};
if (loading) return <Loader />;
return (
<div className="space-y-2">
{agents.map((a) => (
<Card key={a.id} className="flex flex-wrap items-center gap-3 p-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-text">{a.name}</span>
<span className="text-xs text-faint">by @{a.owner.username}</span>
{a.disabled && (
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
DISABLED
</span>
)}
</div>
{a.description && <p className="text-xs text-muted">{a.description}</p>}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => toggle(a)}>
{a.disabled ? "Enable" : "Disable"}
</Button>
<Button variant="ghost" className="text-rejected" onClick={() => remove(a)}>
Delete
</Button>
</div>
</Card>
))}
</div>
);
}
function SettingsTab() {
const [settings, setSettings] = useState<GlobalSettings | null>(null);
useEffect(() => {
admin.settings().then(setSettings).catch(() => {});
}, []);
const update = async (patch: Partial<GlobalSettings>) => {
try {
const res = await admin.updateSettings(patch);
setSettings(res);
toast.success("Settings updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
}
};
if (!settings) return <Loader />;
return (
<div className="space-y-3">
<Card className="flex items-center justify-between p-5">
<div>
<p className="font-medium text-text">Enable registration</p>
<p className="text-sm text-muted">Allow new humans to create accounts.</p>
</div>
<Toggle checked={settings.registration_enabled} onChange={(v) => update({ registration_enabled: v })} />
</Card>
<Card className="flex items-center justify-between p-5">
<div>
<p className="font-medium text-text">Enable requests</p>
<p className="text-sm text-muted">Allow agents to submit new change requests platform-wide.</p>
</div>
<Toggle checked={settings.requests_enabled} onChange={(v) => update({ requests_enabled: v })} />
</Card>
</div>
);
}
function AuditTab() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
admin
.auditLogs(page)
.then((d) => {
setLogs(d.logs);
setTotal(d.total);
})
.finally(() => setLoading(false));
}, [page]);
const totalPages = Math.max(1, Math.ceil(total / 30));
if (loading) return <Loader />;
return (
<div>
<div className="space-y-1.5">
{logs.map((l) => (
<Card key={l.id} className="flex items-center gap-3 p-3 text-sm">
<code className="rounded bg-surface-raised px-2 py-0.5 text-xs text-accent">{l.action}</code>
<span className="min-w-0 flex-1 truncate text-muted">
{l.actor ? `@${l.actor.username}` : "system"}
{l.target_type && `${l.target_type}:${l.target_id}`}
{l.detail && ` · ${l.detail}`}
</span>
<span className="shrink-0 text-xs text-faint">{relativeTime(l.created_at)}</span>
</Card>
))}
</div>
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-3">
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Previous
</Button>
<span className="text-sm text-muted">
Page {page} of {totalPages}
</span>
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Next
</Button>
</div>
)}
</div>
);
}
function Loader() {
return (
<div className="flex justify-center py-16 text-primary">
<Spinner className="h-6 w-6" />
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "react-toastify";
import { agents as agentsApi } from "../api/client";
import type { Agent } from "../api/types";
import { Button, Card, EmptyState, PageHeader, Spinner } from "../components/ui";
import { AgentAvatar } from "../components/AgentAvatar";
import { AgentFormModal, ConnectModal } from "../components/AgentModals";
export function AgentsPage() {
const [agents, setAgents] = useState<Agent[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<Agent | null>(null);
const [connectAgent, setConnectAgent] = useState<Agent | null>(null);
const [revealedKey, setRevealedKey] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setAgents(await agentsApi.list());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const onSaved = (agent: Agent, created: boolean) => {
load();
if (created && agent.api_key) {
setRevealedKey(agent.api_key);
setConnectAgent(agent);
}
};
const regenerate = async (agent: Agent) => {
if (!confirm(`Regenerate the API key for "${agent.name}"? The old key stops working immediately.`)) return;
try {
const updated = await agentsApi.regenerateKey(agent.id);
toast.success("API key regenerated");
setRevealedKey(updated.api_key ?? null);
setConnectAgent(updated);
load();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed");
}
};
const toggleDisabled = async (agent: Agent) => {
try {
await agentsApi.setDisabled(agent.id, !agent.disabled);
toast.success(agent.disabled ? "Agent enabled" : "Agent disabled");
load();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed");
}
};
const remove = async (agent: Agent) => {
if (!confirm(`Delete "${agent.name}"? All its requests will be permanently deleted.`)) return;
try {
await agentsApi.remove(agent.id);
toast.success("Agent deleted");
load();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed");
}
};
return (
<div>
<PageHeader
title="Agents"
subtitle="Each agent has its own API key. You may own up to 5 agents."
actions={
<Button
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
disabled={agents.length >= 5}
>
+ New agent
</Button>
}
/>
{loading ? (
<div className="flex justify-center py-16 text-primary">
<Spinner className="h-6 w-6" />
</div>
) : agents.length === 0 ? (
<EmptyState
icon="🤖"
title="No agents yet"
subtitle="Create an agent, then connect it via OpenClaw, MCP, or the REST API."
/>
) : (
<div className="grid gap-4 md:grid-cols-2">
{agents.map((a) => (
<Card key={a.id} className="p-4">
<div className="flex items-start gap-3">
<AgentAvatar name={a.name} iconUrl={a.icon_url} size={44} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-semibold text-text">{a.name}</h3>
{a.disabled && (
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
DISABLED
</span>
)}
</div>
{a.description && <p className="mt-0.5 line-clamp-2 text-xs text-muted">{a.description}</p>}
<p className="mt-1 text-xs text-faint">
{a.pending_count ?? 0}/{a.max_pending_requests} pending ·{" "}
<code>{a.api_key_masked}</code>
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => setConnectAgent(a)}>
Connect
</Button>
<Button
variant="ghost"
onClick={() => {
setEditing(a);
setFormOpen(true);
}}
>
Edit
</Button>
<Button variant="ghost" onClick={() => regenerate(a)}>
Regenerate key
</Button>
<Button variant="ghost" onClick={() => toggleDisabled(a)}>
{a.disabled ? "Enable" : "Disable"}
</Button>
<Button variant="ghost" className="text-rejected" onClick={() => remove(a)}>
Delete
</Button>
</div>
</Card>
))}
</div>
)}
<AgentFormModal
open={formOpen}
onClose={() => setFormOpen(false)}
agent={editing}
onSaved={onSaved}
/>
{connectAgent && (
<ConnectModal
open={!!connectAgent}
onClose={() => {
setConnectAgent(null);
setRevealedKey(null);
}}
agent={connectAgent}
revealedKey={revealedKey}
/>
)}
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { requests as requestsApi, agents as agentsApi } from "../api/client";
import type { Agent, ChangeRequest, RequestState } from "../api/types";
import { useNotifications } from "../context/NotificationsContext";
import { Card, EmptyState, PageHeader, Spinner } from "../components/ui";
import { StateBadge } from "../components/StateBadge";
import { AgentAvatar } from "../components/AgentAvatar";
import { expiresIn, relativeTime } from "../utils";
const summaryTiles: { state: RequestState; label: string }[] = [
{ state: "PENDING", label: "Pending" },
{ state: "CHANGES_REQUESTED", label: "Changes requested" },
{ state: "APPROVED", label: "Approved" },
{ state: "CONSUMED", label: "Consumed" },
];
export function DashboardPage() {
const { requestEventTick } = useNotifications();
const [pending, setPending] = useState<ChangeRequest[]>([]);
const [counts, setCounts] = useState<Record<string, number>>({});
const [agents, setAgents] = useState<Agent[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const [list, summary, agentList] = await Promise.all([
requestsApi.list({ state: "PENDING", page_size: 20 }),
requestsApi.summary(),
agentsApi.list(),
]);
setPending(list.requests);
setCounts(summary.counts);
setAgents(agentList);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load, requestEventTick]);
if (loading) {
return (
<div className="flex justify-center py-20 text-primary">
<Spinner className="h-7 w-7" />
</div>
);
}
return (
<div>
<PageHeader
title="Dashboard"
subtitle="Requests awaiting your review, and your connected agents."
/>
<div className="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-4">
{summaryTiles.map((t) => (
<Card key={t.state} className="p-4">
<p className="text-3xl font-bold text-text">{counts[t.state] ?? 0}</p>
<p className="mt-1 text-xs text-muted">{t.label}</p>
</Card>
))}
</div>
<div className="grid gap-8 lg:grid-cols-3">
<div className="lg:col-span-2">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold">Awaiting review</h2>
<Link to="/requests" className="text-sm text-primary hover:underline">
View all
</Link>
</div>
{pending.length === 0 ? (
<EmptyState
icon="🎉"
title="You're all caught up"
subtitle="No requests are waiting for your review right now."
/>
) : (
<div className="space-y-3">
{pending.map((r) => (
<PendingRow key={r.request_id} request={r} />
))}
</div>
)}
</div>
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold">Agents</h2>
<Link to="/agents" className="text-sm text-primary hover:underline">
Manage
</Link>
</div>
{agents.length === 0 ? (
<EmptyState icon="🤖" title="No agents yet" subtitle="Create an agent to start receiving requests." />
) : (
<div className="space-y-2">
{agents.map((a) => (
<Card key={a.id} className="flex items-center gap-3 p-3">
<AgentAvatar name={a.name} iconUrl={a.icon_url} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{a.name}</p>
<p className="text-xs text-muted">
{a.pending_count ?? 0}/{a.max_pending_requests} pending
{a.disabled && <span className="ml-1 text-rejected">· disabled</span>}
</p>
</div>
</Card>
))}
</div>
)}
</div>
</div>
</div>
);
}
function PendingRow({ request }: { request: ChangeRequest }) {
const exp = expiresIn(request.expires_at);
return (
<Link to={`/requests/${request.request_id}`}>
<Card className="p-4 transition hover:border-border-strong hover:bg-surface-raised/40">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<AgentAvatar name={request.agent?.name ?? "?"} iconUrl={request.agent?.icon_url} size={32} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate font-medium text-text">{request.title}</p>
{request.resubmitted && (
<span className="animate-pulse-ring rounded-full bg-changes/20 px-2 py-0.5 text-[10px] font-semibold text-changes">
UPDATED
</span>
)}
</div>
<p className="mt-0.5 truncate text-xs text-muted">
{request.agent?.name} · {request.changes.length} change
{request.changes.length === 1 ? "" : "s"} · {relativeTime(request.created_at)}
</p>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<StateBadge state={request.state} />
<span className={`text-[11px] ${exp.urgent ? "text-pending" : "text-faint"}`}>{exp.text}</span>
</div>
</div>
</Card>
</Link>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { Link } from "react-router-dom";
function LegalShell({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mx-auto max-w-3xl px-4 py-12">
<Link to="/" className="mb-6 inline-block text-sm text-muted hover:text-text">
Back
</Link>
<h1 className="mb-6 text-3xl font-bold tracking-tight">{title}</h1>
<div className="space-y-5 text-sm leading-relaxed text-muted [&_h2]:mt-6 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-text">
{children}
</div>
<footer className="mt-12 border-t border-border pt-6 text-xs text-faint">
<Link to="/privacy" className="hover:text-muted">
Privacy Policy
</Link>
<span className="mx-2">·</span>
<Link to="/terms" className="hover:text-muted">
Terms of Service
</Link>
</footer>
</div>
);
}
export function PrivacyPage() {
return (
<LegalShell title="Privacy Policy">
<p>
PatchPass is a self-hostable human approval layer for AI agents. This policy explains what data
the platform stores and the controls you have over it.
</p>
<h2>Data we store</h2>
<p>
We store the account data you provide (username, display name, a bcrypt-hashed password, and an
optional TOTP secret if you enable two-factor authentication), the agents you create (name,
description, website, icon URL, and an API key), the change requests your agents submit, the
decisions you make, and your in-app notifications.
</p>
<h2>Agent icons</h2>
<p>
When you set an agent icon URL, the backend fetches it once to verify it points to a valid image
(JPG, PNG, or GIF, under 1&nbsp;MB). The image itself is <strong>not</strong> stored or cached
only the URL you provided is kept.
</p>
<h2>How your data is used</h2>
<p>
Data is used solely to operate the approval workflow: routing agent requests to you for review,
recording decisions, and delivering notifications. We do not sell data or share it with third
parties. Administrators of your instance can view platform data for moderation; access to another
user's request payloads is explicitly audited.
</p>
<h2>Retention</h2>
<p>
Change requests are retained indefinitely by default. You may enable auto-deletion in Settings to
automatically remove requests older than a retention window you choose (minimum 7 days).
</p>
<h2>Your rights (GDPR)</h2>
<p>
You can export all of your data as machine-readable JSON at any time from Settings. You can also
delete your account, which permanently removes your account and cascades to all your agents,
change requests, and notifications. These actions are self-service and take effect immediately.
</p>
<h2>Security</h2>
<p>
Passwords are hashed with bcrypt. Approval decisions are signed with HMAC-SHA256 so agents can
cryptographically verify a decision was issued by the platform and is bound to the exact reviewed
content.
</p>
</LegalShell>
);
}
export function TermsPage() {
return (
<LegalShell title="Terms of Service">
<p>
By using this PatchPass instance you agree to these terms. PatchPass is provided as-is, without
warranty of any kind.
</p>
<h2>Acceptable use</h2>
<p>
You are responsible for the agents you connect and the actions taken on the basis of approvals you
grant. Do not use the platform to facilitate unlawful activity or to abuse other users.
</p>
<h2>Approvals</h2>
<p>
An approval is an authorization for an agent to proceed with the exact reviewed content. You are
responsible for reviewing changes before approving them. A rejection is a hard blocker; requesting
changes returns the request to the agent for revision. Approvals are single-use and are consumed by
the agent before it applies changes.
</p>
<h2>Rate limits</h2>
<p>
To keep the platform usable, agents are limited to 15 requests per hour and a configurable number
of simultaneous pending requests, and each human may own up to 5 agents.
</p>
<h2>Availability</h2>
<p>
This is self-hosted software. Availability, backups, and data durability are the responsibility of
the operator of this instance.
</p>
<h2>Changes</h2>
<p>These terms may be updated by the operator of your instance.</p>
</LegalShell>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { FormEvent, useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { auth, ApiError } from "../api/client";
import { useAuth } from "../context/AuthContext";
import { Button, Card, Input } from "../components/ui";
export function LoginPage() {
const { user, setUser, settings } = useAuth();
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [totp, setTotp] = useState("");
const [needsTotp, setNeedsTotp] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (user) navigate("/", { replace: true });
}, [user, navigate]);
const submit = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const u = await auth.login(username, password, needsTotp ? totp : undefined);
setUser(u);
navigate("/", { replace: true });
} catch (err) {
if (err instanceof ApiError && (err.data as any)?.totp_required) {
setNeedsTotp(true);
toast.info("Enter your 2FA code");
} else {
toast.error(err instanceof Error ? err.message : "Login failed");
}
} finally {
setLoading(false);
}
};
return (
<AuthShell title="Welcome back" subtitle="Review changes. Approve intent. Let agents proceed.">
<form onSubmit={submit} className="space-y-4">
<Input
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
required
/>
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{needsTotp && (
<Input
label="2FA code"
value={totp}
onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="123456"
inputMode="numeric"
autoFocus
/>
)}
<Button type="submit" loading={loading} className="w-full">
Log in
</Button>
</form>
{settings?.registration_enabled !== false && (
<p className="mt-6 text-center text-sm text-muted">
No account?{" "}
<Link to="/register" className="text-primary hover:underline">
Create one
</Link>
</p>
)}
</AuthShell>
);
}
export function AuthShell({
title,
subtitle,
children,
}: {
title: string;
subtitle: string;
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<div className="mb-3 text-4xl"></div>
<h1 className="text-3xl font-bold tracking-tight">
Patch<span className="text-primary">Pass</span>
</h1>
<p className="mt-2 text-sm text-muted">{subtitle}</p>
</div>
<Card className="p-6">
<h2 className="mb-5 text-lg font-semibold">{title}</h2>
{children}
</Card>
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { useNavigate } from "react-router-dom";
import { useNotifications } from "../context/NotificationsContext";
import { Button, Card, EmptyState, PageHeader } from "../components/ui";
import { relativeTime } from "../utils";
export function NotificationsPage() {
const { items, unreadCount, markRead, markAllRead, clear } = useNotifications();
const navigate = useNavigate();
return (
<div>
<PageHeader
title="Notifications"
subtitle={unreadCount > 0 ? `${unreadCount} unread` : "You're all caught up"}
actions={
items.length > 0 ? (
<>
<Button variant="secondary" onClick={() => markAllRead()}>
Mark all read
</Button>
<Button variant="ghost" className="text-rejected" onClick={() => clear()}>
Clear all
</Button>
</>
) : undefined
}
/>
{items.length === 0 ? (
<EmptyState icon="🔔" title="No notifications" subtitle="Activity from your agents will appear here." />
) : (
<div className="space-y-2">
{items.map((n) => (
<Card
key={n.id}
className={`flex cursor-pointer items-start gap-3 p-4 transition hover:bg-surface-raised/40 ${
n.read ? "opacity-60" : ""
}`}
onClick={() => {
if (!n.read) markRead(n.id);
if (n.request_id) navigate(`/requests/${n.request_id}`);
}}
>
{!n.read && <span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />}
<div className="min-w-0 flex-1">
<p className="font-medium text-text">{n.title}</p>
<p className="text-sm text-muted">{n.message}</p>
</div>
<span className="shrink-0 text-xs text-faint">{relativeTime(n.created_at)}</span>
</Card>
))}
</div>
)}
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { FormEvent, useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { auth } from "../api/client";
import { useAuth } from "../context/AuthContext";
import { Button, Input } from "../components/ui";
import { AuthShell } from "./Login";
export function RegisterPage() {
const { user, setUser, settings } = useAuth();
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (user) navigate("/", { replace: true });
}, [user, navigate]);
const registrationClosed = settings?.registration_enabled === false;
const submit = async (e: FormEvent) => {
e.preventDefault();
if (password !== confirm) return toast.error("Passwords do not match");
if (password.length < 8) return toast.error("Password must be at least 8 characters");
setLoading(true);
try {
const u = await auth.register(username, displayName || username, password);
setUser(u);
toast.success("Account created");
navigate("/", { replace: true });
} catch (err) {
toast.error(err instanceof Error ? err.message : "Registration failed");
} finally {
setLoading(false);
}
};
return (
<AuthShell title="Create your account" subtitle="A human approval layer for AI agents.">
{registrationClosed ? (
<div className="text-center">
<p className="text-muted">Registration is currently disabled by the administrator.</p>
<Link to="/login" className="mt-4 inline-block text-primary hover:underline">
Back to login
</Link>
</div>
) : (
<>
<form onSubmit={submit} className="space-y-4">
<Input
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
hint="Letters, numbers, dots, dashes, underscores. Case-insensitive."
autoFocus
required
/>
<Input
label="Display name (optional)"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<Input
label="Confirm password"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
/>
<Button type="submit" loading={loading} className="w-full">
Create account
</Button>
</form>
<p className="mt-6 text-center text-sm text-muted">
Already have an account?{" "}
<Link to="/login" className="text-primary hover:underline">
Log in
</Link>
</p>
</>
)}
</AuthShell>
);
}
+205
View File
@@ -0,0 +1,205 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { requests as requestsApi, ApiError } from "../api/client";
import type { ChangeRequest } from "../api/types";
import { useNotifications } from "../context/NotificationsContext";
import { Button, Card, Spinner } from "../components/ui";
import { StateBadge } from "../components/StateBadge";
import { ChangeList } from "../components/ChangeRenderer";
import { AgentAvatar } from "../components/AgentAvatar";
import { DecisionModal } from "../components/DecisionModal";
import { expiresIn, formatDateTime, relativeTime } from "../utils";
type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES";
export function RequestDetailPage() {
const { id } = useParams<{ id: string }>();
const { requestEventTick } = useNotifications();
const [request, setRequest] = useState<ChangeRequest | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [decision, setDecision] = useState<Decision | null>(null);
const load = useCallback(async () => {
if (!id) return;
try {
setRequest(await requestsApi.get(id));
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Failed to load");
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
load();
}, [load, requestEventTick]);
if (loading) {
return (
<div className="flex justify-center py-20 text-primary">
<Spinner className="h-7 w-7" />
</div>
);
}
if (error || !request) {
return (
<Card className="p-10 text-center">
<p className="text-muted">{error ?? "Not found"}</p>
<Link to="/requests" className="mt-4 inline-block text-primary hover:underline">
Back to requests
</Link>
</Card>
);
}
const exp = expiresIn(request.expires_at);
const canDecide = request.state === "PENDING";
return (
<div className="animate-fade-in">
<Link to="/requests" className="mb-4 inline-block text-sm text-muted hover:text-text">
Back to requests
</Link>
{request.resubmitted && request.state === "PENDING" && (
<div className="mb-4 flex items-center gap-3 rounded-xl border border-changes/40 bg-changes/10 px-4 py-3">
<span className="animate-pulse-ring rounded-full bg-changes/25 px-2 py-0.5 text-xs font-bold text-changes">
UPDATED
</span>
<p className="text-sm text-text">
This request was revised by the agent after you requested changes (update #{request.update_count}).
Please re-review the changes below.
</p>
</div>
)}
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-bold tracking-tight">{request.title}</h1>
<StateBadge state={request.state} />
</div>
{request.description && <p className="mt-2 text-muted">{request.description}</p>}
</div>
</div>
{request.comment && (
<div className="mb-5 rounded-xl border border-border bg-surface-raised/40 p-4">
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-faint">
{request.state === "CHANGES_REQUESTED" ? "Changes requested" : "Reviewer note"}
</p>
<p className="text-sm text-text">{request.comment}</p>
</div>
)}
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-faint">
Proposed changes ({request.changes.length})
</h2>
<ChangeList changes={request.changes} />
</div>
<div className="space-y-4">
{canDecide && (
<Card className="space-y-2 p-4">
<p className="mb-1 text-sm font-semibold">Your decision</p>
<Button variant="success" className="w-full" onClick={() => setDecision("APPROVE")}>
Approve
</Button>
<Button variant="primary" className="w-full" onClick={() => setDecision("REQUEST_CHANGES")}>
Request changes
</Button>
<Button variant="danger" className="w-full" onClick={() => setDecision("REJECT")}>
Reject
</Button>
<p className={`pt-1 text-center text-xs ${exp.urgent ? "text-pending" : "text-faint"}`}>
{exp.text}
</p>
</Card>
)}
<Card className="p-4">
<p className="mb-3 text-sm font-semibold">Agent</p>
{request.agent ? (
<div className="flex items-center gap-3">
<AgentAvatar name={request.agent.name} iconUrl={request.agent.icon_url} />
<div className="min-w-0">
<p className="truncate text-sm font-medium">{request.agent.name}</p>
{request.agent.website && (
<a
href={request.agent.website}
target="_blank"
rel="noreferrer"
className="truncate text-xs text-primary hover:underline"
>
{request.agent.website}
</a>
)}
</div>
</div>
) : (
<p className="text-sm text-muted">Unknown</p>
)}
{request.agent?.description && (
<p className="mt-2 text-xs text-muted">{request.agent.description}</p>
)}
</Card>
<Card className="space-y-2 p-4 text-xs">
<Row label="Request ID" value={<code className="text-[11px]">{request.request_id}</code>} />
<Row
label="Content hash"
value={<code className="text-[11px] break-all text-muted">{request.content_hash}</code>}
/>
<Row label="Created" value={relativeTime(request.created_at)} />
<Row label="Expires" value={formatDateTime(request.expires_at)} />
{request.decided_at && <Row label="Decided" value={formatDateTime(request.decided_at)} />}
{request.consumed_at && <Row label="Consumed" value={formatDateTime(request.consumed_at)} />}
{request.update_count > 0 && <Row label="Updates" value={String(request.update_count)} />}
</Card>
{request.metadata && Object.keys(request.metadata).length > 0 && (
<Card className="p-4">
<p className="mb-2 text-sm font-semibold">Metadata</p>
<div className="space-y-1 text-xs">
{Object.entries(request.metadata).map(([k, v]) => (
<Row key={k} label={k} value={<span className="text-muted">{String(v)}</span>} />
))}
</div>
</Card>
)}
{request.receipt && (
<Card className="border-approved/30 bg-approved/5 p-4">
<p className="mb-2 flex items-center gap-2 text-sm font-semibold text-approved">
<span>🔏</span> Signed receipt
</p>
<div className="space-y-1 text-xs">
<Row label="Decision" value={request.receipt.payload.decision} />
<Row label="Algorithm" value={request.receipt.algorithm} />
<Row
label="Signature"
value={<code className="text-[11px] break-all text-muted">{request.receipt.signature}</code>}
/>
</div>
</Card>
)}
</div>
</div>
<DecisionModal request={request} decision={decision} onClose={() => setDecision(null)} onDone={setRequest} />
</div>
);
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-faint">{label}</span>
<span className="text-right text-text">{value}</span>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { requests as requestsApi, agents as agentsApi } from "../api/client";
import type { Agent, ChangeRequest, RequestState } from "../api/types";
import { useNotifications } from "../context/NotificationsContext";
import { Card, EmptyState, PageHeader, Spinner, Button } from "../components/ui";
import { StateBadge } from "../components/StateBadge";
import { AgentAvatar } from "../components/AgentAvatar";
import { relativeTime } from "../utils";
const STATES: RequestState[] = [
"PENDING",
"CHANGES_REQUESTED",
"APPROVED",
"REJECTED",
"CONSUMED",
"EXPIRED",
"CANCELLED",
];
const PAGE_SIZE = 20;
export function RequestsPage() {
const { requestEventTick } = useNotifications();
const [items, setItems] = useState<ChangeRequest[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [stateFilter, setStateFilter] = useState<RequestState | "">("");
const [agentFilter, setAgentFilter] = useState<number | "">("");
const [agents, setAgents] = useState<Agent[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
agentsApi.list().then(setAgents).catch(() => {});
}, []);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await requestsApi.list({
page,
page_size: PAGE_SIZE,
state: stateFilter || undefined,
agent_id: agentFilter || undefined,
});
setItems(data.requests);
setTotal(data.total);
} finally {
setLoading(false);
}
}, [page, stateFilter, agentFilter]);
useEffect(() => {
load();
}, [load, requestEventTick]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<div>
<PageHeader title="Requests" subtitle="Full history of change requests submitted by your agents." />
<div className="mb-4 flex flex-wrap items-center gap-2">
<button
onClick={() => {
setStateFilter("");
setPage(1);
}}
className={`rounded-lg px-3 py-1.5 text-sm ${stateFilter === "" ? "bg-surface-raised text-text" : "text-muted hover:text-text"}`}
>
All
</button>
{STATES.map((s) => (
<button
key={s}
onClick={() => {
setStateFilter(s);
setPage(1);
}}
className={`rounded-lg px-3 py-1.5 text-sm ${stateFilter === s ? "bg-surface-raised text-text" : "text-muted hover:text-text"}`}
>
{s.replace("_", " ").toLowerCase()}
</button>
))}
<select
value={agentFilter}
onChange={(e) => {
setAgentFilter(e.target.value ? Number(e.target.value) : "");
setPage(1);
}}
className="ml-auto rounded-lg border border-border-strong bg-bg px-3 py-1.5 text-sm text-text outline-none"
>
<option value="">All agents</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</div>
{loading ? (
<div className="flex justify-center py-16 text-primary">
<Spinner className="h-6 w-6" />
</div>
) : items.length === 0 ? (
<EmptyState icon="📭" title="No requests" subtitle="Nothing matches this filter." />
) : (
<div className="space-y-2">
{items.map((r) => (
<Link key={r.request_id} to={`/requests/${r.request_id}`}>
<Card className="flex items-center gap-3 p-3.5 transition hover:border-border-strong hover:bg-surface-raised/40">
<AgentAvatar name={r.agent?.name ?? "?"} iconUrl={r.agent?.icon_url} size={32} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate font-medium text-text">{r.title}</p>
{r.resubmitted && r.state === "PENDING" && (
<span className="rounded-full bg-changes/20 px-1.5 py-0.5 text-[10px] font-semibold text-changes">
UPDATED
</span>
)}
</div>
<p className="truncate text-xs text-muted">
{r.agent?.name} · {r.changes.length} change{r.changes.length === 1 ? "" : "s"} ·{" "}
{relativeTime(r.created_at)}
</p>
</div>
<StateBadge state={r.state} />
</Card>
</Link>
))}
</div>
)}
{totalPages > 1 && (
<div className="mt-6 flex items-center justify-center gap-3">
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Previous
</Button>
<span className="text-sm text-muted">
Page {page} of {totalPages}
</span>
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Next
</Button>
</div>
)}
</div>
);
}
+276
View File
@@ -0,0 +1,276 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { account, auth as authApi } from "../api/client";
import { useAuth } from "../context/AuthContext";
import { Button, Card, Input, PageHeader, Toggle } from "../components/ui";
import { Modal } from "../components/Modal";
import { CodeBlock } from "../components/CodeBlock";
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
return (
<Card className="p-5">
<h2 className="text-base font-semibold text-text">{title}</h2>
{description && <p className="mb-4 mt-0.5 text-sm text-muted">{description}</p>}
<div className={description ? "" : "mt-4"}>{children}</div>
</Card>
);
}
export function SettingsPage() {
const { user, refresh } = useAuth();
const navigate = useNavigate();
// Profile
const [displayName, setDisplayName] = useState(user?.display_name ?? "");
// Password
const [curPw, setCurPw] = useState("");
const [newPw, setNewPw] = useState("");
// Auto-delete
const [autoDelete, setAutoDelete] = useState(user?.auto_delete_enabled ?? false);
const [autoDeleteDays, setAutoDeleteDays] = useState(user?.auto_delete_days ?? 30);
// 2FA
const [setup, setSetup] = useState<{ secret: string; otpauth_url: string } | null>(null);
const [totp, setTotp] = useState("");
const [disable2faOpen, setDisable2faOpen] = useState(false);
const [disablePw, setDisablePw] = useState("");
// Delete account
const [deleteOpen, setDeleteOpen] = useState(false);
const [deletePw, setDeletePw] = useState("");
const saveProfile = async () => {
try {
await account.updateProfile(displayName);
toast.success("Profile updated");
refresh();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
}
};
const savePassword = async () => {
try {
await account.changePassword(curPw, newPw);
toast.success("Password changed");
setCurPw("");
setNewPw("");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
}
};
const saveAutoDelete = async (enabled: boolean, days: number) => {
try {
const res = await account.updateSettings({ auto_delete_enabled: enabled, auto_delete_days: days });
setAutoDelete(res.auto_delete_enabled);
setAutoDeleteDays(res.auto_delete_days);
toast.success("Settings saved");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
}
};
const begin2fa = async () => {
try {
setSetup(await authApi.setup2fa());
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
}
};
const confirm2fa = async () => {
try {
await authApi.enable2fa(totp);
toast.success("2FA enabled");
setSetup(null);
setTotp("");
refresh();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Invalid code");
}
};
const disable2fa = async () => {
try {
await authApi.disable2fa(disablePw);
toast.success("2FA disabled");
setDisable2faOpen(false);
setDisablePw("");
refresh();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
}
};
const deleteAccount = async () => {
try {
await account.deleteAccount(deletePw);
toast.success("Account deleted");
navigate("/login", { replace: true });
window.location.reload();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
}
};
return (
<div>
<PageHeader title="Settings" subtitle="Manage your account, security, and data." />
<div className="grid gap-5 lg:grid-cols-2">
<Section title="Profile">
<div className="space-y-3">
<Input label="Username" value={user?.username ?? ""} disabled />
<Input label="Display name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
<Button onClick={saveProfile}>Save profile</Button>
</div>
</Section>
<Section title="Password">
<div className="space-y-3">
<Input
label="Current password"
type="password"
value={curPw}
onChange={(e) => setCurPw(e.target.value)}
/>
<Input
label="New password"
type="password"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
hint="At least 8 characters."
/>
<Button onClick={savePassword} disabled={!curPw || newPw.length < 8}>
Change password
</Button>
</div>
</Section>
<Section title="Two-factor authentication" description="Add a TOTP authenticator app for extra security.">
{user?.totp_enabled ? (
<div className="flex items-center justify-between">
<span className="text-sm text-approved"> 2FA is enabled</span>
<Button variant="danger" onClick={() => setDisable2faOpen(true)}>
Disable
</Button>
</div>
) : setup ? (
<div className="space-y-3">
<p className="text-sm text-muted">
Add this secret to your authenticator app, then enter the 6-digit code.
</p>
<CodeBlock code={setup.secret} />
<Input
label="Authenticator code"
value={totp}
onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="123456"
inputMode="numeric"
/>
<div className="flex gap-2">
<Button onClick={confirm2fa} disabled={totp.length !== 6}>
Enable 2FA
</Button>
<Button variant="ghost" onClick={() => setSetup(null)}>
Cancel
</Button>
</div>
</div>
) : (
<Button onClick={begin2fa}>Set up 2FA</Button>
)}
</Section>
<Section
title="Auto-delete old requests"
description="Disabled by default. When on, requests older than the retention window are permanently deleted (minimum 7 days)."
>
<div className="space-y-4">
<Toggle checked={autoDelete} onChange={(v) => saveAutoDelete(v, autoDeleteDays)} label="Enable auto-delete" />
{autoDelete && (
<div>
<div className="mb-1 flex items-center justify-between text-sm">
<span className="text-muted">Retention window</span>
<span className="font-medium text-text">{autoDeleteDays} days</span>
</div>
<input
type="range"
min={7}
max={90}
value={autoDeleteDays}
onChange={(e) => setAutoDeleteDays(Number(e.target.value))}
onMouseUp={() => saveAutoDelete(true, autoDeleteDays)}
onTouchEnd={() => saveAutoDelete(true, autoDeleteDays)}
className="w-full accent-[var(--color-primary)]"
/>
</div>
)}
</div>
</Section>
<Section title="Export your data" description="Download all your data (account, agents, requests, notifications) as JSON.">
<a href={account.exportUrl} download>
<Button variant="secondary">Download export</Button>
</a>
</Section>
<Section title="Danger zone" description="Permanently delete your account and all associated data. This cannot be undone.">
<Button variant="danger" onClick={() => setDeleteOpen(true)}>
Delete account
</Button>
</Section>
</div>
<Modal
open={disable2faOpen}
onClose={() => setDisable2faOpen(false)}
title="Disable 2FA"
footer={
<>
<Button variant="ghost" onClick={() => setDisable2faOpen(false)}>
Cancel
</Button>
<Button variant="danger" onClick={disable2fa}>
Disable
</Button>
</>
}
>
<Input
label="Confirm your password"
type="password"
value={disablePw}
onChange={(e) => setDisablePw(e.target.value)}
/>
</Modal>
<Modal
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
title="Delete account"
footer={
<>
<Button variant="ghost" onClick={() => setDeleteOpen(false)}>
Cancel
</Button>
<Button variant="danger" onClick={deleteAccount} disabled={!deletePw}>
Permanently delete
</Button>
</>
}
>
<div className="space-y-3">
<p className="text-sm text-muted">
This deletes your account and cascades to all your agents, change requests, and notifications.
This action is irreversible.
</p>
<Input
label="Confirm your password"
type="password"
value={deletePw}
onChange={(e) => setDeletePw(e.target.value)}
/>
</div>
</Modal>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
export function relativeTime(iso: string): string {
const then = new Date(iso).getTime();
const diff = Date.now() - then;
const abs = Math.abs(diff);
const mins = Math.round(abs / 60000);
const suffix = diff >= 0 ? "ago" : "from now";
if (abs < 60000) return "just now";
if (mins < 60) return `${mins}m ${suffix}`;
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h ${suffix}`;
const days = Math.round(hours / 24);
if (days < 30) return `${days}d ${suffix}`;
return new Date(iso).toLocaleDateString();
}
export function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
});
}
/** Returns a human "expires in Xm" string, or "expired" if past. */
export function expiresIn(iso: string): { text: string; expired: boolean; urgent: boolean } {
const diff = new Date(iso).getTime() - Date.now();
if (diff <= 0) return { text: "expired", expired: true, urgent: false };
const mins = Math.floor(diff / 60000);
if (mins < 60) return { text: `expires in ${mins}m`, expired: false, urgent: mins < 10 };
const hours = Math.floor(mins / 60);
return { text: `expires in ${hours}h ${mins % 60}m`, expired: false, urgent: false };
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// Same-origin in production (served by the backend from UI/build). In dev we proxy
// API + websocket calls to the backend on :5000.
export default defineConfig({
plugins: [react(), tailwindcss()],
build: {
outDir: "build",
},
server: {
port: 3000,
proxy: {
"/api": { target: "http://localhost:5000", changeOrigin: true, ws: true },
"/v1": { target: "http://localhost:5000", changeOrigin: true },
"/mcp": { target: "http://localhost:5000", changeOrigin: true },
},
},
});
+35
View File
@@ -0,0 +1,35 @@
services:
backend:
image: registry.reversed.dev/patchpass/core:latest
pull_policy: always
restart: always
env_file:
- .env
ports:
- "${PORT:-5000}:${PORT:-5000}"
environment:
- NODE_ENV=production
- TZ=Europe/Berlin
depends_on:
database:
condition: service_healthy
# PostgreSQL database. Point your .env DATABASE_URL at 'db' as the host, e.g.
# DATABASE_URL=postgresql://patchpass:patchpass@db:5432/patchpass
database:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_USER: patchpass
POSTGRES_PASSWORD: patchpass
POSTGRES_DB: patchpass
volumes:
- patchpass_db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U patchpass"]
interval: 10s
timeout: 5s
retries: 5
volumes:
patchpass_db:
+28
View File
@@ -0,0 +1,28 @@
# API server port
PORT=5000
# Database connection string (PostgreSQL)
DATABASE_URL=postgresql://patchpass:patchpass@db:5432/patchpass
# Domain name for cookie scoping (e.g. patchpass.example.com or localhost)
DOMAIN=localhost
# Public UI URL
UI_URL=http://localhost:5000
# API URL the UI connects to (leave same-origin in production)
REACT_APP_API_URL=http://localhost:5000
# Comma-separated list of allowed CORS origins
CORS_URLS=http://localhost:5000,http://localhost:3000
# Rate limit window in milliseconds (0 disables the global RJWEB ratelimiter)
RATELIMIT=1000
# Instance secret — used to sign approval receipts (HMAC-SHA256) and hash sessions.
# Must be a random 32-byte hex string. Generate with: openssl rand -hex 32
INSTANCE_SECRET=changeme_replace_with_a_random_32_byte_hex_string
LOG_LEVEL=info
REQUEST_DEBUGGING=false
RESPONSE_DEBUGGING=false

Some files were not shown because too many files have changed in this diff Show More