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