diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a8369b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +*.sqlite +*.sqlite-* +config.jsonc +dist diff --git a/README.md b/README.md index feb77ef..0460084 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,102 @@ # braindump +A headless server that runs brain-dump note-taking sessions into an Obsidian-style vault, exposing a web API meant to be consumed by a web UI or Obsidian plugin. An LLM agent interviews you conversationally and writes linked markdown notes into the vault as the conversation progresses, following the `brain-dump` skill (incremental writes, 1–3 questions per message, no extrapolation, TODO markers, `projects//` layout with wikilinks). + +Two interchangeable LLM backends, selected per session: + +- **`claude-sdk`** — the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk). Gets file tools, skill loading (`~/.claude/skills/brain-dump`), and session resume from the SDK. Uses your Anthropic credentials. +- **`openai-compat`** — any OpenAI-compatible chat-completions endpoint (openrouter, vllm, ollama). A built-in agent loop supplies vault-scoped file tools and injects the skill into the system prompt. + +## Requirements + +- Node ≥ 22 (uses `node:sqlite`) +- For `claude-sdk`: Anthropic credentials (the SDK inherits Claude Code auth or `ANTHROPIC_API_KEY`) + +## Setup + +```sh +npm install +cp config.example.jsonc config.jsonc # edit: vaults, backends, models +npm run web # boots, auto-migrates the db, listens +``` + +Config is JSONC; path from `BRAINDUMP_CONFIG` (default `./config.jsonc`). `"${ENV_VAR}"` string values are resolved from the environment; `~` is expanded in paths. Only vaults listed in `vaults` are writable — that allow-list is the write fence. + +> **Note:** an exported `PORT` env var overrides the config file's `port` (watch out under code-server, which exports `PORT` globally). `BRAINDUMP_DB` overrides `dbPath`. + +There is **no auth** — put it behind an authenticating reverse proxy. CORS origins are configurable (`app://obsidian.md` is in the example for Obsidian plugins). + +## API + +Base path `/api`. Errors are `{"ok":false,"error":""}`. + +| Method | Path | Description | +|---|---|---| +| GET | `/health` | liveness | +| GET | `/vaults` | configured vaults | +| POST | `/sessions` | create session — `{vault, topic, backend?, model?}` | +| GET | `/sessions[?status=]` | list sessions | +| GET | `/sessions/:id` | session + transcript | +| PATCH | `/sessions/:id` | update `{topic?, status?}` | +| DELETE | `/sessions/:id[?hard=true]` | archive (soft) or delete row; never touches vault files | +| POST | `/sessions/:id/messages` | send a user message — **response is an SSE stream** for the turn | +| GET | `/sessions/:id/file?path=` | read a vault file (path-validated) | +| GET | `/sessions/:id/git/status` | `git status --porcelain`, parsed | +| GET | `/sessions/:id/git/diff[?path=]` | raw diff (read-only; commits are always left to you) | + +### The turn stream + +`POST /sessions/:id/messages` with `{"text": "..."}` responds with `text/event-stream`: + +``` +event: text data: {"delta":"What's the goal of the project?"} +event: tool_start data: {"tool":"write_file","path":"projects/x/x.md"} +event: file_write data: {"path":"projects/x/x.md","bytes":412} +event: tool_end data: {"tool":"write_file","ok":true} +event: assistant_message data: {"text":"...full assistant text..."} +event: turn_complete data: {"turnCount":1} +``` + +Consume with `fetch` + a ReadableStream reader (or `curl -N`). One turn at a time per session — a concurrent POST gets `409 turn_in_progress`. Closing the connection aborts the turn; files already written stay (writes are incremental by design). Heartbeat comments keep proxies from timing out. + +## Curl walkthrough + +```sh +# create a session +SID=$(curl -s localhost:3000/api/sessions -H 'content-type: application/json' \ + -d '{"vault":"brain","topic":"my-project","backend":"claude-sdk"}' | jq -r .session.id) + +# first turn — watch text stream and files land +curl -N localhost:3000/api/sessions/$SID/messages -H 'content-type: application/json' \ + -d '{"text":"Brain dump my-project: a CLI tool that does X, written in Go, status: early prototype."}' + +# what changed in the vault? +curl -s localhost:3000/api/sessions/$SID/git/status | jq +curl -s "localhost:3000/api/sessions/$SID/file?path=projects/my-project/my-project.md" | jq -r .content + +# keep going — the agent asks 1-3 questions per turn and extends the notes +curl -N localhost:3000/api/sessions/$SID/messages -H 'content-type: application/json' \ + -d '{"text":"It also has a TUI mode. TODO on my side: pick a license."}' + +# review + commit yourself when happy (braindump never commits) +git -C ~/git/brain status +``` + +## Development + +```sh +npm run typecheck +npm test # pathSafety + Db unit tests +PORT=11500 npx tsx scripts/mock-openai.ts # deterministic mock chat-completions server +``` + +`scripts/mock-openai.ts` scripts a full tool-calling session (including a vault-escape probe when the user message contains `ESCAPE`), so the whole openai-compat path can be tested end-to-end without a real model — point `backends.openai-compat.baseUrl` at it. + +### Layout + +- `src/agent/` — `AgentBackend` interface + the two adapters (`claudeSdk/`, `openaiCompat/`) and the stub +- `src/vault/` — vault allow-list resolution, `pathSafety` (symlink-aware escape prevention), read-only git helpers +- `src/webapi/` — Koa router, SSE plumbing, middleware +- `src/db/` — `node:sqlite` wrapper + migrations (sessions, messages, turn_events) + +Backends own message persistence: `openai-compat` stores its full replayable history in SQLite; `claude-sdk` stores display rows and resumes via the SDK's own transcript (`sdk_session_id`). diff --git a/config.example.jsonc b/config.example.jsonc new file mode 100644 index 0000000..5413077 --- /dev/null +++ b/config.example.jsonc @@ -0,0 +1,18 @@ +{ + "port": 3000, + "dbPath": "./braindump.sqlite", + "cors": { "origins": ["app://obsidian.md", "http://localhost:5173"] }, + "skillPath": "~/.claude/skills/brain-dump/SKILL.md", + "defaultBackend": "claude-sdk", + "vaults": [{ "name": "brain", "path": "~/git/brain" }], + "backends": { + "claude-sdk": { "model": "claude-sonnet-5", "permissionMode": "acceptEdits" }, + "openai-compat": { + "baseUrl": "http://localhost:11434/v1", + "apiKey": "${OPENROUTER_API_KEY}", + "model": "qwen2.5-coder:32b", + // max tool-call loop iterations per turn (optional, default 32) + "maxToolIterations": 32 + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..18283c9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2554 @@ +{ + "name": "braindump", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "braindump", + "version": "0.1.0", + "license": "ISC", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.212", + "@koa/bodyparser": "^6.0.0", + "@koa/router": "^14.0.0", + "koa": "^3.0.1" + }, + "devDependencies": { + "@types/koa": "^2.15.0", + "@types/koa__router": "^12.0.4", + "@types/node": "^24.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.3.212", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.212.tgz", + "integrity": "sha512-BSowuUv7+EoeBo36oZbSox8uyY3dqyMLFolAoMyKW3uqAfZLDV8MpvjDsv+WDE+OGCkuWK60JIqfhyPhctGTgg==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.212", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.212", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.212", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.212", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.212", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.212", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.212", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.212" + }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.3.212", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.212.tgz", + "integrity": "sha512-t9t5fP94XNslh5hIWlmwE0F7hrhEpnbzM6ddF9qjqyO3zT6XZQ3U/fT2wuM8WYdSpyAsOO+3WiABe1buzNVQbA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.3.212", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.212.tgz", + "integrity": "sha512-0dgxPaf0+9lpOOpKYRXPtcXridq27QjhLFz/7UjVANy7roqcojdnwX4Wpjf8hII/URPkjLH+9PlAgZT3Ml1YIg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.212", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.212.tgz", + "integrity": "sha512-laU4i0eN5yq/H/utAfHKDjyugjvSnFuAfMhHYv8idF7PQxl+6YDahDd4gqVPdIifcZvrAsi1HXfpdKayYv6dJg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.3.212", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.212.tgz", + "integrity": "sha512-oeQr+cQk65eost/O+ZAROQkME0o3affxfLiRWpJjb/focycvA3Z/Z1vI3z6QL+bswMcEGsHp88cXBLoDYC0nNQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.212", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.212.tgz", + "integrity": "sha512-sUELD4LP2JLQeFZdven/7NPWS6/TIlE8aNwQJBj8+8RNF4XketoKUbmRdIBO6q8YpXFGn+4DVzirb+Cz6DIbuA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.3.212", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.212.tgz", + "integrity": "sha512-MYs7C9UOqhwWv4GgEbGdD37BUp8C8ff6WPDupFHW6/html2wK0DAei72sWjTghoEiUJUlRcxay1nxPCiwB9w1A==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.3.212", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.212.tgz", + "integrity": "sha512-fCtzLDJHy8jO4oaK/GC971f3npRWSdVhzDbh2uOQeqsWnHpGnHkxpUVBa7fVeEW5oTvXlRATgjDODjO+3CVLIg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.3.212", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.212.tgz", + "integrity": "sha512-2DCQL7ZfzpoGo4kCzu+jqOifPOKUe+Zc6OgTiSDvatz/JGWxs0kV3jOTTjtxGCCUKpdKnflRjHzOHFjIKjODxA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.112.3", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.112.3.tgz", + "integrity": "sha512-wjcozJlitVIuBEw9cj/xBuRznwkhcLmXmNzlFoeHbh4AvrDG3HGZrdvEOTTmobcbhjGkfOpKbmDTCQ4s9LQvCg==", + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hapi/bourne": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz", + "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==", + "license": "BSD-3-Clause" + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@koa/bodyparser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@koa/bodyparser/-/bodyparser-6.1.0.tgz", + "integrity": "sha512-thVG/Utbz9+dB4Nl8EBJKoaOI1DzO74dJqeDFILbAVhUq6C+4rmmrKFwxiDE63ScywOs0CHypx1IUrSRMueFTw==", + "license": "MIT", + "dependencies": { + "@types/co-body": "^6.1.3", + "co-body": "^6.2.0", + "lodash.merge": "^4.6.2", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "koa": ">=2" + } + }, + "node_modules/@koa/router": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-14.0.0.tgz", + "integrity": "sha512-LBSu5K0qAaaQcXX/0WIB9PGDevyCxxpnc1uq13vV/CgObaVxuis5hKl3Eboq/8gcb6ebnkAStW9NB/Em2eYyFA==", + "deprecated": "Please upgrade to v15 or higher. All reported bugs in this version are fixed in newer releases, dependencies have been updated, and security has been improved.", + "license": "MIT", + "dependencies": { + "debug": "^4.4.1", + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "path-to-regexp": "^8.2.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/co-body": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@types/co-body/-/co-body-6.1.3.tgz", + "integrity": "sha512-UhuhrQ5hclX6UJctv5m4Rfp52AfG9o9+d9/HwjxhVB5NjXxr5t9oKgJxN8xRHgr35oo8meUEHUPFWiKg6y71aA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/content-disposition": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz", + "integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cookies": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.2.tgz", + "integrity": "sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.2.tgz", + "integrity": "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-assert": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz", + "integrity": "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/koa": { + "version": "2.15.2", + "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.15.2.tgz", + "integrity": "sha512-CB+iyjjh1uS5N6/CKwXvw0qA7USMS2WVc4Tjf660yCjhdvqzNr8gdFcIawB41zGGptOQ+d1fnpaQWIIUXYxR3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/accepts": "*", + "@types/content-disposition": "*", + "@types/cookies": "*", + "@types/http-assert": "*", + "@types/http-errors": "*", + "@types/keygrip": "*", + "@types/koa-compose": "*", + "@types/node": "*" + } + }, + "node_modules/@types/koa__router": { + "version": "12.0.5", + "resolved": "https://registry.npmjs.org/@types/koa__router/-/koa__router-12.0.5.tgz", + "integrity": "sha512-1HeLxuDn4n5it1yZYCSyOYXo++73zT0ffoviXnPxbwbxLbvDFEvWD9ZzpRiIpK4oKR0pi+K+Mk/ZjyROjW3HSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "*" + } + }, + "node_modules/@types/koa-compose": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.9.tgz", + "integrity": "sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "*" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/co-body": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/co-body/-/co-body-6.2.0.tgz", + "integrity": "sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==", + "license": "MIT", + "dependencies": { + "@hapi/bourne": "^3.0.0", + "inflation": "^2.0.0", + "qs": "^6.5.2", + "raw-body": "^2.3.3", + "type-is": "^1.6.16" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/co-body/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/co-body/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/co-body/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/co-body/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "peer": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", + "license": "MIT" + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "peer": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "peer": true + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense", + "peer": true + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.30", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.30.tgz", + "integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "license": "MIT", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-assert/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inflation": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/inflation/-/inflation-2.1.0.tgz", + "integrity": "sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT", + "peer": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "peer": true + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koa/-/koa-3.2.1.tgz", + "integrity": "sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA==", + "license": "MIT", + "dependencies": { + "accepts": "^1.3.8", + "content-disposition": "~1.0.1", + "content-type": "^1.0.5", + "cookies": "~0.9.1", + "delegates": "^1.0.0", + "destroy": "^1.2.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.5.0", + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "mime-types": "^3.0.1", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "peer": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT", + "peer": true + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "peer": true + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peer": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6fc028d --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "braindump", + "version": "0.1.0", + "description": "Headless web API around the brain-dump skill", + "type": "module", + "author": "Codinget ", + "license": "ISC", + "engines": { + "node": ">=22" + }, + "scripts": { + "web": "tsx src/bin/braindump-web.ts", + "migrate": "tsx src/bin/braindump-migrate.ts", + "typecheck": "tsc --noEmit", + "test": "tsx --test src/**/*.test.ts" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.212", + "@koa/bodyparser": "^6.0.0", + "@koa/router": "^14.0.0", + "koa": "^3.0.1" + }, + "devDependencies": { + "@types/koa": "^2.15.0", + "@types/koa__router": "^12.0.4", + "@types/node": "^24.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/scripts/mock-openai.ts b/scripts/mock-openai.ts new file mode 100644 index 0000000..c2f053b --- /dev/null +++ b/scripts/mock-openai.ts @@ -0,0 +1,119 @@ +/** + * Deterministic mock of an OpenAI-compatible /v1/chat/completions endpoint that + * speaks streaming SSE, for end-to-end testing the openai-compat backend without + * a real model. tsx-runnable: + * + * PORT=11500 tsx scripts/mock-openai.ts + * + * Scripting (decided per request from the message history it receives): + * - If the latest user message contains "ESCAPE", the first assistant turn + * emits a write_file tool_call with path "../escape.md" (vault-escape probe). + * - Otherwise the first assistant turn streams some text and a write_file + * tool_call for projects/mock/mock.md (arguments split across fragments, and + * id/name only on the first fragment, to exercise streamed-tool_call parsing). + * - Once the history's last message is a tool result, it streams a final text + * and finishes with finish_reason "stop". + * + * It logs a compact summary of every request's message roles to stderr so an + * e2e harness can assert that a second turn replays prior history. + */ +import http from "node:http"; + +const PORT = Number(process.env.PORT ?? 11500); + +interface InMsg { + role: string; + content: string | null; + tool_calls?: unknown[]; + tool_call_id?: string; +} + +function sseChunk(obj: unknown): string { + return `data: ${JSON.stringify(obj)}\n\n`; +} + +function baseDelta(delta: unknown, finish: string | null = null) { + return { + id: "chatcmpl-mock", + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: "mock-model", + choices: [{ index: 0, delta, finish_reason: finish }], + }; +} + +const server = http.createServer((req, res) => { + if (req.method !== "POST" || !req.url?.includes("/chat/completions")) { + res.writeHead(404).end("not found"); + return; + } + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + let messages: InMsg[] = []; + try { + messages = (JSON.parse(body).messages ?? []) as InMsg[]; + } catch { + /* ignore */ + } + + const roles = messages.map((m) => m.role).join(","); + const userMsgs = messages.filter((m) => m.role === "user").length; + process.stderr.write(`[mock] request roles=[${roles}] userMessages=${userMsgs}\n`); + + const last = messages[messages.length - 1]; + const lastUser = [...messages].reverse().find((m) => m.role === "user"); + const wantEscape = typeof lastUser?.content === "string" && lastUser.content.includes("ESCAPE"); + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + + if (last?.role === "tool") { + // Second iteration: finish the turn. + res.write(sseChunk(baseDelta({ role: "assistant", content: "" }))); + res.write(sseChunk(baseDelta({ content: "Done — I captured that into the vault. " }))); + res.write(sseChunk(baseDelta({ content: "What else should we add? (1) goals? (2) stack?" }))); + res.write(sseChunk(baseDelta({}, "stop"))); + res.write("data: [DONE]\n\n"); + res.end(); + return; + } + + // First iteration: stream text + a write_file tool_call across fragments. + const targetPath = wantEscape ? "../escape.md" : "projects/mock/mock.md"; + const fileContent = "---\ntags:\n - mock\n - mock/moc\n---\n\n# Mock\n\nCaptured by the mock backend.\n\n## TODO / Ideas\n\n\n"; + const argsFull = JSON.stringify({ path: targetPath, content: fileContent }); + const mid = Math.floor(argsFull.length / 2); + + res.write(sseChunk(baseDelta({ role: "assistant", content: "" }))); + res.write(sseChunk(baseDelta({ content: "Got it. Let me start the note. " }))); + // tool_call fragment 1: id + name + first half of arguments. + res.write( + sseChunk( + baseDelta({ + tool_calls: [ + { index: 0, id: "call_mock_1", type: "function", function: { name: "write_file", arguments: argsFull.slice(0, mid) } }, + ], + }) + ) + ); + // tool_call fragment 2: remaining arguments only (no id/name). + res.write( + sseChunk( + baseDelta({ + tool_calls: [{ index: 0, function: { arguments: argsFull.slice(mid) } }], + }) + ) + ); + res.write(sseChunk(baseDelta({}, "tool_calls"))); + res.write("data: [DONE]\n\n"); + res.end(); + }); +}); + +server.listen(PORT, () => { + process.stderr.write(`[mock] listening on http://localhost:${PORT}\n`); +}); diff --git a/src/agent/AgentBackend.ts b/src/agent/AgentBackend.ts new file mode 100644 index 0000000..e386a6e --- /dev/null +++ b/src/agent/AgentBackend.ts @@ -0,0 +1,6 @@ +import type { AgentEvent } from "./events.js"; + +export interface AgentBackend { + readonly kind: "claude-sdk" | "openai-compat" | "stub"; + runTurn(input: { userText: string; signal: AbortSignal }): AsyncGenerator; +} diff --git a/src/agent/StubBackend.ts b/src/agent/StubBackend.ts new file mode 100644 index 0000000..37391a5 --- /dev/null +++ b/src/agent/StubBackend.ts @@ -0,0 +1,65 @@ +import type { AgentBackend } from "./AgentBackend.js"; +import type { AgentEvent } from "./events.js"; + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** + * Canned backend used for M3 (session REST + SSE plumbing) before the real + * claude-sdk / openai-compat backends land. Emits a fixed event sequence + * with small delays to exercise streaming, and honors abort by stopping + * early. Does not touch the filesystem — the file_write event is + * synthetic/pretend. + */ +export class StubBackend implements AgentBackend { + readonly kind = "stub" as const; + + async *runTurn({ + userText, + signal, + }: { + userText: string; + signal: AbortSignal; + }): AsyncGenerator { + const chunks = [`Stub response to: `, userText.slice(0, 40), " (stubbed, no real work done)"]; + + for (const delta of chunks) { + if (signal.aborted) return; + await sleep(100, signal); + if (signal.aborted) return; + yield { type: "text", delta }; + } + + if (signal.aborted) return; + await sleep(100, signal); + if (signal.aborted) return; + yield { type: "tool_start", tool: "Write", path: "projects/stub/stub.md" }; + + if (signal.aborted) return; + await sleep(100, signal); + if (signal.aborted) return; + yield { type: "file_write", path: "projects/stub/stub.md", bytes: 42 }; + + if (signal.aborted) return; + await sleep(100, signal); + if (signal.aborted) return; + yield { type: "tool_end", tool: "Write", ok: true }; + + if (signal.aborted) return; + await sleep(100, signal); + if (signal.aborted) return; + const fullText = chunks.join(""); + yield { type: "assistant_message", text: fullText }; + + if (signal.aborted) return; + yield { type: "turn_complete" }; + } +} diff --git a/src/agent/claudeSdk/ClaudeSdkBackend.ts b/src/agent/claudeSdk/ClaudeSdkBackend.ts new file mode 100644 index 0000000..9a377cf --- /dev/null +++ b/src/agent/claudeSdk/ClaudeSdkBackend.ts @@ -0,0 +1,184 @@ +import path from "node:path"; +import fs from "node:fs"; +import { query, type Options, type PermissionMode } from "@anthropic-ai/claude-agent-sdk"; +import type { AgentBackend } from "../AgentBackend.js"; +import type { AgentEvent } from "../events.js"; +import type { Config } from "../../config/types.js"; +import type { Db, SessionRow } from "../../db/Db.js"; + +const ALLOWED_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep"]; +const FILE_TOOLS = new Set(["Write", "Edit"]); + +/** + * Claude Agent SDK backend. Delegates the agent loop, tool execution and + * transcript to the bundled Claude Code CLI via `query()`. The SDK keeps its + * own transcript keyed by session id (persisted here as `sdk_session_id` for + * `resume`); this backend additionally writes display-only user/assistant + * mirror rows into the `messages` table so the transcript endpoint has content. + */ +export class ClaudeSdkBackend implements AgentBackend { + readonly kind = "claude-sdk" as const; + + #session: SessionRow; + #db: Db; + #model?: string; + #permissionMode: PermissionMode; + #skillPath: string; + + constructor(session: SessionRow, cfg: Config, db: Db) { + this.#session = session; + this.#db = db; + const bc = cfg.backends["claude-sdk"]; + this.#model = session.model ?? bc?.model; + this.#permissionMode = (bc?.permissionMode as PermissionMode | undefined) ?? "acceptEdits"; + this.#skillPath = cfg.skillPath; + } + + #toVaultRelative(absOrRel: string): { path: string; outside: boolean } { + const root = path.resolve(this.#session.vault_path); + const abs = path.isAbsolute(absOrRel) ? absOrRel : path.resolve(root, absOrRel); + const rel = path.relative(root, abs); + const outside = rel === "" || rel.startsWith("..") || path.isAbsolute(rel); + return { path: outside ? absOrRel : rel, outside }; + } + + async *runTurn({ + userText, + signal, + }: { + userText: string; + signal: AbortSignal; + }): AsyncGenerator { + const turn = this.#session.turn_count + 1; + const vaultRoot = this.#session.vault_path; + + // Display-only user mirror row (seq 0). + this.#db.createMessage({ sessionId: this.#session.id, turn, seq: 0, role: "user", content: userText }); + + const abortController = new AbortController(); + const onAbort = () => abortController.abort(); + if (signal.aborted) abortController.abort(); + else signal.addEventListener("abort", onAbort, { once: true }); + + const append = `You are running a headless "brain dump" note-taking session into the Obsidian-style vault at ${vaultRoot} (your cwd). Follow the brain-dump skill (${this.#skillPath}): write notes incrementally as you go, ask only 1-3 targeted questions per message, never extrapolate beyond what the user said, flag gaps as TODO markers, use projects// layout with a map-of-content root file and [[wikilinks]], and do not commit to git.`; + + const options: Options = { + cwd: vaultRoot, + resume: this.#session.sdk_session_id ?? undefined, + permissionMode: this.#permissionMode, + allowedTools: ALLOWED_TOOLS, + settingSources: ["user"], + systemPrompt: { type: "preset", preset: "claude_code", append }, + includePartialMessages: true, + abortController, + ...(this.#model ? { model: this.#model } : {}), + }; + + // Track tool_use blocks awaiting their result so file_write/tool_end can be + // emitted with an accurate byte count once the file exists on disk. + const pendingTools = new Map(); + let sessionId: string | undefined = this.#session.sdk_session_id ?? undefined; + let finalText = ""; + + try { + for await (const message of query({ prompt: userText, options })) { + if (signal.aborted) break; + + switch (message.type) { + case "system": { + if (message.subtype === "init" && message.session_id) sessionId = message.session_id; + break; + } + case "stream_event": { + // Partial text streaming (content_block_delta with text_delta). + const ev = message.event as { + type?: string; + delta?: { type?: string; text?: string }; + }; + if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta" && ev.delta.text) { + yield { type: "text", delta: ev.delta.text }; + } + break; + } + case "assistant": { + const content = (message.message?.content ?? []) as unknown as Array>; + for (const block of content) { + if (block.type === "tool_use") { + const name = String(block.name ?? ""); + const id = String(block.id ?? ""); + const input = (block.input ?? {}) as Record; + const rawPath = typeof input.file_path === "string" ? input.file_path : undefined; + let rel: string | undefined; + let outside = false; + if (rawPath) { + const r = this.#toVaultRelative(rawPath); + rel = r.path; + outside = r.outside; + if (outside) { + console.warn(`[claude-sdk] tool ${name} path outside vault: ${rawPath}`); + } + } + if (FILE_TOOLS.has(name)) { + pendingTools.set(id, { tool: name, path: rel, outside }); + yield { type: "tool_start", tool: name, ...(rel ? { path: rel } : {}) }; + } + } + } + break; + } + case "user": { + // Tool results arrive as a user message with tool_result blocks. + const content = (message.message?.content ?? []) as unknown; + if (Array.isArray(content)) { + for (const block of content as Array>) { + if (block.type === "tool_result") { + const id = String(block.tool_use_id ?? ""); + const pending = pendingTools.get(id); + if (pending) { + pendingTools.delete(id); + const ok = block.is_error !== true; + if (ok && pending.path && !pending.outside) { + let bytes = 0; + try { + bytes = fs.statSync(path.resolve(vaultRoot, pending.path)).size; + } catch { + /* file may have been deleted; report 0 */ + } + yield { type: "file_write", path: pending.path, bytes }; + } + yield { type: "tool_end", tool: pending.tool, ok }; + } + } + } + } + break; + } + case "result": { + if (message.session_id) sessionId = message.session_id; + if (message.subtype === "success" && typeof message.result === "string") { + finalText = message.result; + } + break; + } + default: + break; + } + } + } catch (e) { + if (signal.aborted || (e as Error).name === "AbortError") return; + yield { type: "error", message: `claude-sdk query failed: ${(e as Error).message}` }; + return; + } finally { + signal.removeEventListener("abort", onAbort); + } + + if (signal.aborted) return; + + if (finalText) { + // Display-only assistant mirror row (seq 1). + this.#db.createMessage({ sessionId: this.#session.id, turn, seq: 1, role: "assistant", content: finalText }); + yield { type: "assistant_message", text: finalText }; + } + yield { type: "turn_complete", sdkSessionId: sessionId }; + } +} diff --git a/src/agent/events.ts b/src/agent/events.ts new file mode 100644 index 0000000..f35b715 --- /dev/null +++ b/src/agent/events.ts @@ -0,0 +1,8 @@ +export type AgentEvent = + | { type: "text"; delta: string } + | { type: "tool_start"; tool: string; path?: string } + | { type: "file_write"; path: string; bytes: number } // vault-relative + | { type: "tool_end"; tool: string; ok: boolean } + | { type: "assistant_message"; text: string } + | { type: "turn_complete"; sdkSessionId?: string } + | { type: "error"; message: string }; diff --git a/src/agent/openaiCompat/OpenAiBackend.ts b/src/agent/openaiCompat/OpenAiBackend.ts new file mode 100644 index 0000000..0127b3e --- /dev/null +++ b/src/agent/openaiCompat/OpenAiBackend.ts @@ -0,0 +1,309 @@ +import type { AgentBackend } from "../AgentBackend.js"; +import type { AgentEvent } from "../events.js"; +import type { Config } from "../../config/types.js"; +import type { Db, MessageRow, SessionRow } from "../../db/Db.js"; +import { executeTool } from "./tools.js"; +import { toolSchemas } from "./toolSchemas.js"; +import { buildSystemPrompt } from "./systemPrompt.js"; + +const DEFAULT_MAX_ITERATIONS = 32; + +interface ChatMessage { + role: "system" | "user" | "assistant" | "tool"; + content: string | null; + tool_calls?: ChatToolCall[]; + tool_call_id?: string; +} + +interface ChatToolCall { + id: string; + type: "function"; + function: { name: string; arguments: string }; +} + +/** Accumulator for a streamed tool_call, keyed by its delta index. */ +interface ToolCallAccum { + id: string; + name: string; + args: string; +} + +/** + * Hand-rolled OpenAI-compatible agent backend. Owns full message-history + * persistence for its sessions (the router does not persist message rows for + * this backend): loads prior history from SQLite, appends the user turn, runs + * the chat-completions tool loop, and writes every user/assistant/tool row. + */ +export class OpenAiBackend implements AgentBackend { + readonly kind = "openai-compat" as const; + + #session: SessionRow; + #db: Db; + #baseUrl: string; + #apiKey?: string; + #model: string; + #maxIterations: number; + #systemPrompt: string; + + constructor(session: SessionRow, cfg: Config, db: Db) { + this.#session = session; + this.#db = db; + const bc = cfg.backends["openai-compat"]; + if (!bc) throw new Error("openai-compat backend is not configured"); + this.#baseUrl = bc.baseUrl.replace(/\/$/, ""); + this.#apiKey = bc.apiKey; + this.#model = session.model ?? bc.model; + this.#maxIterations = bc.maxToolIterations ?? DEFAULT_MAX_ITERATIONS; + this.#systemPrompt = buildSystemPrompt(cfg.skillPath, session.vault_path); + } + + /** Rebuild chat-completions messages from persisted rows of prior turns. */ + #loadHistory(): ChatMessage[] { + const rows = this.#db.listMessages(this.#session.id); + const out: ChatMessage[] = []; + for (const row of rows) { + if (row.role === "user") { + out.push({ role: "user", content: row.content ?? "" }); + } else if (row.role === "assistant") { + const msg: ChatMessage = { role: "assistant", content: row.content ?? null }; + if (row.tool_calls) { + try { + msg.tool_calls = JSON.parse(row.tool_calls) as ChatToolCall[]; + } catch { + /* ignore malformed */ + } + } + // OpenAI requires content:null (not "") when tool_calls present. + if (msg.tool_calls && !msg.content) msg.content = null; + out.push(msg); + } else if (row.role === "tool") { + let toolCallId = ""; + if (row.tool_calls) { + try { + toolCallId = (JSON.parse(row.tool_calls) as { tool_call_id?: string }).tool_call_id ?? ""; + } catch { + /* ignore */ + } + } + out.push({ role: "tool", content: row.content ?? "", tool_call_id: toolCallId }); + } + } + return out; + } + + async *runTurn({ + userText, + signal, + }: { + userText: string; + signal: AbortSignal; + }): AsyncGenerator { + const turn = this.#session.turn_count + 1; + let seq = 0; + const vaultRoot = this.#session.vault_path; + + const messages: ChatMessage[] = [ + { role: "system", content: this.#systemPrompt }, + ...this.#loadHistory(), + { role: "user", content: userText }, + ]; + + // Persist the user message (seq 0). + this.#db.createMessage({ sessionId: this.#session.id, turn, seq: seq++, role: "user", content: userText }); + + for (let iter = 0; iter < this.#maxIterations; iter++) { + if (signal.aborted) return; + + let assistantText = ""; + const toolCalls = new Map(); + let finishReason: string | null = null; + + try { + const res = await fetch(`${this.#baseUrl}/chat/completions`, { + method: "POST", + signal, + headers: { + "Content-Type": "application/json", + ...(this.#apiKey ? { Authorization: `Bearer ${this.#apiKey}` } : {}), + }, + body: JSON.stringify({ + model: this.#model, + stream: true, + messages, + tools: toolSchemas, + }), + }); + + if (!res.ok || !res.body) { + const detail = res.body ? await res.text().catch(() => "") : ""; + yield { type: "error", message: `chat/completions HTTP ${res.status}: ${detail.slice(0, 500)}` }; + return; + } + + for await (const data of parseSseStream(res.body, signal)) { + if (data === "[DONE]") break; + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + continue; + } + const choice = (parsed as { choices?: unknown[] }).choices?.[0] as + | { delta?: { content?: string; tool_calls?: unknown[] }; finish_reason?: string | null } + | undefined; + if (!choice) continue; + const delta = choice.delta; + if (delta?.content) { + assistantText += delta.content; + yield { type: "text", delta: delta.content }; + } + if (delta?.tool_calls) { + for (const tcRaw of delta.tool_calls) { + const tc = tcRaw as { + index?: number; + id?: string; + function?: { name?: string; arguments?: string }; + }; + const idx = tc.index ?? 0; + let accum = toolCalls.get(idx); + if (!accum) { + accum = { id: "", name: "", args: "" }; + toolCalls.set(idx, accum); + } + if (tc.id) accum.id = tc.id; + if (tc.function?.name) accum.name = tc.function.name; + if (tc.function?.arguments) accum.args += tc.function.arguments; + } + } + if (choice.finish_reason) finishReason = choice.finish_reason; + } + } catch (e) { + if (signal.aborted || (e as Error).name === "AbortError") return; + yield { type: "error", message: `openai-compat request failed: ${(e as Error).message}` }; + return; + } + + if (signal.aborted) return; + + const orderedCalls = [...toolCalls.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v); + const hasToolCalls = orderedCalls.length > 0; + + // Assemble + persist the assistant message for this step. + const assistantMsg: ChatMessage = { role: "assistant", content: assistantText || (hasToolCalls ? null : "") }; + if (hasToolCalls) { + assistantMsg.tool_calls = orderedCalls.map((c) => ({ + id: c.id, + type: "function" as const, + function: { name: c.name, arguments: c.args || "{}" }, + })); + } + messages.push(assistantMsg); + this.#db.createMessage({ + sessionId: this.#session.id, + turn, + seq: seq++, + role: "assistant", + content: assistantText, + toolCalls: hasToolCalls ? assistantMsg.tool_calls : undefined, + }); + + if (finishReason === "tool_calls" || hasToolCalls) { + for (const call of orderedCalls) { + let args: Record = {}; + let parseError = false; + try { + args = call.args ? (JSON.parse(call.args) as Record) : {}; + } catch { + parseError = true; + } + + const pathArg = typeof args.path === "string" ? args.path : undefined; + yield { type: "tool_start", tool: call.name, ...(pathArg ? { path: pathArg } : {}) }; + + const result = parseError + ? { content: `Error: could not parse arguments as JSON: ${call.args}` } + : executeTool(vaultRoot, call.name, args, (msg) => console.warn(`[openai-compat] ${msg}`)); + + const ok = !result.content.startsWith("Error"); + if (result.write) { + yield { type: "file_write", path: result.write.path, bytes: result.write.bytes }; + } + yield { type: "tool_end", tool: call.name, ok }; + + messages.push({ role: "tool", content: result.content, tool_call_id: call.id }); + this.#db.createMessage({ + sessionId: this.#session.id, + turn, + seq: seq++, + role: "tool", + content: result.content, + toolName: call.name, + toolCalls: { tool_call_id: call.id }, + }); + } + continue; // next iteration + } + + // finish_reason === "stop" (or no tool calls): the turn is done. + yield { type: "assistant_message", text: assistantText }; + yield { type: "turn_complete" }; + return; + } + + yield { + type: "error", + message: `Tool loop exceeded ${this.#maxIterations} iterations without completing.`, + }; + } +} + +/** + * Parse an SSE (data:) stream from a web ReadableStream into individual data + * payloads. Handles multi-line events and CRLF; stops on abort. + */ +async function* parseSseStream( + body: ReadableStream, + signal: AbortSignal +): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + if (signal.aborted) return; + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let sepIndex: number; + // Events are separated by a blank line (\n\n, tolerate \r\n\r\n). + while ((sepIndex = indexOfEventSep(buffer)) !== -1) { + const rawEvent = buffer.slice(0, sepIndex); + buffer = buffer.slice(sepIndex).replace(/^(\r?\n){2}/, ""); + const dataLines = rawEvent + .split(/\r?\n/) + .filter((l) => l.startsWith("data:")) + .map((l) => l.slice(5).replace(/^ /, "")); + if (dataLines.length) yield dataLines.join("\n"); + } + } + // Flush any trailing event without a terminating blank line. + const rest = buffer.trim(); + if (rest) { + const dataLines = rest + .split(/\r?\n/) + .filter((l) => l.startsWith("data:")) + .map((l) => l.slice(5).replace(/^ /, "")); + if (dataLines.length) yield dataLines.join("\n"); + } + } finally { + reader.cancel().catch(() => {}); + } +} + +function indexOfEventSep(buffer: string): number { + const lf = buffer.indexOf("\n\n"); + const crlf = buffer.indexOf("\r\n\r\n"); + if (lf === -1) return crlf; + if (crlf === -1) return lf; + return Math.min(lf, crlf); +} diff --git a/src/agent/openaiCompat/systemPrompt.ts b/src/agent/openaiCompat/systemPrompt.ts new file mode 100644 index 0000000..b504274 --- /dev/null +++ b/src/agent/openaiCompat/systemPrompt.ts @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import path from "node:path"; + +const IGNORED_DIRS = new Set([".git", ".obsidian"]); + +/** A shallow (depth-limited) listing of the vault so the model sees structure. */ +function shallowListing(vaultRoot: string, maxDepth = 2): string { + const lines: string[] = []; + function walk(dir: string, depth: number, prefix: string): void { + if (depth > maxDepth) return; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue; + if (entry.isDirectory()) { + lines.push(`${prefix}${entry.name}/`); + walk(path.join(dir, entry.name), depth + 1, `${prefix} `); + } else if (entry.isFile()) { + lines.push(`${prefix}${entry.name}`); + } + } + } + walk(path.resolve(vaultRoot), 1, ""); + return lines.length ? lines.join("\n") : "(vault is empty)"; +} + +/** + * Compose the system prompt for the openai-compat backend from: + * (a) the literal contents of the brain-dump SKILL.md (behavioral spec), + * (b) the vault root path + a shallow directory listing, + * (c) the tool-use protocol and reinforcement of the skill's key rules. + */ +export function buildSystemPrompt(skillPath: string, vaultRoot: string): string { + let skill = ""; + try { + skill = fs.readFileSync(skillPath, "utf8"); + } catch { + skill = "(brain-dump skill file could not be read; follow the rules below.)"; + } + + const listing = shallowListing(vaultRoot); + + return `You are running a headless "brain dump" note-taking session, capturing a conversation with the user into linked markdown notes in their Obsidian-style vault. You MUST follow the brain-dump skill specification below exactly. + +===== BEGIN brain-dump SKILL.md ===== +${skill} +===== END brain-dump SKILL.md ===== + +## Vault + +Vault root: ${vaultRoot} +All file paths you pass to tools are RELATIVE to this root. Current structure (shallow): + +${listing} + +## Tools + +You have four vault-scoped tools. Use them to inspect and write notes — you cannot touch anything outside the vault. +- list_files(subdir?) — discover existing structure. Do this before creating new files so you extend rather than duplicate. +- read_file(path) — read an existing note before editing it. +- write_file(path, content) — create or overwrite a file (parents auto-created). +- edit_file(path, old_string, new_string) — exact-string replacement for incremental updates; old_string must be unique in the file. + +## How to work (reinforcing the skill) + +- Write files AS YOU GO after each meaningful chunk of information, not all at once at the end. Use edit_file for incremental additions to a file you already created. +- Ask only 1–3 targeted questions per message. This is a conversation, not an intake form. Put your questions in your normal text reply (not in a tool call). +- NEVER extrapolate or invent facts. Only write what the user explicitly told you. If something is implied but unconfirmed, ask instead of guessing. +- Flag gaps as explicit TODO markers (a "## TODO / Ideas" section or inline "") rather than silently omitting or guessing. +- Give each topic/project its own subdirectory: projects//. The root map-of-content file is projects//.md; link out to sibling files with Obsidian wikilinks like [[architecture]] (no path, no .md extension). +- Use YAML frontmatter tags following the vault's existing taxonomy where visible. +- Do not commit to git. Do not offer to unless asked. + +When you have captured the current chunk and want to ask the user for more, stop calling tools and reply with your questions in plain text.`; +} diff --git a/src/agent/openaiCompat/toolSchemas.ts b/src/agent/openaiCompat/toolSchemas.ts new file mode 100644 index 0000000..2e6e273 --- /dev/null +++ b/src/agent/openaiCompat/toolSchemas.ts @@ -0,0 +1,106 @@ +/** + * Chat-completions "tools" definitions for the hand-rolled openai-compat agent + * loop. Kept deliberately small — the four vault-scoped file operations the + * brain-dump skill needs. All paths are vault-relative; the implementations in + * tools.ts fence every access through assertInsideVault. + */ + +export interface ChatTool { + type: "function"; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +export const toolSchemas: ChatTool[] = [ + { + type: "function", + function: { + name: "list_files", + description: + "List markdown/other files in the vault, recursively, as vault-relative paths. Ignores .git and .obsidian. Use this to discover existing structure before writing.", + parameters: { + type: "object", + properties: { + subdir: { + type: "string", + description: + "Optional vault-relative subdirectory to list under (e.g. \"projects/foo\"). Omit to list the whole vault.", + }, + }, + required: [], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "read_file", + description: "Read the full contents of a vault file as UTF-8 text.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Vault-relative path to the file to read.", + }, + }, + required: ["path"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "write_file", + description: + "Create or overwrite a vault file with the given content. Parent directories are created automatically. Returns the number of bytes written.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Vault-relative path to write (e.g. \"projects/foo/foo.md\").", + }, + content: { + type: "string", + description: "Full UTF-8 file content.", + }, + }, + required: ["path", "content"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "edit_file", + description: + "Replace an exact substring in an existing vault file. old_string must occur exactly once, or the edit fails. Prefer this for incremental updates over rewriting the whole file.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Vault-relative path to the file to edit.", + }, + old_string: { + type: "string", + description: "The exact text to find (must be unique in the file).", + }, + new_string: { + type: "string", + description: "The replacement text.", + }, + }, + required: ["path", "old_string", "new_string"], + additionalProperties: false, + }, + }, + }, +]; diff --git a/src/agent/openaiCompat/tools.ts b/src/agent/openaiCompat/tools.ts new file mode 100644 index 0000000..54af187 --- /dev/null +++ b/src/agent/openaiCompat/tools.ts @@ -0,0 +1,126 @@ +import fs from "node:fs"; +import path from "node:path"; +import { assertInsideVault } from "../../vault/pathSafety.js"; +import { VaultEscapeError } from "../../errors.js"; + +/** + * Result of executing a tool call: `content` is the string handed back to the + * model as the tool result (errors included, so the model can recover), and + * `write` (when set) describes a file that was created/modified so the caller + * can emit a `file_write` AgentEvent. + */ +export interface ToolExecResult { + content: string; + write?: { path: string; bytes: number }; +} + +const IGNORED_DIRS = new Set([".git", ".obsidian"]); + +function listFilesRecursive(root: string, dir: string, out: string[]): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue; + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + listFilesRecursive(root, abs, out); + } else if (entry.isFile()) { + out.push(path.relative(root, abs)); + } + } +} + +/** + * Execute a single tool call against the vault. Never throws for expected + * failure modes (missing file, ambiguous edit, vault escape) — those come back + * as an error string in `content` so the model can adapt. `onEscape` is invoked + * for logging when a vault-escape attempt is caught. + */ +export function executeTool( + vaultRoot: string, + name: string, + args: Record, + onEscape?: (msg: string) => void +): ToolExecResult { + try { + switch (name) { + case "list_files": { + const subdir = typeof args.subdir === "string" && args.subdir ? args.subdir : "."; + const base = subdir === "." ? path.resolve(vaultRoot) : assertInsideVault(vaultRoot, subdir); + const out: string[] = []; + listFilesRecursive(path.resolve(vaultRoot), base, out); + out.sort(); + return { content: out.length ? out.join("\n") : "(no files)" }; + } + case "read_file": { + if (typeof args.path !== "string" || !args.path) { + return { content: "Error: read_file requires a 'path' string argument." }; + } + const abs = assertInsideVault(vaultRoot, args.path); + if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) { + return { content: `Error: no file at ${args.path}` }; + } + return { content: fs.readFileSync(abs, "utf8") }; + } + case "write_file": { + if (typeof args.path !== "string" || !args.path) { + return { content: "Error: write_file requires a 'path' string argument." }; + } + if (typeof args.content !== "string") { + return { content: "Error: write_file requires a 'content' string argument." }; + } + const abs = assertInsideVault(vaultRoot, args.path); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, args.content, "utf8"); + const bytes = Buffer.byteLength(args.content, "utf8"); + return { + content: `Wrote ${bytes} bytes to ${args.path}`, + write: { path: args.path, bytes }, + }; + } + case "edit_file": { + if (typeof args.path !== "string" || !args.path) { + return { content: "Error: edit_file requires a 'path' string argument." }; + } + if (typeof args.old_string !== "string" || typeof args.new_string !== "string") { + return { content: "Error: edit_file requires 'old_string' and 'new_string' string arguments." }; + } + const abs = assertInsideVault(vaultRoot, args.path); + if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) { + return { content: `Error: no file at ${args.path} to edit` }; + } + const original = fs.readFileSync(abs, "utf8"); + const parts = original.split(args.old_string); + const occurrences = parts.length - 1; + if (occurrences === 0) { + return { content: `Error: old_string not found in ${args.path}; nothing replaced.` }; + } + if (occurrences > 1) { + return { + content: `Error: old_string occurs ${occurrences} times in ${args.path}; must be unique. Add more surrounding context.`, + }; + } + const updated = parts.join(args.new_string); + fs.writeFileSync(abs, updated, "utf8"); + const bytes = Buffer.byteLength(updated, "utf8"); + return { + content: `Edited ${args.path} (${bytes} bytes after edit)`, + write: { path: args.path, bytes }, + }; + } + default: + return { content: `Error: unknown tool "${name}"` }; + } + } catch (e) { + if (e instanceof VaultEscapeError) { + const msg = `Vault-escape attempt blocked in ${name}: ${e.message}`; + onEscape?.(msg); + return { content: `Error: ${e.message}` }; + } + return { content: `Error executing ${name}: ${(e as Error).message}` }; + } +} diff --git a/src/agent/registry.ts b/src/agent/registry.ts new file mode 100644 index 0000000..ac155d9 --- /dev/null +++ b/src/agent/registry.ts @@ -0,0 +1,29 @@ +import type { Config } from "../config/types.js"; +import type { Db, SessionRow } from "../db/Db.js"; +import type { AgentBackend } from "./AgentBackend.js"; +import { StubBackend } from "./StubBackend.js"; +import { OpenAiBackend } from "./openaiCompat/OpenAiBackend.js"; +import { ClaudeSdkBackend } from "./claudeSdk/ClaudeSdkBackend.js"; + +/** + * Backend selection point, keyed off `session.backend`. Each real backend owns + * its own `messages`-table persistence (see the note in apirouter): the + * openai-compat loop persists full history (user/assistant/tool rows), and the + * claude-sdk adapter persists display-only user/assistant mirror rows while the + * SDK keeps the real transcript under its `sdk_session_id`. + * + * "stub" remains constructible for tests/plumbing but is never selected by the + * router (session.backend is validated to claude-sdk | openai-compat). + */ +export function buildBackend(session: SessionRow, cfg: Config, db: Db): AgentBackend { + switch (session.backend) { + case "openai-compat": + return new OpenAiBackend(session, cfg, db); + case "claude-sdk": + return new ClaudeSdkBackend(session, cfg, db); + case "stub": + return new StubBackend(); + default: + throw new Error(`Unknown backend "${session.backend}" for session ${session.id}`); + } +} diff --git a/src/bin/braindump-migrate.ts b/src/bin/braindump-migrate.ts new file mode 100644 index 0000000..ee37857 --- /dev/null +++ b/src/bin/braindump-migrate.ts @@ -0,0 +1,21 @@ +import { Db } from "../db/Db.js"; +import { loadConfig } from "../config/config.js"; + +function resolveDbPath(): string { + const argPath = process.argv[2]; + if (argPath) return argPath; + if (process.env.BRAINDUMP_DB) return process.env.BRAINDUMP_DB; + const cfg = loadConfig(); + return cfg.dbPath; +} + +const dbPath = resolveDbPath(); +console.log(`Migrating database at ${dbPath}`); +const db = new Db(dbPath); +const { applied } = db.migrate(); +if (applied.length === 0) { + console.log("Nothing to do, database already up to date"); +} else { + console.log(`Applied ${applied.length} migration(s): ${applied.join(", ")}`); +} +db.close(); diff --git a/src/bin/braindump-web.ts b/src/bin/braindump-web.ts new file mode 100644 index 0000000..100c4a1 --- /dev/null +++ b/src/bin/braindump-web.ts @@ -0,0 +1,28 @@ +import Koa from "koa"; +import { loadConfig } from "../config/config.js"; +import { Db } from "../db/Db.js"; +import { apirouter } from "../webapi/apirouter.js"; +import { logRequests } from "../webapi/middleware/logRequests.js"; +import { cors } from "../webapi/middleware/cors.js"; +import { convertError } from "../webapi/middleware/convertError.js"; + +const cfg = loadConfig(); + +const db = new Db(cfg.dbPath); +const { applied } = db.migrate(); +if (applied.length) { + console.log(`Applied ${applied.length} pending migration(s) at boot: ${applied.join(", ")}`); +} + +const app = new Koa(); +const router = apirouter({ db, cfg }); + +app.use(logRequests); +app.use(cors(cfg.cors)); +app.use(convertError); +app.use(router.routes()); +app.use(router.allowedMethods()); + +app.listen(cfg.port, () => { + console.log(`braindump listening on http://localhost:${cfg.port}`); +}); diff --git a/src/config/config.ts b/src/config/config.ts new file mode 100644 index 0000000..3e4f0ab --- /dev/null +++ b/src/config/config.ts @@ -0,0 +1,238 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { InvalidError } from "../errors.js"; +import type { Config, VaultConfig } from "./types.js"; + +/** + * Tolerant JSONC comment stripper. Removes // line comments and /* block + * comments while leaving comment-like sequences inside string literals + * alone. Does not handle every edge case of JSON (e.g. no support for + * escaped-backslash-before-quote ambiguity beyond the standard \\ rule), + * but is good enough for hand-authored config files. + */ +export function stripJsonComments(input: string): string { + let out = ""; + let i = 0; + const n = input.length; + let inString = false; + let stringQuote = ""; + + while (i < n) { + const ch = input[i]; + const next = input[i + 1]; + + if (inString) { + out += ch; + if (ch === "\\") { + // preserve escaped char as-is + if (i + 1 < n) { + out += input[i + 1]; + i += 2; + continue; + } + } else if (ch === stringQuote) { + inString = false; + } + i++; + continue; + } + + if (ch === '"' || ch === "'") { + inString = true; + stringQuote = ch; + out += ch; + i++; + continue; + } + + if (ch === "/" && next === "/") { + // line comment: skip to end of line + i += 2; + while (i < n && input[i] !== "\n") i++; + continue; + } + + if (ch === "/" && next === "*") { + // block comment: skip to closing */ + i += 2; + while (i < n && !(input[i] === "*" && input[i + 1] === "/")) i++; + i += 2; + continue; + } + + out += ch; + i++; + } + + return out; +} + +function expandTilde(p: string): string { + if (p === "~") return os.homedir(); + if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2)); + return p; +} + +const ENV_REF_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; + +/** + * Resolve "${ENV_VAR}" placeholder strings against process.env, recursively, + * through plain objects and arrays. Non-matching strings pass through + * unchanged. Throws if the referenced env var is unset, unless `optional` + * returns true for the current JSON-pointer-ish path. + */ +function resolveEnvRefs(value: unknown, pathParts: string[], optional: (p: string) => boolean): unknown { + if (typeof value === "string") { + const m = ENV_REF_RE.exec(value); + if (!m) return value; + const varName = m[1]!; + const resolved = process.env[varName]; + if (resolved === undefined) { + const dotted = pathParts.join("."); + if (optional(dotted)) return undefined; + throw new InvalidError( + `Config field "${dotted}" references env var "${varName}" which is not set` + ); + } + return resolved; + } + if (Array.isArray(value)) { + return value.map((v, idx) => resolveEnvRefs(v, [...pathParts, String(idx)], optional)); + } + if (value && typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + out[k] = resolveEnvRefs(v, [...pathParts, k], optional); + } + return out; + } + return value; +} + +const OPTIONAL_ENV_FIELDS = new Set(["backends.openai-compat.apiKey"]); + +function isOptionalEnvField(dotted: string): boolean { + return OPTIONAL_ENV_FIELDS.has(dotted); +} + +function validateVault(v: unknown, idx: number, errors: string[]): VaultConfig | null { + if (!v || typeof v !== "object") { + errors.push(`vaults[${idx}] must be an object`); + return null; + } + const obj = v as Record; + if (typeof obj.name !== "string" || !obj.name) { + errors.push(`vaults[${idx}].name must be a non-empty string`); + } + if (typeof obj.path !== "string" || !obj.path) { + errors.push(`vaults[${idx}].path must be a non-empty string`); + } + if (errors.length) return null; + return { + name: obj.name as string, + path: path.resolve(expandTilde(obj.path as string)), + }; +} + +export function parseConfig(raw: unknown): Config { + const errors: string[] = []; + if (!raw || typeof raw !== "object") { + throw new InvalidError("Config must be a JSON object"); + } + const obj = raw as Record; + + const port = obj.port === undefined ? 3000 : obj.port; + if (typeof port !== "number") errors.push("port must be a number"); + + const dbPath = obj.dbPath; + if (typeof dbPath !== "string" || !dbPath) errors.push("dbPath must be a non-empty string"); + + const cors = obj.cors; + let corsOrigins: string[] = []; + if (cors !== undefined) { + if ( + !cors || + typeof cors !== "object" || + !Array.isArray((cors as Record).origins) + ) { + errors.push("cors.origins must be an array of strings"); + } else { + corsOrigins = (cors as { origins: unknown[] }).origins.map((o) => String(o)); + } + } + + const skillPath = obj.skillPath; + if (typeof skillPath !== "string" || !skillPath) errors.push("skillPath must be a non-empty string"); + + const defaultBackend = obj.defaultBackend; + if (defaultBackend !== "claude-sdk" && defaultBackend !== "openai-compat") { + errors.push('defaultBackend must be "claude-sdk" or "openai-compat"'); + } + + const vaultsRaw = obj.vaults; + const vaults: VaultConfig[] = []; + if (!Array.isArray(vaultsRaw) || vaultsRaw.length === 0) { + errors.push("vaults must be a non-empty array"); + } else { + vaultsRaw.forEach((v, idx) => { + const parsed = validateVault(v, idx, errors); + if (parsed) vaults.push(parsed); + }); + } + + const backendsRaw = obj.backends; + if (!backendsRaw || typeof backendsRaw !== "object") { + errors.push("backends must be an object"); + } + const backends = (backendsRaw as Config["backends"]) ?? {}; + + if (defaultBackend === "openai-compat") { + const oc = backends["openai-compat"]; + if (!oc || typeof oc.baseUrl !== "string" || typeof oc.model !== "string") { + errors.push("backends.openai-compat.{baseUrl,model} are required when defaultBackend is openai-compat"); + } + } + + if (errors.length) { + throw new InvalidError(`Invalid config:\n- ${errors.join("\n- ")}`); + } + + return { + port: port as number, + dbPath: path.resolve(expandTilde(dbPath as string)), + cors: { origins: corsOrigins }, + skillPath: path.resolve(expandTilde(skillPath as string)), + defaultBackend: defaultBackend as Config["defaultBackend"], + vaults, + backends, + }; +} + +export function loadConfig(): Config { + const configPath = path.resolve(process.env.BRAINDUMP_CONFIG ?? "./config.jsonc"); + if (!fs.existsSync(configPath)) { + throw new InvalidError(`Config file not found at ${configPath} (set BRAINDUMP_CONFIG to override)`); + } + const raw = fs.readFileSync(configPath, "utf8"); + const stripped = stripJsonComments(raw); + let json: unknown; + try { + json = JSON.parse(stripped); + } catch (e) { + throw new InvalidError(`Failed to parse ${configPath} as JSONC: ${(e as Error).message}`); + } + + const withEnv = resolveEnvRefs(json, [], isOptionalEnvField); + const cfg = parseConfig(withEnv); + + if (process.env.PORT) { + const p = Number(process.env.PORT); + if (!Number.isNaN(p)) cfg.port = p; + } + if (process.env.BRAINDUMP_DB) { + cfg.dbPath = path.resolve(expandTilde(process.env.BRAINDUMP_DB)); + } + + return cfg; +} diff --git a/src/config/types.ts b/src/config/types.ts new file mode 100644 index 0000000..77eb09b --- /dev/null +++ b/src/config/types.ts @@ -0,0 +1,34 @@ +export interface VaultConfig { + name: string; + path: string; +} + +export interface ClaudeSdkBackendConfig { + model?: string; + permissionMode?: string; +} + +export interface OpenAiCompatBackendConfig { + baseUrl: string; + apiKey?: string; + model: string; + /** Max tool-execution iterations per turn before the loop errors out. Default 32. */ + maxToolIterations?: number; +} + +export interface CorsConfig { + origins: string[]; +} + +export interface Config { + port: number; + dbPath: string; + cors: CorsConfig; + skillPath: string; + defaultBackend: "claude-sdk" | "openai-compat"; + vaults: VaultConfig[]; + backends: { + "claude-sdk"?: ClaudeSdkBackendConfig; + "openai-compat"?: OpenAiCompatBackendConfig; + }; +} diff --git a/src/db/Db.test.ts b/src/db/Db.test.ts new file mode 100644 index 0000000..1331987 --- /dev/null +++ b/src/db/Db.test.ts @@ -0,0 +1,106 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Db } from "./Db.js"; +import { NotFoundError } from "../errors.js"; + +function tmpDbPath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "braindump-db-")); + return path.join(dir, "test.sqlite"); +} + +test("migrate is idempotent and creates schema", () => { + const db = new Db(tmpDbPath()); + const first = db.migrate(); + assert.ok(first.applied.length > 0); + const second = db.migrate(); + assert.deepEqual(second.applied, []); + db.close(); +}); + +test("session create/list/get/patch/archive/hard-delete", () => { + const db = new Db(tmpDbPath()); + db.migrate(); + + const session = db.createSession({ + vault: "brain", + vaultPath: "/tmp/brain", + topic: "test topic", + backend: "stub", + model: "stub-model", + }); + assert.ok(session.id.startsWith("sess_")); + assert.equal(session.status, "active"); + assert.equal(session.turn_count, 0); + + const fetched = db.getSession(session.id); + assert.equal(fetched.id, session.id); + + const listed = db.listSessions(); + assert.equal(listed.length, 1); + + const patched = db.updateSession(session.id, { topic: "new topic", turn_count: 2 }); + assert.equal(patched.topic, "new topic"); + assert.equal(patched.turn_count, 2); + + db.deleteSession(session.id, false); + const archived = db.getSession(session.id); + assert.equal(archived.status, "archived"); + + const activeOnly = db.listSessions("active"); + assert.equal(activeOnly.length, 0); + const archivedOnly = db.listSessions("archived"); + assert.equal(archivedOnly.length, 1); + + db.deleteSession(session.id, true); + assert.throws(() => db.getSession(session.id), NotFoundError); + + db.close(); +}); + +test("messages insert and list ordering by (turn, seq)", () => { + const db = new Db(tmpDbPath()); + db.migrate(); + const session = db.createSession({ + vault: "brain", + vaultPath: "/tmp/brain", + backend: "stub", + }); + + db.createMessage({ sessionId: session.id, turn: 1, seq: 2, role: "assistant", content: "b" }); + db.createMessage({ sessionId: session.id, turn: 1, seq: 1, role: "user", content: "a" }); + db.createMessage({ sessionId: session.id, turn: 0, seq: 1, role: "user", content: "z" }); + + const messages = db.listMessages(session.id); + assert.equal(messages.length, 3); + assert.deepEqual( + messages.map((m) => [m.turn, m.seq, m.content]), + [ + [0, 1, "z"], + [1, 1, "a"], + [1, 2, "b"], + ] + ); + + db.close(); +}); + +test("turn events append and list, filtered by turn", () => { + const db = new Db(tmpDbPath()); + db.migrate(); + const session = db.createSession({ + vault: "brain", + vaultPath: "/tmp/brain", + backend: "stub", + }); + + db.appendTurnEvent({ sessionId: session.id, turn: 1, kind: "text", data: { delta: "hi" } }); + db.appendTurnEvent({ sessionId: session.id, turn: 2, kind: "text", data: { delta: "bye" } }); + + assert.equal(db.listTurnEvents(session.id).length, 2); + assert.equal(db.listTurnEvents(session.id, 1).length, 1); + + db.close(); +}); diff --git a/src/db/Db.ts b/src/db/Db.ts new file mode 100644 index 0000000..0cf6926 --- /dev/null +++ b/src/db/Db.ts @@ -0,0 +1,274 @@ +import { DatabaseSync } from "node:sqlite"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { migrations } from "./migrations.js"; +import { NotFoundError } from "../errors.js"; + +export interface SessionRow { + id: string; + vault: string; + vault_path: string; + topic: string | null; + backend: string; + model: string | null; + sdk_session_id: string | null; + status: string; + turn_count: number; + created_at: string; + updated_at: string; +} + +export interface MessageRow { + id: number; + session_id: string; + turn: number; + seq: number; + role: string; + content: string | null; + tool_calls: string | null; + tool_name: string | null; + created_at: string; +} + +export interface TurnEventRow { + id: number; + session_id: string; + turn: number; + kind: string; + data: string | null; + created_at: string; +} + +export interface CreateSessionInput { + vault: string; + vaultPath: string; + topic?: string | null; + backend: string; + model?: string | null; +} + +export interface UpdateSessionPatch { + topic?: string | null; + status?: string; + sdk_session_id?: string | null; + model?: string | null; + turn_count?: number; +} + +export interface CreateMessageInput { + sessionId: string; + turn: number; + seq: number; + role: string; + content?: string | null; + toolCalls?: unknown; + toolName?: string | null; +} + +export interface AppendTurnEventInput { + sessionId: string; + turn: number; + kind: string; + data?: unknown; +} + +function now(): string { + return new Date().toISOString(); +} + +export function runMigrations(db: DatabaseSync): { applied: string[] } { + db.exec(` + CREATE TABLE IF NOT EXISTS _migrations ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL + ); + `); + + const appliedRows = db + .prepare(`SELECT id FROM _migrations ORDER BY id ASC`) + .all() as { id: number }[]; + const appliedIds = new Set(appliedRows.map((r) => r.id)); + + const applied: string[] = []; + migrations.forEach((sql, idx) => { + const id = idx + 1; + if (appliedIds.has(id)) return; + const name = `migration_${id}`; + db.exec(sql); + db.prepare(`INSERT INTO _migrations (id, name, applied_at) VALUES (?, ?, ?)`).run( + id, + name, + now() + ); + applied.push(name); + }); + + return { applied }; +} + +export class Db { + #db: DatabaseSync; + + constructor(dbPath: string) { + if (dbPath !== ":memory:") { + const dir = path.dirname(dbPath); + fs.mkdirSync(dir, { recursive: true }); + } + this.#db = new DatabaseSync(dbPath); + this.#db.exec(`PRAGMA journal_mode = WAL;`); + this.#db.exec(`PRAGMA foreign_keys = ON;`); + } + + migrate(): { applied: string[] } { + return runMigrations(this.#db); + } + + close(): void { + this.#db.close(); + } + + // ---- sessions ---- + + createSession(input: CreateSessionInput): SessionRow { + const id = `sess_${crypto.randomUUID()}`; + const ts = now(); + this.#db + .prepare( + `INSERT INTO sessions (id, vault, vault_path, topic, backend, model, sdk_session_id, status, turn_count, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, NULL, 'active', 0, ?, ?)` + ) + .run( + id, + input.vault, + input.vaultPath, + input.topic ?? null, + input.backend, + input.model ?? null, + ts, + ts + ); + return this.getSession(id); + } + + getSession(id: string): SessionRow { + const row = this.#db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as unknown as SessionRow | undefined; + if (!row) throw new NotFoundError(`No session with id ${id}`); + return row; + } + + listSessions(status?: string): SessionRow[] { + if (status) { + return this.#db + .prepare(`SELECT * FROM sessions WHERE status = ? ORDER BY created_at DESC`) + .all(status) as unknown as SessionRow[]; + } + return this.#db.prepare(`SELECT * FROM sessions ORDER BY created_at DESC`).all() as unknown as SessionRow[]; + } + + updateSession(id: string, patch: UpdateSessionPatch): SessionRow { + // ensure it exists first (throws NotFoundError otherwise) + this.getSession(id); + + const sets: string[] = []; + const values: unknown[] = []; + if (patch.topic !== undefined) { + sets.push("topic = ?"); + values.push(patch.topic); + } + if (patch.status !== undefined) { + sets.push("status = ?"); + values.push(patch.status); + } + if (patch.sdk_session_id !== undefined) { + sets.push("sdk_session_id = ?"); + values.push(patch.sdk_session_id); + } + if (patch.model !== undefined) { + sets.push("model = ?"); + values.push(patch.model); + } + if (patch.turn_count !== undefined) { + sets.push("turn_count = ?"); + values.push(patch.turn_count); + } + sets.push("updated_at = ?"); + values.push(now()); + values.push(id); + + this.#db.prepare(`UPDATE sessions SET ${sets.join(", ")} WHERE id = ?`).run(...(values as [])); + return this.getSession(id); + } + + deleteSession(id: string, hard: boolean): void { + if (hard) { + const result = this.#db.prepare(`DELETE FROM sessions WHERE id = ?`).run(id); + if (result.changes === 0) throw new NotFoundError(`No session with id ${id}`); + } else { + this.updateSession(id, { status: "archived" }); + } + } + + // ---- messages ---- + + createMessage(input: CreateMessageInput): MessageRow { + const ts = now(); + const result = this.#db + .prepare( + `INSERT INTO messages (session_id, turn, seq, role, content, tool_calls, tool_name, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + input.sessionId, + input.turn, + input.seq, + input.role, + input.content ?? null, + input.toolCalls !== undefined ? JSON.stringify(input.toolCalls) : null, + input.toolName ?? null, + ts + ); + return this.#db + .prepare(`SELECT * FROM messages WHERE id = ?`) + .get(result.lastInsertRowid) as unknown as MessageRow; + } + + listMessages(sessionId: string): MessageRow[] { + return this.#db + .prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY turn ASC, seq ASC`) + .all(sessionId) as unknown as MessageRow[]; + } + + // ---- turn events ---- + + appendTurnEvent(input: AppendTurnEventInput): TurnEventRow { + const ts = now(); + const result = this.#db + .prepare( + `INSERT INTO turn_events (session_id, turn, kind, data, created_at) + VALUES (?, ?, ?, ?, ?)` + ) + .run( + input.sessionId, + input.turn, + input.kind, + input.data !== undefined ? JSON.stringify(input.data) : null, + ts + ); + return this.#db + .prepare(`SELECT * FROM turn_events WHERE id = ?`) + .get(result.lastInsertRowid) as unknown as TurnEventRow; + } + + listTurnEvents(sessionId: string, turn?: number): TurnEventRow[] { + if (turn !== undefined) { + return this.#db + .prepare(`SELECT * FROM turn_events WHERE session_id = ? AND turn = ? ORDER BY id ASC`) + .all(sessionId, turn) as unknown as TurnEventRow[]; + } + return this.#db + .prepare(`SELECT * FROM turn_events WHERE session_id = ? ORDER BY id ASC`) + .all(sessionId) as unknown as TurnEventRow[]; + } +} diff --git a/src/db/migrations.ts b/src/db/migrations.ts new file mode 100644 index 0000000..416ecab --- /dev/null +++ b/src/db/migrations.ts @@ -0,0 +1,48 @@ +export const migrations: string[] = [ + ` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + vault TEXT NOT NULL, + vault_path TEXT NOT NULL, + topic TEXT, + backend TEXT NOT NULL, + model TEXT, + sdk_session_id TEXT, + status TEXT NOT NULL DEFAULT 'active', + turn_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `, + ` + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + turn INTEGER NOT NULL, + seq INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT, + tool_calls TEXT, + tool_name TEXT, + created_at TEXT NOT NULL + ); + `, + ` + CREATE INDEX idx_messages_session_turn_seq + ON messages(session_id, turn, seq); + `, + ` + CREATE TABLE turn_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + turn INTEGER NOT NULL, + kind TEXT NOT NULL, + data TEXT, + created_at TEXT NOT NULL + ); + `, + ` + CREATE INDEX idx_turn_events_session_turn + ON turn_events(session_id, turn); + `, +]; diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..ff49517 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,11 @@ +export class BraindumpError extends Error {} + +export class NotFoundError extends BraindumpError {} + +export class InvalidError extends BraindumpError {} + +export class ConflictError extends BraindumpError {} + +export class VaultEscapeError extends BraindumpError {} + +export class TurnInProgressError extends BraindumpError {} diff --git a/src/vault/VaultResolver.ts b/src/vault/VaultResolver.ts new file mode 100644 index 0000000..c5880b0 --- /dev/null +++ b/src/vault/VaultResolver.ts @@ -0,0 +1,20 @@ +import type { Config, VaultConfig } from "../config/types.js"; +import { NotFoundError } from "../errors.js"; + +export class VaultResolver { + #vaults: Map; + + constructor(cfg: Config) { + this.#vaults = new Map(cfg.vaults.map((v) => [v.name, v])); + } + + list(): VaultConfig[] { + return [...this.#vaults.values()]; + } + + resolve(name: string): VaultConfig { + const vault = this.#vaults.get(name); + if (!vault) throw new NotFoundError(`Unknown vault "${name}"`); + return vault; + } +} diff --git a/src/vault/git.ts b/src/vault/git.ts new file mode 100644 index 0000000..7cac4a7 --- /dev/null +++ b/src/vault/git.ts @@ -0,0 +1,46 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { InvalidError } from "../errors.js"; + +const execFileAsync = promisify(execFile); + +export interface GitStatusEntry { + path: string; + status: string; +} + +function isNotAGitRepoError(stderr: string): boolean { + return /not a git repository/i.test(stderr); +} + +async function runGit(vaultRoot: string, args: string[]): Promise { + try { + const { stdout } = await execFileAsync("git", args, { cwd: vaultRoot }); + return stdout; + } catch (e) { + const err = e as { stderr?: string; message?: string }; + const stderr = err.stderr ?? err.message ?? ""; + if (isNotAGitRepoError(stderr)) { + throw new InvalidError(`Vault at ${vaultRoot} is not a git repository`); + } + throw new InvalidError(`git ${args.join(" ")} failed: ${stderr}`); + } +} + +export async function status(vaultRoot: string): Promise { + const stdout = await runGit(vaultRoot, ["status", "--porcelain=v1"]); + return stdout + .split("\n") + .filter((line) => line.length > 0) + .map((line) => ({ + status: line.slice(0, 2).trim(), + path: line.slice(3), + })); +} + +export async function diff(vaultRoot: string, filePath?: string): Promise<{ diff: string }> { + const args = ["diff"]; + if (filePath) args.push("--", filePath); + const stdout = await runGit(vaultRoot, args); + return { diff: stdout }; +} diff --git a/src/vault/pathSafety.test.ts b/src/vault/pathSafety.test.ts new file mode 100644 index 0000000..980c9b9 --- /dev/null +++ b/src/vault/pathSafety.test.ts @@ -0,0 +1,92 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { assertInsideVault } from "./pathSafety.js"; +import { VaultEscapeError } from "../errors.js"; + +function makeTmpVault(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "braindump-vault-")); + return dir; +} + +test("normal relative path resolves inside vault", () => { + const vault = makeTmpVault(); + fs.writeFileSync(path.join(vault, "note.md"), "hello"); + const resolved = assertInsideVault(vault, "note.md"); + assert.equal(resolved, path.join(vault, "note.md")); +}); + +test("nested new path resolves even if parent doesn't exist yet", () => { + const vault = makeTmpVault(); + const resolved = assertInsideVault(vault, "projects/foo/bar.md"); + assert.equal(resolved, path.join(vault, "projects", "foo", "bar.md")); +}); + +test("../escape is rejected", () => { + const vault = makeTmpVault(); + assert.throws(() => assertInsideVault(vault, "../escape"), VaultEscapeError); +}); + +test("absolute path is rejected", () => { + const vault = makeTmpVault(); + assert.throws(() => assertInsideVault(vault, "/etc/passwd"), VaultEscapeError); +}); + +test("embedded .. that escapes is rejected", () => { + const vault = makeTmpVault(); + assert.throws( + () => assertInsideVault(vault, "projects/../../escape"), + VaultEscapeError + ); +}); + +test("embedded .. that stays inside is allowed", () => { + const vault = makeTmpVault(); + fs.mkdirSync(path.join(vault, "projects", "foo"), { recursive: true }); + const resolved = assertInsideVault(vault, "projects/foo/../bar.md"); + assert.equal(resolved, path.join(vault, "projects", "bar.md")); +}); + +test("symlink inside vault pointing outside vault is rejected", () => { + const vault = makeTmpVault(); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "braindump-outside-")); + fs.writeFileSync(path.join(outside, "secret.txt"), "nope"); + fs.symlinkSync(outside, path.join(vault, "escape-link"), "dir"); + assert.throws( + () => assertInsideVault(vault, "escape-link/secret.txt"), + VaultEscapeError + ); +}); + +test("writing a NEW file through a symlink that points outside is rejected", () => { + const vault = makeTmpVault(); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "braindump-outside-")); + fs.symlinkSync(outside, path.join(vault, "escape-link"), "dir"); + // outside dir exists but the target file does not — this is the write path. + assert.throws( + () => assertInsideVault(vault, "escape-link/newfile.md"), + VaultEscapeError + ); +}); + +test("dangling symlink pointing outside vault is rejected (escape via mkdir -p)", () => { + const vault = makeTmpVault(); + const outsideBase = fs.mkdtempSync(path.join(os.tmpdir(), "braindump-outside-")); + // Symlink target does NOT exist yet — existsSync would treat the link as + // absent and wrongly allow it; lstat-based walk must still reject. + fs.symlinkSync(path.join(outsideBase, "nonexistent"), path.join(vault, "dangling"), "dir"); + assert.throws( + () => assertInsideVault(vault, "dangling/pwned.md"), + VaultEscapeError + ); +}); + +test("symlink to a directory INSIDE the vault is allowed", () => { + const vault = makeTmpVault(); + fs.mkdirSync(path.join(vault, "real"), { recursive: true }); + fs.symlinkSync(path.join(vault, "real"), path.join(vault, "alias"), "dir"); + const resolved = assertInsideVault(vault, "alias/note.md"); + assert.equal(resolved, path.join(vault, "alias", "note.md")); +}); diff --git a/src/vault/pathSafety.ts b/src/vault/pathSafety.ts new file mode 100644 index 0000000..0e245ac --- /dev/null +++ b/src/vault/pathSafety.ts @@ -0,0 +1,81 @@ +import fs from "node:fs"; +import path from "node:path"; +import { VaultEscapeError } from "../errors.js"; + +/** + * Validate that `relPath` resolves to a location inside `vaultRoot`, and + * return the resolved absolute path. Throws VaultEscapeError otherwise. + * + * Defends against: + * - absolute paths passed as "relative" + * - `..` segments that walk out of the vault (even if they net back inside, + * we reject on principle — see below for the "stays inside" test case, + * which is allowed because the normalized path never leaves the root) + * - symlinks inside the vault that point outside of it + */ +export function assertInsideVault(vaultRoot: string, relPath: string): string { + if (path.isAbsolute(relPath)) { + throw new VaultEscapeError(`Path must be relative: ${relPath}`); + } + + const normalized = path.normalize(relPath); + if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) { + throw new VaultEscapeError(`Path escapes vault: ${relPath}`); + } + + const resolvedRoot = path.resolve(vaultRoot); + const candidate = path.resolve(resolvedRoot, normalized); + + // Belt-and-suspenders: candidate must still be within the root textually. + if (candidate !== resolvedRoot && !candidate.startsWith(resolvedRoot + path.sep)) { + throw new VaultEscapeError(`Path escapes vault: ${relPath}`); + } + + // Symlink defense: realpath the deepest *existing* ancestor of the + // candidate (the candidate itself may not exist yet, e.g. a new file + // about to be written) and verify it lives inside the realpath'd vault + // root. This catches a symlink anywhere along the existing path prefix + // that would otherwise redirect us outside the vault. + let realRoot: string; + try { + realRoot = fs.realpathSync(resolvedRoot); + } catch { + // Vault root doesn't exist on disk (shouldn't normally happen) — fall + // back to the textual root. + realRoot = resolvedRoot; + } + + // Walk up to the deepest ancestor that exists *as a filesystem entry of any + // kind*. Use lstatSync, not existsSync: existsSync follows symlinks, so a + // dangling symlink (e.g. vault/link -> /outside/does-not-exist) would be + // treated as "not existing" and skipped, letting a later mkdir -p + write + // follow the link and escape the vault. lstatSync treats the symlink itself + // as an existing entry so we stop on it and resolve its real target below. + let ancestor = candidate; + while (true) { + try { + fs.lstatSync(ancestor); + break; + } catch { + const parent = path.dirname(ancestor); + if (parent === ancestor) break; // reached filesystem root without finding anything + ancestor = parent; + } + } + + // Fully resolve the ancestor (following any symlinks). If it's a dangling + // symlink, realpathSync throws — we reject, because we cannot prove the link + // stays inside the vault and following it on write could escape. + let realAncestor: string; + try { + realAncestor = fs.realpathSync(ancestor); + } catch { + throw new VaultEscapeError(`Path resolves through an unresolvable/broken symlink: ${relPath}`); + } + + if (realAncestor !== realRoot && !realAncestor.startsWith(realRoot + path.sep)) { + throw new VaultEscapeError(`Path escapes vault via symlink: ${relPath}`); + } + + return candidate; +} diff --git a/src/webapi/apirouter.ts b/src/webapi/apirouter.ts new file mode 100644 index 0000000..57c6730 --- /dev/null +++ b/src/webapi/apirouter.ts @@ -0,0 +1,207 @@ +import KoaRouter from "@koa/router"; +import fs from "node:fs"; +import type { Config } from "../config/types.js"; +import type { Db, SessionRow } from "../db/Db.js"; +import { VaultResolver } from "../vault/VaultResolver.js"; +import { assertInsideVault } from "../vault/pathSafety.js"; +import * as vaultGit from "../vault/git.js"; +import { jsonBody } from "./middleware/jsonBody.js"; +import { startSse } from "./sse.js"; +import { buildBackend } from "../agent/registry.js"; +import { ConflictError, InvalidError, NotFoundError, TurnInProgressError } from "../errors.js"; + +export interface ApiDeps { + db: Db; + cfg: Config; +} + +function sessionToJson(session: SessionRow) { + return session; +} + +export function apirouter(deps: ApiDeps): KoaRouter { + const { db, cfg } = deps; + const vaults = new VaultResolver(cfg); + const router = new KoaRouter({ prefix: "/api" }); + + // In-memory per-session turn lock. One entry per session id currently + // mid-turn; presence alone is the lock (value unused). + const turnLocks = new Set(); + + router.get("/health", async (ctx) => { + ctx.body = { ok: true }; + }); + + router.get("/vaults", async (ctx) => { + ctx.body = { ok: true, vaults: vaults.list() }; + }); + + router.post("/sessions", jsonBody(), async (ctx) => { + const body = ctx.request.body as Record; + if (typeof body.vault !== "string" || !body.vault) { + throw new InvalidError("vault is required and must be a string"); + } + const backend = typeof body.backend === "string" ? body.backend : cfg.defaultBackend; + if (backend !== "claude-sdk" && backend !== "openai-compat") { + throw new InvalidError('backend must be "claude-sdk" or "openai-compat"'); + } + if (body.topic !== undefined && typeof body.topic !== "string") { + throw new InvalidError("topic must be a string"); + } + if (body.model !== undefined && typeof body.model !== "string") { + throw new InvalidError("model must be a string"); + } + + const vaultCfg = vaults.resolve(body.vault); + const session = db.createSession({ + vault: vaultCfg.name, + vaultPath: vaultCfg.path, + topic: (body.topic as string | undefined) ?? null, + backend, + model: (body.model as string | undefined) ?? null, + }); + ctx.status = 201; + ctx.body = { ok: true, session: sessionToJson(session) }; + }); + + router.get("/sessions", async (ctx) => { + const status = typeof ctx.query.status === "string" ? ctx.query.status : undefined; + const sessions = db.listSessions(status); + ctx.body = { ok: true, sessions: sessions.map(sessionToJson) }; + }); + + router.get("/sessions/:id", async (ctx) => { + const session = db.getSession(ctx.params.id!); + const messages = db.listMessages(session.id); + ctx.body = { ok: true, session: sessionToJson(session), messages }; + }); + + router.patch("/sessions/:id", jsonBody(), async (ctx) => { + const body = ctx.request.body as Record; + const patch: { topic?: string | null; status?: string } = {}; + if (body.topic !== undefined) { + if (body.topic !== null && typeof body.topic !== "string") { + throw new InvalidError("topic must be a string or null"); + } + patch.topic = body.topic as string | null; + } + if (body.status !== undefined) { + if (typeof body.status !== "string" || !["active", "archived"].includes(body.status)) { + throw new InvalidError('status must be "active" or "archived"'); + } + patch.status = body.status; + } + if (Object.keys(patch).length === 0) { + throw new InvalidError("no updatable fields provided (topic, status)"); + } + const session = db.updateSession(ctx.params.id!, patch); + ctx.body = { ok: true, session: sessionToJson(session) }; + }); + + router.delete("/sessions/:id", async (ctx) => { + const hard = ctx.query.hard === "true"; + db.getSession(ctx.params.id!); // 404 if missing + db.deleteSession(ctx.params.id!, hard); + ctx.status = 204; + }); + + router.post("/sessions/:id/messages", jsonBody(), async (ctx) => { + const id = ctx.params.id!; + const session = db.getSession(id); + if (session.status !== "active") { + throw new ConflictError(`Session ${id} is not active (status: ${session.status})`); + } + + const body = ctx.request.body as Record; + if (typeof body.text !== "string" || !body.text) { + throw new InvalidError("text is required and must be a non-empty string"); + } + const userText = body.text; + + if (turnLocks.has(id)) { + throw new TurnInProgressError(`Session ${id} already has a turn in progress`); + } + turnLocks.add(id); + + const turn = session.turn_count + 1; + const abortController = new AbortController(); + const onClose = () => abortController.abort(); + ctx.req.on("close", onClose); + + const sse = startSse(ctx); + + try { + // NOTE: message-row persistence is owned by the backend (see registry.ts), + // not the router. The openai-compat backend writes the full user/assistant/ + // tool history it needs to replay; the claude-sdk backend writes display-only + // user/assistant mirror rows. The router owns only turn_events (debug/replay + // log), turn_count, and the sdk_session_id resume token. + const backend = buildBackend(session, cfg, db); + let sdkSessionId: string | undefined; + + for await (const event of backend.runTurn({ userText, signal: abortController.signal })) { + db.appendTurnEvent({ sessionId: id, turn, kind: event.type, data: event }); + sse.send(event.type, event); + + if (event.type === "turn_complete") { + sdkSessionId = event.sdkSessionId; + } + } + + if (!abortController.signal.aborted) { + db.updateSession(id, { + turn_count: turn, + ...(sdkSessionId !== undefined ? { sdk_session_id: sdkSessionId } : {}), + }); + } + } catch (err) { + // Headers are already flushed (SSE), so we cannot let this propagate to + // Koa's error middleware — it would try to send a second response. Surface + // it as a terminal error event on the stream instead, and log it. + console.error(`[turn] session ${id} turn ${turn} failed:`, err); + if (!abortController.signal.aborted) { + try { + const message = err instanceof Error ? err.message : String(err); + db.appendTurnEvent({ sessionId: id, turn, kind: "error", data: { type: "error", message } }); + sse.send("error", { type: "error", message }); + } catch { + /* stream may be gone; nothing more we can do */ + } + } + } finally { + ctx.req.off("close", onClose); + turnLocks.delete(id); + sse.end(); + } + }); + + router.get("/sessions/:id/file", async (ctx) => { + const session = db.getSession(ctx.params.id!); + const relPath = ctx.query.path; + if (typeof relPath !== "string" || !relPath) { + throw new InvalidError("path query param is required"); + } + const absPath = assertInsideVault(session.vault_path, relPath); + if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) { + throw new NotFoundError(`No file at ${relPath}`); + } + const content = fs.readFileSync(absPath, "utf8"); + ctx.body = { ok: true, path: relPath, content }; + }); + + router.get("/sessions/:id/git/status", async (ctx) => { + const session = db.getSession(ctx.params.id!); + const entries = await vaultGit.status(session.vault_path); + ctx.body = { ok: true, status: entries }; + }); + + router.get("/sessions/:id/git/diff", async (ctx) => { + const session = db.getSession(ctx.params.id!); + const filePath = typeof ctx.query.path === "string" ? ctx.query.path : undefined; + if (filePath) assertInsideVault(session.vault_path, filePath); + const result = await vaultGit.diff(session.vault_path, filePath); + ctx.body = { ok: true, ...result }; + }); + + return router; +} diff --git a/src/webapi/middleware/convertError.ts b/src/webapi/middleware/convertError.ts new file mode 100644 index 0000000..b592b3c --- /dev/null +++ b/src/webapi/middleware/convertError.ts @@ -0,0 +1,35 @@ +import type { Middleware } from "@koa/router"; +import { + ConflictError, + InvalidError, + NotFoundError, + TurnInProgressError, + VaultEscapeError, +} from "../../errors.js"; + +export const convertError: Middleware = async (ctx, next) => { + try { + await next(); + } catch (e) { + if (e instanceof NotFoundError) { + ctx.status = 404; + ctx.body = { ok: false, error: "not_found", message: e.message }; + } else if (e instanceof TurnInProgressError) { + ctx.status = 409; + ctx.body = { ok: false, error: "turn_in_progress", message: e.message }; + } else if (e instanceof ConflictError) { + ctx.status = 409; + ctx.body = { ok: false, error: "conflict", message: e.message }; + } else if (e instanceof VaultEscapeError) { + ctx.status = 400; + ctx.body = { ok: false, error: "vault_escape", message: e.message }; + } else if (e instanceof InvalidError) { + ctx.status = 400; + ctx.body = { ok: false, error: "invalid", message: e.message }; + } else { + ctx.status = 500; + ctx.body = { ok: false, error: "unknown" }; + console.error(ctx.method, ctx.path, e); + } + } +}; diff --git a/src/webapi/middleware/cors.ts b/src/webapi/middleware/cors.ts new file mode 100644 index 0000000..f157f60 --- /dev/null +++ b/src/webapi/middleware/cors.ts @@ -0,0 +1,19 @@ +import type { Middleware } from "@koa/router"; +import type { CorsConfig } from "../../config/types.js"; + +export function cors(cfg: CorsConfig): Middleware { + return async (ctx, next) => { + const origin = ctx.get("Origin"); + if (origin && cfg.origins.includes(origin)) { + ctx.set("Access-Control-Allow-Origin", origin); + ctx.set("Vary", "Origin"); + ctx.set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS"); + ctx.set("Access-Control-Allow-Headers", "Content-Type"); + } + if (ctx.method === "OPTIONS") { + ctx.status = 204; + return; + } + await next(); + }; +} diff --git a/src/webapi/middleware/jsonBody.ts b/src/webapi/middleware/jsonBody.ts new file mode 100644 index 0000000..e78b73d --- /dev/null +++ b/src/webapi/middleware/jsonBody.ts @@ -0,0 +1,20 @@ +import { bodyParser } from "@koa/bodyparser"; +import type { Middleware } from "@koa/router"; + +/** + * Simplified from abode's jsonBody: parses a JSON body and 400s if absent. + * No ajv schema validation here — handlers validate shapes by hand. + */ +export function jsonBody(): Middleware { + const parseBody = bodyParser({ enableTypes: ["json"], encoding: "utf8" }); + return async (ctx, next) => { + await parseBody(ctx, async () => { + if (typeof ctx.request.body === "undefined") { + ctx.status = 400; + ctx.body = { ok: false, error: "missing_body" }; + return; + } + await next(); + }); + }; +} diff --git a/src/webapi/middleware/logRequests.ts b/src/webapi/middleware/logRequests.ts new file mode 100644 index 0000000..ec90dc4 --- /dev/null +++ b/src/webapi/middleware/logRequests.ts @@ -0,0 +1,6 @@ +import type { Middleware } from "@koa/router"; + +export const logRequests: Middleware = async (ctx, next) => { + await next(); + console.log(`${ctx.status} - ${ctx.method.toUpperCase()} ${ctx.path}`); +}; diff --git a/src/webapi/sse.ts b/src/webapi/sse.ts new file mode 100644 index 0000000..ffb5fb4 --- /dev/null +++ b/src/webapi/sse.ts @@ -0,0 +1,53 @@ +import type { Context } from "koa"; + +export interface SseWriter { + send(event: string, data: unknown): void; + comment(text: string): void; + end(): void; +} + +const HEARTBEAT_INTERVAL_MS = 15_000; + +/** + * Sets SSE response headers on `ctx` and returns a writer for frames plus a + * heartbeat comment sent every 15s (cleared automatically on end()). + * Caller is responsible for keeping the response open (koa won't finalize + * the body until `ctx.res.end()` is called, which `end()` does here). + */ +export function startSse(ctx: Context): SseWriter { + ctx.status = 200; + ctx.set("Content-Type", "text/event-stream"); + ctx.set("Cache-Control", "no-cache"); + ctx.set("Connection", "keep-alive"); + ctx.set("X-Accel-Buffering", "no"); + ctx.res.flushHeaders?.(); + + // Tell koa we're handling the response body ourselves. + ctx.respond = false; + + let ended = false; + + const heartbeat = setInterval(() => { + if (ended) return; + ctx.res.write(`: heartbeat\n\n`); + }, HEARTBEAT_INTERVAL_MS); + heartbeat.unref?.(); + + return { + send(event, data) { + if (ended) return; + ctx.res.write(`event: ${event}\n`); + ctx.res.write(`data: ${JSON.stringify(data)}\n\n`); + }, + comment(text) { + if (ended) return; + ctx.res.write(`: ${text}\n\n`); + }, + end() { + if (ended) return; + ended = true; + clearInterval(heartbeat); + ctx.res.end(); + }, + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6e734c3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "rootDir": "src", + "strict": true, + "verbatimModuleSyntax": true, + "moduleResolution": "nodenext", + "module": "nodenext", + "target": "esnext", + "allowImportingTsExtensions": false, + "noEmit": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +}